Skip to content

V2: Point cloud viewer with GeoParquet + DuckDB + deck.gl#21

Open
scottstanie wants to merge 60 commits intoopera-adt:mainfrom
scottstanie:v2
Open

V2: Point cloud viewer with GeoParquet + DuckDB + deck.gl#21
scottstanie wants to merge 60 commits intoopera-adt:mainfrom
scottstanie:v2

Conversation

@scottstanie
Copy link
Copy Markdown
Collaborator

@scottstanie scottstanie commented Mar 24, 2026

Bowser V2: Point Cloud + Raster Unified Viewer

Point cloud with viridis colormap

Raster mode with reference marker and timeseries

Timeseries chart with trend line

What's new

  • GeoParquet + DuckDB backend — Two-table data model (points + timeseries), in-process DuckDB queries with spatial extension, Arrow IPC binary transport for 200k+ points
  • MapLibre GL + deck.gl frontend — WebGL-native map replacing Leaflet, ScatterplotLayer for point cloud rendering, Plotly for charts
  • Raster + vector unification — Both raster layers (COG/Zarr) and point layers coexist with independent visibility/opacity controls
  • Single unified controls panel — Replaces the old sidebar. Handles points, rasters, or both. Layer toggles, raster colormap/time slider/opacity, basemap switcher
  • Point cloud interaction — Click for timeseries, shift+click to compare, ctrl+click for reference point subtraction
  • Raster interaction restored — Click to plot pixel timeseries, draggable reference marker with reactive spatial referencing (raster tiles + chart both update)
  • Analysis tools — Trend lines (mm/yr + R²), date range filter, attribute histogram, 5 colormaps with colorbar
  • Shareability — URL-encoded view state (shareable links), dark/light chart theme, PNG export (map+chart), SVG export (vector, publication-ready)
  • Export — CSV, GeoJSON, GeoParquet download with optional timeseries
  • GPS overlay backend/gps router with geepers integration, ENU→LOS projection (constant or GeoTIFF), 5 backend tests
  • Testing — 12 backend API tests, 5 GPS tests, 7 Playwright E2E browser tests, visual screenshot test script
  • CLIbowser convert dolphin (3x faster with parallelized I/O), bowser convert wide-parquet, bowser generate-testdata

Commits (36)

Foundation:

  1. V2 backend: GeoParquet + DuckDB, manifest schema, CLI converters
  2. Wide-form parquet converter with float16 upcast
  3. Frontend rewrite: MapLibre GL + deck.gl + Plotly
  4. Point cloud controls panel with color-by and vmin/vmax

Interaction:
5. Basemap switcher (satellite/OSM/dark) + attribute filter panel
6. Colorbar legend + colormap selector (rdbu_r, viridis, plasma, inferno, coolwarm)
7. Multi-point selection (shift+click) with bulk timeseries
8. Reference point subtraction (ctrl+click) for point cloud mode

Raster + vector unification:
9. Unified init — loads both raster and point layers simultaneously
10. Consolidated single controls panel (removed V1 sidebar)
11. Raster tile URL fix, style load guard, reference marker + TS click restored
12. Reference marker fetches pixel values, TS markers draggable, chart subtracts ref

Analysis & export:
13. Trend line toggle (client-side linear regression)
14. Export endpoint + UI (CSV/GeoJSON/GeoParquet with optional timeseries)
15. Date range filter for timeseries
16. Attribute histogram (SVG mini-chart in controls panel)

Shareability:
17. URL state encoding (viewport, colormap, filter — shareable links)
18. Dark/light chart theme toggle (Palatino serif for publication)
19. PNG export (map+chart combined, 2x DPI) + SVG export (vector)

GPS overlay (backend):
20. LOS config schema in manifest (constant JSON or 3-band GeoTIFF)
21. GPS router with geepers integration + ENU→LOS projection + 5 tests

