From f4939f5d0e242de585e4f3044a0a7eeb612aec7b Mon Sep 17 00:00:00 2001 From: Patrick Leiverkus Date: Sun, 26 Jul 2026 16:40:02 +0200 Subject: [PATCH] feat: make the 2D area filter configurable via STRUCTURA_MIN_AREA MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All three segmenters take a `min_area`, but `make_segmenter` constructed them without arguments, so the setting was unreachable — changing it meant editing the source. That only became visible on real data. A first run over a real 33 x 24 m trench orthophoto (Tiberias 2023-03-10, 0.53 cm/px) produced 1108 polygons with a median area of 3 cm²: at that resolution the 1e-4 m² default is about four pixels, so it filters nothing. With the threshold at 25 cm² the same run yields 107 polygons, median 58 cm², largest 0.14 m² — plausible stones. Thread the value from Settings into every backend and document it in .env.example, with the reasoning that the useful value scales with ground resolution rather than being a universal constant. Default unchanged, so existing behaviour is preserved. Co-Authored-By: Claude Opus 5 --- .env.example | 5 +++++ CHANGELOG.md | 6 ++++++ src/structura/config.py | 5 +++++ src/structura/pipeline.py | 6 +++--- tests/test_make_segmenter.py | 10 +++++++++- tests/test_pipeline.py | 3 ++- 6 files changed, 30 insertions(+), 5 deletions(-) diff --git a/.env.example b/.env.example index 89e6dd3..5f8025d 100644 --- a/.env.example +++ b/.env.example @@ -8,6 +8,11 @@ STRUCTURA_INPUT_DIR=./data/incoming STRUCTURA_2D_BACKEND=classical # Use GPU for the sam/cellpose backends (true/false). STRUCTURA_GPU=false +# Drop 2D polygons below this area, in world units squared (m² for a metric CRS). +# Scale it to the ground resolution: at 0.5 cm/px the default is about 4 pixels, +# 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 # --- 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 aa8c963..230f053 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **`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 + the default (1e-4 m², about four pixels at 0.5 cm/px) filters nothing: a first + run on a 33 × 24 m trench produced 1108 polygons with a median area of 3 cm². + At 25 cm² the same run yields 107, median 58 cm². - **`uv.lock` — CI installs locked dependencies instead of resolving them.** The workflow now runs `uv sync --locked --extra dev --extra geo`, and `--locked` fails if the lock is stale with respect to `pyproject.toml`, so a dependency diff --git a/src/structura/config.py b/src/structura/config.py index 3124b51..b409499 100644 --- a/src/structura/config.py +++ b/src/structura/config.py @@ -28,6 +28,10 @@ class Settings: # 2D segmentation backend: "classical" | "sam" | "cellpose" segmentation_backend: str gpu: bool + # 2D track: drop polygons below this area, in world units squared (CRS). + # 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 # 2.5D track: gap-bridging tolerance (world units) for wall tracing gap_bridge_m: float # File sink (default) @@ -54,6 +58,7 @@ def from_env(cls, dotenv: str | Path = ".env") -> "Settings": sink=os.environ.get("STRUCTURA_SINK", "file"), 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")), 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 c4232c1..674bf5d 100644 --- a/src/structura/pipeline.py +++ b/src/structura/pipeline.py @@ -27,11 +27,11 @@ def make_segmenter(settings: Settings) -> Segmenter: backend = settings.segmentation_backend if backend == "classical": - return ClassicalSegmenter() + return ClassicalSegmenter(min_area=settings.min_area) if backend == "sam": - return SamSegmenter() + return SamSegmenter(min_area=settings.min_area) if backend == "cellpose": - return CellposeSegmenter(gpu=settings.gpu) + return CellposeSegmenter(gpu=settings.gpu, min_area=settings.min_area) raise ValueError(f"Unknown 2D backend: {backend!r}") diff --git a/tests/test_make_segmenter.py b/tests/test_make_segmenter.py index ce4c6c3..2ad15f2 100644 --- a/tests/test_make_segmenter.py +++ b/tests/test_make_segmenter.py @@ -11,12 +11,13 @@ from structura.segmentation.sam import SamSegmenter -def _settings(backend: str) -> Settings: +def _settings(backend: str, min_area: float = 1e-4) -> Settings: return Settings( input_dir=Path("."), sink="file", segmentation_backend=backend, gpu=False, + min_area=min_area, gap_bridge_m=0.3, output_path=Path("out.gpkg"), pg_dsn=None, @@ -33,6 +34,13 @@ def test_factory_returns_correct_backend() -> None: assert isinstance(make_segmenter(_settings("cellpose")), CellposeSegmenter) +def test_factory_threads_min_area_into_every_backend() -> None: + """Without this the setting is unreachable: the segmenters accept `min_area`, + but on real data the only way to change it used to be editing the source.""" + for backend in ("classical", "sam", "cellpose"): + assert make_segmenter(_settings(backend, min_area=0.0025)).min_area == 0.0025 + + 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 da714a6..60bd4b8 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -15,12 +15,13 @@ from structura.config import Settings -def _settings(input_dir: Path, output_path: Path) -> Settings: +def _settings(input_dir: Path, output_path: Path, min_area: float = 1e-4) -> Settings: return Settings( input_dir=input_dir, sink="file", segmentation_backend="classical", gpu=False, + min_area=min_area, gap_bridge_m=0.3, output_path=output_path, pg_dsn=None,