Skip to content

Add basic interferogram+coherence workflow, v1 of web UI, isce2 comparison notebook#152

Open
scottstanie wants to merge 34 commits into
isce-framework:mainfrom
scottstanie:add-simple-ifg-unwrap
Open

Add basic interferogram+coherence workflow, v1 of web UI, isce2 comparison notebook#152
scottstanie wants to merge 34 commits into
isce-framework:mainfrom
scottstanie:add-simple-ifg-unwrap

Conversation

@scottstanie
Copy link
Copy Markdown
Member

@scottstanie scottstanie commented Jun 3, 2026

  • Interferogram workflow (IfgWorkflow) — a self-contained pipeline for users who want multilooked interferograms + coherence without the full dolphin displacement stack. Five steps: OPERA CSLC download → geometry → crossmul → stitch/burst-align → unwrap. Controllable via starting_step for restarts.

  • Crossmul engine (_crossmul.py) — blockwise interferogram formation with boxcar or Gaussian multilooking. Gaussian path uses JAX strided separable convolution (~33× faster than scipy filter+decimate for 10×40 looks). Complex IFG is saved before wrapping so GDAL can interpolate real/imag during stitching without crossing ±π discontinuities.

  • Burst alignment (_burst_alignment.py) — ported from dolphin; estimates and removes inter-burst phase offsets at seams via weighted-median LSQ, applied as a polynomial correction.

  • React web UI — replaces the previous scaffold with a working single-page app: AOI map, track picker, burst-ID selector, workflow-type toggle (displacement vs interferogram), live step progress bar, and results viewer. Backed by FastAPI + WebSocket job executor.

  • JupyterHub notebook — docs/ifg_workflow.ipynb walks through the full IFG workflow interactively, suitable for running on a shared JupyterHub against a real OPERA frame.

  • Tests — 52 new unit + integration tests covering _crossmul (geotransform origin, non-divisible truncation, phase sign convention, overwrite semantics) and _burst_alignment (LSQ solver, weighted-median, polynomial correction, end-to-end seam recovery).

  • Bug fixes — stale wrapped phase on overwrite re-run (P1), geotransform origin shift after multilooking (P1), non-divisible input truncation in Gaussian path (P2).

scottstanie and others added 30 commits May 24, 2026 13:33
Add a FastAPI backend + React/Vite/Leaflet frontend under src/sweets/web/
with four high-leverage features:

- Search: query CMR/ASF for OPERA CSLC bursts, S1 SAFE bursts, or NISAR
  GSLC frames via /api/search; map overlays the granule polygons and
  runs the same missing-data filter sweets uses on downloads so the
  user sees which bursts will actually make it through.
- Config: /api/schema serves Workflow.model_json_schema() and the form
  is auto-generated via @rjsf/core - every Workflow/Dolphin/Tropo field
  shows up without hand-written form code.
- Monitor: /api/jobs/{id}/start spawns 'sweets run' as a subprocess,
  /api/ws/jobs/{id}/logs streams the logs over a WebSocket, and the
  step bar ticks through download -> geocode -> ifg -> stitch -> unwrap.
- View: /api/jobs/{id}/view shells 'bowser setup-dolphin <work_dir>'
  and (optionally) starts 'bowser serve' so the user lands in the
  mature viewer rather than a from-scratch results UI.

Launch with 'pixi run -e web sweets-server' for the backend and
'pixi run -e web web-dev' for the Vite frontend (proxies /api to :8000).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Add four screenshots under docs/web/screenshots/ (search-empty,
  search-results-with-coverage, config-form, jobs-empty) and reference
  them from src/sweets/web/README.md.
- Move the /api/health route registration above the static / mount; the
  StaticFiles catch-all was shadowing /api/health (no other route was
  affected, but the order is brittle either way).
- Nest <input> elements inside <label> in SearchPanel for proper a11y
  association - also lets Playwright's get_by_label drive the form.
