Skip to content

Commit 11e46b6

Browse files
happycubeclaude
andcommitted
Move TBC/CVBS analysis into analysis/; add tbc_frames notebook library
Move the seven TBC/composite analysis scripts out of scripts/ into a new analysis/ directory (tbc_common, tbc_analyze, smpte_analyze, differential_phase, cvbs_verify, cvbs_orc_roundtrip), leaving scripts/ for repo tooling. Self-referencing usage strings updated; sys.path setup is depth-compatible so no import changes were needed. analysis/tbc_frames.py is a notebook-facing library on the shared parsers: load_frames() reads a group of frames from .tbc or CVBS .composite (NTSC and PAL) into a FrameSet of paired fields. FieldView exposes 2D raw/IRE arrays, the fs/4 quadrature reference, per-line burst phasors, and a baseline 1D comb split, and passes directly into CombNTSC and the tbc_common VITS measurement functions, which are all re-exported for one-import notebook use. Frame adds interlaced weave, comb_ntsc (1D/3D), and pattern detection. notebooks/devbook_base2.ipynb is the updated baseline devbook: inline decode via the modular LDdecode (lddecode.decoder) with a per-cell TBC-or-CVBS choice, or loading of existing outputs via tbc_frames, plus comb filter experiment scaffolding (subcarrier geometry notes, baseline splits, CombNTSC reference, burst plots) and VITS measurement cells. Verified by full headless execution on both output formats. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent b769d7c commit 11e46b6

8 files changed

Lines changed: 703 additions & 9 deletions

File tree

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
- 50% luma full-line subcarrier reference (line 331 style) if present
2222
2323
Usage:
24-
python scripts/differential_phase.py [file.tbc | file.composite]
24+
python analysis/differential_phase.py [file.tbc | file.composite]
2525
"""
2626

2727
import argparse
Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,10 @@
1414
which test patterns were detected and exits (use --lines to force).
1515
1616
Usage:
17-
python scripts/smpte_analyze.py cbar_he.tbc
18-
python scripts/smpte_analyze.py pal-kage.tbc
19-
python scripts/smpte_analyze.py jasonbars.composite
20-
python scripts/smpte_analyze.py --decode ../testdata/he010_cbar.lds
17+
python analysis/smpte_analyze.py cbar_he.tbc
18+
python analysis/smpte_analyze.py pal-kage.tbc
19+
python analysis/smpte_analyze.py jasonbars.composite
20+
python analysis/smpte_analyze.py --decode ../testdata/he010_cbar.lds
2121
"""
2222

2323
import argparse
@@ -28,7 +28,7 @@
2828

2929
import numpy as np
3030

31-
# Allow running from the scripts/ directory or project root.
31+
# Allow running from the analysis/ directory or project root.
3232
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
3333
sys.path.insert(0, os.path.dirname(__file__))
3434

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414

1515
import numpy as np
1616

17-
# Allow running from the scripts/ directory or project root.
17+
# Allow running from the analysis/ directory or project root.
1818
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
1919