Testing & tools:
22. Playwright E2E browser tests (7 tests)
23. Visual screenshot test script
24. bowser generate-testdata CLI command
25. Parallelized dolphin converter (3x speedup)

Test results

  • Backend: 12 point API tests + 5 GPS tests passing
  • E2E: 7 Playwright browser tests passing
  • Visual: Screenshot test captures 8+ UI states

How to test

# Point cloud mode (synthetic data)
bowser generate-testdata -o /tmp/testdata -n 50000
bowser run --manifest /tmp/testdata/bowser_manifest.json

# Raster mode
bowser setup-dolphin /path/to/dolphin/work/dir
bowser run

# Both modes
BOWSER_STACK_DATA_FILE=stack.zarr bowser run --manifest manifest.json

# Run tests
pixi run pytest tests/test_points.py tests/test_gps.py tests/test_e2e.py -v
python tests/visual_test.py --headed --n-points 50000

🤖 Generated with Claude Code

scottstanie and others added 30 commits November 17, 2025 16:57
Currently the colors are forgotten as you flip between datasets (e.g. switching to Magma when viewing temporal coherence overrides what you were using for the `timeseries` viewing
The _format_dates function was producing "%Y%m%d"-formatted strings
joined by underscores (e.g. "20250310_20250313"), which
chartjs-adapter-date-fns cannot parse. This caused all data points
to collapse to the same x position on the time series chart.

Changed to output ISO date strings ("2025-03-13") using the last date
from multi-date filenames, consistent with how Zarr mode already works.

https://claude.ai/code/session_01Gs7FnDKQu95PpxXRkpLCiB
When all COG files share the same first date (common for interferogram
stacks), detect it and surface it to the frontend. The chart x-axis
title shows "Time (ref: 2025-03-10)" so users know the reference date.

https://claude.ai/code/session_01Gs7FnDKQu95PpxXRkpLCiB
The x_values check `any(d is None for d in dates)` didn't catch empty
tuples from get_dates() on files without dates (e.g. velocity.tif).
Changed to `any(not d for d in dates)` which handles both None and ().

Added test_titiler.py with unit tests for _format_dates and integration
tests for RasterGroup.x_values and .reference_date using test data.

https://claude.ai/code/session_01Gs7FnDKQu95PpxXRkpLCiB
When multi-date files (unwrapped phase, coherence) have different reference
dates, the secondary dates can repeat. Instead of using only the secondary
date (which caused duplicate x-values and zigzag charts), use full
"ref_secondary" date pair labels and render with a Chart.js category scale.

- Remove unused _format_dates function and its tests
- Detect duplicate secondary dates in x_values and fall back to date pairs
- Add CategoryScale to Chart.js, auto-select scale type from label format
- Clean up unused imports in test fixtures

Co-Authored-By: Claude Opus 4.6 <[email protected]>
- Design doc (BOWSER_V2_DESIGN.md) covering data model, architecture,
  UX, and implementation plan for raster + vector viewing
- Pydantic manifest schema (bowser_manifest.json) for unified dataset config
- Dolphin raster-to-GeoParquet converter with quality masking, spatial
  sorting (Morton curve), and proper row group layout
- DuckDB-backed FastAPI router for point queries: bbox filtering, attribute
  filtering, single/multi-point time series, stats, Arrow IPC transport
- CLI: `bowser convert dolphin` command, `--manifest` flag on `bowser run`
- 11 passing tests covering converter and endpoint functionality

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
- `bowser convert wide-parquet` command for sarlet-style point clouds
  where displacement dates are stored as wide columns (YYYYMMDD_YYYYMMDD)
- Auto-detects date columns, separates static attrs from time series
- Upcasts float16 → float32 for DuckDB compatibility
- Tested against real 193k-point Mexico City dataset (16 dates)

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
- MapContainer: MapLibre GL JS replaces Leaflet, deck.gl ScatterplotLayer
  renders point clouds with WebGL, RdBu_r color scale, click-to-inspect
- TimeSeriesChart: Plotly.js replaces Chart.js, dark theme, scattergl
  renderer for clicked point displacement time series
- App: Auto-detects V2 point layer mode, fetches attributes for bounds
- New usePointsApi hook: Arrow IPC decoding for point data, timeseries
- AppContext: New state for activePointLayer, pointLayerBounds, clickedPoints
- Raster tile layer support preserved in MapContainer for V1 compatibility
- Removed Leaflet CDN dependency from index.html

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
- PointControlsPanel: dark overlay with color-by attribute selector,
  vmin/vmax inputs, point count, and mean display
- Dynamically populated from /points/{layer}/attributes endpoint
- Auto-sets symmetric vmin/vmax for velocity, full range for others
- MapContainer reads pointColorBy/pointVmin/pointVmax from global state
- App.tsx dispatches SET_POINT_ATTRIBUTES on init for the controls panel

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
ESRI satellite tiles max out around zoom 18-19. Google satellite
tiles support up to zoom 21, which is needed when inspecting
individual PS points at street level.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Basemap toggle (Satellite/OSM/Dark) in controls panel with reactive
tile source switching. Attribute filter UI with add/remove expressions
that chain via AND and pass through to DuckDB backend queries.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Shared colorscales module with 5 colormaps (rdbu_r, viridis, plasma,
inferno, coolwarm). Colorbar gradient in controls panel shows current
range. Colormap dropdown switches point colors in real-time.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Normal click selects a single point and shows its timeseries.
Shift+click adds points to the selection for comparison. Selected
points highlighted in yellow with larger radius. Chart shows a
selection bar with per-point remove buttons and clear all.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Ctrl+click (or Cmd+click on Mac) sets a reference point shown in green.
All displayed timeseries are differenced against the reference, with
y-axis label changing to "Relative Displacement". Reference indicator
in the chart bar with clear button. Green highlight + larger radius
for the reference point on the map.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
DuckDB 1.5+ deprecated fetch_arrow_table(). Use .arrow().read_all()
which returns a proper pyarrow.Table for IPC serialization.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
App now initializes BOTH raster datasets and point layers when available,
instead of either/or. Layer visibility toggles let users show/hide
raster and point layers independently. Point opacity slider controls
deck.gl layer transparency. Backend /datasets endpoint returns empty
dict gracefully in points-only mode.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Refactor load_data_sources() to load point layers from manifest AND
raster data from BOWSER_STACK_DATA_FILE or BOWSER_DATASET_CONFIG_FILE
when the manifest doesn't have raster layers. This enables running
bowser with --manifest for points alongside an existing stack file
for rasters.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
bowser generate-testdata -o DIR -n 50000 creates realistic clustered
point distributions with velocity trends, temporal coherence, and
amplitude dispersion. Outputs points.parquet + timeseries.parquet +
bowser_manifest.json for immediate viewing.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
7 end-to-end tests that start a real Bowser server with synthetic data
and drive a headless Chromium browser:
- App loads with map canvas
- Point controls panel visible with layer toggles
- Colormap selector switches between options
- Basemap switcher (satellite/OSM/dark) works
- Stats overlay shows point count after data loads
- Filter panel: add and clear attribute filters
- Click on map triggers timeseries chart

Uses pytest-playwright with session-scoped server fixture.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Client-side linear regression computes mm/yr rate and R² for each
clicked point's timeseries. Trend button in chart header toggles
dashed trend lines with rate shown in legend.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Backend GET /points/{layer}/export with format, bbox, filter, and
include_timeseries params. Frontend export buttons in controls panel
that open download in new tab with current filter applied. Three new
backend tests for CSV, GeoJSON, and Parquet export formats.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Date picker inputs in chart toolbar filter displayed timeseries
client-side. Trend toggle shows dashed regression lines with mm/yr
rate and R² in legend. Reset button clears date range.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
SVG-based mini histogram shows distribution of the current color-by
attribute, colored by the active colormap. Histogram bins computed in
MapContainer when point data loads, stored as lightweight bin counts
in state. Updates when filter or color-by changes.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
scottstanie and others added 30 commits March 26, 2026 10:34
- Replace Shapely Point loop with gpd.points_from_xy() (vectorized C)
- Parallel raster reads with ThreadPoolExecutor for both static
  attributes and timeseries dates
- Vectorize Morton key with numpy part1by1 bit-interleave instead
  of Python for-loop

Should significantly reduce wall time for large datasets (10M+ points).

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
- Fix OSM tile URL ({z}/{y}/{x} → {z}/{x}/{y})
- Remove V1 sidebar when point layer exists; fold raster controls
  (dataset selector, time slider, opacity) into V2 controls panel
- Full-width map layout when points are active
- Add include-timeseries checkbox to export buttons

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Remove V1 ControlPanel sidebar entirely. The overlay panel now handles
all modes: points-only, raster-only, or both. Raster controls (server
colormap with colorbar image, vmin/vmax, opacity, time slider, show
timeseries button) are nested under the raster layer toggle. Point
sections hidden when no point layer is loaded. Full-width map layout
in all modes.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
- Add response.ok guards to all point API hooks (prevents parsing
  HTML error pages as Arrow IPC in raster-only mode)
- Fix filterAttr state-during-render issue with effectiveFilterAttr
- Move chart date range + trend controls from absolute overlay to
  a dedicated toolbar row, eliminating overlap with Plotly legend

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Standalone script that starts a server with synthetic data and captures
screenshots at key UI states: initial load, basemap switching, colormap
change, filter applied, point click, trend lines, multi-select.
Saves numbered PNGs to a timestamped directory for review.

Usage: python tests/visual_test.py [--headed] [--n-points 50000]

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
View state (colorBy, vmin/vmax, colormap, basemap, filter, lat/lon/zoom)
is serialized to URL search params and updated on every change via
history.replaceState. On load, URL params override defaults, so shared
links restore the exact same view. Map viewport syncs on moveend.

Example: ?colorBy=velocity&vmin=-20&vmax=20&colormap=viridis&basemap=dark&lat=19.43&lon=-99.13&zoom=14

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Light theme: white background, serif font (Georgia), dark text, thin
grid lines — suitable for academic papers. Toggle button in chart
toolbar. Theme persists in URL state (?theme=light) for sharing.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Save PNG button in chart toolbar captures the map canvas and Plotly
chart, composites them into a single image, and triggers a download.
Uses dynamic import for Plotly.toImage to avoid bloating the main
bundle.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
- Debounce URL viewport sync to prevent moveend from writing lat/lon/zoom
  before fitBounds runs (which caused fitBounds to be skipped)
- Wait for map style load before adding raster tile source/layer
  (prevents silent failures from adding sources before style ready)

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
New LosConfig and LosVectorConstant models in manifest. Supports two
LOS source types:
- constant: single ENU unit vector (e.g. {"east": 0.477, "north": -0.449, "up": 0.755})
- geotiff: 3-band GeoTIFF with spatially-varying E/N/U components

LOS config lives at manifest level (shared SAR geometry for all layers).

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
New /gps router with endpoints:
- GET /gps/los_info — returns LOS config
- GET /gps/stations?bbox= — list GPS stations in viewport (via geepers)
- GET /gps/stations/{id}/timeseries — ENU→LOS projected timeseries

Supports constant LOS vector and spatially-varying GeoTIFF modes.
Returns both LOS-projected and raw ENU components for frontend toggle.
5 backend tests with mocked geepers (no network calls).

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
The tile URL path was /{endpoint}/WebMercatorQuad/tiles/{z}/{x}/{y}.png
but the titiler route is /{endpoint}/tiles/WebMercatorQuad/{z}/{x}/{y}.png.
This was a V2 regression that made all raster tiles 404.

Also change default map center from Mexico City to (0,0) so it's
obvious when fitBounds doesn't fire.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
In raster-only mode:
- Click map to add a time series point (colored marker + pixel values
  fetched from /point endpoint, displayed in chart)
- Draggable red reference marker for spatial referencing
- Auto-fetch pixel timeseries for new points
- "Show time series" button toggles chart visibility

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Reference marker now fetches pixel values on drag and dispatches
SET_REF_VALUES for datasets with spatial referencing. This triggers
raster tile re-rendering relative to the reference point (shift
algorithm). Also fetches initial ref values on marker creation.

Time series point markers are now draggable — on drag, position
updates and cached data clears, triggering automatic re-fetch at
the new location.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
When a dataset uses spatial referencing (uses_spatial_ref: true),
the chart now subtracts state.refValues from the raw pixel values.
This makes the time series relative to the reference marker position,
consistent with how the raster tiles are rendered via the shift
algorithm. Chart updates reactively when refValues change.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Export fixes:
- Add preserveDrawingBuffer to MapLibre for canvas capture
- Split into PNG (map+chart combined) and SVG (chart only, vector)
- Better export proportions and 2x DPI scaling

Chart improvements:
- Light theme font: Palatino Linotype (nicer for publications)
- Larger font size in light mode (13px vs 11px)

Reference subtraction:
- Fetch ref values for ALL datasets (not just uses_spatial_ref)
- Always subtract refValues in chart when available
- Both ref marker drag and TS point drag trigger reactive updates

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
useUrlStateSync was creating URLSearchParams from scratch, overwriting
lat/lon/zoom written by the map moveend handler. Now reads existing
params first and merges, so viewport and app state coexist in the URL.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
GPS types (GpsStation, GpsTimeseriesEntry, GpsComponent) and state
in AppContext. useGpsApi hook. GPS section in controls panel with
checkbox toggle, station count, LOS/E/N/U component buttons. Only
shown when LOS config exists. App checks /gps/los_info on init.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Green ScatterplotLayer for GPS stations, fetched on moveend when GPS
toggle enabled. Click station to select and fetch timeseries. Uses
deckLayersRef for coordinated layer composition across effects.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
GPS trace rendered as dashed green line with diamond markers, visually
distinct from InSAR traces. Respects LOS/E/N/U component selector.
GPS trend line included when Trend toggled. GPS station tag in
selection bar with dismiss button. Date range filter applies to GPS.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
bowser convert dolphin now auto-detects los_enu.json or los_enu*.tif
in the dolphin directory and writes LOS config into the manifest.
No manual configuration needed.

For raster mode, added --los flag to bowser run:
  bowser run --los path/to/los_enu.json
  bowser run --los '{"east": 0.477, "north": -0.449, "up": 0.755}'

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
The only scipy usage was stats.linregress in calculate_trend().
Replaced with manual normal equations using numpy — same math,
one fewer dependency.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
- GPS stations: use row['id'] column (4-char station code) instead of
  DataFrame numeric index. Fixes "GPS: 0" display. Updated test mock
  to match real geepers GeoDataFrame format.
- Raster pixel query: cast complex values to real before JSON
  serialization. Fixes TypeError with interferogram phase data.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
GPS: use df['date'] column instead of df.index for dates.
Raster: restore localStorage persistence for colormap/vmin/vmax.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
GPS station clicks now suppress the raster click handler via a ref
flag, preventing unwanted time series points when clicking GPS markers.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Scoping document for a standalone GPS station exploration and curation
tool, proposed as a geepers subcommand. Covers the problem (raw GPS
data needs preprocessing before InSAR comparison), the solution
(interactive web app for browse/curate/reference/export), where it
should live, technical architecture, and MVP scope.

The output would be processed GeoParquet files loadable directly
as bowser point layers — keeping bowser focused on viewing.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
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.

2 participants