Skip to content

Multiframe scrubbing improvements#397

Open
BryonLewis wants to merge 42 commits into
masterfrom
multiframe-scrubbing-improvements
Open

Multiframe scrubbing improvements#397
BryonLewis wants to merge 42 commits into
masterfrom
multiframe-scrubbing-improvements

Conversation

@BryonLewis

@BryonLewis BryonLewis commented Jun 5, 2026

Copy link
Copy Markdown
Collaborator

Resolves #126

Summary

Improves multiframe raster scrubbing by fixing RGB detection for sequential rasters, adding server-generated frame preview images, and crossfading from preview to tiles on the client for smoother frame transitions.

Done

Sentinel downloader (scripts/sentinelDownload/)

  • Supports creating multiframe single-image Sentinel raster datasets via --single-file (uses gdal_translate to append frames as subdatasets)
  • Additional CLI arguments for date range, cloud cover, output naming, and clip size
  • Generates an ingest JSON alongside clipped GeoTIFFs for direct import into Geoodatalytics

Dataset / RGB fixes

  • Default band:1 injection removed when no source_filter is supplied — this was preventing Boston ortho imagery from being recognized as RGB
  • band:1 is still applied when explicitly defined in the style spec
  • Multiframe rasters no longer render as grayscaleframe and band are extracted from the style JSON and passed as separate query parameters (outside style) so django-large-image can auto-detect RGB imagery

RasterFramePreview model

  • Foreign keys to LayerStyle and LayerFrame (unique per style/frame pair)
  • Stores width, height, georeferenced bounds, and an S3 preview image
  • Lifecycle status: creating | regenerating | complete | failed
  • style_fingerprint (sha256 of repr_style_configs()) guards against stale concurrent generation
  • Cascades on frame or layer style deletion; S3/MinIO object deleted via post_delete signal

uvdat/core/frame_previews/

  • raster_style.py - Builds django-large-image–compatible style objects from LayerStyle DB records; keeps frame/band outside style
  • preview_regeneration.py - Marks previews stale, supersedes pending tasks, enqueues Celery regeneration on style save/ingest
  • fingerprint.py - Computes style fingerprint for concurrency control
  • types.py - Shared FramePreviewData type

uvdat/core/tasks/frame_preview.py

  • Celery task generates per-frame preview PNGs via django-large-image thumbnails
  • Uses raster_style.py for styling; scales resolution to keep previews between ~1k–4k
  • Writes RasterFramePreview rows with bounds and dimensions
  • Updates TaskResult on completion (used for future client notification)

Preview regeneration on style save

  • LayerStyle create/update hooks call invalidate_and_enqueue_previews()
  • checks fingerprint hash of the style to determine if new previews need to be regenerated
  • Existing preview images are cleared immediately; rows marked regenerating
  • Ingest command also triggers preview generation for multiframe raster styles
  • Only status=complete previews with an image are serialized to the API

API

  • /layers/ and /layer-styles/ return multiframe_previews — ordered list of { bounds, width, height, url } per raster frame (presigned S3 URLs)
  • preview_status aggregated on layer and layer-style: ready | generating | notready (omitted when N/A)
  • Queryset annotations avoid N+1 Python aggregation

Tests

  • uvdat/core/tests/test_frame_preview.py — model serialization, API fields, regeneration hooks, fingerprint guards, raster style helpers

Frame Preview Logic

Loading Frame Preview Flow

  1. User changes frame -> layerStore.updateLayersShown
  2. styleStore.updateLayerStyles
  3. framePreviewStore.showPreviewThenTiles
    • Hide tile layer (raster-opacity: 0)
    • Show georeferenced preview image
    • Wait for tile source to finish loading
    • Crossfade preview → tiles once loaded
    • Preload adjacent frames

web/src/utils/framePreviewLayer.ts Functions

  • upsertPreviewLayer — fetch preview URL, add/update MapLibre source + raster layer
  • hidePreviewLayer — set visibility to none during transition
  • removePreviewLayer / removePreviewLayersExcept — cleanup; keep adjacent preloaded frames
  • waitForRasterSourceLoaded — poll until tile source is ready (with timeout)
  • fadeRasterOpacities — animated crossfade between preview and tile layers
    web/src/store/framePreview.ts Functions
  • prefetchLayerPreviews — preload all preview images for a layer
  • showPreviewThenTiles — main preview → tile transition orchestration
  • dismissPreviewForLayer — skip previews while editing layer styles; restore tile opacity

