From e1f332dce20cc4ec204eb320fd2abd2811bde27d Mon Sep 17 00:00:00 2001 From: Patrick Leiverkus Date: Sun, 26 Jul 2026 18:13:01 +0200 Subject: [PATCH 1/3] feat: cap 2D polygon area and expose the Cellpose tuning knobs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both found by looking at real output in QGIS. STRUCTURA_MAX_AREA. Every backend emits a background mask, and with no upper bound it reaches the output. On the Tiberias trench SAM produced a single polygon of 634 m² — the full raster rectangle, matching the extent to 0.000 m on all four edges, with 1790 holes punched out where it had segmented something else. The Otsu watershed does the same at 29 m². This is not a tiling artefact: only 2 of 814 SAM polygons touch the 512 px tile grid and none is rectangular; it is one whole-image mask. With the cap at 5 m² the watershed run drops from 109 features to 108 and its largest survivor from 419.8 m² to 0.139 m². Cellpose knobs. `cellprob_threshold` did not exist on the segmenter at all, and it is the recall knob — the one that matters when the backend returns few but correct instances, which is what a real trench showed (328 objects at a median of 834 cm², against SAM's 814). `diameter` existed but was never passed by `make_segmenter`, the same failure mode as `min_area` last time: a parameter that looks configurable and is not. Optional numeric settings read through a shared `_opt_float`, so an unset *and* a blanked line in `.env` both mean "no value" — otherwise `STRUCTURA_MAX_AREA=` would crash on `float("")`. Co-Authored-By: Claude Opus 5 --- .env.example | 15 ++++++++++++ CHANGELOG.md | 13 ++++++++++ src/structura/config.py | 24 +++++++++++++++++++ src/structura/geo.py | 27 ++++++++++++++++----- src/structura/pipeline.py | 15 +++++++++--- src/structura/segmentation/_common.py | 11 ++++++--- src/structura/segmentation/cellpose.py | 15 +++++++++++- src/structura/segmentation/classical.py | 4 +++- src/structura/segmentation/sam.py | 3 +++ tests/test_geo.py | 17 +++++++++++++ tests/test_make_segmenter.py | 32 +++++++++++++++++++++++-- tests/test_pipeline.py | 4 ++++ 12 files changed, 164 insertions(+), 16 deletions(-) diff --git a/.env.example b/.env.example index 5f8025d..1981811 100644 --- a/.env.example +++ b/.env.example @@ -13,6 +13,21 @@ STRUCTURA_GPU=false # so on a real orthophoto it filters nothing. 0.0025 (25 cm²) is a usable start # for stones; raise it until the speckle disappears. STRUCTURA_MIN_AREA=1e-4 +# Drop 2D polygons ABOVE this area. Unset = no cap, which lets every backend's +# background mask through: on a real 815 m² trench SAM emitted one polygon of +# 634 m² — the full raster rectangle with 1790 holes punched out — and the +# watershed one of 29 m². Set it to the largest thing you are looking for. +STRUCTURA_MAX_AREA= + +# --- Cellpose backend only --- +# Expected object size in pixels. Unset lets Cellpose estimate it, using an +# estimate tuned for cells; on stones it can settle on the large blocks and drop +# everything smaller. Set it explicitly if recall looks low. +STRUCTURA_CELLPOSE_DIAMETER= +# The recall knob: lower admits more (and fainter) instances. 0.0 is the Cellpose +# default, not a tuned value. +STRUCTURA_CELLPOSE_CELLPROB=0.0 +STRUCTURA_CELLPOSE_FLOW=0.4 # --- 2.5D track (DEM wall tracing): gap-bridging tolerance in world units (m) --- STRUCTURA_GAP_BRIDGE_M=0.3 diff --git a/CHANGELOG.md b/CHANGELOG.md index 230f053..1f241b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **`STRUCTURA_MAX_AREA` — an upper bound on 2D polygon area.** Every backend + emits a *background* mask, and without a cap it reaches the output and swamps + everything in QGIS. On a real 815 m² trench, SAM produced a single polygon of + **634 m²** — the full raster rectangle, matching the extent to 0.000 m on all + four edges, with 1790 holes punched out where it had segmented something else; + the Otsu watershed produced one of 29 m². With the cap at 5 m² the watershed run + goes from 109 features to 108, and the largest survivor drops from 419.8 m² to + 0.139 m². +- **Cellpose tuning is reachable from configuration**: `STRUCTURA_CELLPOSE_DIAMETER`, + `STRUCTURA_CELLPOSE_CELLPROB` and `STRUCTURA_CELLPOSE_FLOW`. `cellprob_threshold` + did not exist on the segmenter at all, and it is the recall knob — the one that + matters when the backend returns few but correct instances. `diameter` existed + but, like `min_area` before it, was never passed by `make_segmenter`. - **`STRUCTURA_MIN_AREA` — the 2D polygon area filter is now configurable.** All three segmenters already accepted `min_area`, but `make_segmenter` never passed it, so the only way to change it was to edit the source. On a real orthophoto diff --git a/src/structura/config.py b/src/structura/config.py index b409499..06661c7 100644 --- a/src/structura/config.py +++ b/src/structura/config.py @@ -21,6 +21,13 @@ def _load_dotenv(path: Path) -> None: os.environ.setdefault(key.strip(), value.strip()) +def _opt_float(name: str) -> float | None: + """Read an optional numeric setting. Unset *and* empty both mean "no value", + so a commented-out or blanked line in `.env` behaves the way it looks.""" + raw = os.environ.get(name, "").strip() + return float(raw) if raw else None + + @dataclass(slots=True) class Settings: input_dir: Path @@ -32,6 +39,15 @@ class Settings: # The useful value depends on ground resolution and on what counts as a find: # at 0.5 cm/px the default 1e-4 m² is ~4 px, i.e. effectively no filter. min_area: float + # 2D track: drop polygons *above* this area. Unset means no cap, which lets + # every backend's background mask through — SAM's covered 78 % of a real + # trench. Set it to the largest thing you are looking for. + max_area: float | None + # Cellpose backend only. `diameter=None` lets Cellpose estimate object size; + # `cellprob_threshold` is the recall knob (lower admits more instances). + cellpose_diameter: float | None + cellpose_flow_threshold: float + cellpose_cellprob_threshold: float # 2.5D track: gap-bridging tolerance (world units) for wall tracing gap_bridge_m: float # File sink (default) @@ -59,6 +75,14 @@ def from_env(cls, dotenv: str | Path = ".env") -> "Settings": segmentation_backend=os.environ.get("STRUCTURA_2D_BACKEND", "classical"), gpu=os.environ.get("STRUCTURA_GPU", "").lower() in ("1", "true", "yes"), min_area=float(os.environ.get("STRUCTURA_MIN_AREA", "1e-4")), + max_area=_opt_float("STRUCTURA_MAX_AREA"), + cellpose_diameter=_opt_float("STRUCTURA_CELLPOSE_DIAMETER"), + cellpose_flow_threshold=float( + os.environ.get("STRUCTURA_CELLPOSE_FLOW", "0.4") + ), + cellpose_cellprob_threshold=float( + os.environ.get("STRUCTURA_CELLPOSE_CELLPROB", "0.0") + ), gap_bridge_m=float(os.environ.get("STRUCTURA_GAP_BRIDGE_M", "0.3")), output_path=Path( os.environ.get("STRUCTURA_OUTPUT_PATH", "./data/output/features.gpkg") diff --git a/src/structura/geo.py b/src/structura/geo.py index 0b8eecd..6a0b56d 100644 --- a/src/structura/geo.py +++ b/src/structura/geo.py @@ -25,15 +25,27 @@ def read_raster(path: Path) -> tuple[Any, Any, str]: def mask_to_polygons( - mask: Any, transform: Any, crs: str, *, min_area: float = 0.0 + mask: Any, + transform: Any, + crs: str, + *, + min_area: float = 0.0, + max_area: float | None = None, ) -> list[Any]: """Vectorise a boolean/label mask into georeferenced Shapely polygons. Used by the 2D track to turn SAM/Cellpose/classical masks into stone/surface outlines. ``transform`` carries the pixel size, so the returned geometries — - and therefore ``min_area`` (a threshold in world units squared) — are in the - raster CRS. ``crs`` is accepted for interface symmetry; the CRS is attached - later at the :class:`~structura.models.Feature` level. + and therefore ``min_area`` / ``max_area`` (thresholds in world units squared) + — are in the raster CRS. ``crs`` is accepted for interface symmetry; the CRS + is attached later at the :class:`~structura.models.Feature` level. + + ``max_area`` discards masks that are too large to be a find. Every backend + produces them: SAM's automatic generator emits a *background* mask covering + everything it did not segment — on a real trench that was the full raster + rectangle, 78 % of the extent, with 1790 holes punched out — and the Otsu + watershed does the same when its global threshold splits foreground from + ground. Without an upper bound these survive and swamp the output in QGIS. Background pixels (label ``0``) are skipped. Each polygon is repaired with a zero-width buffer to fix any self-touching rings from the marching-squares @@ -52,8 +64,11 @@ def mask_to_polygons( if value == 0: continue geom = shape(geom_dict).buffer(0) - if geom.area >= min_area: - polygons.append(geom) + if geom.area < min_area: + continue + if max_area is not None and geom.area > max_area: + continue + polygons.append(geom) return polygons diff --git a/src/structura/pipeline.py b/src/structura/pipeline.py index 674bf5d..0579f2e 100644 --- a/src/structura/pipeline.py +++ b/src/structura/pipeline.py @@ -27,11 +27,20 @@ def make_segmenter(settings: Settings) -> Segmenter: backend = settings.segmentation_backend if backend == "classical": - return ClassicalSegmenter(min_area=settings.min_area) + return ClassicalSegmenter( + min_area=settings.min_area, max_area=settings.max_area + ) if backend == "sam": - return SamSegmenter(min_area=settings.min_area) + return SamSegmenter(min_area=settings.min_area, max_area=settings.max_area) if backend == "cellpose": - return CellposeSegmenter(gpu=settings.gpu, min_area=settings.min_area) + return CellposeSegmenter( + gpu=settings.gpu, + diameter=settings.cellpose_diameter, + flow_threshold=settings.cellpose_flow_threshold, + cellprob_threshold=settings.cellpose_cellprob_threshold, + min_area=settings.min_area, + max_area=settings.max_area, + ) raise ValueError(f"Unknown 2D backend: {backend!r}") diff --git a/src/structura/segmentation/_common.py b/src/structura/segmentation/_common.py index d85606f..5e7ec6f 100644 --- a/src/structura/segmentation/_common.py +++ b/src/structura/segmentation/_common.py @@ -25,14 +25,19 @@ def label_mask_to_features( segmenter: str, source_raster: Path | str, min_area: float = 1e-4, + max_area: float | None = None, feature_type: FeatureType = FeatureType.STONE, ) -> list[Feature]: """Vectorise an instance-label ``mask`` into 2D-track ``Feature`` objects. - ``mask`` is an integer array (label 0 = background). ``min_area`` is in world - units squared (CRS); ``transform``/``crs`` come from the source raster. + ``mask`` is an integer array (label 0 = background). ``min_area`` and + ``max_area`` are in world units squared (CRS); ``transform``/``crs`` come + from the source raster. See :func:`structura.geo.mask_to_polygons` for why an + upper bound matters. """ - geoms = geo.mask_to_polygons(mask, transform, crs, min_area=min_area) + geoms = geo.mask_to_polygons( + mask, transform, crs, min_area=min_area, max_area=max_area + ) return [ Feature( feature_type=feature_type, diff --git a/src/structura/segmentation/cellpose.py b/src/structura/segmentation/cellpose.py index e0ad88a..3c86753 100644 --- a/src/structura/segmentation/cellpose.py +++ b/src/structura/segmentation/cellpose.py @@ -26,12 +26,21 @@ def __init__( gpu: bool = False, diameter: float | None = None, flow_threshold: float = 0.4, + cellprob_threshold: float = 0.0, min_area: float = 1e-4, + max_area: float | None = None, ) -> None: self.gpu = gpu + # Expected object size in pixels; None lets Cellpose estimate it. The + # estimate is tuned for cells — on stones it can settle on the large + # blocks and drop everything smaller, so it is worth setting explicitly. self.diameter = diameter self.flow_threshold = flow_threshold + # The recall knob: lower admits more (and fainter) instances. 0.0 is the + # Cellpose default, not a tuned value. + self.cellprob_threshold = cellprob_threshold self.min_area = min_area + self.max_area = max_area def segment(self, ortho_path: Path) -> list[Feature]: import numpy as np # noqa: PLC0415 @@ -44,7 +53,10 @@ def segment(self, ortho_path: Path) -> list[Feature]: model = models.CellposeModel(gpu=self.gpu) masks, _, _ = model.eval( - img, diameter=self.diameter, flow_threshold=self.flow_threshold + img, + diameter=self.diameter, + flow_threshold=self.flow_threshold, + cellprob_threshold=self.cellprob_threshold, ) # masks is an instance-label array (0 = background). @@ -55,4 +67,5 @@ def segment(self, ortho_path: Path) -> list[Feature]: segmenter=self.name, source_raster=ortho_path, min_area=self.min_area, + max_area=self.max_area, ) diff --git a/src/structura/segmentation/classical.py b/src/structura/segmentation/classical.py index d052d93..a780046 100644 --- a/src/structura/segmentation/classical.py +++ b/src/structura/segmentation/classical.py @@ -23,9 +23,10 @@ class ClassicalSegmenter: name = "classical" - def __init__(self, min_area: float = 1e-4) -> None: + def __init__(self, min_area: float = 1e-4, max_area: float | None = None) -> None: # Minimum polygon area to keep, in world units squared (CRS). self.min_area = min_area + self.max_area = max_area def segment(self, ortho_path: Path) -> list[Feature]: import numpy as np # noqa: PLC0415 @@ -69,4 +70,5 @@ def segment(self, ortho_path: Path) -> list[Feature]: segmenter=self.name, source_raster=ortho_path, min_area=self.min_area, + max_area=self.max_area, ) diff --git a/src/structura/segmentation/sam.py b/src/structura/segmentation/sam.py index 3412bb2..f48cf79 100644 --- a/src/structura/segmentation/sam.py +++ b/src/structura/segmentation/sam.py @@ -38,12 +38,14 @@ def __init__( sam_kwargs: dict | None = None, min_size: int = 100, min_area: float = 1e-4, + max_area: float | None = None, checkpoint: Path | None = None, ) -> None: self.model_type = model_type self.sam_kwargs = sam_kwargs if sam_kwargs is not None else dict(_DEFAULT_SAM_KWARGS) self.min_size = min_size self.min_area = min_area + self.max_area = max_area self.checkpoint = checkpoint def samgeo_kwargs(self) -> dict: @@ -90,4 +92,5 @@ def segment(self, ortho_path: Path) -> list[Feature]: segmenter=self.name, source_raster=ortho_path, min_area=self.min_area, + max_area=self.max_area, ) diff --git a/tests/test_geo.py b/tests/test_geo.py index e4f5b70..0a2d854 100644 --- a/tests/test_geo.py +++ b/tests/test_geo.py @@ -39,5 +39,22 @@ def test_mask_to_polygons_min_area_filters() -> None: assert polys == [] +def test_mask_to_polygons_max_area_filters() -> None: + """The blocks are 4 and 9 m²; a 5 m² cap must keep only the smaller one. + + Without an upper bound a backend's background mask survives — SAM emitted one + covering 78 % of a real trench. + """ + polys = geo.mask_to_polygons(_two_label_mask(), TRANSFORM, "EPSG:32636", max_area=5) + assert [round(p.area) for p in polys] == [4] + + +def test_mask_to_polygons_area_bounds_combine() -> None: + polys = geo.mask_to_polygons( + _two_label_mask(), TRANSFORM, "EPSG:32636", min_area=5, max_area=10 + ) + assert [round(p.area) for p in polys] == [9] + + def test_mask_to_polygons_empty_mask() -> None: assert geo.mask_to_polygons(np.zeros((5, 5), dtype=np.int32), TRANSFORM, "EPSG:32636") == [] diff --git a/tests/test_make_segmenter.py b/tests/test_make_segmenter.py index 117ea8b..629d6dd 100644 --- a/tests/test_make_segmenter.py +++ b/tests/test_make_segmenter.py @@ -11,14 +11,22 @@ from structura.segmentation.sam import SamSegmenter -def _settings(backend: str, min_area: float = 1e-4) -> Settings: +def _settings(backend: str, **over: object) -> Settings: + kwargs: dict = { + "min_area": 1e-4, + "max_area": None, + "cellpose_diameter": None, + "cellpose_flow_threshold": 0.4, + "cellpose_cellprob_threshold": 0.0, + } + kwargs.update(over) return Settings( input_dir=Path("."), sink="file", segmentation_backend=backend, gpu=False, - min_area=min_area, gap_bridge_m=0.3, + **kwargs, # type: ignore[arg-type] output_path=Path("out.gpkg"), pg_dsn=None, pg_schema="public", @@ -41,6 +49,26 @@ def test_factory_threads_min_area_into_every_backend() -> None: assert make_segmenter(_settings(backend, min_area=0.0025)).min_area == 0.0025 +def test_factory_threads_max_area_into_every_backend() -> None: + """Same failure mode as min_area, and the one that lets a background mask + covering most of the raster reach the output.""" + for backend in ("classical", "sam", "cellpose"): + assert make_segmenter(_settings(backend, max_area=5.0)).max_area == 5.0 + assert make_segmenter(_settings(backend)).max_area is None + + +def test_factory_threads_cellpose_knobs() -> None: + seg = make_segmenter( + _settings( + "cellpose", + cellpose_diameter=30.0, + cellpose_flow_threshold=0.6, + cellpose_cellprob_threshold=-2.0, + ) + ) + assert (seg.diameter, seg.flow_threshold, seg.cellprob_threshold) == (30.0, 0.6, -2.0) + + def test_factory_unknown_backend_raises() -> None: with pytest.raises(ValueError, match="2D backend"): make_segmenter(_settings("nope")) diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py index 60bd4b8..7ebf6ef 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -22,6 +22,10 @@ def _settings(input_dir: Path, output_path: Path, min_area: float = 1e-4) -> Set segmentation_backend="classical", gpu=False, min_area=min_area, + max_area=None, + cellpose_diameter=None, + cellpose_flow_threshold=0.4, + cellpose_cellprob_threshold=0.0, gap_bridge_m=0.3, output_path=output_path, pg_dsn=None, From c63f6e8a96292b15a46cc3c002f1f563cc3cf472 Mon Sep 17 00:00:00 2001 From: Patrick Leiverkus Date: Sun, 26 Jul 2026 18:22:33 +0200 Subject: [PATCH 2/3] feat: let the Cellpose backend load a fine-tuned checkpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit STRUCTURA_CELLPOSE_MODEL points CellposeModel at custom weights. Unset keeps Cellpose's generalist default (cpsam_v2), so behaviour is unchanged. The motivating case is ImageGrains 2.0 (Mair et al. 2026, zenodo.org/records/15728186, CC-BY-4.0): Cellpose-SAM fine-tuned on sediment grains in orthophotos — the paper's literature guide calls it "Cellpose for stones". Its `IG2_full_set_cp_SAM` checkpoint is the same architecture as ours and loads unchanged; verified on the GPU host, CellposeModel on cuda:0 with 304.6 M parameters. The record's 26 MB checkpoints are Cellpose-2 U-Nets and will not load into Cellpose 4 — noted in .env.example so nobody downloads the wrong file. `pretrained_model` is omitted from the constructor call when unset rather than passed as None. That is the same mistake that kept SamSegmenter from ever running, so it gets the same shape of test, in the module that runs without the heavy extras. Co-Authored-By: Claude Opus 5 --- .env.example | 6 ++++++ src/structura/config.py | 4 ++++ src/structura/pipeline.py | 1 + src/structura/segmentation/cellpose.py | 22 +++++++++++++++++++++- tests/test_make_segmenter.py | 11 +++++++++++ tests/test_pipeline.py | 1 + 6 files changed, 44 insertions(+), 1 deletion(-) diff --git a/.env.example b/.env.example index 1981811..7f54d53 100644 --- a/.env.example +++ b/.env.example @@ -28,6 +28,12 @@ STRUCTURA_CELLPOSE_DIAMETER= # default, not a tuned value. STRUCTURA_CELLPOSE_CELLPROB=0.0 STRUCTURA_CELLPOSE_FLOW=0.4 +# Path to a fine-tuned Cellpose checkpoint. Unset = Cellpose's generalist default +# (cpsam_v2). The domain-relevant option is ImageGrains 2.0 (Mair et al. 2026), +# Cellpose-SAM fine-tuned on sediment grains: zenodo.org/records/15728186, file +# IG2_full_set_cp_SAM (1.2 GB, CC-BY-4.0). Its 26 MB checkpoints are Cellpose-2 +# U-Nets and will NOT load here. +STRUCTURA_CELLPOSE_MODEL= # --- 2.5D track (DEM wall tracing): gap-bridging tolerance in world units (m) --- STRUCTURA_GAP_BRIDGE_M=0.3 diff --git a/src/structura/config.py b/src/structura/config.py index 06661c7..ccdda83 100644 --- a/src/structura/config.py +++ b/src/structura/config.py @@ -48,6 +48,9 @@ class Settings: cellpose_diameter: float | None cellpose_flow_threshold: float cellpose_cellprob_threshold: float + # Path to a fine-tuned Cellpose checkpoint; unset uses Cellpose's own + # generalist default. See ImageGrains 2.0 for a sediment-grain model. + cellpose_model: str | None # 2.5D track: gap-bridging tolerance (world units) for wall tracing gap_bridge_m: float # File sink (default) @@ -83,6 +86,7 @@ def from_env(cls, dotenv: str | Path = ".env") -> "Settings": cellpose_cellprob_threshold=float( os.environ.get("STRUCTURA_CELLPOSE_CELLPROB", "0.0") ), + cellpose_model=os.environ.get("STRUCTURA_CELLPOSE_MODEL") or None, gap_bridge_m=float(os.environ.get("STRUCTURA_GAP_BRIDGE_M", "0.3")), output_path=Path( os.environ.get("STRUCTURA_OUTPUT_PATH", "./data/output/features.gpkg") diff --git a/src/structura/pipeline.py b/src/structura/pipeline.py index 0579f2e..c84c1ef 100644 --- a/src/structura/pipeline.py +++ b/src/structura/pipeline.py @@ -38,6 +38,7 @@ def make_segmenter(settings: Settings) -> Segmenter: diameter=settings.cellpose_diameter, flow_threshold=settings.cellpose_flow_threshold, cellprob_threshold=settings.cellpose_cellprob_threshold, + pretrained_model=settings.cellpose_model, min_area=settings.min_area, max_area=settings.max_area, ) diff --git a/src/structura/segmentation/cellpose.py b/src/structura/segmentation/cellpose.py index 3c86753..d207130 100644 --- a/src/structura/segmentation/cellpose.py +++ b/src/structura/segmentation/cellpose.py @@ -27,6 +27,7 @@ def __init__( diameter: float | None = None, flow_threshold: float = 0.4, cellprob_threshold: float = 0.0, + pretrained_model: str | Path | None = None, min_area: float = 1e-4, max_area: float | None = None, ) -> None: @@ -39,9 +40,28 @@ def __init__( # The recall knob: lower admits more (and fainter) instances. 0.0 is the # Cellpose default, not a tuned value. self.cellprob_threshold = cellprob_threshold + # Path to a fine-tuned checkpoint. Unset uses Cellpose's own default + # (`cpsam_v2`), a generalist cell model. The domain-relevant alternative + # is ImageGrains 2.0 (Mair et al. 2026), Cellpose-SAM fine-tuned on + # sediment grains in orthophotos — same architecture, so it loads here + # unchanged. Its older 26 MB checkpoints are Cellpose-2 U-Nets and will + # *not* load into Cellpose 4. + self.pretrained_model = pretrained_model self.min_area = min_area self.max_area = max_area + def cellpose_kwargs(self) -> dict: + """Constructor arguments for ``CellposeModel``. + + ``pretrained_model`` is omitted when unset so Cellpose picks its own + default, rather than being passed ``None`` — the mistake that kept the + SAM backend from ever running. + """ + kwargs: dict = {"gpu": self.gpu} + if self.pretrained_model is not None: + kwargs["pretrained_model"] = str(self.pretrained_model) + return kwargs + def segment(self, ortho_path: Path) -> list[Feature]: import numpy as np # noqa: PLC0415 from cellpose import models # noqa: PLC0415 @@ -51,7 +71,7 @@ def segment(self, ortho_path: Path) -> list[Feature]: # rasterio (bands, rows, cols) -> (rows, cols, channels), keep up to RGB. img = np.transpose(np.asarray(array), (1, 2, 0))[:, :, :3] - model = models.CellposeModel(gpu=self.gpu) + model = models.CellposeModel(**self.cellpose_kwargs()) masks, _, _ = model.eval( img, diameter=self.diameter, diff --git a/tests/test_make_segmenter.py b/tests/test_make_segmenter.py index 629d6dd..5dfae57 100644 --- a/tests/test_make_segmenter.py +++ b/tests/test_make_segmenter.py @@ -18,6 +18,7 @@ def _settings(backend: str, **over: object) -> Settings: "cellpose_diameter": None, "cellpose_flow_threshold": 0.4, "cellpose_cellprob_threshold": 0.0, + "cellpose_model": None, } kwargs.update(over) return Settings( @@ -57,6 +58,14 @@ def test_factory_threads_max_area_into_every_backend() -> None: assert make_segmenter(_settings(backend)).max_area is None +def test_cellpose_omits_pretrained_model_when_unset() -> None: + """Same shape as the SAM checkpoint bug: passing None where the library + expects a path or nothing at all. Unset must mean "use Cellpose's default".""" + assert "pretrained_model" not in CellposeSegmenter().cellpose_kwargs() + kw = CellposeSegmenter(pretrained_model="/m/IG2_full_set_cp_SAM").cellpose_kwargs() + assert kw["pretrained_model"] == "/m/IG2_full_set_cp_SAM" + + def test_factory_threads_cellpose_knobs() -> None: seg = make_segmenter( _settings( @@ -64,9 +73,11 @@ def test_factory_threads_cellpose_knobs() -> None: cellpose_diameter=30.0, cellpose_flow_threshold=0.6, cellpose_cellprob_threshold=-2.0, + cellpose_model="/m/IG2_full_set_cp_SAM", ) ) assert (seg.diameter, seg.flow_threshold, seg.cellprob_threshold) == (30.0, 0.6, -2.0) + assert seg.pretrained_model == "/m/IG2_full_set_cp_SAM" def test_factory_unknown_backend_raises() -> None: diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py index 7ebf6ef..6c75081 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -26,6 +26,7 @@ def _settings(input_dir: Path, output_path: Path, min_area: float = 1e-4) -> Set cellpose_diameter=None, cellpose_flow_threshold=0.4, cellpose_cellprob_threshold=0.0, + cellpose_model=None, gap_bridge_m=0.3, output_path=output_path, pg_dsn=None, From 0443f1813e6a5e51aa752f8fb0c81cc046e129cd Mon Sep 17 00:00:00 2001 From: Patrick Leiverkus Date: Sun, 26 Jul 2026 18:34:43 +0200 Subject: [PATCH 3/3] docs: record the scale-dependence finding as ADR-0002 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `diameter` was assumed to be a sensitivity knob. Measured on the Tiberias M10 extent it is a scale *selector*: raising it from Cellpose's own estimate (~30 px) to 120 px lifts the count from 328 to 533, but matching the instances with structura.metrics shows only 130 in common — 40 % of the original recovered. Relaxing IoU from 0.5 to 0.3 moves that to 136, so these are different objects, not the same ones drawn differently. What is lost is systematically smaller: the 198 instances that exist only at 30 px have a median of 511 cm² against 1042 cm² for the shared set, and a p10 of 40 cm² against 272. The union across four diameters holds 856 objects against 533 for the best single run — 61 % more than any one setting can produce. So there is no "correct" diameter to pick, and no default is set. The record proposes a multi-scale pass instead, mirroring dem.relief.multiscale_relief, which already does this for the 2.5D track, and notes that the merge needs containment handling rather than IoU dedup alone. The pretrained_model axis behaves the same way and more strongly: ImageGrains 2.0 returns 3130 objects at a median of 138 cm² where the generalist cpsam_v2 returns 393 at 866 cm². Both are Cellpose-SAM, so the fine-tuning material moves the band rather than raising recall. Consequence for v0.9: the evaluation has to state which scale configuration it scores, or it measures the setting as much as the model. Co-Authored-By: Claude Opus 5 --- docs/adr/0002-multiscale-2d.md | 95 ++++++++++++++++++++++++++++++++++ docs/adr/README.md | 1 + docs/architecture.md | 9 +++- 3 files changed, 104 insertions(+), 1 deletion(-) create mode 100644 docs/adr/0002-multiscale-2d.md diff --git a/docs/adr/0002-multiscale-2d.md b/docs/adr/0002-multiscale-2d.md new file mode 100644 index 0000000..73b2bde --- /dev/null +++ b/docs/adr/0002-multiscale-2d.md @@ -0,0 +1,95 @@ +# ADR-0002 — The 2D track is scale-dependent; one diameter is not a superset + +## Status + +**Proposed** — 2026-07-26. Affects the v0.3 backend defaults and the shape of the +v0.9 evaluation. + +## Context + +Cellpose's `diameter` was assumed to be a sensitivity knob: raise it and the +backend finds more, keeping what it already had. Measured on the Tiberias M10 +extent (815 m², 0.526 cm/px, `min_area` 25 cm², `max_area` 5 m², `cellprob` 0.0), +that is false. + +Object counts rise with diameter — 328 at Cellpose's own estimate (which lands at +about 30 px), 405 at 60 px, 533 at 120 px. But matching the instances against each +other with `structura.metrics.match_instances` shows they are largely **different +objects**: + +| | shared | only in A | only in B | of A recovered | +|---|---|---|---|---| +| auto (30 px) vs 60 px | 151 | 177 | 254 | 46 % | +| 60 px vs 120 px | 217 | 188 | 316 | 54 % | +| **auto vs 120 px** | **130** | **198** | **403** | **40 %** | + +This is not boundary jitter. Relaxing the IoU threshold from 0.5 to 0.3 moves the +auto-vs-120 px overlap from 130 to 136 — the instances genuinely differ rather +than being the same stones drawn slightly differently. + +What is lost when the diameter rises is systematically **smaller**: + +| | n | median | p10 | +|---|---|---|---| +| in both | 130 | 1042 cm² | 272 cm² | +| only at auto / 30 px | 198 | **511 cm²** | **40 cm²** | +| only at 120 px | 403 | 784 cm² | 196 cm² | + +So `diameter` selects a **scale band**. Raising it trades small detections for +different mid-sized ones rather than adding to the set. + +The union across all four sampled diameters, deduplicated at IoU 0.5, holds **856 +objects** against 533 for the best single run — **61 % more than any one +setting can produce**. + +The `pretrained_model` axis behaves the same way, only more strongly: +ImageGrains 2.0, fine-tuned on sediment grains, returns 3130 objects at a median +of 138 cm² where the generalist `cpsam_v2` returns 393 at 866 cm². Both are +Cellpose-SAM; the fine-tuning material moves the scale band, it does not simply +raise recall. + +## Decision + +Treat the 2D track's scale as a **sweep dimension, not a setting**. + +1. Do **not** pick a default `diameter` from these numbers. There is no value + that dominates, and "most objects" is not a quality criterion without ground + truth. +2. Add a multi-scale mode to the 2D track: run a configured set of diameters and + merge the results, mirroring `dem.relief.multiscale_relief`, which already + does exactly this for the 2.5D track. +3. The merge needs more than IoU deduplication. Containment and partial overlap + have to be resolved — one stone found whole at 120 px and as two halves at + 30 px survives both passes under an IoU-only rule, which is why the 856 above + is an upper bound on distinct detections rather than a count of stones. +4. The v0.9 evaluation must state which scale configuration it scores. A single + `(model, diameter)` pair is one sample from a family, and reporting it as + "the backend's output" would overstate what was measured. + +## Consequences + +**Accepted costs.** + +- A multi-scale pass costs roughly the sum of its scales. At 15–30 s per scale on + an RTX A4000 that is still under two minutes for a trench, so the cost is real + but small. +- The merge rule becomes a piece of project-specific logic that has to be + justified and tested. It is not a library call. +- Until it exists, any single-setting output — including everything produced on + 2026-07-26 — under-reports what the backend can find. + +**What this buys.** + +- It stops the search for a "correct" diameter, which the measurements show does + not exist. +- It makes the H_A comparison honest: the fine-tuned specialist and zero-shot SAM + are both scale-dependent, and comparing one arbitrary setting of each would + measure the settings as much as the models. +- The 61 % headroom is available without any annotation. + +**Open.** + +- Whether the extra detections at small diameters are stones or ground texture. + Only ground truth answers this, and it may turn out that the narrow band is the + right one — in which case the decision becomes "pick a band deliberately" + rather than "merge them", but it would still be a decision made on evidence. diff --git a/docs/adr/README.md b/docs/adr/README.md index aeb19cc..5ed3ab3 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -25,3 +25,4 @@ reaches the excavation database does. | ADR | Title | Status | |---|---|---| | [0001](0001-vector-sink.md) | How Structura reaches the excavation database | Proposed | +| [0002](0002-multiscale-2d.md) | The 2D track is scale-dependent; one diameter is not a superset | Proposed | diff --git a/docs/architecture.md b/docs/architecture.md index d64eecb..565f570 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -79,7 +79,14 @@ implement the `Segmenter` protocol (`segment(ortho_path) -> list[Feature]`): (`segment-geospatial`): runs on the (tiled) georeferenced ortho and writes a label raster, which is read back through the shared path. - `CellposeSegmenter` — **Cellpose-SAM (v4)** instance masks - (`CellposeModel.eval`). + (`CellposeModel.eval`). `STRUCTURA_CELLPOSE_MODEL` points it at a fine-tuned + checkpoint; ImageGrains 2.0 (sediment grains) is the domain-near option. + +**The output is scale-dependent.** `diameter` selects a size band rather than a +sensitivity: on a real trench, the instances found at 30 px and at 120 px overlap +by only 40 %, and the union across four diameters is 61 % larger than the best +single run. See [ADR-0002](adr/0002-multiscale-2d.md) — this is why no default +diameter is set, and why the v0.9 evaluation has to state its scale configuration. All three end the same way: an integer instance-label mask → `segmentation._common.label_mask_to_features` → `geo.mask_to_polygons` →