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.
- hyperstudy_perception-0.1.0.dist-info/METADATA +53 -0
- hyperstudy_perception-0.1.0.dist-info/RECORD +82 -0
- hyperstudy_perception-0.1.0.dist-info/WHEEL +4 -0
- hyperstudy_perception-0.1.0.dist-info/entry_points.txt +2 -0
- hyperstudy_perception-0.1.0.dist-info/licenses/LICENSE +21 -0
- perception/__init__.py +0 -0
- perception/_pipeline/RELEASE.json +59 -0
- perception/_pipeline/cloud/runpod/guard.sh +37 -0
- perception/_pipeline/cloud/runpod/image/Dockerfile +39 -0
- perception/_pipeline/cloud/runpod/image/start.sh +11 -0
- perception/_pipeline/cloud/runpod/image/tag.py +40 -0
- perception/_pipeline/cloud/skypilot/gpu-faces.yaml +39 -0
- perception/_pipeline/cloud/skypilot/gpu-tune.yaml +55 -0
- perception/_pipeline/cloud/skypilot/guard.sh +20 -0
- perception/_pipeline/cloud/skypilot/iam-perception-node.json +18 -0
- perception/_pipeline/cloud/skypilot/launch.sh +63 -0
- perception/_pipeline/cloud/skypilot/node-gpu.yaml +25 -0
- perception/_pipeline/cloud/skypilot/node-setup.sh +22 -0
- perception/_pipeline/cloud/skypilot/sky.yaml +7 -0
- perception/_pipeline/cloud/skypilot/smoke.yaml +28 -0
- perception/_pipeline/envs/api/pyproject.toml +12 -0
- perception/_pipeline/envs/api/uv.lock +276 -0
- perception/_pipeline/envs/audio/pyproject.toml +12 -0
- perception/_pipeline/envs/audio/uv.lock +575 -0
- perception/_pipeline/envs/core/pyproject.toml +12 -0
- perception/_pipeline/envs/core/uv.lock +157 -0
- perception/_pipeline/envs/faces/pyproject.toml +12 -0
- perception/_pipeline/envs/faces/uv.lock +1180 -0
- perception/_pipeline/graphs/decode.yaml +11 -0
- perception/_pipeline/graphs/faces.yaml +14 -0
- perception/_pipeline/graphs/full.yaml +31 -0
- perception/_pipeline/graphs/mvp-captions.yaml +27 -0
- perception/_pipeline/graphs/mvp.yaml +17 -0
- perception/_pipeline/nodes/audio/asr/manifest.yaml +20 -0
- perception/_pipeline/nodes/audio/asr/node.py +74 -0
- perception/_pipeline/nodes/audio/asr/prepare.py +12 -0
- perception/_pipeline/nodes/audio/asr/test_wav.py +38 -0
- perception/_pipeline/nodes/audio/asr/wav.py +19 -0
- perception/_pipeline/nodes/conftest.py +64 -0
- perception/_pipeline/nodes/core/decode/manifest.yaml +19 -0
- perception/_pipeline/nodes/core/decode/media.py +104 -0
- perception/_pipeline/nodes/core/decode/node.py +56 -0
- perception/_pipeline/nodes/core/decode/test_media.py +65 -0
- perception/_pipeline/nodes/testdata/README.md +10 -0
- perception/_pipeline/nodes/testdata/rotated-90.mp4 +0 -0
- perception/_pipeline/nodes/vision/face-track/frames.py +88 -0
- perception/_pipeline/nodes/vision/face-track/loader.py +18 -0
- perception/_pipeline/nodes/vision/face-track/manifest.yaml +30 -0
- perception/_pipeline/nodes/vision/face-track/node.py +117 -0
- perception/_pipeline/nodes/vision/face-track/prepare.py +8 -0
- perception/_pipeline/nodes/vision/face-track/test_frames.py +46 -0
- perception/_pipeline/nodes/vision/face-track/test_loader.py +16 -0
- perception/_pipeline/nodes/vision/face-track/test_thumbnails.py +66 -0
- perception/_pipeline/nodes/vision/face-track/thumbnails.py +68 -0
- perception/_pipeline/nodes/vision/face-track/tracking.py +150 -0
- perception/_pipeline/nodes/vlm/shot-captions/manifest.yaml +29 -0
- perception/_pipeline/nodes/vlm/shot-captions/node.py +185 -0
- perception/_pipeline/schema/README.md +54 -0
- perception/_pipeline/schema/bundle-0.1.schema.json +371 -0
- perception/_pipeline/sdk/pyproject.toml +13 -0
- perception/_pipeline/sdk/src/perception_node/__init__.py +157 -0
- perception/artifacts.py +180 -0
- perception/backends/__init__.py +0 -0
- perception/backends/base.py +179 -0
- perception/backends/cloud.py +214 -0
- perception/backends/native.py +140 -0
- perception/backends/probe.py +44 -0
- perception/backends/remote.py +140 -0
- perception/cli.py +780 -0
- perception/estimates.py +68 -0
- perception/export.py +100 -0
- perception/hosts.py +201 -0
- perception/ir.py +245 -0
- perception/keys.py +259 -0
- perception/manifests.py +169 -0
- perception/plan.py +170 -0
- perception/release.py +61 -0
- perception/remote_driver.py +990 -0
- perception/runpod.py +100 -0
- perception/scheduler.py +552 -0
- perception/setup.py +72 -0
- perception/store.py +219 -0
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# Decode only: the smallest real graph, used for backend smoke tests (cloud/skypilot/smoke.yaml).
|
|
2
|
+
# perception run graphs/decode.yaml --input video=PATH/TO/VIDEO.mp4
|
|
3
|
+
schema_version: 1
|
|
4
|
+
pipeline: perception-decode
|
|
5
|
+
inputs:
|
|
6
|
+
video: {type: video.v1, uri: '../data/video.mp4'} # relative to this file; usually set with --input
|
|
7
|
+
policy: {placement: local, privacy: local-only, max_cost_usd: 0, retries: 1}
|
|
8
|
+
nodes:
|
|
9
|
+
- id: decode
|
|
10
|
+
uses: core.decode@1.0.0
|
|
11
|
+
inputs: {video: $inputs.video}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# Decode and face tracks: the first graph timed on an NVIDIA GPU (cloud/skypilot/gpu-faces.yaml).
|
|
2
|
+
# perception run graphs/faces.yaml --input video=PATH/TO/VIDEO.mp4
|
|
3
|
+
schema_version: 1
|
|
4
|
+
pipeline: perception-faces
|
|
5
|
+
inputs:
|
|
6
|
+
video: {type: video.v1, uri: '../data/video.mp4'} # relative to this file; usually set with --input
|
|
7
|
+
policy: {placement: local, privacy: local-only, max_cost_usd: 0, retries: 1}
|
|
8
|
+
nodes:
|
|
9
|
+
- id: decode
|
|
10
|
+
uses: core.decode@1.0.0
|
|
11
|
+
inputs: {video: $inputs.video}
|
|
12
|
+
- id: faces
|
|
13
|
+
uses: vision.face-track@1.0.0
|
|
14
|
+
inputs: {video: $inputs.video, media: $nodes.decode.media, shots: $nodes.decode.shots}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# Everything the pipeline can do: the graph the annotator app runs. The app's feature list picks what runs (--only, with
|
|
2
|
+
# what it needs), settings (--set) and places (--place). Nothing leaves the machine without --allow-uploads, and then
|
|
3
|
+
# only the kinds allow_upload names: frames, faces and transcript for the captions API, and video and audio for a step
|
|
4
|
+
# placed on another machine (a lab server, a cloud GPU), which receives its inputs. max_cost_usd caps the spend.
|
|
5
|
+
# perception plan graphs/full.yaml --input video=PATH/TO/VIDEO.mp4
|
|
6
|
+
# perception run graphs/full.yaml --input video=PATH --only asr,faces # the local, free features
|
|
7
|
+
# perception run graphs/full.yaml --input video=PATH --allow-uploads # everything, captions too
|
|
8
|
+
schema_version: 1
|
|
9
|
+
pipeline: perception-full
|
|
10
|
+
inputs:
|
|
11
|
+
video: {type: video.v1, uri: '../data/video.mp4'}
|
|
12
|
+
policy:
|
|
13
|
+
placement: hybrid
|
|
14
|
+
privacy: local-preferred
|
|
15
|
+
allow_upload: [video, audio, frames, faces, transcript]
|
|
16
|
+
max_cost_usd: 0.50
|
|
17
|
+
retries: 1
|
|
18
|
+
nodes:
|
|
19
|
+
- id: decode
|
|
20
|
+
uses: core.decode@1.0.0
|
|
21
|
+
inputs: {video: $inputs.video}
|
|
22
|
+
- id: asr
|
|
23
|
+
uses: audio.asr@1.0.0
|
|
24
|
+
inputs: {audio: $nodes.decode.audio, shots: $nodes.decode.shots}
|
|
25
|
+
- id: faces
|
|
26
|
+
uses: vision.face-track@1.0.0
|
|
27
|
+
inputs: {video: $inputs.video, media: $nodes.decode.media, shots: $nodes.decode.shots}
|
|
28
|
+
- id: captions
|
|
29
|
+
uses: vlm.shot-captions@1.0.0
|
|
30
|
+
inputs: {shots: $nodes.decode.shots, keyframes: $nodes.decode.keyframes, transcript: $nodes.asr.transcript,
|
|
31
|
+
faces: $nodes.faces.faces, media: $nodes.decode.media}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# The Phase 1 graph plus shot captions through the Anthropic API: the first node that sends data off the machine
|
|
2
|
+
# and costs money. allow_upload names exactly what may leave; max_cost_usd caps the spend.
|
|
3
|
+
# perception run graphs/mvp-captions.yaml --input video=PATH/TO/VIDEO.mp4 --allow-uploads
|
|
4
|
+
schema_version: 1
|
|
5
|
+
pipeline: perception-mvp-captions
|
|
6
|
+
inputs:
|
|
7
|
+
video: {type: video.v1, uri: '../data/video.mp4'}
|
|
8
|
+
policy:
|
|
9
|
+
placement: hybrid
|
|
10
|
+
privacy: local-preferred
|
|
11
|
+
allow_upload: [frames, faces, transcript]
|
|
12
|
+
max_cost_usd: 0.50
|
|
13
|
+
retries: 1
|
|
14
|
+
nodes:
|
|
15
|
+
- id: decode
|
|
16
|
+
uses: core.decode@1.0.0
|
|
17
|
+
inputs: {video: $inputs.video}
|
|
18
|
+
- id: asr
|
|
19
|
+
uses: audio.asr@1.0.0
|
|
20
|
+
inputs: {audio: $nodes.decode.audio, shots: $nodes.decode.shots}
|
|
21
|
+
- id: faces
|
|
22
|
+
uses: vision.face-track@1.0.0
|
|
23
|
+
inputs: {video: $inputs.video, media: $nodes.decode.media, shots: $nodes.decode.shots}
|
|
24
|
+
- id: captions
|
|
25
|
+
uses: vlm.shot-captions@1.0.0
|
|
26
|
+
inputs: {shots: $nodes.decode.shots, keyframes: $nodes.decode.keyframes, transcript: $nodes.asr.transcript,
|
|
27
|
+
faces: $nodes.faces.faces, media: $nodes.decode.media}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# Phase 1 graph: decode, face tracks and speech, published as a track bundle.
|
|
2
|
+
# perception run graphs/mvp.yaml --input video=PATH/TO/VIDEO.mp4
|
|
3
|
+
schema_version: 1
|
|
4
|
+
pipeline: perception-mvp
|
|
5
|
+
inputs:
|
|
6
|
+
video: {type: video.v1, uri: '../data/video.mp4'} # relative to this file; usually set with --input
|
|
7
|
+
policy: {placement: local, privacy: local-only, max_cost_usd: 0, retries: 1}
|
|
8
|
+
nodes:
|
|
9
|
+
- id: decode
|
|
10
|
+
uses: core.decode@1.0.0
|
|
11
|
+
inputs: {video: $inputs.video}
|
|
12
|
+
- id: asr
|
|
13
|
+
uses: audio.asr@1.0.0
|
|
14
|
+
inputs: {audio: $nodes.decode.audio, shots: $nodes.decode.shots}
|
|
15
|
+
- id: faces
|
|
16
|
+
uses: vision.face-track@1.0.0
|
|
17
|
+
inputs: {video: $inputs.video, media: $nodes.decode.media, shots: $nodes.decode.shots}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
id: audio.asr
|
|
2
|
+
version: 1.0.0
|
|
3
|
+
stage: transcribe
|
|
4
|
+
title: Speech transcript
|
|
5
|
+
description: Word-timed transcript and utterances (Parakeet TDT v3); voices come from diarization
|
|
6
|
+
inputs: {audio: audio.wav.v1, shots: bundle.shots@0.1}
|
|
7
|
+
outputs: {transcript: bundle.transcript@0.1}
|
|
8
|
+
config:
|
|
9
|
+
model: {enum: [mlx-community/parakeet-tdt-0.6b-v3], default: mlx-community/parakeet-tdt-0.6b-v3}
|
|
10
|
+
runners:
|
|
11
|
+
- {kind: native, env: envs/audio, entry: node.py, accelerator: mps, platforms: [darwin/arm64]}
|
|
12
|
+
# TODO: [PERCEPTION-GRAPH] Phase 1 step 7 - a CUDA runner (NeMo nvidia/parakeet-tdt-0.6b-v3 in a pinned image)
|
|
13
|
+
# Spec: same output on linux/amd64 + CUDA; validate word times against the MLX conversion on the OOO clip.
|
|
14
|
+
# Plan: docs/plans/GRAPH_MVP_PLAN.md#phase-1--local-mvp
|
|
15
|
+
resources: {cpu_cores: 2, memory_gb: 6, gpu: shared}
|
|
16
|
+
# fetches the weights ahead of a run (perception setup)
|
|
17
|
+
prepare: prepare.py
|
|
18
|
+
models:
|
|
19
|
+
- {id: mlx-community/parakeet-tdt-0.6b-v3, source: hf://mlx-community/parakeet-tdt-0.6b-v3, licence: CC-BY-4.0,
|
|
20
|
+
licence_mode: commercial-ok, size_gb: 2.3}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""audio.asr: words with times, grouped into utterances, as the bundle's transcript layer.
|
|
2
|
+
|
|
3
|
+
Parakeet TDT 0.6B v3 (MLX, Apple silicon). Utterances split at a pause over 0.25 s, a sentence end, or a shot cut,
|
|
4
|
+
as the annotator prototype did (pipeline/bakeoff/label_page.py). Voices come from the diarization node, not here:
|
|
5
|
+
every word's voice is null.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import time
|
|
9
|
+
|
|
10
|
+
from perception_node import Node, NodeError
|
|
11
|
+
from wav import read_wav
|
|
12
|
+
|
|
13
|
+
PAUSE_SPLIT = 0.25 # seconds of silence between words that starts a new utterance
|
|
14
|
+
SENTENCE_END = (".", "?", "!")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def to_words(sentence) -> list[dict]:
|
|
18
|
+
"""Merge Parakeet's subword tokens into words; a leading space starts a new word."""
|
|
19
|
+
words = []
|
|
20
|
+
for token in sentence.tokens:
|
|
21
|
+
if not words or token.text.startswith(" "):
|
|
22
|
+
words.append({"w": token.text.strip(), "start": token.start, "end": token.end})
|
|
23
|
+
else:
|
|
24
|
+
words[-1]["w"] += token.text
|
|
25
|
+
words[-1]["end"] = token.end
|
|
26
|
+
return [{**w, "start": round(w["start"], 3), "end": round(w["end"], 3)} for w in words if w["w"]]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def utterances(words: list[dict], shots: list[dict]) -> list[dict]:
|
|
30
|
+
cuts = [s["start"] for s in shots[1:]]
|
|
31
|
+
units, current = [], []
|
|
32
|
+
for i, word in enumerate(words):
|
|
33
|
+
if current:
|
|
34
|
+
prev = words[current[-1]]
|
|
35
|
+
crosses_cut = any(prev["end"] <= c <= word["start"] or prev["start"] < c <= word["start"] for c in cuts)
|
|
36
|
+
if word["start"] - prev["end"] > PAUSE_SPLIT or prev["w"].endswith(SENTENCE_END) or crosses_cut:
|
|
37
|
+
units.append(current)
|
|
38
|
+
current = []
|
|
39
|
+
current.append(i)
|
|
40
|
+
if current:
|
|
41
|
+
units.append(current)
|
|
42
|
+
|
|
43
|
+
def shot_at(t):
|
|
44
|
+
return next(s["id"] for s in reversed(shots) if s["start"] <= t)
|
|
45
|
+
|
|
46
|
+
return [{"id": f"utt:{n}", "start": words[u[0]]["start"], "end": words[u[-1]]["end"],
|
|
47
|
+
"text": " ".join(words[i]["w"] for i in u), "shot": shot_at((words[u[0]]["start"] + words[u[-1]]["end"]) / 2),
|
|
48
|
+
"words": [u[0], u[-1]], "voice": None} for n, u in enumerate(units)]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
with Node() as node:
|
|
52
|
+
import mlx.core as mx
|
|
53
|
+
from parakeet_mlx import from_pretrained
|
|
54
|
+
from parakeet_mlx.audio import get_logmel
|
|
55
|
+
|
|
56
|
+
model_id = node.config["model"]
|
|
57
|
+
t0 = time.time()
|
|
58
|
+
model = from_pretrained(model_id)
|
|
59
|
+
# what model.transcribe(path) does with no chunking, from the samples read here (wav.py)
|
|
60
|
+
samples = read_wav(node.input("audio"), model.preprocessor_config.sample_rate)
|
|
61
|
+
if len(samples) < model.preprocessor_config.hop_length:
|
|
62
|
+
raise NodeError("input", f"the audio has {len(samples)} samples: too short to transcribe")
|
|
63
|
+
audio = mx.array(samples).astype(mx.float32) / 32768.0
|
|
64
|
+
result = model.generate(get_logmel(audio, model.preprocessor_config))[0]
|
|
65
|
+
seconds = round(time.time() - t0, 1)
|
|
66
|
+
node.model(model_id, licence_mode="commercial-ok", source=f"hf://{model_id}", licence="CC-BY-4.0")
|
|
67
|
+
|
|
68
|
+
words = [{**w, "voice": None} for sentence in result.sentences for w in to_words(sentence)]
|
|
69
|
+
shots = node.input_json("shots")["shots"]
|
|
70
|
+
node.write_json("transcript", "transcript.json", {
|
|
71
|
+
"layer": "transcript", "schema_version": "0.1.0",
|
|
72
|
+
"producer": node.producer(timings={"seconds": seconds}, device="mlx"),
|
|
73
|
+
"voices": [], "words": words, "utterances": utterances(words, shots)})
|
|
74
|
+
node.usage(audio_seconds=round(words[-1]["end"], 1) if words else 0, words=len(words))
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Fetch the ASR node's weights ahead of a run (`perception setup`): parakeet-mlx downloads the model from Hugging Face
|
|
2
|
+
the first time it is loaded, into the cache the node then reads.
|
|
3
|
+
python prepare.py CONFIG_JSON (the node's settings, as its request would carry them)"""
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
from parakeet_mlx import from_pretrained
|
|
9
|
+
|
|
10
|
+
model_id = json.loads(sys.argv[1])["model"]
|
|
11
|
+
from_pretrained(model_id)
|
|
12
|
+
print(f"{model_id} is in the cache")
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Decode's audio read as samples (wav.py), with nothing on PATH: parakeet-mlx's own loader runs ffmpeg, which an app
|
|
2
|
+
started from the Finder does not find. Run: uv run --no-project --with pytest --with numpy --with-editable ./sdk
|
|
3
|
+
pytest nodes/audio/asr"""
|
|
4
|
+
|
|
5
|
+
import wave
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
from perception_node import NodeError
|
|
11
|
+
from wav import read_wav
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@pytest.fixture(autouse=True)
|
|
15
|
+
def no_tools_on_path(monkeypatch):
|
|
16
|
+
monkeypatch.setenv("PATH", "")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def write(path, samples, rate=16000, channels=1):
|
|
20
|
+
with wave.open(str(path), "wb") as w:
|
|
21
|
+
w.setnchannels(channels)
|
|
22
|
+
w.setsampwidth(2)
|
|
23
|
+
w.setframerate(rate)
|
|
24
|
+
w.writeframes(samples.astype(np.int16).tobytes())
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_reads_decodes_audio_as_it_was_written(tmp_path):
|
|
28
|
+
samples = (np.sin(np.arange(16000) / 5) * 12000).astype(np.int16)
|
|
29
|
+
write(tmp_path / "a.wav", samples)
|
|
30
|
+
out = read_wav(tmp_path / "a.wav", 16000)
|
|
31
|
+
assert out.dtype == np.int16 and np.array_equal(out, samples)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@pytest.mark.parametrize("rate, channels", [(44100, 1), (16000, 2)])
|
|
35
|
+
def test_refuses_audio_decode_does_not_write(tmp_path, rate, channels):
|
|
36
|
+
write(tmp_path / "a.wav", np.zeros(1000 * channels), rate, channels)
|
|
37
|
+
with pytest.raises(NodeError, match="decode writes 1 channel, 16-bit, 16000 Hz"):
|
|
38
|
+
read_wav(tmp_path / "a.wav", 16000)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Decode's audio (16 kHz mono 16-bit WAV) as samples, read here rather than by parakeet-mlx: its load_audio runs the
|
|
2
|
+
ffmpeg program, which an app started from the Finder does not find (its PATH is /usr/bin:/bin:/usr/sbin:/sbin)."""
|
|
3
|
+
|
|
4
|
+
import wave
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
|
|
9
|
+
from perception_node import NodeError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def read_wav(path: Path, rate: int) -> np.ndarray:
|
|
13
|
+
"""The samples, as int16: what `ffmpeg -i path -f s16le -ac 1 -ar rate -` printed for a WAV already in that form."""
|
|
14
|
+
with wave.open(str(path)) as w:
|
|
15
|
+
got = (w.getnchannels(), w.getsampwidth(), w.getframerate())
|
|
16
|
+
if got != (1, 2, rate):
|
|
17
|
+
raise NodeError("input", f"the audio is {got[0]} channel(s), {8 * got[1]}-bit, {got[2]} Hz; "
|
|
18
|
+
f"decode writes 1 channel, 16-bit, {rate} Hz")
|
|
19
|
+
return np.frombuffer(w.readframes(w.getnframes()), np.int16)
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""A small synthetic video for the node tests that read one (decode's media.py, face-track's frames.py)."""
|
|
2
|
+
|
|
3
|
+
from fractions import Fraction
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from types import SimpleNamespace
|
|
6
|
+
|
|
7
|
+
import av
|
|
8
|
+
import numpy as np
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
FPS, SECONDS, W, H = 10, 2, 64, 48
|
|
12
|
+
LATE = 3 # frames the video starts after the audio (and the container)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def level(i: int) -> int:
|
|
16
|
+
"""Frame i's grey level: frames tell apart by brightness, through lossy encoding."""
|
|
17
|
+
return 10 + 10 * i
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def make(path):
|
|
21
|
+
"""2 s of 64x48 H.264 at 10 fps, each frame one grey level, starting LATE frames after its 48 kHz stereo AAC (the
|
|
22
|
+
container's start): files cut from a longer one often start that way."""
|
|
23
|
+
with av.open(str(path), "w") as out:
|
|
24
|
+
v = out.add_stream("libx264", rate=FPS)
|
|
25
|
+
v.width, v.height, v.pix_fmt = W, H, "yuv420p"
|
|
26
|
+
a = out.add_stream("aac", rate=48000, layout="stereo")
|
|
27
|
+
for i in range(FPS * SECONDS):
|
|
28
|
+
frame = av.VideoFrame.from_ndarray(np.full((H, W, 3), level(i), np.uint8), format="rgb24")
|
|
29
|
+
frame.pts, frame.time_base = LATE + i, Fraction(1, FPS)
|
|
30
|
+
out.mux(v.encode(frame))
|
|
31
|
+
out.mux(v.encode())
|
|
32
|
+
t = np.arange(48000 * SECONDS) / 48000
|
|
33
|
+
tone = (0.3 * np.sin(2 * np.pi * 440 * t)).astype(np.float32)
|
|
34
|
+
for start in range(0, len(tone), 1024):
|
|
35
|
+
chunk = np.stack([tone[start:start + 1024]] * 2)
|
|
36
|
+
frame = av.AudioFrame.from_ndarray(np.ascontiguousarray(chunk), format="fltp", layout="stereo")
|
|
37
|
+
frame.sample_rate, frame.pts = 48000, start
|
|
38
|
+
out.mux(a.encode(frame))
|
|
39
|
+
out.mux(a.encode())
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def decoded(path) -> list[tuple[float, np.ndarray]]:
|
|
43
|
+
"""Every frame in order: (seconds from the container's start, RGB)."""
|
|
44
|
+
with av.open(str(path)) as c:
|
|
45
|
+
return [(float(f.pts * f.time_base), f.to_ndarray(format="rgb24")) for f in c.decode(c.streams.video[0])]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def nth(path, i: int) -> np.ndarray:
|
|
49
|
+
"""Frame i of the video stream, counted from its first: the frame a detector sampling frame i saw."""
|
|
50
|
+
return [rgb for _, rgb in decoded(path)][i]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@pytest.fixture
|
|
54
|
+
def rotated():
|
|
55
|
+
"""A clip stored sideways with a 90° rotation, as phones record (nodes/testdata/README.md): shown 32x64, blue on top
|
|
56
|
+
and red below."""
|
|
57
|
+
return Path(__file__).parent / "testdata" / "rotated-90.mp4"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@pytest.fixture(scope="session")
|
|
61
|
+
def clip(tmp_path_factory):
|
|
62
|
+
path = tmp_path_factory.mktemp("video") / "clip.mp4"
|
|
63
|
+
make(path)
|
|
64
|
+
return SimpleNamespace(path=path, fps=FPS, seconds=SECONDS, w=W, h=H, late=LATE, nth=lambda i: nth(path, i))
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
id: core.decode
|
|
2
|
+
version: 1.0.0
|
|
3
|
+
stage: extract
|
|
4
|
+
title: Shots and audio
|
|
5
|
+
description: Video identity and timing, shots, keyframes and 16 kHz mono audio
|
|
6
|
+
inputs: {video: video.v1}
|
|
7
|
+
outputs:
|
|
8
|
+
media: media.v1
|
|
9
|
+
shots: bundle.shots@0.1
|
|
10
|
+
keyframes: assets.v1
|
|
11
|
+
audio: audio.wav.v1
|
|
12
|
+
config:
|
|
13
|
+
shot_threshold: {type: number, default: 3.0, description: "AdaptiveDetector adaptive_threshold (PySceneDetect default)"}
|
|
14
|
+
keyframe_width: {type: integer, minimum: 64, default: 768}
|
|
15
|
+
runners:
|
|
16
|
+
- {kind: native, env: envs/core, entry: node.py, accelerator: cpu}
|
|
17
|
+
resources: {cpu_cores: 2, memory_gb: 2, gpu: none}
|
|
18
|
+
models:
|
|
19
|
+
- {id: PySceneDetect AdaptiveDetector, licence: BSD-3-Clause, licence_mode: commercial-ok}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""The video's facts, keyframes and audio, read with PyAV: its wheels carry FFmpeg's libraries, so the node needs no
|
|
2
|
+
ffmpeg or ffprobe on the machine (an app started from the Finder has only /usr/bin:/bin:/usr/sbin:/sbin on its PATH).
|
|
3
|
+
Each function does what the ffprobe or ffmpeg command it replaced did; the comments say which."""
|
|
4
|
+
|
|
5
|
+
import wave
|
|
6
|
+
from fractions import Fraction
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import av
|
|
10
|
+
import numpy as np
|
|
11
|
+
|
|
12
|
+
from perception_node import NodeError
|
|
13
|
+
|
|
14
|
+
KEYFRAME_POSITIONS = (0.15, 0.5, 0.85)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def ratio(r: Fraction | None) -> str:
|
|
18
|
+
"""A rate or time base as ffprobe printed it ("30000/1001", "30/1"; "0/0" when unknown)."""
|
|
19
|
+
return f"{r.numerator}/{r.denominator}" if r else "0/0"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def probe(video: Path) -> dict:
|
|
23
|
+
"""ffprobe's format=duration and the first video and audio streams' fields."""
|
|
24
|
+
try:
|
|
25
|
+
container = av.open(str(video))
|
|
26
|
+
except av.FFmpegError as e:
|
|
27
|
+
raise NodeError("input", f"cannot read the video: {str(e)[:300]}") from None
|
|
28
|
+
with container:
|
|
29
|
+
if not container.streams.video:
|
|
30
|
+
raise NodeError("input", "the file has no video stream")
|
|
31
|
+
v = container.streams.video[0]
|
|
32
|
+
a = container.streams.audio[0] if container.streams.audio else None
|
|
33
|
+
r, avg = v.base_rate, v.average_rate or v.base_rate
|
|
34
|
+
return {"duration": container.duration / av.time_base, "width": v.codec_context.width, "height": v.codec_context.height,
|
|
35
|
+
"fps": float(avg or r), "r_frame_rate": ratio(r), "avg_frame_rate": ratio(v.average_rate),
|
|
36
|
+
"time_base": ratio(v.time_base), "start_pts": v.start_time,
|
|
37
|
+
# containers report r_frame_rate as the base rate and avg_frame_rate from the frame count; they differ
|
|
38
|
+
# when frame durations vary
|
|
39
|
+
"variable_frame_rate": bool(avg) and abs(float(r) - float(avg)) > 1e-3,
|
|
40
|
+
"audio": {"sample_rate": a.codec_context.sample_rate, "channels": a.codec_context.channels} if a else None}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def frame_at(container, t: float) -> av.VideoFrame:
|
|
44
|
+
"""The frame nearest `t` seconds after the video stream's first frame. Callers' times are frame times counted from
|
|
45
|
+
that frame (face-track's frame index / fps, decode's shots from scenedetect's frame counts), rounded to the
|
|
46
|
+
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
|
|
47
|
+
one shown at t is always it. (`ffmpeg -ss` counted from the container's start instead: when the video starts after
|
|
48
|
+
the audio, it cut crops and keyframes early.) The face-track node has the same function."""
|
|
49
|
+
v = container.streams.video[0]
|
|
50
|
+
target = Fraction(v.start_time or 0) * v.time_base + Fraction(round(t * 1000), 1000)
|
|
51
|
+
container.seek(int(target / v.time_base), stream=v, backward=True)
|
|
52
|
+
before = None
|
|
53
|
+
for frame in container.decode(v):
|
|
54
|
+
if frame.pts is None:
|
|
55
|
+
continue
|
|
56
|
+
at = frame.pts * v.time_base
|
|
57
|
+
if at >= target:
|
|
58
|
+
return before if before is not None and target - before.pts * v.time_base < at - target else frame
|
|
59
|
+
before = frame
|
|
60
|
+
if before is not None and target - before.pts * v.time_base < 1 / v.average_rate: # within the last frame
|
|
61
|
+
return before
|
|
62
|
+
raise NodeError("input", f"the video has no frame at {t:.3f} s")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def upright(frame: av.VideoFrame) -> np.ndarray:
|
|
66
|
+
"""The frame as RGB, turned the way players show it. Phone recordings store the picture sideways with a rotation
|
|
67
|
+
to apply: ffmpeg applied it, and so does torchcodec, which reads the frames face-track's detector sees; PyAV only
|
|
68
|
+
reports it. The face-track node has the same function."""
|
|
69
|
+
return np.ascontiguousarray(np.rot90(frame.to_ndarray(format="rgb24"), round((frame.rotation or 0) / 90) % 4))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def even_height(w: int, src_w: int, src_h: int) -> int:
|
|
73
|
+
"""ffmpeg's `scale=w:-2`: the height that keeps the aspect ratio, to the nearest even number."""
|
|
74
|
+
return max(2, round(Fraction(w * src_h, src_w * 2)) * 2)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def keyframes(video: Path, shots: list[dict], out_dir: Path, width: int) -> None:
|
|
78
|
+
"""Three frames per shot, upright, `width` wide (ffmpeg's scale=width:-2, bicubic), as JPEG."""
|
|
79
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
80
|
+
with av.open(str(video)) as container:
|
|
81
|
+
for shot in shots:
|
|
82
|
+
shot["keyframes"] = []
|
|
83
|
+
for pos in KEYFRAME_POSITIONS:
|
|
84
|
+
t = shot["start"] + pos * (shot["end"] - shot["start"])
|
|
85
|
+
name = f"shot{shot['index']:04d}_{int(pos * 100):02d}.jpg"
|
|
86
|
+
rgb = upright(frame_at(container, t))
|
|
87
|
+
h, w = rgb.shape[:2]
|
|
88
|
+
av.VideoFrame.from_ndarray(rgb, format="rgb24").reformat(
|
|
89
|
+
width=width, height=even_height(width, w, h), interpolation="BICUBIC").to_image().save(out_dir / name, quality=90)
|
|
90
|
+
shot["keyframes"].append({"t": round(t, 3), "file": f"assets/keyframes/{name}"})
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def audio(video: Path, wav: Path) -> None:
|
|
94
|
+
"""The first audio stream as 16 kHz mono 16-bit WAV (ffmpeg -vn -ac 1 -ar 16000 -sample_fmt s16)."""
|
|
95
|
+
with av.open(str(video)) as container, wave.open(str(wav), "wb") as out:
|
|
96
|
+
out.setnchannels(1)
|
|
97
|
+
out.setsampwidth(2)
|
|
98
|
+
out.setframerate(16000)
|
|
99
|
+
resampler = av.AudioResampler(format="s16", layout="mono", rate=16000)
|
|
100
|
+
for frame in container.decode(container.streams.audio[0]):
|
|
101
|
+
for f in resampler.resample(frame):
|
|
102
|
+
out.writeframes(f.to_ndarray().tobytes())
|
|
103
|
+
for f in resampler.resample(None):
|
|
104
|
+
out.writeframes(f.to_ndarray().tobytes())
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""core.decode: what every other node starts from.
|
|
2
|
+
|
|
3
|
+
- media: the video's identity and timing (sha256, duration, size, frame rates, the stream's PTS time base, whether
|
|
4
|
+
the frame rate varies), so later nodes and the integer-microsecond mapping never need to re-probe the file;
|
|
5
|
+
- shots: cuts by PySceneDetect's AdaptiveDetector, as the bundle's shots layer;
|
|
6
|
+
- keyframes: three frames per shot (at 15%, 50%, 85% of it), referenced from the shots layer as assets/keyframes/...;
|
|
7
|
+
- audio: 16 kHz mono 16-bit WAV for the audio nodes.
|
|
8
|
+
|
|
9
|
+
From pipeline/bakeoff/extract.py (shots and keyframes unchanged), without its transcription.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import time
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from perception_node import Node, NodeError
|
|
16
|
+
from media import audio, keyframes, probe
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def detect_shots(video: Path, duration: float, threshold: float) -> list[dict]:
|
|
20
|
+
from scenedetect import AdaptiveDetector, detect
|
|
21
|
+
|
|
22
|
+
scenes = detect(str(video), AdaptiveDetector(adaptive_threshold=threshold))
|
|
23
|
+
if not scenes:
|
|
24
|
+
return [{"index": 0, "start": 0.0, "end": round(duration, 3)}]
|
|
25
|
+
return [{"index": i, "start": round(s.get_seconds(), 3), "end": round(e.get_seconds(), 3)} for i, (s, e) in enumerate(scenes)]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
with Node() as node:
|
|
29
|
+
video = node.input("video")
|
|
30
|
+
c = node.config
|
|
31
|
+
media = {"sha256": node.request["inputs"]["video"]["sha256"], "filename": video.name, **probe(video)}
|
|
32
|
+
node.write_json("media", "media.json", media)
|
|
33
|
+
|
|
34
|
+
timings = {}
|
|
35
|
+
t0 = time.time()
|
|
36
|
+
shots = detect_shots(video, media["duration"], c["shot_threshold"])
|
|
37
|
+
timings["shots_s"] = round(time.time() - t0, 1)
|
|
38
|
+
node.model("PySceneDetect AdaptiveDetector", licence_mode="commercial-ok", licence="BSD-3-Clause")
|
|
39
|
+
|
|
40
|
+
t0 = time.time()
|
|
41
|
+
assets = node.path("keyframes", "assets")
|
|
42
|
+
keyframes(video, shots, assets / "keyframes", c["keyframe_width"])
|
|
43
|
+
timings["keyframes_s"] = round(time.time() - t0, 1)
|
|
44
|
+
|
|
45
|
+
t0 = time.time()
|
|
46
|
+
wav = node.path("audio", "audio.wav")
|
|
47
|
+
if media["audio"] is None:
|
|
48
|
+
raise NodeError("input", "the video has no audio stream")
|
|
49
|
+
audio(video, wav)
|
|
50
|
+
timings["audio_s"] = round(time.time() - t0, 1)
|
|
51
|
+
|
|
52
|
+
layer = [{"id": f"shot:{s['index']}", "index": s["index"], "start": s["start"], "end": s["end"],
|
|
53
|
+
"keyframes": s["keyframes"], "caption": None} for s in shots]
|
|
54
|
+
node.write_json("shots", "shots.json", {"layer": "shots", "schema_version": "0.1.0",
|
|
55
|
+
"producer": node.producer(timings=timings, device="cpu"), "shots": layer})
|
|
56
|
+
node.usage(shots=len(shots))
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""The decode node's media reading (media.py), with nothing on PATH: the app runs nodes from the Finder, where ffmpeg
|
|
2
|
+
and ffprobe are not found. Run: uv run --project envs/core --with pytest pytest nodes/core/decode"""
|
|
3
|
+
|
|
4
|
+
import wave
|
|
5
|
+
|
|
6
|
+
import av
|
|
7
|
+
import numpy as np
|
|
8
|
+
import pytest
|
|
9
|
+
from PIL import Image
|
|
10
|
+
|
|
11
|
+
from media import audio, even_height, frame_at, keyframes, probe
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@pytest.fixture(autouse=True)
|
|
15
|
+
def no_tools_on_path(monkeypatch):
|
|
16
|
+
monkeypatch.setenv("PATH", "")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_probe(clip):
|
|
20
|
+
m = probe(clip.path)
|
|
21
|
+
assert (m["width"], m["height"], m["r_frame_rate"], m["avg_frame_rate"], m["fps"]) == (clip.w, clip.h, "10/1", "10/1", 10.0)
|
|
22
|
+
assert m["duration"] == pytest.approx(clip.seconds + clip.late / clip.fps, abs=0.05) # the container's: from the audio's start
|
|
23
|
+
assert m["audio"] == {"sample_rate": 48000, "channels": 2}
|
|
24
|
+
assert not m["variable_frame_rate"]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_frame_at_counts_from_the_video_streams_first_frame(clip):
|
|
28
|
+
with av.open(str(clip.path)) as c:
|
|
29
|
+
v = c.streams.video[0]
|
|
30
|
+
assert float(v.start_time * v.time_base) == clip.late / clip.fps and not c.start_time # video after the audio
|
|
31
|
+
for i in (0, 1, 7, 19): # frame i at i / fps, as face-track and scenedetect count (not ffmpeg -ss's i - late)
|
|
32
|
+
for off in (0, 0.0004, -0.0004): # rounded to the millisecond, either way (29.97 fps frame times are)
|
|
33
|
+
assert np.array_equal(frame_at(c, i / clip.fps + off).to_ndarray(format="rgb24"), clip.nth(i)), (i, off)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_frame_at_past_the_end(clip):
|
|
37
|
+
with av.open(str(clip.path)) as c, pytest.raises(Exception, match="no frame at 5.000 s"):
|
|
38
|
+
frame_at(c, 5.0)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_keyframes(clip, tmp_path):
|
|
42
|
+
shots = [{"index": 0, "start": 0.0, "end": 1.0}, {"index": 1, "start": 1.0, "end": 2.0}]
|
|
43
|
+
keyframes(clip.path, shots, tmp_path, 33)
|
|
44
|
+
assert [k["t"] for k in shots[1]["keyframes"]] == [1.15, 1.5, 1.85]
|
|
45
|
+
img = Image.open(tmp_path / "shot0001_50.jpg")
|
|
46
|
+
assert img.size == (33, even_height(33, clip.w, clip.h)) == (33, 24) # ffmpeg's scale=33:-2
|
|
47
|
+
assert abs(np.asarray(img).mean() - clip.nth(15).mean()) < 3 # frames are 10 grey levels apart
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_audio(clip, tmp_path):
|
|
51
|
+
audio(clip.path, tmp_path / "a.wav")
|
|
52
|
+
with wave.open(str(tmp_path / "a.wav")) as w:
|
|
53
|
+
assert (w.getnchannels(), w.getsampwidth(), w.getframerate()) == (1, 2, 16000)
|
|
54
|
+
assert w.getnframes() == pytest.approx(16000 * clip.seconds, abs=1600)
|
|
55
|
+
samples = np.frombuffer(w.readframes(w.getnframes()), np.int16)
|
|
56
|
+
assert np.abs(samples).max() > 5000 # the tone, downmixed, not silence
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_keyframes_are_upright(rotated, tmp_path):
|
|
60
|
+
shots = [{"index": 0, "start": 0.0, "end": 1.0}]
|
|
61
|
+
keyframes(rotated, shots, tmp_path, 16)
|
|
62
|
+
img = np.asarray(Image.open(tmp_path / "shot0000_50.jpg")).astype(int)
|
|
63
|
+
assert img.shape[:2] == (32, 16) # 16 wide, as ffmpeg shows it: 32x64 upright
|
|
64
|
+
top, bottom = img[:12].mean((0, 1)), img[20:].mean((0, 1))
|
|
65
|
+
assert top[2] > 200 > top[0] and bottom[0] > 200 > bottom[2], (top, bottom)
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
Synthetic clips the node tests read (made with ffmpeg, which the tests themselves do not need):
|
|
2
|
+
|
|
3
|
+
- `rotated-90.mp4`: 1 s of 64x32 H.264 at 10 fps, the left half red and the right half blue, stored with a 90° display
|
|
4
|
+
rotation, as phone recordings are. Players and ffmpeg show it 32x64, blue on top and red below.
|
|
5
|
+
|
|
6
|
+
```sh
|
|
7
|
+
ffmpeg -f lavfi -i color=red:size=32x32:rate=10:duration=1 -f lavfi -i color=blue:size=32x32:rate=10:duration=1 \
|
|
8
|
+
-filter_complex hstack -c:v libx264 -pix_fmt yuv420p -map_metadata -1 -bitexact flat.mp4
|
|
9
|
+
ffmpeg -display_rotation 90 -i flat.mp4 -c copy -map_metadata -1 -bitexact rotated-90.mp4
|
|
10
|
+
```
|
|
Binary file
|