Style editing integration

  • styleStore.setLayerStyleEditing dismisses previews when entering style edit mode
  • Previews re-enabled via updateLayerStyles after exiting edit mode
  • LayerStyle.vue watches active layer to toggle editing state

Creating/Editing and Saving Style Flow

  1. User finishes editing and clicks Create Style or Save in LayerStyle.vue
  2. 1createLayerStyle1 (new) or 1updateLayerStyle1 (existing) -> POST/PATCH /api/v1/layer-styles/
  3. Backend (LayerStyleSerializer.create / .update)
    1. inside those above functions it calls invalidate_and_enqueue_previews for multifram rasters only
      1. This function starts by computing style_fingerprint - hash for comparing to see if the style changed
      2. previews_current_for_fingerprint - if the fingerprint is the same (same style) don't invalidate and generate new previews, instead just return.
      3. Marks all the existing frame previews as regenerating/creating to indicate they are stale
      4. If there are any pending preview tasks it will mark them as superseceded to prevent updating the previews for this style
      5. Create a TaskResult for generating the new previews then execute the task
    2. return back to the client the new layerstyle with the preview_status of not ready to indicate that the previews are being created.
  4. Client Side Response
    1. markStyleSavedAndInvalidatePreviews(style) - clear the existing previews for the style
    2. Apply saved style to styleStore.selectedLayerStyles with multiframe_previews: undefined
    3. framePreviewStore.dismissPreviewForLayer
      1. Bump transition generation (cancels in-flight crossfades)
      2. Remove all preview MapLibre layers
      3. Restore tile layer opacity, user sees tiles styled with the new spec
        refreshLayer() -> layerStore.fetchAvailableLayer (refreshes layer metadata if default changed)
    4. While the user still has the editor window open
      • showPreviewThenStyles is skipped so they see the style that is being edited and not previews
  5. The generating previews task is async, when complete there is a websocket notification of framePreview that lets the client know that new previews are available
    1. framePreviewStore.onPreviewTaskComplete(task)
    2. getLayerStyle(layer_style_id) - refetch style with preview_status: "ready" + multiframe_previews
    3. Update selectedLayerStyles for every selected copy still using that style
    4. Mirror onto layer object if style is default
    5. prefetchLayerPreviews - warm image URLs
    6. If not in style edit mode: showPreviewThenTiles -> preview overlay + crossfade back to tiles

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jun 5, 2026

Copy link
Copy Markdown

Deploying geodatalytics with  Cloudflare Pages  Cloudflare Pages

Latest commit: 3cbb2a0
Status: ✅  Deploy successful!
Preview URL: https://550b87fe.geodatalytics.pages.dev
Branch Preview URL: https://multiframe-scrubbing-improve.geodatalytics.pages.dev

View logs

@BryonLewis
BryonLewis force-pushed the multiframe-scrubbing-improvements branch from bb34a8b to aa64e06 Compare June 5, 2026 14:41
@BryonLewis
BryonLewis marked this pull request as ready for review July 9, 2026 18:30
@BryonLewis

Copy link
Copy Markdown
Collaborator Author

be able to extract the opacity and default frame when calculating the style fingerprint.

@BryonLewis

Copy link
Copy Markdown
Collaborator Author

add in sorting to the dictionary for the styles to resolve any ordering of hashes.

@annehaley annehaley left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since this is a large PR, I'll do my reviews in two rounds. This first round is just from reading the diff. After these comments are addressed I'll do a second round where I try it out locally and can comment on the behavior.