- Lock the npm install graph via package-lock.json.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
UI iteration on the web-ui-react branch driven by Scott's feedback:

- Search: surface `flight_direction` on each granule feature and add a
  `tracks` summary to /api/search. The sidebar now shows a chip per
  (track, ASC/DESC) pair with the granule count; clicking a chip re-runs
  the search narrowed to that single track (sweets processes one per job).
- Search: expandable Granules list, plus CSV and `wget -i`-style URL list
  exports.
- Map: keep the dashed AOI rectangle on top of the burst overlay and
  include it in fitBounds so the user always sees their requested area.
- Layout v2: top nav with tabs (Search / Config / Jobs); Config tab now
  takes the full viewport instead of being crammed into the 380px
  sidebar. Search and Jobs keep the sidebar+map split.
- Config form: trimmed to the basic Workflow knobs by default (work_dir,
  slc_posting, pol_type, gpu_enabled, n_workers, threads_per_worker,
  overwrite). "Show advanced" toggles the nested dolphin / tropo subforms
  back in. Rewrites JSON Schema 2020-12 `prefixItems` to `items` so
  pydantic-emitted tuple fields like `slc_posting` render as proper
  number-pair inputs instead of an "Unsupported field schema" error.
- Screenshots refreshed; README rewritten around the new flows.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- New scripts/web_screenshots.py: builds the frontend, boots a temporary
  uvicorn server on a free port, drives Playwright through every demo
  flow (search, track-narrow, granule list, basic config, advanced
  dolphin + tropo sections, jobs), kills the server. Idempotent — writes
  PNGs into docs/web/screenshots/ in place.
- pixi `web-screenshots` task wired up; `playwright` added to the web
  feature so `pixi install -e web` pulls it in. Chromium binary still
  needs a one-time `playwright install chromium`.
- README: new "Refreshing the screenshots" section documenting the
  cadence (after any UI change), with the one-time setup + the refresh
  command. README also now embeds 03b-config-advanced-dolphin.png and
  03c-config-advanced-tropo.png — the advanced view does render fine,
  it was just clipped by the .content.full overflow in earlier captures.

The dolphin + tropo subforms (DolphinOptions / TropoOptions pydantic
models) come through cleanly under "Show advanced": half-window, strides,
ministack size, unwrap method, snaphu cost, tropo enable/height-max/
interp-method, etc.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Ran an actual sweets pipeline end-to-end through the UI:
- Source: OPERA CSLC, track 71
- Burst IDs: t071_151228_iw2 + t071_151228_iw3 (2 bursts, full IW2/IW3 pair)
- Time range: 2024-01-01 to 2024-04-01 (3 months, 8 dates per burst)
- n_workers=3, threads_per_worker=2
- Bbox: (-118.30, 34.10, -118.28, 34.12) — tight LA AOI
- Wall time: 3m 36s on a 2024 M-series MacBook
- Output: 16 OPERA CSLCs + DEM + watermask + stitched geometry +
  21 unwrapped interferograms + 7 timeseries pairs + velocity.tif

Two new screenshots captured during / after the run:
- 04b-jobs-running.png: step 1 (download) pulsing, WS log tail
  streaming sardem + OPERA fetch messages, manifest already populating.
- 04c-jobs-completed.png: all five step segments lit blue, status
  green COMPLETED, full 86-entry manifest.

Found and fixed two bugs while driving it:

- JobsPanel step-bar never lit in real time. The executor only
  commits current_step to the DB in its `finally` block, so the bar
  showed 0/5 throughout a run. The WebSocket already carries the
  live step alongside each log line — wire that into a local
  liveStep state, take the max with job.current_step, and the bar
  animates correctly while a job runs.

