Add reproducible CPU-student animation renderer
Browse files
scripts/make_cpu_student_tracking_animations.py
ADDED
|
@@ -0,0 +1,321 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Create real-data GIFs of the promoted CPU student's detector and tracker."""
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import hashlib
|
| 6 |
+
import json
|
| 7 |
+
import pickle
|
| 8 |
+
import sys
|
| 9 |
+
from dataclasses import dataclass
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
|
| 12 |
+
import numpy as np
|
| 13 |
+
from PIL import Image, ImageDraw, ImageFont
|
| 14 |
+
|
| 15 |
+
ROOT = Path(__file__).resolve().parents[1]
|
| 16 |
+
sys.path.insert(0, str(ROOT / "src"))
|
| 17 |
+
|
| 18 |
+
from orbitsight.eval.metrics import iou_xywh # noqa: E402
|
| 19 |
+
from orbitsight.ingestion import discover_sequences, iter_windows, load_events, load_gt_boxes # noqa: E402
|
| 20 |
+
from orbitsight.postprocessing import finalize_detections, rank_detections # noqa: E402
|
| 21 |
+
from orbitsight.tracking import get_tracking_policy # noqa: E402
|
| 22 |
+
from orbitsight.tracking.tracker import tracks_to_detections # noqa: E402
|
| 23 |
+
from orbitsight.viz.visualize import _event_image # noqa: E402
|
| 24 |
+
|
| 25 |
+
CACHE = ROOT / "artifacts/cpu_student/post_training_latency_neutral/current_student_top1_cache.pkl"
|
| 26 |
+
OUT = ROOT / "competition_ready_improved_cpu_student/visualizations/animations"
|
| 27 |
+
|
| 28 |
+
BG = "#07111f"
|
| 29 |
+
PANEL = "#0e2035"
|
| 30 |
+
TEXT = "#e9f2f9"
|
| 31 |
+
MUTED = "#91a7ba"
|
| 32 |
+
DETECTOR = "#ff5a5f"
|
| 33 |
+
TRACKER = "#39e6ff"
|
| 34 |
+
GT = "#ffd43b"
|
| 35 |
+
GOOD = "#54e38e"
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
@dataclass(frozen=True)
|
| 39 |
+
class Clip:
|
| 40 |
+
slug: str
|
| 41 |
+
sequence: str
|
| 42 |
+
start: int
|
| 43 |
+
end: int
|
| 44 |
+
title: str
|
| 45 |
+
subtitle: str
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
CLIPS = (
|
| 49 |
+
Clip(
|
| 50 |
+
"evk4_bright_track",
|
| 51 |
+
"2025_12_23_20_53_46_EVK4_mag7.3",
|
| 52 |
+
82,
|
| 53 |
+
113,
|
| 54 |
+
"EVK4: detector + recurrent tracker",
|
| 55 |
+
"Large-sensor event field; cyan Kalman state follows the raw red proposal",
|
| 56 |
+
),
|
| 57 |
+
Clip(
|
| 58 |
+
"dvx_stars_tracker_smoothing",
|
| 59 |
+
"DVX_Filtered_Stars3_2025-01-20-20-22-53",
|
| 60 |
+
4136,
|
| 61 |
+
4165,
|
| 62 |
+
"DVX Stars3: tracker smoothing in clutter",
|
| 63 |
+
"A real interval where tracking materially improves several localization overlaps",
|
| 64 |
+
),
|
| 65 |
+
Clip(
|
| 66 |
+
"dvx_thuraya_low_snr",
|
| 67 |
+
"DVX_Filtered_Thuraya3_32404_2025-01-20-20-02-43",
|
| 68 |
+
2445,
|
| 69 |
+
2477,
|
| 70 |
+
"DVX Thuraya3: low-SNR target",
|
| 71 |
+
"The hardest regime: tiny boxes, weak event evidence, and changing confidence",
|
| 72 |
+
),
|
| 73 |
+
)
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def font(size: int, bold: bool = False):
|
| 77 |
+
name = "DejaVuSans-Bold.ttf" if bold else "DejaVuSans.ttf"
|
| 78 |
+
path = Path("/usr/share/fonts/truetype/dejavu") / name
|
| 79 |
+
return ImageFont.truetype(str(path), size) if path.exists() else ImageFont.load_default()
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def dashed_rectangle(draw: ImageDraw.ImageDraw, box, fill, width=3, dash=8):
|
| 83 |
+
x1, y1, x2, y2 = [int(round(v)) for v in box]
|
| 84 |
+
for x in range(x1, x2, dash * 2):
|
| 85 |
+
draw.line((x, y1, min(x + dash, x2), y1), fill=fill, width=width)
|
| 86 |
+
draw.line((x, y2, min(x + dash, x2), y2), fill=fill, width=width)
|
| 87 |
+
for y in range(y1, y2, dash * 2):
|
| 88 |
+
draw.line((x1, y, x1, min(y + dash, y2)), fill=fill, width=width)
|
| 89 |
+
draw.line((x2, y, x2, min(y + dash, y2)), fill=fill, width=width)
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def xyxy(box):
|
| 93 |
+
cx, cy, w, h = box[:4]
|
| 94 |
+
return cx - w / 2, cy - h / 2, cx + w / 2, cy + h / 2
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def replay_diagnostics(row: dict, end: int) -> dict[int, dict]:
|
| 98 |
+
policy = get_tracking_policy("cpu_student")
|
| 99 |
+
tracker = policy.build_tracker()
|
| 100 |
+
records = {}
|
| 101 |
+
for index, detections in enumerate(row["detections"][: end + 1]):
|
| 102 |
+
fed = rank_detections(
|
| 103 |
+
[tuple(float(v) for v in det) for det in detections
|
| 104 |
+
if float(det[4]) >= policy.detector_threshold],
|
| 105 |
+
policy.pre_max_det,
|
| 106 |
+
)
|
| 107 |
+
tracks = tracker.update(fed)
|
| 108 |
+
emitted = tracks_to_detections(
|
| 109 |
+
tracks,
|
| 110 |
+
emit_coasting=policy.emit_coasting,
|
| 111 |
+
max_coast_age=policy.max_coast_age,
|
| 112 |
+
coast_decay=policy.coast_decay,
|
| 113 |
+
)
|
| 114 |
+
emitted = finalize_detections(emitted, row["sensor_profile"], policy.post_max_det)
|
| 115 |
+
emitted = [det for det in emitted if det[4] >= row["emit_gate"]]
|
| 116 |
+
chosen_id = None
|
| 117 |
+
chosen_age = None
|
| 118 |
+
if emitted:
|
| 119 |
+
target = emitted[0]
|
| 120 |
+
nearest = min(
|
| 121 |
+
tracks,
|
| 122 |
+
key=lambda track: (track.box[0] - target[0]) ** 2 + (track.box[1] - target[1]) ** 2,
|
| 123 |
+
)
|
| 124 |
+
chosen_id, chosen_age = nearest.id, nearest.age
|
| 125 |
+
records[index] = {
|
| 126 |
+
"detector": fed[:1],
|
| 127 |
+
"tracker": emitted[:1],
|
| 128 |
+
"track_id": chosen_id,
|
| 129 |
+
"track_age": chosen_age,
|
| 130 |
+
}
|
| 131 |
+
return records
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
def selected_windows(ref, start: int, end: int):
|
| 135 |
+
selected = {}
|
| 136 |
+
events = load_events(ref, mmap=True)
|
| 137 |
+
gt = load_gt_boxes(ref)
|
| 138 |
+
for window in iter_windows(events, ref.name, gt):
|
| 139 |
+
if start <= window.index <= end:
|
| 140 |
+
selected[window.index] = window
|
| 141 |
+
if window.index >= end:
|
| 142 |
+
break
|
| 143 |
+
expected = set(range(start, end + 1))
|
| 144 |
+
if set(selected) != expected:
|
| 145 |
+
raise RuntimeError(f"missing animation windows for {ref.name}: {sorted(expected-set(selected))}")
|
| 146 |
+
return selected
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
def crop_bounds(windows, records, sensor, start, end):
|
| 150 |
+
points = []
|
| 151 |
+
for index in range(start, end + 1):
|
| 152 |
+
window = windows[index]
|
| 153 |
+
for gt in window.gt_boxes:
|
| 154 |
+
points.append((gt.cx, gt.cy))
|
| 155 |
+
for key in ("detector", "tracker"):
|
| 156 |
+
for box in records[index][key]:
|
| 157 |
+
points.append((box[0], box[1]))
|
| 158 |
+
if not points:
|
| 159 |
+
return 0, 0, sensor.width, sensor.height
|
| 160 |
+
xs, ys = zip(*points)
|
| 161 |
+
span = max(max(xs) - min(xs), max(ys) - min(ys), 95 if sensor.name == "DVX" else 155)
|
| 162 |
+
cx, cy = (min(xs) + max(xs)) / 2, (min(ys) + max(ys)) / 2
|
| 163 |
+
half = span * 0.72
|
| 164 |
+
x1, x2 = max(0, cx - half), min(sensor.width, cx + half)
|
| 165 |
+
y1, y2 = max(0, cy - half), min(sensor.height, cy + half)
|
| 166 |
+
return x1, y1, x2, y2
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
def fit_rect(src_w, src_h, dst):
|
| 170 |
+
x, y, w, h = dst
|
| 171 |
+
scale = min(w / src_w, h / src_h)
|
| 172 |
+
rw, rh = src_w * scale, src_h * scale
|
| 173 |
+
return x + (w - rw) / 2, y + (h - rh) / 2, rw, rh
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
def map_box(box, source_bounds, target_rect):
|
| 177 |
+
sx1, sy1, sx2, sy2 = source_bounds
|
| 178 |
+
tx, ty, tw, th = target_rect
|
| 179 |
+
x1, y1, x2, y2 = xyxy(box)
|
| 180 |
+
return (
|
| 181 |
+
tx + (x1 - sx1) / (sx2 - sx1) * tw,
|
| 182 |
+
ty + (y1 - sy1) / (sy2 - sy1) * th,
|
| 183 |
+
tx + (x2 - sx1) / (sx2 - sx1) * tw,
|
| 184 |
+
ty + (y2 - sy1) / (sy2 - sy1) * th,
|
| 185 |
+
)
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
def render_clip(clip: Clip, ref, row: dict) -> tuple[Path, Path, dict]:
|
| 189 |
+
sensor = ref.sensor
|
| 190 |
+
windows = selected_windows(ref, clip.start, clip.end)
|
| 191 |
+
records = replay_diagnostics(row, clip.end)
|
| 192 |
+
crop = crop_bounds(windows, records, sensor, clip.start, clip.end)
|
| 193 |
+
canvas_size = (960, 540)
|
| 194 |
+
full_slot = (20, 72, 565, 440)
|
| 195 |
+
zoom_slot = (610, 72, 330, 330)
|
| 196 |
+
full_rect = fit_rect(sensor.width, sensor.height, full_slot)
|
| 197 |
+
zoom_rect = fit_rect(crop[2] - crop[0], crop[3] - crop[1], zoom_slot)
|
| 198 |
+
detector_trail, tracker_trail = [], []
|
| 199 |
+
frames = []
|
| 200 |
+
stats = {"frames": clip.end - clip.start + 1, "tracker_iou_better": 0, "gt_frames": 0}
|
| 201 |
+
|
| 202 |
+
for index in range(clip.start, clip.end + 1):
|
| 203 |
+
window = windows[index]
|
| 204 |
+
record = records[index]
|
| 205 |
+
gt = [(g.cx, g.cy, g.w, g.h) for g in window.gt_boxes]
|
| 206 |
+
detector = record["detector"]
|
| 207 |
+
tracked = record["tracker"]
|
| 208 |
+
if detector:
|
| 209 |
+
detector_trail.append((detector[0][0], detector[0][1]))
|
| 210 |
+
if tracked:
|
| 211 |
+
tracker_trail.append((tracked[0][0], tracked[0][1]))
|
| 212 |
+
|
| 213 |
+
base = Image.fromarray(_event_image(np.asarray(window.events), sensor)).convert("RGB")
|
| 214 |
+
canvas = Image.new("RGB", canvas_size, BG)
|
| 215 |
+
draw = ImageDraw.Draw(canvas)
|
| 216 |
+
draw.text((20, 13), clip.title, fill=TEXT, font=font(22, True))
|
| 217 |
+
draw.text((20, 42), clip.subtitle, fill=MUTED, font=font(12))
|
| 218 |
+
|
| 219 |
+
full = base.resize((int(full_rect[2]), int(full_rect[3])), Image.Resampling.BILINEAR)
|
| 220 |
+
canvas.paste(full, (int(full_rect[0]), int(full_rect[1])))
|
| 221 |
+
crop_img = base.crop(tuple(int(round(v)) for v in crop))
|
| 222 |
+
crop_img = crop_img.resize((int(zoom_rect[2]), int(zoom_rect[3])), Image.Resampling.NEAREST)
|
| 223 |
+
canvas.paste(crop_img, (int(zoom_rect[0]), int(zoom_rect[1])))
|
| 224 |
+
draw = ImageDraw.Draw(canvas)
|
| 225 |
+
draw.rectangle((full_rect[0], full_rect[1], full_rect[0]+full_rect[2], full_rect[1]+full_rect[3]), outline="#29445e", width=2)
|
| 226 |
+
draw.rectangle((zoom_rect[0], zoom_rect[1], zoom_rect[0]+zoom_rect[2], zoom_rect[1]+zoom_rect[3]), outline="#29445e", width=2)
|
| 227 |
+
draw.text((zoom_rect[0]+8, zoom_rect[1]+7), "TARGET ZOOM", fill=TEXT, font=font(12, True), stroke_width=2, stroke_fill=BG)
|
| 228 |
+
|
| 229 |
+
all_bounds = (0, 0, sensor.width, sensor.height)
|
| 230 |
+
for trail, color in ((detector_trail[-18:], DETECTOR), (tracker_trail[-18:], TRACKER)):
|
| 231 |
+
for bounds, rect in ((all_bounds, full_rect), (crop, zoom_rect)):
|
| 232 |
+
mapped = [(
|
| 233 |
+
rect[0] + (x-bounds[0])/(bounds[2]-bounds[0])*rect[2],
|
| 234 |
+
rect[1] + (y-bounds[1])/(bounds[3]-bounds[1])*rect[3],
|
| 235 |
+
) for x, y in trail if bounds[0] <= x <= bounds[2] and bounds[1] <= y <= bounds[3]]
|
| 236 |
+
if len(mapped) > 1:
|
| 237 |
+
draw.line(mapped, fill=color, width=2)
|
| 238 |
+
|
| 239 |
+
for bounds, rect, width in ((all_bounds, full_rect, 2), (crop, zoom_rect, 4)):
|
| 240 |
+
for box in gt:
|
| 241 |
+
dashed_rectangle(draw, map_box(box, bounds, rect), GT, width=width, dash=8)
|
| 242 |
+
for box in detector:
|
| 243 |
+
draw.rectangle(map_box(box, bounds, rect), outline=DETECTOR, width=width)
|
| 244 |
+
for box in tracked:
|
| 245 |
+
draw.rectangle(map_box(box, bounds, rect), outline=TRACKER, width=width)
|
| 246 |
+
|
| 247 |
+
det_iou = tracker_iou = None
|
| 248 |
+
if gt:
|
| 249 |
+
stats["gt_frames"] += 1
|
| 250 |
+
if detector:
|
| 251 |
+
det_iou = iou_xywh(detector[0][:4], gt[0])
|
| 252 |
+
if tracked:
|
| 253 |
+
tracker_iou = iou_xywh(tracked[0][:4], gt[0])
|
| 254 |
+
if det_iou is not None and tracker_iou is not None and tracker_iou > det_iou:
|
| 255 |
+
stats["tracker_iou_better"] += 1
|
| 256 |
+
|
| 257 |
+
info_y = 418
|
| 258 |
+
draw.rounded_rectangle((610, info_y, 940, 520), radius=9, fill=PANEL, outline="#29445e")
|
| 259 |
+
elapsed = (window.t_start_us - windows[clip.start].t_start_us) / 1e6
|
| 260 |
+
draw.text((625, info_y+10), f"window {index} +{elapsed:0.2f}s events {len(window.events):,}", fill=TEXT, font=font(12, True))
|
| 261 |
+
det_text = "none" if not detector else f"{detector[0][4]:.3f} IoU {det_iou:.2f}" if det_iou is not None else f"{detector[0][4]:.3f}"
|
| 262 |
+
trk_text = "none" if not tracked else f"#{record['track_id']} age {record['track_age']} IoU {tracker_iou:.2f}" if tracker_iou is not None else f"#{record['track_id']} age {record['track_age']}"
|
| 263 |
+
draw.text((625, info_y+36), f"DETECTOR {det_text}", fill=DETECTOR, font=font(12, True))
|
| 264 |
+
draw.text((625, info_y+58), f"TRACKER {trk_text}", fill=TRACKER, font=font(12, True))
|
| 265 |
+
draw.text((625, info_y+80), "GT dashed yellow", fill=GT, font=font(11))
|
| 266 |
+
progress = (index - clip.start + 1) / (clip.end - clip.start + 1)
|
| 267 |
+
draw.rectangle((20, 526, 920, 532), fill="#1b334a")
|
| 268 |
+
draw.rectangle((20, 526, 20 + int(900 * progress), 532), fill=GOOD)
|
| 269 |
+
frames.append(canvas.quantize(colors=128, method=Image.Quantize.FASTOCTREE))
|
| 270 |
+
|
| 271 |
+
OUT.mkdir(parents=True, exist_ok=True)
|
| 272 |
+
gif = OUT / f"{clip.slug}.gif"
|
| 273 |
+
poster = OUT / f"{clip.slug}_poster.png"
|
| 274 |
+
frames[0].convert("RGB").save(poster, optimize=True)
|
| 275 |
+
frames[0].save(gif, save_all=True, append_images=frames[1:], duration=125,
|
| 276 |
+
loop=0, optimize=True, disposal=2)
|
| 277 |
+
stats.update({"sequence": clip.sequence, "start_window": clip.start,
|
| 278 |
+
"end_window": clip.end, "gif": gif.name, "poster": poster.name})
|
| 279 |
+
return gif, poster, stats
|
| 280 |
+
|
| 281 |
+
|
| 282 |
+
def refresh_checksums(bundle: Path) -> None:
|
| 283 |
+
lines = []
|
| 284 |
+
for path in sorted(p for p in bundle.rglob("*") if p.is_file() and p.name != "CHECKSUMS.sha256"):
|
| 285 |
+
lines.append(f"{hashlib.sha256(path.read_bytes()).hexdigest()} {path.relative_to(bundle)}")
|
| 286 |
+
(bundle / "CHECKSUMS.sha256").write_text("\n".join(lines) + "\n")
|
| 287 |
+
|
| 288 |
+
|
| 289 |
+
def main() -> int:
|
| 290 |
+
with CACHE.open("rb") as stream:
|
| 291 |
+
cache = pickle.load(stream)
|
| 292 |
+
refs = {ref.name: ref for ref in discover_sequences(str(ROOT / "data"))}
|
| 293 |
+
outputs = []
|
| 294 |
+
for clip in CLIPS:
|
| 295 |
+
gif, _poster, stats = render_clip(clip, refs[clip.sequence], cache[clip.sequence])
|
| 296 |
+
stats["sha256"] = hashlib.sha256(gif.read_bytes()).hexdigest()
|
| 297 |
+
outputs.append(stats)
|
| 298 |
+
print(f"rendered {gif}")
|
| 299 |
+
manifest = {
|
| 300 |
+
"source_cache": str(CACHE.relative_to(ROOT)),
|
| 301 |
+
"policy": "cpu_student",
|
| 302 |
+
"scope": "Exact promoted cached top-1 detector proposals replayed through the final Kalman/coast policy; GT is visualization-only.",
|
| 303 |
+
"legend": {"detector": DETECTOR, "tracker": TRACKER, "ground_truth": GT},
|
| 304 |
+
"outputs": outputs,
|
| 305 |
+
}
|
| 306 |
+
(OUT / "animation_manifest.json").write_text(json.dumps(manifest, indent=2) + "\n")
|
| 307 |
+
(OUT / "README.md").write_text(
|
| 308 |
+
"# OrbitSight detector + tracker animations\n\n"
|
| 309 |
+
"These GIFs use real ChallengeON recordings and the exact promoted CPU-student top-one cache. "
|
| 310 |
+
"Red is the raw detector proposal, cyan is the Kalman-smoothed emitted box, yellow dashed is "
|
| 311 |
+
"human ground truth, and the colored trails show recent centers. Ground truth is used only for "
|
| 312 |
+
"annotation and IoU display.\n\n" +
|
| 313 |
+
"\n".join(f"- `{row['gif']}` — `{row['sequence']}`, windows {row['start_window']}–{row['end_window']}" for row in outputs) + "\n"
|
| 314 |
+
)
|
| 315 |
+
refresh_checksums(OUT.parents[1])
|
| 316 |
+
print(json.dumps(manifest, indent=2))
|
| 317 |
+
return 0
|
| 318 |
+
|
| 319 |
+
|
| 320 |
+
if __name__ == "__main__":
|
| 321 |
+
raise SystemExit(main())
|