Files
ykcheungab c377c14474 1. added blender deformation probe for marker size picking and optimization
2. added unwarp map export in current cli, don't need to generate by precompute in sensor running
2026-09-25 15:44:44 +08:00

400 lines
14 KiB
Python

"""Tests for the noise model: real idle calibration, injected noise, retention.
These cover the correction that matters most in this bench. The render-seed floor was
being used as a noise model, its ordering reverses under additive noise, and it
understates the real idle floor measured from hardware. Everything here needs only
numpy and cv2 — no Blender — so the noise path is testable on its own.
"""
from __future__ import annotations
import json
from pathlib import Path
from types import SimpleNamespace
import numpy as np
import pytest
from orisys_sensor_design import compare, floorcal, flow, sensitivity
cv2 = pytest.importorskip("cv2")
def _pattern(size: int = 96, cell: int = 8, seed: int = 3) -> np.ndarray:
rng = np.random.RandomState(seed)
grid = rng.rand(size // cell + 1, size // cell + 1) * 255.0
return cv2.resize(grid, (size, size), interpolation=cv2.INTER_NEAREST).astype(np.uint8)
def _write_frames(directory: Path, frames: list[np.ndarray]) -> Path:
directory.mkdir(parents=True, exist_ok=True)
for index, frame in enumerate(frames):
assert cv2.imwrite(str(directory / f"{index:04d}.png"), frame)
return directory
def _noisy_clip(directory: Path, sigma: float, count: int = 8, seed: int = 11) -> Path:
base = _pattern()
rng = np.random.RandomState(seed)
frames = []
for _ in range(count):
noisy = base.astype(np.float32) + rng.normal(0.0, sigma, base.shape)
frames.append(np.clip(noisy, 0, 255).astype(np.uint8))
return _write_frames(directory, frames)
# --------------------------------------------------------------------------- #
# Real idle calibration
# --------------------------------------------------------------------------- #
def test_idle_floor_grows_with_noise(tmp_path: Path) -> None:
quiet = floorcal.measure_idle_floor(_noisy_clip(tmp_path / "q", 1.0), max_frames=8)
loud = floorcal.measure_idle_floor(_noisy_clip(tmp_path / "l", 8.0), max_frames=8)
assert quiet.floor_px_short >= 0.0
assert loud.floor_px_short > quiet.floor_px_short
assert quiet.frames == 8
def test_idle_floor_reports_a_dominant_period(tmp_path: Path) -> None:
measured = floorcal.measure_idle_floor(_noisy_clip(tmp_path / "p", 2.0), max_frames=8)
assert measured.dominant_period_px > 0
assert measured.floor_per_period_short > 0
payload = measured.to_json()
assert payload["roi"]["pixels"] > 0
assert "short = consecutive frames" in payload["basis"]
def test_drift_shows_as_a_long_over_short_ratio(tmp_path: Path) -> None:
"""A drifting scene must show long > short; white noise must not."""
base = _pattern()
drifting = []
for index in range(8):
matrix = np.float32([[1, 0, 0.35 * index], [0, 1, 0]])
drifting.append(
cv2.warpAffine(base, matrix, (base.shape[1], base.shape[0]),
flags=cv2.INTER_LINEAR, borderMode=cv2.BORDER_REPLICATE)
)
drift = floorcal.measure_idle_floor(_write_frames(tmp_path / "d", drifting), max_frames=8)
white = floorcal.measure_idle_floor(_noisy_clip(tmp_path / "w", 4.0), max_frames=8)
assert drift.drift_ratio > 1.0
assert white.drift_ratio < drift.drift_ratio
def test_measure_idle_floor_rejects_missing_and_tiny_sources(tmp_path: Path) -> None:
with pytest.raises(floorcal.FloorCalibrationError):
floorcal.measure_idle_floor(tmp_path / "nope.mp4")
single = _write_frames(tmp_path / "one", [_pattern()])
with pytest.raises(floorcal.FloorCalibrationError):
floorcal.measure_idle_floor(single)
def test_resolve_idle_source_tolerates_absence(tmp_path: Path) -> None:
assert floorcal.resolve_idle_source(tmp_path, None) is None
assert floorcal.resolve_idle_source(tmp_path, "missing.mp4") is None
present = tmp_path / "clip.mp4"
present.write_bytes(b"not really a video")
assert floorcal.resolve_idle_source(tmp_path, "clip.mp4") == present.resolve()
# --------------------------------------------------------------------------- #
# Injected noise
# --------------------------------------------------------------------------- #
def test_inject_noise_is_deterministic_and_zero_sigma_is_identity() -> None:
base = _pattern()
assert np.array_equal(flow.inject_noise(base, 0.0, 1), base)
first = flow.inject_noise(base, 5.0, 7)
second = flow.inject_noise(base, 5.0, 7)
assert np.array_equal(first, second)
assert not np.array_equal(first, flow.inject_noise(base, 5.0, 8))
assert first.dtype == np.uint8
def _axis_frames(size: int = 96) -> dict:
base = _pattern(size)
frames = {}
for axis, matrix in (
("x", np.float32([[1, 0, 0.8], [0, 1, 0]])),
("y", np.float32([[1, 0, 0], [0, 1, 0.8]])),
):
steps = []
for step_mm in (0.01, 0.05, 0.10):
shifted = cv2.warpAffine(
base, np.float32(matrix * (1, 1, step_mm / 0.10)), (size, size),
flags=cv2.INTER_LINEAR, borderMode=cv2.BORDER_REPLICATE,
)
steps.append((step_mm, shifted))
frames[axis] = steps
return {"base": base, "frames": frames}
def test_noise_model_pass_reports_gain_floor_and_samples() -> None:
data = _axis_frames()
reference = data["base"]
mask, _ = flow.region_masks(reference.shape, (48.0, 48.0), 20.0)
result = flow.noise_model_pass(
reference=reference,
axis_frames=data["frames"],
directions={"x": (1.0, 0.0), "y": (0.0, 1.0)},
roi_mask=mask,
config={"dis_preset": "medium", "dis_mode": 1},
sigmas=(2.0, 8.0),
draws=1,
)
assert result["sigmas"] == [2.0, 8.0]
for axis in ("x", "y"):
entries = result["axes"][axis]
assert [entry["sigma"] for entry in entries] == [2.0, 8.0]
for entry in entries:
assert entry["gain_px_per_mm"] is not None
assert entry["floor_px"] is not None and entry["floor_px"] > 0.0
assert entry["floor_samples"] == 1
assert entry["points"] == 3
# A larger sigma must not produce a quieter floor.
x_entries = {entry["sigma"]: entry for entry in result["axes"]["x"]}
assert x_entries[8.0]["floor_px"] >= x_entries[2.0]["floor_px"]
def test_noise_model_pass_is_deterministic() -> None:
data = _axis_frames()
mask, _ = flow.region_masks(data["base"].shape, (48.0, 48.0), 20.0)
kwargs = dict(
reference=data["base"],
axis_frames=data["frames"],
directions={"x": (1.0, 0.0)},
roi_mask=mask,
config={"dis_preset": "medium", "dis_mode": 1},
sigmas=(4.0,),
draws=1,
)
first = flow.noise_model_pass(**kwargs)
second = flow.noise_model_pass(**kwargs)
assert json.dumps(first, sort_keys=True) == json.dumps(second, sort_keys=True)
def test_resolve_noise_settings_defaults_and_overrides() -> None:
default = flow._resolve_noise_settings({})
assert default["enabled"] is True
assert default["scales"] == ["render"]
assert default["sigmas"] == list(flow.DEFAULT_NOISE_SIGMAS)
off = flow._resolve_noise_settings(
{"settings": {"noise": {"models": ["render_seed"], "sigmas": [4.0]}}}
)
assert off["enabled"] is False
bad = flow._resolve_noise_settings(
{"settings": {"noise": {"sigmas": [-1.0, 0.0, 999.0], "draws": 0}}}
)
assert bad["sigmas"] == list(flow.DEFAULT_NOISE_SIGMAS)
assert bad["draws"] == 1
def test_track_job_carries_the_noise_block(tmp_path: Path) -> None:
"""Regression: `build_track_job` omitted `noise`, so every --noise-* flag and the
design.yaml block were silently ignored and the pass ran on built-in defaults (which
happened to match, hiding it). The job must carry what the caller asked for."""
settings = SimpleNamespace(
resolution=(64, 64),
process_resolution=(32, 32),
axes=("x",),
step_mm=0.01,
max_mm=0.10,
radius_mm=0.5,
tracker={"dis_mode": 1, "dis_preset": "medium"},
noise={"sigmas": [3.0], "draws": 7, "scales": ["process"], "models": ["injected"]},
)
job = flow.build_track_job(settings, tmp_path)
assert job["settings"]["noise"] == {
"sigmas": [3.0],
"draws": 7,
"scales": ["process"],
"models": ["injected"],
}
resolved = flow._resolve_noise_settings(job)
assert resolved["draws"] == 7
assert resolved["sigmas"] == [3.0]
assert resolved["scales"] == ["process"]
def test_floor_basis_is_labelled_render_seed() -> None:
"""The reclassification must be machine-readable, not just prose."""
source = Path(flow.__file__).read_text(encoding="utf-8")
assert '"floor_basis": "render_seed_reproducibility"' in source
assert "NOT a sensor-noise model" in source
# --------------------------------------------------------------------------- #
# Retention and disagreement, in the sensitivity report
# --------------------------------------------------------------------------- #
def _scored_scale(gain: float = 10.0):
from orisys_sensor_design import sensitivity as sens
axis = sens.AxisScore(
axis="x",
direction_px=(1.0, 0.0),
direction_source="test",
floor_px=0.02,
floor_source="null_projection_p95",
points=(),
gain_px_per_mm=gain,
gain_mm_per_mm=None,
scale_px_per_mm=13.0,
gain_gate="no_gain",
snr=1.0,
spearman=1.0,
resolvable_step_mm=None,
saturation_mm=None,
cross_axis_leak=0.0,
score=1.0,
verdict="resolvable",
px_per_mm_axis=13.0,
)
return sens.ScaleScore(
name="render",
width=500,
height=500,
px_per_mm=13.0,
displacement_px_per_step=0.13,
cell_px=6.25,
cell_over_patch_px=0.78,
null_floor_px=0.02,
null_floor_reported_px=0.02,
axes=(axis,),
score=1.0,
verdict="resolvable",
)
def test_attach_noise_models_computes_retention_and_snr() -> None:
scale = _scored_scale(gain=10.0)
report = {
"scales": [
{
"name": "render",
"noise_models": {
"basis": "injected",
"draws": 1,
"sigmas": [4.0],
"axes": {
"x": [
{
"sigma": 4.0,
"gain_px_per_mm": 8.0,
"floor_px": 0.04,
"points": 10,
"floor_samples": 1,
}
]
},
},
}
]
}
attached = sensitivity.attach_noise_models([scale], report, max_mm=0.10)
models = attached[0].noise_models
assert models is not None
row = models["axes"]["x"][0]
assert row["response_retention"] == pytest.approx(0.8)
# snr under the same model: |gain| * max_mm / floor
assert row["snr_noise"] == pytest.approx(8.0 * 0.10 / 0.04)
def test_noise_disagreement_fires_only_past_the_threshold() -> None:
scale = _scored_scale(gain=10.0)
noisy = {
"axes": {"x": [{"sigma": 16.0, "gain_px_per_mm": 6.0, "floor_px": 0.05}]},
}
import dataclasses
loud = dataclasses.replace(scale, noise_models=noisy)
message = sensitivity.noise_disagreement(loud)
assert message is not None and "60%" in message
quiet = dataclasses.replace(
scale,
noise_models={"axes": {"x": [{"sigma": 16.0, "gain_px_per_mm": 9.5, "floor_px": 0.05}]}},
)
assert sensitivity.noise_disagreement(quiet) is None
assert sensitivity.noise_disagreement(scale) is None
def test_calibration_note_states_the_factor() -> None:
note = sensitivity.calibration_note(
{
"floor_per_period_short": 0.0089,
"floor_px_short": 0.2267,
"width": 1280,
"height": 720,
"dominant_period_px": 25.5,
"drift_ratio": 0.98,
}
)
assert "0.00890" in note
assert "white noise" in note
def test_noise_calibration_is_cached_and_skippable(tmp_path: Path) -> None:
frames = _noisy_clip(tmp_path / "footage", 3.0, count=6)
settings = SimpleNamespace(noise={"idle_footage": str(frames), "max_frames": 6})
first = sensitivity.noise_calibration(tmp_path, settings)
assert first is not None and first["source"] == "measured"
assert (tmp_path / sensitivity.IDLE_CALIBRATION_FILENAME).is_file()
second = sensitivity.noise_calibration(tmp_path, settings)
assert second is not None and second["source"] == "cached"
assert sensitivity.noise_calibration(tmp_path, settings, enabled=False) is None
unset = sensitivity.noise_calibration(tmp_path, SimpleNamespace(noise={}))
assert unset is not None and unset["source"] == "unavailable"
# --------------------------------------------------------------------------- #
# Figure selection: the duplicate-label regression
# --------------------------------------------------------------------------- #
def _twelve_runs() -> list[dict]:
runs = []
for nx in (31, 56, 80):
for radius in (0.5, 1.5):
for dis_mode in (1, 0):
runs.append(
{
"id": f"nx{nx}@r{radius}@dm{dis_mode}",
"nx": nx,
"radius_mm": radius,
"dis_mode": dis_mode,
"score": float(nx + radius + dis_mode),
}
)
return runs
def test_select_runs_is_unique_and_represents_every_dis_mode() -> None:
runs = _twelve_runs()
chosen = compare.select_runs(runs, limit=4, reference_radius=0.5)
ids = [run["id"] for run in chosen]
assert len(ids) == len(set(ids)), "a run must not appear twice"
assert len(ids) == 4
# Global best and worst are always present.
ranked = sorted(runs, key=lambda run: -run["score"])
assert ranked[0]["id"] in ids and ranked[-1]["id"] in ids
# And the shipped setting (dm1) is represented at the reference radius: the old
# best+worst-of-keys[:1] rule picked dm0 only and dropped it.
assert any(
run["id"] in ids and run["dis_mode"] == 1 and run["radius_mm"] == 0.5
for run in runs
)
def test_select_runs_never_duplicates_when_radii_differ() -> None:
"""The exact shape that produced two identical `nx=80 dm0` legend entries."""
runs = _twelve_runs()
chosen = compare.select_runs(runs, limit=6, reference_radius=0.5)
labels = [f"nx={r['nx']} r={r['radius_mm']:g} mm dm={r['dis_mode']}" for r in chosen]
assert len(labels) == len(set(labels))