2323
2424import logging
2525from collections .abc import Iterable , Iterator
26- from dataclasses import replace
26+ from dataclasses import dataclass , replace
2727from pathlib import Path
28+ from typing import cast
2829
2930import numpy as np
3031
3132from tabvision .demux import demux
32- from tabvision .fusion import fuse
33+ from tabvision .fusion import TimedNeckAnchor , apply_neck_anchor_priors , fuse
34+ from tabvision .fusion .neck_prior import NeckAnchorLike
3335from tabvision .types import (
3436 AudioBackend ,
3537 AudioEvent ,
3840 GuitarBackend ,
3941 GuitarConfig ,
4042 HandBackend ,
43+ Homography ,
4144 SessionConfig ,
4245 TabEvent ,
4346)
@@ -49,6 +52,12 @@ class _VideoImportError(RuntimeError):
4952 """Internal signal: a soft-optional video dep failed to import."""
5053
5154
55+ @dataclass (frozen = True )
56+ class _VideoStackResult :
57+ fingerings : list [FrameFingering ]
58+ neck_anchors : list [TimedNeckAnchor ]
59+
60+
5261def run_pipeline (
5362 video_path : str | Path ,
5463 * ,
@@ -80,22 +89,29 @@ def run_pipeline(
8089 logger .info ("audio backend produced %d events" , len (audio_events ))
8190
8291 fingerings : list [FrameFingering ] = []
92+ neck_anchors : list [TimedNeckAnchor ] = []
8393 if video_enabled :
8494 try :
85- fingerings = _run_video_stack (
95+ video_result = _run_video_stack (
8696 demuxed .frame_iterator ,
8797 stride = video_stride ,
8898 cfg = cfg ,
8999 guitar_backend = guitar_backend ,
90100 fretboard_backend = fretboard_backend ,
91101 hand_backend = hand_backend ,
92102 )
103+ fingerings = video_result .fingerings
104+ neck_anchors = video_result .neck_anchors
93105 except _VideoImportError as exc :
94106 logger .warning (
95107 "video stack unavailable, falling back to audio-only: %s" ,
96108 exc ,
97109 )
98110
111+ if lambda_vision > 0.0 and neck_anchors :
112+ audio_events = apply_neck_anchor_priors (audio_events , neck_anchors , cfg )
113+ logger .info ("attached %d hand-neck anchors as audio fret priors" , len (neck_anchors ))
114+
99115 logger .info (
100116 "running fuse() with %d audio events, %d fingerings, lambda_vision=%.2f" ,
101117 len (audio_events ),
@@ -118,7 +134,7 @@ def _run_video_stack(
118134 guitar_backend : GuitarBackend | None ,
119135 fretboard_backend : FretboardBackend | None ,
120136 hand_backend : HandBackend | None ,
121- ) -> list [ FrameFingering ] :
137+ ) -> _VideoStackResult :
122138 """Single-pass walk producing one ``FrameFingering`` per sampled frame.
123139
124140 Skipped-by-stride frames produce nothing; sampled frames produce
@@ -138,6 +154,7 @@ def _run_video_stack(
138154 hand_backend = _make_hand_backend ()
139155
140156 fingerings : list [FrameFingering ] = []
157+ neck_anchors : list [TimedNeckAnchor ] = []
141158 n_fingers = 4 # fretting fingers; matches Phase 4 convention.
142159 empty_logits = np .zeros ((n_fingers , cfg .n_strings , cfg .max_fret + 1 ), dtype = np .float64 )
143160
@@ -167,8 +184,28 @@ def _run_video_stack(
167184 ff = hand_backend .detect (frame , H , cfg )
168185 # Backends produce a degenerate t=0.0; stamp the real timestamp here.
169186 fingerings .append (replace (ff , t = t ))
187+ anchor = _detect_neck_anchor (hand_backend , frame , H , cfg )
188+ if anchor is not None and anchor .confidence > 0.0 :
189+ neck_anchors .append ((t , anchor ))
190+
191+ return _VideoStackResult (fingerings = fingerings , neck_anchors = neck_anchors )
170192
171- return fingerings
193+
194+ def _detect_neck_anchor (
195+ hand_backend : HandBackend ,
196+ frame : np .ndarray ,
197+ H : Homography , # noqa: N803 — optional extension outside the §8 protocol
198+ cfg : GuitarConfig ,
199+ ) -> NeckAnchorLike | None :
200+ """Use a backend's optional coarse neck-anchor hook when available."""
201+ detect_anchor = getattr (hand_backend , "detect_anchor" , None )
202+ if detect_anchor is None :
203+ return None
204+ try :
205+ return cast (NeckAnchorLike | None , detect_anchor (frame , H , cfg ))
206+ except Exception as exc : # noqa: BLE001 — optional evidence must degrade softly
207+ logger .debug ("hand-neck anchor unavailable on frame: %s" , exc )
208+ return None
172209
173210
174211# ---------------------------------------------------------------------------
0 commit comments