noaa-goes-visualization/tests/test_comparison_video.py

345 lines
13 KiB
Python
Raw Normal View History

"""Tests for assembling the three-pane comparison video.
The property that matters is *alignment*: a variant that produced no composite for a
timestamp -- which is exactly what the current pipeline does past its gap limit --
must still occupy that slot, or its pane slides out of step with the others and the
comparison is meaningless.
"""
import os
import numpy as np
2026-10-14 00:27:18 -04:00
import pytest
from PIL import Image
import make_comparison_video as mcv
from suvi import paths
T0 = 1715299200
def write_stream(root, variant, satellite, timestamps, size=(64, 36)):
directory = os.path.join(root, variant, f"goes{satellite}")
os.makedirs(directory, exist_ok=True)
for when in timestamps:
img = Image.fromarray(np.full((size[1], size[0], 3), 40, dtype="uint8"))
img.save(os.path.join(directory, f"Composite-{when}.jpg"), quality=90)
return directory
def test_stream_frames_reads_timestamps(tmp_path):
times = [T0, T0 + paths.CADENCE, T0 + 2 * paths.CADENCE]
directory = write_stream(str(tmp_path), "pristine", 16, times)
frames = mcv.stream_frames(directory)
assert sorted(frames) == times
assert all(os.path.exists(p) for p in frames.values())
def test_stream_frames_ignores_other_files(tmp_path):
directory = write_stream(str(tmp_path), "new", 16, [T0])
open(os.path.join(directory, "notes.txt"), "w").write("x")
open(os.path.join(directory, "Composite-nonsense.jpg"), "w").write("x")
assert list(mcv.stream_frames(directory)) == [T0]
def test_stream_frames_on_a_missing_directory(tmp_path):
assert mcv.stream_frames(str(tmp_path / "absent")) == {}
2026-10-14 00:27:18 -04:00
def test_every_known_variant_is_labelled():
"""Variants are now a CLI choice; every name the tool accepts needs a label."""
import render_real
assert set(mcv.LABELS) == {"pristine", "today", "new", "learned", "damaged",
"optical_flow"}
assert set(render_real.FILLERS) <= set(mcv.LABELS) # its stream directories
assert all(mcv.LABELS.values())
def test_encode_emits_one_frame_per_slot_including_gaps(tmp_path, monkeypatch):
"""A pane with holes must still be full length, or the panes drift apart.
'today' legitimately has none for long gaps; those slots have to become black
frames occupying the timeline, not vanish from it.
"""
times = [T0, T0 + 2 * paths.CADENCE] # slot 1 deliberately missing
directory = write_stream(str(tmp_path), "today", 16, times)
frames = mcv.stream_frames(directory)
timeline = [T0 + i * paths.CADENCE for i in range(3)]
written = []
class FakeProcess:
def __init__(self):
self.stdin = self
def write(self, data):
written.append(("real", len(data)))
def save(self, *a, **k):
pass
def close(self):
pass
def wait(self):
return 0
fake = FakeProcess()
monkeypatch.setattr(mcv.subprocess, "Popen", lambda *a, **k: fake)
class CountingImage:
size = (64, 36)
@staticmethod
def fromarray(arr):
class Blank:
def save(self, handle, *a, **k):
written.append(("black", 0))
return Blank()
@staticmethod
def open(path):
return CountingImage
monkeypatch.setattr(mcv, "Image", CountingImage)
present = mcv.encode_stream("ffmpeg", frames, timeline, "out.mp4", 60, 18)
assert present == 2
assert len(written) == 3, "a missing slot did not occupy the timeline"
assert [kind for kind, _ in written] == ["real", "black", "real"]
# ------------------------------------------------------ pristine reads the truth
def test_pristine_resolves_deleted_slots_from_the_archive(tmp_path):
"""The ground-truth pane must show the window as it really is.
A slot the case deleted has no overlay path. Resolving pristine through the
overlay silently drops those timestamps -- 221 of them in one run -- leaving the
reference pane shorter than the panes it exists to be compared against.
"""
import bench
from suvi import cases
slot = (16, 171, T0)
overlay = cases.Overlay(
archive={slot: ("/archive/real.fits", None)},
overrides={},
deleted=frozenset({slot}),
)
assert overlay.path(slot) is None, "precondition: the case deleted this slot"
assert overlay.truth_path(slot) == "/archive/real.fits"
reads = []
def fake_read(path):
reads.append(path)
return "pixels", None
original = bench.fitsio.read_image
bench.fitsio.read_image = fake_read
try:
cache = {}
assert bench._resolve_band(overlay, slot, cache, use_truth=True) == "pixels"
assert reads == ["/archive/real.fits"]
# Without use_truth the slot is genuinely gone, which is right for the
# variants that are meant to show the damage.
assert bench._resolve_band(overlay, slot, cache, use_truth=False) is None
finally:
bench.fitsio.read_image = original
def test_resolve_band_caches_truth_and_overlay_separately(tmp_path):
"""One cache serving both would return the wrong frame for one of them."""
import bench
from suvi import cases
slot = (16, 171, T0)
overlay = cases.Overlay(
archive={slot: ("/archive/real.fits", None)},
overrides={slot: "/overlay/damaged.fits"},
)
original = bench.fitsio.read_image
bench.fitsio.read_image = lambda path: (path, None)
try:
cache = {}
assert bench._resolve_band(overlay, slot, cache, use_truth=True) == "/archive/real.fits"
assert bench._resolve_band(overlay, slot, cache, use_truth=False) == "/overlay/damaged.fits"
finally:
bench.fitsio.read_image = original
2026-10-14 00:27:18 -04:00
def test_pane_spec_carries_per_pane_satellites():
assert mcv.parse_panes("damaged:18,new,damaged,learned", 16, "/r") == (
("damaged", 18, "/r"), ("new", 16, "/r"), ("damaged", 16, "/r"),
("learned", 16, "/r"),
)
def test_a_pane_can_come_from_another_render_root():
"""A new model renders only its learned stream; the inputs and the optical
flow repair of the same case are read from the earlier render."""
panes = mcv.parse_panes("damaged:18,new,damaged,learned@/renders/v7_s128",
16, "/renders/v7")
assert panes[3] == ("learned", 16, "/renders/v7_s128")
assert panes[0] == ("damaged", 18, "/renders/v7")
def test_a_pane_from_another_root_names_it_on_screen():
"""Two models' outputs must never be mistakable for one another."""
own = mcv.pane_label("learned", 16, "/renders/v7_s128/", "/renders/v7")
shared = mcv.pane_label("learned", 16, "/renders/v7", "/renders/v7")
assert own.endswith("| v7_s128")
assert "v7" not in shared.split("|")[-1]
def test_panes_from_different_roots_with_the_same_variant_stay_distinct():
panes = mcv.parse_panes("learned@/a,learned@/b", 16, "/r")
assert len(set(panes)) == 2
def test_pane_spec_rejects_unknown_variants():
with pytest.raises(SystemExit, match="unknown variant"):
mcv.parse_panes("pristine,upscaled", 16)
# ------------------------------------------------------------------ layout
def _run_graph(tmp_path, panes, labels=None):
"""Run the real filter graph through ffmpeg on tiny clips; return the output size."""
import subprocess
ffmpeg = mcv.find_ffmpeg()
clips = []
for index in range(len(panes)):
clip = str(tmp_path / f"pane{index}.mp4")
subprocess.run(f'{ffmpeg} -y -loglevel error -f lavfi -i testsrc=size=192x108:rate=4 '
f'-t 1 -pix_fmt yuv420p "{clip}"', shell=True, check=True)
clips.append(clip)
font = os.path.join(os.path.dirname(os.path.abspath(mcv.__file__)), "OpenSans-Regular.ttf")
out = str(tmp_path / "out.mp4")
inputs = " ".join(f'-i "{c}"' for c in clips)
graph = mcv.filter_graph(panes, "/r", font, labels)
result = subprocess.run(f'{ffmpeg} -y -loglevel error {inputs} -filter_complex "{graph}" '
f'-map "[out]" -pix_fmt yuv420p "{out}"',
shell=True, capture_output=True, text=True)
assert result.returncode == 0, result.stderr
probe = subprocess.run(f'{ffmpeg} -i "{out}"', shell=True, capture_output=True, text=True)
import re
return tuple(int(v) for v in re.search(r", (\d+)x(\d+)", probe.stderr).groups())
def test_a_single_stream_is_labelled_at_full_size_without_stacking(tmp_path):
"""The real-data run renders one stream; ffmpeg's stack filters refuse a
single input, which is what failed the first GOES-16 year video."""
graph = mcv.filter_graph((("learned", 16, "/r"),), "/r", "font.ttf")
assert "stack" not in graph and "scale=" not in graph
assert _run_graph(tmp_path, (("learned", 16, "/r"),)) == (192, 108)
def test_two_panes_sit_side_by_side_at_half_width(tmp_path):
panes = (("new", 16, "/r"), ("learned", 16, "/r"))
assert "hstack=inputs=2" in mcv.filter_graph(panes, "/r", "font.ttf")
assert _run_graph(tmp_path, panes) == (1920, 540)
def test_four_panes_form_a_two_by_two_grid(tmp_path):
panes = (("damaged", 18, "/r"), ("new", 16, "/r"), ("damaged", 16, "/r"),
("learned", 16, "/r"))
assert "xstack=inputs=4:layout=0_0|w0_0|0_h0|w0_h0" in mcv.filter_graph(panes, "/r", "f")
assert _run_graph(tmp_path, panes) == (1920, 1080)
# ------------------------------------------------------------------ labels
def test_without_labels_the_defaults_stand():
assert mcv.parse_labels(None, 4) is None
def test_labels_replace_the_default_pane_labels():
panes = (("optical_flow", 16, "/a"), ("learned", 16, "/b"))
labels = mcv.parse_labels("OLD DETECTION + OPTICAL FLOW, NEW DETECTION + v8", 2)
assert labels == ("OLD DETECTION + OPTICAL FLOW", "NEW DETECTION + v8")
graph = mcv.filter_graph(panes, "/r", "font.ttf", labels)
assert "text='OLD DETECTION + OPTICAL FLOW'" in graph
assert "text='NEW DETECTION + v8'" in graph
assert "LEARNED" not in graph
def test_labels_sit_bottom_centre_clear_of_the_composite_title():
"""At the top they hid the timestamp a frame is found again by."""
graph = mcv.filter_graph((("learned", 16, "/r"), ("new", 16, "/r")), "/r", "f")
assert graph.count("x=(w-text_w)/2:y=h-text_h-12") == 2
assert "y=12" not in graph
def test_one_label_per_pane_is_required():
with pytest.raises(SystemExit, match="names 2 panes; there are 4"):
mcv.parse_labels("a,b", 4)
@pytest.mark.parametrize("label", [
"it's", 'say "hi"', "a:b", "back\\slash", "$(rm -rf ~)", "`id`", "a;b", "a[0]",
"", " ", "new\nline",
])
def test_labels_that_would_break_the_filter_or_the_shell_are_refused(label):
with pytest.raises(SystemExit, match="label"):
mcv.parse_labels(f"ok,{label}", 2)
def test_every_allowed_label_character_survives_ffmpeg(tmp_path):
panes = (("optical_flow", 16, "/r"), ("learned", 16, "/r"))
labels = mcv.parse_labels("A-z 0.9 + (x) | a/b_c, OLD DETECTION + OPTICAL FLOW", 2)
assert _run_graph(tmp_path, panes, labels) == (1920, 540)
# ----------------------------------------------------------------- periods
JAN1 = 1704067200 # 2024-01-01 00:00 UTC
def test_periods_parse_as_utc_and_come_back_in_time_order():
periods = mcv.parse_periods("2024-10-03..2024-10-06, 2024-01-01..2024-01-08")
assert periods == ((JAN1, JAN1 + 7 * 86400), (1727913600, 1727913600 + 3 * 86400))
def test_adjacent_periods_are_allowed_but_overlapping_ones_are_not():
assert len(mcv.parse_periods("2024-01-01..2024-01-02,2024-01-02..2024-01-03")) == 2
with pytest.raises(SystemExit, match="overlap"):
mcv.parse_periods("2024-01-01..2024-01-05,2024-01-04..2024-01-08")
@pytest.mark.parametrize("spec", ["2024-01-01", "2024-01-01..", "..2024-01-02",
"2024-13-01..2024-12-01", "a..b",
"2024-01-01..2024-01-02..2024-01-03", ""])
def test_malformed_periods_are_refused(spec):
with pytest.raises(SystemExit, match="expected YYYY-MM-DD"):
mcv.parse_periods(spec)
def test_a_period_that_ends_before_it_starts_is_refused():
with pytest.raises(SystemExit, match="ends before"):
mcv.parse_periods("2024-01-02..2024-01-01")
with pytest.raises(SystemExit, match="ends before"):
mcv.parse_periods("2024-01-02..2024-01-02")
def test_the_default_timeline_spans_first_to_last_composite_with_gaps_kept():
step = paths.CADENCE
streams = {"a": {JAN1: "x", JAN1 + 3 * step: "y"}, "b": {JAN1 + 5 * step: "z"}}
assert mcv.build_timeline(streams) == [JAN1 + i * step for i in range(6)]
assert mcv.build_timeline({"a": {}}) == []
def test_periods_keep_only_their_own_slots_back_to_back():
"""A whole-year pane must not stretch three sample weeks into a year."""
step = paths.CADENCE
streams = {"year": {JAN1 + i * step: "x" for i in range(0, 360 * 366, 50)}}
periods = ((JAN1, JAN1 + 3 * step), (JAN1 + 86400, JAN1 + 86400 + 2 * step))
assert mcv.build_timeline(streams, periods) == [
JAN1, JAN1 + step, JAN1 + 2 * step, JAN1 + 86400, JAN1 + 86400 + step]