Skip to content

feat(engine/ui): Center Stage pose-stable framing + model-server reliability#151

Merged
TCVinNYC merged 43 commits into
mainfrom
feat/center-stage-output
Jul 5, 2026
Merged

feat(engine/ui): Center Stage pose-stable framing + model-server reliability#151
TCVinNYC merged 43 commits into
mainfrom
feat/center-stage-output

Conversation

@TCVinNYC

@TCVinNYC TCVinNYC commented Jul 5, 2026

Copy link
Copy Markdown
Member

Summary

Lands the feat/center-stage-output branch: Center Stage / pose-stable framing
work 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):

  1. Pose was silently DEAD in model-server mode. RemotePool (the camera
    child's pool when detection is delegated to the shared server) never
    implemented pose()CameraWorker._ensure_pose hit an AttributeError,
    swallowed it, and permanently cached pose = None. Every pose-derived
    behavior (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-child PoseEstimator.
  2. Center Stage crop still followed the arm-inflated box at a desk/webcam.
    subject_height_from_pose/torso_framing_box hard-required shoulders AND
    hips; hips hidden below a desk → None → raw-bbox crop. Added a
    head+shoulders stature fallback, later corrected against real anthropometric
    tables in review (10.0 → 11.8).
  3. Center Stage cropped even with tracking off, and a face-framing crop
    could let the face drift out of frame (superseded by [Feature] NDI Search Implementation #4).
  4. The crop wasn't composing the dot at all — it was box-centred, so
    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)

  • Windows-only CI failures, root-caused: BoxSmoother.update() snapped
    instead of holding on an identical timestamp (Windows' coarser
    time.monotonic() resolution makes this common; macOS/Linux practically
    never 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.
  • Adversarial + parallel review findings fixed: a real thread-safety race
    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_server blocking
    the GUI thread up to ~3.5s (now split across ticks like every other
    model-server respawn path); _model_server_failed_ev latching permanently
    even after a confirmed-healthy respawn; a stray @Slot() on a bare
    @property in engine_client.py.
  • Model-server lifecycle gaps closed: Manage Models mutations (face-pack
    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.
  • Long identity names get a tooltip fallback in the Properties target
    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 new
test_target_combo_identity_tooltip.py.

Also in this branch (prior sessions, previously validated on real cameras)

  • Pose/framing stability: continuous pose-box smoothing + head-recovery
    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.
  • Model-server reliability: adding/re-adding a camera no longer leaves the
    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.
  • Features: per-camera NDI output for the Center Stage feed; per-camera
    group-framing checkbox; first-class face-pack download/remove in Model
    Manager; Experimental Features dialog reorganized into sections.
  • UI polish: a long tail of width/layout/HUD fixes found in live GUI
    validation.

Test plan

  • Full local test suite green (per-file, matching this repo's own CI
    sharding — a known local sandbox issue makes a single monolithic run
    unreliable on this machine, unrelated to correctness)
  • ruff check + ruff format --check clean
  • CI green on all 3 OSes (after 2 rounds of real fixes to genuine
    Windows-only bugs this PR surfaced)
  • Four parallel review passes (engine/tracking, model-server reliability,
    UI, and an adversarial pass on this session's own new work) — every
    credible finding fixed or explicitly tracked as a scoped follow-up
  • Live-validated on the user's real cameras across this session and prior
    sessions (model-server mode, NDI sources)

🤖 Generated with Claude Code

TCVinNYC and others added 30 commits July 3, 2026 21:17
…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>
TCVinNYC and others added 13 commits July 4, 2026 18:47
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 TCVinNYC merged commit a59906d into main Jul 5, 2026
3 checks passed
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>
@TCVinNYC TCVinNYC deleted the feat/center-stage-output branch July 5, 2026 15:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant