hyperstudy-perception 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. hyperstudy_perception-0.1.0.dist-info/METADATA +53 -0
  2. hyperstudy_perception-0.1.0.dist-info/RECORD +82 -0
  3. hyperstudy_perception-0.1.0.dist-info/WHEEL +4 -0
  4. hyperstudy_perception-0.1.0.dist-info/entry_points.txt +2 -0
  5. hyperstudy_perception-0.1.0.dist-info/licenses/LICENSE +21 -0
  6. perception/__init__.py +0 -0
  7. perception/_pipeline/RELEASE.json +59 -0
  8. perception/_pipeline/cloud/runpod/guard.sh +37 -0
  9. perception/_pipeline/cloud/runpod/image/Dockerfile +39 -0
  10. perception/_pipeline/cloud/runpod/image/start.sh +11 -0
  11. perception/_pipeline/cloud/runpod/image/tag.py +40 -0
  12. perception/_pipeline/cloud/skypilot/gpu-faces.yaml +39 -0
  13. perception/_pipeline/cloud/skypilot/gpu-tune.yaml +55 -0
  14. perception/_pipeline/cloud/skypilot/guard.sh +20 -0
  15. perception/_pipeline/cloud/skypilot/iam-perception-node.json +18 -0
  16. perception/_pipeline/cloud/skypilot/launch.sh +63 -0
  17. perception/_pipeline/cloud/skypilot/node-gpu.yaml +25 -0
  18. perception/_pipeline/cloud/skypilot/node-setup.sh +22 -0
  19. perception/_pipeline/cloud/skypilot/sky.yaml +7 -0
  20. perception/_pipeline/cloud/skypilot/smoke.yaml +28 -0
  21. perception/_pipeline/envs/api/pyproject.toml +12 -0
  22. perception/_pipeline/envs/api/uv.lock +276 -0
  23. perception/_pipeline/envs/audio/pyproject.toml +12 -0
  24. perception/_pipeline/envs/audio/uv.lock +575 -0
  25. perception/_pipeline/envs/core/pyproject.toml +12 -0
  26. perception/_pipeline/envs/core/uv.lock +157 -0
  27. perception/_pipeline/envs/faces/pyproject.toml +12 -0
  28. perception/_pipeline/envs/faces/uv.lock +1180 -0
  29. perception/_pipeline/graphs/decode.yaml +11 -0
  30. perception/_pipeline/graphs/faces.yaml +14 -0
  31. perception/_pipeline/graphs/full.yaml +31 -0
  32. perception/_pipeline/graphs/mvp-captions.yaml +27 -0
  33. perception/_pipeline/graphs/mvp.yaml +17 -0
  34. perception/_pipeline/nodes/audio/asr/manifest.yaml +20 -0
  35. perception/_pipeline/nodes/audio/asr/node.py +74 -0
  36. perception/_pipeline/nodes/audio/asr/prepare.py +12 -0
  37. perception/_pipeline/nodes/audio/asr/test_wav.py +38 -0
  38. perception/_pipeline/nodes/audio/asr/wav.py +19 -0
  39. perception/_pipeline/nodes/conftest.py +64 -0
  40. perception/_pipeline/nodes/core/decode/manifest.yaml +19 -0
  41. perception/_pipeline/nodes/core/decode/media.py +104 -0
  42. perception/_pipeline/nodes/core/decode/node.py +56 -0
  43. perception/_pipeline/nodes/core/decode/test_media.py +65 -0
  44. perception/_pipeline/nodes/testdata/README.md +10 -0
  45. perception/_pipeline/nodes/testdata/rotated-90.mp4 +0 -0
  46. perception/_pipeline/nodes/vision/face-track/frames.py +88 -0
  47. perception/_pipeline/nodes/vision/face-track/loader.py +18 -0
  48. perception/_pipeline/nodes/vision/face-track/manifest.yaml +30 -0
  49. perception/_pipeline/nodes/vision/face-track/node.py +117 -0
  50. perception/_pipeline/nodes/vision/face-track/prepare.py +8 -0
  51. perception/_pipeline/nodes/vision/face-track/test_frames.py +46 -0
  52. perception/_pipeline/nodes/vision/face-track/test_loader.py +16 -0
  53. perception/_pipeline/nodes/vision/face-track/test_thumbnails.py +66 -0
  54. perception/_pipeline/nodes/vision/face-track/thumbnails.py +68 -0
  55. perception/_pipeline/nodes/vision/face-track/tracking.py +150 -0
  56. perception/_pipeline/nodes/vlm/shot-captions/manifest.yaml +29 -0
  57. perception/_pipeline/nodes/vlm/shot-captions/node.py +185 -0
  58. perception/_pipeline/schema/README.md +54 -0
  59. perception/_pipeline/schema/bundle-0.1.schema.json +371 -0
  60. perception/_pipeline/sdk/pyproject.toml +13 -0
  61. perception/_pipeline/sdk/src/perception_node/__init__.py +157 -0
  62. perception/artifacts.py +180 -0
  63. perception/backends/__init__.py +0 -0
  64. perception/backends/base.py +179 -0
  65. perception/backends/cloud.py +214 -0
  66. perception/backends/native.py +140 -0
  67. perception/backends/probe.py +44 -0
  68. perception/backends/remote.py +140 -0
  69. perception/cli.py +780 -0
  70. perception/estimates.py +68 -0
  71. perception/export.py +100 -0
  72. perception/hosts.py +201 -0
  73. perception/ir.py +245 -0
  74. perception/keys.py +259 -0
  75. perception/manifests.py +169 -0
  76. perception/plan.py +170 -0
  77. perception/release.py +61 -0
  78. perception/remote_driver.py +990 -0
  79. perception/runpod.py +100 -0
  80. perception/scheduler.py +552 -0
  81. perception/setup.py +72 -0
  82. perception/store.py +219 -0