- /api/jobs/* responses didn't reflect the live step either, so the
  list view still showed "step 0/5" for a running job. Backend now
  patches the returned current_step with LogManager's in-memory
  value before serializing — no extra DB writes per log line.

README updated to embed both new shots with the demo configuration
spelled out so anyone can reproduce.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Found while reviewing the web-ui-react branch with fresh eyes:

- The Search tab held start/end/track/frame in component-local state,
  so jobs created from Config shipped a `search: {kind, bbox}` only.
  BurstSearch/OperaCslcSearch require `start`, so every UI-created
  job failed Workflow validation in the executor subprocess. Lifted
  the params into shared state and built a proper discriminated-union
  payload in ConfigPanel, with a "Create & start" button.

- JobsPanel re-rendered every 3s from the polling loop and passed a
  fresh `onStep` closure into JobLogs, which had it in the useEffect
  deps. The WebSocket tore down and reconnected every 3s, re-shipping
  history each time. Moved onStep into a ref; keyed the effect on
  jobId only. Server already sends a terminal status frame on its own.

- log_manager.append runs on the executor's worker thread and called
  queue.put_nowait on loop-bound asyncio Queues. Rewrote to dispatch
  via loop.call_soon_threadsafe with a threading.Lock on the
  subscriber set. Dropped the dead asyncio.Lock field.

Smaller cleanups bundled in:
- datetime.utcnow() -> datetime.now(timezone.utc) (3.12+ deprecation)
- _norm_direction now handles NISAR's AscendingFlag "T"/"F" instead
  of letting raw "T"/"F" leak into the track chips
- JobUpdate trimmed to user-settable fields (pid was writable via PATCH)
- Executor falls back to Path.cwd() when work_dir isn't in config so
  the manifest/bowser endpoints can still find outputs
- Drop unused titiler.core dep; drop react-leaflet + react-leaflet-draw
  (MapView uses raw Leaflet); add httpx for fastapi.testclient
- Redundant env={**os.environ} on bowser Popen
- web/__init__.py docstring still said "Svelte frontend"
- New tests/test_web.py: 4 smoke tests covering /api/health,
  /api/schema, JobCreate validation, no-spawn job lifecycle

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Second-pass cleanups on top of the React UI:

- Add `_sweep_stale_jobs` in the lifespan: if the server crashed or was
  restarted mid-job, the row stays RUNNING forever with an orphaned pid
  and a pipe we can't reattach to. On startup, best-effort SIGTERM each
  RUNNING pid and flip the row to FAILED with a marker note so the user
  knows what happened.

- WebSocket loop now breaks when the watched job is deleted mid-stream.
  Previously `if job and job.status in (...)` had no fall-through for
  job=None, so the loop spun forever subscribed to an orphaned buffer
  with the socket held open.

- Replace `_with_live_step` (which mutated the SQLModel DB instance in
  read endpoints — a latent footgun if anyone added a `session.commit()`
  later in the request) with `_to_read`, which builds a fresh JobRead
  and folds in the live step there.

- MapView's init effect had `setBbox` in its deps; the state context's
  memoized value rebuilds on every state change, so the effect re-fired
  (then short-circuited) on every search result or tab switch. Empty
  deps + a guard inside.

- Simplify the step-bar `cls` ladder — the third branch `step >= idx`
  could only ever match `step === idx && !running`, so it was just
  obscuring the actual two cases.

- Drop the unused `db_path` parameter from `get_database_url` (renamed
  `_database_url`, internal-only).

- New test: `test_stale_running_jobs_are_swept` covers the lifespan
  reconciliation. The fixture also patches `sweets.web.app.engine` so
  the lifespan path uses the per-test SQLite.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…pping

New end-to-end IFG path: download -> geocode (shared with displacement
workflow) -> blockwise crossmul -> optional SNAPHU/SPURT unwrapping.

- `_crossmul.py`: pure NumPy/SciPy blockwise crossmul; boxcar or
  Gaussian multilooking; writes wrapped-phase + coherence GeoTIFFs
- `ifg.py`: IfgWorkflow config model (Pydantic YamlModel) with
  CrossmulOptions, NetworkOptions (max_bandwidth / reference_date /
  max_temporal_baseline), and IfgUnwrapOptions; pairs grouped per burst
  to handle different spatial extents; resume-safe (skips existing pairs)
- CLI: `sweets ifg-run <config.yaml>` subcommand via tyro
- Web API: GET /api/schema/ifg returns IfgWorkflow JSON schema
- Executor: auto-detects IFG vs displacement config by "crossmul" key
- Plotting: plot_ifg_pairs() and save_ifg_qa_metrics() for QA output

Tested on real Sentinel-1 data (2 bursts, 86 pairs, max_bandwidth=2,
10x40 looks): median coherence 0.197, ~20 min wall-clock for crossmul.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ferogram)

Adds a segmented button toggle to the Config tab that switches between
the displacement and interferogram workflows.  Both schemas are fetched
on mount so the switch is instant.  The IFG view surfaces network,
crossmul, and unwrap as basic fields (the main levers users need) and
hides worker counts behind "Show advanced".  api.ts gains ifgSchema().

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ader order

Burst picker (SearchPanel):
- After a search, a BURSTS section lists unique burst IDs with granule
  counts and checkboxes; only shown when 2+ bursts are present
- Selecting a subset persists to global state (selectedBurstIds); the
  Config tab injects it as search.burst_ids so only those bursts are
  downloaded
- Resets to null (all) on every new search

Schema fixes (ConfigPanel):
- rewriteSchemaInPlace() replaces rewriteTuplesInPlace(); in addition
  to the prefixItems→items tuple rewrite it now also collapses
  anyOf:[T,null] → T so that Optional[float]/Optional[str] fields
  (e.g. max_temporal_baseline, reference_date) render as a plain
  number/text input instead of a schema-picker dropdown
- Removed ui:help + label:false from IFG nested sections (network,
  crossmul, unwrap) so RJSF renders the section title as a fieldset
  legend above the fields rather than as a help blurb below them

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…button)

RJSF + draft-07: sibling properties of a \$ref are ignored, so Pydantic's
{"\$ref": "#/\$defs/Foo", "description": "..."} was seen as a plain object
with no properties, causing the "Add new key" widget.

Fix: rewriteSchemaInPlace() now converts {\$ref:X, ...rest} → {allOf:[{\$ref:X}],
...rest} before RJSF processes the schema. This matches the form RJSF knows
how to merge-and-render correctly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
log_manager: _Subscriber dataclass held an asyncio.Queue which is not
hashable, so set.add() raised TypeError. Fix: eq=False on the dataclass
restores identity-based __hash__ without changing equality semantics.

ifg: _burst_id_from_gslc now parses OPERA CSLC filenames when the
grandparent-directory heuristic fails. OPERA filenames embed the burst
ID as _T048-101101-IW3_ which is normalised to t048_101101_iw3. Flat
OPERA download directories no longer collapse all bursts into "single".

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- results.py bowser endpoint: detect IFG jobs (crossmul key in config)
  and return interferograms/ as the target path with an explanatory
  message instead of pointing at the nonexistent dolphin/ directory
- results.py manifest: add IFG output globs (wrapped phase, coherence,
  unwrapped, qa json, qa plot) so the Results panel lists IFG files
- JobsPanel: IFG jobs render "Results directory" + "Show path" button
  instead of the bowser setup/open buttons

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
ifg.py: _date_from_gslc now delegates to opera_utils.get_dates, which
handles OPERA's ISO-format acquisition dates (20240101T232835Z) that
the old 8-digit split couldn't parse.

ConfigPanel: seed formData with getDefaultFormState() when the schema
loads so integer/float fields show their schema defaults (512, 2, etc.)
rather than empty inputs.  Also reset to defaults when switching
workflow type.

Hide snaphu_ntiles and snaphu_tile_overlap via ui:widget=hidden —
Union[tuple[int,int], Literal["auto"]] has no sensible RJSF widget and
was producing the stale "Add" button; these knobs are rarely changed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Calling getDefaultFormState on the full schema injected bbox,
dem_bbox, orbit_dir, search etc. into formData. pickFields then
removed those from the visible schema, so RJSF rendered each extra
key as an additionalProperties key-value widget with a blue Add button.

Fix: pass the pickFields-filtered schema as the target schema and the
full schema as rootSchema (for \$ref resolution). Defaults are only
computed for fields actually shown. Also seed defaults when toggling
Show Advanced so newly-visible fields get their defaults.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…t hook

Adds `StitchOptions` + `_stitch_ifgs()` to `IfgWorkflow` (step 4).
Uses `dolphin.stitching.merge_by_date` to merge per-burst IFG outputs
into one file per date-pair; optionally crops to the job bbox.
Burst-alignment (feat/burst-alignment) is wired as an opt-in hook.
Web UI exposes `stitch` in the IFG basic field list.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
plotting: extract pair name from filename stem instead of parent dir
  (stitched files are flat; parent.name was "stitched" not the date pair)

web UI: auto-fill work_dir as ~/sweets-jobs/<name>-<YYYYmmdd-HHMM>
  so each job gets a unique directory by default even when the name is reused

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…inuity

crossmul now saves _ifg.tif (complex64) + _coherence.tif instead of
_wrapped_phase.tif.  _stitch_ifgs stitches the complex files (GDAL
interpolates real+imag independently, which is correct), then derives
_wrapped_phase.tif via np.angle() after merging.

_derive_wrapped_phase / _ensure_wrapped_phase helpers added.
_collect_existing_ifg_products updated to look for *_ifg.tif.
plot_ifg_pairs falls back to *_ifg.tif when no wrapped-phase files exist.
Manifest globs updated to include ifg-complex.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Checking only for non-empty existing_static_layers() meant a partial
download (e.g. job killed mid-transfer) silently left truncated files.
Now compares count against expected burst count so missing or truncated
files trigger a fresh download.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Ported burst_alignment module locally so the feature is available
without requiring the dolphin feat/burst-alignment branch. Wires
align_bursts() into _stitch_ifgs before merge_by_date, controlled
by stitch.run_burst_align and stitch.burst_align_degree.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…products

Using rglob on the ifg_dir was including previously-stitched _ifg.tif files
in the per-burst input list, causing burst alignment to process stale stitched
outputs alongside per-burst files and producing malformed output filenames.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…aths

When per-burst IFGs live under {burst_id}/{date1}_{date2}/ subdirs, the
relative path used as a uniqueness prefix already contains the dates.
Concatenating that with the file stem produced doubled date tokens
(e.g. t064_135521_iw1_20250403_20250415__20250403_20250415_ifg.aligned.tif),
which caused opera_utils.get_dates to return 4 dates instead of 2 and
broke the merge_by_date grouping/output filename.

Strip YYYYMMDD tokens from the prefix so only the burst ID remains.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… date stripping

Replace the raw r"_?\d{8}" pattern in _build_output_paths with
_date_format_to_regex(file_date_fmt) from opera_utils, tying the
date-stripping logic to the same date format the rest of the pipeline uses.
Also threads file_date_fmt through the function signature.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Passing all 351 burst files (27 bursts x 13 pairs) to align_bursts as a
flat list caused the overlap detector to compare same-geography bursts from
different date pairs, producing full-overlap garbage phase offsets that
introduced seam artifacts in every burst. Group by date pair first so each
call only sees the 27 bursts for one interferogram.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds docs/ifg_workflow.ipynb — an ipywidgets notebook that mirrors the
web UI for JupyterHub users who prefer a notebook-first experience.

What the notebook covers:
- Tabbed config form: AOI (folium map), Search & Download, Processing
  options (network/crossmul/stitch/unwrap), and Output directory
- Auto-builds and previews the YAML on load via _on_build(None)
- "Build config" / "Save YAML" buttons backed by IfgWorkflow validation
- Step-table run widget: starting-step selector + "Run workflow" button
- Results cell: plot_ifg_pairs() embeds the IFG thumbnails inline
- Appendix: programmatic usage without the widget UI

Pre-filled for OPERA DISP frame 16941 (T064, Mojave, Apr-Jun 2025).
Executed cleanly with jupyter nbconvert on the existing test dataset,
producing embedded wrapped-phase + coherence thumbnails for all 13 pairs.

Also adds:
- scripts/make_ifg_notebook.py — generator script (edit here, re-run)
- scripts/notebook_screenshots.py — playwright-based screenshot driver
- docs/screenshots/final-*.png — 8 HTML-render screenshots proving execution
- docs/ifg-workflow-test-guide.md — step-by-step test reference
- .gitignore: exclude docs/ifg_workflow_executed.* (large generated files)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Fix systematic geolocation offset: _output_geotransform() was shifting
  the GDAL origin by (looks-1)/2 pixels. GDAL origins are pixel corners,
  so the output corner stays at (x0, y0); only pixel spacing scales.

- Fix overwrite=True silently reusing stale IFG/wrapped-phase products:
  pass overwrite to run_crossmul() and _derive_wrapped_phase() so that
  re-runs with changed looks/bbox/stitching actually recompute outputs.

- Fix Gaussian multilook block width overrun: scipy fallback was using
  ceil(n/stride) via [::stride] rather than floor(n/stride), which could
  produce wider blocks than the output raster. Clip input to stride
  multiples before filtering, matching the JAX strided-conv path.

- Fix unwrapped products missing from web results manifest: _run_unwrap()
  writes to work_dir/unwrapped/ but manifest only searched
  interferograms/**/unwrapped/. Add top-level unwrapped/*.tif glob.

- Fix notebook hard-coded /Volumes/WD_BLACK path: replace with
  ~/sweets-output/frame-16941 so it works on any machine including
  JupyterHub. Regenerate docs/ifg_workflow.ipynb.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
test_crossmul.py (27 tests):
- Pure math: kernel normalization/symmetry, boxcar/gaussian shape and
  constant-preservation, non-divisible truncation (P2 regression),
  geotransform origin invariance (P1 regression), coherence edge cases
- Integration: run_crossmul with synthetic GeoTIFF SLCs verifies output
  shape, geotransform, phase sign convention (ref * conj(sec)),
  overwrite=False skip and overwrite=True refresh

test_burst_alignment.py (25 tests):
- BurstCorrection evaluate (constant + planar), is_planar
- _wrap_scalar/_wrap round-trip, _weighted_median CDF behaviour,
  _aggregate_pair mean/median real and complex
- _valid_mask for real nodata and complex zero-nodata
- _solve_offset_lsq: 2-burst recovery, 3-burst chain, empty input
- _apply_correction: real subtraction, complex phase removal, nodata passthrough
- Integration: estimate/apply/align on synthetic overlapping GeoTIFFs
  with known constant offsets for both real and complex inputs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
P1: stitched wrapped phase now respects overwrite=True, so re-runs with
overwrite enabled refresh the _wrapped_phase.tif instead of reusing the
stale file from a prior run.

P2: clarify AOI-mode static-layer completeness check with an explicit
comment noting the known limitation (count can't be verified without a
network query; overwrite=True is the escape hatch).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
scottstanie and others added 4 commits June 3, 2026 10:21
Burst alignment is now on by default — it removes inter-burst phase
offsets at seams and is low-cost relative to crossmul. Users who want
plain merge_by_date can pass stitch.run_burst_align=False.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Stitches per-burst IFGs with and without burst alignment into
separate output directories and saves a side-by-side wrapped phase
figure for visual QA.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Detects burst seam rows from the wrapped difference image and plots
±60-row crops showing no-align, aligned, and difference for each seam.
Makes the per-burst constant offsets visually obvious.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
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.

1 participant