|
| 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