2020
from lddecode.metrics import CombNTSC
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@
1515
1616
Run directly to report which test patterns are present:
1717
18-
python scripts/tbc_common.py file.tbc
19-
python scripts/tbc_common.py file.composite
18+
python analysis/tbc_common.py file.tbc
19+
python analysis/tbc_common.py file.composite
2020
"""
2121

2222
import mmap

analysis/tbc_frames.py

Lines changed: 275 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,275 @@
1+
"""Notebook-friendly frame access for .tbc and CVBS .composite files.
2+
3+
Built on tbc_common's parsers (load_tbc / load_cvbs): loads a group of
4+
frames from either container, NTSC or PAL, and presents them as numpy
5+
arrays with the subcarrier/burst bookkeeping needed for comb filter
6+
experiments. All test-pattern detection and VITS measurement helpers
7+
from tbc_common are re-exported so one import serves a whole notebook.
8+
9+
Usage from a notebook:
10+
11+
import sys
12+
sys.path.insert(0, "/home/cpage/ld-decode/chad-cutdown/analysis")
13+
from tbc_frames import load_frames
14+
15+
frames = load_frames("cbar_he.tbc", n_frames=4) # or .composite
16+
fr = frames[0]
17+
18+
fr.first.ire # field picture, 2D float IRE
19+
fr.first.raw # same, raw uint16
20+
fr.interlaced() # woven full frame, 2D IRE
21+
y, c = fr.first.split_comb1d() # baseline luma/chroma split
22+
fr.first.burst_phasors() # per-line complex burst (IRE, rad)
23+
24+
frames.detect() # which test patterns are present
25+
comb = fr.comb_ntsc() # lddecode CombNTSC on this frame
26+
27+
Subcarrier geometry (both containers, both systems, 4x fsc sampling):
28+
29+
- The subcarrier sits at exactly fs/4, so exp(-0.5j*pi*n) demodulates it
30+
(demod_region / burst_ref in tbc_common use this convention).
31+
- Absolute phase is only meaningful within one line. Reference
32+
measurements to the same line's colour burst (burst_phasors).
33+
- Stored-line-to-stored-line subcarrier phase within a field: NTSC
34+
advances 180 degrees per line (227.5 cycles/63.5us line); PAL advances
35+
270 degrees per line (283.75 cycles) with the V-switch alternating on
36+
top. PAL CVBS line starts additionally drift +0.0064 samples/line
37+
(90 degrees of subcarrier per sample) — one more reason to work
38+
burst-relative.
39+
"""
40+
41+
import os
42+
import sys
43+
44+
import numpy as np
45+
46+
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), ".."))
47+
48+
from tbc_common import ( # noqa: F401 (re-exports for notebook use)
49+
load_tbc, load_cvbs, load_video,
50+
detect_patterns, summarize_patterns, detect_colorbars,
51+
burst_ref, demod_region, line_segment_ire, average_line_ire,
52+
pal_fold_uv, phase_diff, segment_freq_pp, sine_fit_pp,
53+
measure_ntc7_multiburst, measure_ntc7_pedestal,
54+
measure_ntc7_transients, measure_pal_its_transients,
55+
measure_transients, weighted_psnr, chroma_am_pm_noise,
56+
NTC7_MULTIBURST_FREQS, NTC7_PEDESTAL_PP,
57+
)
58+
59+
60+
class FieldView:
61+
"""One field as 2D arrays plus chroma bookkeeping.
62+
63+
Wraps a tbc_common field object and delegates everything it does not
64+
define (line_ire, lineslice_tbc, output_to_ire, dspicture,
65+
fieldPhaseID, ...), so a FieldView can be passed directly to the
66+
tbc_common measurement functions and to lddecode.metrics.CombNTSC.
67+
"""
68+
69+
def __init__(self, field):
70+
self.field = field
71+
self.params = field.params
72+
73+
def __getattr__(self, name):
74+
return getattr(self.field, name)
75+
76+
def __repr__(self):
77+
return (f"FieldView({self.params.system} field "
78+
f"{self.field.field_index}, "
79+
f"{'first' if self.field.isFirstField else 'second'}, "
80+
f"phase {self.field.fieldPhaseID})")
81+
82+
@property
83+
def raw(self):
84+
"""Field picture as (field_height, field_width) uint16."""
85+
p = self.params
86+
return self.field.dspicture[:p.field_samples].reshape(
87+
p.field_height, p.field_width)
88+
89+
@property
90+
def ire(self):
91+
"""Field picture as (field_height, field_width) float64 IRE."""
92+
return self.field.output_to_ire(self.raw)
93+
94+
def carrier(self, n0=0, n1=None):
95+
"""Quadrature fs/4 reference exp(-0.5j*pi*n) for samples [n0, n1).
96+
97+
Multiply a composite line by this and average to demodulate
98+
chroma with the same sign convention as demod_region.
99+
"""
100+
if n1 is None:
101+
n1 = self.params.field_width
102+
return np.exp(-0.5j * np.pi * np.arange(n0, n1))
103+
104+
def burst_phasor(self, line):
105+
"""Complex burst for one line: abs = IRE amplitude, angle = phase.
106+
107+
Returns 0j when the burst is absent/too weak to measure.
108+
"""
109+
amp, phase = burst_ref(self.field, line)
110+
if amp is None:
111+
return 0j
112+
return amp * np.exp(1j * np.radians(phase))
113+
114+
def burst_phasors(self, lines=None):
115+
"""Per-line burst phasors as a complex array over `lines`
116+
(default: every stored line; index i corresponds to line i+1)."""
117+
if lines is None:
118+
lines = range(1, self.params.field_height + 1)
119+
return np.array([self.burst_phasor(ln) for ln in lines])
120+
121+
def split_comb1d(self):
122+
"""Baseline horizontal 1D luma/chroma split, as 2D IRE arrays.
123+
124+
The +/-2 sample comb (chroma = center - (left2+right2)/2) is
125+
exact for a pure fs/4 subcarrier and system-independent: the
126+
reference point for judging fancier combs. Chroma is returned
127+
at full amplitude (the raw comb has gain 2 at fsc).
128+
"""
129+
x = self.raw.astype(np.float64)
130+
c = np.zeros_like(x)
131+
c[:, 2:-2] = (x[:, 2:-2] - (x[:, :-4] + x[:, 4:]) / 2) / 2
132+
y = (x - self.params.blanking_16b_ire) / self.params.out_scale
133+
c /= self.params.out_scale
134+
return y - c, c
135+
136+
def demod(self, line, start_us, duration_us):
137+
"""(luma_ire, chroma_amp_ire, phase_deg) of a line region."""
138+
return demod_region(self.field, line, start_us, duration_us)
139+
140+
141+
class Frame:
142+
"""A first/second field pair."""
143+
144+
def __init__(self, first, second, index):
145+
self.first = FieldView(first)
146+
self.second = FieldView(second)
147+
self.index = index
148+
self.params = first.params
149+
150+
@property
151+
def fields(self):
152+
return (self.first, self.second)
153+
154+
@property
155+
def system(self):
156+
return self.params.system
157+
158+
def __repr__(self):
159+
return (f"Frame({self.index}, {self.system}, fields "
160+
f"{self.first.field.field_index}/"
161+
f"{self.second.field.field_index})")
162+
163+
def interlaced(self, ire=True):
164+
"""Plain weave of the two fields: first field on even rows.
165+
166+
Which parity sits spatially on top depends on the source's field
167+
order — for comb experiments the weave just provides adjacent-
168+
scan-time neighbours; check parity before using it for display.
169+
"""
170+
a = self.first.ire if ire else self.first.raw
171+
b = self.second.ire if ire else self.second.raw
172+
out = np.empty((2 * a.shape[0], a.shape[1]), dtype=a.dtype)
173+
out[0::2] = a
174+
out[1::2] = b
175+
return out
176+
177+
def comb_ntsc(self, prev_frame=None):
178+
"""lddecode CombNTSC over this frame's fields (NTSC only).
179+
180+
With prev_frame, enables 3D inter-frame comb mode. Metrics and
181+
splitIQ_line operate on the LAST field passed (this frame's
182+
second field).
183+
"""
184+
if self.system != "NTSC":
185+
raise ValueError("CombNTSC is NTSC-only")
186+
from lddecode.metrics import CombNTSC
187+
flds = []
188+
if prev_frame is not None:
189+
flds += [prev_frame.first.field, prev_frame.second.field]
190+
return CombNTSC(flds + [self.first.field, self.second.field])
191+
192+
def detect(self):
193+
"""Test patterns present on this frame's two fields."""
194+
return detect_patterns(self.params,
195+
[self.first.field, self.second.field])
196+
197+
198+
class FrameSet:
199+
"""A loaded group of frames; behaves as a sequence of Frame."""
200+
201+
def __init__(self, path, params, frames):
202+
self.path = path
203+
self.params = params
204+
self.frames = frames
205+
206+
@property
207+
def system(self):
208+
return self.params.system
209+
210+
@property
211+
def fields(self):
212+
"""All fields, in field order, as FieldViews."""
213+
return [fv for fr in self.frames for fv in fr.fields]
214+
215+
def __len__(self):
216+
return len(self.frames)
217+
218+
def __getitem__(self, i):
219+
return self.frames[i]
220+
221+
def __iter__(self):
222+
return iter(self.frames)
223+
224+
def __repr__(self):
225+
return (f"FrameSet({os.path.basename(self.path)}: {self.system}, "
226+
f"{len(self.frames)} frames)")
227+
228+
def detect(self, max_fields=8):
229+
"""Detect test patterns and print a summary; returns the dict."""
230+
flds = [fv.field for fv in self.fields]
231+
det = detect_patterns(self.params, flds, max_fields=max_fields)
232+
for line in summarize_patterns(det, flds):
233+
print(line)
234+
return det
235+
236+
def average_line(self, line, first_fields=True):
237+
"""Coherent IRE average of one field line across the group
238+
(matching parity only — the two parities carry different VITS)."""
239+
flds = [fv.field for fv in self.fields
240+
if fv.isFirstField == first_fields]
241+
return average_line_ire(flds, line)
242+
243+
244+
def load_frames(path, n_frames=None, start_frame=0):
245+
"""Load a group of frames from a .tbc or CVBS .composite file.
246+
247+
path: .tbc (companion .tbc.db), .composite (companion .meta),
248+
or a basename of either.
249+
n_frames: frames to return (default: all available).
250+
start_frame: first frame to return, counted in frame pairs from the
251+
start of the file.
252+
253+
Returns a FrameSet. Fields are paired strictly (a first field
254+
followed by a second field makes a frame); leading or stray unpaired
255+
fields are skipped.
256+
"""
257+
max_fields = None
258+
if n_frames is not None:
259+
# +2 spare fields in case the file opens on an unpaired second field
260+
max_fields = 2 * (start_frame + n_frames) + 2
261+
params, fields, _ = load_video(path, max_fields=max_fields)
262+
263+
pairs = []
264+
i = 0
265+
while i < len(fields) - 1:
266+
if fields[i].isFirstField and not fields[i + 1].isFirstField:
267+
pairs.append((fields[i], fields[i + 1]))
268+
i += 2
269+
else:
270+
i += 1
271+
272+
pairs = pairs[start_frame:
273+
None if n_frames is None else start_frame + n_frames]
274+
frames = [Frame(a, b, start_frame + k) for k, (a, b) in enumerate(pairs)]
275+
return FrameSet(path, params, frames)

0 commit comments

Comments
 (0)