Skip to content

Commit b769d7c

Browse files
happycubeclaude
andcommitted
Add CVBS .composite input to the analysis scripts; fix PAL dropout offset
tbc_common gains load_cvbs() (and a load_video() dispatcher): reads CVBS_U16_4FSC .composite/.meta files for both systems and presents the same params/fields interface as load_tbc, so all pattern detection and measurement code works unchanged. NTSC fields are orthogonal slices; PAL lines are extracted with exact-integer lattice math (1135.0064 samples/line, field B line 1 at frame line 313). Field phase IDs are reconstructed from position (files open on colour frame A / sequence frame 1 per the spec). smpte_analyze and differential_phase accept .composite inputs alongside .tbc. Validated against TBC decodes of the same content: field extraction is bit-exact (modulo the writer's protected-value clamp), and bar/DP/SNR/ chroma-noise measurements agree on both systems. Also fix CVBSWriter._collect_dropouts: PAL second-field dropout times used a 312.5-line offset, but downscale_cvbs maps field B display line 0 to frame line 313 (the interlace half-line lives in vsync, not the 0H spacing), so second-field sidecar positions were half a line off. Verified by inverting the sidecar sample positions back to field line/startx against the TBC decode's drop_outs records. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent bd7281e commit b769d7c

4 files changed

Lines changed: 186 additions & 23 deletions

File tree

lddecode/cvbs.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -357,9 +357,13 @@ def _collect_dropouts(self, frame_id, fi_a, fi_b):
357357
start = base + (int(line) - 1) * 910 + int(sx)
358358
count = max(1, int(ex) - int(sx))
359359
else:
360+
# Field display line 0 maps to frame line 0 (field A)
361+
# or 313 (field B) — see downscale_cvbs; the interlace
362+
# half-line lives in the vsync structure, not in the
363+
# 0H spacing.
360364
t = (int(line) - 1) + float(sx) / 1135.0
361365
if parity:
362-
t += 312.5
366+
t += 313.0
363367
start = int(round(t * 709379.0 / 625.0))
364368
count = max(1, int(round(float(ex) - float(sx))))
365369
if start < 0 or start >= fs:

scripts/differential_phase.py

Lines changed: 7 additions & 4 deletions
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]
24+
python scripts/differential_phase.py [file.tbc | file.composite]
2525
"""
2626

2727
import argparse
@@ -36,7 +36,7 @@
3636

3737
from lddecode.metrics import CombNTSC
3838
from tbc_common import (
39-
load_tbc, detect_patterns, summarize_patterns,
39+
load_tbc, load_cvbs, detect_patterns, summarize_patterns,
4040
burst_ref, demod_region, phase_diff,
4141
NTC7_MULTIBURST_FREQS, NTC7_PEDESTAL_PP,
4242
measure_ntc7_transients, measure_pal_its_transients,
@@ -1121,7 +1121,7 @@ def main():
11211121
"tbc_file",
11221122
nargs="?",
11231123
default=os.path.join(os.path.dirname(__file__), "..", "main.tbc"),
1124-
help="Path to .tbc file (default: main.tbc in the project root)",
1124+
help="Path to .tbc or CVBS .composite file (default: main.tbc in the project root)",
11251125
)
11261126
parser.add_argument(
11271127
"-n", "--max-fields",
@@ -1138,7 +1138,10 @@ def main():
11381138
print(f"TBC file: {tbc_path}")
11391139
print("=" * 90)
11401140

1141-
params, fields, _ = load_tbc(tbc_path, max_fields=args.max_fields)
1141+
if tbc_path.endswith(".composite"):
1142+
params, fields, _ = load_cvbs(tbc_path, max_fields=args.max_fields)
1143+
else:
1144+
params, fields, _ = load_tbc(tbc_path, max_fields=args.max_fields)
11421145
print(f"Loaded {len(fields)} fields, system={params.system}")
11431146
print(f" field_width={params.field_width}, field_height={params.field_height}")
11441147
print(f" sample_rate={params.sample_rate_mhz:.4f} MHz")

scripts/smpte_analyze.py

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
#!/usr/bin/env python3
2-
"""Color bar analyzer for NTSC and PAL TBC files.
2+
"""Color bar analyzer for NTSC and PAL TBC and CVBS files.
33
4-
Reads a .tbc file (with companion .tbc.db), detects colour bars, and
5-
measures luminance, chrominance amplitude, and chrominance phase for each
6-
bar against the expected values.
4+
Reads a .tbc file (with companion .tbc.db) or a CVBS .composite file
5+
(with companion .meta), detects colour bars, and measures luminance,
6+
chrominance amplitude, and chrominance phase for each bar against the
7+
expected values.
78
89
NTSC: SMPTE bars (75% or 100%), absolute I/Q via the CombNTSC comb filter.
910
PAL: EBU bars (100/0/75/0 or 100/0/100/0), U/V via burst-relative
@@ -15,6 +16,7 @@
1516
Usage:
1617
python scripts/smpte_analyze.py cbar_he.tbc
1718
python scripts/smpte_analyze.py pal-kage.tbc
19+
python scripts/smpte_analyze.py jasonbars.composite
1820
python scripts/smpte_analyze.py --decode ../testdata/he010_cbar.lds
1921
"""
2022

