Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions src/structura/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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")
Expand Down
6 changes: 3 additions & 3 deletions src/structura/pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}")


Expand Down
10 changes: 9 additions & 1 deletion tests/test_make_segmenter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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"))
Expand Down
3 changes: 2 additions & 1 deletion tests/test_pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down