diff --git a/.env.example b/.env.example index 7f54d53..9031c00 100644 --- a/.env.example +++ b/.env.example @@ -35,8 +35,20 @@ STRUCTURA_CELLPOSE_FLOW=0.4 # U-Nets and will NOT load here. STRUCTURA_CELLPOSE_MODEL= -# --- 2.5D track (DEM wall tracing): gap-bridging tolerance in world units (m) --- +# --- 2.5D track (DEM wall/edge tracing) --- +# Gap-bridging tolerance in world units (m). STRUCTURA_GAP_BRIDGE_M=0.3 +# Relief smoothing scales, in PIXELS — so they depend on the DEM's resolution. +# They decide *what* is traced: a kernel near stone size makes the response +# follow individual stones and the line meander around them. A wall course is +# 50-100 cm wide, so the kernels must exceed a stone. At 2.10 cm/px the default +# (9,25,51) is only 19/53/107 cm; 101,201 is 213/423 cm. +STRUCTURA_RELIEF_KERNELS=9,25,51 +# Threshold on the relief response: mean + k·std, over valid pixels only. +STRUCTURA_DEM_THRESHOLD_K=1.0 +# Drop polylines shorter than this (m), and optionally simplify them (m). +STRUCTURA_DEM_MIN_LENGTH_M=0.5 +STRUCTURA_DEM_SIMPLIFY_M= # --- Output sink: "file" (GeoPackage/GeoJSON, default), "postgis" (direct) or "api" (Django) --- STRUCTURA_SINK=file diff --git a/src/structura/config.py b/src/structura/config.py index ccdda83..4e8e161 100644 --- a/src/structura/config.py +++ b/src/structura/config.py @@ -21,6 +21,14 @@ def _load_dotenv(path: Path) -> None: os.environ.setdefault(key.strip(), value.strip()) +def _int_tuple(name: str, default: tuple[int, ...]) -> tuple[int, ...]: + """Read a comma-separated integer list, e.g. STRUCTURA_RELIEF_KERNELS=51,101,201.""" + raw = os.environ.get(name, "").strip() + if not raw: + return default + return tuple(int(part) for part in raw.split(",") if part.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.""" @@ -53,6 +61,12 @@ class Settings: cellpose_model: str | None # 2.5D track: gap-bridging tolerance (world units) for wall tracing gap_bridge_m: float + # 2.5D track: relief smoothing scales in *pixels*, and the tracing thresholds. + # The kernels decide what is traced — see WallTracer. + relief_kernels: tuple[int, ...] + dem_threshold_k: float + dem_min_length_m: float + dem_simplify_m: float | None # File sink (default) output_path: Path # PostGIS @@ -88,6 +102,10 @@ def from_env(cls, dotenv: str | Path = ".env") -> "Settings": ), cellpose_model=os.environ.get("STRUCTURA_CELLPOSE_MODEL") or None, gap_bridge_m=float(os.environ.get("STRUCTURA_GAP_BRIDGE_M", "0.3")), + relief_kernels=_int_tuple("STRUCTURA_RELIEF_KERNELS", (9, 25, 51)), + dem_threshold_k=float(os.environ.get("STRUCTURA_DEM_THRESHOLD_K", "1.0")), + dem_min_length_m=float(os.environ.get("STRUCTURA_DEM_MIN_LENGTH_M", "0.5")), + dem_simplify_m=_opt_float("STRUCTURA_DEM_SIMPLIFY_M"), output_path=Path( os.environ.get("STRUCTURA_OUTPUT_PATH", "./data/output/features.gpkg") ), diff --git a/src/structura/dem/wall_tracing.py b/src/structura/dem/wall_tracing.py index 3dd14e5..6c30bfd 100644 --- a/src/structura/dem/wall_tracing.py +++ b/src/structura/dem/wall_tracing.py @@ -30,10 +30,19 @@ def __init__( self, gap_bridge_m: float = 0.3, threshold_k: float = 1.0, + kernels: tuple[int, ...] = (9, 25, 51), min_length_m: float = 0.5, simplify_m: float | None = None, ) -> None: self.gap_bridge_m = gap_bridge_m + # Relief smoothing scales in pixels. These decide *what* is traced: a + # kernel near stone size makes the response follow individual stones, so + # the skeleton meanders around them instead of running along the course. + # A wall is 50-100 cm wide, so the kernels have to exceed a stone for the + # stones to merge into one continuous ridge. In pixels, so scale them to + # the DEM's resolution — at 2.10 cm/px the default (9, 25, 51) is only + # 19, 52 and 107 cm. + self.kernels = kernels self.threshold_k = threshold_k self.min_length_m = min_length_m self.simplify_m = simplify_m @@ -42,7 +51,7 @@ def trace(self, dem_path: Path) -> list[Feature]: dem, valid, transform, crs = geo.read_dem(dem_path) res = abs(transform.a) # pixel size in world units - response = relief.multiscale_relief(dem) + response = relief.multiscale_relief(dem, kernels=self.kernels) ridge = threshold_response(response, valid, self.threshold_k) return relief_response_to_features( diff --git a/src/structura/pipeline.py b/src/structura/pipeline.py index c84c1ef..f414db0 100644 --- a/src/structura/pipeline.py +++ b/src/structura/pipeline.py @@ -73,8 +73,18 @@ def run(settings: Settings, *, write: bool = True) -> list[Feature]: if product.kind is RasterKind.ORTHO: features += segmenter.segment(product.path) elif product.kind is RasterKind.DEM: - features += WallTracer(gap_bridge_m=settings.gap_bridge_m).trace(product.path) - features += EdgeTracer().trace(product.path) + features += WallTracer( + gap_bridge_m=settings.gap_bridge_m, + threshold_k=settings.dem_threshold_k, + kernels=settings.relief_kernels, + min_length_m=settings.dem_min_length_m, + simplify_m=settings.dem_simplify_m, + ).trace(product.path) + features += EdgeTracer( + threshold_k=settings.dem_threshold_k, + min_length_m=settings.dem_min_length_m, + simplify_m=settings.dem_simplify_m, + ).trace(product.path) elif product.kind is RasterKind.SEMANTIC: # Discovered and tagged, but not consumed yet — the class field # becomes a prior for the vector tracks in v0.8. Tagging it is what diff --git a/tests/test_make_segmenter.py b/tests/test_make_segmenter.py index 5dfae57..ba44a76 100644 --- a/tests/test_make_segmenter.py +++ b/tests/test_make_segmenter.py @@ -27,6 +27,10 @@ def _settings(backend: str, **over: object) -> Settings: segmentation_backend=backend, gpu=False, gap_bridge_m=0.3, + relief_kernels=(9, 25, 51), + dem_threshold_k=1.0, + dem_min_length_m=0.5, + dem_simplify_m=None, **kwargs, # type: ignore[arg-type] output_path=Path("out.gpkg"), pg_dsn=None, diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py index 6c75081..0c3f7ba 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -28,6 +28,10 @@ def _settings(input_dir: Path, output_path: Path, min_area: float = 1e-4) -> Set cellpose_cellprob_threshold=0.0, cellpose_model=None, gap_bridge_m=0.3, + relief_kernels=(9, 25, 51), + dem_threshold_k=1.0, + dem_min_length_m=0.5, + dem_simplify_m=None, output_path=output_path, pg_dsn=None, pg_schema="public",