@@ -32,7 +34,7 @@
3234

3335
from lddecode.metrics import CombNTSC
3436
from tbc_common import (
35-
load_tbc, detect_patterns, summarize_patterns, detect_colorbars,
37+
load_tbc, load_cvbs, detect_patterns, summarize_patterns, detect_colorbars,
3638
burst_ref, demod_region, pal_fold_uv, phase_diff,
3739
)
3840

@@ -471,7 +473,8 @@ def main():
471473
parser.add_argument(
472474
"tbc_file",
473475
nargs="?",
474-
help="Path to .tbc file (companion .tbc.db must exist)",
476+
help="Path to .tbc file (companion .tbc.db must exist) or "
477+
"CVBS .composite file (companion .meta must exist)",
475478
)
476479
parser.add_argument(
477480
"--decode",
@@ -525,9 +528,12 @@ def main():
525528
parser.error("Provide a .tbc file or use --decode with an .lds/.ldf file")
526529

527530
# Load
528-
params, fields, tbc_data = load_tbc(tbc_path)
531+
if tbc_path.endswith(".composite"):
532+
params, fields, tbc_data = load_cvbs(tbc_path)
533+
else:
534+
params, fields, tbc_data = load_tbc(tbc_path)
529535
system = params.system
530-
print(f"TBC: {tbc_path}", file=sys.stderr)
536+
print(f"Input: {tbc_path}", file=sys.stderr)
531537
print(f" {system}, {params.field_width}x{params.field_height}, "
532538
f"{params.video_sample_rate/1e6:.4f} MHz, "
533539
f"{len(fields)} fields", file=sys.stderr)

scripts/tbc_common.py

Lines changed: 160 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,22 @@
11
#!/usr/bin/env python3
2-
"""Shared TBC loading, chroma demodulation, and test-pattern detection.
2+
"""Shared TBC/CVBS loading, chroma demodulation, and test-pattern detection.
33
44
Used by the analysis scripts (smpte_analyze.py, differential_phase.py).
5-
Supports both NTSC and PAL .tbc files (with companion .tbc.db).
5+
Supports NTSC and PAL .tbc files (with companion .tbc.db) and CVBS
6+
.composite files (with companion .meta, CVBS_U16_4FSC encoding) — see
7+
load_video().
68
7-
Chroma demodulation here is system-independent: both NTSC and PAL TBC
8-
output is sampled at exactly 4x the colour subcarrier, so the subcarrier
9-
sits at fs/4 and can be demodulated with a fixed quadrature reference.
10-
Absolute phase is only meaningful per-line (PAL output subcarrier phase
11-
rotates line to line), so phase measurements are referenced to the colour
12-
burst of the same line.
9+
Chroma demodulation here is system-independent: both NTSC and PAL output
10+
is sampled at exactly 4x the colour subcarrier, so the subcarrier sits at
11+
fs/4 and can be demodulated with a fixed quadrature reference. Absolute
12+
phase is only meaningful per-line (PAL output subcarrier phase rotates
13+
line to line), so phase measurements are referenced to the colour burst
14+
of the same line.
1315
1416
Run directly to report which test patterns are present:
1517
1618
python scripts/tbc_common.py file.tbc
19+
python scripts/tbc_common.py file.composite
1720
"""
1821

1922
import mmap
@@ -28,6 +31,25 @@
2831
# TBC loading
2932
# ---------------------------------------------------------------------------
3033

34+
# CVBS 4fsc field geometry (see cvbs-file-format-specification/). Levels
35+
# are the spec's 10-bit presets; active/burst windows match what ld-decode
36+
# writes to .tbc.db for the same 4fsc line convention (0H at +0.8).
37+
CVBS_GEOMETRY = {
38+
"NTSC": {
39+
"field_width": 910, "field_height": 263,
40+
"sample_rate": 4 * 315e6 / 88, "frame_samples": 477750,
41+
"active": (134, 894), "burst": (74, 110), "phase_cycle": 4,
42+
"levels": {"blanking": 240, "black": 282, "white": 800},
43+
},
44+
"PAL": {
45+
"field_width": 1135, "field_height": 313,
46+
"sample_rate": 17734475.0, "frame_samples": 709379,
47+
"active": (185, 1107), "burst": (98, 138), "phase_cycle": 8,
48+
"levels": {"blanking": 256, "black": 256, "white": 844},
49+
},
50+
}
51+
52+
3153
class CaptureParams:
3254
"""System parameters from the capture table of a .tbc.db."""
3355

@@ -56,6 +78,28 @@ def __init__(self, db_path):
5678
self.field_samples = self.field_width * self.field_height
5779
con.close()
5880

81+
@classmethod
82+
def for_cvbs(cls, system, black_level=None):
83+
"""Build params for a CVBS .composite file from the spec presets."""
84+
g = CVBS_GEOMETRY[system]
85+
p = cls.__new__(cls)
86+
p.system = system
87+
p.field_width = g["field_width"]
88+
p.field_height = g["field_height"]
89+
p.video_sample_rate = g["sample_rate"]
90+
p.sample_rate_mhz = p.video_sample_rate / 1e6
91+
lv = g["levels"]
92+
p.white_16b_ire = lv["white"] * 64
93+
p.blanking_16b_ire = lv["blanking"] * 64
94+
p.black_16b_ire = (black_level if black_level is not None
95+
else lv["black"]) * 64
96+
p.active_video_start, p.active_video_end = g["active"]
97+
p.colour_burst_start, p.colour_burst_end = g["burst"]
98+
p.capture_id = None
99+
p.out_scale = (p.white_16b_ire - p.blanking_16b_ire) / 100.0
100+
p.field_samples = p.field_width * p.field_height
101+
return p
102+
59103
def __repr__(self):
60104
return (
61105
f"CaptureParams(system={self.system}, "
@@ -134,6 +178,109 @@ def load_tbc(tbc_path, max_fields=None):
134178
return params, fields, tbc_data
135179

136180

181+
def _cvbs_extract_field(data, frame_idx, parity, params):
182+
"""One field of a CVBS frame as a flat field_width*field_height array.
183+
184+
NTSC frames are orthogonal (910 samples/line, field A 263 lines then
185+
field B 262). The PAL lattice is not line-locked: a line averages
186+
709379/625 = 1135.0064 samples, and line syncs sit on the integer
187+
line grid with the second field's line 1 at frame line 313 (see
188+
Field.downscale_cvbs), so each field line k (0-based) starts at
189+
lattice index ceil((k + parity*313) * 709379/625), computed with
190+
exact integer math. Rows are 1135 samples wide, matching the PAL TBC
191+
line convention; the sub-sample start offset (< 1 sample) cancels out
192+
of burst-relative phase measurements.
193+
194+
Reads run past the frame boundary into the next frame where the signal
195+
genuinely continues (NTSC field B line 263, PAL field B line 313);
196+
only samples past the end of the file are padded with blanking.
197+
"""
198+
fw, fh = params.field_width, params.field_height
199+
out = np.full(fh * fw, params.blanking_16b_ire, dtype=np.uint16)
200+
f0 = frame_idx * CVBS_GEOMETRY[params.system]["frame_samples"]
201+
202+
if params.system == "NTSC":
203+
starts = [f0 + (parity * 263 + k) * fw for k in range(fh)]
204+
else:
205+
# ceil((k + 313*parity) * 709379/625), integer-exact
206+
starts = [f0 - (-(k + 313 * parity) * 709379 // 625)
207+
for k in range(fh)]
208+
209+
for k, s in enumerate(starts):
210+
e = min(s + fw, len(data))
211+
if s >= len(data):
212+
break
213+
out[k * fw: k * fw + (e - s)] = data[s:e]
214+
return out
215+
216+
217+
def load_cvbs(path, max_fields=None):
218+
"""Load a CVBS .composite file (NTSC or PAL, CVBS_U16_4FSC).
219+
220+
Accepts the .composite path or the basename. Returns (params, fields,
221+
data) with the same interfaces as load_tbc().
222+
223+
Field phase IDs are reconstructed from position: the spec requires the
224+
file to open on a first field starting the colour sequence (NTSC colour
225+
frame A / PAL sequence frame 1), so field i has phase ID i % cycle + 1.
226+
"""
227+
base = path[:-len(".composite")] if path.endswith(".composite") else path
228+
comp_path, meta_path = base + ".composite", base + ".meta"
229+
if not os.path.exists(meta_path):
230+
raise FileNotFoundError(f"Metadata not found: {meta_path}")
231+
if not os.path.exists(comp_path):
232+
raise FileNotFoundError(f"CVBS file not found: {comp_path}")
233+
234+
con = sqlite3.connect(meta_path)
235+
row = con.execute(
236+
"SELECT preset, sample_encoding_preset, black_level "
237+
"FROM cvbs_file LIMIT 1").fetchone()
238+
con.close()
239+
if row is None:
240+
raise RuntimeError(f"No cvbs_file record in {meta_path}")
241+
system, encoding, black_level = row
242+
if encoding != "CVBS_U16_4FSC":
243+
raise RuntimeError(f"Unsupported CVBS encoding: {encoding}")
244+
if system not in CVBS_GEOMETRY:
245+
raise RuntimeError(f"Unsupported CVBS preset: {system}")
246+
247+
params = CaptureParams.for_cvbs(system, black_level)
248+
phase_cycle = CVBS_GEOMETRY[system]["phase_cycle"]
249+
250+
fd = os.open(comp_path, os.O_RDONLY)
251+
try:
252+
mm = mmap.mmap(fd, os.fstat(fd).st_size, access=mmap.ACCESS_READ)
253+
data = np.frombuffer(mm, dtype=np.uint16)
254+
finally:
255+
os.close(fd)
256+
257+
n_fields = 2 * (len(data) // CVBS_GEOMETRY[system]["frame_samples"])
258+
if max_fields is not None:
259+
n_fields = min(n_fields, max_fields)
260+
261+
fields = []
262+
for i in range(n_fields):
263+
arr = _cvbs_extract_field(data, i // 2, i % 2, params)
264+
record = {"field_id": i, "is_first_field": i % 2 == 0,
265+
"field_phase_id": i % phase_cycle + 1}
266+
f = TBCField(arr, 0, params, record)
267+
f.field_index = i
268+
fields.append(f)
269+
return params, fields, data
270+
271+
272+
def load_video(path, max_fields=None):
273+
"""Load a .tbc or CVBS .composite file, dispatching on the extension
274+
(or, for a bare basename, on which companion metadata file exists)."""
275+
if path.endswith(".composite"):
276+
return load_cvbs(path, max_fields)
277+
if path.endswith(".tbc"):
278+
return load_tbc(path, max_fields)
279+
if os.path.exists(path + ".meta"):
280+
return load_cvbs(path, max_fields)
281+
return load_tbc(path, max_fields)
282+
283+
137284
# ---------------------------------------------------------------------------
138285
# fs/4 quadrature chroma demodulation (system-independent)
139286
# ---------------------------------------------------------------------------
@@ -1055,9 +1202,12 @@ def hue_error(folded):
10551202

10561203
def main():
10571204
if len(sys.argv) != 2:
1058-
print(f"Usage: {sys.argv[0]} file.tbc", file=sys.stderr)
1205+
print(f"Usage: {sys.argv[0]} file.tbc|file.composite", file=sys.stderr)
10591206
return 2
1060-
params, fields, _ = load_tbc(sys.argv[1])
1207+
if sys.argv[1].endswith(".composite"):
1208+
params, fields, _ = load_cvbs(sys.argv[1])
1209+
else:
1210+
params, fields, _ = load_tbc(sys.argv[1])
10611211
print(f"{sys.argv[1]}: {params!r}, {len(fields)} fields")
10621212
det = detect_patterns(params, fields)
10631213
for line in summarize_patterns(det, fields):

0 commit comments

Comments
 (0)