diff --git a/scripts/sentinelDownload/README.md b/scripts/sentinelDownload/README.md index e6033f97b..12c758a3a 100644 --- a/scripts/sentinelDownload/README.md +++ b/scripts/sentinelDownload/README.md @@ -27,13 +27,41 @@ The script accepts command-line options via `click`: - `--start-date` _(str, default `2025-01-01`)_ - Start date in `YYYY-MM-DD` format. - `--end-date` _(str, default = today)_ - End date in `YYYY-MM-DD` format. - `--max-results` _(int, default `5`)_ - Maximum number of images to download. -- `--output-dir` _(path, default `sequentialTestRasters`)_ - Directory to save clipped files and JSON file. +- `--output-name` _(str, default `sequentialTestRasters`)_ - Base name for output. Rasters are saved under `downloads//`; ingest JSON is written as `.json` next to the script. - `--cloud-cover` _(float, default `30.0`)_ - Maximum allowed cloud cover percentage of files found. - `--size-km` _(float, default `10.0`)_ - Size of square window (in kilometers) to clip around the point. +- `--single-file` _(flag, default off)_ - Combine all downloaded frames into one multiframe GeoTIFF instead of writing separate files per date. The generated ingest JSON uses `frame_property: "frame"` for multiframe ingest. + +### `--single-file` GDAL requirement + +The `--single-file` option shells out to `gdal_translate` to append each clipped frame as a subdataset in one multiframe GeoTIFF. GDAL must be installed and `gdal_translate` must be on your `PATH`. + +If GDAL is not available, run without `--single-file` (the default writes one GeoTIFF per frame). --- ## Outputs -- **GeoTIFF files** - Clipped Sentinel-2 visual images (RGB). -- **`sample.json`** - JSON metadata describing datasets, layers, and frames, useful for ingestion into GeoDatalytics. +Files are written relative to this script directory: + +``` +scripts/sentinelDownload/ + sentinel2Download.py + .json # ingest manifest (sibling to the script) + downloads/ + / + *.tif # clipped GeoTIFF(s) +``` + +- **GeoTIFF files** - Clipped Sentinel-2 visual images (RGB). With `--single-file`, one multiframe GeoTIFF is written instead of separate per-date files. +- **`.json`** - Ingest manifest describing the project, dataset, layers, and frames. + +## Ingesting into GeoDatalytics + +Then ingest the sequential data from the project root: + +```bash +./manage.py ingest .json --replace +``` + +Use `--replace` if you have previously ingested the same project or dataset and need to refresh it. diff --git a/scripts/sentinelDownload/sentinel2Download.py b/scripts/sentinelDownload/sentinel2Download.py index c0d1ca120..dc90d6666 100644 --- a/scripts/sentinelDownload/sentinel2Download.py +++ b/scripts/sentinelDownload/sentinel2Download.py @@ -14,6 +14,9 @@ from datetime import UTC, datetime import json from pathlib import Path +import shutil +import subprocess +import tempfile import click import numpy as np @@ -26,6 +29,8 @@ # STAC API from AWS Earth Search STAC_API_URL = "https://earth-search.aws.element84.com/v1" +SCRIPT_DIR = Path(__file__).resolve().parent +DOWNLOADS_DIR = SCRIPT_DIR / "downloads" def default_end_date(): @@ -101,6 +106,46 @@ def read_cog_window_rgb(cog_url, lon, lat, size_km=10): return data, meta +def _run_checked_command(cmd: list[str]) -> None: + subprocess.run(cmd, check=True) # noqa: S603 + + +def combine_frames_to_multiframe(frame_paths, output_path): + """ + Combine single-frame GeoTIFFs into one multiframe GeoTIFF. + + Each appended page becomes a scrubbable frame when imported with + frame_property: "frame". + """ + if not frame_paths: + return + + gdal_translate = shutil.which("gdal_translate") + if gdal_translate is None: + msg = ( + "gdal_translate is required for --single-file but was not found on PATH. " + "Install GDAL or run without --single-file." + ) + raise RuntimeError(msg) + + output_path = Path(output_path) + creation_options = ["-co", "COMPRESS=LZW"] + _run_checked_command( + [gdal_translate, *creation_options, str(frame_paths[0]), str(output_path)], + ) + for frame_path in frame_paths[1:]: + _run_checked_command( + [ + gdal_translate, + *creation_options, + "-co", + "APPEND_SUBDATASET=YES", + str(frame_path), + str(output_path), + ], + ) + + @click.command() @click.option( "--lat", default=43.135763, type=float, required=True, help="Latitude of the location." @@ -130,11 +175,14 @@ def read_cog_window_rgb(cog_url, lon, lat, size_km=10): help="Maximum number of images to download.", ) @click.option( - "--output-dir", - type=click.Path(), + "--output-name", + type=str, default="sequentialTestRasters", show_default=True, - help="Directory to save the downloaded files.", + help=( + "Base name for output: writes rasters to downloads// and " + "a sibling ingest JSON named .json next to this script." + ), ) @click.option( "--cloud-cover", type=float, default=30.0, show_default=True, help="Max cloud cover percentage." @@ -146,11 +194,21 @@ def read_cog_window_rgb(cog_url, lon, lat, size_km=10): show_default=True, help="Size of square window to clip around the point in kilometers.", ) -def download_stac_sentinel( # noqa: PLR0913, PLR0915 - lat, lon, start_date, end_date, max_results, output_dir, cloud_cover, size_km +@click.option( + "--single-file", + is_flag=True, + default=False, + help=( + "Write all frames into one multiframe GeoTIFF instead of separate files. " + 'The generated output JSON will use frame_property: "frame".' + ), +) +def download_stac_sentinel( # noqa: C901, PLR0912, PLR0913, PLR0915 + lat, lon, start_date, end_date, max_results, output_name, cloud_cover, size_km, single_file ): """Download clipped Sentinel-2 L1C visual images from AWS via STAC API.""" - Path(output_dir).mkdir(parents=True, exist_ok=True) + output_dir = DOWNLOADS_DIR / output_name + output_dir.mkdir(parents=True, exist_ok=True) catalog = Client.open(STAC_API_URL) @@ -166,7 +224,7 @@ def download_stac_sentinel( # noqa: PLR0913, PLR0915 limit=max_results, ) - items = list(search.get_items()) + items = list(search.items()) if not items: click.echo("⚠️ No Sentinel-2 images found.") @@ -187,40 +245,59 @@ def download_stac_sentinel( # noqa: PLR0913, PLR0915 ) downloaded_files = [] - - for i, item in enumerate(items): - if i >= max_results: - break - date_str = item.datetime.strftime("%Y-%m-%d") - item_id = item.id - click.echo(f"[{i + 1}/{len(items)}] {item_id} from {date_str}") - - visual_asset = item.assets.get("visual") - if visual_asset: - url = visual_asset.href - filename = f"{item_id}_visual_clip_{int(size_km)}km.tif" - filepath = Path(output_dir) / filename - - click.echo(f" - Reading {size_km}km x {size_km}km window around point") - try: - data, meta = read_cog_window_rgb(url, lon, lat, size_km=size_km) - with rasterio.open(filepath, "w", **meta) as dst: - dst.write(data) - except (RasterioError, RasterioIOError) as e: - click.echo(f" - ⚠️ Failed to read or save clipped image: {e}") + downloaded_frame_paths = [] + multiframe_filename = None + + with tempfile.TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) + + for i, item in enumerate(items): + if i >= max_results: + break + date_str = item.datetime.strftime("%Y-%m-%d") + item_id = item.id + click.echo(f"[{i + 1}/{len(items)}] {item_id} from {date_str}") + + visual_asset = item.assets.get("visual") + if visual_asset: + url = visual_asset.href + filename = f"{item_id}_visual_clip_{int(size_km)}km.tif" + filepath = output_dir / filename + write_path = temp_path / filename if single_file else filepath + + click.echo(f" - Reading {size_km}km x {size_km}km window around point") + try: + data, meta = read_cog_window_rgb(url, lon, lat, size_km=size_km) + with rasterio.open(write_path, "w", **meta) as dst: + dst.write(data) + except (RasterioError, RasterioIOError) as e: + click.echo(f" - ⚠️ Failed to read or save clipped image: {e}") + else: + if single_file: + click.echo(f" - Buffered frame {len(downloaded_frame_paths)}") + downloaded_frame_paths.append(write_path) + else: + click.echo(f" - Saved clipped image to {filename}") + downloaded_files.append(filename) else: - click.echo(f" - Saved clipped image to {filename}") - downloaded_files.append(filename) - else: - click.echo(f" - ⚠️ Visual asset not available in item {item_id}") + click.echo(f" - ⚠️ Visual asset not available in item {item_id}") + + if single_file and downloaded_frame_paths: + multiframe_filename = f"sentinel_visual_clip_{int(size_km)}km_multiframe.tif" + multiframe_path = output_dir / multiframe_filename + click.echo( + f"Combining {len(downloaded_frame_paths)} frames into {multiframe_filename}..." + ) + combine_frames_to_multiframe(downloaded_frame_paths, multiframe_path) + click.echo(f" - Saved multiframe image to {multiframe_filename}") click.echo("✅ Download complete.") - # Generate dataset.json dataset_json = { "type": "Dataset", "name": "Sequential Test Rasters", "description": "Clipped Sentinel-2 images downloaded and clipped around point", "category": "imagery", + "tags": ["sentinel-2", "imagery", "sequential"], "files": [], "layers": [], } @@ -229,22 +306,40 @@ def download_stac_sentinel( # noqa: PLR0913, PLR0915 "type": "Project", "name": "Sentinel-2 Clipped Images", "datasets": ["Sequential Test Rasters"], - "default_map_center": [lat, lon], + "default_map_center": [lon, lat], "default_map_zoom": 11, } - # Add each file as its own layer - layer_frames = [] - for idx, f in enumerate(downloaded_files): - dataset_json["files"].append({"path": f"{output_dir}/{f}", "name": f"Frame {idx}"}) - layer_frames.append({"name": f"Sequential Layer {idx}", "index": idx, "data": f}) - - layer = {"name": "Sequential Test Layers", "frames": layer_frames} - dataset_json["layers"].append(layer) - - json_path = Path(output_dir, "sample.json") + if single_file and multiframe_filename: + dataset_json["files"].append( + {"path": f"{output_name}/{multiframe_filename}", "name": multiframe_filename} + ) + dataset_json["layers"].append( + { + "name": "Sequential Test Layers", + "frame_property": "frame", + "data": multiframe_filename, + } + ) + else: + layer_frames = [] + for idx, f in enumerate(downloaded_files): + dataset_json["files"].append({"path": f"{output_name}/{f}", "name": f"Frame {idx}"}) + layer_frames.append( + { + "name": f"Sequential Layer {idx}", + "index": idx, + "data": f, + } + ) + + dataset_json["layers"].append({"name": "Sequential Test Layers", "frames": layer_frames}) + + json_path = SCRIPT_DIR / f"{output_name}.json" with json_path.open("w") as jf: json.dump([project_json, dataset_json], jf, indent=4) + click.echo(f" - Wrote ingest JSON to {json_path}") + click.echo(f" - Rasters saved under {output_dir}") if __name__ == "__main__": diff --git a/uvdat/core/admin.py b/uvdat/core/admin.py index 02b1e859d..7a049c0d4 100644 --- a/uvdat/core/admin.py +++ b/uvdat/core/admin.py @@ -19,6 +19,7 @@ NetworkNode, Project, RasterData, + RasterFramePreview, Region, SizeConfig, SizeRangeConfig, @@ -69,6 +70,12 @@ class LayerStyleAdmin(admin.ModelAdmin): list_display = ["id", "name", "layer"] +@admin.register(RasterFramePreview) +class RasterFramePreviewAdmin(admin.ModelAdmin): + list_display = ["id", "layer_style", "layer_frame", "width", "height"] + list_select_related = ["layer_style", "layer_frame"] + + @admin.register(Colormap) class ColormapAdmin(admin.ModelAdmin): list_display = ["id", "name"] diff --git a/uvdat/core/frame_previews/__init__.py b/uvdat/core/frame_previews/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/uvdat/core/frame_previews/fingerprint.py b/uvdat/core/frame_previews/fingerprint.py new file mode 100644 index 000000000..07cb9e49a --- /dev/null +++ b/uvdat/core/frame_previews/fingerprint.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +import hashlib +import json +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from uvdat.core.models import LayerStyle + + +def style_fingerprint(layer_style: LayerStyle) -> str: + payload = json.dumps(layer_style.repr_style_configs(), sort_keys=True, default=str) + return hashlib.sha256(payload.encode()).hexdigest() diff --git a/uvdat/core/frame_previews/preview_regeneration.py b/uvdat/core/frame_previews/preview_regeneration.py new file mode 100644 index 000000000..39896a3c4 --- /dev/null +++ b/uvdat/core/frame_previews/preview_regeneration.py @@ -0,0 +1,109 @@ +from __future__ import annotations + +from django.utils import timezone + +from uvdat.core.frame_previews.fingerprint import style_fingerprint +from uvdat.core.models import LayerStyle, RasterFramePreview, TaskResult +from uvdat.core.models.frame_preview import PreviewStatus + + +def style_needs_previews(layer_style: LayerStyle) -> bool: + return layer_style.layer.is_multiframe_raster() + + +def supersede_pending_preview_tasks(layer_style_id: int) -> None: + TaskResult.objects.filter( + task_type="frame_preview", + completed__isnull=True, + inputs__layer_style_id=layer_style_id, + ).update( + completed=timezone.now(), + status="Superseded by newer style save.", + ) + + +def mark_previews_regenerating(layer_style: LayerStyle, fingerprint: str) -> list[int]: + """Upsert one preview row per multiframe frame and clear stale images.""" + frame_ids = [] + for frame in layer_style.layer.multiframe_raster_frames(): + preview, created = RasterFramePreview.objects.get_or_create( + layer_style=layer_style, + layer_frame=frame, + defaults={ + "status": PreviewStatus.CREATING, + "style_fingerprint": fingerprint, + }, + ) + preview.style_fingerprint = fingerprint + preview.status = PreviewStatus.CREATING if created else PreviewStatus.REGENERATING + + if preview.image: + preview.image.delete(save=False) + preview.image = None + preview.width = None + preview.height = None + preview.bounds = {} + preview.save() + frame_ids.append(frame.id) + return frame_ids + + +def invalidate_and_enqueue_previews(layer_style: LayerStyle) -> TaskResult | None: + if not style_needs_previews(layer_style): + return None + + fingerprint = style_fingerprint(layer_style) + mark_previews_regenerating(layer_style, fingerprint) + supersede_pending_preview_tasks(layer_style.id) + + result = TaskResult.objects.create( + name=f"Frame previews: {layer_style.name}", + task_type="frame_preview", + project=layer_style.project, + inputs={ + "layer_style_id": layer_style.id, + "layer_id": layer_style.layer_id, + "fingerprint": fingerprint, + }, + ) + + from uvdat.core.tasks.frame_preview import generate_layer_style_previews # noqa: PLC0415 + + generate_layer_style_previews.delay(layer_style.id, fingerprint, result.id) + return result + + +def preview_status_for_style(layer_style: LayerStyle) -> str | None: + """Aggregate per-frame preview rows into a style-level status string.""" + if not style_needs_previews(layer_style): + return None + + frames = layer_style.layer.multiframe_raster_frames() + if not frames: + return None + + previews_by_frame = { + preview.layer_frame_id: preview for preview in layer_style.frame_previews.all() + } + + if all( + (preview := previews_by_frame.get(frame.id)) + and preview.status == PreviewStatus.COMPLETE + and preview.image + for frame in frames + ): + return "ready" + + statuses = [ + previews_by_frame[frame.id].status for frame in frames if frame.id in previews_by_frame + ] + + if any( + status in (PreviewStatus.CREATING, PreviewStatus.REGENERATING) for status in statuses + ) or any(status == PreviewStatus.COMPLETE for status in statuses): + result = "generating" + elif any(status == PreviewStatus.FAILED for status in statuses): + result = "failed" + else: + result = "none" + return result diff --git a/uvdat/core/frame_previews/raster_style.py b/uvdat/core/frame_previews/raster_style.py new file mode 100644 index 000000000..4a2a5d317 --- /dev/null +++ b/uvdat/core/frame_previews/raster_style.py @@ -0,0 +1,148 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from uvdat.core.models import Colormap + +RASTER_SOURCE_FILTER_KEYS = frozenset({"frame", "band"}) + + +def _hex_to_rgb(color: str) -> tuple[int, int, int]: + color = color.lstrip("#") + return int(color[0:2], 16), int(color[2:4], 16), int(color[4:6], 16) + + +def _rgb_to_hex(rgb: tuple[int, int, int]) -> str: + return "#{:02X}{:02X}{:02X}".format(*rgb) + + +def _mix_marker_colors( + marker_a: dict[str, Any], + marker_b: dict[str, Any], +) -> dict[str, Any]: + rgb_a = _hex_to_rgb(marker_a["color"]) + rgb_b = _hex_to_rgb(marker_b["color"]) + mixed = tuple((a + b) // 2 for a, b in zip(rgb_a, rgb_b, strict=True)) + return { + "color": _rgb_to_hex(mixed), + "value": (marker_a["value"] + marker_b["value"]) / 2, + } + + +def colormap_markers_subsample( + colormap: Colormap, + applied_colormap: dict[str, Any], + n: int | None = None, +) -> list[dict[str, Any]]: + markers = list(colormap.markers) + if n is None and applied_colormap.get("discrete") and applied_colormap.get("n_colors"): + n = applied_colormap["n_colors"] + if n and markers: + while n > len(markers): + expanded: list[dict[str, Any]] = [] + for i in range(len(markers) - 1): + expanded.append(markers[i]) + expanded.append(_mix_marker_colors(markers[i], markers[i + 1])) + expanded.append(markers[-1]) + markers = expanded + total_items = len(markers) - 1 + interval = total_items // (n - 1) if n > 1 else 0 + elements = [markers[0]] + elements.extend(markers[i * interval] for i in range(1, n - 1)) + elements.append(markers[-1]) + return elements + return markers + + +def _build_color_query_from_spec( + color_spec: dict[str, Any], + colormaps_by_id: dict[int, Colormap], +) -> dict[str, Any]: + color_query: dict[str, Any] = {} + colormap_spec = color_spec.get("colormap") + if colormap_spec: + if colormap_spec.get("range"): + color_query["min"] = colormap_spec["range"][0] + color_query["max"] = colormap_spec["range"][1] + if colormap_spec.get("discrete"): + color_query["scheme"] = "discrete" + if colormap_spec.get("clamp") is False: + color_query["clamp"] = False + colormap = colormaps_by_id.get(colormap_spec.get("id")) + if colormap and colormap.markers: + markers = colormap_markers_subsample(colormap, colormap_spec) + color_query["palette"] = [marker["color"] for marker in markers] + elif color_spec.get("single_color"): + color_query["palette"] = color_spec["single_color"] + return color_query + + +def _apply_color_spec_to_query( + query: dict[str, Any], + color_spec: dict[str, Any], + color_query: dict[str, Any], +) -> dict[str, Any]: + if not color_spec.get("visible"): + return query + if color_spec.get("name") == "all": + return color_query + query.setdefault("bands", []) + color_query["band"] = color_spec["name"].replace("Band ", "") + query["bands"].append(color_query) + return query + + +def _apply_non_source_filters( + query: dict[str, Any], + style_spec: dict[str, Any], +) -> None: + for filter_spec in style_spec.get("filters", []): + if ( + filter_spec.get("include") + and filter_spec.get("filter_by") + and filter_spec.get("list") + and len(filter_spec["list"]) == 1 + and filter_spec["filter_by"] not in RASTER_SOURCE_FILTER_KEYS + ): + query[filter_spec["filter_by"]] = filter_spec["list"][0] + + +def build_raster_tiles_style_query( + style_spec: dict[str, Any], + colormaps_by_id: dict[int, Colormap], +) -> dict[str, Any]: + query: dict[str, Any] = {} + for color_spec in style_spec.get("colors", []): + color_query = _build_color_query_from_spec(color_spec, colormaps_by_id) + query = _apply_color_spec_to_query(query, color_spec, color_query) + _apply_non_source_filters(query, style_spec) + return query + + +def apply_source_filters_to_style_query( + base_query: dict[str, Any], + source_filters: dict[str, Any] | None, +) -> dict[str, Any]: + query = dict(base_query) + if source_filters and "band" in source_filters: + query["band"] = source_filters["band"] + return query + + +def raster_source_filter_kwargs(source_filters: dict[str, Any] | None) -> dict[str, Any]: + """Return large_image kwargs that must not be embedded in style JSON.""" + if not source_filters or "frame" not in source_filters: + return {} + return {"frame": source_filters["frame"]} + + +def build_thumbnail_style_query( + style_spec: dict[str, Any], + source_filters: dict[str, Any] | None, + colormaps_by_id: dict[int, Colormap], +) -> dict[str, Any]: + return apply_source_filters_to_style_query( + build_raster_tiles_style_query(style_spec, colormaps_by_id), + source_filters, + ) diff --git a/uvdat/core/frame_previews/types.py b/uvdat/core/frame_previews/types.py new file mode 100644 index 000000000..632f892ad --- /dev/null +++ b/uvdat/core/frame_previews/types.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +from typing import TypedDict + + +class FramePreviewCorner(TypedDict): + x: float + y: float + + +class FramePreviewBounds(TypedDict, total=False): + srs: str # EPSG:4326 + xmin: float + xmax: float + ymin: float + ymax: float + ul: FramePreviewCorner + ur: FramePreviewCorner + lr: FramePreviewCorner + ll: FramePreviewCorner + + +class FramePreviewData(TypedDict): + url: str # presigned URL of the preview image + width: int + height: int + bounds: FramePreviewBounds diff --git a/uvdat/core/management/commands/ingest.py b/uvdat/core/management/commands/ingest.py index bcfe0519f..d0d542dd4 100644 --- a/uvdat/core/management/commands/ingest.py +++ b/uvdat/core/management/commands/ingest.py @@ -17,7 +17,9 @@ import djclick as click import pooch -from uvdat.core.models import Chart, Dataset, FileItem, Project +from uvdat.core.frame_previews.preview_regeneration import invalidate_and_enqueue_previews +from uvdat.core.models import Chart, Dataset, FileItem, Layer, Project +from uvdat.core.tasks.frame_preview import ensure_default_layer_style DATA_FOLDER = Path(os.environ.get("INGEST_BIND_MOUNT_POINT", "sample_data")) DOWNLOADS_FOLDER = DATA_FOLDER / "downloads" @@ -152,6 +154,24 @@ def ingest_file(file_info, *, index=0, dataset=None, chart=None, replace=False, new_file_item.file.save(file_path, File(f)) +def generate_ingest_multiframe_previews(converted_dataset_names: set[str]) -> int: + if not converted_dataset_names: + return 0 + style_count = 0 + for project in Project.objects.filter(datasets__name__in=converted_dataset_names).distinct(): + layers = Layer.objects.filter( + dataset__name__in=converted_dataset_names, + dataset__in=project.datasets.all(), + ) + for layer in layers: + if not layer.is_multiframe_raster(): + continue + style = ensure_default_layer_style(layer, project) + invalidate_and_enqueue_previews(style) + style_count += 1 + return style_count + + def ingest_projects(data: list[ProjectItem], *, replace=False) -> None: for project in data: click.echo(f"\t- {project['name']}") @@ -264,7 +284,8 @@ def default_conversion_process(dataset: Dataset, options: DatasetItem): def ingest_datasets( data: list[DatasetItem], json_file_path: Path, *, replace=False, skip_cache=False -) -> None: +) -> set[str]: + converted_dataset_names: set[str] = set() superuser = User.objects.filter(is_superuser=True).first() if superuser is None: raise click.ClickException("Please create at least one superuser") @@ -320,6 +341,7 @@ def ingest_datasets( f"\t\t Dataset {dataset_for_conversion.name} converted.", fg="green", ) + converted_dataset_names.add(dataset["name"]) else: click.secho( f"\t\t Dataset {dataset['name']} already exists, not importing/converting", @@ -329,6 +351,8 @@ def ingest_datasets( dataset_for_conversion.set_tags(dataset.get("tags")) dataset_for_conversion.set_owner(superuser) + return converted_dataset_names + @click.command() @click.argument("file_path") @@ -385,10 +409,16 @@ def ingest(*, file_path, replace, clear, skip_cache): elif item["type"] == "Chart": charts.append(item) click.echo("Ingesting Datasets:") - ingest_datasets(datasets, file_path, replace=replace, skip_cache=skip_cache) + converted_dataset_names = ingest_datasets( + datasets, file_path, replace=replace, skip_cache=skip_cache + ) click.echo("Ingesting Projects:") ingest_projects(projects, replace=replace) click.echo("Ingesting Charts:") ingest_charts(charts, replace=replace, skip_cache=skip_cache) + preview_count = generate_ingest_multiframe_previews(converted_dataset_names) + if preview_count: + click.echo(f"Generated multiframe raster previews for {preview_count} layer style(s).") + click.secho("Ingestion complete.", fg="green") diff --git a/uvdat/core/migrations/0024_raster_frame_preview.py b/uvdat/core/migrations/0024_raster_frame_preview.py new file mode 100644 index 000000000..fc2abc86b --- /dev/null +++ b/uvdat/core/migrations/0024_raster_frame_preview.py @@ -0,0 +1,71 @@ +# Generated by Django 5.2.9 on 2026-06-25 +from __future__ import annotations + +from django.db import migrations, models +import django.db.models.deletion +import s3_file_field.fields + + +class Migration(migrations.Migration): + dependencies = [ + ("core", "0023_task_subscribers"), + ] + + operations = [ + migrations.CreateModel( + name="RasterFramePreview", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ( + "status", + models.CharField( + choices=[ + ("creating", "Creating"), + ("regenerating", "Regenerating"), + ("complete", "Complete"), + ("failed", "Failed"), + ], + default="creating", + max_length=16, + ), + ), + ( + "style_fingerprint", + models.CharField(blank=True, default="", max_length=64), + ), + ("image", s3_file_field.fields.S3FileField(blank=True, null=True)), + ("width", models.PositiveIntegerField(blank=True, null=True)), + ("height", models.PositiveIntegerField(blank=True, null=True)), + ("bounds", models.JSONField(blank=True, default=dict)), + ( + "layer_frame", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="style_previews", + to="core.layerframe", + ), + ), + ( + "layer_style", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="frame_previews", + to="core.layerstyle", + ), + ), + ], + options={ + "constraints": [ + models.UniqueConstraint( + fields=("layer_style", "layer_frame"), + name="unique_layer_style_layer_frame_preview", + ) + ], + }, + ), + ] diff --git a/uvdat/core/models/__init__.py b/uvdat/core/models/__init__.py index 83ff77636..0e4672d76 100644 --- a/uvdat/core/models/__init__.py +++ b/uvdat/core/models/__init__.py @@ -6,6 +6,7 @@ from .data import RasterData, VectorData, VectorFeature from .dataset import Dataset, DatasetTag from .file_item import FileItem +from .frame_preview import RasterFramePreview from .layer import Layer, LayerFrame from .networks import Network, NetworkEdge, NetworkNode from .project import Project @@ -39,6 +40,7 @@ "NetworkNode", "Project", "RasterData", + "RasterFramePreview", "Region", "SizeConfig", "SizeRangeConfig", diff --git a/uvdat/core/models/frame_preview.py b/uvdat/core/models/frame_preview.py new file mode 100644 index 000000000..26fc7b43c --- /dev/null +++ b/uvdat/core/models/frame_preview.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +from django.db import models +from django.dispatch import receiver +from s3_file_field import S3FileField + +from .layer import LayerFrame + + +class PreviewStatus(models.TextChoices): + CREATING = "creating", "Creating" + REGENERATING = "regenerating", "Regenerating" + COMPLETE = "complete", "Complete" + FAILED = "failed", "Failed" + + +class RasterFramePreview(models.Model): + layer_style = models.ForeignKey( + "LayerStyle", + related_name="frame_previews", + on_delete=models.CASCADE, + ) + layer_frame = models.ForeignKey( + LayerFrame, + related_name="style_previews", + on_delete=models.CASCADE, + ) + status = models.CharField( + max_length=16, + choices=PreviewStatus.choices, + default=PreviewStatus.CREATING, + ) + style_fingerprint = models.CharField(max_length=64, blank=True, default="") + image = S3FileField(blank=True, null=True) + width = models.PositiveIntegerField(null=True, blank=True) + height = models.PositiveIntegerField(null=True, blank=True) + bounds = models.JSONField(default=dict, blank=True) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["layer_style", "layer_frame"], + name="unique_layer_style_layer_frame_preview", + ) + ] + + def __str__(self): + return f"Preview style={self.layer_style_id} frame={self.layer_frame_id} ({self.id})" + + +@receiver(models.signals.post_delete, sender=RasterFramePreview) +def delete_preview_image(sender, instance, **kwargs): + if instance.image: + instance.image.delete(save=False) diff --git a/uvdat/core/models/layer.py b/uvdat/core/models/layer.py index 88489d9b1..c32bbe018 100644 --- a/uvdat/core/models/layer.py +++ b/uvdat/core/models/layer.py @@ -1,11 +1,16 @@ from __future__ import annotations +from typing import TYPE_CHECKING + from django.db import models from .data import RasterData, VectorData from .dataset import Dataset from .querysets import ProjectQuerySet +if TYPE_CHECKING: + from uvdat.core.frame_previews.types import FramePreviewData + def default_source_filters(): return {} @@ -25,6 +30,25 @@ class Layer(models.Model): def __str__(self): return f"{self.name} ({self.id})" + def multiframe_raster_frames(self): + prefetched = getattr(self, "raster_frames", None) + if prefetched is not None: + return prefetched + return list( + self.frames.filter(raster__isnull=False).select_related("raster").order_by("index") + ) + + def is_multiframe_raster(self) -> bool: + prefetched = getattr(self, "raster_frames", None) + if prefetched is not None: + return len(prefetched) > 1 + return self.frames.filter(raster__isnull=False).count() > 1 + + def default_multiframe_previews(self) -> list[FramePreviewData | None] | None: + if self.default_style_id is None: + return None + return self.default_style.multiframe_previews(layer=self) + class LayerFrame(models.Model): name = models.CharField(max_length=255, default="Layer Frame") diff --git a/uvdat/core/models/styles.py b/uvdat/core/models/styles.py index 385d1cabb..0e50373e6 100644 --- a/uvdat/core/models/styles.py +++ b/uvdat/core/models/styles.py @@ -1,16 +1,22 @@ from __future__ import annotations import contextlib -from typing import Any +from typing import TYPE_CHECKING, Any from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models +from uvdat.core.frame_previews.types import FramePreviewData + from .colormap import Colormap +from .frame_preview import PreviewStatus from .layer import Layer from .project import Project from .querysets import ProjectQuerySet +if TYPE_CHECKING: + from .frame_preview import RasterFramePreview + class LayerStyle(models.Model): name = models.CharField(max_length=255, default="Layer Style") @@ -224,6 +230,42 @@ def repr_style_configs(self): "filters": filters, } + def _multiframe_previews_by_frame(self, layer=None) -> list[RasterFramePreview | None] | None: + layer = layer or self.layer + frames = layer.multiframe_raster_frames() + if len(frames) <= 1: + return None + + previews_by_frame_id = { + preview.layer_frame_id: preview for preview in self.frame_previews.all() + } + return [ + previews_by_frame_id.get(frame.id) if frame.id in previews_by_frame_id else None + for frame in frames + ] + + @staticmethod + def serialize_frame_preview( + preview: RasterFramePreview | None, + ) -> FramePreviewData | None: + + if preview is None: + return None + if preview.status != PreviewStatus.COMPLETE or not preview.image: + return None + return FramePreviewData( + url=preview.image.url, + width=preview.width, + height=preview.height, + bounds=preview.bounds, + ) + + def multiframe_previews(self, layer=None) -> list[FramePreviewData | None] | None: + previews = self._multiframe_previews_by_frame(layer=layer) + if previews is None: + return None + return [self.serialize_frame_preview(preview) for preview in previews] + def get_default_colormap(): return Colormap.objects.filter(project__isnull=True).first() diff --git a/uvdat/core/rest/dataset.py b/uvdat/core/rest/dataset.py index 65d5a1adb..f0f2ea2d5 100644 --- a/uvdat/core/rest/dataset.py +++ b/uvdat/core/rest/dataset.py @@ -6,6 +6,7 @@ from uvdat.core.access_control import DatasetGuardianPermission from uvdat.core.models import Dataset, DatasetTag, Network +from uvdat.core.rest.querysets import layer_queryset_with_previews from uvdat.core.rest.serializers import ( DatasetSerializer, FileItemSerializer, @@ -43,7 +44,7 @@ def tags(self, request, **kwargs): @action(detail=True, methods=["get"]) def layers(self, request, **kwargs): dataset: Dataset = self.get_object() - layers = list(dataset.layers.all()) + layers = list(layer_queryset_with_previews().filter(dataset=dataset)) serializer = LayerSerializer(layers, many=True) return Response(serializer.data, status=200) diff --git a/uvdat/core/rest/layer.py b/uvdat/core/rest/layer.py index 95e12c217..33ff4f076 100644 --- a/uvdat/core/rest/layer.py +++ b/uvdat/core/rest/layer.py @@ -7,17 +7,20 @@ from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet from uvdat.core.models import Layer, LayerFrame, LayerStyle +from uvdat.core.rest.querysets import layer_queryset_with_previews from uvdat.core.rest.serializers import ( LayerFrameSerializer, LayerSerializer, - LayerStyleSerializer, + LayerStyleWithPreviewsSerializer, ) class LayerViewSet(ReadOnlyModelViewSet): - queryset = Layer.objects.select_related("dataset").all() serializer_class = LayerSerializer + def get_queryset(self): + return layer_queryset_with_previews() + @action(detail=True, methods=["get"]) def frames(self, request, **kwargs): layer: Layer = self.get_object() @@ -33,7 +36,7 @@ class LayerFrameViewSet(ReadOnlyModelViewSet): class LayerStyleViewSet(ModelViewSet): queryset = LayerStyle.objects.all() - serializer_class = LayerStyleSerializer + serializer_class = LayerStyleWithPreviewsSerializer def get_queryset(self): qs = super().get_queryset() @@ -43,11 +46,11 @@ def get_queryset(self): layer_id = int(self.request.query_params.get("layer", -1)) if layer_id > -1: qs = qs.filter(layer=int(layer_id)) - return qs + return layer_queryset_with_previews(qs, for_layer_style=True) def create(self, request, **kwargs): is_default = request.data.pop("is_default", False) - serializer = LayerStyleSerializer(data=request.data) + serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) with transaction.atomic(): try: @@ -62,7 +65,7 @@ def create(self, request, **kwargs): def partial_update(self, request, **kwargs): instance = self.get_object() is_default = request.data.pop("is_default", False) - serializer = LayerStyleSerializer(instance, data=request.data, partial=True) + serializer = self.get_serializer(instance, data=request.data, partial=True) serializer.is_valid(raise_exception=True) with transaction.atomic(): try: diff --git a/uvdat/core/rest/querysets.py b/uvdat/core/rest/querysets.py new file mode 100644 index 000000000..bf567cd48 --- /dev/null +++ b/uvdat/core/rest/querysets.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +from django.db.models import Prefetch, QuerySet + +from uvdat.core.models import Layer, LayerFrame, LayerStyle, RasterFramePreview + +RASTER_FRAMES_QUERYSET = ( + LayerFrame.objects.filter(raster__isnull=False).select_related("raster").order_by("index") +) + +FRAME_PREVIEWS_PREFETCH = Prefetch( + "frame_previews", + queryset=RasterFramePreview.objects.select_related("layer_frame"), +) + +LAYER_RASTER_FRAMES_PREFETCH = Prefetch( + "frames", + queryset=RASTER_FRAMES_QUERYSET, + to_attr="raster_frames", +) + +STYLE_LAYER_RASTER_FRAMES_PREFETCH = Prefetch( + "layer__frames", + queryset=RASTER_FRAMES_QUERYSET, + to_attr="raster_frames", +) + + +def layer_queryset_with_previews( + queryset: QuerySet | None = None, + *, + for_layer_style: bool = False, +) -> QuerySet: + # This is for /layer-styles/?layer={layer_id} so we need to prefetch the frames for all styles + if for_layer_style: + qs = queryset if queryset is not None else LayerStyle.objects.all() + return qs.select_related("layer", "layer__default_style").prefetch_related( + FRAME_PREVIEWS_PREFETCH, + STYLE_LAYER_RASTER_FRAMES_PREFETCH, + ) + + qs = queryset if queryset is not None else Layer.objects.all() + # This is for /layers/{layer_id} so we need to prefetch the frames and the default style + return qs.select_related("dataset", "default_style").prefetch_related( + LAYER_RASTER_FRAMES_PREFETCH, + Prefetch( + "default_style__frame_previews", + queryset=RasterFramePreview.objects.select_related("layer_frame"), + ), + ) diff --git a/uvdat/core/rest/serializers.py b/uvdat/core/rest/serializers.py index d255d1e14..41da2cee5 100644 --- a/uvdat/core/rest/serializers.py +++ b/uvdat/core/rest/serializers.py @@ -5,6 +5,10 @@ from django.contrib.gis.serializers import geojson from rest_framework import serializers +from uvdat.core.frame_previews.preview_regeneration import ( + invalidate_and_enqueue_previews, + preview_status_for_style, +) from uvdat.core.models import ( Basemap, Chart, @@ -149,6 +153,11 @@ class Meta: fields = "__all__" +def _omit_null_field(data: dict, field: str) -> None: + if data.get(field) is None: + data.pop(field, None) + + class LayerStyleSerializer(serializers.ModelSerializer): is_default = serializers.SerializerMethodField("get_is_default") @@ -166,24 +175,77 @@ def create(self, validated_data): style_spec = self.initial_data.pop("style_spec", None) instance = super().create(validated_data) instance.save_style_configs(style_spec) + invalidate_and_enqueue_previews(instance) return instance def update(self, instance, validated_data): style_spec = self.initial_data.pop("style_spec", None) instance.save_style_configs(style_spec) - return super().update(instance, validated_data) + instance = super().update(instance, validated_data) + invalidate_and_enqueue_previews(instance) + return instance class Meta: model = LayerStyle exclude = ["default_frame", "opacity"] +class LayerStyleWithPreviewsSerializer(LayerStyleSerializer): + multiframe_previews = serializers.SerializerMethodField() + preview_status = serializers.SerializerMethodField() + + def _preview_layer(self, obj): + return self.context.get("preview_layer") or obj.layer + + def get_preview_status(self, obj): + return preview_status_for_style(obj) + + def get_multiframe_previews(self, obj): + if preview_status_for_style(obj) != "ready": + return None + return obj.multiframe_previews(layer=self._preview_layer(obj)) + + def to_representation(self, instance): + data = super().to_representation(instance) + _omit_null_field(data, "multiframe_previews") + _omit_null_field(data, "preview_status") + return data + + class LayerSerializer(serializers.ModelSerializer): default_style = LayerStyleSerializer() + multiframe_previews = serializers.SerializerMethodField() + preview_status = serializers.SerializerMethodField() + + def get_preview_status(self, obj): + if obj.default_style_id is None: + return None + return preview_status_for_style(obj.default_style) + + def get_multiframe_previews(self, obj): + if obj.default_style_id is None: + return None + if preview_status_for_style(obj.default_style) != "ready": + return None + return obj.default_multiframe_previews() + + def to_representation(self, instance): + data = super().to_representation(instance) + _omit_null_field(data, "multiframe_previews") + _omit_null_field(data, "preview_status") + return data class Meta: model = Layer - fields = ["id", "name", "metadata", "dataset", "default_style"] + fields = [ + "id", + "name", + "metadata", + "dataset", + "default_style", + "multiframe_previews", + "preview_status", + ] class VectorDataSerializer(serializers.ModelSerializer): diff --git a/uvdat/core/tasks/__init__.py b/uvdat/core/tasks/__init__.py index 9e431834c..f1656a793 100644 --- a/uvdat/core/tasks/__init__.py +++ b/uvdat/core/tasks/__init__.py @@ -2,8 +2,10 @@ from .chart import convert_chart from .dataset import convert_dataset +from .frame_preview import generate_layer_style_previews __all__ = [ "convert_chart", "convert_dataset", + "generate_layer_style_previews", ] diff --git a/uvdat/core/tasks/dataset.py b/uvdat/core/tasks/dataset.py index 2724079ca..435042786 100644 --- a/uvdat/core/tasks/dataset.py +++ b/uvdat/core/tasks/dataset.py @@ -136,7 +136,7 @@ def create_layers_and_frames(dataset, layer_options=None): # noqa: C901, PLR091 index=index, vector=vector, raster=raster, - source_filters=frame_info.get("source_filters", {"band": 1}), + source_filters=frame_info.get("source_filters", {}), ) diff --git a/uvdat/core/tasks/frame_preview.py b/uvdat/core/tasks/frame_preview.py new file mode 100644 index 000000000..cca01750b --- /dev/null +++ b/uvdat/core/tasks/frame_preview.py @@ -0,0 +1,419 @@ +from __future__ import annotations + +from dataclasses import dataclass +import io +import json +import logging +import time +from typing import TYPE_CHECKING, Any + +from celery import shared_task +from django.core.files.base import ContentFile +from django_large_image import tilesource, utilities +from PIL import Image + +from uvdat.core.frame_previews.fingerprint import style_fingerprint +from uvdat.core.frame_previews.raster_style import ( + apply_source_filters_to_style_query, + build_raster_tiles_style_query, + raster_source_filter_kwargs, +) +from uvdat.core.models import ( + Colormap, + Layer, + LayerStyle, + Project, + RasterData, + RasterFramePreview, + TaskResult, +) +from uvdat.core.models.frame_preview import PreviewStatus + +if TYPE_CHECKING: + from uvdat.core.frame_previews.types import FramePreviewBounds + +"""Celery tasks and helpers for multiframe raster frame preview images. + +Previews are styled PNG thumbnails stored on ``RasterFramePreview`` rows. They +let the frontend show a fast full-frame image before tile loading during frame +scrubbing. Generation is keyed by a style fingerprint so rapid successive style +saves do not publish stale images from an older task run. +""" + +logger = logging.getLogger(__name__) + +# Thumbnail sizing: default to 1/8 of FRAME_PREVIEW_MAX_PX (512px), but never +# below FRAME_PREVIEW_MIN_PX when the source raster is large enough to allow it. +FRAME_PREVIEW_MAX_PX = 4096 +FRAME_PREVIEW_MIN_PX = 1024 +FRAME_PREVIEW_DEFAULT_RESOLUTION_FRACTION = 1 / 8 + +# Applied when ingest creates a default style for a new multiframe raster layer. +DEFAULT_MULTIFRAME_RASTER_STYLE_SPEC = { + "default_frame": 0, + "opacity": 1, + "colors": [{"name": "all", "visible": True, "use_feature_props": True}], + "sizes": [{"name": "all", "zoom_scaling": True, "single_size": 5}], + "filters": [], +} + + +@dataclass(frozen=True) +class _PreviewGenerationContext: + """Immutable inputs shared across all frames in one task invocation.""" + + layer_style: LayerStyle + fingerprint: str + base_style_query: dict[str, Any] + resolution_fraction: float | None = None + + @property + def layer_style_id(self) -> int: + return self.layer_style.id + + +@dataclass(frozen=True) +class _FramePreviewImage: + """PNG payload and metadata produced by ``generate_frame_preview_png``.""" + + png_bytes: bytes + width: int + height: int + bounds: FramePreviewBounds | None + + +@dataclass(frozen=True) +class _PreviewGenerationStats: + """Per-task frame counts written to ``TaskResult.outputs`` on completion.""" + + ready_count: int + failed_count: int + + +def resolve_resolution_fraction(resolution_fraction: float | None = None) -> float: + if resolution_fraction is None: + return FRAME_PREVIEW_DEFAULT_RESOLUTION_FRACTION + return float(resolution_fraction) + + +def _raster_max_dimension(metadata: dict[str, Any]) -> int: + size_x = metadata.get("sizeX") or metadata.get("width") or 0 + size_y = metadata.get("sizeY") or metadata.get("height") or 0 + return max(int(size_x), int(size_y)) + + +def resolve_preview_max_dimension( + resolution_fraction: float | None = None, + raster_max_dimension: int | None = None, +) -> int: + """Pick a thumbnail edge length, clamped to the source raster's size.""" + fraction = resolve_resolution_fraction(resolution_fraction) + fractional = round(FRAME_PREVIEW_MAX_PX * fraction) + if not raster_max_dimension or raster_max_dimension < 2: + return max(2, fractional) + # Small rasters use their native size; larger ones get at least MIN_PX. + floor = min(raster_max_dimension, FRAME_PREVIEW_MIN_PX) + return max(2, min(max(fractional, floor), raster_max_dimension)) + + +def _colormaps_for_style(layer_style: LayerStyle) -> dict[int, Colormap]: + colormaps = Colormap.objects.filter(project=layer_style.project) | Colormap.objects.filter( + project__isnull=True + ) + return {colormap.id: colormap for colormap in colormaps} + + +def _thumbnail_png_bytes(thumb_data: Any) -> bytes: + if isinstance(thumb_data, bytes): + return thumb_data + if isinstance(thumb_data, Image.Image): + buffer = io.BytesIO() + thumb_data.save(buffer, format="PNG") + return buffer.getvalue() + msg = f"Unsupported thumbnail data type: {type(thumb_data)!r}" + raise TypeError(msg) + + +def _preview_bounds(source) -> FramePreviewBounds | None: + bounds = tilesource.get_bounds(source, projection="EPSG:4326") + if not bounds: + return None + result: FramePreviewBounds = { + "srs": "EPSG:4326", + "xmin": bounds["xmin"], + "xmax": bounds["xmax"], + "ymin": bounds["ymin"], + "ymax": bounds["ymax"], + } + for corner in ("ul", "ur", "lr", "ll"): + corner_bounds = bounds.get(corner) + if corner_bounds: + result[corner] = {"x": corner_bounds["x"], "y": corner_bounds["y"]} + return result + + +def generate_frame_preview_png( + raster: RasterData, + source_filters: dict[str, Any] | None, + base_style_query: dict[str, Any], + resolution_fraction: float | None = None, +) -> tuple[bytes, int, int, FramePreviewBounds | None]: + """Render one frame as a styled PNG via large-image. + + Frame selection is passed through ``source_filters`` (e.g. ``{"frame": 3}``), + not embedded in the style query, so one style query can be reused for every + frame in a multiframe layer. + """ + style_query = apply_source_filters_to_style_query(base_style_query, source_filters) + style = json.dumps(style_query) if style_query else None + source_kwargs = raster_source_filter_kwargs(source_filters) + raster_path = utilities.field_file_to_local_path(raster.cloud_optimized_geotiff) + source = tilesource.get_tilesource_from_path( + raster_path, + encoding="PNG", + style=style, + ) + max_dimension = resolve_preview_max_dimension( + resolution_fraction, + _raster_max_dimension(source.getMetadata()), + ) + thumb_data, _mime_type = source.getThumbnail( + encoding="PNG", + width=max_dimension, + height=max_dimension, + **source_kwargs, + ) + png_bytes = _thumbnail_png_bytes(thumb_data) + image = Image.open(io.BytesIO(png_bytes)) + return png_bytes, image.width, image.height, _preview_bounds(source) + + +def ensure_default_layer_style(layer: Layer, project: Project) -> LayerStyle: + """Return the project's Default style for a layer, creating it on ingest if needed.""" + style = LayerStyle.objects.filter(layer=layer, project=project, name="Default").first() + if style is None: + style = LayerStyle.objects.create(name="Default", layer=layer, project=project) + style.save_style_configs(DEFAULT_MULTIFRAME_RASTER_STYLE_SPEC) + if layer.default_style_id is None: + layer.default_style = style + layer.save(update_fields=["default_style"]) + return style + + +def _fingerprint_matches(layer_style: LayerStyle, fingerprint: str) -> bool: + """Return whether the style's current configs still match the task fingerprint. + + ``repr_style_configs`` always reads from the database, so this detects a + style save that happened after this task was enqueued. + """ + return style_fingerprint(layer_style) == fingerprint + + +def _open_task_result(result_id: int | None, layer_style_id: int) -> TaskResult | None: + """Load the TaskResult for this run, or None if it was already superseded.""" + if result_id is None: + return None + + result = TaskResult.objects.filter(id=result_id).first() + if result is None or result.completed is not None: + logger.info( + "Skipping preview generation for style=%s; task result %s already closed", + layer_style_id, + result_id, + ) + return None + return result + + +def _save_frame_preview( + preview: RasterFramePreview, + layer_style_id: int, + frame_index: int, + image: _FramePreviewImage, +) -> None: + """Persist a generated preview and mark the row complete (API-servable).""" + preview.width = image.width + preview.height = image.height + preview.bounds = image.bounds or {} + preview.status = PreviewStatus.COMPLETE + preview.image.save( + f"frame-previews/{layer_style_id}/{frame_index}.png", + ContentFile(image.png_bytes), + save=False, + ) + preview.save() + + +def _mark_frame_preview_failed( + preview: RasterFramePreview, + layer_style: LayerStyle, + fingerprint: str, +) -> None: + """Mark a row failed only when both the row and style still match this task.""" + if preview.style_fingerprint == fingerprint and _fingerprint_matches(layer_style, fingerprint): + preview.status = PreviewStatus.FAILED + preview.save(update_fields=["status"]) + + +def _process_frame_preview(ctx: _PreviewGenerationContext, frame) -> str: + """Generate one frame preview. + + Returns ``ready``, ``failed``, ``skipped``, or ``superseded``. A superseded + outcome means a newer style save arrived and this task must stop writing. + """ + if not _fingerprint_matches(ctx.layer_style, ctx.fingerprint): + return "superseded" + + try: + preview = RasterFramePreview.objects.get( + layer_style=ctx.layer_style, + layer_frame=frame, + ) + except RasterFramePreview.DoesNotExist: + logger.warning( + "Missing preview row for style=%s frame=%s; skipping", + ctx.layer_style_id, + frame.id, + ) + return "skipped" + + # Row fingerprint is set on enqueue; skip frames already claimed by a newer save. + if preview.style_fingerprint != ctx.fingerprint: + return "skipped" + + try: + png_bytes, width, height, bounds = generate_frame_preview_png( + frame.raster, + frame.source_filters, + ctx.base_style_query, + ctx.resolution_fraction, + ) + except Exception: + logger.exception( + "Failed to generate frame preview for style=%s frame=%s", + ctx.layer_style_id, + frame.id, + ) + _mark_frame_preview_failed(preview, ctx.layer_style, ctx.fingerprint) + return "failed" + + # PNG generation is expensive; re-check before writing to storage. + if not _fingerprint_matches(ctx.layer_style, ctx.fingerprint): + return "superseded" + + _save_frame_preview( + preview, + ctx.layer_style_id, + frame.index, + _FramePreviewImage(png_bytes, width, height, bounds), + ) + return "ready" + + +def _complete_preview_task( + result: TaskResult | None, + ctx: _PreviewGenerationContext, + stats: _PreviewGenerationStats, +) -> None: + """Finalize the TaskResult, which triggers a WebSocket notification.""" + if not _fingerprint_matches(ctx.layer_style, ctx.fingerprint): + logger.info( + "Skipping task completion for style=%s; style superseded after loop", + ctx.layer_style_id, + ) + return + + if result is None: + return + + result.write_outputs( + { + "layer_style_id": ctx.layer_style_id, + "layer_id": ctx.layer_style.layer_id, + "fingerprint": ctx.fingerprint, + "ready_count": stats.ready_count, + "failed_count": stats.failed_count, + } + ) + result.complete() + + +@shared_task +def generate_layer_style_previews( + layer_style_id: int, + fingerprint: str, + result_id: int | None = None, + resolution_fraction: float | None = None, +): + """Generate styled PNG previews for every frame in a multiframe raster style. + + Enqueued by ``invalidate_and_enqueue_previews`` after a style save or ingest. + Preview rows are created upstream with ``creating``/``regenerating`` status and + cleared images; this task fills them in and sets ``complete`` or ``failed``. + + ``fingerprint`` is a sha256 of ``repr_style_configs()`` at enqueue time. The + task aborts whenever the live style no longer matches, so rapid double-saves + only publish previews for the latest style version. + """ + started = time.perf_counter() + layer_style = LayerStyle.objects.select_related("layer", "project").get(id=layer_style_id) + frames = layer_style.layer.multiframe_raster_frames() + if len(frames) <= 1: + return + + if not _fingerprint_matches(layer_style, fingerprint): + logger.info( + "Skipping superseded preview generation for style=%s", + layer_style_id, + ) + return + + result = _open_task_result(result_id, layer_style_id) + if result_id is not None and result is None: + return + + logger.info( + "Generating %d multiframe raster previews for style=%s layer=%r", + len(frames), + layer_style_id, + layer_style.layer.name, + ) + + style_spec = layer_style.repr_style_configs() + colormaps_by_id = _colormaps_for_style(layer_style) + ctx = _PreviewGenerationContext( + layer_style=layer_style, + fingerprint=fingerprint, + base_style_query=build_raster_tiles_style_query(style_spec, colormaps_by_id), + resolution_fraction=resolution_fraction, + ) + + ready_count = 0 + failed_count = 0 + for frame in frames: + outcome = _process_frame_preview(ctx, frame) + if outcome == "superseded": + logger.info( + "Aborting preview generation for style=%s; style superseded", + layer_style_id, + ) + return + if outcome == "ready": + ready_count += 1 + elif outcome == "failed": + failed_count += 1 + + _complete_preview_task( + result, + ctx, + _PreviewGenerationStats(ready_count, failed_count), + ) + + logger.info( + "Multiframe raster previews for style=%s layer=%r: %d ready, %d failed in %.2fs", + layer_style_id, + layer_style.layer.name, + ready_count, + failed_count, + time.perf_counter() - started, + ) diff --git a/uvdat/core/tests/factories.py b/uvdat/core/tests/factories.py index d392a6293..f5de15e35 100644 --- a/uvdat/core/tests/factories.py +++ b/uvdat/core/tests/factories.py @@ -132,7 +132,7 @@ class Meta: name = factory.Faker("name") layer = factory.SubFactory(LayerFactory) - vector = factory.SubFactory(VectorDataFactory) + vector = None raster = factory.SubFactory(RasterDataFactory) diff --git a/uvdat/core/tests/test_frame_preview.py b/uvdat/core/tests/test_frame_preview.py new file mode 100644 index 000000000..fbbc6e260 --- /dev/null +++ b/uvdat/core/tests/test_frame_preview.py @@ -0,0 +1,298 @@ +from __future__ import annotations + +from django.core.files.base import ContentFile +import pytest + +from uvdat.core.frame_previews.raster_style import ( + apply_source_filters_to_style_query, + build_thumbnail_style_query, + raster_source_filter_kwargs, +) +from uvdat.core.models import RasterFramePreview +from uvdat.core.models.frame_preview import PreviewStatus +from uvdat.core.tasks.frame_preview import ( + FRAME_PREVIEW_DEFAULT_RESOLUTION_FRACTION, + FRAME_PREVIEW_MAX_PX, + FRAME_PREVIEW_MIN_PX, + resolve_preview_max_dimension, +) + + +def test_build_thumbnail_style_query_does_not_embed_frame(): + style_spec = { + "colors": [{"name": "all", "visible": True, "single_color": "#ffffff"}], + "filters": [], + } + query = build_thumbnail_style_query(style_spec, {"frame": 3}, {}) + assert "frame" not in query + assert query == {"palette": "#ffffff"} + + +def test_raster_source_filter_kwargs_extracts_frame(): + assert raster_source_filter_kwargs({"frame": 3}) == {"frame": 3} + assert raster_source_filter_kwargs({"band": 2}) == {} + assert raster_source_filter_kwargs({"frame": 1, "band": 2}) == {"frame": 1} + + +def test_apply_source_filters_to_style_query_embeds_band_not_frame(): + query = apply_source_filters_to_style_query({}, {"frame": 3, "band": 2}) + assert query == {"band": 2} + assert apply_source_filters_to_style_query({}, {"frame": 3, "band": 1}) == {"band": 1} + assert apply_source_filters_to_style_query({}, {"band": 1}) == {"band": 1} + + +@pytest.mark.parametrize( + ("resolution_fraction", "raster_max_dimension", "expected_max_px"), + [ + (None, None, round(FRAME_PREVIEW_MAX_PX * FRAME_PREVIEW_DEFAULT_RESOLUTION_FRACTION)), + (0.25, None, 1024), + (0.5, None, 2048), + (FRAME_PREVIEW_DEFAULT_RESOLUTION_FRACTION, None, 512), + (None, 4096, FRAME_PREVIEW_MIN_PX), + (0.25, 4096, FRAME_PREVIEW_MIN_PX), + (0.5, 4096, 2048), + (None, 800, 800), + (0.5, 500, 500), + (0.75, None, 3072), + ], +) +def test_resolve_preview_max_dimension( + resolution_fraction, + raster_max_dimension, + expected_max_px, +): + assert ( + resolve_preview_max_dimension(resolution_fraction, raster_max_dimension) == expected_max_px + ) + + +@pytest.mark.django_db +def test_multiframe_previews_for_style_returns_none_for_single_frame( + layer_style_factory, + layer_frame_factory, +): + layer_style = layer_style_factory() + layer_frame_factory(layer=layer_style.layer, index=0) + + assert layer_style.multiframe_previews() is None + + +@pytest.mark.django_db +def test_multiframe_previews_for_style_ordered_by_frame_index( + layer_style_factory, + layer_frame_factory, +): + layer_style = layer_style_factory() + frame_0 = layer_frame_factory(layer=layer_style.layer, index=0) + layer_frame_factory(layer=layer_style.layer, index=1) + frame_2 = layer_frame_factory(layer=layer_style.layer, index=2) + + preview_0 = RasterFramePreview.objects.create( + layer_style=layer_style, + layer_frame=frame_0, + status=PreviewStatus.COMPLETE, + width=100, + height=80, + bounds={"srs": "EPSG:4326", "xmin": -1, "xmax": 1, "ymin": -2, "ymax": 2}, + ) + preview_0.image.save("frame-0.png", ContentFile(b"png0"), save=True) + preview_2 = RasterFramePreview.objects.create( + layer_style=layer_style, + layer_frame=frame_2, + status=PreviewStatus.COMPLETE, + width=200, + height=150, + bounds={"srs": "EPSG:4326", "xmin": -3, "xmax": 3, "ymin": -4, "ymax": 4}, + ) + preview_2.image.save("frame-2.png", ContentFile(b"png2"), save=True) + + previews = layer_style.multiframe_previews() + assert previews == [ + { + "url": preview_0.image.url, + "width": 100, + "height": 80, + "bounds": preview_0.bounds, + }, + None, + { + "url": preview_2.image.url, + "width": 200, + "height": 150, + "bounds": preview_2.bounds, + }, + ] + + +@pytest.mark.django_db +def test_preview_bounds_includes_corners( + layer_style_factory, + layer_frame_factory, +): + layer_style = layer_style_factory() + frame = layer_frame_factory(layer=layer_style.layer, index=0) + layer_frame_factory(layer=layer_style.layer, index=1) + + preview = RasterFramePreview.objects.create( + layer_style=layer_style, + layer_frame=frame, + status=PreviewStatus.COMPLETE, + width=100, + height=80, + bounds={ + "srs": "EPSG:4326", + "xmin": -1, + "xmax": 1, + "ymin": -2, + "ymax": 2, + "ul": {"x": -1, "y": 2}, + "ur": {"x": 1, "y": 2}, + "lr": {"x": 1, "y": -2}, + "ll": {"x": -1, "y": -2}, + }, + ) + preview.image.save("frame-0.png", ContentFile(b"png0"), save=True) + + previews = layer_style.multiframe_previews() + assert previews[0]["bounds"]["ul"] == {"x": -1, "y": 2} + + +@pytest.mark.django_db +def test_layer_style_api_includes_multiframe_previews( + authenticated_api_client, + layer_style_factory, + layer_frame_factory, + project, + user, +): + layer_style = layer_style_factory() + project.set_collaborators([user]) + project.datasets.set([layer_style.layer.dataset]) + frame_0 = layer_frame_factory(layer=layer_style.layer, index=0) + frame_1 = layer_frame_factory(layer=layer_style.layer, index=1) + + preview_0 = RasterFramePreview.objects.create( + layer_style=layer_style, + layer_frame=frame_0, + status=PreviewStatus.COMPLETE, + width=100, + height=100, + bounds={}, + ) + preview_0.image.save("frame-0.png", ContentFile(b"png0"), save=True) + preview_1 = RasterFramePreview.objects.create( + layer_style=layer_style, + layer_frame=frame_1, + status=PreviewStatus.COMPLETE, + width=100, + height=100, + bounds={}, + ) + preview_1.image.save("frame-1.png", ContentFile(b"png1"), save=True) + + resp = authenticated_api_client.get(f"/api/v1/layer-styles/{layer_style.id}/") + assert resp.status_code == 200 + data = resp.json() + assert data["preview_status"] == "ready" + assert data["multiframe_previews"] == [ + { + "url": preview_0.image.url, + "width": 100, + "height": 100, + "bounds": {}, + }, + { + "url": preview_1.image.url, + "width": 100, + "height": 100, + "bounds": {}, + }, + ] + + +@pytest.mark.django_db +def test_api_omits_previews_while_generating( + authenticated_api_client, + layer_style_factory, + layer_frame_factory, + project, + user, +): + layer_style = layer_style_factory() + project.set_collaborators([user]) + project.datasets.set([layer_style.layer.dataset]) + frame_0 = layer_frame_factory(layer=layer_style.layer, index=0) + layer_frame_factory(layer=layer_style.layer, index=1) + + preview = RasterFramePreview.objects.create( + layer_style=layer_style, + layer_frame=frame_0, + status=PreviewStatus.COMPLETE, + width=100, + height=100, + bounds={}, + ) + preview.image.save("frame-0.png", ContentFile(b"png0"), save=True) + + resp = authenticated_api_client.get(f"/api/v1/layer-styles/{layer_style.id}/") + assert resp.status_code == 200 + data = resp.json() + assert data["preview_status"] == "generating" + assert "multiframe_previews" not in data + + +@pytest.mark.django_db +def test_layer_api_includes_multiframe_previews( + authenticated_api_client, + layer_style_factory, + layer_frame_factory, + project, + user, +): + layer_style = layer_style_factory() + layer = layer_style.layer + layer.default_style = layer_style + layer.save(update_fields=["default_style"]) + project.set_collaborators([user]) + project.datasets.set([layer.dataset]) + frame_0 = layer_frame_factory(layer=layer, index=0) + frame_1 = layer_frame_factory(layer=layer, index=1) + + preview_0 = RasterFramePreview.objects.create( + layer_style=layer_style, + layer_frame=frame_0, + status=PreviewStatus.COMPLETE, + width=100, + height=100, + bounds={}, + ) + preview_0.image.save("frame-0.png", ContentFile(b"png0"), save=True) + preview_1 = RasterFramePreview.objects.create( + layer_style=layer_style, + layer_frame=frame_1, + status=PreviewStatus.COMPLETE, + width=100, + height=100, + bounds={}, + ) + preview_1.image.save("frame-1.png", ContentFile(b"png1"), save=True) + + resp = authenticated_api_client.get(f"/api/v1/layers/{layer.id}/") + assert resp.status_code == 200 + data = resp.json() + assert data["preview_status"] == "ready" + assert data["multiframe_previews"] == [ + { + "url": preview_0.image.url, + "width": 100, + "height": 100, + "bounds": {}, + }, + { + "url": preview_1.image.url, + "width": 100, + "height": 100, + "bounds": {}, + }, + ] + assert "multiframe_previews" not in data.get("default_style", {}) diff --git a/web/src/components/sidebars/LayerStyle.vue b/web/src/components/sidebars/LayerStyle.vue index 179a5e331..a613aa7d6 100644 --- a/web/src/components/sidebars/LayerStyle.vue +++ b/web/src/components/sidebars/LayerStyle.vue @@ -585,6 +585,15 @@ const debouncedStyleSpecUpdated = debounce(() => { watch(currentStyleSpec, debouncedStyleSpecUpdated, { deep: true }); watch(() => props.activeLayer, init); + +watch( + () => props.activeLayer === props.layer, + (isEditing) => { + styleStore.setLayerStyleEditing(props.layer, isEditing); + }, + { immediate: true }, +); + onMounted(resetCurrentStyle); diff --git a/web/src/store/framePreview.ts b/web/src/store/framePreview.ts new file mode 100644 index 000000000..48fddfeae --- /dev/null +++ b/web/src/store/framePreview.ts @@ -0,0 +1,333 @@ +import { defineStore } from "pinia"; +import type { FramePreview, Layer, LayerFrame, LayerStyle } from "@/types"; +import type { Map as MaplibreMap } from "maplibre-gl"; +import { + fadeRasterOpacities, + hidePreviewLayer, + PREVIEW_FADE_DURATION_MS, + previewLayerId, + removeAllPreviewLayersForLayerKey, + removePreviewLayer, + removePreviewLayersExcept, + upsertPreviewLayer, + waitForRasterSourceLoaded, +} from "@/utils/framePreviewLayer"; +import { prefetchFramePreviewUrls } from "@/utils/framePreviewCache"; +import { useLayerStore, useMapStore, useStyleStore } from "."; + +function layerKey(layer: Layer) { + return `${layer.id}.${layer.copy_id}`; +} + +function orderedRasterFrames(frames: LayerFrame[]) { + return frames + .filter((frame) => frame.raster) + .toSorted((a, b) => a.index - b.index); +} + +function previewsForLayer(layer: Layer, style: LayerStyle | undefined) { + return style?.multiframe_previews ?? layer.multiframe_previews; +} + +function previewAtFrameIndex( + previews: (FramePreview | null)[] | undefined, + rasterFrames: LayerFrame[], + frameIndex: number, +): FramePreview | undefined { + const position = rasterFrames.findIndex( + (frame) => frame.index === frameIndex, + ); + if (position < 0) { + return undefined; + } + return previews?.[position] ?? undefined; +} + +function adjacentRasterFrames( + rasterFrames: LayerFrame[], + currentFrameIndex: number, +) { + const position = rasterFrames.findIndex( + (frame) => frame.index === currentFrameIndex, + ); + if (position < 0) { + return []; + } + return [rasterFrames[position - 1], rasterFrames[position + 1]].filter( + (frame): frame is LayerFrame => frame !== undefined, + ); +} + +export const useFramePreviewStore = defineStore("framePreview", () => { + const activePreviewByLayerKey = new Map(); + const transitionGenerationByLayerKey = new Map(); + + function bumpGeneration(layerKeyValue: string) { + const next = (transitionGenerationByLayerKey.get(layerKeyValue) ?? 0) + 1; + transitionGenerationByLayerKey.set(layerKeyValue, next); + return next; + } + + function prefetchLayerPreviews(layer: Layer, style?: LayerStyle) { + const previews = previewsForLayer(layer, style); + if (!previews?.length) { + return; + } + prefetchFramePreviewUrls(previews.map((preview) => preview?.url)); + } + + async function preloadAdjacentPreviewLayers( + map: MaplibreMap, + layerKeyValue: string, + previews: (FramePreview | null)[] | undefined, + rasterFrames: LayerFrame[], + currentFrameIndex: number, + targetOpacity: number, + ) { + const adjacentFrames = adjacentRasterFrames( + rasterFrames, + currentFrameIndex, + ); + const keepFrameIndices = [ + currentFrameIndex, + ...adjacentFrames.map((frame) => frame.index), + ]; + + removePreviewLayersExcept(map, layerKeyValue, keepFrameIndices); + + const urlsToPrefetch: (string | null | undefined)[] = []; + await Promise.all( + adjacentFrames.map(async (frame) => { + const preview = previewAtFrameIndex( + previews, + rasterFrames, + frame.index, + ); + if (!preview || !frame.raster) { + return; + } + urlsToPrefetch.push(preview.url); + await upsertPreviewLayer( + map, + layerKeyValue, + frame.index, + preview, + frame.raster.metadata, + targetOpacity, + false, + ); + }), + ); + + const currentPreview = previewAtFrameIndex( + previews, + rasterFrames, + currentFrameIndex, + ); + urlsToPrefetch.push(currentPreview?.url); + prefetchFramePreviewUrls(urlsToPrefetch); + } + + function hidePreviousPreview(map: MaplibreMap, layerKeyValue: string) { + const previousFrameIndex = activePreviewByLayerKey.get(layerKeyValue); + if (previousFrameIndex !== undefined) { + hidePreviewLayer(map, layerKeyValue, previousFrameIndex); + } + } + + async function transitionToTiles( + layer: Layer, + frameIndex: number, + layerKeyValue: string, + generation: number, + targetOpacity: number, + tileLayerId: string, + tileSourceId: string, + ) { + const mapStore = useMapStore(); + const map = mapStore.getMap(); + const previewMapLayerId = previewLayerId(layerKeyValue, frameIndex); + + if (transitionGenerationByLayerKey.get(layerKeyValue) !== generation) { + return; + } + + await waitForRasterSourceLoaded(map, tileSourceId); + + if (transitionGenerationByLayerKey.get(layerKeyValue) !== generation) { + return; + } + + if (!map.getLayer(tileLayerId) || !map.getLayer(previewMapLayerId)) { + if (map.getLayer(tileLayerId)) { + map.setPaintProperty(tileLayerId, "raster-opacity", targetOpacity); + } + removePreviewLayer(map, layerKeyValue, frameIndex); + activePreviewByLayerKey.delete(layerKeyValue); + return; + } + + const tileOpacity = + (map.getPaintProperty(tileLayerId, "raster-opacity") as number) ?? 0; + const previewOpacity = + (map.getPaintProperty(previewMapLayerId, "raster-opacity") as number) ?? + targetOpacity; + await fadeRasterOpacities( + map, + [ + { id: previewMapLayerId, from: previewOpacity, to: 0 }, + { id: tileLayerId, from: tileOpacity, to: targetOpacity }, + ], + PREVIEW_FADE_DURATION_MS, + ); + + if (transitionGenerationByLayerKey.get(layerKeyValue) !== generation) { + return; + } + + removePreviewLayer(map, layerKeyValue, frameIndex); + activePreviewByLayerKey.delete(layerKeyValue); + } + + async function showPreviewThenTiles(layer: Layer) { + const layerStore = useLayerStore(); + const mapStore = useMapStore(); + const styleStore = useStyleStore(); + if (styleStore.isLayerStyleEditing(layer)) { + return; + } + const map = mapStore.getMap(); + const layerKeyValue = layerKey(layer); + const style = styleStore.selectedLayerStyles[layerKeyValue]; + const previews = previewsForLayer(layer, style); + + const frames = layerStore.layerFrames(layer); + const rasterFrames = orderedRasterFrames(frames); + if (rasterFrames.length <= 1) { + return; + } + + const currentFrame = rasterFrames.find( + (frame) => frame.index === layer.current_frame_index, + ); + if (!currentFrame?.raster || !layer.visible) { + return; + } + + const preview = previewAtFrameIndex( + previews, + rasterFrames, + layer.current_frame_index, + ); + + const tileSourceId = mapStore.sourceIdFromLayerFrame(layer, currentFrame); + const tileLayerId = `${tileSourceId}.raster`; + const targetOpacity = style?.style_spec?.opacity ?? 1; + + const generation = bumpGeneration(layerKeyValue); + hidePreviousPreview(map, layerKeyValue); + + if (!preview) { + if (map.getLayer(tileLayerId)) { + map.setPaintProperty(tileLayerId, "raster-opacity", targetOpacity); + } + void preloadAdjacentPreviewLayers( + map, + layerKeyValue, + previews, + rasterFrames, + layer.current_frame_index, + targetOpacity, + ); + return; + } + + const previewMapLayerId = await upsertPreviewLayer( + map, + layerKeyValue, + layer.current_frame_index, + preview, + currentFrame.raster.metadata, + targetOpacity, + ); + if (!previewMapLayerId) { + if (map.getLayer(tileLayerId)) { + map.setPaintProperty(tileLayerId, "raster-opacity", targetOpacity); + } + return; + } + + activePreviewByLayerKey.set(layerKeyValue, layer.current_frame_index); + + if (map.getLayer(tileLayerId)) { + map.setPaintProperty(tileLayerId, "raster-opacity", 0); + } + + void preloadAdjacentPreviewLayers( + map, + layerKeyValue, + previews, + rasterFrames, + layer.current_frame_index, + targetOpacity, + ); + + void transitionToTiles( + layer, + layer.current_frame_index, + layerKeyValue, + generation, + targetOpacity, + tileLayerId, + tileSourceId, + ); + } + + function dismissPreviewForLayer(layer: Layer) { + const mapStore = useMapStore(); + const layerStore = useLayerStore(); + const styleStore = useStyleStore(); + const map = mapStore.getMap(); + const layerKeyValue = layerKey(layer); + + bumpGeneration(layerKeyValue); + removeAllPreviewLayersForLayerKey(map, layerKeyValue); + activePreviewByLayerKey.delete(layerKeyValue); + + const frames = layerStore.layerFrames(layer); + const currentFrame = frames.find( + (frame) => frame.index === layer.current_frame_index, + ); + if (!currentFrame?.raster) { + return; + } + + const tileLayerId = `${mapStore.sourceIdFromLayerFrame(layer, currentFrame)}.raster`; + const style = styleStore.selectedLayerStyles[layerKeyValue]; + const targetOpacity = style?.style_spec?.opacity ?? 1; + if (map.getLayer(tileLayerId)) { + map.setPaintProperty(tileLayerId, "raster-opacity", targetOpacity); + } + } + + function cleanupLayer(layer: Layer) { + const mapStore = useMapStore(); + const layerKeyValue = layerKey(layer); + transitionGenerationByLayerKey.delete(layerKeyValue); + activePreviewByLayerKey.delete(layerKeyValue); + removeAllPreviewLayersForLayerKey(mapStore.getMap(), layerKeyValue); + } + + function clearAll() { + transitionGenerationByLayerKey.clear(); + activePreviewByLayerKey.clear(); + } + + return { + prefetchLayerPreviews, + showPreviewThenTiles, + dismissPreviewForLayer, + cleanupLayer, + clearAll, + }; +}); diff --git a/web/src/store/index.ts b/web/src/store/index.ts index f78d38d5c..ea720cd28 100644 --- a/web/src/store/index.ts +++ b/web/src/store/index.ts @@ -7,6 +7,7 @@ import { useProjectStore } from "./project"; import { useStyleStore } from "./style"; import { usePanelStore } from "./panel"; import { useConversionStore } from "./conversion"; +import { useFramePreviewStore } from "./framePreview"; export { useAppStore, @@ -18,4 +19,5 @@ export { useStyleStore, usePanelStore, useConversionStore, + useFramePreviewStore, }; diff --git a/web/src/store/layer.ts b/web/src/store/layer.ts index 276d82769..11fab0087 100644 --- a/web/src/store/layer.ts +++ b/web/src/store/layer.ts @@ -22,6 +22,7 @@ import { useStyleStore, useNetworkStore, useProjectStore, + useFramePreviewStore, } from "."; interface SourceDBObjects { @@ -43,6 +44,7 @@ export const useLayerStore = defineStore("layer", () => { const networkStore = useNetworkStore(); const styleStore = useStyleStore(); const projectStore = useProjectStore(); + const framePreviewStore = useFramePreviewStore(); /** * Return the maplibre layers associated with a Layer DB object @@ -197,6 +199,10 @@ export const useLayerStore = defineStore("layer", () => { } selectedLayers.value = [newLayer, ...selectedLayers.value]; + framePreviewStore.prefetchLayerPreviews( + newLayer, + newLayer.default_style ?? undefined, + ); } watch(selectedLayers, updateLayersShown); @@ -220,6 +226,9 @@ export const useLayerStore = defineStore("layer", () => { ) { styleStore.selectedLayerStyles[styleId] = { ...layer.default_style, + ...(layer.multiframe_previews + ? { multiframe_previews: layer.multiframe_previews } + : {}), }; if ( styleStore.selectedLayerStyles[styleId]?.style_spec diff --git a/web/src/store/map.ts b/web/src/store/map.ts index d22204ace..21cf32926 100644 --- a/web/src/store/map.ts +++ b/web/src/store/map.ts @@ -22,7 +22,17 @@ import { Map, Popup } from "maplibre-gl"; import { getBasemaps, getRasterDataValues } from "@/api/rest"; import { baseURL } from "@/api/auth"; import proj4 from "proj4"; -import { useStyleStore, useLayerStore, useAppStore, useProjectStore } from "."; +import { + useStyleStore, + useLayerStore, + useAppStore, + useProjectStore, + useFramePreviewStore, +} from "."; +import { + isPreviewMapLayerId, + removeAllPreviewLayersForLayerKey, +} from "@/utils/framePreviewLayer"; function getLayerIsVisible(layer: MapLibreLayerWithMetadata) { // Since visibility must be 'visible' for a feature click to even be registered, @@ -324,12 +334,21 @@ export const useMapStore = defineStore("map", () => { function removeLayers(layerIds: string[]) { const map = getMap(); + const framePreviewStore = useFramePreviewStore(); + const cleanedLayerKeys = new Set(); // Must collect all source Ids so they can be removed after all layers // have been removed, since multple layers may use the same source const sourceIdsToRemove = new Set(); const updatedLayerIds: string[] = []; layerIds.forEach((id) => { + if (isPreviewMapLayerId(id)) { + return; + } + const layerKey = id.split(".").slice(0, 2).join("."); + if (layerKey.includes(".")) { + cleanedLayerKeys.add(layerKey); + } // Rasters have implicit bounds layers that also need to be removed if (id.includes(".raster.")) { updatedLayerIds.push(id.replace(".raster.", ".bounds.")); @@ -348,6 +367,17 @@ export const useMapStore = defineStore("map", () => { sourceIdsToRemove.forEach((id) => { map.removeSource(id); }); + + cleanedLayerKeys.forEach((layerKey) => { + removeAllPreviewLayersForLayerKey(map, layerKey); + const [layerId, copyId] = layerKey.split(".").map(Number); + const layer = layerStore.selectedLayers.find( + (candidate) => candidate.id === layerId && candidate.copy_id === copyId, + ); + if (layer) { + framePreviewStore.cleanupLayer(layer); + } + }); } /** @@ -524,9 +554,6 @@ export const useMapStore = defineStore("map", () => { ): Source | undefined { const map = getMap(); - const queryParams: { projection: string; style?: string } = { - projection: "epsg:3857", - }; const { layerId, layerCopyId } = parseSourceString(sourceId); const styleSpec = styleStore.selectedLayerStyles[`${layerId}.${layerCopyId}`].style_spec; @@ -540,22 +567,14 @@ export const useMapStore = defineStore("map", () => { (f: LayerFrame) => f.index === layer.current_frame_index, ); if (frame?.source_filters) { - filters = Object.entries(frame.source_filters).map(([k, v]) => ({ - filter_by: k, - list: [v], - include: true, - transparency: true, - apply: true, - })); + filters = styleStore.sourceFiltersToStyleFilters(frame.source_filters); } } - if (styleSpec) { - const styleParams = styleStore.getRasterTilesQuery( - { ...styleSpec, filters }, - styleStore.colormaps, - ); - if (styleParams) queryParams.style = JSON.stringify(styleParams); - } + const queryParams = styleStore.buildRasterTileQueryParams( + styleSpec ?? styleStore.getDefaultStyleSpec(raster, layerId), + filters, + styleStore.colormaps, + ); const query = new URLSearchParams(queryParams); rasterSourceTileURLs.value[sourceId] = `${baseURL}rasters/${raster.id}/tiles/{z}/{x}/{y}.png/?${query}`; diff --git a/web/src/store/project.ts b/web/src/store/project.ts index d68c13df8..c835c3088 100644 --- a/web/src/store/project.ts +++ b/web/src/store/project.ts @@ -20,7 +20,9 @@ import { usePanelStore, useAppStore, useStyleStore, + useFramePreviewStore, } from "."; +import { clearFramePreviewCache } from "@/utils/framePreviewCache"; export const useProjectStore = defineStore("project", () => { const networkStore = useNetworkStore(); @@ -278,6 +280,9 @@ export const useProjectStore = defineStore("project", () => { layerStore.selectedLayers = []; styleStore.selectedLayerStyles = {}; + styleStore.clearStyleEditing(); + useFramePreviewStore().clearAll(); + clearFramePreviewCache(); mapStore.clickedFeature = undefined; diff --git a/web/src/store/style.ts b/web/src/store/style.ts index e2e54fc38..b3396d934 100644 --- a/web/src/store/style.ts +++ b/web/src/store/style.ts @@ -22,6 +22,7 @@ import { useLayerStore, useProjectStore, useNetworkStore, + useFramePreviewStore, } from "."; export interface MapLayerStyleRaw { @@ -89,6 +90,51 @@ export function colormapMarkersSubsample( return markers; } +// frame/band select which slice to read; they must be flat query params, not in style JSON +const RASTER_SOURCE_FILTER_KEYS = new Set(["frame", "band"]); + +// Ingest defaults missing source_filters to { band: 1 }; that is not a real band selection. +function isDefaultBandSourceFilter( + sourceFilters: Record, +): boolean { + const keys = Object.keys(sourceFilters); + return ( + keys.length === 1 && + keys[0] === "band" && + (sourceFilters.band === 1 || sourceFilters.band === "1") + ); +} + +function sourceFiltersToStyleFilters( + sourceFilters: Record | undefined, +): StyleFilter[] { + if (!sourceFilters || !Object.keys(sourceFilters).length) return []; + if (isDefaultBandSourceFilter(sourceFilters)) return []; + return Object.entries(sourceFilters).map(([k, v]) => ({ + filter_by: k, + list: [v], + include: true, + transparency: true, + apply: true, + })); +} + +function getRasterSourceFilterParams(filters: StyleFilter[]) { + const params: Record = {}; + filters.forEach((f) => { + if ( + f.apply && + f.filter_by && + f.include && + f.list?.length === 1 && + RASTER_SOURCE_FILTER_KEYS.has(f.filter_by) + ) { + params[f.filter_by] = f.list[0]; + } + }); + return params; +} + function getRasterTilesQuery(styleSpec: StyleSpec, colormaps: Colormap[]) { let query: Record = {}; const colorSpecs = styleSpec.colors || []; @@ -126,13 +172,37 @@ function getRasterTilesQuery(styleSpec: StyleSpec, colormaps: Colormap[]) { } }); styleSpec.filters.forEach((f) => { - if (f.apply && f.filter_by && f.include && f.list?.length === 1) { + if ( + f.apply && + f.filter_by && + f.include && + f.list?.length === 1 && + !RASTER_SOURCE_FILTER_KEYS.has(f.filter_by) + ) { query[f.filter_by] = f.list[0]; } }); return query; } +function buildRasterTileQueryParams( + styleSpec: StyleSpec, + filters: StyleFilter[], + colormaps: Colormap[], +) { + const params: Record = { projection: "epsg:3857" }; + Object.entries(getRasterSourceFilterParams(filters)).forEach( + ([key, value]) => { + params[key] = String(value); + }, + ); + const styleQuery = getRasterTilesQuery({ ...styleSpec, filters }, colormaps); + if (Object.keys(styleQuery).length) { + params.style = JSON.stringify(styleQuery); + } + return params; +} + function getVectorColorPaintProperty( styleSpec: StyleSpec, groupName: string, @@ -343,11 +413,39 @@ function getVectorVisibilityPaintProperty( export const useStyleStore = defineStore("style", () => { const selectedLayerStyles = ref>({}); const colormaps = ref([]); + const editingStyleLayerKeys = ref>(new Set()); const mapStore = useMapStore(); const projectStore = useProjectStore(); const layerStore = useLayerStore(); const networkStore = useNetworkStore(); + const framePreviewStore = useFramePreviewStore(); + + function layerStyleKey(layer: Layer) { + return `${layer.id}.${layer.copy_id}`; + } + + function isLayerStyleEditing(layer: Layer) { + return editingStyleLayerKeys.value.has(layerStyleKey(layer)); + } + + function setLayerStyleEditing(layer: Layer, editing: boolean) { + const key = layerStyleKey(layer); + const next = new Set(editingStyleLayerKeys.value); + if (editing) { + next.add(key); + editingStyleLayerKeys.value = next; + framePreviewStore.dismissPreviewForLayer(layer); + return; + } + next.delete(key); + editingStyleLayerKeys.value = next; + updateLayerStyles(layer); + } + + function clearStyleEditing() { + editingStyleLayerKeys.value = new Set(); + } function getDefaultColor(layerId: number) { const color = chroma.hsl( @@ -423,6 +521,12 @@ export const useStyleStore = defineStore("style", () => { } }); networkStore.styleVisibleNetworks(); + + const hasMultiframeRaster = + frames.length > 1 && frames.some((f) => f.raster); + if (hasMultiframeRaster && !isLayerStyleEditing(layer)) { + void framePreviewStore.showPreviewThenTiles(layer); + } } type GeneratedLayerStyle = { @@ -441,13 +545,7 @@ export const useStyleStore = defineStore("style", () => { if (frame?.source_filters) { filters = [ ...filters, - ...Object.entries(frame.source_filters).map(([k, v]) => ({ - filter_by: k, - list: [v], - include: true, - transparency: true, - apply: true, - })), + ...sourceFiltersToStyleFilters(frame.source_filters), ]; } const mapLayer = map.getLayer(mapLayerId) as @@ -550,11 +648,13 @@ export const useStyleStore = defineStore("style", () => { const source = map.getSource(mapLayer.source) as RasterTileSource; const sourceURL = mapStore.rasterSourceTileURLs[mapLayer.source]; if (source && sourceURL) { - const newQueryParams: { projection: string; style?: string } = { - projection: "epsg:3857", - }; - newQueryParams.style = JSON.stringify(rasterTilesQuery); - const newQuery = new URLSearchParams(newQueryParams); + const newQuery = new URLSearchParams( + buildRasterTileQueryParams( + { ...styleSpec, filters }, + filters, + colormaps.value, + ), + ); tileURL = sourceURL.split("?")[0] + "?" + newQuery.toString(); return { paint, tileURL }; } @@ -603,12 +703,19 @@ export const useStyleStore = defineStore("style", () => { return { colormaps, selectedLayerStyles, + editingStyleLayerKeys, fetchColormaps, getRasterTilesQuery, + getRasterSourceFilterParams, + buildRasterTileQueryParams, + sourceFiltersToStyleFilters, colormapMarkersSubsample, getDefaultColor, getDefaultStyleSpec, getVectorColorPaintProperty, + isLayerStyleEditing, + setLayerStyleEditing, + clearStyleEditing, updateLayerStyles, setMapLayerStyle, returnMapLayerStyle, diff --git a/web/src/types.ts b/web/src/types.ts index 9cbeee9be..dc3a68045 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -28,6 +28,30 @@ export interface Dataset { n_layers?: number; } +export interface FramePreviewCorner { + x: number; + y: number; +} + +export interface FramePreviewBounds { + srs: string; + xmin: number; + xmax: number; + ymin: number; + ymax: number; + ul?: FramePreviewCorner; + ur?: FramePreviewCorner; + lr?: FramePreviewCorner; + ll?: FramePreviewCorner; +} + +export interface FramePreview { + url: string; + width: number; + height: number; + bounds: FramePreviewBounds; +} + export interface Layer { id: number; copy_id: number; @@ -38,6 +62,7 @@ export interface Layer { visible: boolean; current_frame_index: number; default_style: LayerStyle | null; + multiframe_previews?: (FramePreview | null)[]; } export interface LayerFrame { @@ -128,6 +153,7 @@ export interface LayerStyle { project?: number; is_default: boolean; style_spec?: StyleSpec; + multiframe_previews?: (FramePreview | null)[]; } export interface VectorData { diff --git a/web/src/utils/framePreviewCache.ts b/web/src/utils/framePreviewCache.ts new file mode 100644 index 000000000..5598c8cd3 --- /dev/null +++ b/web/src/utils/framePreviewCache.ts @@ -0,0 +1,21 @@ +const prefetchedUrls = new Set(); + +export async function getCachedPreviewObjectUrl( + url: string, +): Promise { + return url; +} + +export function prefetchFramePreviewUrls(urls: (string | null | undefined)[]) { + urls.forEach((url) => { + if (!url || prefetchedUrls.has(url)) { + return; + } + prefetchedUrls.add(url); + void fetch(url); + }); +} + +export function clearFramePreviewCache() { + prefetchedUrls.clear(); +} diff --git a/web/src/utils/framePreviewLayer.ts b/web/src/utils/framePreviewLayer.ts new file mode 100644 index 000000000..386af43cd --- /dev/null +++ b/web/src/utils/framePreviewLayer.ts @@ -0,0 +1,299 @@ +import type { + FramePreview, + FramePreviewBounds, + FramePreviewCorner, + RasterMetadata, +} from "@/types"; +import type { Map, MapSourceDataEvent } from "maplibre-gl"; +import proj4 from "proj4"; +import { getCachedPreviewObjectUrl } from "./framePreviewCache"; + +export const PREVIEW_FADE_DURATION_MS = 400; + +const CORNER_KEYS = ["ul", "ur", "lr", "ll"] as const; +type CornerKey = (typeof CORNER_KEYS)[number]; + +function clampRasterOpacity(value: number): number { + return Math.min(1, Math.max(0, value)); +} + +export function previewSourceId(layerKey: string, frameIndex: number) { + return `${layerKey}.preview.${frameIndex}`; +} + +export function previewLayerId(layerKey: string, frameIndex: number) { + return `${previewSourceId(layerKey, frameIndex)}.raster`; +} + +export function isPreviewMapLayerId(mapLayerId: string) { + return mapLayerId.includes(".preview."); +} + +function toLngLat(srs: string, x: number, y: number): [number, number] { + if (srs && srs !== "EPSG:4326") { + return proj4(srs, "EPSG:4326", [x, y]) as [number, number]; + } + return [x, y]; +} + +function hasCornerBounds( + bounds: FramePreviewBounds, +): bounds is FramePreviewBounds & Record { + return CORNER_KEYS.every((corner) => bounds[corner] !== undefined); +} + +function cornersFromRasterMetadata( + rasterBounds: RasterMetadata["bounds"], +): FramePreviewBounds | undefined { + if (!CORNER_KEYS.every((corner) => rasterBounds[corner])) { + return undefined; + } + + const corners = Object.fromEntries( + CORNER_KEYS.map((corner) => { + const point = rasterBounds[corner]!; + const [x, y] = toLngLat(rasterBounds.srs, point.x, point.y); + return [corner, { x, y }]; + }), + ) as Record; + + const lngs = CORNER_KEYS.map((corner) => corners[corner].x); + const lats = CORNER_KEYS.map((corner) => corners[corner].y); + + return { + srs: "EPSG:4326", + xmin: Math.min(...lngs), + xmax: Math.max(...lngs), + ymin: Math.min(...lats), + ymax: Math.max(...lats), + ...corners, + }; +} + +export function resolvePreviewBounds( + preview: FramePreview, + raster?: RasterMetadata, +): FramePreviewBounds { + if (hasCornerBounds(preview.bounds)) { + return preview.bounds; + } + + if (raster?.bounds) { + const rasterCorners = cornersFromRasterMetadata(raster.bounds); + if (rasterCorners) { + return rasterCorners; + } + } + + return preview.bounds; +} + +function boundsToCoordinates( + bounds: FramePreviewBounds, +): [[number, number], [number, number], [number, number], [number, number]] { + if (hasCornerBounds(bounds)) { + return [ + toLngLat(bounds.srs, bounds.ul.x, bounds.ul.y), + toLngLat(bounds.srs, bounds.ur.x, bounds.ur.y), + toLngLat(bounds.srs, bounds.lr.x, bounds.lr.y), + toLngLat(bounds.srs, bounds.ll.x, bounds.ll.y), + ]; + } + + const { xmin, xmax, ymin, ymax, srs } = bounds; + return [ + toLngLat(srs, xmin, ymax), + toLngLat(srs, xmax, ymax), + toLngLat(srs, xmax, ymin), + toLngLat(srs, xmin, ymin), + ]; +} + +export async function upsertPreviewLayer( + map: Map, + layerKey: string, + frameIndex: number, + preview: FramePreview, + raster?: RasterMetadata, + opacity = 1, + visible = true, +) { + const sourceId = previewSourceId(layerKey, frameIndex); + const mapLayerId = previewLayerId(layerKey, frameIndex); + const objectUrl = await getCachedPreviewObjectUrl(preview.url); + if (!objectUrl) { + return undefined; + } + + const coordinates = boundsToCoordinates( + resolvePreviewBounds(preview, raster), + ); + const existingSource = map.getSource(sourceId); + if (existingSource) { + if (map.getLayer(mapLayerId)) { + map.removeLayer(mapLayerId); + } + map.removeSource(sourceId); + } + map.addSource(sourceId, { + type: "image", + url: objectUrl, + coordinates, + }); + + const previewOpacity = clampRasterOpacity(opacity); + const visibility = visible ? "visible" : "none"; + + if (!map.getLayer(mapLayerId)) { + map.addLayer({ + id: mapLayerId, + type: "raster", + source: sourceId, + layout: { + visibility, + }, + paint: { + "raster-opacity": previewOpacity, + "raster-fade-duration": 0, + }, + }); + } else { + map.setPaintProperty(mapLayerId, "raster-opacity", previewOpacity); + map.setLayoutProperty(mapLayerId, "visibility", visibility); + } + + return mapLayerId; +} + +export function hidePreviewLayer( + map: Map, + layerKey: string, + frameIndex: number, +) { + const mapLayerId = previewLayerId(layerKey, frameIndex); + if (map.getLayer(mapLayerId)) { + map.setLayoutProperty(mapLayerId, "visibility", "none"); + } +} + +export function removePreviewLayer( + map: Map, + layerKey: string, + frameIndex: number, +) { + const sourceId = previewSourceId(layerKey, frameIndex); + const mapLayerId = previewLayerId(layerKey, frameIndex); + if (map.getLayer(mapLayerId)) { + map.removeLayer(mapLayerId); + } + if (map.getSource(sourceId)) { + map.removeSource(sourceId); + } +} + +function previewFrameIndexFromLayerId( + layerKey: string, + mapLayerId: string, +): number | undefined { + const prefix = `${layerKey}.preview.`; + if (!mapLayerId.startsWith(prefix)) { + return undefined; + } + const rest = mapLayerId.slice(prefix.length); + const frameIndex = Number.parseInt(rest.split(".")[0], 10); + return Number.isNaN(frameIndex) ? undefined : frameIndex; +} + +export function removePreviewLayersExcept( + map: Map, + layerKey: string, + keepFrameIndices: number[], +) { + const keep = new Set(keepFrameIndices); + const frameIndicesToRemove = new Set(); + + map.getStyle().layers?.forEach((layer) => { + const frameIndex = previewFrameIndexFromLayerId(layerKey, layer.id); + if (frameIndex !== undefined && !keep.has(frameIndex)) { + frameIndicesToRemove.add(frameIndex); + } + }); + + frameIndicesToRemove.forEach((frameIndex) => { + removePreviewLayer(map, layerKey, frameIndex); + }); +} + +export function removeAllPreviewLayersForLayerKey(map: Map, layerKey: string) { + map.getStyle().layers?.forEach((layer) => { + if (layer.id.startsWith(`${layerKey}.preview.`)) { + map.removeLayer(layer.id); + } + }); + Object.keys(map.getStyle().sources ?? {}).forEach((sourceId) => { + if (sourceId.startsWith(`${layerKey}.preview.`)) { + map.removeSource(sourceId); + } + }); +} + +export function waitForRasterSourceLoaded( + map: Map, + sourceId: string, + timeoutMs = 10000, +): Promise { + return new Promise((resolve) => { + if (!map.getSource(sourceId)) { + resolve(); + return; + } + if (map.isSourceLoaded(sourceId)) { + resolve(); + return; + } + + const timeout = window.setTimeout(() => { + map.off("sourcedata", onSourceData); + resolve(); + }, timeoutMs); + + function onSourceData(event: MapSourceDataEvent) { + if (event.sourceId === sourceId && event.isSourceLoaded) { + window.clearTimeout(timeout); + map.off("sourcedata", onSourceData); + resolve(); + } + } + + map.on("sourcedata", onSourceData); + }); +} + +export async function fadeRasterOpacities( + map: Map, + layers: { id: string; from: number; to: number }[], + durationMs: number, +) { + const start = performance.now(); + await new Promise((resolve) => { + function step(now: number) { + const progress = Math.min(1, (now - start) / durationMs); + layers.forEach(({ id, from, to }) => { + if (map.getLayer(id)) { + const opacity = progress >= 1 ? to : from + (to - from) * progress; + map.setPaintProperty( + id, + "raster-opacity", + clampRasterOpacity(opacity), + ); + } + }); + if (progress < 1) { + requestAnimationFrame(step); + } else { + resolve(); + } + } + requestAnimationFrame(step); + }); +}