"""Stage 1: DL inference. RGB (+ optional DEM) -> probability raster (ONNX). Ported from FraXtex's `fraxtex_tools/inference.py`, which is canonical for this pipeline (see CLAUDE.md). The logic below is deliberately kept line-comparable with the plugin: same contract reading, same Hann-blended overlapping tiles, same DEM handling, same output convention (high value = fracture). Two deliberate differences, both because the Space knows things the plugin cannot: * No input-mode enum. The plugin lets the user load any .onnx, so it must offer auto/rgb/rgb_dem to resolve what the file needs. Here the model comes from a fixed registry, so the channel count from the graph is always authoritative. * Input size limits (`limits.py`), because this container is shared. """ import numpy as np import onnxruntime as ort import rasterio from .limits import check_size def load_session(model_path): """Open an ONNX model and read the input contract out of it. Returns `(session, input_name, contract)` where contract carries the channel count, patch size and whether the caller must scale to [0, 1]. """ session = ort.InferenceSession(model_path, providers=["CPUExecutionProvider"]) input_name = session.get_inputs()[0].name shape = session.get_inputs()[0].shape # Symbolic dims (e.g. 'channels') are strings, so model_ch stays None. model_ch = shape[1] if len(shape) == 4 and isinstance(shape[1], int) else None # Tile size: the graph when it pins one (512 for SAM 2, 256 for the RGB+DEM # pair), else the historical default for dynamic exports. dim_h, dim_w = (shape[2], shape[3]) if len(shape) == 4 else (None, None) if isinstance(dim_h, int) and isinstance(dim_w, int): ps, ps_pinned = dim_h, True square = dim_h == dim_w else: ps, ps_pinned, square = 256, False, True meta = session.get_modelmeta().custom_metadata_map or {} return session, input_name, { "channels": model_ch, "patch_size": ps, "patch_pinned": ps_pinned, "square": square, "meta": meta, } def _resolve_input_range(contract, use_dem, feedback): """Decide whether the caller scales to [0, 1], from what the model declares. Preprocessing is not visible in the graph, so the model declares it: some models normalize internally and want raw 0-255. The channel count is the fallback for exports made before the keys existed. """ meta = contract["meta"] declared = (meta.get("input.range") or "").strip() if not declared and meta.get("preprocessing.in_graph", "").strip().lower() == "true": declared = "0-255" if declared in ("0-1", "0-255"): feedback.pushInfo(f"Model declares input range {declared}.") return declared == "0-1", False if declared: feedback.pushWarning( f"Ignoring unrecognised declared input range '{declared}'; " "expected '0-1' or '0-255'." ) scale_255 = use_dem feedback.pushInfo( "Model declares no input range; assuming " + ("0-1, scaling by /255" if scale_255 else "0-255, raw values") + f" from its {4 if use_dem else 3}-channel input." ) return scale_255, True def _read_rgb(rgb_path): """Read an orthophoto to HWC uint8. rasterio handles both PNG and GeoTIFF.""" with rasterio.open(rgb_path) as src: img = src.read() if img.shape[0] >= 3: img = img[:3].transpose(1, 2, 0) elif img.shape[0] == 2: img = img[0:1].repeat(3, axis=0).transpose(1, 2, 0) else: img = img.repeat(3, axis=0).transpose(1, 2, 0) if img.dtype != np.uint8: img = (img * 255).astype(np.uint8) if img.max() <= 1.0 else img.astype(np.uint8) return img, src.profile.copy(), src.crs def run_inference(rgb_path, model_path, feedback, dem_path=None, overlap=0.25): """Run tiled ONNX inference and return `(pred_uint8, profile, info)`. `pred_uint8` follows the plugin's convention: **high values mean fracture**. """ session, input_name, contract = load_session(model_path) model_ch = contract["channels"] ps = contract["patch_size"] if model_ch is not None and model_ch not in (3, 4): feedback.reportError( f"This model declares {model_ch} input channels. Only 3 (RGB) and " "4 (RGB+DEM) are supported.", fatalError=True, ) if not contract["square"]: feedback.reportError( "This model expects non-square inputs. Only square patches are supported.", fatalError=True, ) has_dem = dem_path is not None # The registry always yields a declared channel count; the None branch exists so # a third-party model dropped into the registry still behaves like the plugin. use_dem = has_dem if model_ch is None else model_ch == 4 if use_dem and not has_dem: feedback.reportError( "This model expects 4 input channels (RGB+DEM) but no DEM was provided. " "Provide a DEM, or choose an RGB-only model.", fatalError=True, ) if has_dem and not use_dem: feedback.pushWarning( "Running RGB-only: the supplied DEM is not fed to the model. It still " "provides the output georeferencing." ) scale_255, range_guessed = _resolve_input_range(contract, use_dem, feedback) img, rgb_profile, rgb_crs = _read_rgb(rgb_path) check_size(img.shape[0], img.shape[1], feedback) # The DEM donates the output profile whenever it is supplied, since the RGB may # be a plain PNG with no CRS. dem_arr = None if has_dem: with rasterio.open(dem_path) as src: profile = src.profile.copy() if (src.height, src.width) != img.shape[:2]: feedback.reportError( f"RGB and DEM must have the same dimensions " f"(RGB {img.shape[1]}x{img.shape[0]}, DEM {src.width}x{src.height}).", fatalError=True, ) if use_dem: dem_arr = src.read(1).astype(np.float32) if use_dem: dem_lo, dem_hi = float(dem_arr.min()), float(dem_arr.max()) feedback.pushInfo(f"DEM value range: [{dem_lo:.3f}, {dem_hi:.3f}]") if dem_lo < 0.0 or dem_hi > 255.0: feedback.pushWarning( "DEM values fall outside 0-255. The DEM band is scaled exactly like " "the RGB bands, so a DEM in raw metres enters the model far outside " "the range it was trained on. Byte-scale the DEM the way it was " "scaled for training." ) else: # A PNG source profile cannot be written back, so pin the driver. profile = rgb_profile profile.update(driver="GTiff") if rgb_crs is None: feedback.pushWarning( "The RGB orthophoto has no CRS, so the output probability raster is " "not georeferenced and vectorization works in pixel coordinates." ) # Overlapping patches, Hann-blended: no tile seams. if use_dem: combined = np.concatenate((img[:, :, :3], np.expand_dims(dem_arr, 2)), axis=2) del img, dem_arr # combined owns its data; the sources are dead weight now else: combined = img[:, :, :3] # a view, so img stays alive either way h, w = combined.shape[:2] n_ch = combined.shape[2] # Origins clamp to the far edge, so only sub-patch images need padding. pad_h = max(0, ps - h) pad_w = max(0, ps - w) if pad_h or pad_w: padded = np.pad(combined, ((0, pad_h), (0, pad_w), (0, 0)), mode="symmetric") else: padded = combined ph, pw = padded.shape[:2] stride = max(1, int(round(ps * (1.0 - overlap)))) def origins(size): offs = list(range(0, size - ps + 1, stride)) if offs[-1] != size - ps: offs.append(size - ps) # flush against the far edge return offs ys, xs = origins(ph), origins(pw) # Taper: ~0 at the patch border, 1 at its centre. Min weight ~1.5e-4, so # dividing a single-coverage pixel by it is safe in float32. w1d = np.hanning(ps + 2)[1:-1].astype(np.float32) win = np.outer(w1d, w1d) # win is separable over a full ys x xs grid, so the total weight per pixel is # outer(row_cov, col_cov), so no second full-size accumulator. def coverage(offs, size): cov = np.zeros(size, dtype=np.float32) for o in offs: cov[o:o + ps] += w1d return cov row_cov = coverage(ys, ph) col_cov = coverage(xs, pw) pred_sum = np.zeros((ph, pw), dtype=np.float32) total = len(ys) * len(xs) done = 0 feedback.pushInfo( f"Tiling: {len(xs)} x {len(ys)} = {total} patches " f"({ps} px, {int(overlap * 100)}% overlap, stride {stride}, {n_ch} channels)" ) for y in ys: for x in xs: patch = padded[y:y + ps, x:x + ps, :] patch_t = patch.transpose(2, 0, 1)[np.newaxis, ...].astype(np.float32) if scale_255: patch_t /= 255.0 if done == 0: try: out = session.run(None, {input_name: patch_t}) except Exception as exc: hint = ( f"The model declares {model_ch} input channels and was given " "that many, so the channel count is not the problem." + ( f" The {ps}x{ps} patch size came from the model itself." if contract["patch_pinned"] else f" It declares no patch size, so {ps}x{ps} was assumed." ) ) feedback.reportError( f"The model rejected a {n_ch}-channel {ps}x{ps} input: {exc}\n{hint}", fatalError=True, ) else: out = session.run(None, {input_name: patch_t}) pred_sum[y:y + ps, x:x + ps] += out[0][0, 0] * win done += 1 feedback.setProgress(int(done / total * 100)) # In place; both profiles are > 0 since every pixel is covered. pred_sum /= row_cov[:, np.newaxis] pred_sum /= col_cov[np.newaxis, :] pred = pred_sum[:h, :w] # Logits would wrap modulo 256 in the cast below. # # Deviation from the canonical plugin: it tests `lo < 0.0 or hi > 1.0` exactly. # A sigmoid that saturates to 1.0 (normal, and what unet-rgb does on the example # tile) picks up ~1.2e-07 of float32 error from the Hann blend and the coverage # division, so the exact test fires on every run of a perfectly good model. The # tolerance is far above that error and far below any real logit. Reported # upstream; revert this once the plugin carries the same tolerance. RANGE_TOL = 1e-5 lo, hi = float(pred.min()), float(pred.max()) if lo < -RANGE_TOL or hi > 1.0 + RANGE_TOL: feedback.pushWarning( f"Model output range [{lo:.3f}, {hi:.3f}] falls outside 0-1 and was clipped. " "This expects a model that emits probabilities (e.g. with a final sigmoid), " "not raw logits." ) # Clipped unconditionally, not just in the warning branch. Real logits are what # this guards: 8.0 * 255 = 2040 wraps to 248 in the uint8 cast below. The # sub-tolerance overshoot is harmless by comparison (255.00003 truncates to 255) # but clipping it too keeps the output provably in range. pred = np.clip(pred, 0.0, 1.0) # A near-constant map is the fingerprint of feeding the wrong input range. # Measured on the four published models: wrong range spans 0.000-0.016, right # range spans 0.79-1.00, so 0.05 sits in the gap between them. if range_guessed and hi - lo < 0.05: feedback.pushWarning( f"Output is nearly constant (range [{lo:.4f}, {hi:.4f}]) and this model " "declares no input range, so it was inferred from the channel count. The " "model may expect the other range." ) pred_uint8 = (pred * 255).astype(np.uint8) profile.update(count=1, dtype=rasterio.uint8, nodata=None, compress="lzw") info = { "crs": profile.get("crs"), "transform": profile.get("transform"), "patches": total, "patch_size": ps, "channels": n_ch, "used_dem": use_dem, "output_range": (lo, hi), } feedback.pushInfo("Inference complete.") return pred_uint8, profile, info