v0.7.0 — semantic field v0 + change-detection accuracy
The semantic-field bridge to Structura plus a hardening pass on change-detection accuracy. Mechanism only — Effigies ships the field machinery; the fine-class archaeological material model is a downstream Structura deliverable and is never baked into the MIT image.
Added
- Semantic orthophoto v0 (
helpers/semantic_ortho.py, opt-in--semantic). The
geometry-derived first increment of the semantic field (ROADMAP v0.7.0): it
rasterises the OpenPointClass point classes already written into the LAZ onto the
orthophoto grid — pixel-aligned withodm_dem/dsm.tif— taking the per-cell
majority class, folded into ground / vegetation / structure, as
odm_semantic/orthophoto_semantic.tif(Byte GeoTIFF + colour table) plus a legend
JSON. No trained model: it ships what the cloud classification already knows; the
fine archaeological material classes (stone/earth/paving/ceramic/mortar) are a
downstream 2D-model deliverable. Needs a classified cloud (enable--classify);
self-skips, non-fatally, otherwise. Runs after the orthophoto/DSM so the grid exists.
Unit-tested (majority + ASPRS→v0 mapping + GeoTIFF round-trip) and image-validated
end-to-end. This is the bridge's v0 to Structura's vectorisation. - Multi-epoch semantic propagation (
helpers/semantic_propagate.py). Carries the
semantic field across daily epochs (runs under--semanticwhen--align-togives a
reference epoch). Because change detection re-lands this epoch into the reference frame,
its semantic ortho is already co-registered with the reference epoch's; the step writes
(1) a carry-forward field —odm_semantic/orthophoto_semantic_propagated.tif, where
this epoch's unobserved cells inherit the reference epoch's class (temporally consistent;
honest "unobserved = unchanged" assumption) — and (2) a semantic-change raster —
odm_semantic/semantic_change.tifwith per-pixel class transitions + per-transition area
inodm_report/semantic_change.json(the class complement of the DoD/M3C2 geometric
change: e.g. structure→ground = a feature removed, vegetation→ground = clearing). The
reference is resampled nearest-neighbour (categorical). Self-skips without both semantic
orthos; non-fatal. Unit-tested (carry-forward + transition + stats) and end-to-end
validated.
Changed
- Change detection — M3C2 level-of-detection now includes the co-registration
residual (change_detect.py). The per-point LoD previously reflected local
roughness only; it now passes the post-ICP cloud-to-cloud residual to py4dgeo as
registration_error(Lague 2013), so a cm-level alignment error is folded into the
significance test instead of being treated as zero — small (few-cm) changes are no
longer over-reported as significant. Recorded asregistration_error_min
odm_report/change_detection.json. Unit-tested: a 5 cm residual lifts the LoD
median (≈ 0.3 cm → 10 cm). Stable-area-masked ICP remains v2 (see ROADMAP). - Change detection — DoD now thresholded at a minimum level-of-detection
(change_detect.py). The DEM-of-Difference changed area and fill/cut volumes now
count only cells whose |Δz| exceeds a minimum LoD (Wheaton 2010 — a robust noise
floor of the difference distribution, floored by the co-registration residual;
min_lod_from_dod), so sub-LoD noise is no longer booked as excavation / back-fill.
A raw un-thresholded net volume is kept as a cross-check, and the minLoD is recorded
asmin_lod_minodm_report/change_detection.json. Unit-tested. - Change detection — co-registration is now stable-area-masked (
change_detect.py).
ICP runs in two passes: a whole-cloud fit, then a re-fit on only the unchanged
ground (changed cells dropped viastable_mask), so a localised excavation change no
longer biases the rigid transform, and the residual over the stable area is a clean
registration-only error that now feeds the M3C2 LoD and the DoD minLoD
(coreg_reg_error) instead of the conservative full-cloud C2C. Reports
stable_fraction+registration_errorinchange_detection.json; degrades to the
whole-cloud fit (and records why) when too little stable ground remains. Unit-tested
(stable_maskseparation; the two-pass ICP smoke is pdal-gated). - Change detection —
--align-tonow re-lands the deliverables into the reference
frame by default (change_detect.py), ODM--alignparity. The recovered ICP
transform is applied in place to the delivered mesh OBJ (offset-aware,transform_obj)
and the LAZ (PDAL; EPT rebuilt); because re-landing runs before the raster stages, the
DSM/DTM, orthophoto, contours, glTF and 3D Tiles inherit the reference frame natively
(no re-warp). Previously the alignment was additive-only (deliverables untouched) —
pass--no-relandto keep that.report["relanded"]lists what moved; non-fatal per
asset. Camera assets (shots.geojson) are a known re-land gap (v2). Offset-exact
transform unit-tested; the full raster re-derivation is Docker-validated. - Change detection —
--align-tonow accepts a DEM GeoTIFF as the reference, not
only a point cloud (change_detect.py). A reference.tif(a prior DSM/DEM) is
read as cell-centre points for the ICP co-registration and M3C2, and used directly
(resampled onto the shared grid) as the reference DSM for the DEM-of-Difference —
so a prior epoch's DSM, or any external reference DEM, can drive the comparison.
is_dem/dem_to_xyz/resample_dem; unit-tested. - Change detection — camera assets are re-landed with the rest (
camera_exports.py).
When an--align-torun re-landed this epoch into the reference frame,shots.geojson
camera centres and orientations are now transformed by the recorded re-land transform
(read fromodm_report/change_detection.json, gated on therelandedmarker), so the
camera positions stay consistent with the re-landed mesh / cloud / orthophoto instead
of sitting in the old frame.cameras.jsonis unchanged (intrinsics are
frame-independent). The re-land gate is unit-tested; the pyproj WGS84 path is
Docker-validated. Closes the last v2 gap of the change-detection item. - Change detection — fix: M3C2 no longer misses deep changes (
change_detect.py).
py4dgeo's M3C2max_distance(the cylinder search depth along the normal) defaulted
to 0, which on the auto-scaled small cylinder of a dense cloud is too shallow — a deep
excavation (a change larger than the cylinder scale) had no matching surface in range
and came back NaN / not-significant, even where the DoD clearly measured it. It is now
set generously (max(30·cyl_radius, 3 m)). Found by the new end-to-end smoke
(scripts/smoke_change_detect.py), which the fix takes from M3C2 0 %→significant on a
0.4 m block.
Carried forward (post-v0.7.0)
- The full mesh-classify → z-buffer
--semanticpath (v0 approximates it via per-cell cloud majority). - The fine-class material model (stone / earth / ceramic / mortar) — Structura's deliverable, itself data-blocked.
- Cross-project contract finalisation.
Test gate: pure-Python unit suite green; the only scripts/test.sh failure is the pre-existing macOS bash-3.2 issue in test_autoscale.sh (passes on the bash-5 Docker image).