feat(engine/ui): Center Stage pose-stable framing + model-server reliability#151
Merged
Conversation
…er button Experimental Features was reachable two ways (an Engine-menu action and a Services-panel footer button). Consolidate to a single Help-menu entry, placed between "Run AutoPTZ Mark…" and "About AutoPTZ", and remove the Services-panel button + its handler. Tests: rename the Engine-menu test to assert Help placement (and that it is NOT in Engine, and sits between Mark and About); add tests/test_services_panel.py asserting the footer no longer carries an Experimental button. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The engine on/off state is remembered across launches (default ON). But Help → Run AutoPTZ Mark suspends the engine, so quitting from inside Mark persisted the transient suspended-OFF state — leaving the engine off on every later launch until the user manually started it and quit cleanly. MainWindow now latches "suspended for Mark" on entry and clears it only on a successful resume, exposing desired_engine_running() that reports the pre-Mark state while suspended. app.run() persists that intended state (falling back to the live client for windows without the accessor). Remember-last-state semantics are unchanged: a deliberate Stop still persists off. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…el-server fallback
The retired standalone model-per-child experiment left a compatibility alias
(env_process_per_camera / process_per_camera_enabled forwarding to
env_model_server), which leaked the dead "process-per-camera" concept into logs
and IDE tooltips — a user saw a model-server fallback message reference the
"retired model-per-child process path."
Delete the alias; the one live gate is env_model_server() /
process_worker.model_server_workers_enabled(). Reword the fallback messages in
plain language ("no shared-detection-server slot for this camera — running the
built-in threaded pipeline; restart the engine to attach it"; "shared detection
server permanently failed … cameras continue with their own built-in detectors").
Scrub the stale phrasing from touched docstrings/comments; record the removal in
docs/engineering/retired-experiments.md.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
group_framing was a dead entry in the Experimental dialog's "New-camera tracking defaults" (nothing consumed it when a camera was added). It is a live TrackingConfig field consumed by the Center Stage digital framer, so surface it as a per-camera checkbox next to the Center Stage toggle. It rides the existing debounced write-back, so toggling applies to the running camera with no restart. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ty note
"Camera source" / "Real NDI sources" collided with the main app's Add-Camera
source concept and read as if Mark used the user's real cameras. Rename to
"Test video" with plain-language options ("Built-in clip — real people
(recommended)", "Live NDI sources on your network"). Session field values
("clip"/"ndi") are unchanged.
Add docs/research/mark-simulation-fidelity.md: an honest read on what the bundled
clips measure (throughput/scaling), what they can't (real PTZ ego-motion,
parallax, closed-loop latency), and when a 3D rendered PTZ environment would be
worth building (closed-loop control validation) — which ties into the PTZ-parity
work.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…/diagnostic env vars Rework the flat Experimental Features dialog into grouped sections and widen what it can edit, so every meaningful engine env var has one organized home: - ExperimentalFlag gains a `section` and two new kinds (`text`, `path`); the dialog renders text/path line edits (path adds Browse) alongside bool/choice, grouped under section headers (Experiments / Devices & tuning / Model overrides / Diagnostics). - Surface previously-hidden but meaningful vars: AUTOPTZ_MODEL_PATH, AUTOPTZ_POSE_MODEL_PATH, AUTOPTZ_MODEL_URL (Model overrides), AUTOPTZ_MS_DIAG, AUTOPTZ_SYNTH_DEBUG (Diagnostics). - Deliberately exclude the 4 supervisor-managed hardware vars (FORCE_EP / PRECISION / ORT_INTRA_THREADS / CV2_THREADS) — a hardware-prefs path already writes them, so a second writer here would drift. Launch-only / test-harness / logging-cosmetic vars stay env-only too (documented in flags.md next). - Remove the dead "New-camera tracking defaults" section (TRACKING_DEFAULT_FIELDS): nothing consumed those keys for a new camera; group_framing now lives on the per-camera panel. The supervisor prunes stale keys (removed flags, the dead tracking defaults, excluded vars) from the persisted dict at engine start. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The insightface face pack was a read-only "upstream-managed" row — users could not download or remove it in the app, even though AutoPTZ controls its app-data cache location. Make it a first-class row like the detector/pose models. - ModelManager: face_pack_status() (classify env/bundled/app-data/home/missing, removable only for app-data, size), ensure_face_pack() (download into the app-data cache via identify.ensure_face_model), remove_face_pack() (delete ONLY the app-data copy — path computed from cache_dir, never insightface_root(), so a bundled or ~/.insightface pack is never touched). fetch_models delegates to it. - Model Manager dialog: a "Face recognition pack" row with Download (indeterminate progress, ~300 MB) and Remove (enabled only when app-data-managed, confirm), on the existing background task that brackets release/rebuild of ORT sessions. The "Upstream-managed models" section now shows only ReID, with clearer copy. - Windows file-lock: release_model_sessions now also releases the shared face ORT session (pool.release_face) and each worker drops+rebuilds its own face stack, so removing the pack can't be blocked by a held onnxruntime handle. (Tradeoff: every model op now also cycles the face session — a few seconds of lazy rebuild.) - diagnostics: face/ReID remediation text points at Manage Models. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…leshooting) - flags.md: rebuild around the sectioned Experimental Features dialog (Help menu), document the deliberately-excluded env-only vars (supervisor-managed hardware, launch-only, logging/test), and record that AUTOPTZ_PROCESS_PER_CAMERA's alias is gone (env var still ignored). - configuration.md: Experimental now in Help; add group_framing + vcam_out per-camera rows; add an "Engine autostart" note incl. the Mark rule. - installation.md / troubleshooting.md: the face pack is downloadable/removable in Model Manager (removable only from the app-data cache); fetch_models stays for offline provisioning. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
An adversarial multi-agent review of PR1 confirmed 5 defects in the new face-pack feature; all fixed: - [medium] Eager ~1.3 GB face-pack reload on unrelated model ops: a detector download/remove force-dropped and rebuilt the shared insightface session (regression vs main, which never touched face on detector ops). Gate the face release/rebuild behind an `include_face` flag threaded from the face-pack task only (ModelTask → EngineClient → Supervisor → CameraWorker command queue), so detector/pose ops leave the face session untouched. - [medium] face_pack_status() diverged from insightface_root() when INSIGHTFACE_HOME is set to an empty dir — it fell through to a shadowed pack and falsely reported present, while the engine loads (nothing) from the override. INSIGHTFACE_HOME is now terminal, matching the engine, so status is honest. - [low] After a face download/remove the status line said "Selected models are cached." — now says the correct face-specific message. - [low] The face-download indeterminate progress bar was overwritten to a frozen 0/1 by a (0,0) progress emit — the emit is dropped so the spinner stands. - [low] A partial/interrupted download leaving one leftover .onnx read as a complete pack (disabling Download) — require >=2 .onnx for present. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…r Stage Center Stage (digital crop) and physical PTZ were separate implementations with no shared "which subject to frame" logic: Center Stage had explicit-lock precedence and group framing; physical PTZ only ever followed an explicit lock and ignored group framing entirely. Extract a shared, pure framing-target selector (autoptz/engine/framing_target.py): explicit lock wins (live track, else trusted-box hold), else the confident-group union when group framing is on, else nothing. Center Stage's _current_digital_target now delegates to it (behavior unchanged, now covered by direct unit tests). Physical PTZ consumes the same selector: when nobody is locked and group framing is on, it now aims at the confident group (union or the single confident person), using the same aim anchor as the single-target path — so a group is framed the same way Center Stage frames it. The existing single-locked-target motor path (pose fusion, coast, PD control) is untouched, and with group framing OFF (the default) physical PTZ behavior is exactly as before. NOTE: the new physical group-aim path drives real motors and must be validated on hardware before relying on it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The only feed export was a per-camera virtual camera (local machine only, and its pyvirtualcam backend can't be bundled for installer users). Add NDI network output so the framed feed reaches ANY computer on the LAN with a free NDI receiver. - New autoptz/engine/pipeline/ndi_send.py: NDISendSink mirrors VirtualCamSink but sends over the network via cyndilib.Sender (the same NDI SDK already shipped for input — no new dependency, no license conflict). Import-guarded: degrades to an unavailable no-op where cyndilib is absent (repo .venv/CI). - PtzConfig gains ndi_out + ndi_output_name; the worker's _push_frame drives an NDI sink alongside the virtual-camera sink on the same ~30 fps gate, named "AutoPTZ <camera>" (advertised as "<host> (AutoPTZ <camera>)"). - Properties → PTZ now shows both toggles clearly: "NDI output (other computers)" (recommended) and "Virtual camera (this computer)". NOTE: NDI output and the virtual camera can only be verified with a real receiver / driver on the LAN. The native installer-bundled virtual cameras (Windows DirectShow, macOS CMIO) and the VCamBackend abstraction remain a separate follow-on track (MASTER-PLAN E3/E4). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Launching the real app (PyCharm) surfaced three issues the offscreen tests missed because they never exercised the client-signal → _on_config_changed path: - [functional, pre-existing] PropertiesPanel._on_config_changed called a removed _sync_framing_sliders() method (the framing sliders were replaced by the "Frame on" dropdown), raising AttributeError on every external config change on the selected camera — including a Properties toggle round-tripping through the client's configChanged signal. Remove the dangling call; add a regression test. - [cosmetic] A disabled DangerButton (e.g. Remove on a non-removable face pack) still rendered bright red and looked clickable — add a danger:disabled theme rule that dims it. - [cosmetic] The reorganized Experimental Features dialog's rows overflowed the 560px min width, clipping the "Restart required" badge behind a horizontal scrollbar. Widen to 680 and disable the horizontal scrollbar. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… testing) The PTZ-framing-parity work added physical-motor behavior (group aim) that can only be validated on real hardware. Document a design for closing that gap: a virtual PTZ environment that scores control quality deterministically, with a recommended cheap Phase-1 (2D virtual PTZ over a wide canvas reusing the existing synthetic-source + PTZ-backend seams) before any 3D renderer. Cross-linked from the Mark simulation-fidelity note. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…tate Root cause of "services are always stopped when I open the program": the app persisted `engine_running` = the live engineRunning at shutdown (False whenever the engine wasn't running for ANY reason — never started, failed to start, or suspended for Mark), and a persisted-False skipped auto-start on the next launch. So once the engine was off it stayed off forever — a self-perpetuating trap. My earlier fix only plugged the Mark-quit leak, not the general trap. Persist the user's auto-start INTENT instead: - EngineClient tracks `autostartDesired` (default True). Only the user-facing Start/Stop controls change it (new userStartEngine/userStopEngine slots, wired from the Services panel + Engine menu); startEngine/stopEngine (auto-start, shutdown, Mark, restart) never touch it. - app.run() keys auto-start on a NEW setting `engine_autostart` (default True), seeds the client intent from it, and persists the intent. The retired `engine_running` key is abandoned, so existing stuck-False values are ignored — every launch auto-starts unless the user deliberately Stopped last session. - desired_engine_running() now returns the intent (Mark's system-level stop/start never changes it), so the separate _mark_engine_suspended latch is removed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ialog contrast Round of fixes from live desktop testing: - Model Manager: the face pack is now removable from the caches the user owns — the AutoPTZ app-data cache AND their own ~/.insightface (previously home was non-removable, so a home pack couldn't be deleted at all). A pack baked into the app bundle or pinned via INSIGHTFACE_HOME is still left alone. The face-pack row also moves UP to sit with the other models (right under Downloaded), not in a lower section. - Run AutoPTZ Mark: the NDI source is renamed "Simulated NDI streams (created on this computer)" with a tooltip — it spins up temporary local NDI senders for the benchmark, it does NOT use real network NDI cameras (the old "Live NDI sources on your network" label was misleading). - Properties panel: fields (combos/line edits) no longer overflow a narrow dock — horizontal scrollbar off, content-hungry fields shrink to fit, a minimum panel width floors it, and the longest checkbox labels are trimmed. Fixes the truncated labels + sideways-scroll "weird scaling" look. - Experimental Features dialog: each section is now a distinct elevated card; section captions are brighter/bolder app-wide for better contrast. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…panel fit Second round of fixes from live 5-camera desktop testing: - Camera tile: the cryptic bare "×N" degradation chip is REMOVED (its info now lives, worded, in the "?" per-stage tooltip and the Properties caption), and the tracking-state chip no longer paints on top of the target label — one status surface at a time (the label carries the state in its headline). - Engine label: aggregate per-camera EPs instead of last-writer-wins. Mixed worker modes (shared-server cameras report "model-server", threaded cameras report CoreML/Dml/CPU) made the label flap at telemetry rate; it now reads a stable, honest "CoreML + model-server" when mixed. - Log spam: "center-stage: target=..." now logs only on a state CHANGE (was an identical line every 2 s per camera); "inference behind" logs INFO only on the enter/recover transitions (steady-state repeats → DEBUG), with a new "caught up" line on recovery. - Face pass: skip the whole pass (including the SCRFD full-frame scan) when no tracks exist — an empty scene paid pure CPU for nothing; the timer isn't stamped so the pass runs immediately when someone appears. - Properties captions (state / Degraded ×N) are now always-visible one-line elided labels — no more show/hide making the panel jump under load. - Services panel: Start/Stop/Restart move to their own header row (smaller minimum width) and both side panels pin their hidden horizontal scrollbar to 0 — a narrow dock can no longer end up clipped/side-scrolled. - NDI menu: this machine's OWN AutoPTZ NDI outputs are hidden (with an explanatory disabled entry) — re-adding them as cameras created a feedback loop (encode+decode of our own pixels, nested rebroadcast) that was the main driver of the 5-camera frame drops. Another machine's AutoPTZ output stays listed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…re-elide The saved dock layout can restore side panels NARROWER than their content minimum (Qt only enforces widget minimums on interactive drags, not on restoreState) — a ~180px Properties dock clipped cost chips and help badges off the right edge and hard-cut the tracking captions mid-word. - MainWindow._enforce_dock_minimums(): one event-loop turn after geometry restore, widen any dock narrower than its panel's minimum via resizeDocks — the panels' width floors are now real across sessions. - PropertiesPanel.resizeEvent re-elides the one-line tracking captions from their tooltip (full text) so a resize shows "…" instead of a hard cut. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…le at restore time) Also de-shadow the method-local QTimer import that made the new call raise. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A non-wrapping QLabel's minimum width is its full text width, so the new always-visible captions silently widened the scroll body past the dock, right-clipping every row (cost chips, help badges) and defeating the elide. Ignored horizontal size policy keeps them at exactly the width the panel gives. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ing row 268 fit the rows offscreen but not live: the styled vertical scrollbar (~12pt) plus body margins shrink the scroll viewport, so the Tracker row's cost chip still right-clipped at the floor. Floor at 300 so the widest expanded-Tracking row fits with the scrollbar present. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…an't inflate the form FPS drops: the vcam/NDI output work (BGR→RGBA conversion, resize, SDK hand-off) ran INLINE on the capture thread in _push_frame — milliseconds per frame that surfaced as random frame drops whenever the machine was busy. New OutputSender pump: the capture thread parks the newest frame in a single latest-frame slot (drop-oldest, never queues, never blocks) and a dedicated thread converts+sends. Sinks stay owner-managed; the pump closes before them on teardown. The passthrough frame (adapter-owned buffer) is copied for the pump; the Center Stage crop path is already a fresh array. Loopback transparency: the shared is_own_autoptz_output() helper moves to engine.discovery.ndi (the NDI-menu filter delegates to it), and the supervisor now logs a WARNING when a configured NDI camera ingests this machine's own AutoPTZ output — the feedback loop that was the dominant frame-drop source on the 5-camera rig (menu now prevents adding them, but earlier cameras persist). Properties: read-only value labels (Address!) get an Ignored horizontal policy — a long NDI name / RTSP URL was widening the whole form so the Track button and sliders stretched with it; the address is now elided with the full value on the tooltip (re-elided on resize). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… floor
Replaced screenshot-driven whack-a-mole with an offscreen layout audit that
measures every visible widget against its scroll viewport at multiple widths
(Properties / Services / Camera Info, worst-case long NDI address, all sections
expanded). Two real defects found and fixed:
- Properties: the elided values (Address, tracking captions) were elided against
a STALE width — resizeEvent and set_camera run before the labels' new widths
settle, so a long address stayed full-length and hard-clipped ("source isn't
reaching"). Re-elide is now deferred one event-loop turn after both.
- Services: minimumSizeHint was 260 but the styled scrollbar + margins push the
body 22px past the viewport at that width, clipping the ON/OK pills and the
Restart/Enable-all buttons off the right edge. Floor raised to 300 (audit-
verified clean), enforced as a real dock width by the main window.
Camera Info was verified clean (its values word-wrap). Audit result after fix:
zero overflowing/clipped widgets at every reachable panel width.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…dth floors "I never want horizontal scroll for a min width." Made that a tested invariant: - The "measured: N fps — source isn't reaching M fps" caption now WRAPS and can never widen the form (it appeared exactly under load and inflated the body — the remaining 'source isn't reaching' breakage). - Properties floor 300 → 360: the previous audit never actually expanded the collapsible sections (setChecked doesn't fire clicked), so it missed that the expanded PTZ manual-control cluster needs ~360 with margins + scrollbar. - New tests/test_panel_min_width_no_clip.py pins the invariant for Properties (all sections force-expanded, worst-case long address + degradation + fps captions injected), Services, Camera Info, and the Experimental dialog: at each surface's own minimum width, zero widgets may cross the scroll viewport and zero non-wrapping/non-elided labels may clip (HTML-aware text metrics). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ce warning spam - The model-server tags each detection reply with its detector's actual execution provider; the per-camera client folds it into its label so the UI reads 'model-server (CoreML)' instead of hiding the engine. Worker telemetry re-reads the live detector ep because the server loads its model in the background (the build-time snapshot would freeze stale). Old 2-tuple replies still parse (mixed-version restart safety). - insightface face_align trips a skimage FutureWarning per attribution point per process — every camera child floods the console during face recognition. Scoped ignore (insightface modules only) installed by both install_console_logging and the camera-child logging setup. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… degrading to threaded A camera added after the shared detection server started (including remove + re-add, which mints a new camera id) had no IPC slot and silently fell back to the threaded pipeline INSIDE the GUI process — capture, tracking, face, pose, ReID, Center Stage and NDI output all sharing the UI's GIL, oscillating 20-30fps (reproduced live: re-added MacBook camera ran threaded while the model-server child sat idle). The response-queue set can only cross the process boundary via spawn args, so attach = allocate the queue, then a controlled server restart with the updated dict via the existing R-3 respawn machinery: down-gate holds during the ~2s swap, existing cameras fast-fail and coast, ready-handshake clears the gate. No server (never started / spawn failed) still falls back threaded as before. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ty CRUD sync Root cause of "I'm enrolled but every camera calls me Person N" and "face DB edits do nothing until I restart the services": 1. PR #139 regression: the model-server branch of run_camera_process replaced the whole _wire_models_and_identity call when it swapped the local detector for the IPC client — silently dropping the identity half. Every model-server camera child matched faces against an EMPTY memory-only gallery: enrolled names never loaded, nothing persisted, and the sibling ingest_identity relay no-op'd (it targets the injected service, which was never set). Extracted _wire_identity() and call it from the model-server branch too. 2. The supervisor dropped RENAME_IDENTITY / DELETE_IDENTITY on the floor ("UI/store concerns"), so process-mode children only converged on restart. New handlers re-apply the edit to the shared gallery and broadcast it: rename/label relays the updated record via the existing ingest_identity path (source_cid="" -> all process children); delete broadcasts the new CameraWorker.delete_identity / ProcessWorkerHandle.delete_identity. 3. mergeIdentities / setIdentityEnabled changed what the matcher does but enqueued nothing — children never heard about merges or enable toggles. They now enqueue a rename-to-current-name "touch" (the relayed record carries the merged embeddings / new enabled state), and merge also enqueues the delete of the folded identity. TDD: 8 new tests (model-server child wiring, supervisor broadcast, handle proxy, worker delete, client sync commands). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…: dead until restart) A camera whose source failed to open at service start, or whose session stopped delivering frames (the classic Continuity Camera "session runs, no frames ever arrive" when the iPhone is asleep at spawn), stayed dead FOREVER: the ingest adapters' stall/reconnect loop only runs on the adapter's own thread, which the worker never starts — the worker drives _open/_read_frame directly and its capture loop only backed off and re-polled read() on the dead session. Observed live as a camera child at 0% CPU for 13 minutes with the device present; only a full service restart brought it back. The capture loop now owns reconnect: after reconnect.stall_timeout_s without a frame (or when the source never opened), _maybe_reopen_source() closes the stale session and rebuilds+reopens via build_frame_source, paced by exponential backoff (reconnect.backoff_initial_s doubling to backoff_max_s, reset on any delivered frame). Injected (test/synthetic) sources are never rebuilt. Attempts surface as RECONNECTING health so the UI shows what's happening instead of a silent black tile. TDD: tests/test_source_reopen.py (stall/backoff/reset/injected-guard units + an end-to-end worker recovering from a frameless source). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ver deaf User repro: add a new camera (or remove + re-add) while running → the new camera's subsystems never come up, and in fact ALL cameras silently lose detection until a full engine restart. Root cause (proven with a standalone 5/5 repro + live in-app): the late-attach path (3f47a90) set the server's graceful stop event and then called proc.terminate() on the very next line. The old server is parked inside req_q.get() 30-60x/s, and terminating a multiprocessing.Queue consumer poisons the SHARED request queue — the dead reader holds the queue's reader lock / leaves a partial length-prefixed message in the pipe — so the respawned server never receives a single request. Observed live as a healthy-looking server process with models loaded, ~0% CPU, and zero camera shm attachments for minutes, while both camera children ran capture-only. Fix: drain, don't kill — bounded join (3s; typical exit ~0.2s via the serve loop's stop-event poll) before terminate(), which remains only as the escalation for a wedged process. Also prune the removed camera's response queue + slot id in _on_remove_camera, so remove/re-add cycles stop leaking an mp.Queue (pipe fds + feeder thread) each and respawns stop re-pickling queues for cameras that no longer exist. Verified end-to-end with a headless supervisor + real model-server + synthetic cameras through two remove/re-add cycles: before, the respawned server had shm=[] forever; after, it serves every live camera within ~2s of each cycle. TDD: tests/test_model_server_attach.py (drain-before-terminate contract, escalation on a wedged server, remove-prunes-slots). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… torso, not the arm-inflated bbox The Center Stage digital crop sized and centred itself on the raw YOLO detection box, so aim_body_mode="torso" (the 'Ignore arms' checkbox) had no effect on the output feed: raising or extending an arm inflated the box, which zoomed the crop out and dragged its centre — the reported 'box grows heavily and shifts the tracking'. The toggle only ever reached the physical-PTZ zoom span in _track_error. Fix: a single locked person with fresh cached torso keypoints is now framed on a pose-torso-derived box (framing.torso_framing_box — same 3.3x shoulder→hip scale as the physical auto-zoom, centred on the shoulder midpoint / hips), so arm motion leaves the crop untouched. Falls back to the raw bbox whenever pose is off/unavailable/stale or the keypoints belong to another track, and never applies to a group union or aim_body_mode="full_silhouette". Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ion table; head-out-of-view tilt assist
Round 2 on 'Ignore arms' (first fix covered only the Center Stage crop box):
1. Physical PTZ no longer pans when an arm extends. _track_error fused the
pose anchor with the RAW bbox centre by (1 - pose_conf), so 20-40% of an
arm-induced box shift still leaked into the pan command — the camera
moved, dragging the Center Stage crop with it. In torso mode the bbox
anchor (centre + height) now comes from the same pose-torso framing box
Center Stage crops around, so every share of the blend is arm-invariant.
2. Group-framing single person gets the same treatment: the framing-target
selector now exposes the lone person's track id, and pose runs for them
even without an explicit lock, so the torso-stable box exists there too.
3. One composition table for both actuators: framing_target.
SUBJECT_HEIGHT_TARGETS now drives BOTH the physical auto-zoom targets and
the Center Stage crop fill, so a preset composes the identical shot on
both. face/head_shoulders softened (0.80/0.60 physical, 0.86/0.80 digital
-> 0.65/0.52 shared) per user feedback ('intense'), and the face/hs
digital min-crop floors raised so the crop can't slam to 8x zoom.
4. Head-out-of-view recovery: with a head-centric preset, torso keypoints
visible but NO head landmark, and the detection box clipped at the very
top of the frame, the tilt error is biased up (>= 0.30) so the PTZ
recovers the head automatically instead of parking on the body. Mid-frame
occlusions (box not top-clipped) never trigger it. Throttled log line
states when and why the bias is active.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…xtent box, source-transition logging
Field repro persisted after the arm-invariant anchors: the crop still
EXPANDED on arm motion. Root cause: _pose_aim hard-clears _pose_keypoints
on ANY single failed/inconsistent estimate — and pose quality dips exactly
while the subject gestures (the detector box feeding the pose crop changes
shape). Every dropout snapped framing back to the raw arms-inflated bbox
for 0.2-0.4s: crop re-sizes to the big box, zoom sees a taller subject,
aim shifts — rinse and repeat while the arm moves. Unit tests passed
because they pinned fresh keypoints; the live pipeline flickers them.
Three changes ('use body pose estimation more'):
1. Sticky last-GOOD pose cache (_note_good_kps / _last_good_kps): written
only on successful consistency-checked estimates, cleared on target
change/loss. Framing (_fresh_target_kps) reads it instead of the strict
per-tick cache, so one bad estimate no longer drops the torso box —
only a sustained outage (> _POSE_FRAMING_TTL_S) degrades to the bbox.
2. True body-extent measurement: subject_height_from_pose now prefers the
padded head→ankle extent of the confident NON-ARM landmarks (nose/eyes/
ears/shoulders/hips/knees/ankles) over the 3.3x shoulder→hip heuristic
when taller (heuristic stays as the floor so cropped legs never cause
an over-zoom), and torso_framing_box centres on the extent midpoint
when the extent drives the height so head AND feet stay in the shot.
Elbows/wrists are never measured anywhere.
3. Framing-source transparency: change-only log line when 'Ignore arms'
framing engages the pose-stable torso box or degrades to the raw bbox
(with the reason) — so a live run shows immediately whether pose is
protecting the shot or was silently unavailable the whole time.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… (jitter) The framing box was arm-invariant but JITTERY: pose keypoints are estimated every ~0.2s and each new estimate REPLACED the framing box instantly, so the framing target stepped 5x/second with raw keypoint-regression noise on top — and the PTZ velocity feed-forward differentiates those steps into jerks. The extent-vs-heuristic height choice flipping between estimates added size flips, and the head-recovery tilt bias could flap on/off when a borderline head landmark hovered around the confidence floor. Pose is now consumed as a FILTERED track, not raw samples: 1. framing.BoxSmoother — time-aware EMA (alpha = 1 - exp(-dt/tau), tau=0.35s) over the pose framing box. Frame-rate independent, holds through momentary dropouts, shared by BOTH actuators via _torso_stable_box, reset on target change. The framing target is now a continuous signal: no steps for the crop to chase or the feed-forward to spike on. 2. Head-recovery hysteresis: entering needs the usual conditions; RELEASING needs a clearly visible head landmark (conf floor +0.10) and a wider un-clipped gap (4% vs 2%), so a nose flickering at the floor can't toggle the tilt bias every estimate. Also verified this round: process children build their own InferencePool (process_worker.py _install_child_pool), so pose IS available in model-server mode — the 'framing source' log line confirms it live. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…llowed the arm-inflated bbox Root cause of 'tracking dot moves when I move my arms': with AUTOPTZ_MODEL_SERVER=1 each camera child's pool is RemotePool, which only implemented detector()/detector_ep. CameraWorker._ensure_pose called pool.pose(), swallowed the AttributeError, and permanently cached pose=None (unlike the face stack, which has a local-build fallback — why faces kept working). With no keypoints, NOTHING pose-derived ever engaged in the field: no pose anchor, no sticky torso framing box, no head recovery, no skeleton overlay — the aim degraded to the raw detection-box anchor, which shifts when a raised arm inflates the box. Dead since the model-server PR (#139); all the arm-invariance work on this branch was built on top of it and never ran outside unit tests. Fix: RemotePool.pose() lazily builds and caches a LOCAL PoseEstimator in the camera child (only DETECTION is delegated to the server; pose runs on the target crop a few times/sec, so one small per-child session is cheap). allow_download=False mirrors the local-detector fallback — provisioning stays with the parent/UI. Build failure degrades to None (bbox aim), cached, never raises into the worker. Field verification: on engine start each camera child should log 'PoseEstimator ready | ep=CoreML…' and then 'framing source → torso (pose-stable; arms ignored)'; the skeleton overlay reappears and waving/ extending arms no longer moves the aim dot or the crop. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…hidden (desk/webcam shots) Round 2 of the field report: with pose alive again, the aim dot held but the Center Stage crop still grew/shifted with waving arms. Root cause: the pose-stable framing box hard-required BOTH shoulders and hips — subject_height_from_pose()/torso_framing_box() returned None whenever the hips were below the desk or out of frame (the everyday single-webcam case), so _torso_stable_box() fell back to the raw arms-inflated detection bbox for the crop, for the physical auto-zoom span, and for the (1-w_pose) bbox share of the fused aim (the residual dot jitter). Fix: hips hidden now degrades to a head+shoulders stature estimate instead of None — max(head→shoulder span × 10, shoulder width × 4.1), two arm-invariant anthropometric anchors whose max() is robust to both failure modes (head tilt shrinks the span but not the width; turning sideways shrinks the width but not the span). The framing box top sits a crown pad above the head point. Composition only needs a STABLE ballpark (the framer clamps to min/max crop fractions); following the raw bbox is what it must never do. Shoulders with no head landmark (and no hips) still return None — no invented heights. Hips visible keeps the classic 3.3× shoulder→hip math unchanged. Field verification: sit at a desk (hips hidden), lock on, wave/extend arms — the crop, the physical zoom, and the dot must all hold; log should show 'framing source → torso' and stay there. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ows the head anchor Two field asks from Center Stage validation: 1) Tracking gate: the digital crop used to follow a target even with the Track toggle off — Center Stage now only crops while tracking is active (per-camera _tracking_enabled AND the global tracking feature switch, the same pair gating the physical PTZ drive). With either off the crop eases back to the full frame. 2) Head anchor: with Frame-on: Face the crop could park on the body centre and let the face drift to the crop top or clean out of the cropped output — while the original frame had plenty of room. Cause: the crop is sized from the (full-body) framing box and clamped by max_frac; when the box is taller than the crop, box-centred placement cuts the head off, and nothing re-anchored it. desired_crop() now takes the framing-region anchor (AIM_REGION_FRACTION — the same fraction the aim dot uses, e.g. 0.10 = head for face) and slides the crop vertically just enough to keep that anchor inside a [0.10, 0.75] comfort band of the crop. Anchor already comfortable → byte-identical placement (the tuned desk-shot composition is untouched); group unions keep the old centring. Field verification: Frame-on: Face, stand so your full body is visible — the crop should ride up to keep the face in the top band instead of framing the torso; lean/stand up and the crop follows. Toggle Track off — output eases to the uncropped frame. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rop placement Field report round 4: 'still not trying to move or keep the tracking dot centered in the screen at all'. Correct — the crop was BOX-centred: placed from the framing box's centre (the hips for a standing person), with the round-3 anchor band only reacting at extremes. Move your head while your hips stay put and the box centre barely moves, so the crop did nothing and the dot roamed anywhere inside a 10-75% band. That is containment, not composition. The crop is now DOT-ANCHORED for a single framed person (the actual Center Stage contract): the framing box only SIZES the crop; its position is computed every tick so the tracking dot — the same engine-smoothed, pose-fused aim point drawn on screen (track.aim_x/aim_y, with a box-derived fallback when no aim telemetry exists) — rides at a fixed per-preset point of the output (_CENTERSTAGE_DOT_PLACEMENT: face/head_shoulders upper third, upper_body 0.38, full_body centred). Move, and the crop glides to re-compose you (framer EMA + deadzone still smooth it; clamped at frame edges). Group unions keep the classic box-centred crop. Replaces the round-3 anchor band (dead code now) — containment never delivered composition. Field verification: the dot should sit visually pinned at the same spot of the Center Stage output while you move around the frame — lean left/right, stand up: the crop follows the dot, not the torso midpoint. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ng gate CI caught two issues on PR #151 before merge: 1. ruff format — 9 files (some from this session's commits, some pre-existing on this long-lived branch, which has never been through CI as a PR before today) had formatting drift. Whitespace/line-wrap only, no behavior change. 2. tests/test_group_framing_worker.py — a real regression from this session's tracking-gate commit (04fdf09): _current_digital_target() now returns None unless _tracking_enabled is set, but this pre-existing test file's shared _make_worker() helper never set it (TargetConfig.mode defaults "off"). Missed because the tracking-gate commit's own verification only ran the test files touching the same code path, not the full suite — group framing has its own dedicated test file. Fixed the shared helper (same pattern already applied to test_camera_worker_framing.py and test_digital_ptz.py). Verified via CI's own per-file sharding (tests/test_*.py individually, matches .github/workflows/ci.yml's documented approach — a single-process full-suite run accumulates native Qt state and aborts late-suite in this local anaconda environment on EVERY file after enough Qt fixtures run, confirmed identical on an unmodified main checkout via a throwaway worktree; CI's clean per-OS runners don't hit this, per the workflow's own comments and PR #150's green history). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Windows-only CI failures (macOS/ubuntu both green), root-caused rather than worked around: 1. BoxSmoother.update() snapped instead of holding when two calls landed on an IDENTICAL timestamp. Windows' default time.monotonic() resolution (~15.6 ms) makes this common for fast back-to-back calls; macOS/Linux's finer-grained clock practically never collides, which is why this only failed on windows-latest. t == self._t now falls through to the normal alpha-blend (dt=0 -> alpha=0 -> hold), instead of being treated the same as a genuine backward time jump (t < self._t, still snaps). This is a real production correctness fix, not just a test fix: two real engine ticks CAN share a low-res Windows timestamp. 2. test_head_recovery_has_hysteresis_against_flapping relied on bare back-to-back time.monotonic() calls to represent 3 distinct ticks; _track_error's _aim_cache memoizes by (now, track_id) so a Windows-clock collision replayed a stale cached tick instead of evaluating fresh. Test now advances an explicit timestamp between ticks, matching how a real inference loop actually calls this (one now captured per tick, by construction distinct across ticks) rather than depending on ambient clock resolution. Adversarial + parallel review findings fixed before merge (see PR #151): 3. _torso_box_smoother race: mutated from both the capture thread (Center Stage crop) and the inference thread (physical PTZ aim + reset on target change) with no lock. A concurrent reset() could land inside update()'s compound self._t read-then-use, raising TypeError -- swallowed by a broad except, silently dropping that tick's crop. Added a dedicated lock around every mutating access. Pinned with a deterministic test (a raw thread- hammer didn't reliably hit the race window -- confirmed passing even against the unlocked code across repeated runs -- so the test forces the exact interleaving via a patched, blocking update() instead). 4. Hips-hidden stature constant was anthropometrically off: standard standing tables put nose height ~90% of stature and shoulder height ~81%, an ~8.5% span (~1/11.8), not the 1/10 originally used. Corrected to 11.8; updated the two tests asserting the old ratio. 5. engine_client.py: EngineClient.autostartDesired stacked @slot() on a bare @Property -- dead code (a Slot-decorated property registers nothing in Qt's meta-object system) that also doesn't match this class's established convention (every other exposed property uses @Property(...); @slot() is reserved for real invokable methods). Removed; every call site already used plain Python attribute access, so this is a no-op behavior change that just removes a landmine for a future QMetaObject.invokeMethod/QML caller expecting it to work. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… thread _attach_camera_to_model_server (called from _on_add_camera -> tick(), the GUI-thread QTimer path) used to join() the outgoing model-server process synchronously: up to _MS_DRAIN_TIMEOUT_S (3.0s) for a graceful drain, plus a further terminate()+join(0.5) escalation for a stuck one — a ~3.5s worst-case UI freeze when adding a camera while the shared server (opt-in AUTOPTZ_MODEL_SERVER=1) is unhealthy. Every OTHER model-server respawn path in this file already avoids exactly this (_scan_model_server_health / _poll_model_server_spawn split starting-and-waiting across ticks specifically so a respawn can never block the GUI thread) — this one path never got the same treatment. Fix: _attach_camera_to_model_server now only does the cheap synchronous part (register the new queue/camera-id, set the down-gate, signal the old server's graceful stop) and returns immediately; the actual drain-wait is handed off to a new _poll_model_server_drain, ticked from _scan_model_server_health exactly like the existing spawn-side polling. Only ever polls is_alive() — never joins with a nonzero timeout — so a slow-to-exit old server can't stall tick(). Drained -> respawn; deadline exceeded while still alive -> the same terminate()-then-respawn escalation as before, just non-blocking. Also fixed while in this code: _model_server_failed_ev (latched once the respawn budget is exhausted, so RemotePool.detector() permanently swaps every camera onto a local fallback detector) was never cleared anywhere. A camera added later restarts the server unconditionally (bypassing the exhausted budget cap) via this same method, but even once that fresh server proved itself healthy, every RemotePool — including the new camera's — stayed latched onto local fallback forever, wasting RAM/accelerator on a working shared server nobody used. _poll_model_server_spawn's success branch now clears the flag and refreshes every worker's detector reference when it was set. TDD throughout (each new/changed assertion watched failing first against the old code, via a Qt-free harness that calls the unbound methods against a hand-built fake self — this local sandbox's PySide6/QApplication construction is independently flaky INSIDE pytest, confirmed via a crash on the very first test's fixture setup before any of this session's code even runs; CI's clean per-OS runners are the authoritative signal for the actual pytest suite). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rom real layout metrics 4 panels floored their minimum width with a hardcoded pixel constant, each tuned once against macOS/Linux font metrics with zero headroom for a platform whose default UI font renders wider at the same point size (Windows does) — PropertiesPanel (360), ServicesPanel (300x220), ExperimentalFeaturesDialog (680), CameraInfoPanel (no floor at all, relying entirely on a test-side fallback). All four failed on windows-latest CI while macOS/ubuntu stayed green, in a test file that has never run on Windows before today (this whole branch never went through CI as a PR until now). Root cause, verified empirically: QScrollArea.minimumSizeHint() does NOT account for its contained widget at all (confirmed directly — a QScrollArea wrapping a 462px label still reports ~90px), and every one of these panels is built as QVBoxLayout -> QScrollArea -> body. So each hardcoded constant was a guess standing in for a measurement Qt's own API doesn't provide. A second compounding bug in PropertiesPanel: most CollapsibleGroup sections start collapsed, and a collapsed section's content is excluded from its parent's own minimum-size calculation, so even a correct measurement taken at construction time would under-count once sections are expanded. Fix: three composable helpers in common.py (visible_min_width, scroll_chrome_width, scroll_content_min_width) compute a panel's true minimum width from the live layout's minimumSize() (font-metric-driven, correct on any platform/DPI) plus the real frame/scrollbar overhead (queried via style().pixelMetric(), not guessed). CollapsibleGroup.minimumSizeHint() now reports its expanded content's width even while collapsed — collapsing hides height, never width. Every hardcoded constant replaced with a computed override. Verified by reproducing the failure mechanism directly: swapped the app's default font to a wider family/size and confirmed the ORIGINAL code fails under that stress (same uniform-overflow shape as the real Windows CI log) while the FIXED code passes it — including at a much more extreme stress than any real macOS-vs-Windows difference. No platform-specific number anywhere in the fix. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two fixes landed together in this file: 1. PropertiesPanel's part of the Windows panel-width-clip fix (see the preceding commit for the full root cause): setMinimumWidth(360) -> minimumSizeHint() computed from the scroll body's real, font-metric-driven layout minimum via the new common.py helpers. 2. A long registered identity name assigned as the tracking target could render hard-clipped in the 'Track person' combo with no way to recover the full value. _constrain_field_widths forces every QComboBox/QLineEdit in this panel to an Ignored size policy + a 48px floor (so the form never widens a narrow dock) — unlike _set_caption (used for labels: elides to width, always mirrors the full text on the tooltip), QComboBox has no built-in elide-on-paint for its closed-box text and no tooltip tied to the current selection. Fix: _update_target_combo_tooltip mirrors the full current-selection name on the tooltip -- the combo's version of _set_caption's elide+tooltip contract -- falling back to the original descriptive help text for '— Anyone —'. Hooked at both places the combo's selection changes: _reload_targets (runs signal-blocked, so _on_target_changed's own hook never fires there) and _on_target_changed (direct user selection). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…mera children Three lifecycle gaps in the opt-in AUTOPTZ_MODEL_SERVER=1 mode (one shared detection server, many per-camera child processes over IPC), all narrow but real: a Manage Models action (face-pack swap, pose-model swap) silently never reached a running model-server child's own cached state. 1. ProcessWorkerHandle.reload_inference_models/release_inference_models dropped include_face entirely (no such kwarg in their signatures) — Supervisor.release_model_sessions/rebuild_model_sessions call them with include_face=..., hit a TypeError, and silently fall back to calling without it. Both now accept include_face and relay it verbatim through the command queue; _drain_commands re-dispatches it onto the child's real CameraWorker, whose own release/reload methods already handled it correctly — the child side was never the problem. 2. RemotePool (the per-child pool when detection is IPC-delegated) had no release_pose()/release_detector() mirroring InferencePool's. Added both. But adding them alone doesn't close the loop: Supervisor. release_model_sessions only ever calls getattr(pool, method) on its OWN single InferencePool — a model-server child's RemotePool lives in a different OS process the supervisor can never reach. The actual fix is CameraWorker._release_pool_cache() (new), called from both _release_inference_models/_reload_inference_models, which does the same generic getattr(self._pool, method, None) dispatch using the worker's OWN pool reference — a harmless duplicate release in threaded mode (supervisor already released the same shared pool in-process), but the only path that ever reaches a model-server child's RemotePool. 3. The face-pack completeness heuristic (two-or-more onnx files present) could read a partially-extracted/interrupted download as complete if the surviving files happened to be unused auxiliary ones (landmarks/ attributes). For known packs (buffalo_l/buffalo_s), _face_pack_onnx now also requires a file matching each of the detection/recognition prefixes FaceRecognizer actually loads (allowed detection + recognition modules only). A pre-download checksum/atomic-rename (matching the detector path's part-file + SHA256 + replace-with-retry pattern) is NOT feasible here: insightface's own downloader owns the entire fetch end to end, so AutoPTZ never sees raw bytes or a temp file to verify/move atomically, and there's no AutoPTZ-published manifest for a third-party model-zoo asset. Documented in ensure_face_pack's docstring rather than reimplementing insightface's fetch to regain a hook that isn't worth the maintenance liability. TDD throughout — each new assertion written failing-first. test_process_worker.py hits this session's known pre-existing local Qt/pytest sandbox flakiness (confirmed identical via git stash on the unmodified file); its 3 new tests verified individually via -k filtering (all pass) since they don't touch Qt at all. test_inference_server_ipc.py (23/23) and test_models.py (51/51) run clean in full locally. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
TCVinNYC
added a commit
that referenced
this pull request
Jul 5, 2026
feat/center-stage-output (PR #151) merged into main after this release branch was cut, adding substantial user-facing work on top of the R1-R6 reliability overhaul this CHANGELOG section already documented. Folding it into the same v2.2.0 stable entry rather than a separate release: pose-stable Center Stage framing (dot-anchored crop composition, and the fix for pose being silently dead in model-server mode that blocked it from ever engaging), per-camera NDI output, group-framing, face-pack management, several model-server lifecycle/reliability fixes, and the Windows-only CI failures this branch's own PR surfaced and fixed (real cross-platform layout and clock-resolution bugs, not test flakiness). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Lands the
feat/center-stage-outputbranch: Center Stage / pose-stable framingwork plus a long tail of model-server reliability and UI polish accumulated
across sessions, plus a full multi-agent review pass and CI-driven hardening
before merge.
This session: Center Stage was framing the arm-inflated box, not the person
Four rounds of root-cause fixes, each found by the user running real cameras
in model-server mode (
AUTOPTZ_MODEL_SERVER=1):RemotePool(the camerachild's pool when detection is delegated to the shared server) never
implemented
pose()—CameraWorker._ensure_posehit anAttributeError,swallowed it, and permanently cached
pose = None. Every pose-derivedbehavior (arm-invariant aim, torso framing box, skeleton overlay, head
recovery) silently degraded to the raw detection bbox since PR feat(model-server): scalable shared-detector architecture (AUTOPTZ_MODEL_SERVER, experimental) #139. Fixed:
RemotePool.pose()builds a local per-childPoseEstimator.subject_height_from_pose/torso_framing_boxhard-required shoulders ANDhips; hips hidden below a desk →
None→ raw-bbox crop. Added ahead+shoulders stature fallback, later corrected against real anthropometric
tables in review (10.0 → 11.8).
could let the face drift out of frame (superseded by [Feature] NDI Search Implementation #4).
moving your head while your torso stayed put barely moved the crop.
Reworked to dot-anchored placement: the box only sizes the crop; the
crop is positioned every tick so the tracking dot rides at a fixed
per-preset point of the output.
Review + hardening pass (multi-agent review + CI, before merge)
BoxSmoother.update()snappedinstead of holding on an identical timestamp (Windows' coarser
time.monotonic()resolution makes this common; macOS/Linux practicallynever collide) — a real production correctness fix, not just a test fix.
Four Windows-only panel-width-clip failures in a never-before-CI'd test file,
root-caused to hardcoded pixel floors with zero headroom for Windows'
wider default UI font — replaced with floors computed from real,
font-metric-driven layout minimums.
on
_torso_box_smoother(capture thread vs. inference thread, no lock —pinned with a deterministic interleaving test after a raw thread-hammer
failed to reliably reproduce it);
_attach_camera_to_model_serverblockingthe GUI thread up to ~3.5s (now split across ticks like every other
model-server respawn path);
_model_server_failed_evlatching permanentlyeven after a confirmed-healthy respawn; a stray
@Slot()on a bare@propertyinengine_client.py.swap, pose-model swap) now actually reach a running model-server camera
child's own cached state, not just the supervisor's in-process pool.
combo instead of silently hard-clipping.
All TDD'd (RED verified before each fix) and covered by new/updated tests
across
test_framing.py,test_camera_worker_framing.py,test_digital_framer.py,test_inference_server_ipc.py,test_supervisor_health.py,test_model_server_attach.py,test_process_worker.py,test_models.py,test_panel_min_width_no_clip.py, and a newtest_target_combo_identity_tooltip.py.Also in this branch (prior sessions, previously validated on real cameras)
hysteresis, sticky good-pose cache + true body-extent box, arm-invariant aim
shared across both actuators, a single composition table so Center Stage and
physical PTZ frame the same shot, head-out-of-view tilt assist.
shared model-server deaf; the capture loop reopens a dead/stalled frame
source; model-server children get the real face DB + live identity CRUD
sync; late-added cameras attach to the model-server instead of degrading to
threaded; the EP label shows the real backend.
group-framing checkbox; first-class face-pack download/remove in Model
Manager; Experimental Features dialog reorganized into sections.
validation.
Test plan
sharding — a known local sandbox issue makes a single monolithic run
unreliable on this machine, unrelated to correctness)
ruff check+ruff format --checkcleanWindows-only bugs this PR surfaced)
UI, and an adversarial pass on this session's own new work) — every
credible finding fixed or explicitly tracked as a scoped follow-up
sessions (model-server mode, NDI sources)
🤖 Generated with Claude Code