Files
depth_edges_annotate_worlduav/src/tests/test_inference.py
pikaliov f0c876dfc7 Add segmentation post-processing: dark water fix + wetland reclassify
Two heuristic rules applied after SegEarth-OV3 inference:

1. Dark water: if background pixels have mean_rgb < 0.24 and std < 0.08,
   reclassify as water. Fixes GTA-UAV satellite dark ocean (57% → ~15% bg).

2. Wetland reclassify (GTA-UAV only): split false-positive wetland pixels
   by color — green-dominant → vegetation, else → bare soil. Fixes 14.3%
   muddy/wetland false positives on GTA-V hillside terrain.

Config flags: seg_fix_dark_water (default True), seg_reclassify_wetland
(default False, enabled in run_gta_uav.py).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-18 02:37:43 +03:00

280 lines
11 KiB
Python

"""Tests for inference functions: depth, edges, segmentation."""
from __future__ import annotations
from unittest.mock import MagicMock
from types import SimpleNamespace
import numpy as np
import torch
from src.augmentor.inference import (
compute_edges_from_depth,
infer_chmv2_batch,
infer_depth_batch,
infer_segmentation_batch,
postprocess_segmentation,
_SOBEL_X,
_SOBEL_Y,
)
# ---------------------------------------------------------------------------
# CHMv2 — mock model
# ---------------------------------------------------------------------------
class TestInferChmv2Batch:
def _make_mock(self, B, H, W):
model = MagicMock()
model.parameters = MagicMock(return_value=iter([torch.nn.Parameter(torch.zeros(1))]))
def forward(pixel_values=None):
bb = pixel_values.shape[0]
return SimpleNamespace(predicted_depth=torch.rand(bb, H, W))
model.side_effect = forward
processor = MagicMock()
def preprocess(images=None, return_tensors=None):
bb = len(images)
return {"pixel_values": torch.rand(bb, 3, H, W)}
processor.side_effect = preprocess
def post_process(outputs, target_sizes=None):
bb = outputs.predicted_depth.shape[0]
return [
{"predicted_depth": torch.rand(target_sizes[i][0], target_sizes[i][1])}
for i in range(bb)
]
processor.post_process_depth_estimation = post_process
return model, processor
def test_output_shape(self, sample_batch: torch.Tensor) -> None:
B, _, H, W = sample_batch.shape
model, processor = self._make_mock(B, H, W)
result = infer_chmv2_batch(model, processor, sample_batch, torch.device("cpu"))
assert result.shape == (B, 1, H, W)
def test_output_normalized(self, sample_batch: torch.Tensor) -> None:
B, _, H, W = sample_batch.shape
model, processor = self._make_mock(B, H, W)
result = infer_chmv2_batch(model, processor, sample_batch, torch.device("cpu"))
assert result.min() >= 0.0
assert result.max() <= 1.0 + 1e-6
# ---------------------------------------------------------------------------
# Edges (Sobel) — pure computation, no model needed
# ---------------------------------------------------------------------------
class TestComputeEdges:
def test_output_shape(self, sample_depth: torch.Tensor) -> None:
edges = compute_edges_from_depth(sample_depth)
assert edges.shape == sample_depth.shape
def test_output_range(self, sample_depth: torch.Tensor) -> None:
edges = compute_edges_from_depth(sample_depth)
assert edges.min() >= 0.0
assert edges.max() <= 1.0 + 1e-6
def test_flat_depth_gives_zero_edges(self) -> None:
flat = torch.ones(1, 1, 32, 32) * 0.5
edges = compute_edges_from_depth(flat)
assert edges.max().item() < 1e-6
def test_single_image(self) -> None:
depth = torch.rand(1, 1, 16, 16)
edges = compute_edges_from_depth(depth)
assert edges.shape == (1, 1, 16, 16)
def test_batch_consistency(self) -> None:
"""Batched result matches per-image results."""
depth = torch.rand(4, 1, 32, 32)
edges_batch = compute_edges_from_depth(depth)
for i in range(4):
edges_single = compute_edges_from_depth(depth[i : i + 1])
torch.testing.assert_close(edges_batch[i], edges_single[0], atol=1e-5, rtol=1e-5)
class TestSobelKernelCache:
def test_kernels_are_module_level(self) -> None:
assert _SOBEL_X.shape == (1, 1, 3, 3)
assert _SOBEL_Y.shape == (1, 1, 3, 3)
assert _SOBEL_X.dtype == torch.float32
# ---------------------------------------------------------------------------
# Depth — mock model
# ---------------------------------------------------------------------------
class TestInferDepthBatch:
def test_da3_api_path(self, sample_batch: torch.Tensor) -> None:
"""Test DA3 branch with model.inference()."""
B, _, H, W = sample_batch.shape
def fake_inference(image_list, process_res=None):
depth = np.random.rand(len(image_list), H, W).astype(np.float32)
return SimpleNamespace(depth=depth)
model = MagicMock()
model.inference = fake_inference
result = infer_depth_batch(model, sample_batch, torch.device("cpu"))
assert result.shape == (B, 1, H, W)
assert result.min() >= 0.0
assert result.max() <= 1.0 + 1e-6
def test_transformers_api_path(self, sample_batch: torch.Tensor) -> None:
"""Test DA V2 fallback with model(pixel_values=x).predicted_depth."""
B, _, H, W = sample_batch.shape
# Mock a transformers-style model — spec=[] ensures hasattr(model, "inference") is False.
mock_param = torch.nn.Parameter(torch.zeros(1)) # float32
model = MagicMock(spec=[])
model.parameters = MagicMock(return_value=iter([mock_param]))
pred_depth = torch.rand(B, H, W)
model.return_value = SimpleNamespace(predicted_depth=pred_depth)
result = infer_depth_batch(model, sample_batch, torch.device("cpu"))
assert result.shape == (B, 1, H, W)
assert result.min() >= 0.0
assert result.max() <= 1.0 + 1e-6
def test_per_frame_normalization(self) -> None:
"""Each frame should be independently normalized to [0, 1]."""
B, H, W = 2, 16, 16
def fake_inference(image_list, process_res=None):
depth = np.random.rand(len(image_list), H, W).astype(np.float32) * 100
return SimpleNamespace(depth=depth)
model = MagicMock()
model.inference = fake_inference
batch = torch.rand(B, 3, H, W)
result = infer_depth_batch(model, batch, torch.device("cpu"))
for i in range(B):
assert result[i].min().item() < 0.01
assert result[i].max().item() > 0.99
# ---------------------------------------------------------------------------
# Segmentation — mock model
# ---------------------------------------------------------------------------
class TestInferSegmentationBatch:
def test_segformer_path(self, sample_batch: torch.Tensor) -> None:
"""Test SegFormer fallback branch."""
B, _, H, W = sample_batch.shape
mock_param = torch.nn.Parameter(torch.zeros(1))
model = MagicMock()
model.parameters.return_value = iter([mock_param])
num_classes = 5
logits = torch.randn(B, num_classes, H // 4, W // 4)
model.return_value = SimpleNamespace(logits=logits)
processor = SimpleNamespace(
image_mean=[0.485, 0.456, 0.406],
image_std=[0.229, 0.224, 0.225],
)
seg_config = {"type": "segformer", "processor": processor}
result = infer_segmentation_batch(model, seg_config, sample_batch, torch.device("cpu"))
assert result.shape == (B, 1, H, W)
assert result.dtype == torch.uint8
assert result.min() >= 0
assert result.max() < num_classes
def test_segearth_path(self, sample_batch: torch.Tensor) -> None:
"""Test SegEarth-OV3 branch with mock pipeline."""
B, _, H, W = sample_batch.shape
def fake_predict(pil_img, text_prompts=None):
return np.random.randint(0, 3, (H, W), dtype=np.uint8)
pipeline = MagicMock()
pipeline.predict = fake_predict
seg_config = {
"type": "segearth-ov3",
"prompts": ["bg", "building", "road"],
}
result = infer_segmentation_batch(pipeline, seg_config, sample_batch, torch.device("cpu"))
assert result.shape == (B, 1, H, W)
assert result.dtype == torch.uint8
def test_segearth_handles_failure(self, sample_batch: torch.Tensor) -> None:
"""Failed prediction should produce zero tensor, not crash."""
B, _, H, W = sample_batch.shape
pipeline = MagicMock()
pipeline.predict.side_effect = RuntimeError("OOM")
seg_config = {"type": "segearth-ov3", "prompts": ["bg"]}
result = infer_segmentation_batch(pipeline, seg_config, sample_batch, torch.device("cpu"))
assert result.shape == (B, 1, H, W)
assert (result == 0).all()
# ---------------------------------------------------------------------------
# Post-processing heuristics
# ---------------------------------------------------------------------------
class TestPostprocessSegmentation:
def test_dark_water_reclassified(self) -> None:
"""Dark uniform background → water."""
# Dark image (mean ~0.15, std ~0.02)
rgb = torch.full((1, 3, 32, 32), 0.15)
rgb += torch.randn_like(rgb) * 0.02
rgb.clamp_(0, 1)
seg = torch.zeros(1, 1, 32, 32, dtype=torch.uint8) # all background
result = postprocess_segmentation(seg, rgb)
assert (result == 4).all() # water
def test_bright_background_unchanged(self) -> None:
"""Bright background should NOT become water."""
rgb = torch.full((1, 3, 32, 32), 0.6)
seg = torch.zeros(1, 1, 32, 32, dtype=torch.uint8)
result = postprocess_segmentation(seg, rgb)
assert (result == 0).all() # stays background
def test_non_background_unchanged(self) -> None:
"""Non-background classes untouched even if dark."""
rgb = torch.full((1, 3, 32, 32), 0.1)
seg = torch.full((1, 1, 32, 32), 3, dtype=torch.uint8) # vegetation
result = postprocess_segmentation(seg, rgb)
assert (result == 3).all()
def test_wetland_reclassify_green(self) -> None:
"""Green wetland → vegetation when reclassify_wetland=True."""
rgb = torch.zeros(1, 3, 32, 32)
rgb[0, 1] = 0.6 # green dominant
rgb[0, 0] = 0.2 # low red
seg = torch.full((1, 1, 32, 32), 14, dtype=torch.uint8) # wetland
result = postprocess_segmentation(seg, rgb, reclassify_wetland=True)
assert (result == 3).all() # vegetation
def test_wetland_reclassify_brown(self) -> None:
"""Brown wetland → bare soil when reclassify_wetland=True."""
rgb = torch.zeros(1, 3, 32, 32)
rgb[0, 0] = 0.5 # red dominant
rgb[0, 1] = 0.3 # low green
seg = torch.full((1, 1, 32, 32), 14, dtype=torch.uint8)
result = postprocess_segmentation(seg, rgb, reclassify_wetland=True)
assert (result == 11).all() # bare soil
def test_wetland_unchanged_without_flag(self) -> None:
"""Wetland stays if reclassify_wetland=False."""
rgb = torch.zeros(1, 3, 32, 32)
rgb[0, 1] = 0.6
seg = torch.full((1, 1, 32, 32), 14, dtype=torch.uint8)
result = postprocess_segmentation(seg, rgb, reclassify_wetland=False)
assert (result == 14).all()