Comment thread uvdat/core/frame_previews/preview_regeneration.py
Comment thread uvdat/core/frame_previews/fingerprint.py
Comment thread uvdat/core/frame_previews/preview_regeneration.py Outdated
Comment thread uvdat/core/frame_previews/preview_regeneration.py Outdated
Comment on lines +11 to +17
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)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see that this file is copying logic that previously only existed client-side. It makes sense that we need it on the server now too. But can we avoid having the same logic duplicated in two places (with two different languages)? That makes it harder to maintain. Maybe we could add an API endpoint on the style viewset that can provide the query string for a given style and use that result on the client side?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What we have now:

  1. The client side has a query builder for large-image rasters so it can take colormaps/filters and create a preview while editing
  2. The internal storage of these colormaps and filters is stored in a separate data structure which is passed to the server when you save a style
  3. This JSON style specification is broken into pieces and saved as ColormapConfig, SizeConfig, FilterConfig and others

So when we request a style from the server for the client it needs to take the style JSON and build back up the query params for raster large-images.

Should during saving should we also save the large-image query params that the client has already created. The previews then can use that and break it up into it's params for large-image instead of trying to re-interpret the ColormapConfig and FilterConfig and generate the query params?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, that idea is cleaner. We can add a tile_query field to the LayerStyle model (nullable, since vector styles won't need it). Then we don't need any new logic on the server side to convert the db representation of the style into a query.

Comment on lines +24 to +28
# Set while running in a context (e.g. `manage.py ingest`) where TaskResult
# WebSocket notifications are meaningless: tasks run synchronously with no
# client session listening. When set, result_post_save skips the push entirely
# rather than attempting it and logging a warning.
_suppress_notifications: ContextVar[bool] = ContextVar("suppress_task_notifications", default=False)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is an interesting addition that I had never considered. I do think it's useful, but I think we should be applying this context differently. Right now, I see that there's one place where you've applied this context, in ingest.py:

with suppress_task_notifications():
    invalidate_and_enqueue_previews(style, asynchronous=False)

If we do end up moving the preview generation to the dataset conversion task (see earlier comment), we can't always apply this context. But we can apply it if asynchronous=False. My suggestion is that we add the with suppress_task_notifications(): line inside invalidate_and_enqueue_previews right before we call .apply().

