"""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))