|
1 | 1 | #!/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. |
3 | 3 |
|
4 | 4 | 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(). |
6 | 8 |
|
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. |
13 | 15 |
|
14 | 16 | Run directly to report which test patterns are present: |
15 | 17 |
|
16 | 18 | python scripts/tbc_common.py file.tbc |
| 19 | + python scripts/tbc_common.py file.composite |
17 | 20 | """ |
18 | 21 |
|
19 | 22 | import mmap |
|
28 | 31 | # TBC loading |
29 | 32 | # --------------------------------------------------------------------------- |
30 | 33 |
|
| 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 | + |
31 | 53 | class CaptureParams: |
32 | 54 | """System parameters from the capture table of a .tbc.db.""" |
33 | 55 |
|
@@ -56,6 +78,28 @@ def __init__(self, db_path): |
56 | 78 | self.field_samples = self.field_width * self.field_height |
57 | 79 | con.close() |
58 | 80 |
|
| 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 | + |
59 | 103 | def __repr__(self): |
60 | 104 | return ( |
61 | 105 | f"CaptureParams(system={self.system}, " |
@@ -134,6 +178,109 @@ def load_tbc(tbc_path, max_fields=None): |
134 | 178 | return params, fields, tbc_data |
135 | 179 |
|
136 | 180 |
|
| 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 | + |
137 | 284 | # --------------------------------------------------------------------------- |
138 | 285 | # fs/4 quadrature chroma demodulation (system-independent) |
139 | 286 | # --------------------------------------------------------------------------- |
@@ -1055,9 +1202,12 @@ def hue_error(folded): |
1055 | 1202 |
|
1056 | 1203 | def main(): |
1057 | 1204 | 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) |
1059 | 1206 | 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]) |
1061 | 1211 | print(f"{sys.argv[1]}: {params!r}, {len(fields)} fields") |
1062 | 1212 | det = detect_patterns(params, fields) |
1063 | 1213 | for line in summarize_patterns(det, fields): |
|
0 commit comments