@@ -0,0 +1,88 @@
1
+ """Face crops, cut with PyAV: its wheels carry FFmpeg's libraries, so the node needs no ffmpeg on the machine (an app
2
+ started from the Finder has only /usr/bin:/bin:/usr/sbin:/sbin on its PATH). Each crop is what the ffmpeg command it
3
+ replaced wrote (`ffmpeg -ss {t:.3f} -i video -frames:v 1 -vf crop=...,scale=...`), at the frame the detector saw."""
4
+
5
+ from fractions import Fraction
6
+ from pathlib import Path
7
+
8
+ import av
9
+ import numpy as np
10
+
11
+ from perception_node import NodeError
12
+
13
+
14
+ def frame_at(container, t: float) -> av.VideoFrame:
15
+ """The frame nearest `t` seconds after the video stream's first frame. Callers' times are frame times counted from
16
+ that frame (face-track's frame index / fps, decode's shots from scenedetect's frame counts), rounded to the
17
+ millisecond: on either side of the frame's own time at 29.97 fps, so neither the first frame at or after t nor the
18
+ one shown at t is always it. (`ffmpeg -ss` counted from the container's start instead: when the video starts after
19
+ the audio, it cut crops and keyframes early.) The decode node has the same function."""
20
+ v = container.streams.video[0]
21
+ target = Fraction(v.start_time or 0) * v.time_base + Fraction(round(t * 1000), 1000)
22
+ container.seek(int(target / v.time_base), stream=v, backward=True)
23
+ before = None
24
+ for frame in container.decode(v):
25
+ if frame.pts is None:
26
+ continue
27
+ at = frame.pts * v.time_base
28
+ if at >= target:
29
+ return before if before is not None and target - before.pts * v.time_base < at - target else frame
30
+ before = frame
31
+ if before is not None and target - before.pts * v.time_base < 1 / v.average_rate: # within the last frame
32
+ return before
33
+ raise NodeError("input", f"the video has no frame at {t:.3f} s")
34
+
35
+
36
+ def upright(frame: av.VideoFrame) -> np.ndarray:
37
+ """The frame as RGB, turned the way players show it. Phone recordings store the picture sideways with a rotation
38
+ to apply: ffmpeg applied it, and so does torchcodec, which reads the frames face-track's detector sees; PyAV only
39
+ reports it. The decode node has the same function."""
40
+ return np.ascontiguousarray(np.rot90(frame.to_ndarray(format="rgb24"), round((frame.rotation or 0) / 90) % 4))
41
+
42
+
43
+ def crop_box(box, pad: float, frame_w: int, frame_h: int) -> tuple[int, int, int, int]:
44
+ """x, y, width, height of the padded box within the frame; even, as ffmpeg's crop makes them on 4:2:0 video."""
45
+ x, y, w, h = box
46
+ x0, y0 = max(0, x - pad * w), max(0, y - pad * h)
47
+ x1, y1 = min(frame_w, x + (1 + pad) * w), min(frame_h, y + (1 + pad) * h)
48
+ return int(x0) & ~1, int(y0) & ~1, int(x1 - x0) & ~1, int(y1 - y0) & ~1
49
+
50
+
51
+ def even(n: Fraction) -> int:
52
+ """ffmpeg's -2 in a scale: the nearest even number."""
53
+ return max(2, round(n / 2) * 2)
54
+
55
+
56
+ def crop(video: Path, t: float, box, pad: float, size) -> av.VideoFrame:
57
+ """The padded box at `t` (in the upright frame, as the detector saw it, and within it), scaled (bicubic, as
58
+ ffmpeg's scale) to size(width, height) -> (width, height)."""
59
+ with av.open(str(video)) as container:
60
+ rgb = upright(frame_at(container, t))
61
+ x, y, w, h = crop_box(box, pad, rgb.shape[1], rgb.shape[0])
62
+ out_w, out_h = size(w, h)
63
+ return av.VideoFrame.from_ndarray(np.ascontiguousarray(rgb[y:y + h, x:x + w]), format="rgb24").reformat(
64
+ width=out_w, height=out_h, interpolation="BICUBIC")
65
+
66
+
67
+ def longest_side(px: int):
68
+ """scale='if(gt(iw,ih),px,-2)':'if(gt(iw,ih),-2,px)'"""
69
+ return lambda w, h: (px, even(Fraction(px * h, w))) if w > h else (even(Fraction(px * w, h)), px)
70
+
71
+
72
+ def width(px: int):
73
+ """scale=px:-2"""
74
+ return lambda w, h: (px, even(Fraction(px * h, w)))
75
+
76
+
77
+ def square(px: int):
78
+ """scale=px:px"""
79
+ return lambda w, h: (px, px)
80
+
81
+
82
+ def save_crop(video: Path, t: float, box, pad: float, out: Path, size) -> None:
83
+ crop(video, t, box, pad, size).to_image().save(out, quality=90)
84
+
85
+
86
+ def crop_pixels(video: Path, t: float, box, pad: float, px: int) -> np.ndarray:
87
+ """The crop as px x px x 3 RGB, for scoring."""
88
+ return crop(video, t, box, pad, square(px)).to_ndarray(format="rgb24")
@@ -0,0 +1,18 @@
1
+ """How many processes decode frames beside the detector (py-feat's DataLoader workers).
2
+
3
+ On an 8-vCPU L4 (OOO clip 2, 2026-10-24), 4 workers took detection from 99 s to 57 s; batch size barely mattered, and
4
+ the boxes were byte-identical with and without workers. Workers must be forked: spawned ones (macOS, Windows)
5
+ re-import node.py and would run the node again, so there the default is none.
6
+ """
7
+
8
+ AUTO_WORKERS = 4
9
+
10
+
11
+ def decode_workers(requested: int | None, start_method: str, cpus: int) -> int:
12
+ """`requested` from the config; None means automatic."""
13
+ if requested is None:
14
+ return min(AUTO_WORKERS, max(0, cpus - 1)) if start_method == "fork" else 0
15
+ if requested and start_method != "fork":
16
+ raise ValueError(f"num_workers needs forked workers; this platform starts them with {start_method!r}, "
17
+ "which would run the node again in each")
18
+ return requested
@@ -0,0 +1,30 @@
1
+ id: vision.face-track
2
+ version: 1.0.0
3
+ stage: faces
4
+ title: Face tracks
5
+ description: Face tracks within shots, identity clusters across the video (py-feat + ArcFace), crops
6
+ inputs: {video: video.v1, media: media.v1, shots: bundle.shots@0.1}
7
+ outputs: {faces: bundle.faces@0.1, crops: assets.v1}
8
+ config:
9
+ fps: {type: number, exclusiveMinimum: 0, maximum: 30, default: 8, description: "frames sampled per second"}
10
+ cluster_threshold: {type: number, minimum: 0, maximum: 2, default: 0.65}
11
+ batch_size: {type: integer, minimum: 1, default: 32}
12
+ # Frame-decoding processes beside the detector (py-feat's DataLoader workers); null is automatic: 4 where workers
13
+ # can be forked (Linux), none where they would be spawned (macOS). See loader.py. Workers do not change the output.
14
+ num_workers: {type: [integer, "null"], minimum: 0, default: null}
15
+ runners:
16
+ - {kind: native, env: envs/faces, entry: node.py, accelerator: mps, platforms: [darwin/arm64]}
17
+ # The faces lock resolves PyPI's CUDA 13 torch wheels on Linux (needs an NVIDIA driver >= 580).
18
+ - {kind: native, env: envs/faces, entry: node.py, accelerator: cuda, platforms: [linux/amd64], minimum_vram_gb: 8}
19
+ - {kind: native, env: envs/faces, entry: node.py, accelerator: cpu}
20
+ # TODO: [PERCEPTION-GRAPH] Phase 1 step 7 - CUDA image for linux/amd64 (pinned digest), validated against MPS output
21
+ # Plan: docs/plans/GRAPH_MVP_PLAN.md#phase-1--local-mvp
22
+ resources: {cpu_cores: 4, memory_gb: 8, gpu: exclusive}
23
+ # fetches the weights ahead of a run (perception setup)
24
+ prepare: prepare.py
25
+ models:
26
+ # py-feat fetches its weights from hf://py-feat/* on first use, through its own fallback logic: `plan` cannot check
27
+ # them repository by repository, so no hf:// source is declared here for the cache check.
28
+ - {id: "py-feat Detectorv2 (RetinaFace + multitask)", source: "py-feat==2.1.5 (weights from hf://py-feat)",
29
+ licence: "py-feat code MIT; v2 weights non-commercial", licence_mode: academic-only}
30
+ - {id: "ArcFace (InsightFace, via py-feat)", source: "py-feat==2.1.5", licence: research-only weights, licence_mode: academic-only}
@@ -0,0 +1,117 @@
1
+ """vision.face-track: who is on screen, across the whole video, as the bundle's faces layer.
2
+
3
+ py-feat Detectorv2 (RetinaFace + multitask model + ArcFace) on frames sampled at `fps`; faces linked into tracks
4
+ within each shot and clustered into people (tracking.py). Writes one crop per person (thumbnails.py: shortlisted by size and
5
+ frontality, chosen on exposure, colour cast and sharpness) and one per track (its largest face), referenced from the
6
+ layer as assets/crops/...
7
+
8
+ Licence: py-feat's code is MIT, but the Detectorv2 multitask weights are non-commercial and InsightFace-derived
9
+ ArcFace weights are research-only, so this layer is academic-only.
10
+ """
11
+
12
+ import multiprocessing
13
+ import os
14
+ import time
15
+ from concurrent.futures import ThreadPoolExecutor
16
+ from functools import partial
17
+
18
+ import numpy as np
19
+
20
+ from perception_node import Node, NodeError
21
+ from frames import crop_pixels, longest_side, save_crop, width
22
+ from loader import decode_workers
23
+ from thumbnails import QUALITY_PX, choose, shortlist
24
+ from tracking import FRONTAL_YAW, build_tracks, cluster_tracks, detection_rows, name_people, person_pairs
25
+
26
+ PERSON_CROP_PAD, PERSON_CROP_PX = 0.25, 256 # padding as a fraction of the box; longest side of the saved crop
27
+ TRACK_CROP_PAD, TRACK_CROP_WIDTH = 0.4, 320
28
+
29
+
30
+ def run_jobs(jobs) -> list:
31
+ with ThreadPoolExecutor(8) as pool:
32
+ return list(pool.map(lambda job: job(), jobs))
33
+
34
+
35
+ with Node() as node:
36
+ from feat.detector_v2 import Detectorv2
37
+
38
+ c = node.config
39
+ video, media = node.input("video"), node.input_json("media")
40
+ shots = [{"index": s["index"], "start": s["start"], "end": s["end"]} for s in node.input_json("shots")["shots"]]
41
+ fps = media["fps"]
42
+ device = {"mps": "mps", "cuda": "cuda"}.get(node.accelerator, "cpu")
43
+
44
+ try:
45
+ workers = decode_workers(c["num_workers"], multiprocessing.get_start_method(), os.cpu_count() or 1)
46
+ except ValueError as e:
47
+ raise NodeError("input", str(e)) from None
48
+
49
+ t0 = time.time()
50
+ detector = Detectorv2(device=device)
51
+ fex = detector.detect(str(video), data_type="video", skip_frames=max(1, round(fps / c["fps"])), batch_size=c["batch_size"],
52
+ num_workers=workers, pin_memory=device == "cuda")
53
+ detect_s = round(time.time() - t0, 1)
54
+ node.model("py-feat Detectorv2 (RetinaFace + multitask)", licence_mode="academic-only", source="hf://py-feat",
55
+ licence="py-feat code MIT; v2 weights non-commercial")
56
+ node.model("ArcFace (InsightFace, via py-feat)", licence_mode="academic-only", licence="research-only weights")
57
+
58
+ rows = detection_rows(fex, fps)
59
+ tracks = build_tracks(rows, shots)
60
+ labels = cluster_tracks(tracks, c["cluster_threshold"]) if tracks else []
61
+ order, first_seen, names = name_people(tracks, labels)
62
+ emotion_cols = list(fex.emotion_columns)
63
+
64
+ assets = node.path("crops", "assets")
65
+ crops = assets / "crops"
66
+ crops.mkdir(parents=True, exist_ok=True)
67
+ t0 = time.time()
68
+ members = {label: [t for t, l in zip(tracks, labels) if l == label] for label in order}
69
+ candidates = {label: shortlist(members[label]) for label in order}
70
+ flat = [(label, r) for label in order for r in candidates[label]]
71
+ raw = run_jobs([partial(crop_pixels, video, r["t"], r["box"], 0.0, QUALITY_PX) for _, r in flat]) # the face box only
72
+ images = {label: [] for label in order}
73
+ for (label, _), pixels in zip(flat, raw):
74
+ images[label].append(pixels)
75
+ thumbnails_s = round(time.time() - t0, 1)
76
+
77
+ jobs, people, thumbnail_measures = [], [], {}
78
+ for label in order:
79
+ index, measures = choose(candidates[label], images[label])
80
+ best = candidates[label][index]
81
+ thumbnail_measures[names[label]] = {"t": best["t"], **measures[index]}
82
+ name = f"person_{names[label].replace(' ', '_')}.jpg"
83
+ jobs.append(partial(save_crop, video, best["t"], best["box"], PERSON_CROP_PAD, crops / name, longest_side(PERSON_CROP_PX)))
84
+ people.append({"id": f"person:{names[label]}", "first_seen": round(first_seen[label], 3),
85
+ "seconds_on_screen": round(sum(len(t["rows"]) for t in members[label]) / c["fps"], 1),
86
+ "crop": f"assets/crops/{name}", "shots": [f"shot:{s}" for s in sorted({t["shot"] for t in members[label]})]})
87
+
88
+ out_tracks = []
89
+ for i, (track, label) in enumerate(zip(tracks, labels)):
90
+ rs = track["rows"]
91
+ biggest = max(rs, key=lambda r: r["box"][2] * r["box"][3])
92
+ jobs.append(partial(save_crop, video, biggest["t"], biggest["box"], TRACK_CROP_PAD, crops / f"face_{i}.jpg", width(TRACK_CROP_WIDTH)))
93
+ mean_emotions = {col: float(np.mean([r["emotions"][col] for r in rs])) for col in emotion_cols}
94
+ out_tracks.append({
95
+ "id": f"face:{i}", "person": f"person:{names[label]}" if label != -1 else None, "shot": f"shot:{track['shot']}",
96
+ "start": rs[0]["t"], "end": rs[-1]["t"], "linked_body": None, "crop": f"assets/crops/face_{i}.jpg",
97
+ "samples": [{"t": r["t"], "box": [round(v, 1) for v in r["box"]], "mouth": round(r["mouth"], 3),
98
+ "gaze": [round(r["gaze_yaw"], 3), round(r["gaze_pitch"], 3)]} for r in rs],
99
+ "summary": {"head_yaw": round(float(np.mean([r["yaw"] for r in rs])), 3),
100
+ "gaze_yaw": round(float(np.mean([r["gaze_yaw"] for r in rs])), 3),
101
+ "gaze_pitch": round(float(np.mean([r["gaze_pitch"] for r in rs])), 3),
102
+ "dominant_emotion": max(mean_emotions, key=mean_emotions.get),
103
+ "emotions": {k: round(v, 3) for k, v in mean_emotions.items()},
104
+ "mean_box": [round(float(np.mean([r["box"][k] for r in rs])), 1) for k in range(4)]},
105
+ })
106
+ t0 = time.time()
107
+ run_jobs(jobs)
108
+ crops_s = round(time.time() - t0, 1)
109
+
110
+ pairs = [{**p, "a": f"person:{p['a']}", "b": f"person:{p['b']}"} for p in person_pairs(tracks, labels, names)]
111
+ node.write_json("faces", "faces.json", {
112
+ "layer": "faces", "schema_version": "0.1.0",
113
+ "producer": node.producer(timings={"detect_s": detect_s, "thumbnails_s": thumbnails_s, "crops_s": crops_s}, device=device),
114
+ "sample_fps": c["fps"], "angles": "radians; yaw + = subject's right (image left)",
115
+ "people": people, "tracks": out_tracks, "person_pairs": pairs, "detail": None})
116
+ node.usage(detections=len(rows), tracks=len(tracks), people=len(people), frontal_yaw=FRONTAL_YAW, thumbnails=thumbnail_measures,
117
+ decode_workers=workers)
@@ -0,0 +1,8 @@
1
+ """Fetch the face node's weights ahead of a run (`perception setup`, and the driver before a remote run): py-feat
2
+ downloads Detectorv2's models (RetinaFace, the multitask model, ArcFace) from hf://py-feat the first time a detector is
3
+ built, into the Hugging Face cache the node then reads. Built on the CPU: only the files matter here."""
4
+
5
+ from feat.detector_v2 import Detectorv2
6
+
7
+ Detectorv2(device="cpu")
8
+ print("py-feat Detectorv2 weights are in the cache")
@@ -0,0 +1,46 @@
1
+ """Face crops (frames.py), with nothing on PATH: the app runs nodes from the Finder, where ffmpeg is not found.
2
+ Run: uv run --project envs/faces --with pytest pytest nodes/vision/face-track"""
3
+
4
+ import numpy as np
5
+ import pytest
6
+ from PIL import Image
7
+
8
+ from frames import crop_box, crop_pixels, longest_side, save_crop, square, width
9
+
10
+
11
+ @pytest.fixture(autouse=True)
12
+ def no_tools_on_path(monkeypatch):
13
+ monkeypatch.setenv("PATH", "")
14
+
15
+
16
+ def test_crop_box_is_padded_clipped_and_even():
17
+ assert crop_box([10, 10, 20, 20], 0.25, 64, 48) == (4, 4, 30, 30) # 5..35 -> even
18
+ assert crop_box([50, 30, 20, 20], 0.5, 64, 48) == (40, 20, 24, 28) # clipped to the frame
19
+
20
+
21
+ def test_sizes_are_ffmpegs():
22
+ assert longest_side(256)(30, 20) == (256, 170) # scale='if(gt(iw,ih),256,-2)':'if(gt(iw,ih),-2,256)'
23
+ assert longest_side(256)(20, 30) == (170, 256)
24
+ assert width(320)(30, 21) == (320, 224) # scale=320:-2
25
+ assert square(64)(30, 20) == (64, 64)
26
+
27
+
28
+ def test_crop_pixels(clip):
29
+ pixels = crop_pixels(clip.path, 0.4, [8, 8, 16, 16], 0.0, 64)
30
+ assert pixels.shape == (64, 64, 3) and pixels.dtype == np.uint8
31
+ assert abs(pixels.mean() - clip.nth(4)[8:24, 8:24].mean()) < 1
32
+
33
+
34
+ def test_save_crop(clip, tmp_path):
35
+ save_crop(clip.path, 1.2, [8, 8, 30, 20], 0.0, tmp_path / "c.jpg", longest_side(256))
36
+ img = Image.open(tmp_path / "c.jpg")
37
+ assert img.size == (256, 170)
38
+ assert abs(np.asarray(img).mean() - clip.nth(12)[8:28, 8:38].mean()) < 1
39
+
40
+
41
+ def test_a_crop_is_cut_from_the_upright_frame_the_detector_saw(rotated):
42
+ # torchcodec, which reads the detector's frames, turns a rotated clip upright: 32x64 here, so its boxes are in that
43
+ # frame. The crop of the bottom half is red, of the top half blue, and a box past the stored 64x32 still fits.
44
+ bottom = crop_pixels(rotated, 0.5, [0, 40, 32, 24], 0.0, 16).astype(int).mean((0, 1))
45
+ top = crop_pixels(rotated, 0.5, [0, 0, 32, 24], 0.0, 16).astype(int).mean((0, 1))
46
+ assert bottom[0] > 200 > bottom[2] and top[2] > 200 > top[0], (bottom, top)
@@ -0,0 +1,16 @@
1
+ import pytest
2
+
3
+ from loader import decode_workers
4
+
5
+
6
+ def test_automatic_means_four_where_workers_fork_and_none_where_they_spawn():
7
+ assert decode_workers(None, "fork", cpus=8) == 4
8
+ assert decode_workers(None, "fork", cpus=2) == 1 # leave a core for the detector's own process
9
+ assert decode_workers(None, "spawn", cpus=8) == 0 # macOS: a spawned worker would run the node again
10
+
11
+
12
+ def test_an_explicit_count_is_kept_but_refused_where_workers_spawn():
13
+ assert decode_workers(0, "spawn", cpus=8) == 0
14
+ assert decode_workers(7, "fork", cpus=8) == 7
15
+ with pytest.raises(ValueError, match="needs forked workers"):
16
+ decode_workers(2, "spawn", cpus=8)
@@ -0,0 +1,66 @@
1
+ """Thumbnail choice (thumbnails.py). Run: uv run --project envs/faces --with pytest pytest nodes/vision/face-track"""
2
+
3
+ import numpy as np
4
+
5
+ from thumbnails import choose, shortlist
6
+
7
+ rng = np.random.default_rng(0)
8
+
9
+
10
+ def face(colour, size=64):
11
+ """A textured face-like crop: a base colour with detail, so sharpness is comparable between crops."""
12
+ base = np.array(colour, dtype=np.float64)
13
+ texture = rng.normal(0, 18, (size, size, 1))
14
+ return np.clip(base + texture, 0, 255).astype(np.uint8)
15
+
16
+
17
+ def with_graphic(image):
18
+ """White on-screen graphics over the left third of the face (clip 2's panelist circles and name cards)."""
19
+ out = image.copy()
20
+ out[:, :22] = 255
21
+ out[::8, :22] = 0 # hard edges: graphics are much "sharper" than a face
22
+ return out
23
+
24
+
25
+ def row(t, side, yaw=0.0, score=0.99):
26
+ return {"t": t, "box": [0, 0, side, side], "yaw": yaw, "score": score}
27
+
28
+
29
+ RED_LIT = (120, 8, 10) # a face under red stage light: dark, almost one channel
30
+ WELL_LIT = (200, 160, 140) # a face under white light (warm skin, but no strong cast)
31
+ DIM = (110, 90, 80) # a face against clip 2's black backdrop, fairly dark but neutral
32
+
33
+
34
+ def pick_time(tracks, image_at):
35
+ candidates = shortlist(tracks)
36
+ index, _ = choose(candidates, [image_at(c["t"]) for c in candidates])
37
+ return candidates[index]["t"]
38
+
39
+
40
+ def test_a_smaller_well_lit_face_beats_a_larger_red_lit_one():
41
+ tracks = [{"rows": [row(1.0, 200)]}, {"rows": [row(5.0, 150)]}]
42
+ assert pick_time(tracks, {1.0: face(RED_LIT), 5.0: face(WELL_LIT)}.get) == 5.0
43
+
44
+
45
+ def test_graphics_over_a_face_do_not_win_on_sharpness():
46
+ tracks = [{"rows": [row(1.0, 214)]}, {"rows": [row(5.0, 217)]}]
47
+ images = {1.0: with_graphic(face(DIM)), 5.0: face(DIM)}
48
+ assert pick_time(tracks, images.get) == 5.0
49
+
50
+
51
+ def test_geometry_still_decides_between_faces_of_similar_quality():
52
+ tracks = [{"rows": [row(1.0, 200)]}, {"rows": [row(5.0, 120)]}, {"rows": [row(9.0, 200, yaw=1.2)]}]
53
+ assert pick_time(tracks, lambda t: face(WELL_LIT)) == 1.0 # largest and frontal
54
+
55
+
56
+ def test_a_person_with_one_track_still_gets_a_choice():
57
+ track = {"rows": [row(t, 240 if t < 3 else 200) for t in np.arange(0, 6, 0.25).round(2)]}
58
+ times = [c["t"] for c in shortlist([track])]
59
+ assert len(times) == 6 and all(abs(a - b) >= 1.0 for i, a in enumerate(times) for b in times[i + 1:])
60
+ assert pick_time([track], lambda t: face(RED_LIT) if t < 3 else face(WELL_LIT)) >= 3
61
+
62
+
63
+ def test_shortlist_goes_round_the_tracks():
64
+ many_red = {"rows": [row(t, 200) for t in (1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 11.0, 12.0, 13.0)]}
65
+ small_clean = {"rows": [row(20.0, 100)]}
66
+ assert 20.0 in [c["t"] for c in shortlist([many_red, small_clean])]
@@ -0,0 +1,68 @@
1
+ """Choosing each person's thumbnail from their face detections.
2
+
3
+ Size and frontality alone picked a large frontal face under red stage light on OOO clip 2 (Person A). So the
4
+ geometry score only shortlists, and each shortlisted face box (no padding: the backdrop must not count) is scored on
5
+ its pixels. Measured on clip 2:
6
+ - exposure and colour cast reject the red-lit and the near-black faces;
7
+ - sharpness only gates blur (min(1, sharpness / SHARP_ENOUGH)): as a relative score, the edges of on-screen
8
+ graphics over a face made it look 40x sharper than a clean, larger view of the same person;
9
+ - blown-out pixels (white graphics, logos) are penalised directly.
10
+ The shortlist goes round the person's tracks, best geometry first, taking detections at least SPACING_S apart, so a
11
+ person with one track still gets a choice and one badly lit shot cannot fill the whole list.
12
+ """
13
+
14
+ import numpy as np
15
+
16
+ SHORTLIST = 12
17
+ SPACING_S = 1.0 # shortlisted detections of one track are at least this far apart
18
+ QUALITY_PX = 64 # face boxes are scored at this size (square)
19
+ SHARP_ENOUGH = 3e-4 # Laplacian variance of a 64 px face box above which blur no longer counts against it
20
+ CLIPPED_LUMA = 0.92 # a pixel this bright is blown out (white graphics, logos, overexposure)
21
+ LUMA = np.array([0.2126, 0.7152, 0.0722])
22
+
23
+
24
+ def geometry(row) -> float:
25
+ """Large and frontal: box area times the cosine of the head yaw (capped near profile)."""
26
+ return row["box"][2] * row["box"][3] * float(np.cos(min(abs(row["yaw"]), 1.4)))
27
+
28
+
29
+ def shortlist(tracks: list[dict], k: int = SHORTLIST) -> list[dict]:
30
+ per_track = []
31
+ for t in tracks:
32
+ picked = []
33
+ for r in sorted(t["rows"], key=geometry, reverse=True):
34
+ if all(abs(r["t"] - p["t"]) >= SPACING_S for p in picked):
35
+ picked.append(r)
36
+ per_track.append(picked)
37
+ per_track.sort(key=lambda rows: geometry(rows[0]), reverse=True)
38
+ out, depth = [], 0
39
+ while len(out) < k and any(depth < len(rows) for rows in per_track):
40
+ out += [rows[depth] for rows in per_track if depth < len(rows)][: k - len(out)]
41
+ depth += 1
42
+ return out
43
+
44
+
45
+ def quality(rgb: np.ndarray) -> dict:
46
+ """Pixel measures of one face box (H x W x 3, uint8): exposure, neutral and unclipped in 0..1; sharpness is the
47
+ Laplacian variance."""
48
+ pixels = rgb.astype(np.float64) / 255
49
+ luma = pixels @ LUMA
50
+ exposure = 1 - min(1.0, abs(float(luma.mean()) - 0.5) / 0.4) # 1 at mid-grey; 0 below 0.1 or above 0.9
51
+ means = pixels.reshape(-1, 3).mean(axis=0)
52
+ cast = float((means.max() - means.min()) / max(means.max(), 1e-6)) # 0 neutral; 1 a single channel
53
+ lap = luma[:-2, 1:-1] + luma[2:, 1:-1] + luma[1:-1, :-2] + luma[1:-1, 2:] - 4 * luma[1:-1, 1:-1]
54
+ return {"exposure": round(exposure, 3), "neutral": round(1 - cast, 3), "sharpness": round(float(lap.var()), 6),
55
+ "unclipped": round(1 - min(1.0, 4 * float((luma > CLIPPED_LUMA).mean())), 3)}
56
+
57
+
58
+ def score(row, measures: dict, top_geometry: float) -> float:
59
+ return (geometry(row) / top_geometry * measures["exposure"] * measures["neutral"] * measures["unclipped"]
60
+ * min(1.0, measures["sharpness"] / SHARP_ENOUGH) * row["score"])
61
+
62
+
63
+ def choose(candidates: list[dict], images: list[np.ndarray]) -> tuple[int, list[dict]]:
64
+ """The shortlisted detection to use as the thumbnail, given each one's face box; also returns the measures.
65
+ Geometry counts relative to the best of this shortlist, so the choice is made among one person's faces only."""
66
+ measures = [quality(image) for image in images]
67
+ top_geometry = max(geometry(r) for r in candidates)
68
+ return int(np.argmax([score(r, m, top_geometry) for r, m in zip(candidates, measures)])), measures
@@ -0,0 +1,150 @@
1
+ """Face tracks and identity clusters from per-sample detections. Pure functions (numpy, scikit-learn); the node feeds
2
+ them py-feat's rows. Ported unchanged from pipeline/bakeoff/faces/faces.py, whose comments record why each threshold
3
+ is what it is.
4
+ """
5
+
6
+ from itertools import combinations
7
+
8
+ import numpy as np
9
+
10
+ SAME_TRACK_IOU = 0.3 # box overlap between consecutive samples to continue a track
11
+ SAME_PERSON_COSINE = 0.65 # anchor clustering; on OOO 0.75 merged panelists more fully but also merged junk (King Kong + blur + logo). Splits are fixable in review, wrong merges mislead agents
12
+ FRONTAL_YAW = 0.5 # radians (~30 deg); py-feat head pose and gaze are in radians
13
+ ANCHOR_FACE_PX = 60 # a track defines a person only if it has a near-frontal face at least this big
14
+ ATTACH_COSINE = 0.6 # profile/small/blurry tracks join a person only within this distance; else Unknown
15
+ MIN_FACE_PX = 40 # ignore faces smaller than this (too small to identify or read)
16
+ MIN_FACE_SCORE = 0.9 # RetinaFace confidence. On NES - Biking this removed the title lettering and one of two skull shirt prints (30 -> 14 people)
17
+ MOUTH_AUS = ("AU25", "AU26") # lips part, jaw drop: mouth movement for linking voices to faces
18
+
19
+
20
+ def iou(a, b):
21
+ ax2, ay2, bx2, by2 = a[0] + a[2], a[1] + a[3], b[0] + b[2], b[1] + b[3]
22
+ w, h = max(0, min(ax2, bx2) - max(a[0], b[0])), max(0, min(ay2, by2) - max(a[1], b[1]))
23
+ inter = w * h
24
+ return inter / (a[2] * a[3] + b[2] * b[3] - inter) if inter else 0.0
25
+
26
+
27
+ def shot_of(t, shots):
28
+ for shot in shots:
29
+ if shot["start"] <= t < shot["end"]:
30
+ return shot["index"]
31
+ return shots[-1]["index"]
32
+
33
+
34
+ def build_tracks(rows, shots):
35
+ """Greedy IoU linking of detections in consecutive samples, never across a cut."""
36
+ tracks = []
37
+ open_tracks = {} # shot index -> tracks still extendable
38
+ for t in sorted({r["t"] for r in rows}):
39
+ frame_rows = [r for r in rows if r["t"] == t]
40
+ shot = shot_of(t, shots)
41
+ candidates = open_tracks.setdefault(shot, [])
42
+ used = set()
43
+ for r in sorted(frame_rows, key=lambda r: -r["box"][2] * r["box"][3]):
44
+ best = max((c for c in candidates if id(c) not in used),
45
+ key=lambda c: iou(c["rows"][-1]["box"], r["box"]), default=None)
46
+ if best is not None and iou(best["rows"][-1]["box"], r["box"]) >= SAME_TRACK_IOU:
47
+ best["rows"].append(r)
48
+ used.add(id(best))
49
+ else:
50
+ track = {"shot": shot, "rows": [r]}
51
+ candidates.append(track)
52
+ tracks.append(track)
53
+ used.add(id(track))
54
+ return tracks
55
+
56
+
57
+ def is_anchor(track):
58
+ """A track good enough to define a person: some near-frontal samples of a reasonably large face."""
59
+ return any(abs(r["yaw"]) <= FRONTAL_YAW and min(r["box"][2], r["box"][3]) >= ANCHOR_FACE_PX for r in track["rows"])
60
+
61
+
62
+ def track_embedding(track):
63
+ frontal = [r["embedding"] for r in track["rows"] if abs(r["yaw"]) <= FRONTAL_YAW]
64
+ e = np.mean(frontal or [r["embedding"] for r in track["rows"]], axis=0)
65
+ return e / np.linalg.norm(e)
66
+
67
+
68
+ def cluster_tracks(tracks, threshold=SAME_PERSON_COSINE):
69
+ """Two passes. People are defined only by anchor tracks (average-linkage clustering of frontal ArcFace embeddings,
70
+ never merging tracks on screen at the same time). Every other track (profile-only, small, blurry) joins its nearest
71
+ person if it is within ATTACH_COSINE and does not co-occur with that person; otherwise it is unknown (-1)."""
72
+ from sklearn.cluster import AgglomerativeClustering
73
+
74
+ emb = np.stack([track_embedding(t) for t in tracks])
75
+ times = [{r["t"] for r in t["rows"]} for t in tracks]
76
+ anchors = [i for i, t in enumerate(tracks) if is_anchor(t)]
77
+ labels = [-1] * len(tracks)
78
+ if not anchors:
79
+ return labels
80
+
81
+ dist = np.clip(1 - emb[anchors] @ emb[anchors].T, 0, 2)
82
+ for a in range(len(anchors)):
83
+ for b in range(a + 1, len(anchors)):
84
+ if times[anchors[a]] & times[anchors[b]]:
85
+ dist[a, b] = dist[b, a] = 2.0 # on screen together: different people
86
+ anchor_labels = ([0] if len(anchors) == 1 else
87
+ AgglomerativeClustering(n_clusters=None, metric="precomputed", linkage="average",
88
+ distance_threshold=threshold).fit_predict(dist))
89
+ for i, label in zip(anchors, anchor_labels):
90
+ labels[i] = int(label)
91
+
92
+ people = sorted(set(anchor_labels))
93
+ centroid = {p: np.mean([emb[i] for i in anchors if labels[i] == p], axis=0) for p in people}
94
+ occupied = {p: set().union(*(times[i] for i in anchors if labels[i] == p)) for p in people}
95
+ for i, track in enumerate(tracks):
96
+ if labels[i] != -1:
97
+ continue
98
+ options = sorted((1 - float(emb[i] @ (centroid[p] / np.linalg.norm(centroid[p]))), p) for p in people
99
+ if not (times[i] & occupied[p]))
100
+ if options and options[0][0] <= ATTACH_COSINE:
101
+ labels[i] = options[0][1]
102
+ return labels
103
+
104
+
105
+ def name_people(tracks, labels):
106
+ """Person A, B, ... in order of first appearance; -1 stays unnamed (Unknown)."""
107
+ first_seen = {}
108
+ for track, label in zip(tracks, labels):
109
+ if label != -1:
110
+ first_seen[label] = min(first_seen.get(label, 1e9), track["rows"][0]["t"])
111
+ order = sorted(first_seen, key=first_seen.get)
112
+ return order, first_seen, {label: f"Person {chr(65 + i)}" if i < 26 else f"Person {i + 1}" for i, label in enumerate(order)}
113
+
114
+
115
+ def person_pairs(tracks, labels, names):
116
+ """Cosine distance between people's ArcFace centroids, and whether they are ever on screen together (then they
117
+ cannot be one person): the annotator's merge suggestions (pipeline/bakeoff/faces/identity_similarity.py)."""
118
+ members = {}
119
+ for i, label in enumerate(labels):
120
+ if label != -1:
121
+ members.setdefault(names[label], []).append(i)
122
+ centroid = {}
123
+ for person, idx in members.items():
124
+ c = np.mean([track_embedding(tracks[i]) for i in idx], axis=0)
125
+ centroid[person] = c / np.linalg.norm(c)
126
+ times = {p: {round(r["t"], 3) for i in idx for r in tracks[i]["rows"]} for p, idx in members.items()}
127
+ pairs = [{"a": a, "b": b, "distance": round(float(1 - centroid[a] @ centroid[b]), 3), "co_visible": bool(times[a] & times[b])}
128
+ for a, b in combinations(sorted(members), 2)]
129
+ return sorted(pairs, key=lambda p: p["distance"])
130
+
131
+
132
+ def detection_rows(fex, fps):
133
+ """py-feat rows -> detections kept for tracking (size and score filtered), with ArcFace embeddings."""
134
+ emotion_cols = list(fex.emotion_columns)
135
+ rows = []
136
+ for index, r in fex.iterrows():
137
+ if (np.isnan(r["FaceRectX"]) or min(r["FaceRectWidth"], r["FaceRectHeight"]) < MIN_FACE_PX
138
+ or r["FaceScore"] < MIN_FACE_SCORE):
139
+ continue
140
+ rows.append({
141
+ "index": index, "t": round(float(r["frame"]) / fps, 3), "frame": int(r["frame"]),
142
+ "box": [float(r["FaceRectX"]), float(r["FaceRectY"]), float(r["FaceRectWidth"]), float(r["FaceRectHeight"])],
143
+ "score": float(r["FaceScore"]),
144
+ "embedding": np.array([r[f"Identity_{i + 1}"] for i in range(512)], dtype=np.float32),
145
+ "yaw": float(r["Yaw"]), "pitch": float(r["Pitch"]),
146
+ "gaze_yaw": float(r["gaze_yaw"]), "gaze_pitch": float(r["gaze_pitch"]),
147
+ "emotions": {c: float(r[c]) for c in emotion_cols},
148
+ "mouth": float(np.mean([r[au] for au in MOUTH_AUS])),
149
+ })
150
+ return rows
@@ -0,0 +1,29 @@
1
+ id: vlm.shot-captions
2
+ version: 1.0.0
3
+ stage: extract+caption
4
+ title: Shot captions
5
+ service: Anthropic API
6
+ description: Per-shot descriptions from keyframes (faces boxed and labelled), speech and the face roster; on-screen text
7
+ inputs:
8
+ shots: bundle.shots@0.1
9
+ keyframes: assets.v1
10
+ transcript: bundle.transcript@0.1
11
+ faces: bundle.faces@0.1
12
+ media: media.v1
13
+ outputs: {shots: bundle.shots@0.1, text: bundle.text@0.1}
14
+ config:
15
+ model: {enum: [claude-haiku-5-5, claude-opus-5-5], default: claude-haiku-5-5, description: "bake-off choice: Haiku 5.5 with the face roster"}
16
+ effort: {enum: [low, medium, high], default: medium}
17
+ runners:
18
+ - {kind: native, env: envs/api, entry: node.py, accelerator: cpu}
19
+ resources: {cpu_cores: 1, memory_gb: 1, gpu: none}
20
+ # Sent to Anthropic: keyframes with face boxes and labels, and the transcript lines of each shot.
21
+ privacy: {external_upload: [frames, faces, transcript]}
22
+ secrets: [ANTHROPIC_API_KEY]
23
+ # Upper estimate per shot, per model (measured on OOO: $0.04 for 44 shots with Haiku 5.5, about $0.001 a shot; Opus 5.5
24
+ # costs 40x more per token). A model added to the config enum needs a price here, or the manifest does not load.
25
+ cost: {usd: {claude-haiku-5-5: 0.003, claude-opus-5-5: 0.12}, by: model, per: shot, input: shots}
26
+ models:
27
+ - {id: claude-haiku-5-5, licence: Anthropic API terms, licence_mode: api-terms}
28
+ - {id: claude-opus-5-5, licence: Anthropic API terms, licence_mode: api-terms}
29
+ - {id: PySceneDetect AdaptiveDetector, licence: BSD-3-Clause, licence_mode: commercial-ok}