"""Overlap-tiled inference helpers for WildFIRE-FM probability maps.""" from __future__ import annotations from typing import Iterable, Tuple import torch import torch.nn.functional as F def _starts(length: int, tile_size: int, stride: int) -> list[int]: if length <= tile_size: return [0] out = list(range(0, max(length - tile_size + 1, 1), stride)) last = length - tile_size if out[-1] != last: out.append(last) return out def _crop_slices(top: int, left: int, tile_size: int, height: int, width: int, halo: int) -> Tuple[slice, slice, slice, slice]: y0 = 0 if top == 0 else halo x0 = 0 if left == 0 else halo y1 = tile_size if top + tile_size >= height else tile_size - halo x1 = tile_size if left + tile_size >= width else tile_size - halo return slice(y0, y1), slice(x0, x1), slice(top + y0, top + y1), slice(left + x0, left + x1) def predict_probability_tiled( model: torch.nn.Module, x: torch.Tensor, tile_size: int = 32, stride: int = 16, halo: int = 8, device: torch.device | str | None = None, batch_size: int = 16, ) -> torch.Tensor: """Predict a full probability map from an input tensor using overlap tiles. Parameters ---------- model: WildFIRE-FM model returning logits or ``(logits, aux_logits)``. x: Input tensor in ``[C, H, W]`` or ``[1, C, H, W]`` order. tile_size: Spatial tile size used for model calls. stride: Distance between tile origins. Use a value smaller than ``tile_size`` for overlap. halo: Number of pixels cropped away from interior tile borders before stitching. Border tiles keep the image edge. device: Device for inference. Defaults to the model parameter device. batch_size: Number of tiles evaluated per model call. Returns ------- torch.Tensor Probability map in ``[H, W]`` order on CPU. """ if x.ndim == 3: x = x.unsqueeze(0) if x.ndim != 4 or x.shape[0] != 1: raise ValueError("x must have shape [C, H, W] or [1, C, H, W].") if tile_size <= 0 or stride <= 0: raise ValueError("tile_size and stride must be positive.") if halo < 0 or halo * 2 >= tile_size: raise ValueError("halo must be non-negative and smaller than tile_size / 2.") if device is None: try: device = next(model.parameters()).device except StopIteration: device = torch.device("cpu") device = torch.device(device) model.eval() _, channels, height, width = x.shape pad_h = max(tile_size - height, 0) pad_w = max(tile_size - width, 0) if pad_h or pad_w: x_work = F.pad(x, (0, pad_w, 0, pad_h), mode="replicate") else: x_work = x _, _, work_h, work_w = x_work.shape output = torch.zeros((work_h, work_w), dtype=torch.float32) weight = torch.zeros((work_h, work_w), dtype=torch.float32) coords = [(top, left) for top in _starts(work_h, tile_size, stride) for left in _starts(work_w, tile_size, stride)] with torch.no_grad(): for start in range(0, len(coords), batch_size): batch_coords = coords[start : start + batch_size] tiles = torch.cat( [x_work[:, :, top : top + tile_size, left : left + tile_size] for top, left in batch_coords], dim=0, ).to(device) pred = model(tiles) logits = pred[0] if isinstance(pred, tuple) else pred probs = torch.sigmoid(logits.float()).detach().cpu()[:, 0] for prob, (top, left) in zip(probs, batch_coords): sy, sx, dy, dx = _crop_slices(top, left, tile_size, work_h, work_w, halo) output[dy, dx] += prob[sy, sx] weight[dy, dx] += 1.0 output = output / weight.clamp_min(1.0) return output[:height, :width].contiguous() __all__ = ["predict_probability_tiled"]