In fact, we can do that in all three places where we run a task synchronously. If you search the code for .apply(, you can add the context wrapper around all three places, which includes the convert_chart and convert_dataset tasks. This should make the suppression behavior more consistent for all synchronous tasks.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added the suppression to other locations that use .apply and have a sync and async path. I'll be moving the invalidate_and_enqueue_previews out of the ingest.py and into the dataset conversion process in a future PR.
This is the pr that has the base changes: c4525bd

Comment on lines +47 to +48
for layer in Layer.objects.filter(dataset=dataset, default_style__isnull=True):
layer.ensure_default_style()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm concerned about having a db-write side effect within a get endpoint. Is there anywhere else we could put this statement to ensure that layers have a default style? Same goes for the ensure_default_style call in uvdat/core/rest/layer.py.

Your comment about ensure_default_style explains that a layer can be left without a default style if its existing default style is deleted, so maybe we can just put that function call in a delete signal?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is an oversight on my part. I need to do a bit more thinking and it relates to the other questiona bout having a default_style LayerStyle object for use with the previews.
I haven't made a decision but I'm weighing the options

  • Move the creation of the frame-previews and the default style for multiframe raster into the dataset ingestion task script
    • additional write a migration that will create a layerstyle for any multiframe rasters that don't have one already
  • Accept the case of not having a default_style and having the logic to handle this and properly generate one if none exists (probably again in a migration).

Comment on lines +194 to +196
if style is None:
style = LayerStyle.objects.create(name="Default", layer=layer, project=project)
style.save_style_configs(DEFAULT_MULTIFRAME_RASTER_STYLE_SPEC)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure I fully understand why we need to create default style objects. The current behavior allows for the absence of any created styles on a layer; in that case, raster layers will just use the large-image defaults. Is there a reason we can no longer use that pattern? If it's only for the foreign key relationship on the preview, we could just allow that field to be null.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it keeps some of the logic simpler if we have an explicit default layer style after data is ingested.

The preview system is essentially keyed on the LayerStyle. We can change it but we would need to add logic branches to handle fingerprinting, task invalidation, the preview_status in the HTTP response. I.E. conditionals of if layer.default_style is None: then do a slightly different thing using DEFAULT_MULTIFRAME_RASTER_STYLE_SPEC

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With your current approach, we need to ensure that every layer has a default style created. We only need them to exist for raster layers, but for the sake of consistency we make them for every layer. You've added this step in the ingest process, and you also ensure that step is completed for already-existing layers. You currently do this in the endpoint where we get all layers for a target dataset, and I've noted in my previous comment that I don't think we should do that in a get request. If you can find another place to do that, I think your approach is acceptable, but design-wise I still think a None style makes more sense.

How much more complex would it be to support a None style in those places you mentioned, compared to creating these default style objects? They seem equally complex to me.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I completely agree with moving the creation out of the GET endpoint comments (that was an oversight on my part).

I think it would require that within the layer need some default Query URL (like in the other comment) for default_style and possible a fingerprint style spec for checking to make sure we really need to create new empty previews on save.

  • Then in the code it would need changes, mostly more arguments because I access the layer a lot through the layer_style.layer:
    • invalidate_and_enqueue_previews - the layer_style passed in could be null meaning that the fingerprint would be some default fingerprint. So we need to pass in the Layer as well to this function because before we would access the layer by using the connected layer_style.layer
    • When mark_previews_regenerating it would have the new layer and the layer_style (which could be null) and create RasterFramePreview objects where the layer_style foreign key is null.
    • clear_style_preview_instance_cache would also need the Layer and the layer_style passed in the function to remove the previous cached prefetched results
    • supersede_pending_preview_tasks would need the layer id in addition to the layer_style_id and the inputs for the task for the TaskResult would require the layer_id as well as the layer_style_id to be added to invalidate any previous tasks creating a style for the default style
    • when passing into generate_layer_style_previews (the task for creating the previews) the layer_id would have to be added along with the layer_style id.
  • Inside of the preview generation task:
    • the _PreviewGenerationContext would now need the layer_id as well as the layer_style_id
    • in the creation of a frame preview _process_frame_preview it would now use a git based on the layer_id, the layer_style (null being valid) and the frame for replacing/creating a new Preview object
  • Access for REST Responses:
    • preview_status_for_style(layer_style) - this was used before to determine if previews were ready for a style. I may need a whole seprate function of preview_status_for_empty_style(layer_id) that would check the RasterFramePreviews that match the layer_id and see if they are all ready for preview. In the original function layer_styles now had a foreign key to layer_style so I could use layer_style.frame_previews and see if they were all ready. Now I think I need to update this function to use the style_previews (foreign key into layerFrame). So this seprate function would take a layer -> get the layer_frames for it and then filter RasterFramePreviews for any matching LayerFrame Ids that have a null layer_style set.
    • The Layer serializer itself would return None if the obj.defaul_style_id is None. So instead we need to get the layer_frames from the layer, then filter to see what RasterFramePreviews have a null layer style and match the layer_frame for the null case of default style and return them as the previews. Again we can't utilize the foreign key of layer_style.frame_previews
    • There may be some abstraction that can be done in the two above functions to create a single way to take a layer with no default style and return the frame previews to check their status or return their presigned URLS for serialization I just thought about it after walking through the logic.
  • One thing I didn't consider is that I assume the empty default_layer_style for the raster frame previews just sticks around and is never deleted until the layer itself is deleted. That way if the user ever deletes the styles it would have this set of styles to fall back on.

That's a rough 15-20 minute analysis of what may need to change with a None default_style. Most of it has to do relying on the foreign key relationships between LayerStyle and RasterFramePreview (layer_style.frame_previews) being broken as well as utillizing layer_style.layer for reverse lookup of the layer.

if result_id is None:
return None

result = TaskResult.objects.filter(id=result_id).first()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just use TaskResult.objects.get(id=result_id)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm using the filter there to prevent a DoesNotExist error that would happen with get where instead I want None. When the result is None it means that the task result has been superseded by another task. I.E while generating previews a user chained their mind so it started another task for generating previews.

I could update it to use try block with catching DoesNotExist. I could also add a comment as to why I'm using filter here.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok that makes sense. A comment would be sufficient, then.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

added a descriptivie comment in: 515223e

Comment on lines +264 to +266
const layerStore = useLayerStore();
const mapStore = useMapStore();
const styleStore = useStyleStore();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For these other stores that you use in this store's functions, just get them all at the top of the store definition. That's what we do in all the other stores.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Layer Frame Preloading

2 participants