HishaamA commited on
Commit
3afcaa7
·
verified ·
1 Parent(s): 1365762

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