diff --git a/autoptz/config/models.py b/autoptz/config/models.py index 5e9444db..e28161a8 100644 --- a/autoptz/config/models.py +++ b/autoptz/config/models.py @@ -299,8 +299,11 @@ class PTZConfig(BaseModel, frozen=True): # / ``goto_preset(slot)`` drive. Round-trips through JSON (keys are coerced # back to ``int`` on load). preset_slots: dict[int, PtzPresetSlot] = Field(default_factory=dict) - # Center Stage: emit the auto-framed crop as a virtual camera (Zoom/Teams/OBS). + # Center Stage: emit the auto-framed crop as a virtual camera (Zoom/Teams/OBS, + # this computer) and/or an NDI network source (any computer on the LAN). vcam_out: bool = False + ndi_out: bool = False + ndi_output_name: str = "" # "" → default "AutoPTZ " digital_output_w: int = Field(default=1280, ge=320, le=3840) digital_output_h: int = Field(default=720, ge=240, le=2160) diff --git a/autoptz/engine/camera_worker.py b/autoptz/engine/camera_worker.py index bd5129b6..0b8de92a 100644 --- a/autoptz/engine/camera_worker.py +++ b/autoptz/engine/camera_worker.py @@ -39,6 +39,7 @@ from numpy.typing import NDArray from autoptz.config.models import AIM_REGION_FRACTION +from autoptz.engine.framing_target import SUBJECT_HEIGHT_TARGETS from autoptz.engine.runtime.messages import ( BBox, FaceBox, @@ -59,6 +60,7 @@ if TYPE_CHECKING: from autoptz.config.models import CameraConfig, IdentityRecord + from autoptz.engine.framing_target import FramingTarget from autoptz.engine.runtime.shm import ShmWriter log = logging.getLogger(__name__) @@ -106,11 +108,29 @@ def _push_due(now: float, last: float, min_period: float) -> bool: # framings allow a tighter crop; full_body stays conservative so a whole-person # shot doesn't over-zoom. True specks are already dropped upstream by # ``tracking.min_detection_size_frac``, so a low floor here is safe. +# ``fill`` comes from the SHARED composition table (framing_target. +# SUBJECT_HEIGHT_TARGETS) so the Center Stage crop and the physical auto-zoom +# compose the SAME shot per preset — the crop is sized so the subject occupies +# exactly the fraction of it that physical PTZ zooms the subject to occupy of +# the frame. (The old per-actuator fills 0.86/0.80/… framed noticeably tighter +# than physical and were user-reported as "intense".) _CENTERSTAGE_FRAMING: dict[str, tuple[float, float, float, float]] = { - "face": (0.86, 0.12, 0.50, 0.06), # tight head/face closeup, zooms in on far faces - "head_shoulders": (0.80, 0.16, 0.62, 0.08), # head + shoulders - "upper_body": (0.70, 0.22, 0.74, 0.10), # head + chest — default midpoint - "full_body": (0.58, 0.34, 0.94, 0.14), # whole person, conservative min zoom + "face": (SUBJECT_HEIGHT_TARGETS["face"], 0.18, 0.50, 0.06), + "head_shoulders": (SUBJECT_HEIGHT_TARGETS["head_shoulders"], 0.20, 0.62, 0.08), + "upper_body": (SUBJECT_HEIGHT_TARGETS["upper_body"], 0.22, 0.74, 0.10), + "full_body": (SUBJECT_HEIGHT_TARGETS["full_body"], 0.34, 0.94, 0.14), +} + +# Where the tracking DOT sits in the Center Stage output, per preset — as +# (x, y) fractions of the crop. This is the crop's composition contract: the +# crop is placed every tick so the aim dot rides at this point (horizontally +# centred; head-centric presets put the dot in the upper third, full-body at +# the middle). Single-person targets only — group unions frame the union box. +_CENTERSTAGE_DOT_PLACEMENT: dict[str, tuple[float, float]] = { + "face": (0.5, 0.26), + "head_shoulders": (0.5, 0.32), + "upper_body": (0.5, 0.38), + "full_body": (0.5, 0.50), } _DEFAULT_TELEMETRY_HZ = 10.0 @@ -270,6 +290,29 @@ def wrapper(self: Any, *args: Any, **kwargs: Any) -> Any: # stale torso point is fine and far cheaper than per-frame pose inference). _POSE_INTERVAL_S = 0.2 _POSE_TTL_S = 0.12 +# How long cached torso keypoints stay eligible for the Center Stage crop box +# ("Ignore arms"). Generous vs _POSE_INTERVAL_S (keypoints refresh every 0.2 s +# while the target is live) but tight enough that a stalled inference thread +# falls back to raw-bbox framing instead of freezing the crop on a stale torso. +_POSE_FRAMING_TTL_S = 1.0 + +# Head-recovery tilt assist: when a head-centric framing preset is active, the +# pose sees a torso but no head landmark, and the detection box is clipped at +# the very top of the frame, the head is above the field of view — bias the +# tilt error up to at least this value so the PTZ recovers the shot instead of +# parking on the body. The box must start within this fraction of the frame +# height from the top to count as "clipped". +_HEAD_RECOVERY_FRAMINGS = frozenset({"face", "head_shoulders", "upper_body"}) +_HEAD_RECOVERY_EY = 0.30 +_HEAD_CLIP_TOP_FRAC = 0.02 +# Hysteresis: once recovery is active, releasing it needs a CLEARLY visible +# head landmark (base keypoint floor + this margin) and a bigger un-clipped +# gap, so borderline confidences can't flap the tilt bias on/off. +_HEAD_RECOVERY_EXIT_CONF_MARGIN = 0.10 +_HEAD_CLIP_EXIT_FRAC = 0.04 + +# Time constant for smoothing the pose-derived framing box (seconds). +_TORSO_BOX_TAU_S = 0.35 # How often (seconds) to run OSNet appearance ReID: refresh the target's template # while it's visible, or attempt recovery while it's lost. Throttled because the @@ -441,6 +484,12 @@ def __init__( self._injected_shm = shm_writer self._telemetry_period = 1.0 / max(1.0, telemetry_hz) + # Worker-owned source reconnect state (see ``_maybe_reopen_source``); the + # capture loop re-arms these on every delivered frame. + self._last_frame_t = 0.0 + self._reopen_backoff = config.reconnect.backoff_initial_s + self._reopen_next_t = 0.0 + # ── face / identity wiring ────────────────────────────────────────────── # The face stack (insightface + the gallery service) is built lazily in # the worker thread unless injected (tests). When a worker-thread face @@ -453,6 +502,7 @@ def __init__( self._last_face_t = 0.0 self._last_preview_push_t = 0.0 self._last_vcam_push_t = 0.0 + self._last_ndi_push_t = 0.0 self._last_harvest_t = 0.0 self._last_crop_t = 0.0 # Most recent face→identity bindings seen this tick: track_id → (id, conf). @@ -530,6 +580,27 @@ def __init__( self._pose_keypoints: list[Any] | None = None # last keypoints (reused) self._pose_kp_track_id: int | None = None # track they belong to self._last_pose_t = 0.0 + # Sticky last-GOOD pose cache: unlike _pose_keypoints (cleared by any + # single failed/inconsistent estimate — pose quality dips exactly while + # the subject gestures), these survive momentary dropouts so framing + # holds the torso box instead of snapping to the arms-inflated bbox. + # Written only on successful estimates; cleared on target change/loss. + self._last_good_kps: list[Any] | None = None + self._last_good_kps_track_id: int | None = None + self._last_pose_good_t = 0.0 + # Throttle for the head-out-of-view recovery log line. + self._last_head_recover_log_t = 0.0 + # Head-recovery hysteresis state (see _head_recovery_bias). + self._head_recovery_active = False + # Last logged 'Ignore arms' framing source ("torso"/"bbox"), change-only. + self._framing_source = "" + # Continuous smoother for the pose-derived framing box (lazy). Mutated + # from BOTH the capture thread (Center Stage crop, _current_digital_target) + # and the inference thread (physical PTZ aim, _track_error; also reset on + # target change/loss) — guard every read-modify-write with this lock so a + # reset() can never land inside update()'s compound self._t access. + self._torso_box_smoother: Any | None = None + self._torso_box_smoother_lock = threading.Lock() self._last_pose_overlay_t = 0.0 self._last_pose_overlay_frame_id = 0 self._last_pose_emitted_frame_id = -1 @@ -679,12 +750,23 @@ def __init__( self._source: FrameSource | None = None self._shm: ShmWriter | None = None self._vcam: Any | None = None # VirtualCamSink (lazily created when vcam_out enabled) + self._ndi: Any | None = None # NDISendSink (lazily created when ndi_out enabled) + self._output_sender: Any | None = None # OutputSender pump (lazy, off-capture-thread) self._digital_framer: Any | None = None # Center Stage auto-framer (lazy) # True when the last _current_digital_target() returned a multi-person group # UNION box (which must fit-width); False for a single locked person (which # keeps the prior height-only sizing). Read by the Center Stage crop path. self._digital_target_is_group: bool = False - self._cs_diag_t: float = 0.0 # throttle for the Center Stage diagnostic log + # Track id of the single framed person from the last + # _current_digital_target() — lets the crop read that track's aim dot. + self._digital_primary_track_id: int | None = None + # Center Stage diagnostic log: last logged (target-present, tid) so the + # line fires only on a state CHANGE, never as a periodic repeat. + self._cs_last_logged_state: tuple[bool, int | None] | None = None + # Whether the last inference-backpressure window was ≥50% skipped — the + # "inference behind" line logs at INFO only on the enter/recover + # transitions (steady-state repeats go to DEBUG). + self._inference_behind = False # The active Center-Stage digital crop (x, y, w, h) in full-frame pixels # for the most recently framed output, or None when Center Stage is not # driving the painted frame. Stamped onto each TelemetryMsg so the UI can @@ -819,9 +901,16 @@ def stop(self, timeout: float = 5.0) -> None: if thread is not None and thread is not threading.current_thread(): thread.join(timeout=timeout) self._thread = None + # Stop the output pump BEFORE closing its sinks (it may be mid-send). + if self._output_sender is not None: + self._output_sender.close() + self._output_sender = None if self._vcam is not None: self._vcam.close() self._vcam = None + if self._ndi is not None: + self._ndi.close() + self._ndi = None @property def is_running(self) -> bool: @@ -953,6 +1042,18 @@ def ingest_identity(self, record: Any) -> None: except Exception: # noqa: BLE001 log.debug("camera_id=%s ingest_identity failed", self.camera_id, exc_info=True) + def delete_identity(self, identity_id: str) -> None: + """Drop an identity deleted in the UI from this worker's gallery (process mode). + + Best-effort: a missing identity service (identity features off) is a no-op. + """ + service = self._injected_identity_service + if service is not None and hasattr(service, "delete"): + try: + service.delete(identity_id) + except Exception: # noqa: BLE001 + log.debug("camera_id=%s delete_identity failed", self.camera_id, exc_info=True) + def set_inference_pool(self, pool: Any | None) -> None: """Inject the process-wide shared inference pool (heavy models once). @@ -967,19 +1068,20 @@ def refresh_detector_from_pool(self) -> None: with self._cmd_lock: self._cmd_queue.append(("refresh_detector", None)) - def reload_inference_models(self) -> None: + def reload_inference_models(self, *, include_face: bool = False) -> None: """Drop + rebuild detector/pose after the on-disk model cache changed. Unlike ``refresh_detector_from_pool`` (a hot-swap that keeps the old model if the new one isn't ready), this force-drops the worker's model references so a *removed* model truly stops drawing boxes, then rebuilds from the (now-refreshed) shared pool — yielding the new model, or nothing - when the files were deleted. + when the files were deleted. ``include_face`` also cycles the face stack + (only for a face-pack op). """ with self._cmd_lock: - self._cmd_queue.append(("reload_models", None)) + self._cmd_queue.append(("reload_models", {"include_face": include_face})) - def release_inference_models(self, *, wait: float = 0.0) -> None: + def release_inference_models(self, *, wait: float = 0.0, include_face: bool = False) -> None: """Drop the worker's detector/pose refs so their ORT sessions can be freed. Unlike :meth:`reload_inference_models` this does *not* rebuild — it only @@ -987,10 +1089,11 @@ def release_inference_models(self, *, wait: float = 0.0) -> None: is mutated (delete/replace fails on Windows while a handle is open). Pass ``wait > 0`` to block until the inference thread confirms the release (or the timeout elapses); the caller then GCs and mutates the files. + ``include_face`` also drops the face ref (only for a face-pack op). """ done = threading.Event() if wait > 0 else None with self._cmd_lock: - self._cmd_queue.append(("release_models", done)) + self._cmd_queue.append(("release_models", {"done": done, "include_face": include_face})) if done is not None: done.wait(timeout=wait) @@ -1173,11 +1276,20 @@ def _apply_command(self, kind: str, payload: Any) -> None: elif kind == "refresh_detector": self._refresh_detector_from_pool() elif kind == "reload_models": - self._reload_inference_models() + self._reload_inference_models( + include_face=bool(payload.get("include_face")) + if isinstance(payload, dict) + else False + ) elif kind == "release_models": - self._release_inference_models() - if isinstance(payload, threading.Event): - payload.set() + done = payload.get("done") if isinstance(payload, dict) else payload + self._release_inference_models( + include_face=bool(payload.get("include_face")) + if isinstance(payload, dict) + else False + ) + if isinstance(done, threading.Event): + done.set() elif kind == "save_ptz_preset": self._save_ptz_preset(int(payload)) elif kind == "recall_ptz_preset": @@ -1212,12 +1324,43 @@ def _apply_target_fps(self, fps: float) -> None: except Exception: # noqa: BLE001 log.debug("camera_id=%s set_target_fps failed", self.camera_id, exc_info=True) - def _release_inference_models(self) -> None: + def _release_pool_cache(self) -> None: + """Release the INJECTED POOL's own cached detector/pose (getattr-guarded). + + Mirrors ``Supervisor.release_model_sessions``'s generic + ``getattr(pool, method, None)`` dispatch for the one shared + :class:`InferencePool`. For a threaded worker this duplicates that + release (harmless — ``release_detector``/``release_pose`` just drop a + ref and reset a "built" flag) because the supervisor already released + the SAME shared pool object directly, in-process, before this ever + runs. For a model-server camera CHILD, though, ``self._pool`` is a + :class:`~autoptz.engine.pipeline.inference_server.RemotePool` living + only inside THIS process — the supervisor can never reach it — so this + is the only call that ever clears its cached pose / local-fallback- + detector session. Without it, a Manage Models mutation never reaches a + model-server child, which keeps serving a stale session until the app + restarts. + """ + pool = self._pool + if pool is None: + return + for method in ("release_detector", "release_pose"): + fn = getattr(pool, method, None) + if callable(fn): + try: + fn() + except Exception: # noqa: BLE001 — pool release must never break the worker + log.debug("camera_id=%s pool %s failed", self.camera_id, method, exc_info=True) + + def _release_inference_models(self, include_face: bool = False) -> None: """Drop detector/pose refs *without* rebuilding so their ORT sessions free. Called before the on-disk model cache is mutated: once these refs and the shared pool's are gone (and GC runs), Windows can delete/replace the files that onnxruntime had open. The subsequent ``reload_models`` rebuilds. + ``include_face`` also drops the face ref (only for a face-pack op, so an + unrelated detector/pose op never disturbs the ~1.3 GB face pack). An + INJECTED face stack (tests) is left alone — it isn't ours to drop. """ self._detect = None self._unified_pose_active = False @@ -1226,9 +1369,16 @@ def _release_inference_models(self) -> None: self._pose_probed = False self._pose_keypoints = None self._pose_kp_track_id = None + if include_face and self._injected_face_stack is None: + self._face = None + self._release_pool_cache() + + def _reload_inference_models(self, include_face: bool = False) -> None: + """Force-drop + rebuild detector/pose to match the current model cache. - def _reload_inference_models(self) -> None: - """Force-drop + rebuild detector/pose to match the current model cache.""" + ``include_face`` also cycles the face stack — set only for a face-pack op, + so a detector/pose cache change never force-reloads the ~1.3 GB face pack. + """ self._detect = None self._unified_pose_active = False self._last_detections = [] @@ -1236,6 +1386,11 @@ def _reload_inference_models(self) -> None: self._pose_probed = False self._pose_keypoints = None self._pose_kp_track_id = None + self._release_pool_cache() + if include_face and self._injected_face_stack is None: + self._face = None + if self._feature("face_recognition"): + self._ensure_face_stack() if self._feature("detection"): self._ensure_detect_stack() model = ( @@ -1591,15 +1746,40 @@ def _drive_ptz_auto( except Exception: # noqa: BLE001 log.debug("camera_id=%s ptz auto step failed", self.camera_id, exc_info=True) else: - # No target → drop the velocity estimate so a re-acquire starts clean. - self._prev_aim_err = None - self._aim_vel = (0.0, 0.0) - try: - self._publish_ptz( - ctrl, (0.0, 0.0), (0.0, 0.0), 0.0, track_active=False, now=now, log_label=None - ) - except Exception: # noqa: BLE001 - log.debug("camera_id=%s ptz auto idle step failed", self.camera_id, exc_info=True) + # No locked target. Parity with Center Stage: if group framing is on and + # nobody is locked, aim the camera at the confident group. Otherwise drop + # the velocity estimate so a re-acquire starts clean and idle the loop. + group_box = ( + self._group_ptz_target(tracks) + if frame is not None and self._tracking_enabled and tracking_on + else None + ) + if group_box is not None: + err, height = self._group_error(group_box, frame) + vel = self._estimate_aim_velocity(err, now) + try: + self._publish_ptz( + ctrl, err, vel, height, track_active=True, now=now, log_label="group" + ) + except Exception: # noqa: BLE001 + log.debug("camera_id=%s ptz group step failed", self.camera_id, exc_info=True) + else: + self._prev_aim_err = None + self._aim_vel = (0.0, 0.0) + try: + self._publish_ptz( + ctrl, + (0.0, 0.0), + (0.0, 0.0), + 0.0, + track_active=False, + now=now, + log_label=None, + ) + except Exception: # noqa: BLE001 + log.debug( + "camera_id=%s ptz auto idle step failed", self.camera_id, exc_info=True + ) def _update_ego_motion( self, @@ -1723,6 +1903,56 @@ def _resolve_target_track(self, tracks: list[TrackInfo]) -> TrackInfo | None: return t return None + def _ndi_output_name(self) -> str: + """The NDI source name for this camera's output feed. + + Uses the configured ``ndi_output_name`` when set, else ``"AutoPTZ "``; + NDI advertises it on the network as ``" (AutoPTZ )"``. + """ + configured = str(getattr(self.config.ptz, "ndi_output_name", "") or "").strip() + if configured: + return configured + return f"AutoPTZ {self.config.name}".strip() + + def _group_ptz_target( + self, tracks: list[TrackInfo] + ) -> tuple[float, float, float, float] | None: + """The group box physical PTZ should frame when NO person is locked. + + Parity with Center Stage: with group framing on and nobody explicitly + locked, aim the camera at the confident group (union, or the single + confident person) instead of doing nothing. Returns None when a person is + locked (the normal single-target path owns that), group framing is off, or + no one is present — so default behaviour (group framing off) is unchanged. + """ + if self._target_track_id is not None or self._target_identity_id is not None: + return None + if not bool(getattr(self.config.tracking, "group_framing", False)): + return None + from autoptz.engine.framing_target import select_framing_target + + ft = select_framing_target( + tracks, + target_track_id=None, + target_identity_id=None, + trusted_bbox=None, + group_framing=True, + ) + return ft.bbox + + def _group_error( + self, + box: tuple[float, float, float, float], + frame: NDArray[np.uint8], + ) -> tuple[tuple[float, float], float]: + """Normalized aim error + height for a group box (no pose fusion).""" + from autoptz.engine.framing_target import aim_error_for_box + + h, w = frame.shape[:2] + framing_name = _resolve_framing(self.config.tracking) + aim_fraction = AIM_REGION_FRACTION.get(framing_name, 0.5) + return aim_error_for_box(box, int(w), int(h), float(aim_fraction)) + def _commit_target_track( self, track_id: int | None, @@ -2431,11 +2661,12 @@ def _track_error( grows the YOLO box but does **not** move the aim. The point is EMA-smoothed to suppress keypoint jitter. - The **arms toggle** (``aim_body_mode``) changes only the *zoom* source, - not the aim centre: + The **arms toggle** (``aim_body_mode``): - - ``torso`` (ignore arms) → zoom on the stable shoulder→hip span, so an - extended arm does not make the camera zoom out. + - ``torso`` (ignore arms) → zoom on the stable shoulder→hip span, AND the + box anchor itself is the pose-torso framing box when fresh torso + keypoints are cached — so even the ``(1 - pose_conf)`` bbox share of the + fused aim is arm-invariant and an extended arm cannot pan the camera. - ``full_silhouette`` (include arms) → zoom on the full detection-box height, so the shot widens to fit outstretched arms. @@ -2466,8 +2697,18 @@ def _track_error( framing_name = _resolve_framing(self.config.tracking) # ── bbox anchor — always available (centre-x, framing fraction down) ───── - ax_bbox = (bb.x1 + bb.x2) * 0.5 - ay_bbox = bb.y1 + (bb.y2 - bb.y1) * AIM_REGION_FRACTION.get(framing_name, 0.5) + # "Ignore arms": when fresh torso keypoints are cached for this track, + # the anchor comes from the arm-invariant torso framing box (same box + # Center Stage crops around) so the raw arms-inflated detection box never + # steers the camera — not even through the (1 - pose_conf) blend share. + anchor = (bb.x1, bb.y1, bb.x2, bb.y2) + if now is not None and ignore_arms: + tb = self._torso_stable_box(track.track_id) + if tb is not None: + anchor = tb + bbox_height = (tb[3] - tb[1]) / h + ax_bbox = (anchor[0] + anchor[2]) * 0.5 + ay_bbox = anchor[1] + (anchor[3] - anchor[1]) * AIM_REGION_FRACTION.get(framing_name, 0.5) ax, ay = ax_bbox, ay_bbox subject_height = bbox_height aim_source = "bbox" @@ -2518,6 +2759,10 @@ def _track_error( ex = (ax - w * 0.5) / (w * 0.5) # [-1, 1] right-positive ey = -((ay - h * 0.5) / (h * 0.5)) # [-1, 1] up-positive + if now is not None: + # Head cut off above the frame while a head-centric preset is active → + # bias the tilt up so the camera recovers the head automatically. + ey = self._head_recovery_bias(track, ey, float(h)) ex = max(-1.0, min(1.0, ex)) ey = max(-1.0, min(1.0, ey)) subject_height = max(0.0, min(1.0, subject_height)) @@ -2782,6 +3027,7 @@ def _pose_aim( self._pose_kp_track_id = track.track_id self._last_pose_overlay_t = now self._last_pose_overlay_frame_id = max(1, self._current_inference_frame_id) + self._note_good_kps(kps, track.track_id, now) else: self._pose_keypoints = None self._pose_kp_track_id = None @@ -2811,13 +3057,15 @@ def _maybe_estimate_pose_overlay( ) -> None: """Populate the tracked target's pose keypoints for the overlay + aim. - Pose is tied strictly to the **tracked subject** (the locked/selected - target), so the skeleton and the green aim circle always describe the - same one person — a skeleton never appears on someone with no aim circle. - Selecting a person (clicking their box) sets the target, which is enough - to see their skeleton; no PTZ follow required. Throttled + cached inside - ``_pose_aim`` (``_POSE_INTERVAL_S``), so the later aim call this tick - reuses the same keypoints (no double inference). + Pose is tied to the **framed subject**: the locked/selected target, or — + with group framing on and no lock — the lone confident person (they are + the framing subject too, so their torso must be pose-stable exactly like + a locked target's). The skeleton and the green aim circle always + describe the same one person — a skeleton never appears on someone with + no aim circle. Selecting a person (clicking their box) sets the target, + which is enough to see their skeleton; no PTZ follow required. + Throttled + cached inside ``_pose_aim`` (``_POSE_INTERVAL_S``), so the + later aim call this tick reuses the same keypoints (no double inference). """ if frame is None or not self._feature("pose"): self._pose_keypoints = None @@ -2827,11 +3075,23 @@ def _maybe_estimate_pose_overlay( self._last_pose_emitted_frame_id = -1 return target = self._resolve_target_track(tracks) + if target is None and self._target_track_id is None: + target = self._group_single_track(tracks) if target is None or target.lost: self._reset_pose_aim() return self._pose_aim(target, frame, now, tracks=tracks) # side effect: fills _pose_keypoints + def _group_single_track(self, tracks: list[TrackInfo]) -> TrackInfo | None: + """The lone confident person when group framing is on and nothing is + locked — the framing subject of the group-single path, or None.""" + if self._target_identity_id is not None: + return None + if not bool(getattr(self.config.tracking, "group_framing", False)): + return None + confident = [t for t in tracks if not t.lost and t.bbox is not None] + return confident[0] if len(confident) == 1 else None + def _ensure_pose(self) -> Any | None: """Return the pose estimator (shared pool's first, else per-worker build). @@ -2889,6 +3149,16 @@ def _reset_pose_aim(self) -> None: """Clear the pose aim smoother + cached keypoints (on target change).""" self._pose_keypoints = None self._pose_kp_track_id = None + self._last_good_kps = None + self._last_good_kps_track_id = None + self._last_pose_good_t = 0.0 + self._head_recovery_active = False + with self._torso_box_smoother_lock: + if self._torso_box_smoother is not None: + try: + self._torso_box_smoother.reset() + except Exception: # noqa: BLE001 + pass self._last_pose_overlay_t = 0.0 self._last_pose_overlay_frame_id = 0 self._last_pose_emitted_frame_id = -1 @@ -2938,6 +3208,12 @@ def _run(self) -> None: fps_window_start = time.monotonic() fps_window_frames = 0 self._next_drop_log_t = time.monotonic() + _DROP_LOG_INTERVAL_S + # Worker-owned reconnect state: the ingest adapters' own stall/reconnect + # loop never runs here (the worker drives _open/_read_frame directly), + # so the capture loop must reopen a dead/stalled source itself. + self._last_frame_t = time.monotonic() + self._reopen_backoff = self.config.reconnect.backoff_initial_s + self._reopen_next_t = 0.0 last_health = HealthState.OK if self._source is not None else HealthState.ERROR last_error = None if self._source is not None else "frame source unavailable" @@ -2989,6 +3265,9 @@ def _run(self) -> None: self._push_frame(frame) fps_window_frames += 1 miss_streak = 0 # got a frame → drop back to fast retries + self._last_frame_t = now + self._reopen_backoff = self.config.reconnect.backoff_initial_s + self._reopen_next_t = 0.0 last_health = HealthState.OK last_error = None @@ -3031,6 +3310,14 @@ def _run(self) -> None: # to the cap so a stalled/offline/denied camera doesn't spin the # capture thread. if frame is None: + # A sustained stall (or a source that never opened) gets a + # full close+rebuild+reopen — read() alone can NEVER revive + # a session that stopped delivering (e.g. a Continuity + # Camera that was asleep at service start). + if self._maybe_reopen_source(now): + last_health = HealthState.RECONNECTING + if self._source is None: + last_error = "frame source unavailable; retrying" miss_streak += 1 if miss_streak <= _RECONNECT_FAST_RETRIES: wait = 0.01 @@ -3345,6 +3632,74 @@ def _open_resources(self) -> None: self._build_ptz_stack() self._maybe_start_ptz_pump() + def _maybe_reopen_source(self, now: float) -> bool: + """Rebuild + reopen a dead/stalled frame source (worker-owned reconnect). + + The ingest adapters carry their own stall/reconnect loop, but it only runs + on the adapter's own thread — which this worker never starts (it drives + ``_open``/``_read_frame`` directly to feed detection). Without this, a + source that failed to open at startup, or a session that stopped + delivering frames (a Continuity Camera that was asleep at service start), + stayed dead until a full service restart. + + Called from the capture loop's no-frame branch. Attempts fire when the + source is missing or ``reconnect.stall_timeout_s`` has passed without a + frame, paced by exponential backoff (``backoff_initial_s`` doubling up to + ``backoff_max_s``; a delivered frame resets it). Injected sources + (tests / synthetic children) are never rebuilt. Returns ``True`` when an + attempt was made — successful or not — so the loop can surface + RECONNECTING health. Never raises. + """ + if self._injected_source is not None: + return False + reconnect = self.config.reconnect + stalled = (now - self._last_frame_t) >= reconnect.stall_timeout_s + if self._source is not None and not stalled: + return False + if now < self._reopen_next_t: + return False + self._reopen_next_t = now + self._reopen_backoff + self._reopen_backoff = min(self._reopen_backoff * 2.0, reconnect.backoff_max_s) + + old = self._source + self._source = None + if old is not None: + try: + old.close() + except Exception: # noqa: BLE001 + log.debug("camera_id=%s stale source close failed", self.camera_id, exc_info=True) + log.warning( + "camera_id=%s no frames for %.1fs; reopening frame source", + self.camera_id, + now - self._last_frame_t, + ) + try: + source = build_frame_source(self.camera_id, self.config) + if source is not None and source.open(): + self._source = source + # Fresh stall window: give the new session a full stall_timeout + # before it can be torn down again. + self._last_frame_t = now + log.info("camera_id=%s frame source reopened", self.camera_id) + else: + if source is not None: + try: + source.close() + except Exception: # noqa: BLE001 + log.debug( + "camera_id=%s failed-open source close failed", + self.camera_id, + exc_info=True, + ) + log.info( + "camera_id=%s frame source reopen failed; next attempt in %.1fs", + self.camera_id, + self._reopen_next_t - now, + ) + except Exception: # noqa: BLE001 — reopen is best-effort, never kills capture + log.warning("camera_id=%s frame source reopen raised", self.camera_id, exc_info=True) + return True + def _build_inference_stacks(self) -> None: """Build the detect + face stacks ON the inference thread. @@ -3797,19 +4152,31 @@ def _maybe_log_drops(self, now: float) -> None: self._last_logged_inf_captured = self._frames_captured self._last_logged_inf_inferred = self._frames_inferred skipped = cap_delta - inf_delta - if cap_delta > 0 and skipped > 0: - ratio = skipped / cap_delta - if ratio >= 0.5: # only shout when the inference thread is well behind - log.info( - "camera_id=%s inference behind: processed %d/%d frames (%.0f%% skipped) " - "in the last %.0fs; cadence=%s", - self.camera_id, - inf_delta, - cap_delta, - ratio * 100.0, - _DROP_LOG_INTERVAL_S, - self._quality_active, - ) + ratio = (skipped / cap_delta) if cap_delta > 0 else 0.0 + behind = cap_delta > 0 and ratio >= 0.5 + if behind: + # INFO only when ENTERING the behind state; a steady-state repeat every + # window said nothing new and, at 5 cameras, spammed a line every ~2 s. + emit = log.info if not self._inference_behind else log.debug + emit( + "camera_id=%s inference behind: processed %d/%d frames (%.0f%% skipped) " + "in the last %.0fs; cadence=%s", + self.camera_id, + inf_delta, + cap_delta, + ratio * 100.0, + _DROP_LOG_INTERVAL_S, + self._quality_active, + ) + elif self._inference_behind and cap_delta > 0: + log.info( + "camera_id=%s inference caught up: processed %d/%d frames in the last %.0fs", + self.camera_id, + inf_delta, + cap_delta, + _DROP_LOG_INTERVAL_S, + ) + self._inference_behind = behind def _framed_output(self, frame: NDArray[np.uint8]) -> NDArray[np.uint8]: """Center Stage: crop+scale the frame to auto-frame the target. @@ -3851,13 +4218,31 @@ def _framed_output(self, frame: NDArray[np.uint8]) -> NDArray[np.uint8]: if target is not None: # fit_width only for a multi-person group UNION (so it auto-widens to # keep everyone in shot); a single locked/standalone person stays on - # the prior height-only sizing. - x, y, cw, ch = framer.frame_for(target, w, h, fit_width=self._digital_target_is_group) + # the prior height-only sizing. A single person is DOT-ANCHORED: + # the box only sizes the crop, and the crop is placed so the + # tracking dot (the aim point the operator sees) rides at the + # preset's composition point — move, and the crop glides to + # re-compose you. Group unions keep the classic box-centred crop. + anchor = None + if not self._digital_target_is_group: + anchor = self._digital_aim_anchor(target, framing) + x, y, cw, ch = framer.frame_for( + target, + w, + h, + fit_width=self._digital_target_is_group, + anchor_xy=anchor, + anchor_place=_CENTERSTAGE_DOT_PLACEMENT.get(framing, (0.5, 0.38)), + ) else: x, y, cw, ch = framer.full_frame(w, h) - nowm = time.monotonic() - if nowm - self._cs_diag_t > 2.0: - self._cs_diag_t = nowm + # Log ONLY on a state change (target appears/vanishes or the tid flips): + # the previous 2-second repeat of an unchanged state spammed the console + # and the in-app Logs panel — 5 cameras produced a line every ~400 ms + # while saying nothing new. + cs_state = (target is not None, self._target_track_id) + if cs_state != self._cs_last_logged_state: + self._cs_last_logged_state = cs_state log.info( "camera_id=%s center-stage: target=%s crop=%dx%d of %dx%d (tid=%s)", self.camera_id, @@ -3903,44 +4288,224 @@ def _current_digital_target(self) -> tuple[float, float, float, float] | None: person present the crop frames the UNION of their boxes (auto-widening, capped by ``max_frac``). One person, or the toggle off, is the single target's box exactly as before. + + **"Ignore arms"** (``aim_body_mode == "torso"``, the default): a single + locked person is framed on the pose-torso-derived box instead of the raw + detection bbox, so raised/extended arms — which inflate the YOLO box and + drag its centre — no longer grow or shift the crop. Falls back to the + raw bbox whenever fresh torso keypoints for *this* track aren't cached + (pose off/unavailable/stale), and never applies to a group union. + + **Tracking gate**: Center Stage only crops while tracking is active — + the per-camera Track toggle (``_tracking_enabled``) AND the global + tracking feature switch, the same pair that gates the physical PTZ + drive. With either off there is no target, so the crop eases back to + the full frame. + """ + if not (self._tracking_enabled and self._feature("tracking")): + self._digital_target_is_group = False + self._digital_primary_track_id = None + return None + # Delegate to the shared framing-target selector so Center Stage and + # physical PTZ frame the same subject the same way (see + # autoptz.engine.framing_target). + ft = self._select_framing_target() + self._digital_target_is_group = ft.is_group + self._digital_primary_track_id = ft.primary_track_id + if ft.bbox is None or ft.is_group: + return ft.bbox + torso = self._torso_stable_box(ft.primary_track_id) + return torso if torso is not None else ft.bbox + + def _digital_aim_anchor( + self, + box: tuple[float, float, float, float], + framing: str, + ) -> tuple[float, float]: + """The tracking DOT the Center Stage crop composes, in frame pixels. + + Prefers the framed track's live aim point (``aim_x/aim_y`` — the same + engine-smoothed dot drawn on screen, pose-fused and arm-invariant), so + the crop and the visible dot can never disagree. Falls back to the + framing-region point of *box* (its centre-x, ``AIM_REGION_FRACTION`` + down) when no aim telemetry exists — e.g. the group-single path before + a lock. Runs on the capture thread: reads are plain float attributes + (reference-swapped by the inference thread), same contract as the + overlay. """ - self._digital_target_is_group = False - tid = self._target_track_id - explicit_lock = tid is not None or self._target_identity_id is not None + tid = self._digital_primary_track_id if tid is not None: - for t in self._last_tracks or (): + for t in self._last_tracks: if ( t.track_id == tid and not getattr(t, "lost", False) - and getattr(t, "bbox", None) is not None + and t.aim_x is not None + and t.aim_y is not None ): - bb = t.bbox - return (bb.x1, bb.y1, bb.x2, bb.y2) - # Fallback: the last trusted target box (set whenever a target is locked, - # by track id OR by identity), so the crop holds through brief track gaps. - if explicit_lock: - tb = getattr(self._target_lock, "trusted_bbox", None) - if tb is not None: - return (tb.x1, tb.y1, tb.x2, tb.y2) - # An explicit lock ALWAYS wins: never fall through to the group union - # just because the locked track is momentarily absent (transient — e.g. - # the first frame(s) after selecting a target, before trusted_bbox is - # populated). Returning None holds the prior crop / full frame instead. + return (float(t.aim_x), float(t.aim_y)) + x1, y1, x2, y2 = box + return ( + (x1 + x2) * 0.5, + y1 + (y2 - y1) * AIM_REGION_FRACTION.get(framing, 0.5), + ) + + def _note_good_kps(self, kps: list[Any], track_id: int, now: float) -> None: + """Record a successful, consistency-checked pose estimate (sticky). + + Framing reads this cache instead of the strict per-tick one so a single + bad estimate — common exactly while the subject gestures — cannot snap + the shot back to the raw bbox. + """ + self._last_good_kps = kps + self._last_good_kps_track_id = track_id + self._last_pose_good_t = now + + def _fresh_target_kps(self, track_id: int | None) -> list[Any] | None: + """The last GOOD pose keypoints iff they belong to *track_id* and a + successful estimate happened within ``_POSE_FRAMING_TTL_S``. + + Safe from any thread: the keypoints are written by the inference thread + (reference swap — GIL-atomic, same contract as the overlay). The + freshness gate means a sustained pose outage (dead estimator, stalled + inference thread) degrades to raw-bbox behaviour instead of acting on a + stale torso — but a momentary dropout does NOT. + """ + kps = self._last_good_kps + if not kps or track_id is None or self._last_good_kps_track_id != track_id: return None + if time.monotonic() - self._last_pose_good_t > _POSE_FRAMING_TTL_S: + return None + return kps - # No explicit lock: optionally frame the whole confident group as a union. - if bool(getattr(self.config.tracking, "group_framing", False)): - boxes = self._confident_person_boxes(self._last_tracks) - if not boxes: - return None - # fit-width only when the union actually spans MORE THAN ONE person; a - # single confident person keeps the prior height-only single-target feel. - self._digital_target_is_group = len(boxes) > 1 - from autoptz.engine.pipeline.digital_framer import union_bbox + def _torso_stable_box(self, track_id: int | None) -> tuple[float, float, float, float] | None: + """The cached pose-torso framing box for *track_id*, or None. + + Only in "Ignore arms" mode (``aim_body_mode == "torso"``); falls back to + None — raw-bbox framing — whenever fresh owned keypoints are missing. + Transitions between the two sources are logged (throttled by + change-only) so a field run shows whether pose is actually protecting + the framing or it silently degraded to the raw box. + """ + if getattr(self.config.tracking, "aim_body_mode", "torso") != "torso": + return None + kps = self._fresh_target_kps(track_id) + box = None + if kps is not None: + try: + from autoptz.engine.pipeline import framing - return union_bbox(boxes) + box = framing.torso_framing_box(kps) + except Exception: # noqa: BLE001 + box = None + if box is not None: + # Pose estimates arrive as discrete ~0.2 s samples with keypoint + # noise — smooth them into a continuous signal so the framing + # target (and the PTZ velocity feed-forward differentiating it) + # never sees a step. Shared by BOTH actuators (capture thread via + # Center Stage, inference thread via physical PTZ aim) — the lock + # makes their sub-steps and _reset_pose_aim's reset() mutually + # exclusive instead of racing on the smoother's internal state. + with self._torso_box_smoother_lock: + if self._torso_box_smoother is None: + from autoptz.engine.pipeline import framing + + self._torso_box_smoother = framing.BoxSmoother(tau=_TORSO_BOX_TAU_S) + smoothed = self._torso_box_smoother.update(box, time.monotonic()) + self._note_framing_source("torso") + return smoothed if smoothed is not None else box + self._note_framing_source( + "bbox", + reason="no fresh pose keypoints" if kps is None else "torso landmarks unavailable", + ) return None + def _note_framing_source(self, source: str, *, reason: str = "") -> None: + """Log 'Ignore arms' framing-source transitions (change-only).""" + if source == self._framing_source: + return + self._framing_source = source + if source == "torso": + log.info( + "camera_id=%s framing source → torso (pose-stable; arms ignored)", + self.camera_id, + ) + else: + log.info( + "camera_id=%s framing source → bbox (%s) — arm motion CAN move " + "the shot until pose recovers", + self.camera_id, + reason or "pose unavailable", + ) + + def _head_recovery_bias(self, track: TrackInfo, ey: float, frame_h: float) -> float: + """Tilt-up bias when the head is out of view above the frame. + + The smart "we're accidentally framing the body" check: the framing + preset expects the head (face / head_shoulders / upper_body), the pose + sees a torso but NO head landmark, and the detection box is clipped at + the very top of the frame — i.e. the head is above the field of view. + Returns *ey* raised to at least ``_HEAD_RECOVERY_EY`` so the controller + tilts up until the head re-enters the frame. Occlusions mid-frame + (head behind an object, box not top-clipped) never trigger it, so it + cannot oscillate against a blocked view. HYSTERESIS: once active, + releasing needs a *clearly* visible head landmark (confidence floor + + ``_HEAD_RECOVERY_EXIT_CONF_MARGIN``) and a wider un-clipped gap — a + borderline nose flickering around the floor can't flap the bias on/off + (which read as tilt jitter). + """ + active = self._head_recovery_active + framing_name = _resolve_framing(self.config.tracking) + if framing_name not in _HEAD_RECOVERY_FRAMINGS: + self._head_recovery_active = False + return ey + clip_frac = _HEAD_CLIP_EXIT_FRAC if active else _HEAD_CLIP_TOP_FRAC + if float(track.bbox.y1) > frame_h * clip_frac: + self._head_recovery_active = False + return ey + kps = self._fresh_target_kps(track.track_id) + if kps is None: + self._head_recovery_active = False + return ey + try: + from autoptz.engine.pipeline import framing + + head_conf = framing.DEFAULT_KP_CONF + ( + _HEAD_RECOVERY_EXIT_CONF_MARGIN if active else 0.0 + ) + if framing.head_point(kps, min_conf=head_conf) is not None: + self._head_recovery_active = False + return ey # head is visible — normal aim math owns the tilt + if framing.shoulder_midpoint(kps) is None and framing.hip_midpoint(kps) is None: + self._head_recovery_active = False + return ey # no body evidence either — don't invent a direction + except Exception: # noqa: BLE001 + return ey + self._head_recovery_active = True + now = time.monotonic() + if now - self._last_head_recover_log_t >= _TICK_WARN_INTERVAL_S: + self._last_head_recover_log_t = now + log.info( + "camera_id=%s head out of view above frame (torso visible, no head " + "landmark, box top-clipped) — biasing tilt up to recover the %s shot", + self.camera_id, + framing_name, + ) + return max(ey, _HEAD_RECOVERY_EY) + + def _select_framing_target(self) -> FramingTarget: + """This tick's shared framing target (used by Center Stage AND physical PTZ).""" + from autoptz.engine.framing_target import select_framing_target + + tb = getattr(self._target_lock, "trusted_bbox", None) + trusted = (tb.x1, tb.y1, tb.x2, tb.y2) if tb is not None else None + return select_framing_target( + self._last_tracks, + target_track_id=self._target_track_id, + target_identity_id=self._target_identity_id, + trusted_bbox=trusted, + group_framing=bool(getattr(self.config.tracking, "group_framing", False)), + ) + @staticmethod def _confident_person_boxes( tracks: list[TrackInfo], @@ -3975,24 +4540,31 @@ def _push_frame(self, frame: NDArray[np.uint8]) -> None: return now = time.monotonic() vcam_on = bool(getattr(self.config.ptz, "vcam_out", False)) - # Two INDEPENDENT rate gates: + ndi_on = bool(getattr(self.config.ptz, "ndi_out", False)) + # Independent rate gates: # • preview (~20 fps) — the SHM monitoring tile only; capping it skips the # per-frame resize/copy on faster sources (overlays come from telemetry). - # • vcam (~30 fps) — a real product feed, so it is NOT throttled to the - # preview rate; it sends up to 30 fps when ``vcam_out`` is on. + # • vcam / ndi (~30 fps) — real product feeds, so NOT throttled to the + # preview rate; each sends up to 30 fps when its toggle is on. preview_due = _push_due(now, self._last_preview_push_t, _PREVIEW_PUSH_MIN_PERIOD_S) vcam_due = vcam_on and _push_due(now, self._last_vcam_push_t, _VCAM_PUSH_MIN_PERIOD_S) + ndi_due = ndi_on and _push_due(now, self._last_ndi_push_t, _VCAM_PUSH_MIN_PERIOD_S) - # Release the device promptly once output is turned off (so it disconnects - # from Zoom/OBS instead of lingering on a frozen frame) regardless of gates. + # Release each output promptly once turned off (so it disconnects from + # Zoom/OBS / NDI receivers instead of lingering on a frozen frame). if not vcam_on and self._vcam is not None: try: self._vcam.close() finally: self._vcam = None + if not ndi_on and self._ndi is not None: + try: + self._ndi.close() + finally: + self._ndi = None - # CPU savings preserved: if neither sink is due this tick, do no framing. - if not preview_due and not vcam_due: + # CPU savings preserved: if no sink is due this tick, do no framing. + if not preview_due and not vcam_due and not ndi_due: return try: @@ -4001,6 +4573,11 @@ def _push_frame(self, frame: NDArray[np.uint8]) -> None: if preview_due: self._last_preview_push_t = now self._shm.push(self._fit_frame(framed)) + # Output sinks (vcam / NDI) send on their own pump thread: the + # BGR→RGBA conversion + SDK hand-off cost milliseconds per frame, + # and paying them here — on the CAPTURE thread — surfaced as random + # frame drops under load. The capture thread only parks the frame. + sinks: list[Any] = [] if vcam_due: self._last_vcam_push_t = now ow = int(getattr(self.config.ptz, "digital_output_w", 1280)) @@ -4009,7 +4586,26 @@ def _push_frame(self, frame: NDArray[np.uint8]) -> None: from autoptz.engine.pipeline.vcam import VirtualCamSink self._vcam = VirtualCamSink(ow, oh) - self._vcam.send_bgr(framed) + sinks.append(self._vcam) + if ndi_due: + self._last_ndi_push_t = now + ow = int(getattr(self.config.ptz, "digital_output_w", 1280)) + oh = int(getattr(self.config.ptz, "digital_output_h", 720)) + if self._ndi is None: + from autoptz.engine.pipeline.ndi_send import NDISendSink + + self._ndi = NDISendSink(ow, oh, self._ndi_output_name()) + sinks.append(self._ndi) + if sinks: + if self._output_sender is None: + from autoptz.engine.pipeline.output_sender import OutputSender + + self._output_sender = OutputSender(name=self.camera_id[:8]) + # The passthrough path hands out the adapter's own buffer, which + # the next capture tick may overwrite — copy it for the pump. + # The Center Stage crop path returns a fresh array (no copy). + safe = framed if framed is not frame else np.ascontiguousarray(frame).copy() + self._output_sender.submit(safe, sinks) except Exception: # noqa: BLE001 # Was DEBUG-only: a broken preview pipe (shm/vcam) left the operator # staring at a frozen preview with an empty log. Surface it as a @@ -4238,6 +4834,13 @@ def _maybe_identify( # if an explicit identity target can't be resolved we leave the # current track lock untouched (manual box-tracking still works). return + # Nobody detected → a face couldn't bind to any track (matching, targeting + # and harvest all key off a containing track), so skip the whole pass — + # the SCRFD full-frame scan is pure CPU burn on an empty scene. The timer + # is NOT stamped, so the pass runs immediately once someone appears. + if not tracks and not self._pending_enroll: + self._clear_face_overlay() + return self._last_face_t = now try: @@ -5374,6 +5977,13 @@ def _emit_telemetry( # a streaming-but-box-blind camera has no tracks. if last_error is None and self._infer_last_error is not None: last_error = self._infer_last_error + # The model-server client's ``ep`` enriches itself ("model-server (CoreML)") + # only after the server's BACKGROUND model load reports in — re-read the live + # detector so the label follows, instead of freezing the build-time snapshot. + if self._detect is not None: + live_ep = str(getattr(self._detect.detector, "ep", "") or "") + if live_ep: + self._ep = live_ep # Phase 0a — per-source frame-delivery telemetry (NDI-only real values; # other sources return {} and fall back to the worker's own counters). dm = self._delivery_metrics() diff --git a/autoptz/engine/discovery/ndi.py b/autoptz/engine/discovery/ndi.py index 2e7d7e8e..2ef92aea 100644 --- a/autoptz/engine/discovery/ndi.py +++ b/autoptz/engine/discovery/ndi.py @@ -150,3 +150,29 @@ def _fire(self, event: DiscoveryEvent, source: NDISource) -> None: cb(event, source) except Exception as exc: # noqa: BLE001 log.error("NDIDiscovery callback raised: %s", exc) + + +def is_own_autoptz_output(name: str, host: str | None = None) -> bool: + """True when *name* is THIS machine's own AutoPTZ NDI output feed. + + NDI advertises sources as ``"HOSTNAME (sender name)"`` and AutoPTZ's output + sender names start with ``"AutoPTZ "`` — so our own feeds look like + ``"PRINCES-MBP (AutoPTZ Camera 1)"`` on PRINCES-MBP. Requires BOTH the local + hostname AND the AutoPTZ prefix, so another machine's AutoPTZ output (a + legitimate remote source) never matches. Ingesting our own output is a + feedback loop: the machine encodes and re-decodes its own pixels, and the + re-broadcast can nest — the UI hides these from the NDI menu and the + supervisor warns when a configured camera still points at one. ``host`` is + injectable for tests; defaults to this machine's short hostname. + """ + if host is None: + import socket + + host = socket.gethostname().split(".")[0] + text = (name or "").strip() + lead, sep, inner = text.partition(" (") + if not sep: + return False + return lead.strip().lower() == (host or "").strip().lower() and inner.lstrip().startswith( + "AutoPTZ " + ) diff --git a/autoptz/engine/framing_target.py b/autoptz/engine/framing_target.py new file mode 100644 index 00000000..2904e72e --- /dev/null +++ b/autoptz/engine/framing_target.py @@ -0,0 +1,140 @@ +"""Shared framing-target selection for Center Stage and physical PTZ. + +Both the digital (Center Stage) crop and the physical PTZ aim must frame the same +subject the same way. This module is the single source of truth for *which box to +frame this tick*: + +- **Explicit lock wins.** A person locked by track id (or by configured identity) + is followed even with group framing on. The live track is preferred; a + ``trusted_bbox`` fallback holds the frame through brief track-id churn. +- **Group framing** (only when nothing is locked): with more than one confident, + non-lost person, frame the UNION of their boxes; a single confident person is + framed alone. +- Otherwise there is no target. + +Center Stage crops around the returned box; physical PTZ steers its aim toward the +same box — so explicit-lock precedence and group framing apply identically to both +actuators, which is what "physical PTZ frames like Center Stage" means. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +Box = tuple[float, float, float, float] + +# Named "Framing" presets → target subject-height as a fraction of the visible +# frame. THE single source of truth for shot composition: the physical PTZ +# auto-zoom drives the subject toward this height, and the Center Stage crop is +# sized so the subject fills this fraction of it — so both actuators produce the +# same shot for the same preset. face/head_shoulders are deliberately moderate +# (the old 0.80/0.60 physical targets and 0.86/0.80 digital fills were +# user-reported as "intense": over-zoomed and twitchy). +SUBJECT_HEIGHT_TARGETS: dict[str, float] = { + "face": 0.65, + "head_shoulders": 0.52, + "upper_body": 0.45, + "full_body": 0.30, +} + + +@dataclass(frozen=True) +class FramingTarget: + """The box to frame this tick, plus how it was chosen.""" + + bbox: Box | None = None + is_group: bool = False + #: The single locked track id when a lone person is the target — enables the + #: pose-fused physical aim. ``None`` for a group union or no target. + primary_track_id: int | None = None + + +def confident_person_boxes(tracks: list[Any] | None) -> list[Box]: + """Every confident, non-lost person box in *tracks* (pure, testable).""" + out: list[Box] = [] + for t in tracks or (): + if getattr(t, "lost", False): + continue + bb = getattr(t, "bbox", None) + if bb is None: + continue + out.append((bb.x1, bb.y1, bb.x2, bb.y2)) + return out + + +def aim_error_for_box( + box: Box, + frame_w: int, + frame_h: int, + aim_fraction: float, +) -> tuple[tuple[float, float], float]: + """Normalized center error + subject-height fraction for framing *box*. + + ``ex > 0`` → the box is right of frame center; ``ey > 0`` → above center (image + y grows downward, so it's negated to match the PTZ tilt convention where + positive = up). ``aim_fraction`` is how far down the box the aim sits (the + ``framing`` composition, e.g. ~0.38 for upper-body). Mirrors the bbox anchor + the single-target path uses, so a group is framed with the same composition. + """ + if frame_w <= 0 or frame_h <= 0: + return (0.0, 0.0), 0.0 + x1, y1, x2, y2 = box + ax = (x1 + x2) * 0.5 + ay = y1 + (y2 - y1) * aim_fraction + ex = (ax - frame_w * 0.5) / (frame_w * 0.5) + ey = -((ay - frame_h * 0.5) / (frame_h * 0.5)) + return (float(ex), float(ey)), float((y2 - y1) / frame_h) + + +def select_framing_target( + tracks: list[Any] | None, + *, + target_track_id: int | None, + target_identity_id: Any | None, + trusted_bbox: Box | None, + group_framing: bool, +) -> FramingTarget: + """Resolve the framing target for this tick (see module docstring).""" + explicit_lock = target_track_id is not None or target_identity_id is not None + + if target_track_id is not None: + for t in tracks or (): + if ( + getattr(t, "track_id", None) == target_track_id + and not getattr(t, "lost", False) + and getattr(t, "bbox", None) is not None + ): + bb = t.bbox + return FramingTarget((bb.x1, bb.y1, bb.x2, bb.y2), False, target_track_id) + + if explicit_lock: + # Locked but the live box is momentarily absent: hold on the trusted box. + # An explicit lock NEVER falls through to the group union. + if trusted_bbox is not None: + return FramingTarget(trusted_bbox, False, target_track_id) + return FramingTarget(None, False, target_track_id) + + if group_framing: + confident = [ + t + for t in tracks or () + if not getattr(t, "lost", False) and getattr(t, "bbox", None) is not None + ] + if not confident: + return FramingTarget(None, False, None) + if len(confident) == 1: + # A lone person is a single subject: expose their track id so the + # pose-stable ("Ignore arms") framing applies like an explicit lock. + t = confident[0] + bb = t.bbox + return FramingTarget((bb.x1, bb.y1, bb.x2, bb.y2), False, getattr(t, "track_id", None)) + from autoptz.engine.pipeline.digital_framer import union_bbox + + return FramingTarget( + union_bbox([(t.bbox.x1, t.bbox.y1, t.bbox.x2, t.bbox.y2) for t in confident]), + True, + None, + ) + + return FramingTarget(None, False, None) diff --git a/autoptz/engine/pipeline/digital_framer.py b/autoptz/engine/pipeline/digital_framer.py index 24a90ac9..1a42f84c 100644 --- a/autoptz/engine/pipeline/digital_framer.py +++ b/autoptz/engine/pipeline/digital_framer.py @@ -21,6 +21,12 @@ def _clamp(v: float, lo: float, hi: float) -> float: return lo if v < lo else hi if v > hi else v +# Default in-crop placement for a dot-anchored crop: dot horizontally centred, +# vertically at the upper-body composition point. Callers pass a per-preset +# placement; this is only the fallback. +_DEFAULT_ANCHOR_PLACE = (0.5, 0.38) + + def union_bbox( boxes: list[tuple[float, float, float, float]], ) -> tuple[float, float, float, float] | None: @@ -51,6 +57,8 @@ def desired_crop( max_frac: float, headroom: float = 0.10, fit_width: bool = False, + anchor_xy: tuple[float, float] | None = None, + anchor_place: tuple[float, float] = _DEFAULT_ANCHOR_PLACE, ) -> tuple[float, float, float, float]: """The crop ``(x, y, w, h)`` (pixels) that frames *bbox*. @@ -68,6 +76,15 @@ def desired_crop( multi-person *group framing* union, so the crop auto-widens to keep everyone in shot (still aspect-locked and capped at ``max_frac``). Single-person / non-group framing keeps ``fit_width=False`` for byte-identical prior behaviour. + + ``anchor_xy`` is the tracking DOT (the aim point, frame pixels). When given, + the crop is **dot-anchored**: *bbox* only sizes it, and the crop is placed so + the dot sits at ``anchor_place`` — ``(x_frac, y_frac)`` of the crop, e.g. + ``(0.5, 0.26)`` = horizontally centred, upper-third for a face shot — + continuously, every call. This is the Center Stage contract: the output + composes the dot; move and the crop glides to re-compose (clamped at the + frame edges). ``None`` keeps the classic box-centred + headroom placement + (group unions / callers without an aim point). """ bx1, by1, bx2, by2 = (float(v) for v in bbox) subj_h = max(1.0, by2 - by1) @@ -93,6 +110,12 @@ def desired_crop( if cw > fw: cw, ch = fw, fw / out_aspect + if anchor_xy is not None: + # Dot-anchored: place the crop so the tracking dot sits at anchor_place. + px, py = anchor_place + x = _clamp(anchor_xy[0] - cw * px, 0.0, max(0.0, fw - cw)) + y = _clamp(anchor_xy[1] - ch * py, 0.0, max(0.0, fh - ch)) + return (x, y, cw, ch) cy_adj = cy - headroom * ch x = _clamp(cx - cw * 0.5, 0.0, max(0.0, fw - cw)) y = _clamp(cy_adj - ch * 0.5, 0.0, max(0.0, fh - ch)) @@ -162,11 +185,15 @@ def frame_for( frame_h: int, *, fit_width: bool = False, + anchor_xy: tuple[float, float] | None = None, + anchor_place: tuple[float, float] = _DEFAULT_ANCHOR_PLACE, ) -> tuple[int, int, int, int]: """Smoothed integer crop framing *bbox*. ``fit_width=True`` widens the crop to cover a wide subject (the group-union box); the default keeps the prior height-only sizing for single people. + ``anchor_xy`` switches to dot-anchored placement — the crop composes the + tracking dot at ``anchor_place`` — see :func:`desired_crop`. """ tgt = desired_crop( bbox, @@ -178,6 +205,8 @@ def frame_for( max_frac=self.max_frac, headroom=self.headroom, fit_width=fit_width, + anchor_xy=anchor_xy, + anchor_place=anchor_place, ) tgt = self._apply_lead(bbox, tgt, frame_w, frame_h) return self._step(tgt) diff --git a/autoptz/engine/pipeline/framing.py b/autoptz/engine/pipeline/framing.py index ed01f0b4..2cb272ee 100644 --- a/autoptz/engine/pipeline/framing.py +++ b/autoptz/engine/pipeline/framing.py @@ -22,6 +22,7 @@ from __future__ import annotations +import math from dataclasses import dataclass # COCO-17 keypoint indices for the torso anchors we rely on. Documented here so @@ -35,6 +36,48 @@ KP_RIGHT_SHOULDER = 6 KP_LEFT_HIP = 11 KP_RIGHT_HIP = 12 +KP_LEFT_KNEE = 13 +KP_RIGHT_KNEE = 14 +KP_LEFT_ANKLE = 15 +KP_RIGHT_ANKLE = 16 + +# Every COCO-17 keypoint EXCEPT the arms (elbows 7/8, wrists 9/10): the body +# landmarks whose extent is stable when arms wave/extend. Framing math must +# only ever measure these, so gesturing can never grow or shift the shot. +NON_ARM_KEYPOINTS: tuple[int, ...] = ( + KP_NOSE, + KP_LEFT_EYE, + KP_RIGHT_EYE, + KP_LEFT_EAR, + KP_RIGHT_EAR, + KP_LEFT_SHOULDER, + KP_RIGHT_SHOULDER, + KP_LEFT_HIP, + KP_RIGHT_HIP, + KP_LEFT_KNEE, + KP_RIGHT_KNEE, + KP_LEFT_ANKLE, + KP_RIGHT_ANKLE, +) + +# Real body extent (nose→ankle) misses the crown of the head and the sole of +# the foot; pad it so the framed height covers the whole person. +_BODY_EXTENT_PAD = 1.08 + +# Hips-hidden (desk/webcam) stature estimate, from two ARM-INVARIANT anchors: +# the vertical head→shoulder span is ≈ 1/11.8 of standing height (nose height +# ≈ 90% of stature, acromion/shoulder height ≈ 81%, per standard standing +# anthropometric tables — a ≈8.5% span), the biacromial (shoulder) width ≈ +# 1/4.1. max() of the two is robust to both failure modes — tilting the head +# shrinks the span but not the width; turning sideways shrinks the width but +# not the span. Composition only needs a STABLE ballpark (the framer clamps +# to min/max crop fractions), so modest anthropometric error is fine; +# following the raw bbox is not. +_HEAD_SHOULDER_SPAN_TO_HEIGHT = 11.8 +_SHOULDER_WIDTH_TO_HEIGHT = 4.1 +# Hips-hidden framing box: its top sits this fraction of the height above the +# head point (crown + hair margin), mirroring where the hips-based box lands. +_CROWN_PAD_FRAC = 0.10 # Head landmarks, in fallback order (nose is the best single head point). KP_HEAD_GROUPS: tuple[tuple[int, ...], ...] = ( @@ -272,26 +315,179 @@ def subject_height_from_pose( ) -> float | None: """Return a **stable** subject-height span (pixels), or ``None``. - Uses the shoulder→hip vertical distance — a torso measure that does not - change when arms/legs move — scaled up to approximate the framing-relevant - person height (a standing adult is roughly ~3.3× their shoulder→hip span). - The caller divides this by the frame height for the auto-zoom fraction, so - only the *ratio* matters; the scale just keeps the zoom target comparable to - the legacy bbox-height behaviour. - - Returns ``None`` when shoulders or hips are not both confidently available - (the caller then keeps the bbox-height zoom math). + Prefers the REAL vertical extent of the confident non-arm landmarks (head → + ankles, padded ×1.08 for crown/sole) when that is taller than the classic + 3.3× shoulder→hip heuristic; the heuristic remains the floor so a subject + with cropped legs (extent = head→hips only) is never under-measured into an + over-zoom. Arms (elbows/wrists) are NEVER measured, so gesturing cannot + change the result. The caller divides this by the frame height for the + auto-zoom fraction, so only the *ratio* matters. + + When the hips are hidden (desk/webcam shots — the everyday single-camera + case) it degrades to a head+shoulders stature estimate instead of ``None``, + because ``None`` sends the caller back to the arms-inflated bbox height: + exactly the instability this module exists to prevent. Returns ``None`` + only when neither anchor pair is available (no shoulders, or shoulders with + no head landmark and no hips) — the caller then keeps the bbox-height math. """ shoulders = shoulder_midpoint(kps, min_conf) hips = hip_midpoint(kps, min_conf) - if shoulders is None or hips is None: + if shoulders is None: + return None + if hips is not None: + span = abs(hips[1] - shoulders[1]) + if span <= 0.0: + return None + # Empirical torso→full-height factor; keeps the zoom subject-height in + # the same ballpark as the person bbox height the controller was tuned + # against. + height = span * 3.3 + else: + height = _head_shoulder_height(kps, min_conf) + if height is None: + return None + extent = _body_extent(kps, min_conf) + if extent is not None: + height = max(height, (extent[1] - extent[0]) * _BODY_EXTENT_PAD) + return height + + +def _head_shoulder_height( + kps: Keypoints, + min_conf: float = DEFAULT_KP_CONF, +) -> float | None: + """Stature estimate from head + shoulders only (hips hidden), or ``None``. + + ``max(head→shoulder span × 10, shoulder width × 4.1)`` — see the constants + above for why the max of the two anchors is stable. The width term needs + BOTH shoulders confident; the span term carries a lone shoulder. + """ + shoulders = shoulder_midpoint(kps, min_conf) + head = head_point(kps, min_conf) + if shoulders is None or head is None: return None - span = abs(hips[1] - shoulders[1]) + span = shoulders[1] - head[1] # positive: head above the shoulder line if span <= 0.0: return None - # Empirical torso→full-height factor; keeps the zoom subject-height in the - # same ballpark as the person bbox height the controller was tuned against. - return span * 3.3 + height = span * _HEAD_SHOULDER_SPAN_TO_HEIGHT + both = _confident(kps, (KP_LEFT_SHOULDER, KP_RIGHT_SHOULDER), min_conf) + if len(both) == 2: + width = abs(both[0].x - both[1].x) + height = max(height, width * _SHOULDER_WIDTH_TO_HEIGHT) + return height + + +def _body_extent( + kps: Keypoints, + min_conf: float = DEFAULT_KP_CONF, +) -> tuple[float, float] | None: + """(min_y, max_y) of the confident NON-ARM landmarks, or ``None``.""" + pts = _confident(kps, NON_ARM_KEYPOINTS, min_conf) + if not pts: + return None + ys = [p.y for p in pts] + return (min(ys), max(ys)) + + +def torso_framing_box( + kps: Keypoints, + min_conf: float = DEFAULT_KP_CONF, +) -> tuple[float, float, float, float] | None: + """A **stable** person-framing box (x1, y1, x2, y2), or ``None``. + + Derived from the torso anchors only, so raising/extending an arm — which + inflates the YOLO person bbox and drags its centre — leaves this box + untouched. Center Stage crops around it when ``aim_body_mode == "torso"`` + ("Ignore arms"): + + - height = :func:`subject_height_from_pose`: the padded head→ankle body + extent when visible, floored by the 3.3× shoulder→hip heuristic — so the + crop zoom matches the physical auto-zoom's torso-mode subject height; + - centre-x = the shoulder midpoint (the steadiest horizontal anchor); + - centre-y = the body-extent midpoint when the extent drives the height + (covers head AND feet), else the hips (≈ a standing body's mid-height). + + Hips hidden (desk/webcam shots) degrades to the head+shoulders stature + estimate — the box top sits a crown pad above the head point — instead of + ``None``, so the crop stays arm-invariant in the everyday single-camera + case. The width is nominal (the digital crop is sized height-only for a + single person; only the centre-x matters). ``None`` when the shoulders — + or both the head and the hips — are not confidently present: the caller + keeps the raw-bbox behaviour. + """ + height = subject_height_from_pose(kps, min_conf) + if height is None: + return None + shoulders = shoulder_midpoint(kps, min_conf) + hips = hip_midpoint(kps, min_conf) + if shoulders is None: # pragma: no cover — height implies shoulders + return None + cx = shoulders[0] + if hips is not None: + cy = hips[1] + else: + head = head_point(kps, min_conf) + if head is None: # pragma: no cover — hips-less height implies a head + return None + cy = (head[1] - height * _CROWN_PAD_FRAC) + height * 0.5 + extent = _body_extent(kps, min_conf) + if extent is not None and (extent[1] - extent[0]) * _BODY_EXTENT_PAD >= height: + # The real body extent set the height — centre the box on it so the + # head and the feet both stay inside (hips are NOT mid-height then). + cy = (extent[0] + extent[1]) * 0.5 + half_w = height * 0.2 # nominal ~0.4 aspect person + return (cx - half_w, cy - height * 0.5, cx + half_w, cy + height * 0.5) + + +class BoxSmoother: + """Time-aware EMA over a framing box (x1, y1, x2, y2). + + Pose-derived framing boxes arrive as discrete ~0.2 s estimates with + keypoint regression noise; consumed raw they make the framing target STEP + several times a second (visible as jitter, and the PTZ velocity + feed-forward differentiates the steps into jerks). This smooths them into + a continuous signal: ``alpha = 1 - exp(-dt / tau)`` so the smoothing is + frame-rate independent — a call 1 ms after the last barely moves, a call + seconds later lands on the target. ``None`` holds the last box (momentary + pose dropouts don't snap anything). + """ + + def __init__(self, tau: float = 0.35) -> None: + self._tau = max(1e-3, tau) + self._value: tuple[float, float, float, float] | None = None + self._t: float | None = None + + @property + def value(self) -> tuple[float, float, float, float] | None: + return self._value + + def reset(self) -> None: + self._value = None + self._t = None + + def update( + self, + box: tuple[float, float, float, float] | None, + t: float, + ) -> tuple[float, float, float, float] | None: + """Blend *box* (at time *t*, seconds) into the running estimate.""" + if box is None: + return self._value + if self._value is None or self._t is None or t < self._t: + # First sample, or a genuine backward time jump — snap. An + # IDENTICAL t (below) is not this case: two calls can land on the + # same tick under a coarse clock (Windows' default + # ``time.monotonic()`` resolution is ~15.6 ms — common there, all + # but impossible on macOS/Linux's finer-grained clock) and must + # hold instead of snapping onto the new box. + self._value = box + self._t = t + return self._value + dt = t - self._t + alpha = 1.0 - math.exp(-dt / self._tau) + self._value = tuple(v + alpha * (b - v) for v, b in zip(self._value, box, strict=True)) # type: ignore[assignment] + self._t = t + return self._value class AimSmoother: diff --git a/autoptz/engine/pipeline/inference_server.py b/autoptz/engine/pipeline/inference_server.py index 355ee365..6da6b614 100644 --- a/autoptz/engine/pipeline/inference_server.py +++ b/autoptz/engine/pipeline/inference_server.py @@ -86,6 +86,10 @@ def __init__( # an outage. None (tests/no-recovery callers) → gate is always "up". self._server_down = server_down self._seq = 0 # per-request id so a timed-out reply can't desync the next call + # Real execution provider of the SERVER's detector (e.g. "CoreMLExecutionProvider"). + # Arrives tagged on replies once the server's background model load finishes; + # until then the label is the plain "model-server". + self._server_ep = "" # The response queue may be reused across a worker restart; drop any replies # left by a previous (crashed) run so they can't be matched to a fresh request. try: @@ -96,6 +100,11 @@ def __init__( @property def ep(self) -> str: + """Diagnostics label: enriched with the server's REAL provider once known, + so the UI can say "model-server (CoreML)" instead of hiding the engine.""" + if self._server_ep: + short = self._server_ep.replace("ExecutionProvider", "") + return f"model-server ({short})" return "model-server" def detect(self, frame: Any) -> Any: @@ -131,7 +140,16 @@ def detect(self, frame: Any) -> Any: msg = self._resp_q.get(timeout=remaining) except Exception: # noqa: BLE001 — server gone / timed out return [] - rseq, dets = msg if isinstance(msg, tuple) and len(msg) == 2 else (seq, msg) + if isinstance(msg, tuple) and len(msg) == 3: + # New replies carry the server detector's real EP; remember it for + # the ``ep`` label. Empty while the server is still loading its model. + rseq, dets, srv_ep = msg + if srv_ep: + self._server_ep = str(srv_ep) + elif isinstance(msg, tuple) and len(msg) == 2: + rseq, dets = msg + else: + rseq, dets = seq, msg if rseq != seq: continue # stale reply from a prior (timed-out) request — discard if not dets: @@ -156,8 +174,6 @@ class RemotePool: model-server worker to refresh — no new IPC, no protocol change. """ - detector_ep = "model-server" - def __init__( self, client: InferenceClient, @@ -169,6 +185,14 @@ def __init__( self._failed = failed self._build_local_fn = build_local_fn self._local: Any | None = None + self._pose: Any | None = None + self._pose_built = False + + @property + def detector_ep(self) -> str: + """ "model-server", enriched to "model-server (CoreML)" once the server + has reported its detector's real execution provider.""" + return str(getattr(self._client, "ep", "") or "model-server") def detector(self) -> Any: if self._failed is not None and self._failed.is_set(): @@ -181,6 +205,67 @@ def detector(self) -> Any: return self._local return self._client + def pose(self) -> Any | None: + """Local (per-child) pose estimator — only DETECTION is delegated to the + server. Pose runs on the target's crop a few times a second, so a small + per-camera session is cheap; without this the worker's + ``_ensure_pose`` → ``pool.pose()`` raised AttributeError and permanently + disabled pose — every pose-derived behaviour (arm-invariant aim, torso + framing box, skeleton overlay) silently degraded to the raw + arms-inflated detection bbox. Built lazily, cached (including a + ``None`` failure). Never downloads models (children mirror the + local-detector fallback; provisioning belongs to the parent/UI).""" + if self._pose_built: + return self._pose + self._pose_built = True + try: + from autoptz.engine.pipeline.pose import PoseEstimator + + self._pose = PoseEstimator(allow_download=False) + except Exception: # noqa: BLE001 — pose must never break the camera child + log.warning("camera-child pose estimator init failed; bbox aim only.", exc_info=True) + self._pose = None + return self._pose + + # ── release (mirrors InferencePool's release_detector/release_pose) ───────── + # + # Supervisor.release_model_sessions/rebuild_model_sessions call these BY NAME + # (``getattr(pool, method, None)``) so a Manage Models mutation (detector tier + # switch, pose model swap) invalidates any cached session before the on-disk + # cache is mutated/rebuilt. For a model-server camera child this pool is a + # per-PROCESS RemotePool the supervisor can never reach directly — the only + # thing that ever calls these is this same child's own + # ``CameraWorker._release_inference_models``/``_reload_inference_models`` + # (see camera_worker.py), which mirrors the supervisor's generic dispatch for + # whatever pool it was given. Matching InferencePool's method names/semantics + # here is what lets that one generic call site work for both pool types. + + def release_detector(self) -> None: + """Drop the cached LOCAL fallback detector so it rebuilds on next use. + + Detection itself is delegated to the shared model-server + (``self._client``, an IPC handle owned by the supervisor's server + process) — there is no local ORT session here to free in the normal + case. The one piece of local, per-child state this pool DOES cache is + the R-3 degraded-mode fallback built by ``build_local_fn`` once the + supervisor marks the server ``failed`` (see :meth:`detector`). Dropping + it here means a Manage Models mutation while a camera is running in + degraded/local-fallback mode doesn't leave it stuck on a stale local + detector session until the app restarts. + """ + self._local = None + + def release_pose(self) -> None: + """Drop the cached local pose estimator so :meth:`pose` rebuilds it. + + Without this, swapping the pose model in Manage Models never invalidated + a model-server camera child's own cached ``self._pose``/ + ``self._pose_built`` — every camera process kept the stale pose session + until a full app restart. + """ + self._pose = None + self._pose_built = False + def serve( req_q: Any, @@ -189,6 +274,7 @@ def serve( detect_fn: Any, stop_ev: Any, attach: Any = None, + ep_fn: Any = None, ) -> None: """Server loop: drain detection requests, read each camera's latest frame from shm, run ``detect_fn`` once, and reply ``(seq, dets)`` on that camera's response @@ -208,7 +294,12 @@ def serve( def _reply(rq: Any, seq: int, dets: Any) -> None: try: - rq.put((seq, dets)) + if ep_fn is not None: + # Tag the reply with the detector's real EP ("" while still loading) + # so clients can label themselves "model-server (CoreML)". + rq.put((seq, dets, str(ep_fn() or ""))) + else: + rq.put((seq, dets)) except Exception: # noqa: BLE001 — client gone pass @@ -350,9 +441,13 @@ def _detect(frame: Any) -> Any: det = holder["detector"] return det.detect(frame) if det is not None else [] + def _detector_ep() -> str: + det = holder["detector"] + return str(getattr(det, "ep", "") or "") if det is not None else "" + ready_ev.set() # "accepting requests" — detector may still be loading in the background try: - serve(req_q, resp_qs, readers, _detect, stop_ev, attach=_attach) + serve(req_q, resp_qs, readers, _detect, stop_ev, attach=_attach, ep_fn=_detector_ep) finally: for r in readers.values(): # release attached shm views on shutdown try: diff --git a/autoptz/engine/pipeline/ndi_send.py b/autoptz/engine/pipeline/ndi_send.py new file mode 100644 index 00000000..14eb638f --- /dev/null +++ b/autoptz/engine/pipeline/ndi_send.py @@ -0,0 +1,97 @@ +"""NDI output sink — publish the Center Stage feed as a network NDI source. + +Mirrors :class:`autoptz.engine.pipeline.vcam.VirtualCamSink` but sends over the +network instead of a local virtual-camera device, so ANY computer on the LAN can +receive the framed feed with a free NDI receiver (NDI Tools, OBS, vMix, a +monitor). Uses cyndilib's ``Sender`` — the same SDK already shipped for NDI input +(so no new dependency, no license conflict). Import-guarded: cyndilib is absent in +the repo .venv / CI, where this degrades to an unavailable no-op. +""" + +from __future__ import annotations + +import logging + +import numpy as np +from numpy.typing import NDArray + +log = logging.getLogger(__name__) + +try: # import-guard: cyndilib is conda-only, absent in .venv/CI + from cyndilib.sender import Sender as _Sender + from cyndilib.video_frame import VideoSendFrame as _VideoSendFrame + from cyndilib.wrapper import FourCC as _FourCC + + _CYNDILIB_OK = True +except Exception: # noqa: BLE001 — any import failure → feature unavailable + _Sender = None # type: ignore[assignment,misc] + _VideoSendFrame = None # type: ignore[assignment,misc] + _FourCC = None # type: ignore[assignment,misc] + _CYNDILIB_OK = False + + +def ndi_output_available() -> bool: + """True when cyndilib is importable so an NDI output can be created.""" + return _CYNDILIB_OK + + +def bgr_to_rgba_flat(frame: NDArray[np.uint8]) -> NDArray[np.uint8]: + """Convert a contiguous BGR ``(H, W, 3)`` frame to a flat RGBA buffer for NDI.""" + h, w = frame.shape[:2] + rgba = np.empty((h, w, 4), dtype=np.uint8) + rgba[..., 0] = frame[..., 2] + rgba[..., 1] = frame[..., 1] + rgba[..., 2] = frame[..., 0] + rgba[..., 3] = 255 + return np.ascontiguousarray(rgba).ravel() + + +class NDISendSink: + """Send BGR frames as an NDI source named *name*. No-op when cyndilib is absent.""" + + def __init__(self, width: int, height: int, name: str, fps: float = 30.0) -> None: + self.width = int(width) + self.height = int(height) + self.ndi_name = name + self._sender = None + self.available = False + if not _CYNDILIB_OK: + return + try: + self._sender = _Sender(ndi_name=name, clock_video=True) + vf = _VideoSendFrame() + vf.set_resolution(self.width, self.height) + vf.set_fourcc(_FourCC.RGBA) + vf.set_frame_rate(int(round(max(1.0, fps)))) + self._sender.set_video_frame(vf) + self._sender.open() + self.available = True + except Exception: # noqa: BLE001 — NDI runtime missing / name clash, etc. + log.info("NDI output unavailable; Center Stage NDI feed disabled", exc_info=True) + self._sender = None + self.available = False + + def send_bgr(self, frame: NDArray[np.uint8]) -> None: + if self._sender is None: + return + try: + if frame.shape[1] != self.width or frame.shape[0] != self.height: + import cv2 + + from autoptz.engine.pipeline.vcam import pick_interpolation + + interp = pick_interpolation( + (int(frame.shape[1]), int(frame.shape[0])), (self.width, self.height) + ) + frame = cv2.resize(frame, (self.width, self.height), interpolation=interp) + self._sender.write_video(bgr_to_rgba_flat(frame)) + except Exception: # noqa: BLE001 — never let output break the pipeline + log.debug("NDI output send failed", exc_info=True) + + def close(self) -> None: + if self._sender is not None: + try: + self._sender.close() + finally: + self._sender = None + self.available = False diff --git a/autoptz/engine/pipeline/output_sender.py b/autoptz/engine/pipeline/output_sender.py new file mode 100644 index 00000000..4c748937 --- /dev/null +++ b/autoptz/engine/pipeline/output_sender.py @@ -0,0 +1,74 @@ +"""Latest-frame output pump — sends vcam/NDI frames OFF the capture thread. + +The capture loop's only job is keeping frame cadence; the output sinks +(virtual camera, NDI sender) cost real milliseconds per frame (BGR→RGBA +conversion, resize, SDK hand-off), and paying them inline showed up as random +frame drops whenever the machine was busy. The pump owns a single "latest +frame" slot: the capture thread just parks the newest frame (cheap) and the +pump thread converts + sends at its own pace. If the pump is still busy when +the next frame arrives, the older pending frame is REPLACED — outputs always +show the newest frame and the capture thread never waits. +""" + +from __future__ import annotations + +import logging +import threading +from typing import Any + +import numpy as np +from numpy.typing import NDArray + +log = logging.getLogger(__name__) + + +class OutputSender: + """Background sender for the per-camera output sinks (vcam / NDI).""" + + def __init__(self, name: str = "output") -> None: + self._cond = threading.Condition() + self._pending: tuple[NDArray[np.uint8], list[Any]] | None = None + self._stop = False + self._thread = threading.Thread(target=self._run, name=f"{name}-output-sender", daemon=True) + self._thread.start() + + def submit(self, frame: NDArray[np.uint8], sinks: list[Any]) -> None: + """Park *frame* for delivery to *sinks* (each needs ``send_bgr``). + + Drop-oldest: an undelivered previous frame is replaced, never queued — + the slot holds at most one frame so memory and latency stay bounded and + the capture thread returns immediately. + """ + if not sinks: + return + with self._cond: + if self._stop: + return + self._pending = (frame, list(sinks)) + self._cond.notify() + + def close(self, timeout: float = 2.0) -> None: + """Stop the pump thread (idempotent). Sinks are closed by their owner.""" + with self._cond: + self._stop = True + self._pending = None + self._cond.notify_all() + if self._thread.is_alive() and self._thread is not threading.current_thread(): + self._thread.join(timeout=timeout) + + # ── internals ──────────────────────────────────────────────────────────── + + def _run(self) -> None: + while True: + with self._cond: + while self._pending is None and not self._stop: + self._cond.wait() + if self._stop: + return + frame, sinks = self._pending # type: ignore[misc] + self._pending = None + for sink in sinks: + try: + sink.send_bgr(frame) + except Exception: # noqa: BLE001 — output must never kill the pump + log.debug("output sink send failed", exc_info=True) diff --git a/autoptz/engine/process_worker.py b/autoptz/engine/process_worker.py index 9c455534..261ffe94 100644 --- a/autoptz/engine/process_worker.py +++ b/autoptz/engine/process_worker.py @@ -17,8 +17,8 @@ picklable pydantic models, so no hand-rolled framing is needed. - **Models** are not loaded per child in the supported process path. The camera child receives model-server IPC queues and uses a shared detector server; if - those queues are missing, the supervisor falls back to the normal threaded - worker instead of reviving the retired model-per-child mode. + those queues are missing, the supervisor falls back to the built-in threaded + worker. **Identity:** labeled identities converge through the shared SQLite DB (each child opens its own connection). *Unlabeled* auto-harvested faces are propagated live @@ -198,6 +198,11 @@ def _configure_child_logging() -> None: """ import sys + from autoptz.logsetup import suppress_noisy_dependency_warnings + + # Children run face alignment on the hot path; without this every child + # floods stderr with insightface's skimage FutureWarning. + suppress_noisy_dependency_warnings() root = logging.getLogger() root.setLevel(logging.WARNING) if not root.handlers: @@ -295,6 +300,12 @@ def _build_local_detector(_spec: WorkerSpec = spec) -> Any: RemotePool(client, failed=infer_failed_ev, build_local_fn=_build_local_detector) ) worker._infer_shm_writer = _infer_writer # keep the shm alive for the worker's life + # Only DETECTION is delegated to the server — this child still owns its + # face matching, so it needs the DB-backed gallery like any other worker. + # Without it the face stack falls back to an empty in-memory gallery: + # enrolled names never load ("Person N" forever) and the sibling + # ingest_identity relay no-ops (it targets the injected service). + _wire_identity(worker, spec) elif not spec.synthetic: _wire_models_and_identity(worker, spec) @@ -343,6 +354,11 @@ def _wire_models_and_identity(worker: Any, spec: WorkerSpec) -> None: except Exception: # noqa: BLE001 — pool is an optimisation, never load-bearing log.warning("camera process %s: inference pool init failed", spec.camera_id, exc_info=True) + _wire_identity(worker, spec) + + +def _wire_identity(worker: Any, spec: WorkerSpec) -> None: + """Build this child's DB-backed identity gallery and inject it (both modes).""" try: from pathlib import Path @@ -604,6 +620,10 @@ def ingest_identity(self, record: Any) -> None: """Relay an identity harvested in another process into this child's gallery.""" self._send("ingest_identity", (record,)) + def delete_identity(self, identity_id: str) -> None: + """Drop an identity deleted in the UI from this child's gallery.""" + self._send("delete_identity", (identity_id,)) + def enroll_track(self, *args: Any) -> None: self._send("enroll_track", args) @@ -632,13 +652,23 @@ def update_config(self, config: CameraConfig) -> None: def refresh_detector_from_pool(self) -> None: self._send("refresh_detector_from_pool") - def reload_inference_models(self) -> None: - self._send("reload_inference_models") + def reload_inference_models(self, *, include_face: bool = False) -> None: + # include_face must reach the child verbatim: the child rebuilds its OWN + # local face stack (_wire_identity / _build_face_stack) — it is not the + # shared pool's face session, so Supervisor's generic + # `reload(include_face=include_face)` call needs this kwarg accepted here, + # not silently swallowed by the caller's `except TypeError` fallback. + self._send("reload_inference_models", (), {"include_face": bool(include_face)}) - def release_inference_models(self, *, wait: float = 0.0) -> None: + def release_inference_models(self, *, wait: float = 0.0, include_face: bool = False) -> None: # Best-effort across the process boundary (the on-disk model-cache mutation # retry in models.py covers any residual lock); the wait is not honoured. - self._send("release_inference_models", (), {"wait": 0.0}) + # include_face IS honoured — it must reach the child's own + # release_inference_models so a face-pack op drops ITS OWN FaceRecognizer + # session (see reload_inference_models above for why). + self._send( + "release_inference_models", (), {"wait": 0.0, "include_face": bool(include_face)} + ) # ── child → parent event pump ─────────────────────────────────────────────── @@ -680,8 +710,13 @@ def _drain_events(self) -> None: continue -def process_per_camera_enabled() -> bool: - """True only for model-server mode; the model-per-child flag is retired.""" - from autoptz.engine.runtime.flags import env_process_per_camera +def model_server_workers_enabled() -> bool: + """Whether camera workers run as model-server child processes. + + True only when the shared detection server (``AUTOPTZ_MODEL_SERVER``) is on; + each camera process then delegates detection to that one server instead of + loading its own model set. The default threaded path returns False. + """ + from autoptz.engine.runtime.flags import env_model_server - return env_process_per_camera() + return env_model_server() diff --git a/autoptz/engine/ptz/controller.py b/autoptz/engine/ptz/controller.py index 4c07d550..0ab5b233 100644 --- a/autoptz/engine/ptz/controller.py +++ b/autoptz/engine/ptz/controller.py @@ -20,6 +20,8 @@ from enum import Enum, auto from typing import TYPE_CHECKING, Any +from autoptz.engine.framing_target import SUBJECT_HEIGHT_TARGETS + if TYPE_CHECKING: from autoptz.config.models import PTZConfig from autoptz.engine.ptz.base import PTZBackend @@ -45,20 +47,16 @@ # Named framing presets → target subject-height fraction of the frame. # A larger fraction means the subject fills more of the frame (tighter shot). -# face — head fills most of the frame (closeup) -# head_shoulders — classic head-and-shoulders -# upper_body — waist-up (default) -# full_body — whole person in frame -# wide — person small in a wide establishing shot +# The four named shots come from the SHARED composition table (see +# autoptz.engine.framing_target.SUBJECT_HEIGHT_TARGETS) so physical PTZ and the +# Center Stage crop compose identically; "wide" + legacy aliases are +# controller-only extras. _ZOOM_FRAMING_TARGETS = { - "face": 0.80, - "head_shoulders": 0.60, - "upper_body": 0.45, - "full_body": 0.30, + **SUBJECT_HEIGHT_TARGETS, "wide": 0.20, # Legacy names kept so an un-migrated config still resolves sanely. - "tight": 0.60, - "medium": 0.45, + "tight": SUBJECT_HEIGHT_TARGETS["head_shoulders"], + "medium": SUBJECT_HEIGHT_TARGETS["upper_body"], } _DEFAULT_ZOOM_FRAMING_TARGET = 0.45 # == upper_body # Auto-zoom is deliberately slow + stable: it holds the crop across a wide band diff --git a/autoptz/engine/runtime/diagnostics.py b/autoptz/engine/runtime/diagnostics.py index 10a60639..b7fb167f 100644 --- a/autoptz/engine/runtime/diagnostics.py +++ b/autoptz/engine/runtime/diagnostics.py @@ -135,7 +135,7 @@ def face_status() -> dict[str, str]: "Face recognition", "warn", f"insightface installed but model {model!r} was not found at {model_dir} " - "· run tools.fetch_models or use a build with the bundled face pack", + "· download it in Manage Models (or run tools.fetch_models for offline setup)", ) return _entry("face", "Face recognition", "ok", f"insightface SCRFD + ArcFace · {model_dir}") @@ -208,8 +208,9 @@ def optional_components() -> list[dict[str, str]]: "path": str(cache / "reid"), "why": "Stable re-acquire after occlusion or crowds.", "managed": ( - "Managed by boxmot/torch upstream caches; AutoPTZ never deletes the " - "files but unloads ReID from memory when its feature is off." + "Optional install — the OSNet weights are downloaded and cached by the " + "upstream boxmot/torch packages, so AutoPTZ never deletes them; it only " + "unloads ReID from memory when its feature is off." ), "network": "May contact package/model hosts when prepared.", } @@ -243,10 +244,9 @@ def optional_components() -> list[dict[str, str]]: "path": face_path, "why": "Named-person confirmation and face identity matching.", "managed": ( - "Loaded from INSIGHTFACE_HOME, the bundled AutoPTZ model pack, the " - "AutoPTZ model cache, or the upstream insightface cache. AutoPTZ " - "never deletes these files but unloads face recognition from memory " - "when its feature is off." + "Download or remove the pack in Manage Models when it lives in the " + "AutoPTZ app-data cache; packs bundled inside the app, set via " + "INSIGHTFACE_HOME, or in ~/.insightface are never deleted." ), "network": "tools.fetch_models can prefetch the pack for offline installers.", } diff --git a/autoptz/engine/runtime/experimental_flags.py b/autoptz/engine/runtime/experimental_flags.py index ccc6f09b..c1680ddd 100644 --- a/autoptz/engine/runtime/experimental_flags.py +++ b/autoptz/engine/runtime/experimental_flags.py @@ -1,10 +1,16 @@ -"""Curated dev/benchmark AUTOPTZ_* flags + TrackingConfig defaults. +"""Curated AUTOPTZ_* flags surfaced in the Experimental Features dialog. -Single source of truth for env values that may be persisted by dev tools and +Single source of truth for env values that may be persisted from the dialog and then published by :func:`autoptz.engine.supervisor.Supervisor._apply_experimental_env` -before workers spawn. The normal product UI does not expose this inventory; a -flag listed here remains a dev/benchmark control until release artifacts justify -promoting it to an automatic default or deleting it. +before workers spawn. Flags are grouped by ``section`` (Experiments, Devices & +tuning, Model overrides, Diagnostics) and read at engine start, so a restart +applies changes. + +Deliberately NOT listed here (they stay env-only, documented in docs/flags.md): +the supervisor-managed hardware vars (``AUTOPTZ_FORCE_EP`` / ``AUTOPTZ_PRECISION`` +/ ``AUTOPTZ_ORT_INTRA_THREADS`` / ``AUTOPTZ_CV2_THREADS`` — a hardware-prefs path +already writes them, so a second writer here would drift), plus launch-only +(``AUTOPTZ_SKIP_CAMERA_PREFLIGHT``), test-harness, and logging-cosmetic vars. Each ``default`` is the value that means "engine default" — when the persisted selection equals it, the supervisor leaves the env var UNSET so the existing @@ -19,15 +25,21 @@ @dataclass(frozen=True) class ExperimentalFlag: - """One managed experimental env flag for dev/benchmark tools.""" + """One managed experimental env flag for dev/benchmark tools. + + ``section`` groups the flag under a heading in the dialog. ``kind`` selects + the editor: ``bool`` → checkbox, ``choice`` → dropdown (from ``choices``), + ``text`` → free-text line edit, ``path`` → line edit with a Browse button. + """ env_key: str label: str description: str default: str - kind: Literal["bool", "choice"] + kind: Literal["bool", "choice", "text", "path"] choices: tuple[str, ...] restart_required: bool + section: str = "Experiments" # Ordered for display. ``default`` strings mirror the real in-code fallbacks @@ -97,6 +109,7 @@ class ExperimentalFlag: kind="choice", choices=("", "cpu", "mps", "cuda"), restart_required=True, + section="Devices & tuning", ), ExperimentalFlag( env_key="AUTOPTZ_COREML_UNITS", @@ -111,6 +124,7 @@ class ExperimentalFlag: kind="choice", choices=("", "ALL", "CPUAndGPU", "CPUOnly", "CPUAndNeuralEngine"), restart_required=True, + section="Devices & tuning", ), ExperimentalFlag( env_key="AUTOPTZ_TRUE_LATENCY_LEAD", @@ -138,6 +152,7 @@ class ExperimentalFlag: kind="choice", choices=("fastest", "bgra"), restart_required=True, + section="Devices & tuning", ), ExperimentalFlag( env_key="AUTOPTZ_MODEL_SERVER", @@ -157,38 +172,71 @@ class ExperimentalFlag: choices=(), restart_required=True, ), -) - - -# The 4 experimental TrackingConfig bool fields, surfaced as app-level defaults -# applied to NEWLY added cameras. Defaults MUST mirror -# ``autoptz.config.models.TrackingConfig`` exactly. -TRACKING_DEFAULT_FIELDS: tuple[tuple[str, str, str, bool], ...] = ( - ( - "unified_pose", - "Unified pose (new cameras)", - "Default new cameras to the unified one-backbone pose detector.", - False, + # ── Model overrides ────────────────────────────────────────────────────── + ExperimentalFlag( + env_key="AUTOPTZ_MODEL_PATH", + label="Detector model file", + description=( + "Use a specific detector ONNX file instead of the managed, tier-based " + "download. Leave empty to use the model chosen in Manage Models." + ), + default="", + kind="path", + choices=(), + restart_required=True, + section="Model overrides", ), - ( - "use_target_associator", - "Fused target associator (new cameras)", - "Default new cameras to the fused keep/switch associator (motion + " - "appearance + identity + pose) instead of the heuristic path.", - False, + ExperimentalFlag( + env_key="AUTOPTZ_POSE_MODEL_PATH", + label="Pose model file", + description=( + "Use a specific pose ONNX file instead of the managed download. Leave " + "empty to use the bundled/downloaded pose model." + ), + default="", + kind="path", + choices=(), + restart_required=True, + section="Model overrides", + ), + ExperimentalFlag( + env_key="AUTOPTZ_MODEL_URL", + label="Model download base URL", + description=( + "Override the base URL prebuilt model ONNX files are downloaded from. " + "Leave empty to use the built-in release URL." + ), + default="", + kind="text", + choices=(), + restart_required=True, + section="Model overrides", ), - ( - "stage_spread", - "Stage-spread inference (new cameras)", - "Never run the detector and pose pass on the same frame, so heavy ticks " - "don't stack into one slow frame. On by default.", - True, + # ── Diagnostics ────────────────────────────────────────────────────────── + ExperimentalFlag( + env_key="AUTOPTZ_MS_DIAG", + label="Shared-server diagnostics logging", + description=( + "Emit periodic shared-detection-server health logs (queue depth, " + "round-trip timing) to help diagnose model-server issues. Off by default." + ), + default="0", + kind="bool", + choices=(), + restart_required=True, + section="Diagnostics", ), - ( - "group_framing", - "Group framing (new cameras)", - "When several people are present with no locked target, frame the whole " - "group instead of one subject (Center Stage digital crop only).", - False, + ExperimentalFlag( + env_key="AUTOPTZ_SYNTH_DEBUG", + label="Synthetic source debug logging", + description=( + "Emit extra logging from the synthetic/test frame source. Only useful " + "when running against synthetic input; off by default." + ), + default="0", + kind="bool", + choices=(), + restart_required=True, + section="Diagnostics", ), ) diff --git a/autoptz/engine/runtime/flags.py b/autoptz/engine/runtime/flags.py index 3522987c..146d8c91 100644 --- a/autoptz/engine/runtime/flags.py +++ b/autoptz/engine/runtime/flags.py @@ -17,25 +17,14 @@ def _env_true(name: str) -> bool: return os.environ.get(name, "").strip().lower() in _TRUE_VALUES -def env_process_per_camera() -> bool: - """Whether camera workers should cross a process boundary. - - Lives here (not just in ``process_worker``) so lightweight callers like the - inference layer can branch on it without importing the heavy worker module. - The standalone ``AUTOPTZ_PROCESS_PER_CAMERA`` model-per-child experiment is - retired and intentionally ignored. The only remaining process-worker path is - model-server mode, where each camera process delegates detection to one shared - detector server instead of loading its own model set. - """ - return env_model_server() - - def env_model_server() -> bool: """Opt-in for the multi-process model-server architecture candidate. Each camera runs in its own process (escaping the GIL) and delegates detection to ONE shared model-server process (one model set → no per-process RAM cliff). - This is not a product feature; it stays env-only until Mark artifacts prove the + This is the only path that crosses a process boundary; lightweight callers + (the inference layer, the supervisor's worker factory) branch on it too. It + is not a product feature — it stays env-only until Mark artifacts prove the 6/8-camera gates, CPU/RAM stability, and clean shutdown behavior. """ return _env_true("AUTOPTZ_MODEL_SERVER") @@ -95,8 +84,8 @@ def apply_thread_caps(budget: int) -> None: **Two paths, two mechanisms:** - * **Process-per-camera (future)** — the child process inherits the env - before any library is imported, so all four env vars take full effect. + * **Model-server child processes** — the child inherits the env before any + library is imported, so all four env vars take full effect. * **In-process threaded path (current default)** — OMP/BLAS/MKL/NumExpr env vars only bind *before the library's first import*; by the time this runs those libraries may already be loaded and their thread pools already diff --git a/autoptz/engine/runtime/inference.py b/autoptz/engine/runtime/inference.py index b1fc78b9..6f76ec60 100644 --- a/autoptz/engine/runtime/inference.py +++ b/autoptz/engine/runtime/inference.py @@ -276,9 +276,9 @@ def _provider_options(ep: EP, prefs: HardwarePrefs | None) -> dict[str, object]: # from path"). Omit the on-disk cache in that process mode: the child # still runs on the ANE/GPU, it just recompiles its MLProgram each start. # The shared in-process path keeps the cache. - from autoptz.engine.runtime.flags import env_process_per_camera + from autoptz.engine.runtime.flags import env_model_server - if not env_process_per_camera(): + if not env_model_server(): coreml_opts["ModelCacheDirectory"] = _coreml_cache_dir() return coreml_opts if ep is EP.TENSORRT: diff --git a/autoptz/engine/runtime/models.py b/autoptz/engine/runtime/models.py index 943c9cb8..069c580d 100644 --- a/autoptz/engine/runtime/models.py +++ b/autoptz/engine/runtime/models.py @@ -148,6 +148,23 @@ def detector_model_for_tier(tier: str | None) -> str: # the same release/directory as the ``.onnx`` files themselves. _CHECKSUM_MANIFEST_NAME = "SHA256SUMS" +# The insightface face pack is fetched by insightface's OWN downloader (see +# ``ensure_face_pack`` below) — unlike ``_download_prebuilt``, AutoPTZ does not +# control the bytes on the wire, so there is no ``.part`` + SHA-256 + atomic- +# rename hook available on the way IN. ``_face_pack_onnx`` instead strengthens +# the POST-hoc completeness check: for the packs AutoPTZ ships/documents, only +# two of the five ``.onnx`` files a full pack contains are ever loaded — +# ``FaceRecognizer`` restricts insightface to +# ``allowed_modules=["detection", "recognition"]`` (see identify.py) — named +# with these prefixes. Requiring both, not just "any 2 files", means a +# partial/interrupted extract that left only the (unused) auxiliary landmark/ +# attribute files behind reads as incomplete rather than a false "present". An +# unrecognized/custom ``AUTOPTZ_FACE_MODEL`` pack keeps the plain "≥2 files" +# heuristic (its naming convention is unknown), so a legitimate custom override +# is never wrongly reported as broken. +_KNOWN_FACE_PACKS = frozenset({"buffalo_l", "buffalo_s"}) +_FACE_PACK_REQUIRED_PREFIXES: tuple[str, ...] = ("det_", "w600k_") + # Export knobs that match what detect.py expects (see module docstring). _EXPORT_KWARGS = {"format": "onnx", "nms": False, "dynamic": False, "opset": 12} _DISABLE_EXPORT_ENV = "AUTOPTZ_NO_MODEL_EXPORT" @@ -670,6 +687,192 @@ def _managed_files_for_stem(self, stem: str) -> list[Path]: out.append(path) return out + # ── face pack (insightface) ───────────────────────────────────────────────── + + def _face_model_name(self) -> str: + return os.environ.get("AUTOPTZ_FACE_MODEL", "buffalo_l") + + def _face_appdata_dir(self) -> Path: + """The app-data insightface model dir this manager owns (download/remove).""" + return self._cache_dir / "insightface" / "models" / self._face_model_name() + + @staticmethod + def _face_pack_onnx(pack: Path, model: str = "") -> list[Path]: + """The pack's ``.onnx`` files, or [] unless the pack looks *complete + enough* for AutoPTZ's own use. + + Both checks are necessarily POST-hoc — see the module-level comment + above :data:`_KNOWN_FACE_PACKS` for why there is no pre-download hook: + + 1. At least two ``.onnx`` files overall (a lone leftover file from an + interrupted download must not read as a usable pack). + 2. For a *known* pack name, at least one file matching each of + :data:`_FACE_PACK_REQUIRED_PREFIXES` — the detection/recognition + sub-models this app actually loads — so a partial extract that left + only the (unused) auxiliary files behind is correctly reported + incomplete instead of a false "present". + """ + try: + onnx = sorted(pack.glob("*.onnx")) if pack.is_dir() else [] + except OSError: + return [] + if len(onnx) < 2: + return [] + if model in _KNOWN_FACE_PACKS: + names = [p.name.lower() for p in onnx] + has_required = all( + any(name.startswith(prefix) for name in names) + for prefix in _FACE_PACK_REQUIRED_PREFIXES + ) + if not has_required: + return [] + return onnx + + def _face_status_at(self, location: str, root: Path, model: str) -> dict[str, Any]: + pack = root / "models" / model + onnx = self._face_pack_onnx(pack, model) + return { + "model": model, + "location": location, + "path": str(pack), + "present": bool(onnx), + # Removable from the caches the user owns (the AutoPTZ app-data cache + # and their own ~/.insightface). A pack baked into the app bundle or + # pinned via INSIGHTFACE_HOME is left alone. + "removable": bool(onnx) and location in ("app-data", "home"), + "size_bytes": sum(p.stat().st_size for p in onnx if p.is_file()), + } + + def face_pack_status(self) -> dict[str, Any]: + """Where the insightface face pack resolves, and whether we can remove it. + + Classified in the same priority as + :func:`autoptz.engine.pipeline.identify.insightface_root` — ``INSIGHTFACE_HOME`` + → bundled-in-app → app-data cache → ``~/.insightface`` — so the reported + location matches what the engine actually loads. ``INSIGHTFACE_HOME`` is + **terminal** (the engine returns it unconditionally): when it is set we + report its state directly and never fall through to a lower-priority pack, + so an empty override is honestly reported as not-present rather than masked + by a shadowed pack. ``removable`` is True only for the app-data cache (the + copy this manager owns); a bundled, custom, or home pack is never deleted. + """ + model = self._face_model_name() + env = os.environ.get("INSIGHTFACE_HOME") + if env: + return self._face_status_at("custom", Path(env), model) + for location, root in ( + ("bundled", bundled_models_dir() / "insightface"), + ("app-data", self._cache_dir / "insightface"), + ("home", Path.home() / ".insightface"), + ): + status = self._face_status_at(location, root, model) + if status["present"]: + return status + return { + "model": model, + "location": "missing", + "path": str(self._face_appdata_dir()), + "present": False, + "removable": False, + "size_bytes": 0, + } + + def ensure_face_pack(self) -> list[dict[str, str]]: + """Download the insightface face pack into the app-data cache. + + Delegates to :func:`autoptz.engine.pipeline.identify.ensure_face_model` + (which triggers insightface's own download) with the app-data root, so the + pack lands where :meth:`remove_face_pack` and the packaging step expect it. + + Unlike :meth:`_download_prebuilt` (AutoPTZ's own ``.part`` + SHA-256 + + atomic-rename path for the detector), there is no pre-download hook here: + ``ensure_face_model`` hands the entire fetch — download, zip extraction, + final placement — to insightface's ``FaceAnalysis.prepare()``, so AutoPTZ + never sees the raw bytes or an intermediate temp file to verify/move + atomically, and there is no AutoPTZ-published checksum manifest for a + third-party model zoo asset we don't host. Reimplementing that fetch + ourselves (to regain a pre-verification hook) would mean tracking + insightface's model-zoo URLs/versions independently — a maintenance + liability out of proportion to the risk. :meth:`_face_pack_onnx` + instead strengthens the POST-hoc completeness check (see its docstring + and the module comment above :data:`_KNOWN_FACE_PACKS`) so an + interrupted/partial extract is still caught, just after the fact. + """ + from autoptz.engine.pipeline.identify import ensure_face_model # noqa: PLC0415 + + model = self._face_model_name() + root = str(self._cache_dir / "insightface") + with self._lock: + err = ensure_face_model(root=root, model_name=model) + if err: + return [ + { + "name": f"Face pack ({model})", + "state": "failed", + "path": root, + "size": "", + "error": err, + } + ] + status = self.face_pack_status() + return [ + { + "name": f"Face pack ({model})", + "state": "downloaded", + "path": status["path"], + "size": str(status["size_bytes"]), + "error": "", + } + ] + + def remove_face_pack(self) -> list[dict[str, str]]: + """Delete the face pack from the cache the user owns (app-data or home). + + Deletes only when :meth:`face_pack_status` reports the pack as ``removable`` + (``location`` is ``app-data`` or ``home``) and uses that resolved path — so a + remove can never touch a pack baked into the app bundle or pinned via + ``INSIGHTFACE_HOME``. + """ + removed: list[dict[str, str]] = [] + status = self.face_pack_status() + if not status.get("removable"): + return removed + pack = Path(status["path"]) + with self._lock: + if not pack.is_dir(): + return removed + for path in sorted(pack.glob("*")): + if not path.is_file(): + continue + try: + size = path.stat().st_size + _unlink_with_retry(path) + removed.append( + { + "name": path.name, + "state": "removed", + "path": str(path), + "size": str(size), + "error": "", + } + ) + except Exception as exc: # noqa: BLE001 + removed.append( + { + "name": path.name, + "state": "failed", + "path": str(path), + "size": "", + "error": str(exc), + } + ) + log.warning("could not remove face pack file %s", path, exc_info=True) + try: # best-effort: drop the now-empty model dir + pack.rmdir() + except OSError: + pass + return removed + # ── internals ───────────────────────────────────────────────────────────── def _prebuilt_url_for(self, stem: str) -> str: diff --git a/autoptz/engine/supervisor.py b/autoptz/engine/supervisor.py index 2ac7ccb4..effc06ba 100644 --- a/autoptz/engine/supervisor.py +++ b/autoptz/engine/supervisor.py @@ -17,9 +17,9 @@ Threading --------- Each camera runs on its own threads (capture + inference) in this process. -The retired model-per-child process mode is not a product path. The only retained -process boundary is the explicit model-server candidate, where camera children -use shm/msgpack transport and delegate detector inference to one shared server. +The only path that crosses a process boundary is the shared detection server +candidate, where camera children use shm/msgpack transport and delegate detector +inference to one shared server. The command pump can either be driven externally (``tick()`` from a GUI-thread ``QTimer`` — the default the UI uses) or by an internal daemon thread @@ -75,6 +75,11 @@ # treated as failed. Polled across ticks (never awaited) so a slow spawn cannot # block the GUI thread — see Supervisor._scan_model_server_health. _MS_SPAWN_TIMEOUT_S = 30.0 +# How long a late-camera attach waits for the OLD server to drain out on the stop +# event before escalating to terminate(). Typical exit is ~0.2s (the serve loop's +# get() timeout); the window is generous because terminate() on a queue consumer +# poisons the shared request queue (see _attach_camera_to_model_server). +_MS_DRAIN_TIMEOUT_S = 3.0 # A worker can be ALIVE yet HUNG (capture/inference threads stuck, no telemetry). # Treat it as unhealthy and respawn it through the same backoff path once its # telemetry is older than this. Deliberately above the 2.0 s inference-stall @@ -188,6 +193,11 @@ def __init__( # respawn is currently in flight. self._ms_ready_ev: Any | None = None self._ms_spawn_deadline: float | None = None + # Non-blocking DRAIN bookkeeping for a late-camera attach's old-server + # drain (see _attach_camera_to_model_server / _poll_model_server_drain). + # Both None when no drain is in flight. + self._ms_drain_proc: Any | None = None + self._ms_drain_deadline: float | None = None # Global ML-subsystem switches (detection / tracking / face_recognition / # pose), broadcast via SetFeaturesCmd and applied to every worker. @@ -338,6 +348,8 @@ def stop(self) -> None: self._model_server_camera_ids = [] self._ms_ready_ev = None self._ms_spawn_deadline = None + self._ms_drain_proc = None + self._ms_drain_deadline = None if proc is not None: try: if stop_ev is not None: @@ -593,7 +605,7 @@ def _worker_inference_dead(self, cid: str, worker: Any, now: float) -> bool: surface reports. Only in-process (threaded) ``CameraWorker`` instances expose the - inference-thread health surface; process-per-camera handles + inference-thread health surface; model-server process handles (``_is_process_worker``) are monitored by their own liveness path (``is_alive()`` on the child process) and are never subject to this predicate. @@ -729,7 +741,9 @@ def _scan_model_server_health(self, now: float) -> None: ``tick()`` (and therefore this scan) runs on the GUI thread in the shipped app, so a respawn attempt must never block it: starting the child and - waiting for its ready-handshake are split across ticks. ``_ms_spawn_deadline`` + waiting for its ready-handshake are split across ticks, and so is draining + the OLD process out before a late-camera attach's restart (see + :meth:`_poll_model_server_drain`, dispatched first). ``_ms_spawn_deadline`` is None when no respawn is in flight; while it is set, this method only POLLS the ready Event (never ``.wait()`` with a timeout) and otherwise no-ops until the deadline passes. ``_model_server_down`` stays set for the @@ -750,6 +764,10 @@ def _scan_model_server_health(self, now: float) -> None: if not env_model_server() or self._model_server_proc is None: return + if self._ms_drain_proc is not None: + self._poll_model_server_drain(now) + return + if self._ms_spawn_deadline is not None: self._poll_model_server_spawn(now) return @@ -779,8 +797,8 @@ def _scan_model_server_health(self, now: float) -> None: if new_attempts >= _MAX_RESTART_ATTEMPTS: self._ms_restart_state = (new_attempts, now + backoff, True) log.error( - "model-server permanently failed: %d auto-restart attempts exhausted " - "— falling back to local per-camera detectors.", + "shared detection server permanently failed: %d auto-restart attempts " + "exhausted — cameras continue with their own built-in detectors.", new_attempts, ) if self._model_server_failed_ev is not None: @@ -812,6 +830,16 @@ def _poll_model_server_spawn(self, now: float) -> None: self._ms_restart_state = (0, 0.0, False) if self._model_server_down is not None: self._model_server_down.clear() + if self._model_server_failed_ev is not None and self._model_server_failed_ev.is_set(): + # A PRIOR budget exhaustion had latched every RemotePool onto its + # local-fallback detector (see RemotePool.detector()); this fresh + # server is confirmed healthy (ready-handshake just arrived), so + # clear the flag or every camera — including one added after this + # very respawn, via _attach_camera_to_model_server — would stay on + # local fallback forever despite a working shared server. + self._model_server_failed_ev.clear() + self._refresh_model_server_workers() + log.info("shared detection server recovered — resuming for all cameras.") log.info("model-server respawned successfully — resuming shared detection.") return @@ -842,8 +870,8 @@ def _poll_model_server_spawn(self, now: float) -> None: if attempts >= _MAX_RESTART_ATTEMPTS: self._ms_restart_state = (attempts, now + backoff, True) log.error( - "model-server permanently failed: %d auto-restart attempts exhausted " - "— falling back to local per-camera detectors.", + "shared detection server permanently failed: %d auto-restart attempts " + "exhausted — cameras continue with their own built-in detectors.", attempts, ) if self._model_server_failed_ev is not None: @@ -893,6 +921,124 @@ def _respawn_model_server(self, now: float) -> None: # routes to fallback (budget exhausted) — see # _scan_model_server_health / _poll_model_server_spawn. + def _attach_camera_to_model_server(self, camera_id: str) -> Any | None: + """Give a camera added AFTER the shared server started its own IPC slot. + + The server's response-queue set is fixed at spawn (an ``mp.Queue`` can only + cross the process boundary via spawn args), so a late camera — including a + remove + re-add, which mints a NEW camera id — gets a slot by restarting + the server process with the updated queue dict. This reuses the R-3 respawn + machinery: the down-gate holds while the fresh server boots, every existing + camera's ``InferenceClient`` fast-fails (serving empty) instead of blocking, + and predictive coast keeps their tracking smooth through the ~2s swap. + + This method itself never blocks: it registers the new queue/slot and the + graceful stop signal (all cheap), then hands the OLD process off to + :meth:`_poll_model_server_drain` — ticked from :meth:`_scan_model_server_health` + — instead of joining here. ``_on_add_camera`` (this method's only caller) runs + on the GUI-thread ``tick()``, so a slow-to-exit old server must not stall it; + the down-gate already set below means every camera (including the new one) + fast-fails on detection until the drain+respawn actually completes a beat + later, exactly like the existing crash-respawn window. + + Returns the new response queue, or ``None`` when there is no live server to + attach to (the caller then falls back to the threaded pipeline, as before). + """ + if self._infer_req_q is None or self._model_server_proc is None: + return None + try: + import multiprocessing as mp + + ctx = mp.get_context("spawn") + q = ctx.Queue() + self._infer_resp_qs[camera_id] = q + if camera_id not in self._model_server_camera_ids: + self._model_server_camera_ids.append(camera_id) + log.info( + "camera %s added after the shared detection server started — " + "restarting the server with an updated slot set (other cameras " + "coast briefly).", + camera_id, + ) + if self._model_server_down is not None: + self._model_server_down.set() + old_proc = self._model_server_proc + stop_ev = self._model_server_stop + if stop_ev is not None: + try: + stop_ev.set() # graceful: serve() drains out on its own + except Exception: # noqa: BLE001 + log.debug("model-server stop event set failed", exc_info=True) + # Drain, do NOT kill, on THIS call: the old server is parked in + # ``req_q.get()``, and terminating a process inside a + # multiprocessing.Queue read poisons the SHARED request queue (the dead + # reader holds the queue's reader lock / leaves a partial length-prefixed + # message in the pipe) — the respawned server then never receives a + # single request and every camera loses detection until the app + # restarts. The serve loop polls the stop event every ≤0.2s, so it + # typically exits in ~a quarter second; terminate() stays only as the + # escalation _poll_model_server_drain applies for a truly wedged + # process. Handing the deadline off (instead of joining here) is what + # keeps this call non-blocking. + self._ms_drain_proc = old_proc + self._ms_drain_deadline = time.monotonic() + _MS_DRAIN_TIMEOUT_S + except Exception: # noqa: BLE001 — attach is best-effort; threaded fallback below + log.warning( + "late model-server attach for camera %s failed — falling back to " + "the threaded pipeline.", + camera_id, + exc_info=True, + ) + self._infer_resp_qs.pop(camera_id, None) + return None + return q + + def _poll_model_server_drain(self, now: float) -> None: + """Non-blocking continuation of an in-flight old-server drain. + + Dispatched from :meth:`_scan_model_server_health` on every health-scan tick + while ``_ms_drain_proc`` is set (a late-camera attach is waiting for the OLD + server to exit before :meth:`_respawn_model_server` starts the new one). + Only ever polls ``is_alive()`` — never ``.join()`` with a nonzero timeout — + so a slow-to-exit old server cannot stall the GUI thread's ``tick()``. + """ + proc = self._ms_drain_proc + if proc is None: + return + deadline = self._ms_drain_deadline + assert deadline is not None # guarded by caller + + try: + alive = bool(proc.is_alive()) + except Exception: # noqa: BLE001 + alive = False + if not alive: + self._ms_drain_proc = None + self._ms_drain_deadline = None + self._respawn_model_server(now) + return + + if now < deadline: + return # still draining; check again next tick + + log.warning( + "old model-server did not drain within %.1fs — terminating; the " + "shared request queue may be poisoned (cameras may need an " + "engine restart to regain detection).", + _MS_DRAIN_TIMEOUT_S, + ) + try: + proc.terminate() + except Exception: # noqa: BLE001 + log.debug("old model-server terminate failed", exc_info=True) + try: + proc.join(timeout=0.5) + except Exception: # noqa: BLE001 + log.debug("old model-server join failed", exc_info=True) + self._ms_drain_proc = None + self._ms_drain_deadline = None + self._respawn_model_server(now) + def _refresh_model_server_workers(self) -> None: """Ask every model-server-mode camera worker to re-pull its detector from the pool — the seam RemotePool uses to swap in a local detector once the @@ -941,9 +1087,13 @@ def _route(self, cmd: Any) -> None: self._on_update_config(cmd) elif kind == CmdKind.ENROLL_IDENTITY: self._on_enroll_identity(cmd) - # Remaining commands (named-preset, identities, layouts) are UI/store - # concerns with no per-worker effect yet; they are intentionally ignored - # here so the pump never errors on them. + elif kind == CmdKind.RENAME_IDENTITY: + self._on_rename_identity(cmd) + elif kind == CmdKind.DELETE_IDENTITY: + self._on_delete_identity(cmd) + # Remaining commands (named-preset, layouts) are UI/store concerns with + # no per-worker effect yet; they are intentionally ignored here so the + # pump never errors on them. def _on_add_camera(self, cmd: AddCameraCmd) -> None: # _spawn_worker dedupes + registers under the lock and starts the worker @@ -959,6 +1109,15 @@ def _on_remove_camera(self, cmd: RemoveCameraCmd) -> None: self._restart_state.pop(cid, None) self._last_telemetry_t.pop(cid, None) self._spawn_t.pop(cid, None) + # Drop the camera's model-server slot: each leaked mp.Queue holds pipe fds + # + a feeder thread, and the next server respawn would re-pickle queues for + # cameras that no longer exist. (The running server keeps its own copy of + # the dict and safely ignores requests for unknown cameras.) + self._infer_resp_qs.pop(cid, None) + try: + self._model_server_camera_ids.remove(cid) + except ValueError: + pass if worker is not None: try: worker.stop() @@ -1006,6 +1165,59 @@ def _on_enroll_identity(self, cmd: EnrollIdentityCmd) -> None: getattr(cmd, "click_y", None), ) + def _on_rename_identity(self, cmd: Any) -> None: + """Apply a rename/label/touch to the shared gallery, then relay the + updated record to every process-mode child. + + The UI already mutated the shared (parent) gallery, so the rename here is + an idempotent re-apply; the point of the handler is the relay — each + process child holds its OWN gallery and would otherwise only learn about + face-DB edits on restart. ``merge`` and enable/disable arrive as a + rename-to-the-current-name "touch": the relayed record carries the merged + embeddings / new enabled state. + """ + if not cmd.identity_id: + return + service = self._ensure_identity_service() + if service is None: + return + try: + service.rename(cmd.identity_id, cmd.new_name) + except Exception: # noqa: BLE001 + log.debug("identity rename in shared gallery failed", exc_info=True) + try: + record = service.get(cmd.identity_id) + except Exception: # noqa: BLE001 + record = None + if record is not None: + # source_cid="" → no worker matches, so this relays to ALL process + # children (a UI edit has no source camera). + self._relay_identity_to_siblings("", record) + + def _on_delete_identity(self, cmd: Any) -> None: + """Delete from the shared gallery, then tell every process-mode child. + + Without the broadcast a process child keeps matching (and following) an + identity the user deleted until its next restart. + """ + if not cmd.identity_id: + return + service = self._ensure_identity_service() + if service is not None: + try: + service.delete(cmd.identity_id) + except Exception: # noqa: BLE001 + log.debug("identity delete in shared gallery failed", exc_info=True) + with self._lock: + workers = [w for w in self._workers.values() if getattr(w, "_is_process_worker", False)] + for worker in workers: + drop = getattr(worker, "delete_identity", None) + if callable(drop): + try: + drop(cmd.identity_id) + except Exception: # noqa: BLE001 + log.debug("identity delete relay failed", exc_info=True) + def _on_ptz_nudge(self, cmd: PtzNudgeCmd) -> None: worker = self._get(cmd.camera_id) if worker is not None: @@ -1081,8 +1293,8 @@ def turned_off(key: str) -> bool: except Exception: # noqa: BLE001 log.debug("pool %s failed", method, exc_info=True) - def release_model_sessions(self) -> None: - """Free every detector/pose ORT session (pool + workers) before a cache mutation. + def release_model_sessions(self, *, include_face: bool = False) -> None: + """Free detector/pose ORT sessions (pool + workers) before a cache mutation. Windows refuses to delete/replace a model file while onnxruntime still has it open, so the UI calls this *before* downloading/removing files: the pool @@ -1090,12 +1302,19 @@ def release_model_sessions(self) -> None: then a ``gc.collect()`` finalises the sessions so the OS handles are gone. POSIX tolerates unlink-while-open, which is why the old (release-after) order only failed on Windows. + + ``include_face`` additionally releases the shared insightface face session — + set ONLY for a face-pack download/remove. Left False for detector/pose ops + so an unrelated cache change never drops and reloads the ~1.3 GB face pack. """ if not self._running: return pool = self._inference_pool if pool is not None: - for method in ("release_detector", "release_pose"): + methods = ["release_detector", "release_pose"] + if include_face: + methods.append("release_face") + for method in methods: fn = getattr(pool, method, None) if callable(fn): try: @@ -1110,18 +1329,21 @@ def release_model_sessions(self) -> None: try: # Block briefly so the inference thread actually drops its refs # before we GC + mutate; the file-op retry covers any residual. - release(wait=1.0) + release(wait=1.0, include_face=include_face) + except TypeError: + release(wait=1.0) # older/test workers without include_face except Exception: # noqa: BLE001 log.debug("worker model release failed", exc_info=True) import gc gc.collect() - def rebuild_model_sessions(self) -> None: + def rebuild_model_sessions(self, *, include_face: bool = False) -> None: """Rebuild detector/pose from the (now-refreshed) cache after a mutation. Each worker force-reloads from the shared pool — yielding the new model, or - live-preview-only when the files were removed. Pairs with + live-preview-only when the files were removed. ``include_face`` also + rebuilds the face stack (only for a face-pack op). Pairs with :meth:`release_model_sessions`. """ if not self._running: @@ -1132,7 +1354,9 @@ def rebuild_model_sessions(self) -> None: reload = getattr(worker, "reload_inference_models", None) if callable(reload): try: - reload() + reload(include_face=include_face) + except TypeError: + reload() # older/test workers without include_face except Exception: # noqa: BLE001 log.debug("worker model reload failed", exc_info=True) @@ -1350,12 +1574,12 @@ def _apply_experimental_env(self) -> None: Read once at engine start (from inside :meth:`_apply_hardware_env`, before any worker spawns) so the flags take effect on the next engine run. Only - keys persisted in the ``experimental_features`` dict by legacy UI/dev tools - are managed: for each such env-flag key, set the env var when the saved - value differs from the flag's engine default, or pop it (clearing a stale - value from a prior selection) when it equals the default. Keys in the - saved dict that are not env flags (the per-camera ``TrackingConfig`` - defaults) are ignored here — they are consumed when a NEW camera is added. + keys registered in ``EXPERIMENTAL_FLAGS`` are managed: for each such key, + set the env var when the saved value differs from the flag's engine + default, or pop it (clearing a stale value from a prior selection) when it + equals the default. Unknown keys in the saved dict (a flag that was + removed, an excluded hardware var, the dead per-camera tracking defaults) + are ignored for env and pruned from the persisted dict. A key the user never persisted is left UNTOUCHED: an absent / empty / unreadable dict is the feature-inactive baseline, and any env var set @@ -1380,6 +1604,17 @@ def _apply_experimental_env(self) -> None: else: os.environ.pop(flag.env_key, None) + # Prune keys that are no longer in the registry so the persisted dict does + # not accumulate dead flags across upgrades. Only rewrite when something + # actually changed, to avoid a needless store write on every engine start. + known = {flag.env_key for flag in EXPERIMENTAL_FLAGS} + cleaned = {k: v for k, v in saved.items() if k in known} + if self._store is not None and cleaned != saved: + try: + self._store.set_setting("experimental_features", cleaned) + except Exception: # noqa: BLE001 — pruning is best-effort + log.debug("pruning stale experimental_features keys failed", exc_info=True) + def _make_telemetry_callback(self, camera_id: str) -> Callable[[Any], None]: """Wrap push_telemetry so the supervisor records a per-camera last-seen time. @@ -1412,9 +1647,9 @@ def _relay_identity_to_siblings(self, source_cid: str, record: Any) -> None: process workers are active every worker shares the same in-process gallery, so no relay is necessary and we avoid touching the supervisor RLock. """ - from autoptz.engine.process_worker import process_per_camera_enabled + from autoptz.engine.process_worker import model_server_workers_enabled - if not process_per_camera_enabled(): + if not model_server_workers_enabled(): return with self._lock: siblings = [ @@ -1433,18 +1668,36 @@ def _relay_identity_to_siblings(self, source_cid: str, record: Any) -> None: def _make_worker(self, camera_id: str, config: CameraConfig) -> Any: """Build a camera worker. - Production uses the threaded worker. The only remaining process-worker - path is the explicit model-server architecture: camera children may run in - separate processes only when a shared inference request queue and a - per-camera response queue are ready. If the model-server did not start or - the camera was added after its fixed queue set was built, fall back to the - normal threaded worker instead of recreating the retired model-per-child - experiment. + Production uses the threaded worker. The only path that crosses a process + boundary is the shared detection server: camera children run in separate + processes only when a shared inference request queue and a per-camera + response queue are ready. If the server did not start, or the camera was + added after its fixed queue set was built, fall back to the built-in + threaded worker. """ on_telemetry = self._make_telemetry_callback(camera_id) + # Transparency: a configured NDI camera that ingests THIS machine's own + # AutoPTZ output is a feedback loop (encode + re-decode of our own + # pixels, possibly nested) — a major, easy-to-miss CPU/frame-drop source. + # The NDI menu hides these now, but cameras added earlier persist. + src = getattr(config, "source", None) + if src is not None and str(getattr(src, "type", "")) == "ndi": + from autoptz.engine.discovery.ndi import is_own_autoptz_output + + addr = str(getattr(src, "address", "") or "").removeprefix("ndi://") + cam_name = str(getattr(config, "name", "") or "") + if is_own_autoptz_output(addr) or is_own_autoptz_output(cam_name): + log.warning( + "camera_id=%s ingests this machine's OWN AutoPTZ NDI output (%s) — " + "a feedback loop that re-encodes and re-decodes the same pixels. " + "Expect heavy CPU and frame drops; remove this camera unless " + "intentional.", + camera_id, + addr or cam_name, + ) from autoptz.engine.process_worker import ( ProcessWorkerHandle, - process_per_camera_enabled, + model_server_workers_enabled, ) # ``==`` (not ``is``): ``self._default_worker_factory`` is a bound method, and @@ -1453,15 +1706,22 @@ def _make_worker(self, camera_id: str, config: CameraConfig) -> Any: # opt-in process path. Bound methods compare equal by (instance, function), # so ``==`` is True only when no custom/test factory was injected. use_process = ( - self._worker_factory == self._default_worker_factory and process_per_camera_enabled() + self._worker_factory == self._default_worker_factory and model_server_workers_enabled() ) if not use_process: return self._worker_factory(camera_id, config, on_telemetry) infer_resp_q = self._infer_resp_qs.get(camera_id) + if self._infer_req_q is not None and infer_resp_q is None: + # Camera added (or removed + re-added, which mints a new id) after the + # server built its fixed queue set — give it a slot via a controlled + # server restart instead of silently degrading to the threaded + # pipeline inside the GUI process. + infer_resp_q = self._attach_camera_to_model_server(camera_id) if self._infer_req_q is None or infer_resp_q is None: log.warning( - "model-server process worker unavailable for %s; using the normal " - "threaded worker instead of the retired model-per-child process path.", + "shared detection server unavailable for camera %s — this camera " + "runs the built-in threaded pipeline; restart the engine to attach " + "it to the shared server.", camera_id, ) return self._worker_factory(camera_id, config, on_telemetry) diff --git a/autoptz/logsetup.py b/autoptz/logsetup.py index 8f63d24e..5b0d2890 100644 --- a/autoptz/logsetup.py +++ b/autoptz/logsetup.py @@ -106,6 +106,20 @@ def format(self, record: logging.LogRecord) -> str: return line +def suppress_noisy_dependency_warnings() -> None: + """Silence third-party warning spam that fires on the per-frame hot path. + + insightface's ``face_align`` trips a skimage ``FutureWarning`` ("`estimate` + is deprecated…") that floods the console during face recognition — one line + per attribution point per process, multiplied by every camera child. It is + upstream's to fix; scoped to insightface so OUR deprecation warnings stay + visible. Called from both the app console setup and the camera-child setup. + """ + import warnings + + warnings.filterwarnings("ignore", category=FutureWarning, module=r"insightface(\.|$)") + + def install_console_logging( level: int = logging.WARNING, *, @@ -118,6 +132,7 @@ def install_console_logging( re-calling (e.g. after ``--log-level``) doesn't stack handlers. Safe to call before the UI is imported. """ + suppress_noisy_dependency_warnings() stream = stream or sys.stderr root = logging.getLogger() # Drop a prior handler we installed so level changes don't duplicate output. diff --git a/autoptz/ui/app.py b/autoptz/ui/app.py index e8e7c5a6..71b0bb8d 100644 --- a/autoptz/ui/app.py +++ b/autoptz/ui/app.py @@ -227,6 +227,27 @@ def _build_main_window( ) +def _engine_autostart_to_persist(window: Any, client: Any) -> bool: + """The auto-start intent to persist for next launch. + + The user's intent (``desired_engine_running()`` / ``client.autostartDesired``), + NOT the momentary ``engineRunning``: only a deliberate user Stop records "off", + so a stopped/failed engine — or one suspended for a Mark run — never traps + auto-start off. Windows/clients without the accessor (older builds / test + fakes) fall back to a safe default of ON. + """ + probe = getattr(window, "desired_engine_running", None) + if callable(probe): + try: + return bool(probe()) + except Exception: # noqa: BLE001 + log.debug("desired_engine_running probe failed", exc_info=True) + try: + return bool(client.autostartDesired) + except Exception: # noqa: BLE001 + return True + + def run(argv: list[str] | None = None) -> int: """Launch the AutoPTZ UI. Returns the process exit code. @@ -439,9 +460,15 @@ def _marker_lines() -> tuple[str, str]: QTimer.singleShot(1200, window._start_mark) # ── engine auto-start ────────────────────────────────────────────────────── - # Restore the last on/off state (default ON) and start after the window is - # shown and exposed so the first paint happens before heavy ingest/ML work. - if bool(store.get_setting("engine_running", True)): + # Restore the user's auto-start INTENT (default ON) and start after the window + # is shown/exposed so the first paint happens before heavy ingest/ML work. + # Keyed on ``engine_autostart`` (not the retired ``engine_running``, whose value + # meant "was the engine running at shutdown" and self-trapped off: a stopped + # engine persisted "off", which then skipped auto-start forever). Seed the + # client so an untouched session round-trips the same intent. + autostart = bool(store.get_setting("engine_autostart", True)) + client.set_autostart_desired(autostart) + if autostart: class _CameraAccessBridge(QObject): resolved = Signal(bool) @@ -476,11 +503,11 @@ def _auto_start_when_engine_ready() -> None: # prevents entering a real event loop. exit_code = QApplication.exec(app) - # Persist the engine on/off state for the next launch. + # Persist the auto-start intent for the next launch. try: - store.set_setting("engine_running", bool(client.engineRunning)) + store.set_setting("engine_autostart", _engine_autostart_to_persist(window, client)) except Exception: # noqa: BLE001 - log.exception("Error persisting engine_running on shutdown") + log.exception("Error persisting engine_autostart on shutdown") # Orderly shutdown: stop the pump + engine before touching the store so no # worker thread is still pushing telemetry / draining commands. diff --git a/autoptz/ui/engine_client.py b/autoptz/ui/engine_client.py index 83c4eef8..6713b611 100644 --- a/autoptz/ui/engine_client.py +++ b/autoptz/ui/engine_client.py @@ -193,6 +193,16 @@ def __init__( self._supervisor: Any | None = None self._supervisor_factory: Callable[[EngineClient], Any] | None = None self._engine_running: bool = False + # Whether the engine should auto-start on the next launch — the user's + # INTENT, not the momentary running state. Only a deliberate user Stop + # clears it; a never-started or failed engine leaves it alone, so a + # stopped engine is never "trapped off" across launches. Persisted under + # ``engine_autostart``; seeded from that setting at startup. + self._autostart_desired: bool = True + # Per-camera inference EP from telemetry — aggregated into the stable + # Engine label (mixed worker modes report different strings; see + # _on_telemetry_main). + self._camera_eps: dict[str, str] = {} self._engine_ep: str = "" self._startup_active: bool = False self._startup_phase: str = "" @@ -373,7 +383,32 @@ def set_supervisor_factory( """ self._supervisor_factory = factory + @property + def autostartDesired(self) -> bool: + """Whether the engine should auto-start next launch (the user's intent).""" + return self._autostart_desired + + def set_autostart_desired(self, value: bool) -> None: + """Seed the auto-start intent (from the persisted setting at startup).""" + self._autostart_desired = bool(value) + + @Slot() + def userStartEngine(self) -> None: + """Start the engine from a user control — records intent to auto-start.""" + self._autostart_desired = True + self.startEngine() + @Slot() + def userStopEngine(self) -> None: + """Stop the engine from a user control — records intent NOT to auto-start. + + Distinct from :meth:`stopEngine` (called by shutdown, Mark suspend, and + restart), which must NOT change the auto-start intent — otherwise a + transient stop would trap the engine off on every future launch. + """ + self._autostart_desired = False + self.stopEngine() + def startEngine(self) -> None: """Create (if needed) and start the supervisor. Idempotent.""" if self._engine_running: @@ -465,6 +500,7 @@ def stopEngine(self) -> None: log.exception("Supervisor stop failed") self._engine_running = False self._engine_ep = "" + self._camera_eps.clear() self._set_startup_progress(active=False, phase="") # Detach the (now-stopped) engine's gallery; CRUD reverts to store-only. self._identity_service = None @@ -1122,32 +1158,39 @@ def setAutoDownloadModels(self, enabled: bool) -> None: self.optionalComponentsChanged.emit() @Slot() - def releaseModelSessions(self) -> None: + def releaseModelSessions(self, include_face: bool = False) -> None: """Free the engine's ORT sessions *before* the model cache is mutated. The Model Manager calls this prior to a download/removal so onnxruntime no longer holds the files open — without it, delete/replace fails on Windows (POSIX tolerates unlink-while-open, which is why it only broke - there). Safe no-op when the engine is stopped. + there). Safe no-op when the engine is stopped. ``include_face`` also + releases the shared face session — set only for a face-pack op. """ sup = self._supervisor if self._engine_running and sup is not None: fn = getattr(sup, "release_model_sessions", None) if callable(fn): try: - fn() + fn(include_face=include_face) + except TypeError: + fn() # older supervisor without include_face except Exception: # noqa: BLE001 log.debug("release_model_sessions failed", exc_info=True) - @Slot() - def rebuildModelSessions(self) -> None: - """Rebuild live models from the fresh cache after a download/removal.""" + def rebuildModelSessions(self, include_face: bool = False) -> None: + """Rebuild live models from the fresh cache after a download/removal. + + ``include_face`` also rebuilds the face stack — set only for a face-pack op. + """ sup = self._supervisor if self._engine_running and sup is not None: fn = getattr(sup, "rebuild_model_sessions", None) if callable(fn): try: - fn() + fn(include_face=include_face) + except TypeError: + fn() # older supervisor without include_face except Exception: # noqa: BLE001 log.debug("rebuild_model_sessions failed", exc_info=True) self.optionalComponentsChanged.emit() @@ -1750,6 +1793,15 @@ def setIdentityEnabled(self, identity_id: str, enabled: bool) -> None: self._identity_model.update_identity(updated) self.identitiesChanged.emit() + # Engine sync: a rename-to-current-name "touch" relays the record (with + # its new enabled state) to process-mode children's galleries. + self._enqueue( + RenameIdentityCmd( + camera_id=None, + identity_id=identity_id, + new_name=getattr(updated, "name", "") or "", + ) + ) @Slot(str, str) def mergeIdentities(self, keep_id: str, drop_id: str) -> None: @@ -1784,6 +1836,16 @@ def mergeIdentities(self, keep_id: str, drop_id: str) -> None: self._identity_model.update_identity(merged) self._identity_model.remove_identity(drop_id) self.identitiesChanged.emit() + # Engine sync (process-mode children hold their own galleries): touch the + # kept identity so the merged record is relayed, and drop the folded one. + self._enqueue( + RenameIdentityCmd( + camera_id=None, + identity_id=keep_id, + new_name=getattr(merged, "name", "") or "", + ) + ) + self._enqueue(DeleteIdentityCmd(camera_id=None, identity_id=drop_id)) @Slot(str, str) def setTargetIdentity(self, camera_id: str, identity_id: str) -> None: @@ -2197,14 +2259,21 @@ def push_telemetry(self, msg: TelemetryMsg) -> None: def _on_telemetry_main(self, msg: TelemetryMsg) -> None: """Apply telemetry on the owning (GUI) thread.""" self._model.update_telemetry(msg) - # Surface the actually-active inference EP reported by the worker. Without - # this the status bar / camera-info panel showed a blank EP on every - # platform (the value was only ever seeded empty); the user noticed it on - # Windows. Telemetry carries the real provider (CoreML / Dml / CPU / …). + # Surface the actually-active inference EP(s). Workers can legitimately + # report DIFFERENT strings at the same time — cameras on the shared + # detection server say "model-server" while cameras on the threaded path + # say the real provider (CoreML / Dml / CPU) — so last-writer-wins made + # the Engine label flap between the two at telemetry rate. Aggregate per + # camera instead and compose a stable, honest label ("model-server", + # "CoreML", or "CoreML + model-server" when mixed). ep = (getattr(msg, "ep", "") or "").replace("ExecutionProvider", "") - if ep and ep != self._engine_ep: - self._engine_ep = ep - self.engineStateChanged.emit() + if ep: + self._camera_eps[str(getattr(msg, "camera_id", "") or "")] = ep + live_ids = set(self._model.camera_ids()) + composed = " + ".join(sorted({v for k, v in self._camera_eps.items() if k in live_ids})) + if composed and composed != self._engine_ep: + self._engine_ep = composed + self.engineStateChanged.emit() # Fan out to additive observers (Mark quality / ground-truth accumulators). # Guarded so a bad observer never breaks telemetry delivery. for observer in self._telemetry_observers: diff --git a/autoptz/ui/theme.py b/autoptz/ui/theme.py index 3e1f21dd..5371bae2 100644 --- a/autoptz/ui/theme.py +++ b/autoptz/ui/theme.py @@ -312,6 +312,7 @@ def build_stylesheet(pal: Palette, accent: QColor, selection: QColor) -> str: QPushButton[danger="true"] {{ background: transparent; color: {ERROR}; border: 1px solid {pal.border}; }} QPushButton[danger="true"]:hover {{ background: {ERROR}; color: {ACCENT_TEXT}; border-color: {ERROR}; }} QPushButton[danger="true"]:pressed {{ background: {DANGER_HOVER}; color: {ACCENT_TEXT}; }} + QPushButton[danger="true"]:disabled {{ background: transparent; color: {pal.muted}; border-color: {pal.border}; }} /* icon-only square button (IconButton): borderless, subtle hover fill */ QToolButton#iconButton {{ background: transparent; border: none; border-radius: {r}px; padding: 0; color: {pal.subtext}; }} @@ -394,8 +395,8 @@ def build_stylesheet(pal: Palette, accent: QColor, selection: QColor) -> str: QToolButton#collGroupHeader:hover {{ color: {pal.text}; }} /* palette-driven shared widgets (common.py) — track light/dark for free */ - QLabel#sectionCaption {{ color: {pal.subtext}; font-size: {fs(10)}px; - font-weight: 700; letter-spacing: 1px; }} + QLabel#sectionCaption {{ color: {pal.text}; font-size: {fs(11)}px; + font-weight: 800; letter-spacing: 1px; }} QFrame#hline {{ background: {pal.border}; border: none; }} QLabel#helpBadge {{ color: {pal.muted}; background: {pal.surface_alt}; border: 1px solid {pal.border}; border-radius: {fs(8)}px; diff --git a/autoptz/ui/widgets/camera_info_panel.py b/autoptz/ui/widgets/camera_info_panel.py index 1e2fa0a2..ed397e3f 100644 --- a/autoptz/ui/widgets/camera_info_panel.py +++ b/autoptz/ui/widgets/camera_info_panel.py @@ -12,7 +12,7 @@ import re from typing import Any -from PySide6.QtCore import Qt, QTimer +from PySide6.QtCore import QSize, Qt, QTimer from PySide6.QtWidgets import ( QFormLayout, QHBoxLayout, @@ -23,7 +23,13 @@ ) from autoptz.ui import theme as T -from autoptz.ui.widgets.common import HelpBadge, on_theme_changed, section_label +from autoptz.ui.widgets.common import ( + HelpBadge, + on_theme_changed, + scroll_chrome_width, + section_label, + visible_min_width, +) log = logging.getLogger(__name__) @@ -52,6 +58,7 @@ def __init__(self, client: Any, parent: QWidget | None = None) -> None: scroll.setWidgetResizable(True) scroll.setFrameShape(QScrollArea.Shape.NoFrame) root.addWidget(scroll) + self._scroll = scroll body = QWidget() self._col = QVBoxLayout(body) @@ -82,6 +89,35 @@ def __init__(self, client: Any, parent: QWidget | None = None) -> None: self._timer.timeout.connect(self.refresh) self._timer.start(1000) + def minimumSizeHint(self) -> QSize: # noqa: N802 + """The real floor: the live (font-metric-driven) group layout minimum. + + No panel-level floor existed here before — Qt's default + ``minimumSizeHint()`` for a ``QVBoxLayout → QScrollArea`` tree is a + small, content-unrelated constant (``QScrollArea.minimumSizeHint()`` + ignores its contained widget entirely; see + :func:`~autoptz.ui.widgets.common.scroll_content_min_width`), so this + panel relied entirely on the test's own hardcoded ``280`` floor — which + is a macOS/Linux-tuned guess with no headroom for a wider font + (Windows' default UI font measurably renders wider at the same point + size). ``self._groups`` starts hidden (no camera selected yet) and a + hidden widget's size is normally excluded from its PARENT layout's + (``self._col``) minimum — so it's measured directly via + :func:`~autoptz.ui.widgets.common.visible_min_width`, plus the margins + ``self._col`` wraps it in, so the real requirement counts whether or + not a camera happens to be selected yet. + """ + scroll = getattr(self, "_scroll", None) + if scroll is None: + return super().minimumSizeHint() + body = scroll.widget() + content_w = visible_min_width(body) if body is not None else 0 + groups = getattr(self, "_groups", None) + if groups is not None and body is not None and body.layout() is not None: + margins = body.layout().contentsMargins() + content_w = max(content_w, visible_min_width(groups) + margins.left() + margins.right()) + return QSize(content_w + scroll_chrome_width(scroll), super().minimumSizeHint().height()) + def _restyle(self) -> None: """Re-apply literal-color styling (construction + theme change). diff --git a/autoptz/ui/widgets/camera_tile.py b/autoptz/ui/widgets/camera_tile.py index 59dffa8d..1c7d528b 100644 --- a/autoptz/ui/widgets/camera_tile.py +++ b/autoptz/ui/widgets/camera_tile.py @@ -191,6 +191,10 @@ def __init__( # so it's available without hovering. self._info_badge = HelpBadge("Per-stage performance", self) self._info_badge.setObjectName("helpBadge") + # Whether the top-left target label painted this frame (set by + # _draw_target_label, reset each paintEvent) — the state chip skips + # painting when it did, so the two never overlap. + self._target_label_drawn = False # ── overlay (per-tile tracking control) ────────────────────────────────────── @@ -531,6 +535,13 @@ def eff(total_ms: float) -> str: kind = src.get("source_label") or src.get("type") or "" res = f"{w}×{h}" if w and h else "—" lines.append(f"Source: {res}" + (f" · {kind}" if kind else "")) + # Auto-quality cadence (the old on-tile "×N" chip, now worded here where + # there's room to say what it means; the Properties panel shows it too). + deg = _degradation_chip_info(rec) + if deg.get("visible"): + reason = str(deg.get("tooltip", "") or "").strip() + line = f"Auto quality: detector cadence relaxed {deg.get('text')}" + lines.append(f"{line} ({reason})" if reason else line) return "\n".join(lines) # ── painting ─────────────────────────────────────────────────────────────── @@ -575,16 +586,15 @@ def paintEvent(self, _event: Any) -> None: # noqa: N802 # HUD overlays (only meaningful once we have a frame/telemetry) if rec is not None: + self._target_label_drawn = False self._paint_tracks(p, rec) self._paint_name_pill(p, rec) self._paint_fps_chip(p, rec) - # Resolved once and shared: the degradation chip's x-position depends - # on whether the state chip drew (same row), so it needs this result - # too — computing it twice per paint would double the (cheap but - # unnecessary) dict-building work in ``_state_chip_info``. - state_info = _state_chip_info(rec) - state_chip_w = self._paint_state_chip(p, state_info) - self._paint_degradation_chip(p, rec, state_chip_width=state_chip_w) + # One status surface at a time: when a target label drew at top-left + # (it carries the same state as its headline, plus identity and + # confidence), the state chip would paint on top of it — skip it. + if not self._target_label_drawn: + self._paint_state_chip(p, _state_chip_info(rec)) self._paint_banner(p, rec, streaming) # Selection glow is separate from tracking; tracking is shown on the target marker. @@ -1063,6 +1073,8 @@ def _draw_target_label( detail: str = "", ) -> None: """Draw the active target label as a readable tile chip, not a bbox chip.""" + # The state chip skips painting when this label drew (same top-left spot). + self._target_label_drawn = True f = QFont(self.font()) f.setPixelSize(12) f.setBold(True) @@ -1185,21 +1197,16 @@ def _paint_fps_chip(self, p: QPainter, rec: Any) -> None: } def _paint_state_chip(self, p: QPainter, info: dict[str, Any]) -> float: - """Small colored chip showing the live tracking state (name-pill row). + """Small colored chip showing the live tracking state (below the name pill). Same scrim-pill treatment as ``_paint_fps_chip``/``_paint_name_pill``: no per-frame allocation beyond the QColor/QFont/QRectF Qt itself needs to paint text, matching the rest of the HUD. Chip is display-only — no config, no interaction — it reflects ``tracking_status.state`` exactly as the engine reports it (project rule: configured→effective, - never silently hidden). ``info`` is the pre-resolved - ``_state_chip_info(rec)`` result (shared with ``_paint_degradation_chip`` - for its same-row positioning, so it's resolved once in ``paintEvent``). - - Returns the painted chip's pixel width (0.0 when hidden) so the - degradation chip can offset off the *actual* label instead of a - hardcoded guess — labels vary (LOCKED/COASTING/STANDBY/MANUAL/ - DEGRADED/...) and a fixed offset would misplace the second chip. + never silently hidden). Painted only when no target label drew this + frame (the label occupies the same top-left spot and already carries + the state in its headline), so the two surfaces never overlap. """ if not info.get("visible"): return 0.0 @@ -1222,41 +1229,6 @@ def _paint_state_chip(self, p: QPainter, info: dict[str, Any]) -> float: p.drawText(rect, Qt.AlignmentFlag.AlignCenter, text) return chip_w - def _paint_degradation_chip( - self, p: QPainter, rec: Any, *, state_chip_width: float = 0.0 - ) -> None: - """Small ``×2``/``×4`` chip when the auto quality ladder has relaxed the - detector cadence below what's configured (transparency for the "auto - scales but stays visible" rule) — hidden at the configured cadence. - - ``state_chip_width`` is the pixel width ``_paint_state_chip`` just - painted (0.0 if that chip is hidden), so this chip sits immediately - to the right of whatever the state chip actually rendered. - """ - info = _degradation_chip_info(rec) - if not info.get("visible"): - return - text = str(info.get("text", "")) - f = QFont(self.font()) - f.setPixelSize(10) - f.setBold(True) - p.setFont(f) - fm = QFontMetrics(f) - tw = fm.horizontalAdvance(text) - # Sits to the right of the state chip, same row. - x = 8 + state_chip_width + 6 if state_chip_width > 0 else 8 - rect = QRectF(x, 35, tw + 16, 20) - p.setPen(Qt.PenStyle.NoPen) - p.setBrush(T.VIDEO_SCRIM) - p.drawRoundedRect(rect, 5, 5) - p.setPen(QColor(T.WARNING)) - p.drawText(rect, Qt.AlignmentFlag.AlignCenter, text) - tip = str(info.get("tooltip", "")) - if tip and self.toolTip() != tip: - # Reuse the widget tooltip as the "detail carrying the reason" the - # brief asks for; cheap to set only when it actually changes. - self.setToolTip(tip) - def _paint_banner(self, p: QPainter, rec: Any, streaming: bool) -> None: health = str(getattr(rec, "health", "ok")) text = "" diff --git a/autoptz/ui/widgets/common.py b/autoptz/ui/widgets/common.py index 2aba0bd1..2d55491b 100644 --- a/autoptz/ui/widgets/common.py +++ b/autoptz/ui/widgets/common.py @@ -14,7 +14,9 @@ QGraphicsOpacityEffect, QLabel, QPushButton, + QScrollArea, QSizePolicy, + QStyle, QToolButton, QToolTip, QVBoxLayout, @@ -74,6 +76,64 @@ def _finish() -> None: anim.start() +# ── layout sizing ───────────────────────────────────────────────────────────── + + +def visible_min_width(widget: QWidget) -> int: + """A widget's minimum width AS IF it were visible, even if currently hidden. + + Qt layouts exclude a hidden widget's size from its PARENT layout's own + minimum-size calculation — correct for content that's gone for good, but + wrong for a panel that toggles an "empty state" vs. a "populated state" + (an accordion section while collapsed; an info panel's no-camera-selected + placeholder): the real content's width requirement must still count, or a + panel measured while collapsed/empty silently under-reports, then is too + narrow the instant real content appears (on expand, on selecting a camera, + or when a clip-audit test forces every section open). Querying the + widget's OWN layout directly sidesteps the exclusion, which only applies + where something ELSE queries this widget as a child of its parent's layout. + """ + lay = widget.layout() + return lay.minimumSize().width() if lay is not None else widget.minimumSizeHint().width() + + +def scroll_chrome_width(scroll: QScrollArea) -> int: + """The real frame + (possible) vertical-scrollbar overhead a scroll area adds. + + Queried from the live style/frame rather than guessed, so it stays correct + on any platform/theme/DPI — not just whatever was on hand when someone last + hand-picked a replacement pixel constant. + """ + chrome = scroll.frameWidth() * 2 + if scroll.verticalScrollBarPolicy() != Qt.ScrollBarPolicy.ScrollBarAlwaysOff: + chrome += scroll.style().pixelMetric(QStyle.PixelMetric.PM_ScrollBarExtent, None, scroll) + return chrome + + +def scroll_content_min_width(scroll: QScrollArea) -> int: + """The REAL minimum width a scroll area's content needs, from live metrics. + + ``QScrollArea.minimumSizeHint()`` does **not** take its contained widget into + account — verified directly: a plain ``QScrollArea`` wrapping a 462px-wide + label still reports ``~90px``, a small style-driven constant that has zero + relationship to the content. Every side panel in this app is built as + ``QVBoxLayout(self) → QScrollArea(resizable) → body-with-real-content``, so + floating a panel's minimum width off the scroll area's (or the panel's own + default) ``minimumSizeHint()`` silently under-budgets — which is exactly + what happened: the previous code used flat pixel constants "measured" once + against macOS/Linux font metrics, with no headroom for a platform whose + default UI font renders wider at the same point size (Windows does). + + Compute the floor from the scrolled content's OWN layout instead — that is + a bottom-up calculation from each child widget's real ``minimumSizeHint()`` + (font-metric-driven, so it's correct on any platform/DPI/scale) — plus the + scroll area's real chrome (:func:`scroll_chrome_width`). + """ + body = scroll.widget() + content_w = visible_min_width(body) if body is not None else 0 + return content_w + scroll_chrome_width(scroll) + + # ── theme reactivity ────────────────────────────────────────────────────────── @@ -371,6 +431,28 @@ def add_widget(self, w: QWidget) -> None: if self._expanded: self._content.setMaximumHeight(_QWIDGETSIZE_MAX) + def minimumSizeHint(self) -> QSize: # noqa: N802 + """Report the width the EXPANDED content needs, even while collapsed. + + Collapsing an accordion section hides its HEIGHT — that's the entire + point — it must never silently shrink the reported WIDTH floor too. A + panel built from several of these (most starting collapsed, e.g. + PropertiesPanel) that floors its own minimum width off "whatever's + visible right now" would under-report until the user (or a clip-audit + test) expands a section, at which point the panel would suddenly be + too narrow for its own content. :func:`visible_min_width` reads + ``_content``'s layout directly, which is unaffected by ``_content``'s + own hidden flag (that flag only matters to whatever layout item + queries ``_content`` as a child of its OWN parent), so this always + reflects the real, font-metric-driven minimum regardless of the + current toggle state. + """ + hdr_w = self._toggle.minimumSizeHint().width() + content = getattr(self, "_content", None) + content_w = visible_min_width(content) if content is not None else 0 + height = super().minimumSizeHint().height() + return QSize(max(hdr_w, content_w), height) + # ── thumbnails ────────────────────────────────────────────────────────────────── diff --git a/autoptz/ui/widgets/dialogs/experimental.py b/autoptz/ui/widgets/dialogs/experimental.py index d329a750..119fcf4c 100644 --- a/autoptz/ui/widgets/dialogs/experimental.py +++ b/autoptz/ui/widgets/dialogs/experimental.py @@ -18,9 +18,10 @@ QDialogButtonBox, QFrame, QGridLayout, - QHBoxLayout, QLabel, + QLineEdit, QMessageBox, + QPushButton, QScrollArea, QVBoxLayout, QWidget, @@ -28,11 +29,13 @@ from autoptz.engine.runtime.experimental_flags import ( EXPERIMENTAL_FLAGS, - TRACKING_DEFAULT_FIELDS, ExperimentalFlag, ) from autoptz.ui import theme as T -from autoptz.ui.widgets.common import HelpBadge, hline, section_label +from autoptz.ui.widgets.common import HelpBadge, scroll_content_min_width, section_label + +# Section headers, in display order. +_SECTION_ORDER = ("Experiments", "Devices & tuning", "Model overrides", "Diagnostics") log = logging.getLogger(__name__) @@ -63,20 +66,20 @@ def __init__(self, client: Any = None, parent: QWidget | None = None) -> None: self._client = client self.setWindowTitle("Experimental Features") self.setModal(True) - self.setMinimumWidth(560) + self.resize(720, 620) self._bool_boxes: dict[str, QCheckBox] = {} self._choice_combos: dict[str, QComboBox] = {} - self._tracking_boxes: dict[str, QCheckBox] = {} + self._text_fields: dict[str, QLineEdit] = {} outer = QVBoxLayout(self) outer.setContentsMargins(20, 20, 20, 20) outer.setSpacing(8) intro = QLabel( - "These features are experimental and may change or be removed. Most " - "are read when the engine starts, so a restart is needed for changes " - "to take effect." + "Optional engine features and overrides, grouped by area. They are read " + "when the engine starts, so use Apply and restart when prompted for " + "changes to take effect." ) intro.setWordWrap(True) intro.setStyleSheet(f"color: {T.CURRENT.subtext};") @@ -85,30 +88,36 @@ def __init__(self, client: Any = None, parent: QWidget | None = None) -> None: scroll = QScrollArea(self) scroll.setWidgetResizable(True) scroll.setFrameShape(QFrame.Shape.NoFrame) + scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) outer.addWidget(scroll, 1) body = QWidget() scroll.setWidget(body) root = QVBoxLayout(body) root.setContentsMargins(2, 2, 2, 2) - root.setSpacing(4) + root.setSpacing(12) - root.addWidget(section_label("Engine flags")) + by_section: dict[str, list[ExperimentalFlag]] = {} for flag in EXPERIMENTAL_FLAGS: - root.addWidget(self._build_flag_row(flag)) - - root.addWidget(hline()) - th = QHBoxLayout() - th.addWidget(section_label("New-camera tracking defaults")) - th.addWidget( - HelpBadge( - "These set the defaults applied to cameras you add from now on. " - "Existing cameras keep their current per-camera setting." + by_section.setdefault(flag.section, []).append(flag) + # Known sections in fixed order, then any unexpected section appended. + ordered = [s for s in _SECTION_ORDER if s in by_section] + ordered += [s for s in by_section if s not in _SECTION_ORDER] + for section in ordered: + # Each section is a distinct elevated card so the four groups read as + # separate panels rather than one flat list. + card = QFrame() + card.setObjectName("expCard") + card.setStyleSheet( + f"QFrame#expCard {{ background: {T.CURRENT.surface_alt};" + f" border: 1px solid {T.CURRENT.border}; border-radius: 10px; }}" ) - ) - th.addStretch(1) - root.addLayout(th) - for name, label, desc, default in TRACKING_DEFAULT_FIELDS: - root.addWidget(self._build_tracking_row(name, label, desc, default)) + cl = QVBoxLayout(card) + cl.setContentsMargins(14, 12, 14, 12) + cl.setSpacing(2) + cl.addWidget(section_label(section)) + for flag in by_section[section]: + cl.addWidget(self._build_flag_row(flag)) + root.addWidget(card) root.addStretch(1) note = QLabel("Some changes need a restart to take effect.") @@ -133,6 +142,18 @@ def __init__(self, client: Any = None, parent: QWidget | None = None) -> None: # — drives whether Apply offers a restart (no nag when nothing changed). self._applied_snapshot = self._collect() + # Real floor: the scrolled content's live layout minimum (a row is + # [label + editor + Browse + help + "Restart required" badge]) plus + # this layout's own margins and the scroll area's chrome. Previously a + # flat ``setMinimumWidth(680)`` — a guess "measured" once on + # macOS/Linux — which silently under-budgets on any font that renders + # wider at the same point size (Windows' default UI font measurably + # does; this is what clipped section captions/badges on Windows CI + # while macOS/Linux stayed green). Computed AFTER every section row is + # built, so it reflects the real (font-metric-driven) requirement. + m = outer.contentsMargins() + self.setMinimumWidth(scroll_content_min_width(scroll) + m.left() + m.right()) + # ── row builders ───────────────────────────────────────────────────────── def _build_flag_row(self, flag: ExperimentalFlag) -> QFrame: @@ -145,7 +166,7 @@ def _build_flag_row(self, flag: ExperimentalFlag) -> QFrame: box.setToolTip(flag.description) self._bool_boxes[flag.env_key] = box lay.addWidget(box, 0, 0) - else: + elif flag.kind == "choice": lay.addWidget(QLabel(f"{flag.label}"), 0, 0) combo = QComboBox() for choice in flag.choices: @@ -153,37 +174,36 @@ def _build_flag_row(self, flag: ExperimentalFlag) -> QFrame: combo.setToolTip(flag.description) self._choice_combos[flag.env_key] = combo lay.addWidget(combo, 0, 1, Qt.AlignmentFlag.AlignLeft) - lay.addWidget(HelpBadge(flag.description), 0, 2) + else: # text / path — free-form line edit (path adds a Browse button) + lay.addWidget(QLabel(f"{flag.label}"), 0, 0) + edit = QLineEdit() + edit.setToolTip(flag.description) + edit.setPlaceholderText("(default)") + self._text_fields[flag.env_key] = edit + lay.addWidget(edit, 0, 1) + if flag.kind == "path": + browse = QPushButton("Browse…") + browse.clicked.connect(lambda _=False, e=edit: self._browse_path(e)) + lay.addWidget(browse, 0, 2, Qt.AlignmentFlag.AlignLeft) + lay.addWidget(HelpBadge(flag.description), 0, 4) if flag.restart_required: - lay.addWidget(_restart_badge(), 0, 3, Qt.AlignmentFlag.AlignRight) + lay.addWidget(_restart_badge(), 0, 5, Qt.AlignmentFlag.AlignRight) desc = QLabel( f"{flag.description}" f" Default: {'(auto)' if flag.default == '' else flag.default}." ) desc.setTextFormat(Qt.TextFormat.RichText) desc.setWordWrap(True) - lay.addWidget(desc, 1, 0, 1, 4) - lay.setColumnStretch(0, 1) + lay.addWidget(desc, 1, 0, 1, 6) + lay.setColumnStretch(1, 1) return row - def _build_tracking_row(self, name: str, label: str, desc: str, default: bool) -> QFrame: - row = QFrame() - lay = QGridLayout(row) - lay.setContentsMargins(4, 6, 4, 6) - box = QCheckBox(label) - box.setToolTip(desc) - self._tracking_boxes[name] = box - lay.addWidget(box, 0, 0) - lay.addWidget(HelpBadge(desc), 0, 1, Qt.AlignmentFlag.AlignRight) - detail = QLabel( - f"{desc} Default: " - f"{'on' if default else 'off'}." - ) - detail.setTextFormat(Qt.TextFormat.RichText) - detail.setWordWrap(True) - lay.addWidget(detail, 1, 0, 1, 2) - lay.setColumnStretch(0, 1) - return row + def _browse_path(self, edit: QLineEdit) -> None: + from PySide6.QtWidgets import QFileDialog + + path, _ = QFileDialog.getOpenFileName(self, "Choose a model file") + if path: + edit.setText(path) # ── state <-> widgets ────────────────────────────────────────────────────── @@ -197,22 +217,22 @@ def _load(self) -> None: value = str(saved.get(flag.env_key, flag.default)) if flag.kind == "bool": self._bool_boxes[flag.env_key].setChecked(value not in ("0", "", "false")) - else: + elif flag.kind == "choice": combo = self._choice_combos[flag.env_key] idx = combo.findData(value) combo.setCurrentIndex(idx if idx >= 0 else combo.findData(flag.default)) - for name, _label, _desc, default in TRACKING_DEFAULT_FIELDS: - self._tracking_boxes[name].setChecked(bool(saved.get(name, default))) + else: + self._text_fields[flag.env_key].setText(value) def _collect(self) -> dict[str, Any]: out: dict[str, Any] = {} for flag in EXPERIMENTAL_FLAGS: if flag.kind == "bool": out[flag.env_key] = "1" if self._bool_boxes[flag.env_key].isChecked() else "0" - else: + elif flag.kind == "choice": out[flag.env_key] = self._choice_combos[flag.env_key].currentData() - for name, _label, _desc, _default in TRACKING_DEFAULT_FIELDS: - out[name] = bool(self._tracking_boxes[name].isChecked()) + else: + out[flag.env_key] = self._text_fields[flag.env_key].text().strip() return out def _apply(self) -> None: @@ -286,10 +306,10 @@ def _restore_defaults(self) -> None: for flag in EXPERIMENTAL_FLAGS: if flag.kind == "bool": self._bool_boxes[flag.env_key].setChecked(flag.default not in ("0", "", "false")) - else: + elif flag.kind == "choice": combo = self._choice_combos[flag.env_key] combo.setCurrentIndex(combo.findData(flag.default)) - for name, _label, _desc, default in TRACKING_DEFAULT_FIELDS: - self._tracking_boxes[name].setChecked(bool(default)) + else: + self._text_fields[flag.env_key].setText(flag.default) self._apply() self._applied_snapshot = self._collect() diff --git a/autoptz/ui/widgets/dialogs/mark_preflight.py b/autoptz/ui/widgets/dialogs/mark_preflight.py index 1d0da41a..2369a9fb 100644 --- a/autoptz/ui/widgets/dialogs/mark_preflight.py +++ b/autoptz/ui/widgets/dialogs/mark_preflight.py @@ -193,18 +193,27 @@ def __init__( # Only two sources: the bundled clip (real decode) and real NDI senders. # The old "Synthetic — drawn people" option is removed — the drawn scene now # survives only as the env-gated ground-truth scene, never a user source. - source_box = QGroupBox("Camera source") + source_box = QGroupBox("Test video") source_col = QVBoxLayout(source_box) self._source_group = QButtonGroup(self) clip_label = ( - "Bundled clip — real people (real decode)" + "Built-in clip — real people (recommended)" if clip_ok - else "Bundled clip — not installed (uses drawn people)" + else "Built-in clip — not installed (uses drawn people)" ) self._clip_radio = QRadioButton(clip_label) ndi_ok = ndi_sim_available() - ndi_text = "Real NDI sources" if ndi_ok else "Real NDI sources (requires cyndilib)" + ndi_text = ( + "Simulated NDI streams (created on this computer)" + if ndi_ok + else "Simulated NDI streams (requires cyndilib)" + ) self._ndi_radio = QRadioButton(ndi_text) + self._ndi_radio.setToolTip( + "Broadcasts temporary NDI senders on this computer and receives them " + "through the real NDI pipeline — it does NOT use NDI cameras already on " + "your network. Measures the actual NDI decode + capture path at scale." + ) self._ndi_radio.setEnabled(ndi_ok) self._source_group.addButton(self._clip_radio) self._source_group.addButton(self._ndi_radio) diff --git a/autoptz/ui/widgets/dialogs/model_manager.py b/autoptz/ui/widgets/dialogs/model_manager.py index b0ce6e79..af7e0b86 100644 --- a/autoptz/ui/widgets/dialogs/model_manager.py +++ b/autoptz/ui/widgets/dialogs/model_manager.py @@ -79,9 +79,12 @@ def run(self) -> None: # to delete/replace a model onnxruntime still has open (POSIX tolerates it, # which is why this only failed on Windows). Rebuild from the fresh cache # afterwards so a removed model stops drawing and a new one is picked up. + # include_face only for a face-pack op, so a detector/pose op never drops + # and reloads the ~1.3 GB face pack. + include_face = self._action in ("download_face", "remove_face") if self._client is not None: try: - self._client.releaseModelSessions() + self._client.releaseModelSessions(include_face=include_face) except Exception: # noqa: BLE001 log.debug("releaseModelSessions before model op failed", exc_info=True) try: @@ -97,6 +100,15 @@ def run(self) -> None: total, ), ) + elif self._action == "download_face": + # No byte progress from insightface — leave the indeterminate bar the + # caller set (emitting (0,0) here would force _on_progress to a frozen + # determinate 0/1). + results = manager.ensure_face_pack() + elif self._action == "remove_face": + self.progress.emit("Removing face recognition pack", 0, 1) + results = manager.remove_face_pack() + self.progress.emit("Removing face recognition pack", 1, 1) else: self.progress.emit("Removing models", 0, 1) results = manager.remove_app_models(keys=self._keys) @@ -113,7 +125,7 @@ def run(self) -> None: finally: if self._client is not None: try: - self._client.rebuildModelSessions() + self._client.rebuildModelSessions(include_face=include_face) except Exception: # noqa: BLE001 log.debug("rebuildModelSessions after model op failed", exc_info=True) self.done.emit({"action": self._action, "results": results}) @@ -241,6 +253,98 @@ def __init__(self, row: dict[str, Any], parent: QWidget | None = None) -> None: lay.addWidget(detail) +class _FacePackRow(QFrame): + """The insightface face pack as a first-class row: status + Download/Remove. + + Unlike ReID (whose weights are managed entirely by the upstream boxmot/torch + caches), the face pack lives in a location AutoPTZ controls, so it can be + downloaded and removed here — but only when it resolves to the app-data cache; + a bundled-in-app or ~/.insightface copy is never removable. + """ + + def __init__( + self, + status: dict[str, Any], + *, + on_download: Any, + on_remove: Any, + parent: QWidget | None = None, + ) -> None: + super().__init__(parent) + self.setFrameShape(QFrame.Shape.NoFrame) + self.setObjectName("facePackRow") + self.setStyleSheet( + f"QFrame#facePackRow {{ background: transparent; border: none;" + f" border-bottom: 1px solid {T.CURRENT.border}; }}" + ) + self.present = bool(status.get("present")) + self.removable = bool(status.get("removable")) + location = str(status.get("location", "missing")) + size = int(status.get("size_bytes", 0) or 0) + + lay = QGridLayout(self) + lay.setContentsMargins(4, 10, 4, 10) + lay.setHorizontalSpacing(10) + lay.setVerticalSpacing(4) + + title = QLabel("Face recognition pack (insightface buffalo_l)") + title.setTextFormat(Qt.TextFormat.RichText) + lay.addWidget(title, 0, 0) + + if not self.present: + status_text, status_color = "NOT DOWNLOADED", T.CURRENT.muted + elif location == "app-data": + status_text, status_color = "DOWNLOADED", T.TRACKING + elif location == "home": + status_text, status_color = "IN ~/.INSIGHTFACE", T.TRACKING + else: # bundled / custom (INSIGHTFACE_HOME) + status_text, status_color = "INCLUDED", T.TRACKING + pill = QLabel(status_text) + pill.setAlignment(Qt.AlignmentFlag.AlignCenter) + pill.setStyleSheet( + f"color: {status_color}; border: 1px solid {status_color};" + f" border-radius: 8px; padding: 1px 8px; font-size: {T.fs(9)}px; font-weight: 700;" + ) + lay.addWidget(pill, 0, 3, Qt.AlignmentFlag.AlignRight) + + self.download_btn = QPushButton("Download") + self.download_btn.setEnabled(not self.present) + self.download_btn.setToolTip( + "Download the ~300 MB face pack so face recognition works." + if not self.present + else "The face pack is already available." + ) + self.download_btn.clicked.connect(on_download) + self.remove_btn = DangerButton("Remove") + self.remove_btn.setEnabled(self.removable) + self.remove_btn.setToolTip( + "Remove the downloaded face pack from the AutoPTZ cache." + if self.removable + else "Only a pack in the AutoPTZ cache can be removed " + "(bundled / ~/.insightface packs can't)." + ) + self.remove_btn.clicked.connect(on_remove) + btns = QHBoxLayout() + btns.addStretch(1) + btns.addWidget(self.remove_btn) + btns.addWidget(self.download_btn) + lay.addLayout(btns, 1, 0, 1, 4) + + detail_bits = ["Named-person confirmation and face identity matching."] + if self.present and size: + detail_bits.append(f"{size / 1e6:.0f} MB") + detail_bits.append(str(status.get("path", ""))) + detail = QLabel(f"{' · '.join(detail_bits)}") + detail.setTextFormat(Qt.TextFormat.RichText) + detail.setWordWrap(True) + lay.addWidget(detail, 2, 0, 1, 4) + lay.setColumnStretch(0, 1) + + def set_controls_enabled(self, enabled: bool) -> None: + self.download_btn.setEnabled(enabled and not self.present) + self.remove_btn.setEnabled(enabled and self.removable) + + class ModelManagerDialog(QDialog): """Interactive model download/removal window.""" @@ -259,6 +363,7 @@ def __init__( self._rows: dict[str, _ModelRow] = {} self._status_by_key: dict[str, dict[str, Any]] = {} self._task: _ModelTask | None = None + self._face_row: _FacePackRow | None = None self.setWindowTitle("AutoPTZ Models") self.setModal(True) self.setMinimumSize(880, 700) @@ -275,9 +380,9 @@ def __init__( "The detector model is the foundation — it finds the bodies that " "everything else builds on. Face recognition, pose and ReID only label " "or stabilise bodies the detector already found, so with no detector " - "model nothing is drawn. Face and ReID weights live in their upstream " - "package caches: AutoPTZ doesn't delete those files, but it unloads them " - "from memory whenever their feature is switched off in Services." + "model nothing is drawn. The face recognition pack can be " + "downloaded or removed here; ReID weights are managed by their " + "upstream package (optional install) and are never deleted by AutoPTZ." ) intro.setTextFormat(Qt.TextFormat.RichText) intro.setWordWrap(True) @@ -491,6 +596,24 @@ def _refresh_rows(self) -> None: else: self._add_empty_note("No AutoPTZ-managed models are downloaded.") + # Face recognition pack sits WITH the other downloadable models (up top), + # with its own Download / Remove (it isn't a checkbox-selectable row). + self._face_row = None + self._add_section_label("Face recognition pack") + try: + from autoptz.engine.runtime.models import default_manager + + face_status = default_manager().face_pack_status() + self._face_row = _FacePackRow( + face_status, + on_download=self._download_face, + on_remove=self._remove_face, + ) + self._list.addWidget(self._face_row) + except Exception: # noqa: BLE001 + log.debug("face pack status failed", exc_info=True) + self._add_empty_note("Face pack status unavailable.") + self._add_section_label("Available to download") if missing: for row in missing: @@ -502,9 +625,7 @@ def _refresh_rows(self) -> None: external = [] try: external = [ - row - for row in (self._client.optionalComponents() or []) - if row.get("key") in {"face", "reid"} + row for row in (self._client.optionalComponents() or []) if row.get("key") == "reid" ] except Exception: # noqa: BLE001 log.debug("optional component inventory failed", exc_info=True) @@ -556,6 +677,8 @@ def _set_all_rows(self, checked: bool) -> None: def _set_busy(self, busy: bool) -> None: for row in self._rows.values(): row.set_controls_enabled(not busy) + if self._face_row is not None: + self._face_row.set_controls_enabled(not busy) for widget in ( self._tier, self._select_missing, @@ -635,6 +758,34 @@ def _run_task(self, action: str) -> None: self._status.setText("Starting model operation...") threading.Thread(target=task.run, name=f"ui-model-{action}", daemon=True).start() + def _download_face(self) -> None: + self._run_face_task("download_face") + + def _remove_face(self) -> None: + answer = QMessageBox.question( + self, + "Remove Face Pack", + "Remove the downloaded face recognition pack from the AutoPTZ cache?", + ) + if answer != QMessageBox.StandardButton.Yes: + return + self._run_face_task("remove_face") + + def _run_face_task(self, action: str) -> None: + if self._task is not None: + return + task = _ModelTask(action, [], client=self._client) + self._task = task + task.progress.connect(self._on_progress) + task.done.connect(self._on_done) + self._set_busy(True) + if action == "download_face": + self._progress.setVisible(True) + self._progress.setRange(0, 0) # indeterminate — no byte progress + self._progress.setFormat("Downloading face recognition pack (~300 MB)…") + self._status.setText("Starting face pack operation...") + threading.Thread(target=task.run, name=f"ui-model-{action}", daemon=True).start() + def _on_client_download_started(self, label: str) -> None: if self._task is not None: return @@ -670,7 +821,7 @@ def _on_done(self, payload: object) -> None: failed = [ row for row in results - if isinstance(row, dict) and row.get("state") not in {"ok", "removed"} + if isinstance(row, dict) and row.get("state") not in {"ok", "removed", "downloaded"} ] # Model sessions were released before the mutation and rebuilt after (see # _ModelTask.run), so a removed model has already stopped drawing boxes and @@ -687,7 +838,11 @@ def _on_done(self, payload: object) -> None: f"Some model operations failed.\n\n{detail}", ) return - if action == "remove": + if action == "remove_face": + self._status.setText("Face recognition pack removed.") + elif action == "download_face": + self._status.setText("Face recognition pack downloaded.") + elif action == "remove": self._status.setText(f"Removed {len(results)} cached model file(s).") else: self._status.setText("Selected models are cached.") diff --git a/autoptz/ui/widgets/main_window.py b/autoptz/ui/widgets/main_window.py index d0aff7f9..e07d05e2 100644 --- a/autoptz/ui/widgets/main_window.py +++ b/autoptz/ui/widgets/main_window.py @@ -17,7 +17,7 @@ import threading from typing import Any -from PySide6.QtCore import QByteArray, QObject, Qt, Signal +from PySide6.QtCore import QByteArray, QObject, Qt, QTimer, Signal from PySide6.QtGui import QAction, QActionGroup, QCloseEvent, QGuiApplication from PySide6.QtWidgets import ( QDockWidget, @@ -94,6 +94,10 @@ def __init__( # window (or None) and whether the main engine was running when we suspended # into Mark (so Return-to-AutoPTZ can restore that exact state). self._mark_window: Any | None = None + # Whether the engine was running just before a Mark run suspended it, so + # Return-to-AutoPTZ can restore that exact state. (Auto-start persistence + # uses the client's auto-start intent, which Mark's system-level stop/start + # never touches — so no separate "suspended" latch is needed.) self._engine_was_running = False # Set if this (suspended) main window is itself closed while a Mark swap is # active — a later Mark Return then has no live window to resume, so it quits @@ -370,14 +374,14 @@ def _build_menus(self) -> None: self._act_start = _action( self, "Start", - self._client.startEngine, + self._client.userStartEngine, "Ctrl+E", "Start the detection/tracking engine and open all enabled cameras.", ) self._act_stop = _action( self, "Stop", - self._client.stopEngine, + self._client.userStopEngine, "Ctrl+Shift+E", "Stop the engine and release all cameras.", ) @@ -403,17 +407,6 @@ def _build_menus(self) -> None: ), ) ) - self._act_experimental = _action( - self, - "Experimental Features...", - self._open_experimental_features, - tip=( - "Toggle curated experimental engine flags (e.g. the shared " - "detection server) and per-camera tracking defaults for new " - "cameras. Most changes need a restart to take effect." - ), - ) - engine.addAction(self._act_experimental) engine.addSeparator() self._act_stop_tracking = _action( self, @@ -540,6 +533,16 @@ def _build_menus(self) -> None: tip="Benchmark this machine with simulated cameras (3DMark-style).", ) ) + self._act_experimental = _action( + self, + "Experimental Features…", + self._open_experimental_features, + tip=( + "Turn optional engine features on or off (e.g. the shared " + "detection server). Most changes apply after a restart." + ), + ) + helpm.addAction(self._act_experimental) helpm.addAction(_action(self, "About AutoPTZ", self._show_about)) def _build_scale_menu(self, view: QMenu) -> None: @@ -680,11 +683,30 @@ def _populate_cameras_menu(self, menu: QMenu | None = None) -> None: else: # List discovered sources directly (check to add, uncheck to remove), # exactly like USB — populated from the background discovery cache. - sources = list(self._ndi_sources_cache) + # This machine's OWN AutoPTZ NDI outputs are hidden: adding one as a + # camera re-ingests our own broadcast (a feedback loop that can nest + # and eats CPU with encode+decode of the same pixels). Another + # machine's AutoPTZ output remains listed — that's a legitimate use. + all_sources = list(self._ndi_sources_cache) + sources = [ + s + for s in all_sources + if not _is_own_ndi_output(str(s.get("name", s.get("uri", "")))) + ] + hidden = len(all_sources) - len(sources) if not sources: msg = "Scanning for NDI sources…" if self._ndi_scanning else "No NDI sources found" placeholder = ndi.addAction(msg) placeholder.setEnabled(False) + if hidden: + note = ndi.addAction( + f"{hidden} AutoPTZ output(s) from this Mac hidden (feedback loop)" + ) + note.setEnabled(False) + note.setToolTip( + "These are AutoPTZ's own NDI output feeds from this computer. " + "Re-adding them as cameras would make AutoPTZ watch itself." + ) for src in sources: name = str(src.get("name", src.get("uri", "?"))) uri = str(src.get("uri", "")) @@ -942,6 +964,16 @@ def _exit_mark_mode(self, *, quit_app: bool) -> None: except Exception: # noqa: BLE001 log.debug("resume engine after mark failed", exc_info=True) + def desired_engine_running(self) -> bool: + """Whether the engine should auto-start next launch — the user's intent. + + This is the persisted auto-start choice, not the momentary ``engineRunning``: + only a deliberate user Stop clears it, so a stopped/failed engine (or one + suspended for a Mark run — Mark stops/starts the engine at the system level, + which never touches the intent) is never trapped off across launches. + """ + return bool(_safe(lambda: self._client.autostartDesired, True)) + def _open_model_manager( self, *, @@ -1392,6 +1424,29 @@ def _restore_geometry(self) -> None: if not self._has_visible_window_frame(): self.resize(1320, 820) self._center_on_primary_screen() + # Saved layouts can restore docks NARROWER than their panels' minimums + # (Qt only enforces minimums on interactive drags) — widen them once the + # restored sizes are actually applied. + QTimer.singleShot(0, self._enforce_dock_minimums) + + def _enforce_dock_minimums(self) -> None: + """Widen any dock the restored layout left narrower than its panel needs. + + ``restoreState`` re-applies saved dock sizes verbatim — including sizes + saved before a panel's minimum width existed (or grew). A too-narrow dock + clips its panel's right edge (cost chips / help badges vanish, captions + hard-cut). Runs one event-loop turn after restore so widths are real. + """ + for dock in self._docks.values(): + widget = dock.widget() + if widget is None or not dock.isVisible() or dock.isFloating(): + continue + need = max(widget.minimumSizeHint().width(), widget.minimumWidth()) + if need > 0 and dock.width() < need: + try: + self.resizeDocks([dock], [need], Qt.Orientation.Horizontal) + except Exception: # noqa: BLE001 + log.debug("dock minimum enforcement failed", exc_info=True) def showEvent(self, event: Any) -> None: # noqa: N802 # Qt can defer creating the dock tab bar until first show; restyle it @@ -1402,11 +1457,12 @@ def showEvent(self, event: Any) -> None: # noqa: N802 if not self._has_visible_window_frame(): self.resize(1320, 820) self._center_on_primary_screen() + # Docks aren't visible until the window shows, so the post-restore + # minimum enforcement skipped them — run it (again) now that they are. + QTimer.singleShot(0, self._enforce_dock_minimums) # Fire the throttled update check once, deferred so first paint isn't blocked. if not self._startup_update_checked: self._startup_update_checked = True - from PySide6.QtCore import QTimer - QTimer.singleShot(900, self._maybe_show_model_setup_on_startup) QTimer.singleShot(2500, self._updates.maybe_check_on_startup) @@ -1485,6 +1541,17 @@ def _center_on_primary_screen(self) -> None: # ── helpers ────────────────────────────────────────────────────────────────── +def _is_own_ndi_output(name: str, host: str | None = None) -> bool: + """True when *name* is THIS machine's own AutoPTZ NDI output feed. + + Thin wrapper over the shared engine-side helper so the NDI menu filter and + the supervisor's feedback-loop warning can never disagree. + """ + from autoptz.engine.discovery.ndi import is_own_autoptz_output + + return is_own_autoptz_output(name, host) + + class _ScanTask(QObject): """Runs a blocking discovery callable off the GUI thread, emitting its result. diff --git a/autoptz/ui/widgets/properties_helpers.py b/autoptz/ui/widgets/properties_helpers.py index 0e8e33dd..abcb5e57 100644 --- a/autoptz/ui/widgets/properties_helpers.py +++ b/autoptz/ui/widgets/properties_helpers.py @@ -11,7 +11,7 @@ from typing import TYPE_CHECKING, Any from PySide6.QtCore import Qt -from PySide6.QtWidgets import QComboBox, QFormLayout, QHBoxLayout, QLabel, QWidget +from PySide6.QtWidgets import QComboBox, QFormLayout, QHBoxLayout, QLabel, QSizePolicy, QWidget from autoptz.ui import theme as T @@ -56,6 +56,13 @@ def _ro_value() -> QLabel: lab = QLabel("—") lab.setStyleSheet(f"color: {T.CURRENT.text};") lab.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) + # A read-only value can be arbitrarily long (an NDI source name, an RTSP + # URL) and a QLabel's minimum width is its FULL text width — without this, + # one long Address silently widens the whole form past the dock and every + # other row (Track button, sliders) stretches/clips with it. Ignored keeps + # the label at exactly the width the layout gives it; the caller elides + # long values with the full text on the tooltip. + lab.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred) return lab diff --git a/autoptz/ui/widgets/properties_panel.py b/autoptz/ui/widgets/properties_panel.py index eae355c8..1429c105 100644 --- a/autoptz/ui/widgets/properties_panel.py +++ b/autoptz/ui/widgets/properties_panel.py @@ -28,6 +28,7 @@ QMessageBox, QPushButton, QScrollArea, + QSizePolicy, QSlider, QSpinBox, QVBoxLayout, @@ -42,6 +43,7 @@ HelpBadge, data_uri_to_pixmap, on_theme_changed, + scroll_content_min_width, ) from autoptz.ui.widgets.joystick import JoystickPad from autoptz.ui.widgets.properties_helpers import ( # noqa: F401 re-exported @@ -80,6 +82,15 @@ ("responsive", "Responsive"), ] +# Base tooltip for the "Track person" combo when nothing identity-specific is +# selected ("— Anyone —"). Overridden with the full, unelided identity name +# whenever a specific person is selected — see _update_target_combo_tooltip. +_TARGET_COMBO_HELP = ( + "Lock tracking to a registered person — the camera follows them " + "whenever they're recognized. Choose “— Anyone —” to follow whoever " + "is detected." +) + _TRACKER_HELP = { "botsort": "BoT-SORT: best default for people. Uses motion plus optional appearance cues; steady but medium cost.", "deepocsort": "DeepOCSORT: stronger through occlusion and crossing people; usually heavier than BoT-SORT.", @@ -242,6 +253,10 @@ def __init__( super().__init__(parent) self.setObjectName("propertiesPanel") self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True) + # Width floor so nothing EVER clips horizontally, whatever a saved + # layout restored: see :meth:`minimumSizeHint`, computed from the real + # (font-metric-driven) content layout rather than a flat guess — the + # invariant is pinned by tests/test_panel_min_width_no_clip.py. self._client = client # Optional live-frame handle (a ``ShmFrameSource`` like the camera tiles # use). When supplied, "Save preset" grabs the current frame as a JPEG @@ -269,6 +284,14 @@ def __init__( self._scroll = QScrollArea(self) self._scroll.setWidgetResizable(True) self._scroll.setFrameShape(QScrollArea.Shape.NoFrame) + # Never scroll sideways: the form fits the panel width; content-hungry + # fields (combos/line edits) are constrained below so labels aren't + # truncated behind a horizontal scrollbar in a narrow dock. + self._scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + # And pin the hidden bar to 0 — Qt can still auto-scroll sideways (focus + # navigation), which reads as content clipped at the left edge. + hsb = self._scroll.horizontalScrollBar() + hsb.valueChanged.connect(lambda v: hsb.setValue(0) if v else None) root.addWidget(self._scroll) body = QWidget() @@ -277,6 +300,7 @@ def __init__( self._col.setSpacing(6) self._build_controls() self._col.addStretch(1) + self._constrain_field_widths(body) self._scroll.setWidget(body) self._scroll.setVisible(False) @@ -311,6 +335,43 @@ def __init__( # save/clear writes the new ``preset_slots`` back asynchronously). _connect(self._client, "configChanged", self._on_config_changed) + def minimumSizeHint(self) -> QSize: # noqa: N802 + """The real floor: the scroll body's live layout minimum + scrollbar chrome. + + Previously a flat ``setMinimumWidth(360)`` — "sized for the widest + expanded section" by eyeballing it once on macOS/Linux — which + silently under-budgets on any font that renders wider at the same + point size (Windows' default UI font measurably does; this is what + broke on Windows CI while macOS/Linux stayed green). Computed instead + from :func:`~autoptz.ui.widgets.common.scroll_content_min_width`, which + reads the scrolled content's OWN (font-metric-driven) layout minimum — + correct on any platform/DPI. ``CollapsibleGroup.minimumSizeHint`` + already reports its EXPANDED content's width even while collapsed, so + this reflects the worst case regardless of which sections happen to be + open right now. ``root`` has zero contents margins, so no extra + padding is needed beyond the scroll area's own chrome. + """ + scroll = getattr(self, "_scroll", None) + if scroll is None: + return super().minimumSizeHint() + return QSize(scroll_content_min_width(scroll), super().minimumSizeHint().height()) + + def _constrain_field_widths(self, body: QWidget) -> None: + """Let content-hungry fields shrink to the panel width. + + QComboBox/QLineEdit report a content-sized minimum width; in a narrow dock + that pushes the form wider than the panel, truncating labels behind a + horizontal scrollbar. Give them an ``Ignored`` horizontal size policy so the + form's ``AllNonFixedFieldsGrow`` fills the available field column and nothing + overflows. Preset thumbnails / the fps readout keep their fixed sizes. + """ + from PySide6.QtWidgets import QComboBox, QLineEdit + + for field in (*body.findChildren(QComboBox), *body.findChildren(QLineEdit)): + vpol = field.sizePolicy().verticalPolicy() + field.setSizePolicy(QSizePolicy.Policy.Ignored, vpol) + field.setMinimumWidth(48) + def _restyle_all(self) -> None: """Re-apply EVERY per-widget literal-color style from the LIVE palette. @@ -485,24 +546,28 @@ def _build_controls(self) -> None: # detector cadence is degraded when the quality ladder has engaged — # same "configured→effective(reason)" transparency as the Detection # section's ``_quality_effective``/``_detect_effective`` rows below. - self._track_state = QLabel("") - self._track_state.setWordWrap(True) + # Both captions are ALWAYS visible, single-line, and elided — text-only + # updates, never show/hide — so the panel below them doesn't jump every + # time the state or the quality ladder changes (which is frequent under + # load). The full text lives in the tooltip. CRITICAL: a non-wrapping + # QLabel's minimum width is its FULL text width, which would silently + # widen the whole scroll body past the dock (clipping every row's right + # edge) — an Ignored horizontal policy keeps them from inflating the + # layout, so they take exactly the width the panel gives them. + self._track_state = QLabel(" ") + self._track_state.setWordWrap(False) + self._track_state.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred) self._muted_captions.append(self._track_state) - self._track_state.setVisible(False) tr.add_widget(self._track_state) - self._track_reason = QLabel("") - self._track_reason.setWordWrap(True) + self._track_reason = QLabel(" ") + self._track_reason.setWordWrap(False) + self._track_reason.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred) self._muted_captions.append(self._track_reason) - self._track_reason.setVisible(False) tr.add_widget(self._track_reason) tf = _form() self._target_combo = QComboBox() - self._target_combo.setToolTip( - "Lock tracking to a registered person — the camera follows them " - "whenever they're recognized. Choose “— Anyone —” to follow whoever " - "is detected." - ) + self._target_combo.setToolTip(_TARGET_COMBO_HELP) self._target_combo.currentIndexChanged.connect(self._on_target_changed) tf.addRow( "Track person", @@ -576,7 +641,7 @@ def _build_controls(self) -> None: ) tf.addRow("Frame on", _with_chip(self._framing, framing_help)) # Builder, part 2 — whether arms are ignored (steady) or included (widen). - self._ignore_arms = QCheckBox("Ignore arms (steadier framing)") + self._ignore_arms = QCheckBox("Ignore arms (steadier)") self._ignore_arms.setToolTip( "On: the aim sits on the body (pose torso) and the zoom stays steady " "when arms move — raising a hand won't yank or widen the shot. " @@ -608,7 +673,7 @@ def _build_controls(self) -> None: # Center Stage: the user-facing toggle for software auto-framing. When on, # this camera uses the digital backend (crop-follow) instead of hardware PTZ; # the raw transport selector (moved to Advanced below) is then overridden. - self._center_stage = QCheckBox("Center Stage — auto-frame this camera (no PTZ hardware)") + self._center_stage = QCheckBox("Center Stage — auto-frame (no PTZ)") self._center_stage.setToolTip( "Software auto-framing: digitally pans and zooms a crop to follow the " "selected target, for cameras without motorised PTZ. Select a person to " @@ -627,6 +692,23 @@ def _build_controls(self) -> None: ), ), ) + self._group_framing = QCheckBox("Frame the group when no one is locked") + self._group_framing.setToolTip( + "With several people in view and no one locked, frame everyone together " + "instead of one person. A person you explicitly lock always wins." + ) + self._group_framing.toggled.connect(self._schedule) + pf.addRow( + "", + _with_chip( + self._group_framing, + HelpBadge( + "When several people are present and you have not locked a target, " + "widen the shot to frame the whole group rather than picking one " + "subject. Locking a person always overrides this." + ), + ), + ) self._auto_zoom = QCheckBox("Auto-zoom to frame the subject") self._auto_zoom.setToolTip( "Let the controller zoom in/out to keep the chosen Framing " @@ -634,10 +716,28 @@ def _build_controls(self) -> None: ) self._auto_zoom.toggled.connect(self._schedule) self._auto_zoom.setVisible(False) - self._vcam_out = QCheckBox("Virtual camera output") + self._ndi_out = QCheckBox("NDI output (other computers)") + self._ndi_out.setToolTip( + "Publish the framed feed as an NDI source on your network, so any " + "computer running an NDI receiver (OBS, vMix, a monitor) can pick it up." + ) + self._ndi_out.toggled.connect(self._schedule) + pf.addRow( + "", + _with_chip( + self._ndi_out, + HelpBadge( + "NDI sends the feed over the network — recommended for using it on " + "OTHER computers. Any NDI receiver on the LAN sees " + '" (AutoPTZ )". Free NDI Tools can also ' + "turn it into a webcam on the receiving machine." + ), + ), + ) + self._vcam_out = QCheckBox("Virtual camera (this computer)") self._vcam_out.setToolTip( - "Publish the auto-framed crop as a virtual camera device " - "(Center Stage / digital backend only)." + "Publish the framed feed as a virtual camera device on THIS computer " + "(Zoom / Teams / OBS). Needs a system virtual-camera driver installed." ) self._vcam_out.toggled.connect(self._schedule) pf.addRow( @@ -645,9 +745,9 @@ def _build_controls(self) -> None: _with_chip( self._vcam_out, HelpBadge( - "When the digital (Center Stage) backend is active, publish the " - "auto-framed crop as a virtual camera device so apps like Zoom " - "or OBS can pick it up without hardware PTZ." + "A virtual camera appears in apps on THIS computer only, and needs a " + "system virtual-camera driver. To reach other computers, use NDI " + "output above." ), ), ) @@ -1046,8 +1146,6 @@ def _on_config_changed(self, camera_id: str) -> None: _safe(lambda: self._client.getCameraConfig(self._camera_id), self._cfg) or self._cfg ) self._refresh_presets() - # Mirror framing changes made on the tile (drag-resize) into the sliders. - self._sync_framing_sliders() def _build_fps_row(self) -> QWidget: """A frame-rate slider with a live value + measured-fps readout. @@ -1081,6 +1179,11 @@ def _build_fps_row(self) -> QWidget: col.addLayout(top) self._fps_measured = QLabel("") self._fps_measured.setObjectName("fpsMeasured") + # This caption grows a long suffix under load ("— source isn't reaching + # 30 fps") — it must WRAP, and must never widen the form (a QLabel's + # minimum width is otherwise its full text width). + self._fps_measured.setWordWrap(True) + self._fps_measured.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred) self._restyle_fps_measured() col.addWidget(self._fps_measured) return holder @@ -1140,6 +1243,9 @@ def set_camera(self, camera_id: str) -> None: self._scroll.setVisible(bool(self._camera_id)) if self._camera_id: self._load() + # Elided values (Address, tracking captions) were set while the + # layout was still settling — re-elide once widths are real. + QTimer.singleShot(0, self._reelide_captions) # ── load / push ────────────────────────────────────────────────────────────── @@ -1153,7 +1259,9 @@ def _load(self) -> None: pz = cfg.get("ptz", {}) or {} self._name.setText(cfg.get("name", "")) self._source_type.setText(src.get("type", "—")) - self._address.setText(_short(src.get("address", ""))) + # Long addresses (NDI names / RTSP URLs) are elided with the full + # value on the tooltip so they can never widen the form. + self._set_caption(self._address, _short(src.get("address", ""))) self._refresh_substream_visibility(src) self._substream.setChecked(bool(src.get("substream", False))) cap = self._fps_cap() @@ -1174,6 +1282,7 @@ def _load(self) -> None: tr.get("framing") or tr.get("aim_region") or "upper_body", ) self._ignore_arms.setChecked((tr.get("aim_body_mode") or "torso") == "torso") + self._group_framing.setChecked(bool(tr.get("group_framing", False))) backend = pz.get("backend", "auto") is_center_stage = backend == "digital" self._center_stage.setChecked(is_center_stage) @@ -1183,6 +1292,7 @@ def _load(self) -> None: self._ptz_baud.setCurrentText(str(pz.get("baud", 9600))) self._auto_zoom.setChecked(bool(pz.get("auto_zoom", False))) self._vcam_out.setChecked(bool(pz.get("vcam_out", False))) + self._ndi_out.setChecked(bool(pz.get("ndi_out", False))) self._refresh_presets() # Tracking target + on/off (driven via dedicated client calls). enabled = _safe( @@ -1295,6 +1405,7 @@ def _push(self) -> None: cfg["tracking"]["aim_body_mode"] = ( "torso" if self._ignore_arms.isChecked() else "full_silhouette" ) + cfg["tracking"]["group_framing"] = self._group_framing.isChecked() cfg["ptz"]["backend"] = ( "digital" if self._center_stage.isChecked() else self._backend.currentText() ) @@ -1305,6 +1416,7 @@ def _push(self) -> None: cfg["ptz"]["baud"] = 9600 cfg["ptz"]["auto_zoom"] = self._auto_zoom.isChecked() cfg["ptz"]["vcam_out"] = self._vcam_out.isChecked() + cfg["ptz"]["ndi_out"] = self._ndi_out.isChecked() cfg["ptz"]["zoom_framing"] = framing # Advanced PTZ internals are no longer normal-user controls. Preserve any # saved legacy values from ``_cfg``; do not rewrite speed/gain/dead-zone @@ -1360,11 +1472,9 @@ def _update_tracking_state_row(self) -> None: state = str(status.get("state", "") or "") if state and state != "idle": headline = str(status.get("headline", "") or "") or state.capitalize() - self._track_state.setText(f"State: {headline} ({state})") - self._track_state.setVisible(True) + self._set_caption(self._track_state, f"State: {headline} ({state})") else: - self._track_state.clear() - self._track_state.setVisible(False) + self._set_caption(self._track_state, "") qs = _safe(lambda: rec.quality_state_as_dict(), {}) if rec is not None else {} configured = int(qs.get("configured_interval", 1) or 1) @@ -1372,11 +1482,48 @@ def _update_tracking_state_row(self) -> None: if configured > 0 and effective > configured: multiplier = quality_multiplier(effective, configured) reason = str(qs.get("reason", "") or "") or "Auto quality ladder engaged." - self._track_reason.setText(f"Degraded ×{multiplier}: {reason}") - self._track_reason.setVisible(True) + self._set_caption(self._track_reason, f"Degraded ×{multiplier}: {reason}") else: - self._track_reason.clear() - self._track_reason.setVisible(False) + self._set_caption(self._track_reason, "") + + @staticmethod + def _set_caption(label: QLabel, text: str) -> None: + """Update an always-visible one-line caption without moving the layout. + + Empty text becomes a single space (keeps the line's height reserved); + long text is elided to the label's current width with the full string on + the tooltip (from which :meth:`resizeEvent` re-elides after a panel + resize, so the ellipsis tracks the real width). The label is never + shown/hidden, so surrounding widgets never shift when the state flips. + """ + full = (text or "").strip() + if not full: + label.setText(" ") + label.setToolTip("") + return + fm = label.fontMetrics() + width = max(60, label.width() - 4) + label.setText(fm.elidedText(full, Qt.TextElideMode.ElideRight, width)) + label.setToolTip(full) + + def resizeEvent(self, event: Any) -> None: # noqa: N802 + super().resizeEvent(event) + # Re-elide the one-line captions to the new width (their full text lives + # on the tooltip) so a narrower panel shows "…" instead of a hard cut. + # DEFERRED one event-loop turn: during resizeEvent the labels still + # report their OLD width, so an immediate elide targets a stale size + # (the bug that left a full-length Address hard-clipped in a narrow + # label). Same deferral after a camera load, when widths settle late. + QTimer.singleShot(0, self._reelide_captions) + + def _reelide_captions(self) -> None: + for label in ( + getattr(self, "_track_state", None), + getattr(self, "_track_reason", None), + getattr(self, "_address", None), + ): + if label is not None and label.toolTip(): + self._set_caption(label, label.toolTip()) def _update_effective_detection(self) -> None: """Echo what the engine is *actually* doing next to the configured values. @@ -1493,6 +1640,26 @@ def _reload_targets(self) -> None: self._target_combo.setCurrentIndex(idx if idx >= 0 else 0) finally: self._target_combo.blockSignals(False) + # setCurrentIndex() above ran signal-blocked, so _on_target_changed's + # tooltip refresh never fired for this (re)population — do it explicitly. + self._update_target_combo_tooltip() + + def _update_target_combo_tooltip(self) -> None: + """Mirror the full identity name on the tooltip — the combo's version + of :meth:`_set_caption`'s elide+tooltip contract for labels. + + A QComboBox has no built-in elide-on-paint for its closed-box text and + no tooltip that tracks the current selection. Combined with + ``_constrain_field_widths`` (every combo in this panel gets an + ``Ignored`` horizontal policy + a 48px floor so the form never widens a + narrow dock), a long registered name can render hard-clipped with no + way to recover the full value. Mirror the CURRENT selection's full, + untruncated name on the tooltip so it is always one hover away; fall + back to the descriptive help text for "— Anyone —" (nothing to show). + """ + ident = self._target_combo.currentData() or "" + name = self._target_combo.currentText().strip() + self._target_combo.setToolTip(name if ident and name else _TARGET_COMBO_HELP) def _on_track_toggled(self, on: bool) -> None: self._apply_track_label(on) @@ -1523,6 +1690,7 @@ def _restyle_track_btn(self, on: bool) -> None: ) def _on_target_changed(self, _index: int) -> None: + self._update_target_combo_tooltip() if self._loading or not self._camera_id: return ident = self._target_combo.currentData() or "" diff --git a/autoptz/ui/widgets/services_panel.py b/autoptz/ui/widgets/services_panel.py index cab0f2f7..3e8a23f1 100644 --- a/autoptz/ui/widgets/services_panel.py +++ b/autoptz/ui/widgets/services_panel.py @@ -28,6 +28,7 @@ HelpBadge, hline, on_theme_changed, + scroll_content_min_width, section_label, ) @@ -124,8 +125,15 @@ def __init__(self, client: Any, parent: QWidget | None = None) -> None: scroll.setWidgetResizable(True) scroll.setFrameShape(QFrame.Shape.NoFrame) scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + # With the bar hidden, Qt can still auto-scroll sideways (e.g. focusing a + # button wider than a narrow dock), which shows up as content clipped at + # the LEFT with no way back. Pin the hidden scrollbar to 0 so the panel + # content always starts at its left edge. + hsb = scroll.horizontalScrollBar() + hsb.valueChanged.connect(lambda v: hsb.setValue(0) if v else None) scroll.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) outer.addWidget(scroll, 1) + self._scroll = scroll body = QWidget() body.setMinimumSize(0, 0) @@ -135,7 +143,8 @@ def __init__(self, client: Any, parent: QWidget | None = None) -> None: root.setContentsMargins(12, 12, 12, 12) root.setSpacing(8) - # header + controls + # header (title row + its own controls row: keeps the panel's minimum + # width small enough that a narrow dock never clips/h-scrolls content) head = QHBoxLayout() head.setSpacing(6) title = QLabel("Services and Status") @@ -148,17 +157,22 @@ def __init__(self, client: Any, parent: QWidget | None = None) -> None: ) ) head.addStretch(1) + root.addLayout(head) + + controls = QHBoxLayout() + controls.setSpacing(6) self._start = QPushButton("Start") - self._start.clicked.connect(client.startEngine) + self._start.clicked.connect(client.userStartEngine) self._stop = QPushButton("Stop") - self._stop.clicked.connect(client.stopEngine) + self._stop.clicked.connect(client.userStopEngine) self._restart = QPushButton("Restart") self._restart.clicked.connect(client.restartEngine) for b in (self._start, self._stop, self._restart): # min-height (not fixed) so vertical padding/descenders aren't clipped. b.setMinimumHeight(26) - head.addWidget(b) - root.addLayout(head) + controls.addWidget(b) + controls.addStretch(1) + root.addLayout(controls) # ── module switches ───────────────────────────────────────────────────── root.addWidget(hline()) @@ -229,22 +243,6 @@ def __init__(self, client: Any, parent: QWidget | None = None) -> None: root.addLayout(self._list) root.addStretch(1) - # ── footer (mirrors the Manage Models button pattern) ───────────────────── - root.addWidget(hline()) - experimental_row = QHBoxLayout() - experimental_row.setSpacing(6) - self._experimental_btn = QPushButton("Experimental...") - self._experimental_btn.setToolTip( - "Toggle curated experimental engine flags (e.g. the shared detection " - "server) and per-camera tracking defaults for new cameras. Most " - "changes need a restart to take effect." - ) - self._experimental_btn.clicked.connect(self._open_experimental_features) - experimental_row.addWidget(self._experimental_btn) - experimental_row.addWidget(HelpBadge(self._experimental_btn.toolTip())) - experimental_row.addStretch(1) - root.addLayout(experimental_row) - self._restyle() on_theme_changed(client, self._restyle) _connect(client, "engineStateChanged", self.refresh) @@ -257,7 +255,26 @@ def __init__(self, client: Any, parent: QWidget | None = None) -> None: self.refresh() def minimumSizeHint(self) -> QSize: # noqa: N802 - return QSize(260, 220) + """The real floor: the scroll body's live layout minimum + scrollbar chrome. + + Previously a flat ``QSize(300, 220)`` — "measured by the offscreen + layout audit" once, on macOS/Linux — which silently under-budgets on + any font that renders wider at the same point size (Windows' default + UI font measurably does; this is what broke on Windows CI, where the + trailing ON/OK/UNAVAILABLE pills and Restart/Enable-all buttons + clipped, while macOS/Linux stayed green). Computed instead from + :func:`~autoptz.ui.widgets.common.scroll_content_min_width`, which + reads the scrolled content's OWN (font-metric-driven) layout minimum — + correct on any platform/DPI. Falls back to the old constant only when + called before construction (``ServicesPanel.__new__`` without + ``__init__``, as in ``test_hud_and_status_fixes.py``'s bypass test — + touching any real Qt method there raises). The main window enforces + this as a real dock floor after layout restore. + """ + scroll = getattr(self, "_scroll", None) + if scroll is None: + return QSize(300, 220) + return QSize(scroll_content_min_width(scroll), 220) def sizeHint(self) -> QSize: # noqa: N802 return QSize(360, 520) @@ -442,11 +459,6 @@ def _open_model_manager(self) -> None: self._refresh_optional_components() self.refresh() - def _open_experimental_features(self) -> None: - from autoptz.ui.widgets.dialogs.experimental import ExperimentalFeaturesDialog - - ExperimentalFeaturesDialog(self._client, parent=self).exec() - def _ensure_row(self, key: str) -> None: if key in self._rows: return diff --git a/docs/configuration.md b/docs/configuration.md index dea53b19..8be5188f 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -44,6 +44,10 @@ Defaults are the validated, broadcast-sane starting point; one concept = one con | `deadzone_x` / `deadzone_y` | `0.05` | Per-axis circular deadzone (used when the safe zone is off). | | `auto_zoom` | `false` | Labs-only during 2.2 stabilization. Fixed zoom is the release default because pan/tilt is easier to stabilize when zoom is not changing the image scale. | | `zoom_framing` | `upper_body` | Auto-zoom target height: `face`, `head_shoulders`, `upper_body`, `full_body`, or `wide`. Mirrors `framing`; `wide` is the one extra (looser) option. | +| `group_framing` | `false` | Per-camera checkbox in **Properties → PTZ** (next to Center Stage): with several people in view and no locked target, frame the whole group instead of one subject. Applies to BOTH Center Stage and physical PTZ (they share one framing-target selector). Locking a person always overrides it. | +| `ndi_out` | `false` | Publish the framed feed as an **NDI network source** (Properties → PTZ) so any computer on the LAN can receive it with a free NDI receiver (OBS, vMix, NDI Tools). Advertised as ` (AutoPTZ )`; override the name with `ndi_output_name`. Reuses the NDI SDK already shipped for input — no extra dependency. Recommended for using the feed on OTHER computers. | +| `ndi_output_name` | `""` | Optional override for the NDI source name (empty → `AutoPTZ `). | +| `vcam_out` | `false` | Publish the framed feed as a **virtual camera on this computer** (Zoom/Teams/OBS), Properties → PTZ. Needs a system virtual-camera driver installed; only visible on this machine. | | `loss_zoom_out` / `reacquire_window_s` | `0.0` / `4.0` | Loss defaults to hold/stop. Zoom-out search is Labs-only until tracking is stable. | | `soft_limits` | none | Optional pan/tilt/zoom travel clamps. | @@ -71,4 +75,13 @@ See [Performance](performance.md) for how these interact with your accelerator. | `AUTOPTZ_UPDATE_REPO` | Override the GitHub repo the updater checks. | The full `AUTOPTZ_*` flag surface — including the experimental flags managed by -Engine → Experimental Features... — is documented in [flags.md](flags.md). +**Help → Experimental Features…** — is documented in [flags.md](flags.md). + +## Engine autostart + +The engine's on/off state is remembered across launches (default **on**), so the +app comes back up the way you left it. Pressing **Stop** and quitting keeps it off +next launch; leaving it running keeps it on. Running **Help → Run AutoPTZ Mark** +suspends the engine for the benchmark and restores your pre-Mark state on return — +and quitting from inside Mark preserves that pre-Mark state too, so a Mark run +never leaves the engine off on the next launch. diff --git a/docs/engineering/retired-experiments.md b/docs/engineering/retired-experiments.md index ea8a8ad4..ef37a7e4 100644 --- a/docs/engineering/retired-experiments.md +++ b/docs/engineering/retired-experiments.md @@ -207,7 +207,20 @@ Only promote after it passes the same release gates as production: 6 and 8 fake NDI streams, CPU/RAM stability, clean shutdown, and no app-induced capture drops. > **Reconsidered 2026-07-03:** the curated Experimental Features dialog is -> mounted again (Engine → Experimental Features…), by explicit user decision for +> mounted again (Help → Experimental Features…), by explicit user decision for > the 2.2.0 release. The 2026-06-29 rationale above still governs the *shape* of > the surface — curated registry entries with honest descriptions and restart > semantics, never a generic dumping ground for research switches. + +## `AUTOPTZ_PROCESS_PER_CAMERA` alias removal (2026-07) + +The retired standalone model-per-child experiment left a compatibility alias +behind: `flags.env_process_per_camera()` and +`process_worker.process_per_camera_enabled()` both just forwarded to +`env_model_server()`. That naming leaked the dead concept into logs and IDE +tooltips (a user saw a model-server fallback message mention a "process-per-camera +/ model-per-child process path"). The alias is deleted; the one live gate is +`env_model_server()` / `process_worker.model_server_workers_enabled()`, and the +fallback log messages now use plain language ("no shared-detection-server slot for +this camera — running the built-in threaded pipeline; restart to attach"). The +`AUTOPTZ_PROCESS_PER_CAMERA` env var remains ignored. diff --git a/docs/flags.md b/docs/flags.md index cccd8926..981e555a 100644 --- a/docs/flags.md +++ b/docs/flags.md @@ -6,19 +6,22 @@ anywhere in `autoptz/`. Categories: -- **Experimental** — managed by the Engine → Experimental Features... dialog - (also reachable from the Services panel). Persisted selections are applied to - `os.environ` by the supervisor at the next engine start - (`Supervisor._apply_experimental_env`, see - `autoptz/engine/runtime/experimental_flags.py` for the full schema). Setting - these directly in the shell also works — the dialog only clobbers a key it - actually persisted, never an operator-exported var it never touched. -- **Operations override** — for advanced/offline/CI deployment, not surfaced in - any dialog. Safe to set by the operator; the app runs fine without them. +- **Experimental Features dialog** — managed by **Help → Experimental Features…**. + Persisted selections are applied to `os.environ` by the supervisor at the next + engine start (`Supervisor._apply_experimental_env`; the registry lives in + `autoptz/engine/runtime/experimental_flags.py`). The dialog groups these into + sections (Experiments / Devices & tuning / Model overrides / Diagnostics). + Setting any of them directly in the shell also works — the dialog only clobbers + a key it actually persisted, never an operator-exported var it never touched, + and it prunes keys it no longer recognises. +- **Operations override** — for advanced/offline/CI deployment, deliberately kept + out of the dialog. Safe to set by the operator; the app runs fine without them. - **Internal/dev** — set BY the app itself (derived from config, not meant to be hand-set) or intended for CI/local development and debugging only. -## Experimental (Engine → Experimental Features...) +## Experimental Features dialog (Help → Experimental Features…) + +**Experiments** | Name | Meaning | Default | | --- | --- | --- | @@ -26,52 +29,70 @@ Categories: | `AUTOPTZ_ASYNC_APPEARANCE` | Run face recognition and appearance ReID on their own thread, overlapping inference. | `1` (on) | | `AUTOPTZ_PTZ_PUMP` | Drive PTZ commands from a dedicated background loop instead of inline on the inference thread. | `0` (off) | | `AUTOPTZ_PTZ_SERIAL_AUTOPROBE` | Scan serial ports for a companion VISCA control port when a USB PTZ camera opens. | `1` (on) | -| `AUTOPTZ_REID_DEVICE` | Force the OSNet appearance (ReID) model onto a specific device (`cpu`/`mps`/`cuda`). | `""` (auto) | -| `AUTOPTZ_COREML_UNITS` | Target for the CoreML execution provider on Apple/Intel-Mac builds. | `""` (ALL) | | `AUTOPTZ_TRUE_LATENCY_LEAD` | Lead the PTZ aim by the measured whole-pipeline dead time instead of just ingest+inference latency. | `0` (off) | -| `AUTOPTZ_NDI_COLOR_FORMAT` | Color format requested from NDI sources (`fastest`/`bgra`). | `fastest` | | `AUTOPTZ_MODEL_SERVER` | Run one shared detection server process that every camera delegates to (best-scaling mode for many cameras; self-healing — auto-respawn, fast-fail, local-detector fallback). Still experimental. | `0` (off) | -The dialog also exposes 4 per-camera `TrackingConfig` defaults (`unified_pose`, -`use_target_associator`, `stage_spread`, `group_framing`) applied to newly added -cameras — these are config fields, not env vars, so they aren't in this table. +**Devices & tuning** -## Operations override +| Name | Meaning | Default | +| --- | --- | --- | +| `AUTOPTZ_REID_DEVICE` | Force the OSNet appearance (ReID) model onto a specific device (`cpu`/`mps`/`cuda`). | `""` (auto) | +| `AUTOPTZ_COREML_UNITS` | Target for the CoreML execution provider on Apple/Intel-Mac builds. | `""` (ALL) | +| `AUTOPTZ_NDI_COLOR_FORMAT` | Color format requested from NDI sources (`fastest`/`bgra`). | `fastest` | + +**Model overrides** | Name | Meaning | Default | | --- | --- | --- | | `AUTOPTZ_MODEL_PATH` | Use this detector ONNX file verbatim; skips download/export. | unset (auto-managed) | | `AUTOPTZ_POSE_MODEL_PATH` | Use this pose ONNX file verbatim. | unset (auto-managed) | -| `AUTOPTZ_MODEL_URL` | Mirror/base URL to fetch a prebuilt detector ONNX from (air-gapped/offline installs); accepts a `{stem}`/`{model}` placeholder. | built-in HuggingFace export URL | +| `AUTOPTZ_MODEL_URL` | Mirror/base URL to fetch a prebuilt detector ONNX from (air-gapped/offline installs); accepts a `{stem}`/`{model}` placeholder. | built-in release export URL | + +**Diagnostics** + +| Name | Meaning | Default | +| --- | --- | --- | +| `AUTOPTZ_MS_DIAG` | Verbose diagnostic logging for the shared detection server. | `0` (off) | +| `AUTOPTZ_SYNTH_DEBUG` | Verbose logging for the synthetic-camera ingest path (bench/Mark tooling). | `0` (off) | + +## Operations override + +Deliberately kept out of the dialog — they change library thread pools, the +profile location, launch behaviour, or the update source, which are operator/CI +concerns rather than per-run tuning. + +| Name | Meaning | Default | +| --- | --- | --- | | `AUTOPTZ_MODEL_URL_` | Per-model override of `AUTOPTZ_MODEL_URL` for one specific weight (e.g. `AUTOPTZ_MODEL_URL_YOLO11M`). | unset | -| `AUTOPTZ_FACE_MODEL` | InsightFace model pack name used for face recognition. | `buffalo_l` | +| `AUTOPTZ_FACE_MODEL` | InsightFace model pack name used for face recognition (also the pack the Model Manager downloads/removes). | `buffalo_l` | | `AUTOPTZ_DB_PATH` | Override the ConfigStore SQLite path. | platform user-data dir | | `AUTOPTZ_UPDATE_REPO` | Override the GitHub repo the in-app updater checks for releases. | the AutoPTZ repo | -| `AUTOPTZ_FORCE_EP` | Force a specific ONNX Runtime execution provider, bypassing auto-detection. | unset (auto) — normally config-driven, see Internal/dev | -| `AUTOPTZ_PRECISION` | Force inference precision (`auto`/`fp32`/`fp16`/`int8`). | `auto` — normally config-driven, see Internal/dev | - -`AUTOPTZ_FORCE_EP` and `AUTOPTZ_PRECISION` are operator-settable overrides, but -in normal operation the supervisor sets them itself from the hardware/config -selection at engine start (see Internal/dev) — set them by hand only to -override that choice for a single run. +| `AUTOPTZ_NO_MODEL_EXPORT` | Disable the Ultralytics/Torch ONNX export fallback; used by CI and locked-down installs where `torch` isn't available. | unset (export allowed) | +| `AUTOPTZ_SKIP_CAMERA_PREFLIGHT` | Skip the startup camera-availability preflight (NDI/RTSP-only or headless runs). | unset (preflight runs) | ## Internal/dev +Set by the app itself, or only used by CI / the Mark harness / logging. + | Name | Meaning | Default | | --- | --- | --- | +| `AUTOPTZ_FORCE_EP` | Force a specific ONNX Runtime execution provider. **Set BY the supervisor** at engine start from the hardware/config selection; hand-set only to override for one run. | unset (auto) | +| `AUTOPTZ_PRECISION` | Force inference precision (`auto`/`fp32`/`fp16`/`int8`). **Set BY the supervisor** from config; hand-set only to override. | `auto` | | `AUTOPTZ_ORT_INTRA_THREADS` | ONNX Runtime intra-op thread cap. **Set BY the supervisor** at engine start from the detected core count; hand-set only for benchmarking. | unset (auto) | | `AUTOPTZ_CV2_THREADS` | OpenCV thread cap. **Set BY the supervisor** alongside `AUTOPTZ_ORT_INTRA_THREADS` to prevent thread-pool oversubscription. | unset (auto) | -| `AUTOPTZ_NO_MODEL_EXPORT` | Disable the Ultralytics/Torch ONNX export fallback; used by CI and locked-down installs where `torch` isn't available. | unset (export allowed) | -| `AUTOPTZ_MS_DIAG` | Verbose diagnostic logging for the model-server process. | unset (off) | | `AUTOPTZ_MARK_GT` | Enable AutoPTZ Mark's ground-truth synthetic-camera scoring path. | unset (off) | | `AUTOPTZ_MARK_NO_AUTOSTART` | Skip Mark's engine auto-start (dev/test harness convenience). | unset (off) | | `AUTOPTZ_START_MARK` | Launch straight into AutoPTZ Mark instead of the normal app on startup. | unset (off) | -| `AUTOPTZ_SYNTH_DEBUG` | Verbose logging for the synthetic-camera ingest path (bench/Mark tooling). | unset (off) | -| `AUTOPTZ_SKIP_CAMERA_PREFLIGHT` | Skip the startup camera-availability preflight check. | unset (preflight runs) | | `AUTOPTZ_NO_COLOR` / `NO_COLOR` | Disable ANSI color in log output. | unset (color on when a TTY) | | `AUTOPTZ_FORCE_COLOR` | Force ANSI color in log output even when not a TTY (e.g. piped CI logs). | unset (off) | -`AUTOPTZ_PROCESS_PER_CAMERA` is **retired and ignored** by the env parser (see -`docs/engineering/retired-experiments.md`) — the standalone model-per-child -experiment it gated is superseded by the shared model-server candidate -(`AUTOPTZ_MODEL_SERVER` above). It is intentionally not listed as a live flag. +> **Why the four `FORCE_EP` / `PRECISION` / `ORT_INTRA_THREADS` / `CV2_THREADS` +> vars aren't in the Experimental dialog:** the supervisor already writes them at +> engine start from the hardware/performance selection. Surfacing them in the +> dialog would create a second writer for the same variable and let the two drift, +> so they stay operator-only. + +`AUTOPTZ_PROCESS_PER_CAMERA` no longer exists as a code path: the standalone +model-per-child experiment it gated is retired (superseded by the shared detection +server, `AUTOPTZ_MODEL_SERVER`) and its compatibility alias was removed. See +`docs/engineering/retired-experiments.md`. diff --git a/docs/installation.md b/docs/installation.md index d2aa35ca..f4389ebf 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -96,18 +96,20 @@ uninstall all `onnxruntime*` packages, then install exactly one of Release builds may start without bundled model weights. Use **Engine → Models...** or run `python -m tools.fetch_models` to cache the detector -tiers, pose model, and InsightFace face pack before going offline. Use -**Engine → Models...** to delete AutoPTZ-managed detector/pose files from the -local cache. +tiers, pose model, and InsightFace face pack before going offline. In that window +you can download or remove the detector/pose models **and the face recognition +pack** (the face pack has its own row with Download / Remove); ReID weights stay +managed by their upstream package. `python -m tools.fetch_models` remains the way +to provision the same packs headlessly for offline installers. AutoPTZ does not silently fetch a missing detector tier when you switch models unless **Automatically download a missing detector tier when I select it** is enabled in that window. The Services panel labels why each model is needed and disables feature controls -whose required model/dependency is missing. Face recognition and ReID model packs -are loaded from `INSIGHTFACE_HOME`, the bundled AutoPTZ model pack, the AutoPTZ -model cache, or their upstream caches. AutoPTZ does not delete those upstream -files; review upstream model licenses before redistributing them. See +whose required model/dependency is missing. The face pack is downloadable/removable +in Model Manager when it lives in the AutoPTZ app-data cache; a pack bundled inside +the app, set via `INSIGHTFACE_HOME`, or in `~/.insightface` is loaded but never +deleted. Review upstream model licenses before redistributing them. See [`NOTICE.md`](../NOTICE.md). ## Verify diff --git a/docs/research/mark-simulation-fidelity.md b/docs/research/mark-simulation-fidelity.md new file mode 100644 index 00000000..2716e4af --- /dev/null +++ b/docs/research/mark-simulation-fidelity.md @@ -0,0 +1,76 @@ +# AutoPTZ Mark — simulation fidelity + +*What the bundled Mark scenes actually measure, what they can't, and when a +rendered 3D PTZ environment would be worth building.* + +AutoPTZ Mark adds fake cameras one at a time and measures how many the machine can +run smoothly, with the real pipeline running on a built-in scene. This note is an +honest read on what those scenes prove — so a green Mark verdict is not mistaken +for evidence it can't give. + +## What the built-in clips exercise + +The source is either a **built-in clip** (real decoded video, the recommended +default) or **live NDI sources** on the network. The clip library +([`autoptz/ui/mark_session.py`](../../autoptz/ui/mark_session.py), `CLIP_LIBRARY`) +covers the pipeline's main capabilities: + +| Clip | Native | Exercises | +|------|--------|-----------| +| **Crowd Crossing** (`crowd`, default) | 720p / 30 | Tracking + re-ID through people crossing and occluding each other | +| **Pedestrians** (`pedestrians`) | 1080p / 30 | Sustained multi-person tracking at HD | +| **Cinematic People** (`cinematic_24`) | 1080p / 24 | Center Stage framing at 24 fps | +| **Cinematic People** (`cinematic_60`) | 1080p / 60 | Center Stage framing at high frame rate | +| **Faces** (`faces`) | 720p / 30 | Face detection + recognition | + +The Mark transcode grid fabricates upscaled and frame-duplicated variants +(720p→1080p→4k, 24/30→60) so a scene can be measured at resolutions/fps it wasn't +recorded at; those synthetic variants are labelled honestly in the pre-flight UI +("(upscaled)", "(frame-duplicated)"). They stress **decode + inference + framing +throughput** at the requested size, which is exactly what the "how many cameras +can this machine run" verdict needs. + +## What the clips cannot simulate + +A fixed-viewpoint recorded clip is decoded and fed to the pipeline. It is a +faithful load test, but it is **not** the physical camera loop: + +- **Real PTZ ego-motion.** The clips never pan/tilt/zoom, so the egomotion / ego-gate + stack (which separates subject motion from camera-induced frame shift) is not + exercised. A Mark run says nothing about how well physical PTZ holds a subject + while the camera itself is moving. +- **Parallax and optical zoom artifacts.** Focus breathing, exposure/white-balance + shifts on zoom, and depth parallax as the lens moves are absent from a flat clip. +- **Closed-loop control latency.** The true PTZ loop is *command → motor motion → + observed pixel change*. A clip has no actuator, so lead-time, slew limiting, and + oscillation behaviour are never closed against real motion — only measured as + telemetry against a scripted scene. +- **Network behaviour.** The live-NDI source option covers some of this (real SDK + buffering and drops); the clip path does not. + +In short: clips are honest for **throughput and scaling** verdicts. They are **not** +evidence of **PTZ control quality**. + +## When a rendered 3D PTZ environment would pay off + +A 3D scene with a virtual PTZ camera that actually pans/tilts/zooms on command +would add exactly the things clips cannot: + +- **Closed-loop control validation** — the rendered camera moves when the controller + commands it, so aim latency, overshoot, oscillation, and reacquire can be measured + end to end. +- **Ground-truth ego-motion** — known camera pose per frame gives a reference for the + egomotion/ego-gate stack instead of inferring correctness. +- **Repeatable regression scenes** — deterministic renders make control-quality + regressions reproducible in CI in a way live cameras never are. + +That is a substantial new subsystem (a renderer, a virtual-camera-motion model, and +ground-truth plumbing). It is worth building **only when a tracking-quality gate +demands closed-loop evidence** — see the control-quality direction in the PTZ-parity +work and `docs/MASTER-PLAN.md`. Until then, the current clips remain the right tool +for throughput/scaling, and real cameras remain the check for control quality. + +A concrete, cheaper-first design for that closed-loop rig — a 2D "virtual PTZ over +a wide canvas" that reuses the existing synthetic-source and PTZ-backend seams, +before any 3D renderer — is written up in +[virtual-ptz-simulation.md](virtual-ptz-simulation.md). diff --git a/docs/research/virtual-ptz-simulation.md b/docs/research/virtual-ptz-simulation.md new file mode 100644 index 00000000..29f58db4 --- /dev/null +++ b/docs/research/virtual-ptz-simulation.md @@ -0,0 +1,93 @@ +# Virtual PTZ simulation — closing the control loop without hardware + +*Design note. Motivated by a concrete, current gap: the PTZ-framing-parity work +(physical PTZ now frames like Center Stage, including group framing) drives real +motors on a code path that can only be validated on a physical PTZ camera. There +is no repeatable, CI-able way to test PTZ **control quality** today.* + +## The gap + +AutoPTZ Mark and the bundled clips are honest for **throughput/scaling** — "how +many cameras can this machine run" — but they are **fixed-viewpoint recorded +video**. They never pan, tilt, or zoom, so they cannot exercise the physical PTZ +control loop at all (see [mark-simulation-fidelity.md](mark-simulation-fidelity.md)). +Everything that makes physical PTZ hard — aim latency, overshoot, oscillation, +reacquire after occlusion, ego-motion separation, and now group-framing +composition on motors — is invisible to the current test surface. The only way to +see it is to point a real PTZ camera at a real scene and watch. + +That means a change like the shared framing target (group aim on physical PTZ) +ships with unit tests that prove the *math* but no automated proof of the +*behavior*. A virtual PTZ environment is the missing piece: a scene whose camera +actually moves when the controller commands it, so the loop is **closed** and +**repeatable**. + +## What a virtual PTZ environment gives you + +A rendered scene + a virtual camera that responds to pan/tilt/zoom commands lets a +test: + +- **Close the loop.** Command → the viewport actually moves → the next frame + reflects it → the controller reacts. This is the one thing clips can't do. +- **Score control quality deterministically.** With known ground-truth subject + position and camera pose per frame, a run yields hard numbers: time-to-center, + overshoot %, settle time, oscillation count, reacquire time, and — for the new + group path — how well the framed union tracks the true group centroid. +- **Regress in CI.** Deterministic renders make control-quality regressions + reproducible without a lab, so a tuning change that adds oscillation fails a + test instead of a demo. +- **Exercise ego-motion.** A moving virtual camera with known pose is exactly the + reference the egomotion/ego-gate stack never gets from a fixed clip. + +## Recommended path: start 2D, not 3D + +A full 3D renderer is a large subsystem and is **not** the cheapest way to close +the loop. The control loop only needs "the observed image moves in response to a +PTZ command." That can be done in 2D first: + +**Phase 1 — 2D virtual PTZ over a wide canvas (cheap, high value).** +A synthetic frame source holds a **wide scene** — a panorama, a large rendered +canvas, or a 4K/8K video — and emits a **cropped viewport** of it. A new virtual +PTZ backend (sibling of the existing `DigitalPTZBackend`) does not move motors; it +**moves the crop viewport**: pan/tilt shift the viewport center, zoom changes the +viewport size. Move one or more synthetic "people" across the wide scene on a +known path. Now: +- The controller commands the virtual backend → the viewport moves → the person's + apparent position in the emitted frame changes → the pipeline detects it → the + controller reacts. The loop is closed. +- Ground truth is free: we know the person's canvas position and the viewport, so + we know the true center error every frame. +- No renderer, no GPU, no hardware — it reuses the synthetic-source and PTZ-backend + seams that already exist (`autoptz/engine/pipeline/ingest.py` synthetic adapter, + `autoptz/engine/ptz/` backends). Latency/actuation can be injected by delaying + when a command takes effect on the viewport, so lead-time and slew behavior are + testable too. + +This Phase-1 rig directly validates the PR2 group-aim path: put two "people" on +the canvas, enable group framing, and assert the viewport centers on their +midpoint and holds without oscillating. + +**Phase 2 — rendered 3D scene (later, if needed).** +A real 3D scene adds parallax, perspective foreshortening, optical-zoom artifacts +(focus breathing), and true 6-DoF camera pose — things a 2D canvas approximates +but doesn't reproduce. Build this only when Phase 1's 2D fidelity is provably the +limiting factor (e.g., ego-motion tests need real parallax). It's a renderer + a +camera-motion model + ground-truth export — a distinct project. + +## When to build it + +- **Now-ish (Phase 1)** is justified: the PTZ-parity work just added motor + behavior with no closed-loop test, and the 2D rig is cheap because it reuses + existing seams. It converts "validate on real cameras" from a manual demo into + an automated gate for the parts that don't need real optics. +- **Phase 2 (3D)** waits for a tracking-quality gate that specifically needs + parallax/optical realism. + +## Non-goals + +- This is **not** a replacement for a final real-camera check — real lenses, + motors, and networks still surface issues no sim will. It's a way to catch + control-quality regressions early and repeatably, and to shrink what has to be + verified by hand on hardware. +- It is **not** part of the current release; it's the recommended next investment + for making physical-PTZ control quality testable. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 742f1674..b3e7367c 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -26,16 +26,15 @@ falls back to a built-in IoU tracker). Face recognition is optional, but when enabled the Services panel should show where the InsightFace `buffalo_l` pack is being loaded from. If it reports a -missing model path, run: - -```bash -python -m tools.fetch_models -``` - -Packaged builds should bundle the pack under `autoptz/models/insightface`. -Source/dev runs can also set `INSIGHTFACE_HOME` to a directory containing -`models/buffalo_l/*.onnx`. Manual click-to-track still works without face -recognition. +missing model path, open **Engine → Models…** and click **Download** on the +"Face recognition pack" row (or, headlessly / for offline installers, run +`python -m tools.fetch_models`). + +Packaged builds bundle the pack under `autoptz/models/insightface`. Source/dev +runs can also set `INSIGHTFACE_HOME` to a directory containing +`models/buffalo_l/*.onnx`. The Model Manager can **remove** the pack only when it +lives in the AutoPTZ app-data cache — a bundled or `~/.insightface` copy is never +deleted. Manual click-to-track still works without face recognition. ## Running on CPU when a GPU is present diff --git a/tests/test_app_mark_routing.py b/tests/test_app_mark_routing.py index 5a3cf529..22bd1b2f 100644 --- a/tests/test_app_mark_routing.py +++ b/tests/test_app_mark_routing.py @@ -39,6 +39,36 @@ def _stub_event_loop(monkeypatch) -> None: monkeypatch.setattr(QApplication, "processEvents", lambda self, *a, **k: None) +def test_persist_uses_window_intended_engine_state() -> None: + """The state persisted for next launch is the user's auto-start INTENT (which + survives a Mark suspend and a failed start), not the live running state.""" + import autoptz.ui.app as app_mod + + class _Win: + def desired_engine_running(self) -> bool: + return True + + class _Client: + engineRunning = False # not running, but intent is ON + autostartDesired = False # (ignored: the window accessor wins) + + assert app_mod._engine_autostart_to_persist(_Win(), _Client()) is True + + +def test_persist_falls_back_to_client_intent_when_accessor_missing() -> None: + """Windows without the accessor fall back to the client's auto-start intent.""" + import autoptz.ui.app as app_mod + + class _Win: + pass + + class _Client: + engineRunning = False # not running... + autostartDesired = True # ...but the intent is ON → persist ON + + assert app_mod._engine_autostart_to_persist(_Win(), _Client()) is True + + def test_run_always_builds_main_window_and_disables_quit_on_close(monkeypatch) -> None: from PySide6.QtWidgets import QApplication diff --git a/tests/test_camera_worker_framing.py b/tests/test_camera_worker_framing.py index bfb1d318..721cc163 100644 --- a/tests/test_camera_worker_framing.py +++ b/tests/test_camera_worker_framing.py @@ -46,6 +46,405 @@ def test_framed_output_records_crop_rect_when_center_stage_active() -> None: assert 0 < cw <= 1920 and 0 < ch <= 1080 +def _standing_kps(): + """COCO-17 keypoints for a standing person: shoulders y=100, hips y=300.""" + from autoptz.engine.pipeline.framing import ( + KP_LEFT_HIP, + KP_LEFT_SHOULDER, + KP_RIGHT_HIP, + KP_RIGHT_SHOULDER, + Keypoint, + ) + + kps = [Keypoint(0.0, 0.0, 0.0)] * 17 + kps[KP_LEFT_SHOULDER] = Keypoint(170.0, 100.0, 0.9) + kps[KP_RIGHT_SHOULDER] = Keypoint(230.0, 100.0, 0.9) + kps[KP_LEFT_HIP] = Keypoint(180.0, 300.0, 0.9) + kps[KP_RIGHT_HIP] = Keypoint(220.0, 300.0, 0.9) + return kps + + +def _locked_worker_with_pose(*, aim_body_mode: str = "torso", raised_arm_bbox=None): + """A bare worker with track 1 locked, a big 'arms raised' bbox, and fresh + cached torso keypoints for that track.""" + import time + + from autoptz.config.models import CameraConfig, TrackingConfig + from autoptz.engine.camera_worker import CameraWorker + from autoptz.engine.runtime.messages import BBox, TrackInfo + + cfg = CameraConfig( + id="cam-fr", + name="Cam FR", + tracking=TrackingConfig(aim_body_mode=aim_body_mode), + ) + w = CameraWorker("cam-fr", cfg, on_telemetry=lambda m: None) + w._tracking_enabled = True # Center Stage only crops while tracking is on + box = raised_arm_bbox or (60.0, 20.0, 340.0, 640.0) # arms up: tall + wide + w._last_tracks = [TrackInfo(track_id=1, bbox=BBox(x1=box[0], y1=box[1], x2=box[2], y2=box[3]))] + w._target_track_id = 1 + # Mirror _pose_aim's success state: the strict per-tick cache AND the + # sticky last-good cache that framing reads. + w._pose_keypoints = _standing_kps() + w._pose_kp_track_id = 1 + w._last_pose_t = time.monotonic() + w._note_good_kps(_standing_kps(), 1, time.monotonic()) + return w, box + + +def test_torso_box_smoother_update_and_reset_are_mutually_exclusive() -> None: + """_torso_stable_box (capture thread: Center Stage crop, AND inference + thread: physical PTZ aim) and _reset_pose_aim (inference thread, on target + change/loss) both mutate the shared _torso_box_smoother. Without a lock, + reset() can land between update()'s read of self._t and its use, raising + TypeError (framing.py: `t - self._t` with self._t suddenly None) — + swallowed by _torso_stable_box's broad except, silently dropping that + tick's Center Stage crop. + + A raw thread-hammer doesn't reliably hit the few-instruction race window + (confirmed: passes even against the unlocked code across repeated runs). + So this widens the window deliberately — patch BoxSmoother.update to block + partway through on an Event, matching the 'read self._t, then use it' shape + of the real bug — and proves a concurrent reset() cannot run its real body + until that update() call has fully returned (i.e. the two are serialized + by a lock at the CameraWorker level, not just individually thread-safe). + """ + import threading + from unittest import mock + + from autoptz.engine.pipeline import framing + + w, _ = _locked_worker_with_pose() + w._torso_stable_box(1) # seed the smoother so update() takes the blend path + assert w._torso_box_smoother is not None + + update_entered = threading.Event() + release_update = threading.Event() + reset_ran = threading.Event() + reset_ran_before_release = threading.Event() + real_update = framing.BoxSmoother.update + real_reset = framing.BoxSmoother.reset + + def slow_update(self, box, t): # noqa: ANN001 + update_entered.set() + release_update.wait(timeout=2.0) + return real_update(self, box, t) + + def tracked_reset(self): # noqa: ANN001 + if not release_update.is_set(): + reset_ran_before_release.set() + reset_ran.set() + return real_reset(self) + + def do_update() -> None: + w._torso_stable_box(1) + + def do_reset() -> None: + update_entered.wait(timeout=2.0) + w._reset_pose_aim() + + with mock.patch.multiple(framing.BoxSmoother, update=slow_update, reset=tracked_reset): + updater = threading.Thread(target=do_update) + resetter = threading.Thread(target=do_reset) + updater.start() + resetter.start() + update_entered.wait(timeout=2.0) + # Give the resetter thread a real window to (wrongly) run concurrently + # if nothing serializes it — generous vs. thread-wakeup latency. + reset_ran.wait(timeout=0.3) + release_update.set() + updater.join(timeout=2.0) + resetter.join(timeout=2.0) + + assert reset_ran.is_set() # sanity: the reset path actually executed + assert not reset_ran_before_release.is_set(), ( + "reset() ran its real body while update() was still in flight — not mutually exclusive" + ) + + +def test_center_stage_composes_the_tracking_dot() -> None: + """The crop must FOLLOW the aim dot (the Center Stage contract): moving the + dot while the framing box stays put re-composes the crop toward it. The old + box-centred placement ignored the dot entirely — 'not trying to keep the + tracking dot centered at all'.""" + import numpy as np + + from autoptz.engine.ptz.digital import DigitalPTZBackend + + w, _ = _locked_worker_with_pose() + w._ptz_backend = DigitalPTZBackend() + t = w._last_tracks[0] + t.is_target = True + t.aim_x, t.aim_y = 960.0, 600.0 + frame = np.zeros((1080, 1920, 3), dtype=np.uint8) + for _ in range(120): + w._framed_output(frame) + x_before, y_before = w._last_digital_crop_rect[:2] + + # Dot moves up 150 px and right 150 px; box and pose keypoints unchanged. + t.aim_x, t.aim_y = 1110.0, 450.0 + for _ in range(120): + w._framed_output(frame) + x_after, y_after = w._last_digital_crop_rect[:2] + assert 110 < (y_before - y_after) < 190 # crop re-composed up with the dot + assert 110 < (x_after - x_before) < 190 # ... and right + + +def test_center_stage_no_crop_when_tracking_disabled() -> None: + """Center Stage must only crop while tracking is enabled: with the Track + toggle off there is no framing target, so the crop eases to full frame.""" + w, _ = _locked_worker_with_pose() + w._tracking_enabled = False + assert w._current_digital_target() is None + + +def test_center_stage_no_crop_when_tracking_feature_off() -> None: + """The global tracking feature switch gates the crop like the per-camera + Track toggle does.""" + w, _ = _locked_worker_with_pose() + w.set_features({"tracking": False}) + assert w._current_digital_target() is None + + +def test_center_stage_torso_box_when_ignore_arms() -> None: + """aim_body_mode="torso" (Ignore arms): the Center Stage crop frames the + pose-torso-derived box, NOT the raw (arms-inflated) detection bbox.""" + from autoptz.engine.pipeline.framing import torso_framing_box + + w, raw_box = _locked_worker_with_pose(aim_body_mode="torso") + target = w._current_digital_target() + assert target is not None + assert target != raw_box + assert target == torso_framing_box(_standing_kps()) + + +def test_center_stage_torso_box_invariant_to_bbox_growth() -> None: + """Raising arms grows the YOLO bbox — the framed target must not change.""" + w1, _ = _locked_worker_with_pose(raised_arm_bbox=(150.0, 60.0, 250.0, 640.0)) + w2, _ = _locked_worker_with_pose(raised_arm_bbox=(20.0, 5.0, 380.0, 640.0)) + assert w1._current_digital_target() == w2._current_digital_target() + + +def test_center_stage_raw_bbox_when_full_silhouette() -> None: + """aim_body_mode="full_silhouette" (include arms) keeps the raw bbox.""" + w, raw_box = _locked_worker_with_pose(aim_body_mode="full_silhouette") + assert w._current_digital_target() == raw_box + + +def test_framing_pose_survives_single_bad_estimate() -> None: + """One failed/inconsistent pose estimate (production clears _pose_keypoints) + must NOT snap framing back to the raw arms-inflated bbox: the last GOOD + keypoints hold the torso box for _POSE_FRAMING_TTL_S.""" + from autoptz.engine.pipeline.framing import torso_framing_box + + w, raw_box = _locked_worker_with_pose() + # Exactly what _pose_aim's failure branch does on one bad estimate: + w._pose_keypoints = None + w._pose_kp_track_id = None + assert w._current_digital_target() == torso_framing_box(_standing_kps()) + assert w._current_digital_target() != raw_box + + +def test_center_stage_raw_bbox_without_pose() -> None: + w, raw_box = _locked_worker_with_pose() + w._reset_pose_aim() # pose fully unavailable — no good keypoints ever held + assert w._current_digital_target() == raw_box + + +def test_center_stage_raw_bbox_when_pose_is_other_track() -> None: + w, raw_box = _locked_worker_with_pose() + # Keypoints belong to someone else (both caches). + w._pose_kp_track_id = 2 + w._last_good_kps_track_id = 2 + assert w._current_digital_target() == raw_box + + +def test_center_stage_raw_bbox_when_pose_stale() -> None: + import time + + w, raw_box = _locked_worker_with_pose() + # No successful estimate for a while (inference thread stalled). + w._last_pose_good_t = time.monotonic() - 5.0 + assert w._current_digital_target() == raw_box + + +def test_pose_runs_for_group_single_person_without_lock() -> None: + """Group framing + one confident person + NO explicit lock: pose must still + be estimated for that person, so the torso-stable framing box exists.""" + import time + + from autoptz.config.models import CameraConfig, TrackingConfig + from autoptz.engine.camera_worker import CameraWorker + from autoptz.engine.runtime.messages import BBox, TrackInfo + + cfg = CameraConfig(id="cam-fr", name="Cam FR", tracking=TrackingConfig(group_framing=True)) + w = CameraWorker("cam-fr", cfg, on_telemetry=lambda m: None) + tracks = [TrackInfo(track_id=7, bbox=BBox(x1=0, y1=0, x2=100, y2=200))] + seen: list[int] = [] + w._pose_aim = lambda t, *a, **k: (seen.append(t.track_id), (None, 0.0, 0.0))[1] + w._maybe_estimate_pose_overlay(tracks, np.zeros((720, 1280, 3), np.uint8), time.monotonic()) + assert seen == [7] + + +def test_pose_not_run_for_group_of_many_without_lock() -> None: + """A multi-person group union has no single subject — no pose focus.""" + import time + + from autoptz.config.models import CameraConfig, TrackingConfig + from autoptz.engine.camera_worker import CameraWorker + from autoptz.engine.runtime.messages import BBox, TrackInfo + + cfg = CameraConfig(id="cam-fr", name="Cam FR", tracking=TrackingConfig(group_framing=True)) + w = CameraWorker("cam-fr", cfg, on_telemetry=lambda m: None) + tracks = [ + TrackInfo(track_id=7, bbox=BBox(x1=0, y1=0, x2=100, y2=200)), + TrackInfo(track_id=8, bbox=BBox(x1=300, y1=0, x2=400, y2=200)), + ] + seen: list[int] = [] + w._pose_aim = lambda t, *a, **k: (seen.append(t.track_id), (None, 0.0, 0.0))[1] + w._maybe_estimate_pose_overlay(tracks, np.zeros((720, 1280, 3), np.uint8), time.monotonic()) + assert seen == [] + + +def _ptz_error_for_box(box, *, aim_body_mode: str = "torso"): + """(error, subject_height) the physical-PTZ path computes for *box* when the + pose fusion is unavailable (worst case: previously pure raw-bbox aim).""" + import time + + w, _ = _locked_worker_with_pose(aim_body_mode=aim_body_mode, raised_arm_bbox=box) + w._pose_aim = lambda *a, **k: (None, 0.0, 0.0) + frame = np.zeros((720, 1280, 3), dtype=np.uint8) + return w._track_error(w._last_tracks[0], frame, time.monotonic(), tracks=w._last_tracks) + + +def test_ptz_track_error_arm_growth_invariant_in_torso_mode() -> None: + """Physical PTZ: with fresh torso keypoints cached, an arm-inflated bbox must + not move the aim error or the zoom subject height ("Ignore arms").""" + err_a, h_a = _ptz_error_for_box((150.0, 60.0, 250.0, 640.0)) # arms down + err_b, h_b = _ptz_error_for_box((20.0, 5.0, 380.0, 640.0)) # arms out+up + assert err_a == err_b + assert h_a == h_b + + +def test_ptz_track_error_follows_bbox_in_full_silhouette_mode() -> None: + """Include-arms mode intentionally keeps the raw-box behaviour.""" + err_a, _ = _ptz_error_for_box((150.0, 60.0, 250.0, 640.0), aim_body_mode="full_silhouette") + err_b, _ = _ptz_error_for_box((20.0, 5.0, 380.0, 640.0), aim_body_mode="full_silhouette") + assert err_a != err_b + + +def test_torso_box_is_smoothed_not_stepped() -> None: + """Pose estimates arrive in ~0.2 s steps; the framing box must EASE toward + a new estimate, never jump onto it (the reported jitter).""" + import time + + from autoptz.engine.pipeline.framing import Keypoint, torso_framing_box + + w, _ = _locked_worker_with_pose() + first = w._current_digital_target() + # Next pose estimate: torso shifted 80 px right (subject moved / kp noise). + shifted = [Keypoint(kp.x + 80.0, kp.y, kp.conf) for kp in _standing_kps()] + w._note_good_kps(shifted, 1, time.monotonic()) + second = w._current_digital_target() + raw = torso_framing_box(shifted) + assert second != raw # no instant jump onto the new estimate + assert abs(second[0] - first[0]) < 8.0 # microseconds later → barely moved + + +def test_head_recovery_has_hysteresis_against_flapping() -> None: + """Once the tilt-up recovery is active, a BORDERLINE head landmark (conf + just above the normal visibility floor) must not flap it off — exit needs a + clearly-visible head.""" + import time + + from autoptz.engine.pipeline.framing import KP_NOSE, Keypoint + + w, _ = _locked_worker_with_pose( + aim_body_mode="full_silhouette", raised_arm_bbox=(150.0, 0.0, 250.0, 720.0) + ) + w._pose_aim = lambda *a, **k: (None, 0.0, 0.0) + frame = np.zeros((720, 1280, 3), dtype=np.uint8) + # Explicit, distinct per-tick timestamps: _track_error memoizes its result + # by (now, track_id) so a real inference loop's single call per tick + # doesn't double-step the aim smoother. Production always advances `now` + # between ticks by construction; bare back-to-back time.monotonic() calls + # in a fast test do NOT reliably advance under Windows' coarser default + # clock resolution (~15.6 ms) — two calls a few microseconds apart can + # read back the SAME value there, which would silently replay tick 2's + # cached result on "tick 3" instead of evaluating it fresh. + now = time.monotonic() + # Tick 1: head fully missing → recovery activates. + (_, ey1), _ = w._track_error(w._last_tracks[0], frame, now, tracks=w._last_tracks) + assert ey1 >= 0.30 + # Tick 2: nose flickers in at conf 0.40 (visible by the 0.35 floor, but not + # CLEARLY visible) — recovery must hold, not flap off. + now += 0.05 + kps = list(_standing_kps()) + kps[KP_NOSE] = Keypoint(200.0, 60.0, 0.40) + w._note_good_kps(kps, 1, now) + (_, ey2), _ = w._track_error(w._last_tracks[0], frame, now, tracks=w._last_tracks) + assert ey2 >= 0.30 + # Tick 3: nose clearly visible (0.60) → recovery releases. + now += 0.05 + kps2 = list(_standing_kps()) + kps2[KP_NOSE] = Keypoint(200.0, 60.0, 0.60) + w._note_good_kps(kps2, 1, now) + (_, ey3), _ = w._track_error(w._last_tracks[0], frame, now, tracks=w._last_tracks) + assert ey3 < 0.30 + + +def test_framing_source_flip_is_logged(caplog) -> None: + """Transparency: when 'Ignore arms' framing degrades torso→bbox (or + recovers), a log line says so — field runs must be diagnosable.""" + import logging + + w, _ = _locked_worker_with_pose() + with caplog.at_level(logging.INFO, logger="autoptz.engine.camera_worker"): + w._current_digital_target() # torso-stable engaged + w._last_pose_good_t -= 30.0 # pose expires mid-run + w._current_digital_target() # → raw bbox + messages = [r.getMessage() for r in caplog.records] + assert any("framing source" in m and "bbox" in m for m in messages), messages + + +def _head_assist_error(box, *, with_head: bool = False): + """(ex, ey) for a worker whose cached pose has torso keypoints and — only + when *with_head* — a confident nose. full_silhouette mode isolates the + assist from the torso-anchor substitution.""" + import time + + from autoptz.engine.pipeline.framing import KP_NOSE, Keypoint + + w, _ = _locked_worker_with_pose(aim_body_mode="full_silhouette", raised_arm_bbox=box) + if with_head: + kps = list(w._pose_keypoints) + kps[KP_NOSE] = Keypoint(200.0, 60.0, 0.9) + w._pose_keypoints = kps + w._note_good_kps(kps, 1, time.monotonic()) + w._pose_aim = lambda *a, **k: (None, 0.0, 0.0) + frame = np.zeros((720, 1280, 3), dtype=np.uint8) + (ex, ey), _ = w._track_error(w._last_tracks[0], frame, time.monotonic(), tracks=w._last_tracks) + return ex, ey + + +def test_head_out_of_view_above_frame_tilts_up() -> None: + """Body visible (torso keypoints), no head landmark, box clipped at the + frame top → the aim error is biased upward so the PTZ recovers the head + instead of parking on the body.""" + _, ey = _head_assist_error((150.0, 0.0, 250.0, 720.0)) # top-clipped, full height + assert ey >= 0.30 + + +def test_no_tilt_assist_when_head_visible() -> None: + _, ey = _head_assist_error((150.0, 0.0, 250.0, 720.0), with_head=True) + assert ey < 0.30 + + +def test_no_tilt_assist_when_box_not_top_clipped() -> None: + _, ey = _head_assist_error((150.0, 200.0, 250.0, 720.0)) + assert ey < 0.0 # aim sits below centre; no artificial up bias + + def test_telemetry_carries_last_digital_crop_rect() -> None: from autoptz.engine.runtime.messages import HealthState, TelemetryMsg diff --git a/tests/test_digital_framer.py b/tests/test_digital_framer.py index dab42436..f1e538f0 100644 --- a/tests/test_digital_framer.py +++ b/tests/test_digital_framer.py @@ -51,6 +51,81 @@ def test_min_frac_floor(self): ) assert h >= 0.34 * 1080 - 1 + def test_dot_anchored_placement_composes_the_dot(self): + # Dot-anchored composition (the Center Stage contract): the crop is + # positioned so the tracking DOT sits at the requested fraction of the + # crop — continuously, not only when some band is violated. + bbox = (860, 100, 1060, 900) # sizes the crop + dot = (960.0, 260.0) + x, y, w, h = desired_crop( + bbox, + 1920, + 1080, + out_aspect=ASPECT, + fill=0.65, + min_frac=0.18, + max_frac=0.50, + headroom=0.06, + anchor_xy=dot, + anchor_place=(0.5, 0.26), + ) + assert abs((x + w * 0.5) - dot[0]) < 1.0 # dot horizontally centred + assert abs((y + h * 0.26) - dot[1]) < 1.0 # dot at the composed height + + def test_dot_anchored_placement_follows_the_dot(self): + # The user-visible bug: moving the dot (head) while the box centre stays + # put (hips fixed) MUST move the crop 1:1 — the old box-centred placement + # ignored it entirely. + bbox = (860, 100, 1060, 900) + kwargs = { + "out_aspect": ASPECT, + "fill": 0.65, + "min_frac": 0.18, + "max_frac": 0.50, + "headroom": 0.06, + "anchor_place": (0.5, 0.26), + } + _, y1, _, _ = desired_crop(bbox, 1920, 1080, anchor_xy=(960.0, 400.0), **kwargs) + _, y2, _, _ = desired_crop(bbox, 1920, 1080, anchor_xy=(960.0, 300.0), **kwargs) + x1, _, _, _ = desired_crop(bbox, 1920, 1080, anchor_xy=(800.0, 400.0), **kwargs) + x2, _, _, _ = desired_crop(bbox, 1920, 1080, anchor_xy=(900.0, 400.0), **kwargs) + assert abs((y1 - y2) - 100.0) < 1.0 # crop follows the dot vertically + assert abs((x2 - x1) - 100.0) < 1.0 # ... and horizontally + + def test_dot_anchored_placement_clamped_at_frame_edges(self): + # A dot near the frame top can't be composed at 26% of the crop without + # leaving the frame — the crop clamps to the edge instead. + bbox = (860, 0, 1060, 800) + x, y, w, h = desired_crop( + bbox, + 1920, + 1080, + out_aspect=ASPECT, + fill=0.65, + min_frac=0.18, + max_frac=0.50, + headroom=0.06, + anchor_xy=(960.0, 10.0), + anchor_place=(0.5, 0.26), + ) + assert y == 0.0 + assert x >= 0.0 and x + w <= 1920 + 1 + + def test_no_anchor_reproduces_box_centred_placement(self): + # Group unions (and any caller without a dot) keep the classic + # box-centred + headroom placement. + bbox = (860, 300, 1060, 700) + kwargs = { + "out_aspect": ASPECT, + "fill": 0.65, + "min_frac": 0.18, + "max_frac": 0.50, + "headroom": 0.06, + } + assert desired_crop(bbox, 1920, 1080, **kwargs) == desired_crop( + bbox, 1920, 1080, anchor_xy=None, **kwargs + ) + def test_far_subject_zooms_tighter_with_lower_min_frac(self): # The far-subject under-zoom fix: a person far from the camera (small in # frame) must zoom in MORE when the framing allows a lower min_frac. With a diff --git a/tests/test_digital_ptz.py b/tests/test_digital_ptz.py index a9935430..2b72379f 100644 --- a/tests/test_digital_ptz.py +++ b/tests/test_digital_ptz.py @@ -52,6 +52,7 @@ def test_framed_output_frames_the_target_when_digital_backend_active(): ) w = CameraWorker("cam-dig-000001", cfg, on_telemetry=lambda m: None) w._ptz_backend = DigitalPTZBackend() + w._tracking_enabled = True # Center Stage only crops while tracking is on # A selected target occupying ~25% of a 1280x720 frame → the auto-framer crops # onto it (zoomed in) and the output is scaled to the configured size. w._target_track_id = 7 diff --git a/tests/test_engine_autostart_intent.py b/tests/test_engine_autostart_intent.py new file mode 100644 index 00000000..f2c1b3c2 --- /dev/null +++ b/tests/test_engine_autostart_intent.py @@ -0,0 +1,62 @@ +"""Engine auto-start persists the user's INTENT, not the momentary running state. + +Root cause of "services are always stopped when I open the program": the persist +logic wrote engineRunning (False whenever the engine wasn't running for ANY +reason), and a persisted-False skipped auto-start — so a stopped engine trapped +itself off across launches. The intent only clears on a deliberate user Stop. +""" + +from __future__ import annotations + +import pytest + + +@pytest.fixture +def qtapp(): + from PySide6.QtWidgets import QApplication + + yield QApplication.instance() or QApplication([]) + + +def _client(): + from autoptz.ui.engine_client import EngineClient + + return EngineClient() + + +def test_autostart_defaults_true(qtapp) -> None: + assert _client().autostartDesired is True + + +def test_never_started_engine_keeps_autostart_true(qtapp) -> None: + """The trap: an engine that never started must NOT record 'don't auto-start'.""" + c = _client() + assert c.engineRunning is False # never started (no supervisor) + assert c.autostartDesired is True # → next launch still auto-starts + + +def test_user_stop_clears_autostart(qtapp) -> None: + c = _client() + c.userStopEngine() + assert c.autostartDesired is False + + +def test_user_start_sets_autostart(qtapp) -> None: + c = _client() + c.userStopEngine() + c.userStartEngine() # may not actually run (no supervisor), but intent is set + assert c.autostartDesired is True + + +def test_system_stop_does_not_change_intent(qtapp) -> None: + """stopEngine (shutdown / Mark suspend / restart) must not touch the intent.""" + c = _client() + c.set_autostart_desired(True) + c.stopEngine() + assert c.autostartDesired is True + + +def test_seed_from_persisted(qtapp) -> None: + c = _client() + c.set_autostart_desired(False) # a deliberate stop persisted last session + assert c.autostartDesired is False diff --git a/tests/test_engine_client_crud.py b/tests/test_engine_client_crud.py index 45bd65bd..23970b4b 100644 --- a/tests/test_engine_client_crud.py +++ b/tests/test_engine_client_crud.py @@ -552,3 +552,49 @@ def test_get_layout(self): found = m.get_layout(lo.id) assert found is not None assert found.name == "Stage" + + +# ── merge / enable must sync the engine (process-mode children) ────────────── + + +class TestIdentityEngineSyncCommands: + """Merge and enable/disable change what the matcher does, so they must + enqueue engine commands too — otherwise process-mode children (isolated + galleries) never learn about them until restart.""" + + def _client_with_identity(self): + client, _ = make_client() + cid = client.addCamera("usb://0", "X") + client.enrollIdentity(cid, "Alice", 1) + iid = client._identity_model.get_all()[0].id + client.drain_commands() + return client, cid, iid + + def test_merge_enqueues_engine_sync_commands(self): + client, cid, keep_id = self._client_with_identity() + client.enrollIdentity(cid, "Bob", 2) + drop_id = next(r.id for r in client._identity_model.get_all() if r.id != keep_id) + client.drain_commands() + + client.mergeIdentities(keep_id, drop_id) + + cmds = client.drain_commands() + renames = [c for c in cmds if c.kind == CmdKind.RENAME_IDENTITY] + deletes = [c for c in cmds if c.kind == CmdKind.DELETE_IDENTITY] + assert [(c.identity_id, c.new_name) for c in renames] == [(keep_id, "Alice")], ( + "merge must touch the kept identity so the merged record is relayed" + ) + assert [c.identity_id for c in deletes] == [drop_id], ( + "merge must tell the engine to drop the folded identity" + ) + + def test_set_enabled_enqueues_touch_command(self): + client, _cid, iid = self._client_with_identity() + + client.setIdentityEnabled(iid, False) + + cmds = client.drain_commands() + renames = [c for c in cmds if c.kind == CmdKind.RENAME_IDENTITY] + assert [(c.identity_id, c.new_name) for c in renames] == [(iid, "Alice")], ( + "toggling enabled must touch the identity so the new state is relayed" + ) diff --git a/tests/test_experimental_dialog.py b/tests/test_experimental_dialog.py index 4a5f0e39..5833e784 100644 --- a/tests/test_experimental_dialog.py +++ b/tests/test_experimental_dialog.py @@ -20,6 +20,12 @@ def qtapp(): yield QApplication.instance() or QApplication([]) +def _flags(): + from autoptz.engine.runtime.experimental_flags import EXPERIMENTAL_FLAGS + + return EXPERIMENTAL_FLAGS + + def _client(settings: dict[str, Any] | None = None) -> SimpleNamespace: store: dict[str, Any] = dict(settings or {}) @@ -119,6 +125,73 @@ def test_dialog_renders_model_server_row(qtapp) -> None: dlg.close() +def test_dialog_renders_sections(qtapp) -> None: + """The dialog groups flags under section headers (uppercased captions).""" + from PySide6.QtWidgets import QLabel + + client = _client() + dlg, _ = _dialog(client) + captions = { + lbl.text() for lbl in dlg.findChildren(QLabel) if lbl.objectName() == "sectionCaption" + } + for section in ("Experiments", "Devices & tuning", "Model overrides", "Diagnostics"): + assert section.upper() in captions, section + dlg.close() + + +def test_dialog_renders_text_and_path_fields(qtapp) -> None: + """Model-override flags render editable text/path fields, not checkboxes.""" + client = _client() + dlg, _ = _dialog(client) + assert "AUTOPTZ_MODEL_PATH" in dlg._text_fields + assert "AUTOPTZ_POSE_MODEL_PATH" in dlg._text_fields + assert "AUTOPTZ_MODEL_URL" in dlg._text_fields + dlg.close() + + +def test_no_tracking_defaults_section(qtapp) -> None: + """The dead "New-camera tracking defaults" section is gone.""" + from PySide6.QtWidgets import QLabel + + client = _client() + dlg, _ = _dialog(client) + assert not hasattr(dlg, "_tracking_boxes") + labels = {lbl.text() for lbl in dlg.findChildren(QLabel)} + assert not any("tracking defaults" in (t or "").lower() for t in labels) + dlg.close() + + +def test_text_field_value_persists_on_apply(qtapp) -> None: + client = _client() + dlg, _ = _dialog(client) + dlg._text_fields["AUTOPTZ_MODEL_URL"].setText("https://example.invalid/models") + dlg._on_apply() + saved = client.getSetting("experimental_features", {}) + assert saved.get("AUTOPTZ_MODEL_URL") == "https://example.invalid/models" + dlg.close() + + +def test_stale_saved_keys_dropped_on_apply(qtapp) -> None: + """A persisted dict with removed keys loads cleanly and is rewritten to the + current registry set on Apply.""" + client = _client( + { + "experimental_features": { + "AUTOPTZ_REID_DEVICE": "cpu", + "group_framing": True, # dead tracking-defaults key + "AUTOPTZ_PTZ_PUMP": "1", + } + } + ) + dlg, _ = _dialog(client) + assert dlg._bool_boxes["AUTOPTZ_PTZ_PUMP"].isChecked() # loaded, tolerated + dlg._on_apply() + saved = client.getSetting("experimental_features", {}) + assert "group_framing" not in saved + assert set(saved) == {f.env_key for f in _flags()} + dlg.close() + + def test_legacy_apply_still_just_persists(qtapp) -> None: """``_apply`` stays a pure persist (no modal) so existing callers/tests hold.""" client = _client() diff --git a/tests/test_experimental_flags.py b/tests/test_experimental_flags.py index a3fd9f28..f023fd51 100644 --- a/tests/test_experimental_flags.py +++ b/tests/test_experimental_flags.py @@ -2,12 +2,14 @@ from __future__ import annotations +import autoptz.engine.runtime.experimental_flags as ef_mod from autoptz.engine.runtime.experimental_flags import ( EXPERIMENTAL_FLAGS, - TRACKING_DEFAULT_FIELDS, ExperimentalFlag, ) +_KINDS = ("bool", "choice", "text", "path") + def test_all_env_keys_unique_and_prefixed() -> None: keys = [f.env_key for f in EXPERIMENTAL_FLAGS] @@ -18,42 +20,106 @@ def test_all_env_keys_unique_and_prefixed() -> None: def test_kinds_and_choices_consistent() -> None: for f in EXPERIMENTAL_FLAGS: assert isinstance(f, ExperimentalFlag) - assert f.kind in ("bool", "choice") + assert f.kind in _KINDS if f.kind == "bool": assert f.choices == () assert f.default in ("0", "1") - else: + elif f.kind == "choice": assert len(f.choices) >= 2 assert f.default in f.choices + else: # text / path — free-form, no choices + assert f.choices == () -def test_descriptions_present() -> None: +def test_descriptions_and_sections_present() -> None: for f in EXPERIMENTAL_FLAGS: assert f.label.strip() assert f.description.strip() + assert f.section.strip() def test_expected_flags_inventoried() -> None: keys = {f.env_key for f in EXPERIMENTAL_FLAGS} assert keys == { + # Experiments "AUTOPTZ_UNIFIED_POSE", "AUTOPTZ_ASYNC_APPEARANCE", "AUTOPTZ_PTZ_PUMP", + "AUTOPTZ_PTZ_SERIAL_AUTOPROBE", + "AUTOPTZ_TRUE_LATENCY_LEAD", + "AUTOPTZ_MODEL_SERVER", + # Devices & tuning "AUTOPTZ_REID_DEVICE", "AUTOPTZ_COREML_UNITS", "AUTOPTZ_NDI_COLOR_FORMAT", + # Model overrides + "AUTOPTZ_MODEL_PATH", + "AUTOPTZ_POSE_MODEL_PATH", + "AUTOPTZ_MODEL_URL", + # Diagnostics + "AUTOPTZ_MS_DIAG", + "AUTOPTZ_SYNTH_DEBUG", + } + + +def test_flags_grouped_into_sections() -> None: + by_section: dict[str, set[str]] = {} + for f in EXPERIMENTAL_FLAGS: + by_section.setdefault(f.section, set()).add(f.env_key) + assert by_section["Experiments"] == { + "AUTOPTZ_UNIFIED_POSE", + "AUTOPTZ_ASYNC_APPEARANCE", + "AUTOPTZ_PTZ_PUMP", "AUTOPTZ_PTZ_SERIAL_AUTOPROBE", "AUTOPTZ_TRUE_LATENCY_LEAD", "AUTOPTZ_MODEL_SERVER", } + assert by_section["Devices & tuning"] == { + "AUTOPTZ_REID_DEVICE", + "AUTOPTZ_COREML_UNITS", + "AUTOPTZ_NDI_COLOR_FORMAT", + } + assert by_section["Model overrides"] == { + "AUTOPTZ_MODEL_PATH", + "AUTOPTZ_POSE_MODEL_PATH", + "AUTOPTZ_MODEL_URL", + } + assert by_section["Diagnostics"] == {"AUTOPTZ_MS_DIAG", "AUTOPTZ_SYNTH_DEBUG"} -def test_process_per_camera_stays_out_of_the_normal_experimental_ui() -> None: - # The standalone model-per-child experiment is retired (see - # docs/engineering/retired-experiments.md) and is NOT the same thing as the - # shared model-server candidate below — it stays out of the registry. +def test_model_path_flags_are_path_kind() -> None: + for key in ("AUTOPTZ_MODEL_PATH", "AUTOPTZ_POSE_MODEL_PATH"): + flag = next(f for f in EXPERIMENTAL_FLAGS if f.env_key == key) + assert flag.kind == "path" + assert flag.default == "" + url = next(f for f in EXPERIMENTAL_FLAGS if f.env_key == "AUTOPTZ_MODEL_URL") + assert url.kind == "text" + assert url.default == "" + + +def test_dead_and_excluded_vars_absent_from_registry() -> None: keys = {f.env_key for f in EXPERIMENTAL_FLAGS} + # Retired / dead — the alias is gone from the codebase entirely. assert "AUTOPTZ_PROCESS_PER_CAMERA" not in keys + # Supervisor-managed hardware vars: a hardware-prefs path already writes these, + # so surfacing them here would create two writers and silent drift. + for hw in ( + "AUTOPTZ_FORCE_EP", + "AUTOPTZ_PRECISION", + "AUTOPTZ_ORT_INTRA_THREADS", + "AUTOPTZ_CV2_THREADS", + ): + assert hw not in keys + # Live but intentionally env-only (dev/CI controls, not GUI-worthy): they stay + # out of the dialog and are documented in docs/flags.md. + assert "AUTOPTZ_NO_MODEL_EXPORT" not in keys + assert "AUTOPTZ_SKIP_CAMERA_PREFLIGHT" not in keys + + +def test_no_tracking_default_fields_export() -> None: + # The dead "New-camera tracking defaults" section is gone: nothing consumed + # those keys for a new camera; group_framing now lives on the per-camera panel. + assert not hasattr(ef_mod, "TRACKING_DEFAULT_FIELDS") def test_model_server_flag_registered() -> None: @@ -61,33 +127,9 @@ def test_model_server_flag_registered() -> None: assert flag.kind == "bool" assert flag.default == "0" # off by default — still experimental assert flag.restart_required is True - assert flag.label.strip() - assert flag.description.strip() - - -def test_true_latency_lead_flag_registered() -> None: - flag = next(f for f in EXPERIMENTAL_FLAGS if f.env_key == "AUTOPTZ_TRUE_LATENCY_LEAD") - assert flag.kind == "bool" - assert flag.default == "0" # default OFF → legacy latency lead - assert flag.restart_required is True - assert flag.label.strip() - assert flag.description.strip() def test_ndi_color_format_uses_real_source_values() -> None: ndi = next(f for f in EXPERIMENTAL_FLAGS if f.env_key == "AUTOPTZ_NDI_COLOR_FORMAT") assert ndi.choices == ("fastest", "bgra") assert ndi.default == "fastest" - - -def test_tracking_default_fields() -> None: - names = [t[0] for t in TRACKING_DEFAULT_FIELDS] - assert names == ["unified_pose", "use_target_associator", "stage_spread", "group_framing"] - defaults = {t[0]: t[3] for t in TRACKING_DEFAULT_FIELDS} - # Mirror config/models.py TrackingConfig defaults exactly. - assert defaults == { - "unified_pose": False, - "use_target_associator": False, - "stage_spread": True, - "group_framing": False, - } diff --git a/tests/test_framing.py b/tests/test_framing.py index decd2fe6..14a657ed 100644 --- a/tests/test_framing.py +++ b/tests/test_framing.py @@ -22,6 +22,7 @@ shoulder_midpoint, subject_height_from_pose, torso_aim_point, + torso_framing_box, ) # COCO-17 has 17 keypoints; build a full list with a low-conf default and fill @@ -132,6 +133,217 @@ def test_invariant_to_arm_motion(self) -> None: moved[9] = Keypoint(110.0, 10.0, 0.9) # raise a wrist assert subject_height_from_pose(moved) == before + def test_full_body_extent_beats_heuristic_when_taller(self) -> None: + """With head + ankle landmarks the REAL body extent (padded) wins over + the 3.3x torso heuristic — 'use body pose estimation more'.""" + kps = list(_STANDING) + kps[KP_NOSE] = Keypoint(200.0, 60.0, 0.9) + kps[15] = Keypoint(190.0, 700.0, 0.9) # left ankle + kps[16] = Keypoint(210.0, 700.0, 0.9) # right ankle + # extent nose→ankle = 640, padded ×1.08 = 691.2 > 660 (3.3×200) + assert abs(subject_height_from_pose(kps) - 640.0 * 1.08) < 1e-6 + + def test_extent_ignores_arm_keypoints(self) -> None: + """A wrist thrown above the head must not stretch the body extent.""" + kps = list(_STANDING) + kps[KP_NOSE] = Keypoint(200.0, 60.0, 0.9) + kps[15] = Keypoint(190.0, 700.0, 0.9) + kps[16] = Keypoint(210.0, 700.0, 0.9) + before = subject_height_from_pose(kps) + kps[9] = Keypoint(110.0, 0.0, 0.9) # left wrist way above the head + kps[10] = Keypoint(290.0, 720.0, 0.9) # right wrist at the floor + assert subject_height_from_pose(kps) == before + + +class TestTorsoFramingBox: + def test_box_from_torso_anchors(self) -> None: + box = torso_framing_box(_STANDING) + assert box is not None + x1, y1, x2, y2 = box + # Height = the same 3.3× shoulder→hip scale as subject_height_from_pose, + # so Center Stage zooms like the physical auto-zoom in torso mode. + assert math.isclose(y2 - y1, 200.0 * 3.3) + # Centred on the shoulder midpoint x, and vertically on the hips (a + # standing body's mid-height), so the head stays inside the box. + assert math.isclose((x1 + x2) * 0.5, 200.0) + assert math.isclose((y1 + y2) * 0.5, 300.0) + # The head (above the shoulders) is inside: top well above shoulder y. + assert y1 < 100.0 + + def test_invariant_to_arm_motion(self) -> None: + """The whole point: raised arms must not grow or shift the framing box.""" + before = torso_framing_box(_STANDING) + moved = list(_STANDING) + moved[7] = Keypoint(120.0, 40.0, 0.9) # left_elbow up high + moved[9] = Keypoint(110.0, 10.0, 0.9) # left_wrist way up + moved[10] = Keypoint(290.0, 10.0, 0.9) # right_wrist way up + assert torso_framing_box(moved) == before + + def test_none_without_both_anchors(self) -> None: + assert torso_framing_box(_pose(ls=(170.0, 100.0, 0.9), rs=(230.0, 100.0, 0.9))) is None + assert torso_framing_box(_pose(lh=(180.0, 300.0, 0.9), rh=(220.0, 300.0, 0.9))) is None + assert torso_framing_box(_pose()) is None + + def test_full_body_extent_centres_on_the_body(self) -> None: + """With head + ankles the box must cover the REAL body (centre at the + extent midpoint), not assume hips-at-mid-height — else feet get cut.""" + kps = list(_STANDING) + kps[KP_NOSE] = Keypoint(200.0, 60.0, 0.9) + kps[15] = Keypoint(190.0, 700.0, 0.9) + kps[16] = Keypoint(210.0, 700.0, 0.9) + box = torso_framing_box(kps) + assert box is not None + x1, y1, x2, y2 = box + assert y1 <= 60.0 and y2 >= 700.0 # head AND feet inside + assert abs((x1 + x2) * 0.5 - 200.0) < 1e-6 # still shoulder-centred x + + def test_none_on_degenerate_span(self) -> None: + flat = _pose( + ls=(170.0, 100.0, 0.9), + rs=(230.0, 100.0, 0.9), + lh=(180.0, 100.0, 0.9), + rh=(220.0, 100.0, 0.9), + ) + assert torso_framing_box(flat) is None + + +def _desk_pose() -> list[Keypoint]: + """A webcam/desk subject: head + shoulders confident, hips hidden (below + the desk / out of frame). Nose at (200, 60), shoulders at y=100, 60 px + apart — the everyday single-camera case where the old hips-required math + silently degraded framing to the raw arms-inflated bbox.""" + kps = [_LOW] * 17 + kps[KP_NOSE] = Keypoint(200.0, 60.0, 0.9) + kps[KP_LEFT_SHOULDER] = Keypoint(170.0, 100.0, 0.9) + kps[KP_RIGHT_SHOULDER] = Keypoint(230.0, 100.0, 0.9) + return kps + + +class TestHipsHiddenFallback: + """Hips hidden (desk/webcam shot) must still yield an ARM-INVARIANT subject + height + framing box from head+shoulders — not None (which made Center + Stage and the zoom fall back to the raw bbox, so waving arms moved the + shot even with pose healthy).""" + + def test_subject_height_from_head_and_shoulders(self) -> None: + # span head→shoulders = 40 → 472; shoulder width = 60 → 246; max wins. + h = subject_height_from_pose(_desk_pose()) + assert h is not None + assert math.isclose(h, 40.0 * 11.8) + + def test_shoulder_width_floors_the_estimate(self) -> None: + # Head tilted down (nose close to the shoulder line): the span estimate + # collapses but the shoulder-width term holds the height steady. + kps = _desk_pose() + kps[KP_NOSE] = Keypoint(200.0, 96.0, 0.9) # span = 4 → 40 via span + h = subject_height_from_pose(kps) + assert h is not None + assert math.isclose(h, 60.0 * 4.1) # width term wins + + def test_height_invariant_to_arm_motion(self) -> None: + before = subject_height_from_pose(_desk_pose()) + moved = _desk_pose() + moved[7] = Keypoint(120.0, 40.0, 0.9) # left elbow up high + moved[9] = Keypoint(110.0, 10.0, 0.9) # left wrist way up + moved[10] = Keypoint(290.0, 10.0, 0.9) # right wrist way up + assert subject_height_from_pose(moved) == before + + def test_framing_box_from_head_and_shoulders(self) -> None: + box = torso_framing_box(_desk_pose()) + assert box is not None + x1, y1, x2, y2 = box + height = 40.0 * 11.8 + assert math.isclose(y2 - y1, height) + assert math.isclose((x1 + x2) * 0.5, 200.0) # shoulder-centred x + # Top sits a crown-pad above the head point so the head stays inside. + assert math.isclose(y1, 60.0 - height * 0.10) + + def test_framing_box_invariant_to_arm_motion(self) -> None: + """The user-visible bug: waving arms must not grow or shift the crop.""" + before = torso_framing_box(_desk_pose()) + moved = _desk_pose() + moved[7] = Keypoint(120.0, 40.0, 0.9) + moved[9] = Keypoint(110.0, 10.0, 0.9) + moved[10] = Keypoint(290.0, 10.0, 0.9) + assert torso_framing_box(moved) == before + + def test_still_none_without_a_head_landmark(self) -> None: + # Shoulders alone give no vertical span to scale from — keep the bbox + # fallback rather than inventing a height. + only_shoulders = _pose(ls=(170.0, 100.0, 0.9), rs=(230.0, 100.0, 0.9)) + assert subject_height_from_pose(only_shoulders) is None + assert torso_framing_box(only_shoulders) is None + + def test_hips_present_keeps_the_torso_math(self) -> None: + # With hips visible the classic 3.3× shoulder→hip span still rules — + # the head fallback must not override the better anchor. + kps = list(_STANDING) + kps[KP_NOSE] = Keypoint(200.0, 60.0, 0.9) + assert subject_height_from_pose(kps) == 200.0 * 3.3 + + +class TestBoxSmoother: + """Time-aware EMA over a framing box: pose estimates arrive in ~0.2 s steps + with keypoint noise — the smoother turns them into a continuous signal so + the framing target never jumps (the reported jitter).""" + + def _smoother(self): + from autoptz.engine.pipeline.framing import BoxSmoother + + return BoxSmoother(tau=0.35) + + def test_first_update_passes_through(self) -> None: + s = self._smoother() + assert s.update((0.0, 0.0, 100.0, 200.0), t=10.0) == (0.0, 0.0, 100.0, 200.0) + + def test_small_dt_barely_moves(self) -> None: + s = self._smoother() + s.update((0.0, 0.0, 100.0, 200.0), t=10.0) + out = s.update((50.0, 0.0, 150.0, 200.0), t=10.001) # 1 ms later + assert abs(out[0] - 0.0) < 1.0 # nearly unmoved + + def test_identical_timestamp_holds_instead_of_snapping(self) -> None: + """Two calls can land on the EXACT same ``t`` — Windows' default + ``time.monotonic()`` resolution (~15.6 ms) makes this common for two + fast back-to-back calls, where macOS/Linux's higher resolution + practically never collides. dt=0 must mean 'no time passed, hold the + smoothed value' (alpha=0), not 'first sample, snap onto the new box' — + the same bug this exact collision caused in + test_camera_worker_framing.py::test_torso_box_is_smoothed_not_stepped + on Windows CI (green on macOS/Linux, where the collision never + happens in practice).""" + s = self._smoother() + s.update((0.0, 0.0, 100.0, 200.0), t=10.0) + out = s.update((80.0, 0.0, 180.0, 200.0), t=10.0) # identical t + assert out == (0.0, 0.0, 100.0, 200.0) # held, not snapped to the new box + + def test_large_dt_converges(self) -> None: + s = self._smoother() + s.update((0.0, 0.0, 100.0, 200.0), t=10.0) + out = s.update((50.0, 0.0, 150.0, 200.0), t=13.0) # 3 s ≫ tau + assert abs(out[0] - 50.0) < 1.0 # essentially at the new target + + def test_step_sequence_moves_monotonically(self) -> None: + s = self._smoother() + s.update((0.0, 0.0, 100.0, 200.0), t=0.0) + xs = [] + for i in range(1, 6): + out = s.update((50.0, 0.0, 150.0, 200.0), t=i * 0.2) + xs.append(out[0]) + assert all(b > a for a, b in zip(xs, xs[1:], strict=False)) # smooth approach + assert 0.0 < xs[0] < 50.0 # no instant jump + + def test_none_holds_last(self) -> None: + s = self._smoother() + s.update((0.0, 0.0, 100.0, 200.0), t=0.0) + assert s.update(None, t=0.2) == (0.0, 0.0, 100.0, 200.0) + + def test_reset_forgets(self) -> None: + s = self._smoother() + s.update((0.0, 0.0, 100.0, 200.0), t=0.0) + s.reset() + assert s.update((50.0, 0.0, 150.0, 200.0), t=0.2) == (50.0, 0.0, 150.0, 200.0) + class TestAimSmoother: def test_first_sample_passes_through(self) -> None: diff --git a/tests/test_framing_target.py b/tests/test_framing_target.py new file mode 100644 index 00000000..b05b9b13 --- /dev/null +++ b/tests/test_framing_target.py @@ -0,0 +1,163 @@ +"""Shared framing-target selection (pure) — the single source of truth both +Center Stage and physical PTZ consume.""" + +from __future__ import annotations + +from types import SimpleNamespace + +import pytest + +from autoptz.engine.framing_target import ( + FramingTarget, + aim_error_for_box, + confident_person_boxes, + select_framing_target, +) + + +def _bbox(x1, y1, x2, y2): + return SimpleNamespace(x1=x1, y1=y1, x2=x2, y2=y2) + + +def _track(track_id, box, *, lost=False): + return SimpleNamespace(track_id=track_id, lost=lost, bbox=_bbox(*box)) + + +def _select(tracks, *, tid=None, iid=None, trusted=None, group=False): + return select_framing_target( + tracks, + target_track_id=tid, + target_identity_id=iid, + trusted_bbox=trusted, + group_framing=group, + ) + + +class TestSharedCompositionTargets: + """One preset table drives BOTH actuators, so Center Stage and physical PTZ + compose the same shot for the same "Framing" setting.""" + + def test_shared_table_values(self) -> None: + from autoptz.engine.framing_target import SUBJECT_HEIGHT_TARGETS + + t = SUBJECT_HEIGHT_TARGETS + # face/head_shoulders deliberately calmer than the old 0.80/0.60 — + # user-reported as "intense" (over-zoomed, twitchy) on both actuators. + assert t["face"] == pytest.approx(0.65) + assert t["head_shoulders"] == pytest.approx(0.52) + assert t["upper_body"] == pytest.approx(0.45) + assert t["full_body"] == pytest.approx(0.30) + assert t["face"] > t["head_shoulders"] > t["upper_body"] > t["full_body"] + + def test_ptz_zoom_targets_come_from_shared_table(self) -> None: + from autoptz.engine.framing_target import SUBJECT_HEIGHT_TARGETS + from autoptz.engine.ptz.controller import _ZOOM_FRAMING_TARGETS + + for key, value in SUBJECT_HEIGHT_TARGETS.items(): + assert _ZOOM_FRAMING_TARGETS[key] == pytest.approx(value), key + + def test_center_stage_fill_comes_from_shared_table(self) -> None: + from autoptz.engine.camera_worker import _CENTERSTAGE_FRAMING + from autoptz.engine.framing_target import SUBJECT_HEIGHT_TARGETS + + for key, value in SUBJECT_HEIGHT_TARGETS.items(): + assert _CENTERSTAGE_FRAMING[key][0] == pytest.approx(value), key + + +def test_explicit_track_lock_returns_that_box() -> None: + tracks = [_track(1, (0, 0, 10, 20)), _track(2, (30, 0, 40, 20))] + ft = _select(tracks, tid=2) + assert ft == FramingTarget((30, 0, 40, 20), False, 2) + + +def test_explicit_lock_wins_over_group() -> None: + """A locked person is followed even with group framing on and a crowd present.""" + tracks = [_track(1, (0, 0, 10, 20)), _track(2, (30, 0, 40, 20))] + ft = _select(tracks, tid=1, group=True) + assert ft.bbox == (0, 0, 10, 20) + assert ft.is_group is False + assert ft.primary_track_id == 1 + + +def test_lock_holds_on_trusted_box_when_live_track_absent() -> None: + ft = _select([_track(9, (0, 0, 1, 1))], tid=2, trusted=(5, 5, 15, 25), group=True) + assert ft.bbox == (5, 5, 15, 25) + assert ft.is_group is False + + +def test_lock_without_trusted_box_returns_none_not_group() -> None: + ft = _select([_track(1, (0, 0, 10, 20)), _track(2, (30, 0, 40, 20))], tid=7, group=True) + assert ft.bbox is None + assert ft.primary_track_id == 7 + + +def test_identity_lock_holds_on_trusted_box() -> None: + ft = _select([], iid="alice", trusted=(2, 3, 4, 5), group=True) + assert ft.bbox == (2, 3, 4, 5) + assert ft.is_group is False + + +def test_no_lock_group_off_returns_none() -> None: + ft = _select([_track(1, (0, 0, 10, 20)), _track(2, (30, 0, 40, 20))], group=False) + assert ft.bbox is None + + +def test_group_single_confident_frames_that_person_not_group() -> None: + ft = _select([_track(1, (10, 10, 20, 30))], group=True) + assert ft.bbox == (10, 10, 20, 30) + assert ft.is_group is False + # The lone person's track id is exposed so the pose-stable ("Ignore arms") + # framing applies to the group-single case exactly like an explicit lock. + assert ft.primary_track_id == 1 + + +def test_group_multiple_confident_frames_union() -> None: + tracks = [_track(1, (0, 5, 10, 25)), _track(2, (30, 0, 40, 20))] + ft = _select(tracks, group=True) + assert ft.bbox == (0, 0, 40, 25) # union + assert ft.is_group is True + assert ft.primary_track_id is None + + +def test_group_ignores_lost_people() -> None: + tracks = [_track(1, (0, 0, 10, 20)), _track(2, (30, 0, 40, 20), lost=True)] + ft = _select(tracks, group=True) + assert ft.bbox == (0, 0, 10, 20) # only the non-lost person + assert ft.is_group is False + + +def test_confident_person_boxes_filters_lost_and_missing() -> None: + tracks = [ + _track(1, (0, 0, 10, 20)), + _track(2, (30, 0, 40, 20), lost=True), + SimpleNamespace(track_id=3, lost=False, bbox=None), + ] + assert confident_person_boxes(tracks) == [(0, 0, 10, 20)] + + +def test_aim_error_centered_box_is_zero() -> None: + (ex, ey), h = aim_error_for_box((45, 45, 55, 55), 100, 100, 0.5) + assert abs(ex) < 1e-9 and abs(ey) < 1e-9 + assert abs(h - 0.10) < 1e-9 + + +def test_aim_error_right_of_center_is_positive_x() -> None: + (ex, ey), _ = aim_error_for_box((80, 45, 90, 55), 100, 100, 0.5) + assert ex > 0 + + +def test_aim_error_above_center_is_positive_y() -> None: + # A box near the top → aim point above center → ey positive (tilt up). + (_ex, ey), _ = aim_error_for_box((45, 0, 55, 10), 100, 100, 0.5) + assert ey > 0 + + +def test_aim_error_fraction_moves_aim_down_the_box() -> None: + # aim_fraction=0.0 aims at the box top; 1.0 aims at the bottom → smaller ey. + (_a, ey_top), _ = aim_error_for_box((45, 10, 55, 90), 100, 100, 0.0) + (_b, ey_bot), _ = aim_error_for_box((45, 10, 55, 90), 100, 100, 1.0) + assert ey_top > ey_bot + + +def test_aim_error_degenerate_frame_is_zero() -> None: + assert aim_error_for_box((0, 0, 1, 1), 0, 0, 0.5) == ((0.0, 0.0), 0.0) diff --git a/tests/test_group_framing_worker.py b/tests/test_group_framing_worker.py index 260d749c..cb527ded 100644 --- a/tests/test_group_framing_worker.py +++ b/tests/test_group_framing_worker.py @@ -29,7 +29,9 @@ def _make_worker(*, group_framing: bool = False, identity_id: str | None = None) config = config.model_copy( update={"target": config.target.model_copy(update={"identity_id": identity_id})} ) - return CameraWorker("test-cam-group12345", config, on_telemetry=lambda m: None) + w = CameraWorker("test-cam-group12345", config, on_telemetry=lambda m: None) + w._tracking_enabled = True # Center Stage only crops while tracking is on + return w def _track(track_id: int, x1: float, y1: float, x2: float, y2: float) -> TrackInfo: diff --git a/tests/test_hud_and_status_fixes.py b/tests/test_hud_and_status_fixes.py new file mode 100644 index 00000000..b2f29500 --- /dev/null +++ b/tests/test_hud_and_status_fixes.py @@ -0,0 +1,362 @@ +"""Fixes from live 5-camera desktop testing (offscreen): + +- the tile's cryptic ``×N`` degradation chip is gone (info moved to the "?" + per-stage tooltip); the state chip never paints over the target label; +- the Engine EP label aggregates mixed worker EPs instead of flapping; +- this machine's own AutoPTZ NDI outputs are filtered from the NDI menu + (feedback loop); +- the Properties tracking captions never show/hide (no layout jumping). +""" + +from __future__ import annotations + +from types import SimpleNamespace + +import pytest + + +@pytest.fixture +def qtapp(): + from PySide6.QtWidgets import QApplication + + yield QApplication.instance() or QApplication([]) + + +# ── tile HUD ───────────────────────────────────────────────────────────────── + + +def _tile(qtapp): + from autoptz.ui.engine_client import EngineClient + from autoptz.ui.widgets.camera_tile import CameraTile + + client = EngineClient() + cid = client.addCamera("usb://0", "Cam") + return CameraTile(cid, client, frame_source=None) + + +def test_degradation_chip_paint_removed(qtapp) -> None: + """The bare ×N tile chip is gone — its info lives in the '?' tooltip now.""" + from autoptz.ui.widgets.camera_tile import CameraTile + + assert not hasattr(CameraTile, "_paint_degradation_chip") + + +def test_perf_tooltip_carries_cadence_when_degraded(qtapp) -> None: + tile = _tile(qtapp) + rec = SimpleNamespace( + fps=20.0, + telemetry=SimpleNamespace( + ingest_ms=5.0, + detect_ms=20.0, + face_ms=0.0, + width=1280, + height=720, + quality_state=None, + ), + camera_config=None, + quality_state_as_dict=lambda: { + "configured_interval": 1, + "detect_interval": 4, + "reason": "over frame budget", + }, + tracking_status_as_dict=lambda: {}, + ) + tip = tile._compose_perf_tooltip(rec) + assert "×4" in tip # cadence surfaced with room for words + tile.deleteLater() + + +def test_target_label_sets_flag_and_state_chip_skips(qtapp) -> None: + """_draw_target_label marks the frame; the paint loop then skips the state + chip — the two never overlap at top-left.""" + from PySide6.QtGui import QColor, QPainter, QPixmap + + tile = _tile(qtapp) + tile.resize(320, 180) + assert tile._target_label_drawn is False + pm = QPixmap(320, 180) + p = QPainter(pm) + try: + tile._draw_target_label(p, "Tracking: prince 92%", QColor("red")) + finally: + p.end() + assert tile._target_label_drawn is True + tile.deleteLater() + + +# ── engine EP label aggregation ────────────────────────────────────────────── + + +def test_engine_ep_label_stable_with_mixed_workers(qtapp) -> None: + """Mixed worker modes (model-server + threaded CoreML) must compose ONE + stable label, not flap between the two on every telemetry message.""" + from autoptz.engine.runtime.messages import TelemetryMsg + from autoptz.ui.engine_client import EngineClient + + c = EngineClient() + a = c.addCamera("usb://0", "A") + b = c.addCamera("usb://1", "B") + changes: list[str] = [] + c.engineStateChanged.connect(lambda: changes.append(c.engineEp)) + + c._on_telemetry_main(TelemetryMsg(camera_id=a, seq=1, ep="CoreMLExecutionProvider")) + assert c.engineEp == "CoreML" + c._on_telemetry_main(TelemetryMsg(camera_id=b, seq=1, ep="model-server")) + assert c.engineEp == "CoreML + model-server" + # More telemetry from either camera must NOT change the label again. + for seq in range(2, 6): + c._on_telemetry_main(TelemetryMsg(camera_id=a, seq=seq, ep="CoreMLExecutionProvider")) + c._on_telemetry_main(TelemetryMsg(camera_id=b, seq=seq, ep="model-server")) + assert c.engineEp == "CoreML + model-server" + assert changes == ["CoreML", "CoreML + model-server"] # exactly two updates + + +def test_engine_ep_label_single_mode_unchanged(qtapp) -> None: + from autoptz.engine.runtime.messages import TelemetryMsg + from autoptz.ui.engine_client import EngineClient + + c = EngineClient() + a = c.addCamera("usb://0", "A") + c._on_telemetry_main(TelemetryMsg(camera_id=a, seq=1, ep="CoreMLExecutionProvider")) + assert c.engineEp == "CoreML" + + +# ── NDI own-output filtering ───────────────────────────────────────────────── + + +def test_is_own_ndi_output_matches_local_autoptz_feeds() -> None: + from autoptz.ui.widgets.main_window import _is_own_ndi_output + + host = "PRINCES-MBP" + assert _is_own_ndi_output("PRINCES-MBP (AutoPTZ MacBook Pro Camera)", host) + # Nested loopback of a loopback is still ours. + assert _is_own_ndi_output( + "PRINCES-MBP (AutoPTZ PRINCES-MBP (AutoPTZ MacBook Pro Camera))", host + ) + assert _is_own_ndi_output("princes-mbp (AutoPTZ Cam)", host) # case-insensitive + + +def test_is_own_ndi_output_keeps_legitimate_sources() -> None: + from autoptz.ui.widgets.main_window import _is_own_ndi_output + + host = "PRINCES-MBP" + # Another machine's AutoPTZ output is a legitimate remote source. + assert not _is_own_ndi_output("STUDIO-PC (AutoPTZ Stage Cam)", host) + # Non-AutoPTZ senders on this machine stay listed (OBS, Test Patterns, …). + assert not _is_own_ndi_output("PRINCES-MBP (OBS)", host) + assert not _is_own_ndi_output("PRINCES-MBP (Test Pattern)", host) + assert not _is_own_ndi_output("", host) + assert not _is_own_ndi_output("no-parens-name", host) + + +# ── properties captions never move the layout ──────────────────────────────── + + +def test_set_caption_reserves_space_and_elides(qtapp) -> None: + from PySide6.QtWidgets import QLabel + + from autoptz.ui.widgets.properties_panel import PropertiesPanel + + label = QLabel(" ") + label.resize(120, 16) + PropertiesPanel._set_caption(label, "") + assert label.text() == " " # keeps its line height — no layout jump + PropertiesPanel._set_caption( + label, "Degraded ×4: Auto quality: over frame budget; detector cadence relaxed" + ) + assert label.text().strip() + assert "Degraded" in label.toolTip() # full text on hover + label.deleteLater() + + +# ── log spam: change/transition-based, never steady-state repeats ──────────── + + +def _bare_worker(): + from autoptz.config.models import CameraConfig + from autoptz.engine.camera_worker import CameraWorker + + cfg = CameraConfig(id="cam-log", name="LogCam") + return CameraWorker("cam-log", cfg, on_telemetry=lambda m: None) + + +def test_center_stage_log_fires_on_change_not_repeat(qtapp, caplog) -> None: + import logging + + import numpy as np + + from autoptz.engine.ptz.digital import DigitalPTZBackend + + w = _bare_worker() + w._ptz_backend = DigitalPTZBackend() + frame = np.zeros((720, 1280, 3), dtype=np.uint8) + with caplog.at_level(logging.INFO, logger="autoptz.engine.camera_worker"): + w._framed_output(frame) # first state (no target) → one line + w._framed_output(frame) # unchanged → silent + w._framed_output(frame) # unchanged → silent + lines = [r for r in caplog.records if "center-stage:" in r.message] + assert len(lines) == 1 + + +def test_inference_behind_logs_transitions_only(qtapp, caplog) -> None: + import logging + + w = _bare_worker() + + def window(captured_delta: int, inferred_delta: int) -> None: + w._frames_captured += captured_delta + w._frames_inferred += inferred_delta + w._maybe_log_drops(w._next_drop_log_t + 1.0) # force the window + + with caplog.at_level(logging.INFO, logger="autoptz.engine.camera_worker"): + window(100, 15) # enters behind → INFO + window(100, 15) # still behind → DEBUG only + window(100, 15) # still behind → DEBUG only + window(100, 95) # recovers → INFO "caught up" + infos = [r for r in caplog.records if r.levelno == logging.INFO] + behind_lines = [r for r in infos if "inference behind" in r.message] + caught_up = [r for r in infos if "caught up" in r.message] + assert len(behind_lines) == 1 # only the transition, not every window + assert len(caught_up) == 1 + + +def test_face_pass_skipped_when_no_tracks(qtapp) -> None: + """An empty scene must not pay the SCRFD full-frame scan.""" + import numpy as np + + w = _bare_worker() + calls: list[int] = [] + w._face = SimpleNamespace( + recognizer=SimpleNamespace(available=True, detect=lambda f: calls.append(1) or []), + service=None, + ) + frame = np.zeros((720, 1280, 3), dtype=np.uint8) + w._maybe_identify(frame, [], now=100.0) # no tracks → no detect + assert calls == [] + # And the timer was not stamped: a person appearing runs the pass at once. + track = SimpleNamespace( + track_id=1, lost=False, bbox=SimpleNamespace(x1=0, y1=0, x2=100, y2=200) + ) + w._maybe_identify(frame, [track], now=100.05) + assert calls == [1] + + +# ── output pump: sinks never run on the capture thread ────────────────────── + + +def test_output_sender_delivers_on_its_own_thread() -> None: + import threading + + import numpy as np + + from autoptz.engine.pipeline.output_sender import OutputSender + + seen: list[str] = [] + done = threading.Event() + + class _Sink: + def send_bgr(self, frame): + seen.append(threading.current_thread().name) + done.set() + + sender = OutputSender(name="t1") + try: + sender.submit(np.zeros((4, 4, 3), dtype=np.uint8), [_Sink()]) + assert done.wait(2.0) + assert seen and "output-sender" in seen[0] # NOT the caller's thread + finally: + sender.close() + + +def test_output_sender_drops_oldest_when_busy() -> None: + import threading + import time + + import numpy as np + + from autoptz.engine.pipeline.output_sender import OutputSender + + delivered: list[int] = [] + release = threading.Event() + first_started = threading.Event() + + class _SlowSink: + def send_bgr(self, frame): + first_started.set() + release.wait(2.0) # hold the pump busy + delivered.append(int(frame[0, 0, 0])) + + sender = OutputSender(name="t2") + try: + mk = lambda v: np.full((2, 2, 3), v, dtype=np.uint8) # noqa: E731 + sink = _SlowSink() + sender.submit(mk(1), [sink]) + assert first_started.wait(2.0) + # While busy, park two more — only the NEWEST must survive. + sender.submit(mk(2), [sink]) + sender.submit(mk(3), [sink]) + release.set() + deadline = time.monotonic() + 2.0 + while len(delivered) < 2 and time.monotonic() < deadline: + time.sleep(0.01) + assert delivered == [1, 3] # frame 2 was replaced, never sent + finally: + sender.close() + + +def test_output_sender_close_is_idempotent_and_fast() -> None: + from autoptz.engine.pipeline.output_sender import OutputSender + + sender = OutputSender(name="t3") + sender.close() + sender.close() # second close must be a no-op + + +def test_is_own_autoptz_output_shared_helper() -> None: + from autoptz.engine.discovery.ndi import is_own_autoptz_output + + assert is_own_autoptz_output("HOSTY (AutoPTZ Cam)", "HOSTY") + assert not is_own_autoptz_output("OTHER (AutoPTZ Cam)", "HOSTY") + assert not is_own_autoptz_output("HOSTY (OBS)", "HOSTY") + + +# ── layout audit regressions: values elide late-settling widths; floors real ─ + + +def test_services_panel_floor_covers_trailing_pills() -> None: + """At 260 the body overflowed the viewport by 22px (scrollbar + margins), + clipping the ON/OK pills and Restart/Enable-all — the floor is 300 now.""" + from autoptz.ui.widgets.services_panel import ServicesPanel + + assert ServicesPanel.minimumSizeHint(ServicesPanel.__new__(ServicesPanel)).width() >= 300 + + +def test_properties_address_elides_after_layout_settles(qtapp, tmp_path) -> None: + """The Address used to be elided against a stale (pre-layout) width, leaving + the full text hard-clipped in a narrow label. After set_camera + one event + loop turn, a long address must be elided with the full value on the tooltip.""" + from pathlib import Path + + from autoptz.config.store import ConfigStore + from autoptz.ui.engine_client import EngineClient + from autoptz.ui.frames import ShmFrameSource + from autoptz.ui.widgets.properties_panel import PropertiesPanel + + client = EngineClient(store=ConfigStore(db_path=Path(tmp_path) / "cfg.db", debounce_s=0)) + long_addr = "ndi://PRINCES-MBP (AutoPTZ PRINCES-MBP (AutoPTZ MacBook Pro Camera))" + cid = client.addCamera(long_addr, "Loopy") + panel = PropertiesPanel(client, frame_source=ShmFrameSource()) + try: + panel.resize(300, 760) + panel.show() + qtapp.processEvents() + panel.set_camera(cid) + qtapp.processEvents() # deferred re-elide fires here + qtapp.processEvents() + label = panel._address + assert long_addr.startswith(label.toolTip()[:20]) # full value on tooltip + shown = label.text() + assert "…" in shown # elided, not hard-clipped + assert label.fontMetrics().horizontalAdvance(shown) <= label.width() + 2 + finally: + panel.deleteLater() diff --git a/tests/test_inference_server_ipc.py b/tests/test_inference_server_ipc.py index ffeda2dc..90f72380 100644 --- a/tests/test_inference_server_ipc.py +++ b/tests/test_inference_server_ipc.py @@ -395,6 +395,149 @@ def _build_local(): writer.close() +def test_remote_pool_provides_local_pose_estimator(monkeypatch) -> None: # noqa: ANN001 + """Model-server mode must not silently kill pose-stable framing. + + Only DETECTION is delegated to the server; pose runs locally in the camera + child on the target's crop a few times a second. Regression this pins: + ``RemotePool`` had no ``pose()`` at all, so ``CameraWorker._ensure_pose`` + caught the AttributeError and permanently cached ``pose = None`` — every + pose-derived behaviour (arm-invariant aim, torso framing box, skeleton + overlay) silently degraded to the raw arms-inflated detection bbox. + """ + import queue + + import autoptz.engine.pipeline.pose as pose_mod + + built = [] + + class _StubPose: + available = True + + def __init__(self, **kwargs): # noqa: ANN003 + built.append(kwargs) + + monkeypatch.setattr(pose_mod, "PoseEstimator", _StubPose) + + name = f"itest_{uuid.uuid4().hex[:8]}" + writer = ShmWriter(name, 16, 16) + client = InferenceClient("camA", queue.Queue(), queue.Queue(), writer) + pool = RemotePool(client) + + pose = pool.pose() + assert isinstance(pose, _StubPose) + assert pool.pose() is pose # cached — exactly one session per camera child + assert len(built) == 1 + # Children never download models (mirrors the local-detector fallback); + # provisioning happens via Manage Models / fetch_models in the parent. + assert built[0].get("allow_download") is False + writer.close() + + +def test_remote_pool_pose_build_failure_degrades_to_none(monkeypatch) -> None: # noqa: ANN001 + """A pose build failure in the child degrades to bbox aim (None), cached — + it must not raise into the worker or retry-build on every call.""" + import queue + + import autoptz.engine.pipeline.pose as pose_mod + + calls = [] + + def _boom(**kwargs): # noqa: ANN003 + calls.append(True) + raise RuntimeError("no pose model") + + monkeypatch.setattr(pose_mod, "PoseEstimator", _boom) + + name = f"itest_{uuid.uuid4().hex[:8]}" + writer = ShmWriter(name, 16, 16) + client = InferenceClient("camA", queue.Queue(), queue.Queue(), writer) + pool = RemotePool(client) + + assert pool.pose() is None + assert pool.pose() is None + assert len(calls) == 1 # failure cached, not retried per tick + writer.close() + + +def test_remote_pool_release_pose_forces_rebuild(monkeypatch) -> None: # noqa: ANN001 + """RemotePool must expose release_pose(), mirroring InferencePool's contract + (drop the cached ref + built flag so the next pose() call rebuilds). + + Without this, Supervisor.release_model_sessions/rebuild_model_sessions's + generic getattr(pool, "release_pose", None) has nothing to call on a + model-server camera child's pool — swapping the pose model in Manage Models + would never invalidate the child's cached self._pose/self._pose_built, so + every camera process would keep the stale pose session until a full app + restart. + """ + import queue + + import autoptz.engine.pipeline.pose as pose_mod + + built = [] + + class _StubPose: + available = True + + def __init__(self, **kwargs): # noqa: ANN003 + built.append(kwargs) + + monkeypatch.setattr(pose_mod, "PoseEstimator", _StubPose) + + name = f"itest_{uuid.uuid4().hex[:8]}" + writer = ShmWriter(name, 16, 16) + client = InferenceClient("camA", queue.Queue(), queue.Queue(), writer) + pool = RemotePool(client) + + first = pool.pose() + assert pool.pose() is first # cached before release + + pool.release_pose() + second = pool.pose() + assert second is not first # rebuilt after release + assert len(built) == 2 + writer.close() + + +def test_remote_pool_release_detector_clears_stale_local_fallback() -> None: + """RemotePool must expose release_detector(), mirroring InferencePool. + + Detection itself is delegated to the server (self._client is just an IPC + handle, not an ORT session to free) — but the R-3 degraded-mode LOCAL + fallback detector (self._local, built once the server is marked ``failed``) + IS local per-child state. Without release_detector() a Manage Models swap + while a camera is running in degraded/local-fallback mode would leave it + stuck on the stale local detector session forever. + """ + import queue + import threading + + name = f"itest_{uuid.uuid4().hex[:8]}" + writer = ShmWriter(name, 16, 16) + client = InferenceClient("camA", queue.Queue(), queue.Queue(), writer) + calls = [] + + def _build_local(): + obj = object() + calls.append(obj) + return obj + + failed = threading.Event() + failed.set() # already in degraded/local-fallback mode + pool = RemotePool(client, failed=failed, build_local_fn=_build_local) + + first = pool.detector() + assert first is calls[0] + assert pool.detector() is first # cached + + pool.release_detector() + second = pool.detector() + assert second is not first # rebuilt after release + assert len(calls) == 2 + writer.close() + + def test_respawned_server_reuses_same_queues_no_client_reconstruction() -> None: """(a)+(d) Kill the server-side thread mid-run, "respawn" it (a fresh serve() loop reusing the SAME req/resp queues and shm reader dict — exactly what the @@ -454,3 +597,142 @@ def attach(_c: str): # noqa: ANN202 t2.join(timeout=1.0) finally: writer.close() + + +# ── real execution provider surfaces through the model-server label ────────── +# +# "model-server" alone hides WHAT is actually running the model (CoreML? CPU?). +# The server knows its detector's real EP; it tags each reply with it, the client +# folds it into its ``ep`` label, and the UI shows "model-server (CoreML)". + + +def test_server_ep_flows_into_client_ep_label() -> None: + import queue + + cam = "camA" + name = f"itest_{uuid.uuid4().hex[:8]}" + writer = ShmWriter(name, 64, 64) + reader = ShmReader(name, 64, 64) + req_q: queue.Queue = queue.Queue() + resp_q: queue.Queue = queue.Queue() + stop = threading.Event() + + t = threading.Thread( + target=serve, + args=(req_q, {cam: resp_q}, {cam: reader}, lambda f: [], stop), + kwargs={"ep_fn": lambda: "CoreMLExecutionProvider"}, + daemon=True, + ) + t.start() + try: + client = InferenceClient(cam, req_q, resp_q, writer, timeout_s=2.0) + assert client.ep == "model-server" # server hasn't spoken yet + client.detect(_frame(1)) + assert client.ep == "model-server (CoreML)" + # RemotePool relays the enriched label to the worker's diagnostics. + assert RemotePool(client).detector_ep == "model-server (CoreML)" + finally: + stop.set() + t.join(timeout=1.0) + writer.close() + reader.close() + + +def test_client_ep_stays_plain_when_server_does_not_report() -> None: + """A server without an ep_fn (old build / detector still loading → empty ep) + keeps the plain label — never 'model-server ()'.""" + import queue + + cam = "camA" + name = f"itest_{uuid.uuid4().hex[:8]}" + writer = ShmWriter(name, 64, 64) + reader = ShmReader(name, 64, 64) + req_q: queue.Queue = queue.Queue() + resp_q: queue.Queue = queue.Queue() + stop = threading.Event() + + t = threading.Thread( + target=serve, args=(req_q, {cam: resp_q}, {cam: reader}, lambda f: [], stop), daemon=True + ) + t.start() + try: + client = InferenceClient(cam, req_q, resp_q, writer, timeout_s=2.0) + client.detect(_frame(1)) + assert client.ep == "model-server" + assert RemotePool(client).detector_ep == "model-server" + finally: + stop.set() + t.join(timeout=1.0) + writer.close() + reader.close() + + +def test_worker_telemetry_ep_follows_late_server_ep() -> None: + """The worker snapshots ``ep`` when the detect stack is built — but the + model-server loads its detector in the BACKGROUND, so the real EP arrives + later. Telemetry must re-read the live detector's ep, not the stale snapshot.""" + from autoptz.config.models import CameraConfig + from autoptz.engine.camera_worker import CameraWorker, _DetectStack + from autoptz.engine.runtime.messages import HealthState + + class _LateEpDetector: + ep = "model-server" + + seen = [] + w = CameraWorker("cam-ep", CameraConfig(id="cam-ep", name="EpCam"), on_telemetry=seen.append) + det = _LateEpDetector() + w._detect = _DetectStack(detector=det, tracker=None, ep=det.ep) + w._ep = det.ep + + w._emit_telemetry(tracks=[], health=HealthState.OK, last_error=None) + assert seen[-1].ep == "model-server" + + det.ep = "model-server (CoreML)" # server finished loading; client label enriched + w._emit_telemetry(tracks=[], health=HealthState.OK, last_error=None) + assert seen[-1].ep == "model-server (CoreML)" + + +def test_worker_release_and_reload_propagate_to_pool_release_methods() -> None: + """CameraWorker._release_inference_models/_reload_inference_models must also + call the INJECTED POOL's own release_detector()/release_pose() (generic + getattr, mirroring Supervisor.release_model_sessions' pattern for the shared + InferencePool). + + For a threaded worker this duplicates the supervisor's direct release of the + ONE shared InferencePool (harmless — release_* is idempotent, and the + supervisor already released it in-process before this ever runs). For a + model-server camera CHILD, though, the worker's pool is a RemotePool living + only inside that child's own process — the supervisor can never reach it + directly — so this is the ONLY call that can ever clear its cached + pose/local-fallback-detector session. Without it, a Manage Models swap never + reaches a model-server child's RemotePool (the bug this test pins). + """ + from autoptz.config.models import CameraConfig + from autoptz.engine.camera_worker import CameraWorker + + class _FakePool: + def __init__(self) -> None: + self.released: list[str] = [] + + def release_detector(self) -> None: + self.released.append("detector") + + def release_pose(self) -> None: + self.released.append("pose") + + w = CameraWorker( + "cam-pool-release", + CameraConfig(id="cam-pool-release", name="PoolReleaseCam"), + on_telemetry=lambda _m: None, + ) + pool = _FakePool() + w.set_inference_pool(pool) + + w._release_inference_models() + assert "detector" in pool.released + assert "pose" in pool.released + + pool.released.clear() + w._reload_inference_models() + assert "detector" in pool.released + assert "pose" in pool.released diff --git a/tests/test_logsetup.py b/tests/test_logsetup.py index 66b36c4e..e1280f94 100644 --- a/tests/test_logsetup.py +++ b/tests/test_logsetup.py @@ -67,3 +67,57 @@ def test_non_tty_stream_has_no_color(self) -> None: for h in list(logging.getLogger().handlers): if getattr(h, "_autoptz_console", False): logging.getLogger().removeHandler(h) + + +# ── third-party warning spam ────────────────────────────────────────────────── +# +# insightface's face_align triggers a skimage FutureWarning ("`estimate` is +# deprecated…") on EVERY alignment call attribution point; with 5 camera child +# processes the console drowns in it. Both the app console setup and the camera +# child logging setup must suppress FutureWarnings attributed to insightface — +# and ONLY insightface, so our own deprecation signals stay visible. + + +def _emit_insightface_future_warning() -> None: + import warnings + + warnings.warn_explicit( + "`estimate` is deprecated since version 0.26 and will be removed", + FutureWarning, + filename="/site-packages/insightface/utils/face_align.py", + lineno=23, + module="insightface.utils.face_align", + ) + + +def test_install_console_logging_suppresses_insightface_future_warnings() -> None: + import warnings + + from autoptz.logsetup import install_console_logging + + with warnings.catch_warnings(record=True) as rec: + warnings.simplefilter("always") + install_console_logging() + _emit_insightface_future_warning() + # Our own FutureWarnings must NOT be swallowed. + warnings.warn_explicit( + "autoptz thing deprecated", + FutureWarning, + filename="/autoptz/engine/foo.py", + lineno=1, + module="autoptz.engine.foo", + ) + msgs = [str(w.message) for w in rec if issubclass(w.category, FutureWarning)] + assert msgs == ["autoptz thing deprecated"] + + +def test_child_logging_suppresses_insightface_future_warnings() -> None: + import warnings + + from autoptz.engine.process_worker import _configure_child_logging + + with warnings.catch_warnings(record=True) as rec: + warnings.simplefilter("always") + _configure_child_logging() + _emit_insightface_future_warning() + assert [w for w in rec if issubclass(w.category, FutureWarning)] == [] diff --git a/tests/test_mark_lifecycle.py b/tests/test_mark_lifecycle.py index 76d7e880..e8f7c395 100644 --- a/tests/test_mark_lifecycle.py +++ b/tests/test_mark_lifecycle.py @@ -83,6 +83,61 @@ def test_confirm_required_before_suspend(qtapp, monkeypatch) -> None: win.deleteLater() +def test_desired_engine_running_reflects_intent(qtapp) -> None: + """desired_engine_running is the auto-start INTENT (default ON), not the live + running state — so a stopped engine is never persisted as 'don't auto-start'.""" + win = _main(qtapp) + assert win.desired_engine_running() is True # default intent ON + win._client.userStopEngine() # deliberate stop + assert win.desired_engine_running() is False + win._client.userStartEngine() # deliberate start + assert win.desired_engine_running() is True + win.deleteLater() + + +def test_mark_suspend_does_not_change_intent(qtapp, monkeypatch) -> None: + """Suspending the engine for a Mark run is a system stop — it must NOT change + the auto-start intent.""" + import autoptz.ui.widgets.main_window as mw + + win = _main(qtapp) + monkeypatch.setattr(mw, "MarkPreflightDialog", _FakeDlg, raising=False) + win._client._engine_running = True # engine running before Mark + win._start_mark() + assert win._client.engineRunning is False # suspended (system stop) + assert win.desired_engine_running() is True # intent unchanged → still ON + win._mark_window.close() + + +def test_quit_from_mark_keeps_intent_on(qtapp, monkeypatch) -> None: + """The original bug: quitting from inside Mark used to persist OFF forever. + Mark never touches the auto-start intent now.""" + from PySide6.QtWidgets import QApplication + + import autoptz.ui.widgets.main_window as mw + + win = _main(qtapp) + monkeypatch.setattr(mw, "MarkPreflightDialog", _FakeDlg, raising=False) + monkeypatch.setattr(QApplication, "quit", lambda *a: None) + win._client._engine_running = True + win._start_mark() + win._mark_window.request_quit() + assert win.desired_engine_running() is True + + +def test_deliberate_stop_survives_a_mark_run(qtapp, monkeypatch) -> None: + """A user who deliberately stopped keeps 'off' across a Mark run too.""" + import autoptz.ui.widgets.main_window as mw + + win = _main(qtapp) + monkeypatch.setattr(mw, "MarkPreflightDialog", _FakeDlg, raising=False) + win._client.userStopEngine() # deliberate off + win._start_mark() + win._mark_window.request_return() + assert win.desired_engine_running() is False # intent honored + win.deleteLater() + + def test_enter_mark_hides_main_and_builds_isolated_window(qtapp, monkeypatch) -> None: import autoptz.ui.widgets.main_window as mw diff --git a/tests/test_mark_preflight.py b/tests/test_mark_preflight.py index f30b88c5..b42ae8e8 100644 --- a/tests/test_mark_preflight.py +++ b/tests/test_mark_preflight.py @@ -46,6 +46,34 @@ def test_defaults_and_session(self, qtapp) -> None: assert ndi_sim_available() or s.source != "ndi" dlg.deleteLater() + def test_source_group_renamed_test_video(self, qtapp) -> None: + """The source group reads "Test video", not "Camera source" (which collided + with the main app's Add-Camera source types).""" + from PySide6.QtWidgets import QGroupBox + + from autoptz.ui.widgets.dialogs.mark_preflight import MarkPreflightDialog + + dlg = MarkPreflightDialog(defaults=MarkSession()) + titles = [b.title() for b in dlg.findChildren(QGroupBox)] + assert "Test video" in titles + assert "Camera source" not in titles + dlg.deleteLater() + + def test_source_labels_plain_language(self, qtapp) -> None: + from autoptz.benchmark.ndi_sim import ndi_sim_available + from autoptz.ui.widgets.dialogs.mark_preflight import MarkPreflightDialog + + dlg = MarkPreflightDialog(defaults=MarkSession()) + clip_text = dlg._clip_radio.text().lower() + assert "built-in clip" in clip_text + ndi_text = dlg._ndi_radio.text().lower() + # The NDI mode creates temporary local senders — not real network cameras. + assert "simulated ndi streams" in ndi_text + assert "network" not in ndi_text # must not imply real network sources + if not ndi_sim_available(): + assert "requires cyndilib" in ndi_text + dlg.deleteLater() + def test_pose_profile_round_trip(self, qtapp) -> None: from autoptz.ui.widgets.dialogs.mark_preflight import MarkPreflightDialog diff --git a/tests/test_model_manager_dialog.py b/tests/test_model_manager_dialog.py new file mode 100644 index 00000000..6ef9fdae --- /dev/null +++ b/tests/test_model_manager_dialog.py @@ -0,0 +1,222 @@ +"""ModelManagerDialog face-pack row (offscreen). + +The face pack is a first-class row (Download / Remove) driven by ModelManager's +app-data cache; ReID stays a read-only upstream-managed row. +""" + +from __future__ import annotations + +from types import SimpleNamespace +from typing import Any + +import pytest + + +@pytest.fixture +def qtapp(): + from PySide6.QtWidgets import QApplication + + yield QApplication.instance() or QApplication([]) + + +class _FakeMgr: + def __init__(self, face_status: dict[str, Any]) -> None: + self._face_status = face_status + self.calls: list[str] = [] + + def app_model_statuses(self) -> list[dict[str, Any]]: + return [] + + def face_pack_status(self) -> dict[str, Any]: + return dict(self._face_status) + + def ensure_face_pack(self) -> list[dict[str, str]]: + self.calls.append("ensure_face_pack") + return [ + {"name": "Face pack", "state": "downloaded", "path": "/x", "size": "1", "error": ""} + ] + + def remove_face_pack(self) -> list[dict[str, str]]: + self.calls.append("remove_face_pack") + return [ + {"name": "w.onnx", "state": "removed", "path": "/x/w.onnx", "size": "1", "error": ""} + ] + + +def _client() -> SimpleNamespace: + store: dict[str, Any] = {} + return SimpleNamespace( + getDetectorModelTier=lambda: "auto", + autoDownloadModels=lambda: False, + optionalComponents=lambda: [ + { + "key": "reid", + "name": "ReID (re-acquire)", + "state": "off", + "detail": "OSNet", + "managed": "upstream", + "why": "stable tracking", + "path": "/reid", + }, + { + "key": "face", + "name": "Face recognition", + "state": "warn", + "detail": "insightface", + "managed": "downloadable", + "why": "identity", + "path": "/face", + }, + ], + getSetting=lambda k, d=None: store.get(k, d), + setSetting=lambda k, v: store.__setitem__(k, v), + releaseModelSessions=lambda include_face=False: store.__setitem__( + "rel", store.get("rel", 0) + 1 + ) + or store.__setitem__("rel_face", include_face), + rebuildModelSessions=lambda include_face=False: store.__setitem__( + "reb", store.get("reb", 0) + 1 + ) + or store.__setitem__("reb_face", include_face), + _store=store, + ) + + +def _dialog(qtapp, monkeypatch, face_status: dict[str, Any]): + import autoptz.engine.runtime.models as models + from autoptz.ui.widgets.dialogs.model_manager import ModelManagerDialog + + mgr = _FakeMgr(face_status) + monkeypatch.setattr(models, "default_manager", lambda: mgr) + dlg = ModelManagerDialog(_client()) + return dlg, mgr + + +_MISSING = { + "model": "buffalo_l", + "location": "missing", + "path": "/c", + "present": False, + "removable": False, + "size_bytes": 0, +} +_APPDATA = { + "model": "buffalo_l", + "location": "app-data", + "path": "/c", + "present": True, + "removable": True, + "size_bytes": 300_000_000, +} +_BUNDLED = { + "model": "buffalo_l", + "location": "bundled", + "path": "/b", + "present": True, + "removable": False, + "size_bytes": 300_000_000, +} +_HOME = { + "model": "buffalo_l", + "location": "home", + "path": "/h", + "present": True, + "removable": True, + "size_bytes": 300_000_000, +} + + +def test_face_row_download_enabled_when_missing(qtapp, monkeypatch) -> None: + dlg, _ = _dialog(qtapp, monkeypatch, _MISSING) + try: + assert dlg._face_row is not None + assert dlg._face_row.download_btn.isEnabled() is True + assert dlg._face_row.remove_btn.isEnabled() is False + finally: + dlg.close() + + +def test_face_row_remove_enabled_only_for_appdata(qtapp, monkeypatch) -> None: + dlg, _ = _dialog(qtapp, monkeypatch, _APPDATA) + try: + assert dlg._face_row.remove_btn.isEnabled() is True + assert dlg._face_row.download_btn.isEnabled() is False # already present + finally: + dlg.close() + + +def test_face_row_bundled_not_removable(qtapp, monkeypatch) -> None: + dlg, _ = _dialog(qtapp, monkeypatch, _BUNDLED) + try: + assert dlg._face_row.removable is False + assert dlg._face_row.remove_btn.isEnabled() is False + assert dlg._face_row.download_btn.isEnabled() is False + finally: + dlg.close() + + +def test_face_row_home_is_removable(qtapp, monkeypatch) -> None: + """A pack in the user's own ~/.insightface can be removed.""" + dlg, _ = _dialog(qtapp, monkeypatch, _HOME) + try: + assert dlg._face_row.removable is True + assert dlg._face_row.remove_btn.isEnabled() is True + finally: + dlg.close() + + +def test_face_not_in_upstream_external_rows(qtapp, monkeypatch) -> None: + """The upstream-managed section shows only ReID — face has its own row now.""" + from autoptz.ui.widgets.dialogs.model_manager import _ExternalRow + + dlg, _ = _dialog(qtapp, monkeypatch, _MISSING) + try: + external = dlg.findChildren(_ExternalRow) + texts = [w.findChild(type(w)) for w in external] # touch to ensure built + assert texts is not None + # No _ExternalRow should be a face row; ReID is the only upstream row. + labels = " ".join( + lbl.text() for row in external for lbl in row.findChildren(type(dlg._status)) + ) + assert "Face recognition" not in labels + assert "ReID" in labels + finally: + dlg.close() + + +def test_face_download_runs_manager_and_brackets_sessions(qtapp, monkeypatch) -> None: + """Download drives the background task, which releases + rebuilds ORT sessions + around the manager call (the Windows file-lock bracket).""" + from autoptz.ui.widgets.dialogs.model_manager import _ModelTask + + dlg, mgr = _dialog(qtapp, monkeypatch, _MISSING) + try: + # Drive the task body synchronously (no thread) for a deterministic assert. + task = _ModelTask("download_face", [], client=dlg._client) + task.run() + assert "ensure_face_pack" in mgr.calls + assert dlg._client._store.get("rel") == 1 # released before + assert dlg._client._store.get("reb") == 1 # rebuilt after + # A face op releases/rebuilds the shared face session too. + assert dlg._client._store.get("rel_face") is True + assert dlg._client._store.get("reb_face") is True + finally: + dlg.close() + + +def test_detector_op_does_not_touch_face_session(qtapp, monkeypatch) -> None: + """A detector download/remove must pass include_face=False so it never drops + and reloads the ~1.3 GB face pack.""" + from autoptz.ui.widgets.dialogs.model_manager import _ModelTask + + dlg, mgr = _dialog(qtapp, monkeypatch, _MISSING) + try: + # Give the fake manager a remove_app_models so the detector 'remove' works. + mgr.remove_app_models = lambda *, keys=None: [ + {"name": "m", "state": "removed", "path": "", "size": "0", "error": ""} + ] + _ModelTask("remove", ["detector_fast"], client=dlg._client).run() + assert dlg._client._store.get("rel_face") is False + assert dlg._client._store.get("reb_face") is False + finally: + dlg.close() diff --git a/tests/test_model_server_attach.py b/tests/test_model_server_attach.py new file mode 100644 index 00000000..16afe8d7 --- /dev/null +++ b/tests/test_model_server_attach.py @@ -0,0 +1,154 @@ +"""Late-camera attach: the old model-server must DRAIN, never die mid-read. + +``_attach_camera_to_model_server`` restarts the shared server so a camera added +after startup gets an IPC slot. The old code set the graceful stop event and +then called ``terminate()`` on the very next line — killing a process that is +parked inside ``req_q.get()``. Terminating a ``multiprocessing.Queue`` consumer +poisons the shared 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: every camera loses detection/tracking/face until the +whole app restarts. Reproduced 5/5 with a standalone two-consumer harness and +live in-app after a remove + re-add. + +These tests pin the drain-first contract: stop event set, bounded wait, and +``terminate()`` only as an escalation when the old server does not exit. + +The wait itself must be NON-BLOCKING (``_attach_camera_to_model_server`` runs on +the GUI-thread ``tick()``): it hands the old process off to +``_poll_model_server_drain`` (ticked from ``_scan_model_server_health``) instead +of joining synchronously, so these tests drive the drain forward explicitly via +``_poll_model_server_drain(now)`` calls with an advancing synthetic clock — +mirroring how ``TestModelServerRespawnNonBlocking`` in test_supervisor_health.py +already drives the (pre-existing) spawn side of the same non-blocking pattern. +""" + +from __future__ import annotations + +import time +import uuid + +from autoptz.engine.supervisor import _MS_DRAIN_TIMEOUT_S + + +class _FakeEvent: + def __init__(self) -> None: + self.set_calls = 0 + + def is_set(self) -> bool: + return self.set_calls > 0 + + def set(self) -> None: + self.set_calls += 1 + + +class _FakeProc: + """Records join/terminate calls; 'exits' on its own (no terminate needed) + only if drains=True. is_alive() is polled non-blockingly across simulated + ticks by _poll_model_server_drain — it is never blocked on via join().""" + + def __init__(self, *, drains: bool) -> None: + self._drains = drains + self._terminated = False + self.calls: list[tuple[str, float | None]] = [] + + def join(self, timeout: float | None = None) -> None: + self.calls.append(("join", timeout)) + + def terminate(self) -> None: + self.calls.append(("terminate", None)) + self._terminated = True + + def is_alive(self) -> bool: + if self._terminated: + return False + return not self._drains + + +def _forged_supervisor(monkeypatch, *, drains: bool): # noqa: ANN201 + """A Supervisor with hand-forged model-server state (no processes spawned).""" + from autoptz.engine.supervisor import Supervisor + from autoptz.ui.engine_client import EngineClient + + sup = Supervisor(EngineClient(), store=None) + proc = _FakeProc(drains=drains) + stop_ev = _FakeEvent() + down_ev = _FakeEvent() + sup._infer_req_q = object() + sup._infer_resp_qs = {} + sup._model_server_camera_ids = [] + sup._model_server_proc = proc + sup._model_server_stop = stop_ev + sup._model_server_down = down_ev + + respawned: list[float] = [] + monkeypatch.setattr(sup, "_respawn_model_server", lambda now: respawned.append(now)) + return sup, proc, stop_ev, respawned + + +class TestLateAttachDrainsOldServer: + def test_drained_server_is_never_terminated(self, qapp, monkeypatch) -> None: # noqa: ANN001 + sup, proc, stop_ev, respawned = _forged_supervisor(monkeypatch, drains=True) + + q = sup._attach_camera_to_model_server("cam-" + uuid.uuid4().hex[:8]) + + assert q is not None, "attach must mint a response queue" + assert stop_ev.set_calls >= 1, "graceful stop event must be set" + # The attach call itself must be non-blocking: it hands the old process + # off instead of joining it here (tick() runs on the GUI thread). + assert proc.calls == [] + assert sup._ms_drain_proc is proc + assert not respawned, "must not respawn before the drain is even polled" + + # One non-blocking poll tick: the server has already exited on its own + # (drains=True) — respawn now, and it must NEVER have been terminated. + sup._poll_model_server_drain(time.monotonic()) + + assert ("terminate", None) not in proc.calls, ( + "terminating a server parked in req_q.get() poisons the shared request " + "queue — a server that drains on its own must NEVER be terminated" + ) + assert sup._ms_drain_proc is None + assert respawned, "a fresh server must be spawned after the drain" + + def test_stuck_server_is_terminated_after_drain_window(self, qapp, monkeypatch) -> None: # noqa: ANN001 + sup, proc, stop_ev, respawned = _forged_supervisor(monkeypatch, drains=False) + + q = sup._attach_camera_to_model_server("cam-" + uuid.uuid4().hex[:8]) + assert q is not None + now = time.monotonic() + + # Well within the drain window: the wedged server must get a REAL chance + # to exit before being killed — no terminate yet, no respawn yet. + sup._poll_model_server_drain(now + 0.1) + assert ("terminate", None) not in proc.calls + assert sup._ms_drain_proc is proc + assert not respawned + + # Past the drain deadline, still alive: escalate. + sup._poll_model_server_drain(now + _MS_DRAIN_TIMEOUT_S + 0.1) + names = [name for (name, _t) in proc.calls] + assert "terminate" in names, "a wedged server must still be escalated to terminate" + assert sup._ms_drain_proc is None + assert respawned + + +class TestRemovePrunesServerSlots: + """Removing a camera must drop its response queue + slot id, or every + remove/re-add cycle leaks an mp.Queue (fds + feeder thread) and the next + server respawn re-pickles queues for cameras that no longer exist.""" + + def test_remove_camera_prunes_resp_queue_and_slot(self, qapp, monkeypatch) -> None: # noqa: ANN001 + from autoptz.engine.runtime.messages import RemoveCameraCmd + from autoptz.engine.supervisor import Supervisor + from autoptz.ui.engine_client import EngineClient + + sup = Supervisor(EngineClient(), store=None) + cid = "cam-" + uuid.uuid4().hex[:8] + sup._infer_resp_qs = {cid: object(), "other": object()} + sup._model_server_camera_ids = [cid, "other"] + + sup._on_remove_camera(RemoveCameraCmd(camera_id=cid)) + + assert cid not in sup._infer_resp_qs + assert cid not in sup._model_server_camera_ids + assert "other" in sup._infer_resp_qs and "other" in sup._model_server_camera_ids diff --git a/tests/test_models.py b/tests/test_models.py index fff1daa3..59378b4c 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -758,3 +758,184 @@ def boom(): id="cam-abcd1234", name="C", source=SourceConfig(type="usb", address="usb://0") ) assert camera_worker._resolve_model_path(cfg) is None + + +class TestFacePack: + """ModelManager owns download/remove of the insightface face pack in its own + app-data cache (never the bundled or ~/.insightface copies).""" + + @pytest.fixture(autouse=True) + def _isolate(self, monkeypatch, tmp_path): + monkeypatch.delenv("INSIGHTFACE_HOME", raising=False) + monkeypatch.delenv("AUTOPTZ_FACE_MODEL", raising=False) + # No bundled pack and an empty fake home by default; tests opt into each. + self._home = tmp_path / "home" + self._home.mkdir() + monkeypatch.setattr(Path, "home", lambda: self._home) + + def _write_pack(self, insightface_root: Path, model: str = "buffalo_l") -> Path: + """Write a fake pack at ``/models//*.onnx``.""" + pack = insightface_root / "models" / model + pack.mkdir(parents=True) + (pack / "w600k_r50.onnx").write_bytes(b"x" * 2048) + (pack / "det_10g.onnx").write_bytes(b"y" * 1024) + return pack + + def test_status_missing(self, tmp_path): + mgr = ModelManager(cache_dir=tmp_path / "cache") + st = mgr.face_pack_status() + assert st["present"] is False + assert st["location"] == "missing" + assert st["removable"] is False + + def test_status_app_data(self, tmp_path): + cache = tmp_path / "cache" + self._write_pack(cache / "insightface") + mgr = ModelManager(cache_dir=cache) + st = mgr.face_pack_status() + assert st["present"] is True + assert st["location"] == "app-data" + assert st["removable"] is True + assert st["size_bytes"] == 2048 + 1024 + + def test_status_bundled_wins_and_not_removable(self, tmp_path, monkeypatch): + bundled = tmp_path / "bundled" + self._write_pack(bundled / "insightface") + monkeypatch.setattr("autoptz.engine.runtime.models.bundled_models_dir", lambda: bundled) + cache = tmp_path / "cache" + self._write_pack(cache / "insightface") # app-data copy shadowed by bundled + mgr = ModelManager(cache_dir=cache) + st = mgr.face_pack_status() + assert st["location"] == "bundled" + assert st["removable"] is False + + def test_status_home_is_removable(self, tmp_path): + """The user's own ~/.insightface cache can be removed (they own it).""" + self._write_pack(self._home / ".insightface") + mgr = ModelManager(cache_dir=tmp_path / "cache") + st = mgr.face_pack_status() + assert st["location"] == "home" + assert st["removable"] is True + + def test_insightface_home_is_terminal_when_present(self, tmp_path, monkeypatch): + env = tmp_path / "custom" + self._write_pack(env) + monkeypatch.setenv("INSIGHTFACE_HOME", str(env)) + mgr = ModelManager(cache_dir=tmp_path / "cache") + st = mgr.face_pack_status() + assert st["location"] == "custom" + assert st["present"] is True + assert st["removable"] is False + + def test_insightface_home_empty_reports_missing_not_a_shadowed_pack( + self, tmp_path, monkeypatch + ): + """When INSIGHTFACE_HOME is set but empty, the engine loads from it + (unconditionally) and finds nothing — so status must NOT fall through to a + lower-priority present pack and falsely report the pack as available.""" + env = tmp_path / "empty_custom" + env.mkdir() + self._write_pack(self._home / ".insightface") # a present fallback exists + monkeypatch.setenv("INSIGHTFACE_HOME", str(env)) + mgr = ModelManager(cache_dir=tmp_path / "cache") + st = mgr.face_pack_status() + assert st["location"] == "custom" + assert st["present"] is False # matches what the engine actually loads + + def test_ensure_uses_appdata_root(self, tmp_path, monkeypatch): + cache = tmp_path / "cache" + seen = {} + + def fake_ensure(root=None, model_name="buffalo_l"): + seen["root"] = root + self._write_pack(Path(root)) + return None + + monkeypatch.setattr("autoptz.engine.pipeline.identify.ensure_face_model", fake_ensure) + mgr = ModelManager(cache_dir=cache) + results = mgr.ensure_face_pack() + assert seen["root"] == str(cache / "insightface") + assert results[0]["state"] == "downloaded" + + def test_ensure_failure_reports_error(self, tmp_path, monkeypatch): + monkeypatch.setattr( + "autoptz.engine.pipeline.identify.ensure_face_model", + lambda root=None, model_name="buffalo_l": "OSError: offline", + ) + mgr = ModelManager(cache_dir=tmp_path / "cache") + results = mgr.ensure_face_pack() + assert results[0]["state"] == "failed" + assert "offline" in results[0]["error"] + + def test_partial_download_single_onnx_not_reported_present(self, tmp_path): + """A single leftover .onnx from an interrupted download must not read as a + complete/usable pack (which would wrongly disable Download).""" + cache = tmp_path / "cache" + pack = cache / "insightface" / "models" / "buffalo_l" + pack.mkdir(parents=True) + (pack / "det_10g.onnx").write_bytes(b"x" * 1024) # only one of several files + mgr = ModelManager(cache_dir=cache) + st = mgr.face_pack_status() + assert st["present"] is False + assert st["location"] == "missing" + + def test_partial_download_only_auxiliary_files_not_reported_present(self, tmp_path): + """A partial extract that left only the pack's (AutoPTZ-unused) auxiliary + landmark/attribute files behind must not read as complete either — the + old "≥2 onnx files" heuristic alone is fooled by exactly this case, since + it never checks WHICH files are present. FaceRecognizer restricts + insightface to allowed_modules=["detection", "recognition"], so a real + pack is only usable once its det_*/w600k_* files (the ones AutoPTZ + actually loads) exist — not just any two of the five shipped files.""" + cache = tmp_path / "cache" + pack = cache / "insightface" / "models" / "buffalo_l" + pack.mkdir(parents=True) + (pack / "genderage.onnx").write_bytes(b"a" * 1024) + (pack / "2d106det.onnx").write_bytes(b"b" * 1024) + mgr = ModelManager(cache_dir=cache) + st = mgr.face_pack_status() + assert st["present"] is False + assert st["location"] == "missing" + + def test_unknown_face_model_keeps_legacy_two_file_heuristic(self, tmp_path, monkeypatch): + """A custom/unlisted AUTOPTZ_FACE_MODEL pack has an unknown naming + convention, so the stricter detection/recognition prefix check must NOT + apply to it — otherwise a legitimate custom pack would be wrongly + reported as incomplete/broken with no way to fix it from the UI.""" + monkeypatch.setenv("AUTOPTZ_FACE_MODEL", "custom_pack") + cache = tmp_path / "cache" + pack = cache / "insightface" / "models" / "custom_pack" + pack.mkdir(parents=True) + (pack / "modelA.onnx").write_bytes(b"a" * 1024) + (pack / "modelB.onnx").write_bytes(b"b" * 1024) + mgr = ModelManager(cache_dir=cache) + st = mgr.face_pack_status() + assert st["present"] is True + + def test_remove_prefers_appdata_over_home(self, tmp_path): + cache = tmp_path / "cache" + app_pack = self._write_pack(cache / "insightface") + home_pack = self._write_pack(self._home / ".insightface") + mgr = ModelManager(cache_dir=cache) + results = mgr.remove_face_pack() + assert results and all(r["state"] == "removed" for r in results) + assert not any(app_pack.glob("*.onnx")) # app-data (resolved) pack gone + assert list(home_pack.glob("*.onnx")) # home pack untouched (not resolved) + + def test_remove_deletes_home_pack_when_it_is_resolved(self, tmp_path): + """The user's own ~/.insightface pack can be deleted (their blocker).""" + home_pack = self._write_pack(self._home / ".insightface") + mgr = ModelManager(cache_dir=tmp_path / "cache") + assert mgr.face_pack_status()["location"] == "home" + results = mgr.remove_face_pack() + assert results and all(r["state"] == "removed" for r in results) + assert not any(home_pack.glob("*.onnx")) # home pack deleted + + def test_remove_never_touches_bundled(self, tmp_path, monkeypatch): + bundled = tmp_path / "bundled" + bundled_pack = self._write_pack(bundled / "insightface") + monkeypatch.setattr("autoptz.engine.runtime.models.bundled_models_dir", lambda: bundled) + mgr = ModelManager(cache_dir=tmp_path / "cache") + assert mgr.face_pack_status()["location"] == "bundled" + assert mgr.remove_face_pack() == [] # nothing removed + assert list(bundled_pack.glob("*.onnx")) # bundled pack intact diff --git a/tests/test_ndi_send.py b/tests/test_ndi_send.py new file mode 100644 index 00000000..6ec1bfc6 --- /dev/null +++ b/tests/test_ndi_send.py @@ -0,0 +1,107 @@ +"""NDI output sink: pure BGR→RGBA conversion + graceful no-op when cyndilib is +absent (the repo .venv/CI case).""" + +from __future__ import annotations + +import numpy as np + +from autoptz.engine.pipeline.ndi_send import ( + NDISendSink, + bgr_to_rgba_flat, + ndi_output_available, +) + + +def test_bgr_to_rgba_flat_swaps_channels_and_sets_alpha() -> None: + # A single pixel BGR = (1, 2, 3) → RGBA = (3, 2, 1, 255). + frame = np.array([[[1, 2, 3]]], dtype=np.uint8) + out = bgr_to_rgba_flat(frame) + assert out.shape == (4,) + assert list(out) == [3, 2, 1, 255] + + +def test_bgr_to_rgba_flat_length_matches_pixels() -> None: + frame = np.zeros((4, 5, 3), dtype=np.uint8) + out = bgr_to_rgba_flat(frame) + assert out.shape == (4 * 5 * 4,) + assert out.dtype == np.uint8 + + +def test_available_is_bool() -> None: + assert isinstance(ndi_output_available(), bool) + + +def test_sink_is_graceful_noop_without_cyndilib() -> None: + """With no cyndilib (the CI/.venv case) the sink builds, reports unavailable, + and send/close never raise.""" + sink = NDISendSink(1280, 720, "AutoPTZ Test Cam") + assert sink.ndi_name == "AutoPTZ Test Cam" + if not ndi_output_available(): + assert sink.available is False + # Regardless of availability, these must never raise. + sink.send_bgr(np.zeros((720, 1280, 3), dtype=np.uint8)) + sink.close() + sink.send_bgr(np.zeros((720, 1280, 3), dtype=np.uint8)) # safe after close + + +def test_send_bgr_uses_injected_sender_when_present() -> None: + """Inject a fake sender to prove the send path converts + writes even when the + real cyndilib runtime is absent.""" + writes: list[np.ndarray] = [] + sink = NDISendSink(2, 1, "Cam") + sink._sender = type("S", (), {"write_video": lambda self, buf: writes.append(buf)})() + frame = np.array([[[10, 20, 30], [40, 50, 60]]], dtype=np.uint8) # (1, 2, 3) + sink.send_bgr(frame) + assert len(writes) == 1 + # 2 px × RGBA = 8 bytes; first pixel BGR(10,20,30) → RGBA(30,20,10,255). + assert list(writes[0][:4]) == [30, 20, 10, 255] + + +# ── worker wiring ──────────────────────────────────────────────────────────── + + +def _worker(**ptz): + from autoptz.config.models import CameraConfig, PTZConfig + from autoptz.engine.camera_worker import CameraWorker + + cfg = CameraConfig(id="c", name="Cam 1", ptz=PTZConfig(**ptz)) + w = CameraWorker("c", cfg, on_telemetry=lambda m: None) + w._ptz_backend = None # passthrough framing + w._shm = type("Shm", (), {"push": lambda self, f: None, "height": 100, "width": 100})() + return w + + +def test_ndi_output_name_default_and_configured() -> None: + assert _worker()._ndi_output_name() == "AutoPTZ Cam 1" + assert _worker(ndi_output_name="Studio Feed")._ndi_output_name() == "Studio Feed" + + +def test_push_frame_creates_ndi_sink_when_enabled(monkeypatch) -> None: + import autoptz.engine.pipeline.ndi_send as ndi_mod + + created: list[str] = [] + + class _FakeSink: + def __init__(self, w, h, name, fps=30.0): + created.append(name) + + def send_bgr(self, f): + pass + + def close(self): + pass + + monkeypatch.setattr(ndi_mod, "NDISendSink", _FakeSink) + w = _worker(ndi_out=True) + w._push_frame(np.zeros((100, 100, 3), dtype=np.uint8)) + assert created == ["AutoPTZ Cam 1"] + assert w._ndi is not None + + +def test_push_frame_closes_ndi_sink_when_disabled() -> None: + w = _worker(ndi_out=False) + closed = {"n": 0} + w._ndi = type("S", (), {"close": lambda self: closed.__setitem__("n", 1)})() + w._push_frame(np.zeros((100, 100, 3), dtype=np.uint8)) + assert closed["n"] == 1 + assert w._ndi is None diff --git a/tests/test_orchestration.py b/tests/test_orchestration.py index f54e4c20..5b4ade36 100644 --- a/tests/test_orchestration.py +++ b/tests/test_orchestration.py @@ -1706,10 +1706,10 @@ def test_model_task_releases_sessions_before_mutating(self, qapp, monkeypatch) - order: list[str] = [] class _SpyClient: - def releaseModelSessions(self): + def releaseModelSessions(self, include_face=False): order.append("release") - def rebuildModelSessions(self): + def rebuildModelSessions(self, include_face=False): order.append("rebuild") class _FakeManager: @@ -1854,6 +1854,42 @@ def test_disabling_feature_releases_pool_model(self, qapp) -> None: finally: sup.stop() + def test_release_model_sessions_releases_face_only_for_face_ops(self, qapp) -> None: + """A face-pack download/remove passes include_face=True, so the shared face + ORT session is released too (else Windows can't delete the pack files while + onnxruntime holds them open).""" + client = _make_client(qapp) + client.addCamera("usb://0", "A") + client.drain_commands() + sup = _make_supervisor(client, factory=_FeatureFakeWorker) + sup.start() + try: + pool = _FakePool() + sup._inference_pool = pool + sup.release_model_sessions(include_face=True) + assert "face" in pool.released + assert "detector" in pool.released and "pose" in pool.released + finally: + sup.stop() + + def test_release_model_sessions_default_keeps_face(self, qapp) -> None: + """A detector/pose cache op (the default, include_face=False) must NOT touch + the shared face session — dropping the ~1.3 GB pack on an unrelated op is a + needless reload.""" + client = _make_client(qapp) + client.addCamera("usb://0", "A") + client.drain_commands() + sup = _make_supervisor(client, factory=_FeatureFakeWorker) + sup.start() + try: + pool = _FakePool() + sup._inference_pool = pool + sup.release_model_sessions() # default: detector/pose only + assert "face" not in pool.released + assert "detector" in pool.released and "pose" in pool.released + finally: + sup.stop() + def test_apply_model_cache_changed_reloads_workers(self, qapp) -> None: client = _make_client(qapp) cid = client.addCamera("usb://0", "A") diff --git a/tests/test_panel_min_width_no_clip.py b/tests/test_panel_min_width_no_clip.py new file mode 100644 index 00000000..030fc815 --- /dev/null +++ b/tests/test_panel_min_width_no_clip.py @@ -0,0 +1,168 @@ +"""INVARIANT: at a panel's own minimum width, nothing may overflow horizontally. + +"I never want horizontal scroll for a min width" — every side panel and dialog, +with every collapsible section EXPANDED and worst-case dynamic content injected +(a long NDI address, the "source isn't reaching" fps caption, a degradation +reason), must lay out with zero widgets crossing the scroll viewport's right +edge and zero hard-clipped (non-wrapping, non-elided) label text. +""" + +from __future__ import annotations + +import pytest + + +@pytest.fixture +def qtapp(): + from PySide6.QtWidgets import QApplication + + yield QApplication.instance() or QApplication([]) + + +LONG_ADDR = "ndi://PRINCES-MBP (AutoPTZ PRINCES-MBP (AutoPTZ MacBook Pro Camera Long))" +LONG_NAME = "PRINCES-MBP (AutoPTZ Very Long Loopback Camera Name)" + + +def _expand_all_sections(panel, app) -> None: + """Force-expand every CollapsibleGroup, skipping the animation.""" + from autoptz.ui.widgets.common import CollapsibleGroup + + for g in panel.findChildren(CollapsibleGroup): + g._toggle.setChecked(True) + g._on_toggle(True) + g._height_anim.stop() + g._content.setMaximumHeight(16777215) + g._content.setVisible(True) + g._expanded = True + app.processEvents() + app.processEvents() + + +def _overflows(scroll, app) -> list[str]: + """Every visible leaf widget crossing the viewport's right edge, and every + hard-clipped label (text wider than widget, no wrap, no ellipsis).""" + from PySide6.QtWidgets import QCheckBox, QLabel, QPushButton, QWidget + + app.processEvents() + vp = scroll.viewport() + vp_right = vp.mapToGlobal(vp.rect().topRight()).x() + problems: list[str] = [] + for w in vp.findChildren(QWidget): + if not w.isVisible() or w.width() <= 0: + continue + over = w.mapToGlobal(w.rect().topRight()).x() - vp_right + if over > 1 and not w.findChildren(QWidget): + problems.append(f"+{over}px {type(w).__name__}({w.objectName() or ''})") + if isinstance(w, QLabel | QCheckBox | QPushButton): + import re + + raw = str(w.text()) + txt = re.sub(r"<[^>]+>", "", raw) # metrics on VISIBLE text, not HTML tags + wraps = bool(getattr(w, "wordWrap", lambda: False)()) + if txt and "…" not in txt and not wraps: + need = w.fontMetrics().horizontalAdvance(txt) + # Small tolerance for rich-text labels: plain metrics slightly + # misestimate bold rendering. + slack = 8 if raw != txt else 2 + if need > w.width() + slack: + problems.append( + f"CLIPTEXT {type(w).__name__}({txt[:30]!r}) needs {need} has {w.width()}" + ) + return problems + + +def test_properties_panel_min_width_never_clips(qtapp) -> None: + from autoptz.ui.engine_client import EngineClient + from autoptz.ui.frames import ShmFrameSource + from autoptz.ui.widgets.properties_panel import PropertiesPanel + + client = EngineClient() + cid = client.addCamera(LONG_ADDR, LONG_NAME) + panel = PropertiesPanel(client, frame_source=ShmFrameSource()) + try: + width = max(panel.minimumWidth(), panel.minimumSizeHint().width()) + panel.resize(width, 820) + panel.show() + qtapp.processEvents() + panel.set_camera(cid) + _expand_all_sections(panel, qtapp) + # Worst-case dynamic texts (the ones that appear under load). + panel._fps_measured.setText("measured: 10.2 fps — source isn't reaching 30 fps") + panel._set_caption( + panel._track_reason, + "Degraded ×4: Auto quality: over frame budget; detector cadence relaxed", + ) + panel._set_caption(panel._track_state, "State: Standing by for reacquire (standby)") + qtapp.processEvents() + qtapp.processEvents() # deferred re-elide + problems = _overflows(panel._scroll, qtapp) + assert not problems, f"overflow at min width {width}: " + "; ".join(problems[:8]) + finally: + panel.deleteLater() + + +def test_services_panel_min_width_never_clips(qtapp) -> None: + from PySide6.QtWidgets import QScrollArea + + from autoptz.ui.engine_client import EngineClient + from autoptz.ui.widgets.services_panel import ServicesPanel + + panel = ServicesPanel(EngineClient()) + try: + width = max(panel.minimumWidth(), panel.minimumSizeHint().width()) + panel.resize(width, 820) + panel.show() + qtapp.processEvents() + scroll = panel.findChildren(QScrollArea)[0] + problems = _overflows(scroll, qtapp) + assert not problems, f"overflow at min width {width}: " + "; ".join(problems[:8]) + finally: + panel.deleteLater() + + +def test_camera_info_panel_min_width_never_clips(qtapp) -> None: + from PySide6.QtWidgets import QScrollArea + + from autoptz.ui.engine_client import EngineClient + from autoptz.ui.widgets.camera_info_panel import CameraInfoPanel + + client = EngineClient() + cid = client.addCamera(LONG_ADDR, LONG_NAME) + panel = CameraInfoPanel(client) + try: + width = max(280, panel.minimumWidth(), panel.minimumSizeHint().width()) + panel.resize(width, 820) + panel.show() + qtapp.processEvents() + panel.set_camera(cid) + qtapp.processEvents() + scrolls = panel.findChildren(QScrollArea) + if scrolls: + problems = _overflows(scrolls[0], qtapp) + assert not problems, f"overflow at min width {width}: " + "; ".join(problems[:8]) + finally: + panel.deleteLater() + + +def test_experimental_dialog_min_width_never_clips(qtapp) -> None: + from types import SimpleNamespace + + from PySide6.QtWidgets import QScrollArea + + from autoptz.ui.widgets.dialogs.experimental import ExperimentalFeaturesDialog + + store: dict = {} + client = SimpleNamespace( + getSetting=lambda k, d=None: store.get(k, d), + setSetting=lambda k, v: store.__setitem__(k, v), + ) + dlg = ExperimentalFeaturesDialog(client) + try: + dlg.resize(dlg.minimumWidth(), 700) + dlg.show() + qtapp.processEvents() + scroll = dlg.findChildren(QScrollArea)[0] + problems = _overflows(scroll, qtapp) + assert not problems, "; ".join(problems[:8]) + finally: + dlg.close() diff --git a/tests/test_process_worker.py b/tests/test_process_worker.py index 138877a0..81a9d799 100644 --- a/tests/test_process_worker.py +++ b/tests/test_process_worker.py @@ -18,7 +18,7 @@ _STOP, ProcessWorkerHandle, WorkerSpec, - process_per_camera_enabled, + model_server_workers_enabled, ) @@ -66,19 +66,27 @@ class TestEnabledFlag: def test_disabled_by_default(self, monkeypatch) -> None: monkeypatch.delenv("AUTOPTZ_PROCESS_PER_CAMERA", raising=False) monkeypatch.delenv("AUTOPTZ_MODEL_SERVER", raising=False) - assert process_per_camera_enabled() is False + assert model_server_workers_enabled() is False def test_standalone_process_flag_is_retired(self, monkeypatch) -> None: for val in ("1", "true", "on", "YES"): monkeypatch.setenv("AUTOPTZ_PROCESS_PER_CAMERA", val) monkeypatch.delenv("AUTOPTZ_MODEL_SERVER", raising=False) - assert process_per_camera_enabled() is False + assert model_server_workers_enabled() is False def test_model_server_enables_process_workers(self, monkeypatch) -> None: for val in ("1", "true", "on", "YES"): monkeypatch.delenv("AUTOPTZ_PROCESS_PER_CAMERA", raising=False) monkeypatch.setenv("AUTOPTZ_MODEL_SERVER", val) - assert process_per_camera_enabled() is True + assert model_server_workers_enabled() is True + + def test_no_process_per_camera_alias_left(self) -> None: + """The retired process-per-camera naming is fully gone from the codebase.""" + import autoptz.engine.process_worker as pw + import autoptz.engine.runtime.flags as flags + + assert not hasattr(flags, "env_process_per_camera") + assert not hasattr(pw, "process_per_camera_enabled") class TestRelayIdentityLockFree: @@ -111,7 +119,7 @@ def test_relay_is_noop_when_disabled(self, qapp, monkeypatch) -> None: sentinel = object() # Patch at the source module so the local import inside the method picks up the mock. with patch( - "autoptz.engine.process_worker.process_per_camera_enabled", + "autoptz.engine.process_worker.model_server_workers_enabled", return_value=False, ) as mock_gate: sup._relay_identity_to_siblings("cam-source", sentinel) @@ -264,6 +272,57 @@ def put(self, item) -> None: assert ("set_features", ({"pose": False},), {}) in q.items assert ("set_target", ("track-7",), {}) in q.items + def test_release_and_reload_forward_include_face(self) -> None: + """Supervisor.release_model_sessions/rebuild_model_sessions call + worker.release_inference_models(wait=1.0, include_face=...) and + worker.reload_inference_models(include_face=...) — a model-server camera + child's handle must accept + relay ``include_face`` too, or a face-pack + download/remove never reaches the child's own face stack (it silently + falls back to the TypeError branch that omits include_face entirely).""" + h = self._handle() + + class _FakeQ: + def __init__(self) -> None: + self.items: list = [] + + def put(self, item) -> None: + self.items.append(item) + + q = _FakeQ() + h._cmd_q = q + h._started = True + + h.release_inference_models(wait=1.0, include_face=True) + h.reload_inference_models(include_face=True) + + # wait is deliberately NOT honoured across the process boundary (matches + # the existing "wait=0.0" behavior) — only include_face is new here. + assert ("release_inference_models", (), {"wait": 0.0, "include_face": True}) in q.items + assert ("reload_inference_models", (), {"include_face": True}) in q.items + + def test_release_and_reload_default_include_face_false(self) -> None: + """A plain detector/pose cache op (the default) must not force + include_face=True onto the child — an unrelated op must never disturb + the face session.""" + h = self._handle() + + class _FakeQ: + def __init__(self) -> None: + self.items: list = [] + + def put(self, item) -> None: + self.items.append(item) + + q = _FakeQ() + h._cmd_q = q + h._started = True + + h.release_inference_models() + h.reload_inference_models() + + assert ("release_inference_models", (), {"wait": 0.0, "include_face": False}) in q.items + assert ("reload_inference_models", (), {"include_face": False}) in q.items + def test_injection_setters_are_noops(self) -> None: # The shared pool/service can't cross to a child; these must not raise. h = self._handle() @@ -469,6 +528,35 @@ def ingest_identity(self, record) -> None: # noqa: ANN001 assert ingested == [rec.id] +def test_child_drain_routes_release_and_reload_with_include_face() -> None: + """End-to-end: ProcessWorkerHandle enqueues (name, args, kwargs); _drain_commands + re-dispatches it verbatim onto the child's real CameraWorker via getattr — so + once the handle forwards include_face, the child's own + release_inference_models/reload_inference_models receive it too.""" + import queue as _queue + + from autoptz.engine.process_worker import _STOP, _drain_commands + + calls: list = [] + + class _FakeWorker: + def release_inference_models( + self, *, wait: float = 0.0, include_face: bool = False + ) -> None: + calls.append(("release", wait, include_face)) + + def reload_inference_models(self, *, include_face: bool = False) -> None: + calls.append(("reload", include_face)) + + q: _queue.Queue = _queue.Queue() + q.put(("release_inference_models", (), {"wait": 0.0, "include_face": True})) + q.put(("reload_inference_models", (), {"include_face": True})) + q.put((_STOP, (), {})) + _drain_commands(_FakeWorker(), q) + assert ("release", 0.0, True) in calls + assert ("reload", True) in calls + + class TestIdentityRelay: def test_harvested_identity_relays_to_sibling_not_source(self, qapp, monkeypatch) -> None: # noqa: ANN001 from autoptz.config.models import IdentityRecord @@ -692,3 +780,239 @@ def _latest_child_frame(): _cleanup_shm(shm_name) assert not proc.is_alive(), "child process did not stop on the _STOP sentinel" + + +# ── model-server children must keep a DB-backed identity gallery ───────────── + + +class TestModelServerChildIdentity: + """Model-server-mode children must still get a DB-backed identity gallery. + + PR #139 regression: the model-server branch of ``run_camera_process`` replaced + the whole ``_wire_models_and_identity`` call (to skip the local detector), which + silently dropped the identity half — every child matched faces against an empty + in-memory gallery, so enrolled names never loaded ("Person N" forever), face-DB + edits were invisible until restart, and the sibling ``ingest_identity`` relay + no-op'd (it targets the injected service). + """ + + def test_model_server_child_wires_db_backed_identity(self, monkeypatch) -> None: + from pathlib import Path + + import autoptz.config.store as store_mod + import autoptz.engine.camera_worker as cw_mod + import autoptz.engine.identity.service as svc_mod + import autoptz.engine.pipeline.inference_server as inf_mod + import autoptz.engine.runtime.shm as shm_mod + from autoptz.engine import process_worker as pw + + captured: dict[str, object] = {} + sentinel_service = object() + + class _FakeStore: + def __init__(self, db_path=None, **_kw) -> None: # noqa: ANN001 + captured["db_path"] = db_path + + monkeypatch.setattr(store_mod, "ConfigStore", _FakeStore) + monkeypatch.setattr(svc_mod, "IdentityService", lambda _s: sentinel_service) + + class _FakeWorker: + def __init__(self, *_a, **_k) -> None: + pass + + def set_inference_pool(self, pool) -> None: # noqa: ANN001 + captured["pool"] = pool + + def set_identity_service(self, svc) -> None: # noqa: ANN001 + captured["service"] = svc + + def set_features(self, _f) -> None: # noqa: ANN001 + pass + + def start(self) -> None: + captured["started"] = True + + def stop(self) -> None: + pass + + class _FakeShm: + def __init__(self, *_a, **_k) -> None: + pass + + def close(self) -> None: + pass + + monkeypatch.setattr(cw_mod, "CameraWorker", _FakeWorker) + monkeypatch.setattr(shm_mod, "ShmWriter", _FakeShm) + monkeypatch.setattr(inf_mod, "InferenceClient", lambda *_a, **_k: object()) + monkeypatch.setattr(inf_mod, "RemotePool", lambda *_a, **_k: object()) + monkeypatch.setattr(pw, "_install_parent_death_watchdog", lambda *_a, **_k: None) + monkeypatch.setattr(pw, "_apply_child_thread_caps", lambda: None) + monkeypatch.setattr(pw, "_configure_child_logging", lambda: None) + + class _StopQ: + def get(self): # noqa: ANN201 + return (_STOP, (), {}) + + class _NullQ: + def put_nowait(self, _item) -> None: # noqa: ANN001 + pass + + spec = WorkerSpec(camera_id="cam-ms", config=_config("cam-ms"), db_path="/tmp/ident.db") + pw.run_camera_process( + spec, + _StopQ(), + _NullQ(), + _NullQ(), + infer_req_q=object(), + infer_resp_q=object(), + ) + + assert captured.get("started") is True, "sanity: the fake worker must have run" + assert captured.get("service") is sentinel_service, ( + "a model-server-mode child must wire a DB-backed IdentityService " + "(otherwise faces match an empty in-memory gallery forever)" + ) + assert captured.get("db_path") == Path("/tmp/ident.db") + + +# ── UI identity CRUD must reach process-mode children live ─────────────────── + + +class TestIdentityCrudBroadcast: + """Rename/label/delete in the UI must converge every child's gallery live. + + The supervisor used to drop RENAME_IDENTITY / DELETE_IDENTITY on the floor + ("UI/store concerns"), so process-mode children only learned about face-DB + edits on restart. + """ + + class _FakeHandle: + _is_process_worker = True + + registry: dict[str, TestIdentityCrudBroadcast._FakeHandle] = {} + + def __init__(self, camera_id, config, on_telemetry) -> None: # noqa: ANN001 + self.camera_id = camera_id + self.shm_name = f"cam_{camera_id[:8]}_preview" + self.ingested: list = [] + self.deleted: list = [] + type(self).registry[camera_id] = self + + def is_alive(self) -> bool: + return True + + def set_identity_service(self, _s) -> None: ... # noqa: ANN001 + def set_identity_callback(self, _cb) -> None: ... # noqa: ANN001 + def set_inference_pool(self, _p) -> None: ... # noqa: ANN001 + def set_features(self, _f) -> None: ... # noqa: ANN001 + + def ingest_identity(self, record) -> None: # noqa: ANN001 + self.ingested.append(record) + + def delete_identity(self, identity_id: str) -> None: + self.deleted.append(identity_id) + + def start(self) -> None: ... + def stop(self) -> None: ... + + def _build(self, qapp, monkeypatch): # noqa: ANN001, ANN201 + from autoptz.engine.supervisor import Supervisor + from autoptz.ui.engine_client import EngineClient + + monkeypatch.setenv("AUTOPTZ_MODEL_SERVER", "1") + self._FakeHandle.registry = {} + client = EngineClient() + cid_a = client.addCamera("usb://0", "CrudA") + cid_b = client.addCamera("usb://0", "CrudB") + client.drain_commands() + sup = Supervisor(client, store=None, worker_factory=self._FakeHandle) + sup.start() + return sup, cid_a, cid_b + + def test_rename_relays_updated_record_to_all_process_workers(self, qapp, monkeypatch) -> None: # noqa: ANN001 + from autoptz.config.models import IdentityRecord + from autoptz.engine.runtime.messages import RenameIdentityCmd + + sup, cid_a, cid_b = self._build(qapp, monkeypatch) + try: + service = sup._ensure_identity_service() + rec = IdentityRecord(name="Person 1", enabled=True, labeled=True) + service.ingest_record(rec) + + sup._route(RenameIdentityCmd(identity_id=rec.id, new_name="prince")) + + assert service.get(rec.id).name == "prince" + for cid in (cid_a, cid_b): + handle = self._FakeHandle.registry[cid] + assert [r.name for r in handle.ingested] == ["prince"], ( + f"worker {cid} must receive the renamed record" + ) + finally: + sup.stop() + + def test_delete_broadcasts_to_all_process_workers(self, qapp, monkeypatch) -> None: # noqa: ANN001 + from autoptz.config.models import IdentityRecord + from autoptz.engine.runtime.messages import DeleteIdentityCmd + + sup, cid_a, cid_b = self._build(qapp, monkeypatch) + try: + service = sup._ensure_identity_service() + rec = IdentityRecord(name="Joey", enabled=True, labeled=True) + service.ingest_record(rec) + + sup._route(DeleteIdentityCmd(identity_id=rec.id)) + + assert service.get(rec.id) is None + for cid in (cid_a, cid_b): + handle = self._FakeHandle.registry[cid] + assert handle.deleted == [rec.id], ( + f"worker {cid} must be told to drop the deleted identity" + ) + finally: + sup.stop() + + +class TestDeleteIdentityProxy: + def test_delete_identity_enqueues_command(self) -> None: + cid = "proc-" + uuid.uuid4().hex[:8] + h = ProcessWorkerHandle(cid, _config(cid), on_telemetry=lambda _m: None, db_path="") + + class _FakeQ: + def __init__(self) -> None: + self.items: list = [] + + def put(self, item) -> None: # noqa: ANN001 + self.items.append(item) + + q = _FakeQ() + h._cmd_q = q + h._started = True + h.delete_identity("ident-1") + assert any( + name == "delete_identity" and args == ("ident-1",) for (name, args, _k) in q.items + ) + + def test_delete_identity_noop_before_start(self) -> None: + cid = "proc-" + uuid.uuid4().hex[:8] + h = ProcessWorkerHandle(cid, _config(cid), on_telemetry=lambda _m: None, db_path="") + h.delete_identity("ident-1") # no queue yet -> must not raise + + +def test_camera_worker_delete_identity_removes_from_gallery() -> None: + from autoptz.config.models import IdentityRecord + from autoptz.engine.camera_worker import CameraWorker + from autoptz.engine.identity.service import IdentityService + + worker = CameraWorker("cw-del", _config("cw-del"), lambda _m: None) + service = IdentityService() + worker.set_identity_service(service) + rec = IdentityRecord(name="Person 1", enabled=False, labeled=False) + service.ingest_record(rec) + + worker.delete_identity(rec.id) + + assert service.get(rec.id) is None + # A worker with no injected service must not raise (identity features off). + bare = CameraWorker("cw-del2", _config("cw-del2"), lambda _m: None) + bare.delete_identity("whatever") diff --git a/tests/test_properties_group_framing.py b/tests/test_properties_group_framing.py new file mode 100644 index 00000000..deaca2c6 --- /dev/null +++ b/tests/test_properties_group_framing.py @@ -0,0 +1,96 @@ +"""Per-camera Group framing checkbox (Properties → PTZ), offscreen. + +Group framing used to be a dead "New-camera tracking defaults" entry in the +Experimental dialog (nothing consumed it for a new camera). It is a live +TrackingConfig field, so it belongs on the per-camera panel next to Center Stage, +where the debounced write-back applies it to the running camera. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + + +@pytest.fixture +def qtapp(): + from PySide6.QtWidgets import QApplication + + yield QApplication.instance() or QApplication([]) + + +def _panel_with_camera(tmp_path: Path): + from autoptz.config.store import ConfigStore + from autoptz.ui.engine_client import EngineClient + from autoptz.ui.frames import ShmFrameSource + from autoptz.ui.widgets.properties_panel import PropertiesPanel + + client = EngineClient(store=ConfigStore(db_path=tmp_path / "cfg.db", debounce_s=0)) + cid = client.addCamera("usb://0", "Cam") + panel = PropertiesPanel(client, frame_source=ShmFrameSource()) + return client, cid, panel + + +def test_checkbox_defaults_unchecked_for_new_camera(qtapp, tmp_path) -> None: + client, cid, panel = _panel_with_camera(tmp_path) + try: + panel.set_camera(cid) + assert panel._group_framing.isChecked() is False + finally: + panel.deleteLater() + + +def test_checkbox_loads_saved_value(qtapp, tmp_path) -> None: + client, cid, panel = _panel_with_camera(tmp_path) + try: + cfg = client.getCameraConfig(cid) + cfg["tracking"]["group_framing"] = True + client.updateCameraConfig(cid, json.dumps(cfg)) + panel.set_camera(cid) + assert panel._group_framing.isChecked() is True + finally: + panel.deleteLater() + + +def test_on_config_changed_does_not_raise(qtapp, tmp_path) -> None: + """Regression: _on_config_changed called a removed _sync_framing_sliders() + method, so any external config change on the selected camera (e.g. a toggle + round-tripping through the client's configChanged signal) raised + AttributeError. It must complete cleanly.""" + client, cid, panel = _panel_with_camera(tmp_path) + try: + panel.set_camera(cid) + panel._on_config_changed(cid) # must not raise + finally: + panel.deleteLater() + + +def test_toggle_persists_via_push(qtapp, tmp_path) -> None: + client, cid, panel = _panel_with_camera(tmp_path) + try: + panel.set_camera(cid) + framing_before = client.getCameraConfig(cid)["tracking"].get("framing") + panel._group_framing.setChecked(True) + panel._push() + tracking = client.getCameraConfig(cid)["tracking"] + assert tracking["group_framing"] is True + # Unrelated tracking fields are untouched by the toggle. + assert tracking.get("framing") == framing_before + finally: + panel.deleteLater() + + +def test_uncheck_persists_false(qtapp, tmp_path) -> None: + client, cid, panel = _panel_with_camera(tmp_path) + try: + cfg = client.getCameraConfig(cid) + cfg["tracking"]["group_framing"] = True + client.updateCameraConfig(cid, json.dumps(cfg)) + panel.set_camera(cid) + panel._group_framing.setChecked(False) + panel._push() + assert client.getCameraConfig(cid)["tracking"]["group_framing"] is False + finally: + panel.deleteLater() diff --git a/tests/test_properties_output.py b/tests/test_properties_output.py new file mode 100644 index 00000000..911fe57d --- /dev/null +++ b/tests/test_properties_output.py @@ -0,0 +1,62 @@ +"""Per-camera output toggles (Properties → PTZ): NDI output + virtual camera.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + + +@pytest.fixture +def qtapp(): + from PySide6.QtWidgets import QApplication + + yield QApplication.instance() or QApplication([]) + + +def _panel_with_camera(tmp_path: Path): + from autoptz.config.store import ConfigStore + from autoptz.ui.engine_client import EngineClient + from autoptz.ui.frames import ShmFrameSource + from autoptz.ui.widgets.properties_panel import PropertiesPanel + + client = EngineClient(store=ConfigStore(db_path=tmp_path / "cfg.db", debounce_s=0)) + cid = client.addCamera("usb://0", "Cam") + panel = PropertiesPanel(client, frame_source=ShmFrameSource()) + return client, cid, panel + + +def test_ndi_and_vcam_toggles_default_off(qtapp, tmp_path) -> None: + client, cid, panel = _panel_with_camera(tmp_path) + try: + panel.set_camera(cid) + assert panel._ndi_out.isChecked() is False + assert panel._vcam_out.isChecked() is False + finally: + panel.deleteLater() + + +def test_ndi_out_persists_via_push(qtapp, tmp_path) -> None: + client, cid, panel = _panel_with_camera(tmp_path) + try: + panel.set_camera(cid) + panel._ndi_out.setChecked(True) + panel._push() + ptz = client.getCameraConfig(cid)["ptz"] + assert ptz["ndi_out"] is True + assert ptz["vcam_out"] is False # independent of NDI + finally: + panel.deleteLater() + + +def test_ndi_out_loads_saved_value(qtapp, tmp_path) -> None: + client, cid, panel = _panel_with_camera(tmp_path) + try: + cfg = client.getCameraConfig(cid) + cfg["ptz"]["ndi_out"] = True + client.updateCameraConfig(cid, json.dumps(cfg)) + panel.set_camera(cid) + assert panel._ndi_out.isChecked() is True + finally: + panel.deleteLater() diff --git a/tests/test_ptz_factory.py b/tests/test_ptz_factory.py index 4fefcd64..b4009ae5 100644 --- a/tests/test_ptz_factory.py +++ b/tests/test_ptz_factory.py @@ -381,9 +381,12 @@ def test_ordering_tighter_is_larger_fraction(self) -> None: assert t["face"] > t["head_shoulders"] > t["upper_body"] > t["full_body"] > t["wide"] def test_expected_values(self) -> None: + # face/head_shoulders softened from 0.80/0.60 (user-reported "intense") + # and now sourced from the shared framing_target.SUBJECT_HEIGHT_TARGETS + # so Center Stage composes the same shot. t = _ZOOM_FRAMING_TARGETS - assert t["face"] == pytest.approx(0.80) - assert t["head_shoulders"] == pytest.approx(0.60) + assert t["face"] == pytest.approx(0.65) + assert t["head_shoulders"] == pytest.approx(0.52) assert t["upper_body"] == pytest.approx(0.45) assert t["full_body"] == pytest.approx(0.30) assert t["wide"] == pytest.approx(0.20) diff --git a/tests/test_ptz_group_framing.py b/tests/test_ptz_group_framing.py new file mode 100644 index 00000000..3039fc67 --- /dev/null +++ b/tests/test_ptz_group_framing.py @@ -0,0 +1,80 @@ +"""Physical PTZ honors group framing (parity with Center Stage): with group +framing on and nobody locked, the camera aims at the confident group instead of +idling. With group framing off (default), the single-target path is unchanged.""" + +from __future__ import annotations + +from types import SimpleNamespace + +import numpy as np + + +def _worker(*, group_framing: bool): + from autoptz.config.models import CameraConfig, TrackingConfig + from autoptz.engine.camera_worker import CameraWorker + + cfg = CameraConfig( + id="cam-grp", + name="Grp", + tracking=TrackingConfig(group_framing=group_framing), + ) + return CameraWorker("cam-grp", cfg, on_telemetry=lambda m: None) + + +class _Ctrl: + def set_loop_latency(self, _s: float) -> None: ... + def last_cmd_send_ms(self) -> float: + return 0.0 + + +def _track(track_id, box): + x1, y1, x2, y2 = box + return SimpleNamespace( + track_id=track_id, + lost=False, + vx=0.0, + vy=0.0, + bbox=SimpleNamespace(x1=x1, y1=y1, x2=x2, y2=y2), + ) + + +def _drive(worker, tracks): + published: list[dict] = [] + worker._ptz = _Ctrl() + worker._tracking_enabled = True + worker._publish_ptz = lambda ctrl, err, vel, height, *, track_active, now, log_label: ( + published.append({"err": err, "height": height, "active": track_active, "label": log_label}) + ) + frame = np.zeros((100, 100, 3), dtype=np.uint8) + worker._drive_ptz_auto(tracks, frame, now=1.0) + return published + + +def test_group_framing_on_no_lock_drives_ptz_at_group() -> None: + w = _worker(group_framing=True) + tracks = [_track(1, (0, 40, 20, 90)), _track(2, (80, 40, 100, 90))] + published = _drive(w, tracks) + assert published, "controller was never stepped" + last = published[-1] + assert last["active"] is True + assert last["label"] == "group" + # Union spans both people symmetrically around center → ~zero horizontal error. + assert abs(last["err"][0]) < 0.05 + + +def test_group_framing_off_no_lock_idles() -> None: + """Default behaviour (group framing off): no lock → controller idles.""" + w = _worker(group_framing=False) + tracks = [_track(1, (0, 40, 20, 90)), _track(2, (80, 40, 100, 90))] + published = _drive(w, tracks) + assert published + last = published[-1] + assert last["active"] is False + assert last["label"] is None + + +def test_group_framing_off_no_people_idles() -> None: + w = _worker(group_framing=True) + published = _drive(w, []) + assert published + assert published[-1]["active"] is False diff --git a/tests/test_services_panel.py b/tests/test_services_panel.py new file mode 100644 index 00000000..026af20e --- /dev/null +++ b/tests/test_services_panel.py @@ -0,0 +1,43 @@ +"""ServicesPanel (offscreen): the panel's own surface, built with a real +EngineClient so construction exercises the real refresh path. + +Runs in its own process (CI shards per file) and builds a real ``QApplication`` +via a local ``qtapp`` fixture — widgets need a GUI application object, not the +session-scoped headless ``QCoreApplication``. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + + +@pytest.fixture +def qtapp(): + from PySide6.QtWidgets import QApplication + + yield QApplication.instance() or QApplication([]) + + +def _panel(tmp_path: Path): + from autoptz.config.store import ConfigStore + from autoptz.ui.engine_client import EngineClient + from autoptz.ui.widgets.services_panel import ServicesPanel + + client = EngineClient(store=ConfigStore(db_path=tmp_path / "cfg.db", debounce_s=0)) + return ServicesPanel(client) + + +def test_no_experimental_button_in_footer(qtapp, tmp_path) -> None: + """Experimental Features moved to the Help menu — the Services panel must no + longer carry its own "Experimental..." button.""" + from PySide6.QtWidgets import QPushButton + + panel = _panel(tmp_path) + try: + assert not hasattr(panel, "_experimental_btn") + buttons = [b.text() for b in panel.findChildren(QPushButton)] + assert not any("Experimental" in (t or "") for t in buttons), buttons + finally: + panel.deleteLater() diff --git a/tests/test_source_reopen.py b/tests/test_source_reopen.py new file mode 100644 index 00000000..fbe167cb --- /dev/null +++ b/tests/test_source_reopen.py @@ -0,0 +1,216 @@ +"""Worker-owned frame-source reconnect (reopen a dead/stalled source). + +The ingest ``SourceAdapter`` has a full open/stall/reconnect loop, but it only +runs on the adapter's OWN thread — which the camera worker never starts (the +worker drives ``_open``/``_read_frame`` directly so it can feed detection). So +a source that failed to open at startup, or a session that stops delivering +frames (the classic Continuity-Camera "session runs, no frames ever arrive"), +stayed dead FOREVER: the capture loop only backed off and re-polled ``read()`` +on the dead session. The camera showed nothing until a full service restart. + +These tests cover the worker-side reopen: rebuild + reopen after a sustained +stall (``reconnect.stall_timeout_s``), retry a failed open with exponential +backoff (``backoff_initial_s`` → ``backoff_max_s``), and never touch injected +(test/synthetic) sources. +""" + +from __future__ import annotations + +import numpy as np + +from autoptz.config.models import CameraConfig, ReconnectConfig, SourceConfig + + +def _config(camera_id: str, **reconnect_kw) -> CameraConfig: + return CameraConfig( + id=camera_id, + name="ReopenCam", + source=SourceConfig(type="usb", address="usb://0"), + reconnect=ReconnectConfig(**reconnect_kw) if reconnect_kw else ReconnectConfig(), + ) + + +class _FakeSource: + def __init__(self, opens: bool = True) -> None: + self.opens = opens + self.open_calls = 0 + self.closed = False + + def open(self) -> bool: + self.open_calls += 1 + return self.opens + + def read(self): # noqa: ANN201 + return np.zeros((4, 4, 3), dtype=np.uint8) + + def close(self) -> None: + self.closed = True + + +def _worker(config: CameraConfig): # noqa: ANN201 + from autoptz.engine.camera_worker import CameraWorker + + return CameraWorker(config.id, config, lambda _m: None) + + +class TestMaybeReopenSource: + def test_dead_source_is_rebuilt_after_backoff(self, monkeypatch) -> None: + import autoptz.engine.camera_worker as cw + + built: list[_FakeSource] = [] + + def _build(_cid, _cfg): # noqa: ANN001, ANN202 + src = _FakeSource(opens=True) + built.append(src) + return src + + monkeypatch.setattr(cw, "build_frame_source", _build) + w = _worker(_config("reopen-dead", backoff_initial_s=1.0, backoff_max_s=30.0)) + w._source = None # open failed at startup + w._last_frame_t = 100.0 + + assert w._maybe_reopen_source(now=100.0) is True, "first attempt is immediate" + assert w._source is built[0], "a fresh source must be built and opened" + assert built[0].open_calls == 1 + + def test_stalled_source_is_closed_and_replaced(self, monkeypatch) -> None: + import autoptz.engine.camera_worker as cw + + built: list[_FakeSource] = [] + + def _build(_cid, _cfg): # noqa: ANN001, ANN202 + src = _FakeSource(opens=True) + built.append(src) + return src + + monkeypatch.setattr(cw, "build_frame_source", _build) + w = _worker(_config("reopen-stall", stall_timeout_s=5.0)) + stalled = _FakeSource() + w._source = stalled + w._last_frame_t = 100.0 + + # Frames still fresh → no reopen. + assert w._maybe_reopen_source(now=104.0) is False + assert w._source is stalled + + # Past the stall timeout → close the old session, open a fresh one. + assert w._maybe_reopen_source(now=105.1) is True + assert stalled.closed is True + assert w._source is built[0] + + def test_failed_open_backs_off_exponentially_to_cap(self, monkeypatch) -> None: + import autoptz.engine.camera_worker as cw + + attempts: list[float] = [] + + def _build(_cid, _cfg): # noqa: ANN001, ANN202 + return _FakeSource(opens=False) + + monkeypatch.setattr(cw, "build_frame_source", _build) + w = _worker(_config("reopen-backoff", backoff_initial_s=1.0, backoff_max_s=4.0)) + w._source = None + w._last_frame_t = 0.0 + + now = 100.0 + # Walk time forward in small steps; record when attempts actually fire. + while now < 120.0: + if w._maybe_reopen_source(now=now): + attempts.append(now) + now += 0.25 + + assert len(attempts) >= 4 + gaps = [round(b - a, 2) for a, b in zip(attempts, attempts[1:], strict=False)] + assert gaps[0] == 1.0, "second attempt after backoff_initial_s" + assert gaps[1] == 2.0, "backoff doubles" + assert all(g <= 4.0 for g in gaps), "backoff never exceeds backoff_max_s" + assert gaps[-1] == 4.0, "backoff settles at the cap" + + def test_success_resets_backoff_and_stall_window(self, monkeypatch) -> None: + import autoptz.engine.camera_worker as cw + + failing = _FakeSource(opens=False) + working = _FakeSource(opens=True) + srcs = [failing, working] + + def _build(_cid, _cfg): # noqa: ANN001, ANN202 + return srcs.pop(0) + + monkeypatch.setattr(cw, "build_frame_source", _build) + w = _worker(_config("reopen-reset", backoff_initial_s=1.0, backoff_max_s=30.0)) + w._source = None + w._last_frame_t = 0.0 + + assert w._maybe_reopen_source(now=100.0) is True # fails, backoff → 2s + assert w._source is None + assert w._maybe_reopen_source(now=101.0) is True # succeeds + assert w._source is working + # A successful reopen restarts the stall window from "now" so the fresh + # session gets a full stall_timeout before being torn down again. + assert w._last_frame_t == 101.0 + + def test_injected_source_is_never_rebuilt(self, monkeypatch) -> None: + import autoptz.engine.camera_worker as cw + + def _build(_cid, _cfg): # noqa: ANN001, ANN202 + raise AssertionError("injected sources must never be rebuilt") + + monkeypatch.setattr(cw, "build_frame_source", _build) + from autoptz.engine.camera_worker import CameraWorker + + injected = _FakeSource() + config = _config("reopen-injected", stall_timeout_s=1.0) + w = CameraWorker(config.id, config, lambda _m: None, frame_source=injected) + w._source = injected + w._last_frame_t = 0.0 + + assert w._maybe_reopen_source(now=500.0) is False + assert w._source is injected + + +class TestCaptureLoopReopenWiring: + """End-to-end: a worker whose source opens but never delivers frames must + come back on its own once the (rebuilt) source starts delivering.""" + + def test_worker_recovers_from_frameless_source(self, monkeypatch, wait_until) -> None: # noqa: ANN001 + import autoptz.engine.camera_worker as cw + + class _FramelessSource(_FakeSource): + def read(self): # noqa: ANN201 + import time + + time.sleep(0.01) + return None + + sources: list[_FakeSource] = [] + + def _build(_cid, _cfg): # noqa: ANN001, ANN202 + # First (startup) build delivers nothing; the reopened one delivers. + src = _FramelessSource() if not sources else _FakeSource() + sources.append(src) + return src + + monkeypatch.setattr(cw, "build_frame_source", _build) + config = _config( + "reopen-e2e", + stall_timeout_s=0.2, + backoff_initial_s=0.05, + backoff_max_s=0.2, + ) + w = cw.CameraWorker(config.id, config, lambda _m: None) + w.start() + try: + wait_until( + lambda: len(sources) >= 2 or None, + timeout=10.0, + interval=0.05, + message="worker never attempted to reopen the frameless source", + ) + wait_until( + lambda: (w._frames_captured > 0) or None, + timeout=10.0, + interval=0.05, + message="worker never captured frames from the reopened source", + ) + assert sources[0].closed is True, "the dead session must be closed" + finally: + w.stop() diff --git a/tests/test_supervisor_experimental.py b/tests/test_supervisor_experimental.py index 14d5cef7..957cac80 100644 --- a/tests/test_supervisor_experimental.py +++ b/tests/test_supervisor_experimental.py @@ -161,3 +161,41 @@ def test_tracking_keys_in_dict_are_ignored_for_env(tmp_path: Any, monkeypatch: A sup._apply_experimental_env() # Non-env-flag keys never reach os.environ. assert "stage_spread" not in os.environ + + +def test_stale_keys_pruned_from_persisted_dict(tmp_path: Any, monkeypatch: Any) -> None: + """Keys no longer in the registry (removed flags, the dead tracking defaults, + or excluded hardware vars) are dropped from the persisted dict at engine start.""" + monkeypatch.delenv("AUTOPTZ_PTZ_PUMP", raising=False) + sup, store = _sup(tmp_path) + store.set_setting( + "experimental_features", + { + "AUTOPTZ_PTZ_PUMP": "1", # registry key — kept + "AUTOPTZ_REID_DEVICE": "cpu", # registry key — kept + "stage_spread": False, # dead tracking default — dropped + "AUTOPTZ_FORCE_EP": "CPUExecutionProvider", # excluded hardware var — dropped + }, + ) + sup._apply_experimental_env() + cleaned = store.get_setting("experimental_features", {}) + assert set(cleaned) == {"AUTOPTZ_PTZ_PUMP", "AUTOPTZ_REID_DEVICE"} + # The excluded hardware var never reached the environment either. + assert ( + "AUTOPTZ_FORCE_EP" not in os.environ + or os.environ.get("AUTOPTZ_FORCE_EP") != "CPUExecutionProvider" + ) + + +def test_prune_does_not_rewrite_a_clean_dict(tmp_path: Any, monkeypatch: Any) -> None: + """A dict that is already all-registry-keys is left byte-for-byte unchanged.""" + monkeypatch.delenv("AUTOPTZ_PTZ_PUMP", raising=False) + sup, store = _sup(tmp_path) + clean = {"AUTOPTZ_PTZ_PUMP": "1"} + store.set_setting("experimental_features", dict(clean)) + writes: list[Any] = [] + orig = store.set_setting + store.set_setting = lambda k, v: (writes.append((k, v)), orig(k, v))[1] # type: ignore[method-assign] + sup._apply_experimental_env() + assert writes == [], "clean dict must not be rewritten" + assert store.get_setting("experimental_features", {}) == clean diff --git a/tests/test_supervisor_health.py b/tests/test_supervisor_health.py index a6510314..61d9b8be 100644 --- a/tests/test_supervisor_health.py +++ b/tests/test_supervisor_health.py @@ -8,11 +8,15 @@ from __future__ import annotations +import time +import typing + from autoptz.engine.supervisor import ( _BASE_BACKOFF_S, _INFER_RESTART_S, _MAX_BACKOFF_S, _MAX_RESTART_ATTEMPTS, + _MS_DRAIN_TIMEOUT_S, _MS_SPAWN_TIMEOUT_S, _WORKER_HANG_S, _WORKER_WARMUP_GRACE_S, @@ -896,6 +900,49 @@ def test_budget_exhaustion_sets_failed_flag_and_triggers_worker_fallback( finally: sup.stop() + def test_failed_flag_clears_once_a_fresh_respawn_is_confirmed_healthy( + self, qapp, monkeypatch + ) -> None: + """Once permanently failed, every RemotePool.detector() latches onto its + local fallback FOREVER unless model_server_failed_ev is cleared — but + nothing ever cleared it. A camera added later restarts the server (via + _attach_camera_to_model_server, which respawns unconditionally, bypassing + the exhausted-attempts cap) — once THAT fresh server's ready-handshake + actually arrives, the flag must clear and every worker must be told to + re-pull the (now healthy) shared client, or the new server sits unused.""" + sup, client, factory_log, proc_log, cid = self._build_ms(qapp, monkeypatch) + try: + now = 1000.0 + for _attempt in range(_MAX_RESTART_ATTEMPTS): + sup._model_server_proc._alive = False + now += _MAX_BACKOFF_S + 1.0 + sup._scan_model_server_health(now) + now += _MS_SPAWN_TIMEOUT_S + 0.1 + sup._scan_model_server_health(now) + assert sup.model_server_failed() is True + assert factory_log[0].refresh_calls == 1 + + # A camera added later restarts the server unconditionally. + sup._attach_camera_to_model_server("cam-late") + now += 0.1 + sup._scan_model_server_health(now) # drains the (already-dead) old proc + assert sup._ms_spawn_deadline is not None # fresh respawn now in flight + + # The fresh server's ready-handshake arrives. + sup._model_server_proc._alive = True + sup._ms_ready_ev.set() + sup._scan_model_server_health(now + 0.1) + + assert sup.model_server_failed() is False, ( + "a confirmed-healthy respawn must clear the latched failed flag" + ) + assert factory_log[0].refresh_calls == 2, ( + "workers must be told to re-pull the pool's detector a second time " + "so RemotePool resumes the shared client instead of local fallback" + ) + finally: + sup.stop() + def test_healthy_server_is_not_touched(self, qapp, monkeypatch) -> None: sup, client, factory_log, proc_log, cid = self._build_ms(qapp, monkeypatch) try: @@ -1233,3 +1280,155 @@ def test_remove_clears_failed_state(self, qapp) -> None: assert sup.is_camera_failed(cid) is False finally: sup.stop() + + +# ── late camera attach: added-after-server-start must not degrade to threaded ── + + +class _FakeProcessHandle(_MsFakeProcessWorker): + """Stands in for ProcessWorkerHandle on the real process path; records the + kwargs so tests can assert the camera got a live response queue.""" + + created: typing.ClassVar[list] = [] + + def __init__(self, camera_id, config, on_telemetry, **kwargs) -> None: # noqa: ANN001, ANN003 + super().__init__(camera_id, config, on_telemetry) + self.kwargs = kwargs + type(self).created.append(self) + + +class TestLateCameraAttach: + """A camera added AFTER the shared detection server started must attach by + restarting the server with an updated slot set (reusing the R-3 respawn + machinery — other cameras fast-fail behind the down-gate and coast), instead + of silently degrading to the threaded pipeline inside the GUI process.""" + + def _build(self, qapp, monkeypatch): # noqa: ANN001 + import multiprocessing as mp + + from autoptz.engine import process_worker + from autoptz.engine.supervisor import Supervisor + from autoptz.ui.engine_client import EngineClient + + monkeypatch.setenv("AUTOPTZ_MODEL_SERVER", "1") + client = EngineClient() + cid_a = client.addCamera("usb://0", "A") + client.drain_commands() + + threaded_log: list = [] + + def fake_threaded_factory(self, camera_id, config, on_telemetry): # noqa: ANN001, ARG001 + w = _MsFakeProcessWorker(camera_id, config, on_telemetry) + threaded_log.append(w) + return w + + # Patch the CLASS before construction so ``worker_factory == + # _default_worker_factory`` stays True (the real process path), while the + # threaded fallback stays fake and light. + monkeypatch.setattr(Supervisor, "_default_worker_factory", fake_threaded_factory) + _FakeProcessHandle.created = [] + monkeypatch.setattr(process_worker, "ProcessWorkerHandle", _FakeProcessHandle) + + real_ctx = mp.get_context("spawn") + proc_log: list[_FakeModelServerProc] = [] + + class _FakeCtx: + Queue = staticmethod(real_ctx.Queue) + Event = staticmethod(_FakeEvent) + + def Process(self, *_a, **_k): # noqa: ANN002, ANN003, N802 + p = _FakeModelServerProc() + proc_log.append(p) + return p + + monkeypatch.setattr(mp, "get_context", lambda *_a, **_k: _FakeCtx()) + + sup = Supervisor(client, store=None) + sup._ensure_identity_service = lambda: None # type: ignore[method-assign] + sup._ensure_inference_pool = lambda: None # type: ignore[method-assign] + sup.start() + return sup, client, threaded_log, proc_log, cid_a + + def test_late_added_camera_attaches_as_process_worker(self, qapp, monkeypatch) -> None: + sup, client, threaded_log, proc_log, _cid_a = self._build(qapp, monkeypatch) + try: + assert len(proc_log) == 1 + assert len(_FakeProcessHandle.created) == 1 # camera A: process worker + + cid_b = client.addCamera("usb://1", "B") + sup.tick() # route ADD_CAMERA + + # B got its own IPC slot and runs as a PROCESS worker (never the + # threaded fallback) — all decided synchronously by the attach call + # (it registers the slot and returns immediately; see + # test_attach_registers_slot_and_returns_without_blocking below). + assert cid_b in sup._infer_resp_qs + assert cid_b in sup._model_server_camera_ids + assert len(_FakeProcessHandle.created) == 2 + assert _FakeProcessHandle.created[-1].camera_id == cid_b + assert threaded_log == [] + assert sup._model_server_down.is_set() + + # The OLD server's actual drain — and therefore the restart — is NOT + # done yet: it is polled asynchronously via _scan_model_server_health, + # never blocked on inside the attach call above. + assert len(proc_log) == 1 + assert proc_log[0].terminate_calls == 0 + assert sup._ms_drain_proc is proc_log[0] + + # _FakeModelServerProc never "drains" on its own (join() is a no-op) — + # advancing past the drain deadline must escalate to terminate, then + # start the replacement server. + sup._scan_model_server_health(time.monotonic() + _MS_DRAIN_TIMEOUT_S + 0.1) + assert sup._ms_drain_proc is None + assert proc_log[0].terminate_calls >= 1 + assert len(proc_log) == 2 + + # Down-gate held for the whole swap; the ready handshake clears it. + assert sup._model_server_down.is_set() + sup._ms_ready_ev.set() + sup._scan_model_server_health(time.monotonic()) + assert not sup._model_server_down.is_set() + finally: + sup.stop() + + def test_attach_registers_slot_and_returns_without_blocking(self, qapp, monkeypatch) -> None: + """The attach call itself must never join()/block on the OLD server — + _on_add_camera runs on the GUI-thread tick(), and _MS_DRAIN_TIMEOUT_S + (3.0s) blocking there is exactly the multi-second UI stutter this + non-blocking drain redesign exists to remove.""" + sup, client, _threaded_log, proc_log, _cid_a = self._build(qapp, monkeypatch) + try: + join_calls: list[float | None] = [] + real_join = proc_log[0].join + + def recording_join(timeout: float | None = None) -> None: + join_calls.append(timeout) + real_join(timeout) + + proc_log[0].join = recording_join # type: ignore[method-assign] + + cid_b = client.addCamera("usb://1", "B") + sup.tick() # route ADD_CAMERA -> _attach_camera_to_model_server + + assert join_calls == [], ( + f"attach must not join() the old server synchronously, got {join_calls}" + ) + assert cid_b in sup._infer_resp_qs # slot registration IS synchronous + finally: + sup.stop() + + def test_no_server_at_all_still_falls_back_threaded(self, qapp, monkeypatch) -> None: + """When the server never came up there is nothing to attach to — the + threaded fallback (with its warning) must behave exactly as before.""" + sup, client, threaded_log, proc_log, _cid_a = self._build(qapp, monkeypatch) + try: + sup._infer_req_q = None + sup._model_server_proc = None + client.addCamera("usb://1", "B") + sup.tick() + assert len(threaded_log) == 1 + assert len(_FakeProcessHandle.created) == 1 # unchanged + assert len(proc_log) == 1 # no restart attempted + finally: + sup.stop() diff --git a/tests/test_target_combo_identity_tooltip.py b/tests/test_target_combo_identity_tooltip.py new file mode 100644 index 00000000..c6c8d5db --- /dev/null +++ b/tests/test_target_combo_identity_tooltip.py @@ -0,0 +1,128 @@ +"""Regression: the 'Track person' combo has no elide+tooltip recovery path. + +``PropertiesPanel._constrain_field_widths`` (properties_panel.py) forces every +``QComboBox``/``QLineEdit`` descendant to an ``Ignored`` horizontal size policy +plus a 48px floor, so the form never widens a narrow dock — including +``self._target_combo``, which is populated with user-entered identity names of +unbounded length (see ``_reload_targets``). + +Unlike the panel's own ``_set_caption`` helper (elides a QLabel to its current +width and always mirrors the FULL text on the tooltip — used for the Address +and tracking-state captions), a ``QComboBox`` has no built-in elide-on-paint +for its closed-box text and no tooltip that tracks the current selection. At a +panel resized to its own floor width, a long identity name can render +hard-clipped with nothing to recover the full value. + +``tests/test_panel_min_width_no_clip.py``'s ``_overflows()`` helper only +inspects ``QLabel | QCheckBox | QPushButton`` widgets, so it never catches +this combo-box gap. +""" + +from __future__ import annotations + +import pytest + + +@pytest.fixture +def qtapp(): + from PySide6.QtWidgets import QApplication + + yield QApplication.instance() or QApplication([]) + + +LONG_IDENTITY_NAME = "Bartholomew Alexander Montgomery-Fitzgerald the Third of Whitmoreshire" + + +def _expand_all_sections(panel, app) -> None: + """Force-expand every CollapsibleGroup, skipping the animation. + + ``_target_combo`` lives in the "Tracking" section, which is collapsed by + default (``expanded=False``) — expand it so the combo has real, laid-out + geometry to measure (same helper as tests/test_panel_min_width_no_clip.py). + """ + from autoptz.ui.widgets.common import CollapsibleGroup + + for g in panel.findChildren(CollapsibleGroup): + g._toggle.setChecked(True) + g._on_toggle(True) + g._height_anim.stop() + g._content.setMaximumHeight(16777215) + g._content.setVisible(True) + g._expanded = True + app.processEvents() + app.processEvents() + + +def _panel_targeting(name: str): + """Build a panel whose one camera's tracking target is a labeled identity + named *name*, registered BEFORE ``set_camera`` so ``_reload_targets`` picks + it up and auto-selects it (mirrors ``_load`` reading ``target.identity_id``).""" + from autoptz.config.models import IdentityRecord + from autoptz.ui.engine_client import EngineClient + from autoptz.ui.frames import ShmFrameSource + from autoptz.ui.widgets.properties_panel import PropertiesPanel + + client = EngineClient() + cid = client.addCamera("usb://0", "Cam") + ident = IdentityRecord(name=name) + client.push_identity(ident) + client.setTargetIdentity(cid, ident.id) + panel = PropertiesPanel(client, frame_source=ShmFrameSource()) + return client, cid, panel + + +def test_target_combo_tooltip_mirrors_full_identity_name_at_floor_width(qtapp) -> None: + """Repro: long identity name + panel shrunk to its own floor width. + + The combo may still hard-clip its VISIBLE text (Qt gives QComboBox no + built-in elide-on-paint for the closed box), but the full untruncated name + must be one hover away — the tooltip must mirror it, exactly like + ``_set_caption``'s label contract elsewhere in this panel. + """ + _client, cid, panel = _panel_targeting(LONG_IDENTITY_NAME) + try: + width = max(panel.minimumWidth(), panel.minimumSizeHint().width()) + panel.resize(width, 820) + panel.show() + qtapp.processEvents() + panel.set_camera(cid) + _expand_all_sections(panel, qtapp) + + combo = panel._target_combo + assert combo.currentText() == LONG_IDENTITY_NAME # sanity: selection loaded + + # Sanity: this repro only proves something if the combo really is too + # narrow to fit the whole name at the panel's own floor width (the + # reported scenario) — _constrain_field_widths forces Ignored + 48px. + avail = combo.width() + needed = combo.fontMetrics().horizontalAdvance(LONG_IDENTITY_NAME) + assert avail < needed, ( + f"test setup didn't reproduce overflow at floor width: avail={avail} needed={needed}" + ) + + assert combo.toolTip() == LONG_IDENTITY_NAME, ( + "long identity name is not recoverable when the combo can't show it " + f"in full: expected the tooltip to mirror the full name, got {combo.toolTip()!r}" + ) + finally: + panel.deleteLater() + + +def test_target_combo_tooltip_keeps_help_text_for_anyone(qtapp) -> None: + """No identity is selected ('— Anyone —') -> keep the descriptive help + tooltip instead of mirroring a (non-existent) identity name.""" + from autoptz.ui.engine_client import EngineClient + from autoptz.ui.frames import ShmFrameSource + from autoptz.ui.widgets.properties_panel import PropertiesPanel + + client = EngineClient() + cid = client.addCamera("usb://0", "Cam") + panel = PropertiesPanel(client, frame_source=ShmFrameSource()) + try: + panel.set_camera(cid) + qtapp.processEvents() + combo = panel._target_combo + assert combo.currentText() == "— Anyone —" + assert "registered person" in combo.toolTip() + finally: + panel.deleteLater() diff --git a/tests/test_ui.py b/tests/test_ui.py index 3e3fec6e..2f6c3503 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -502,11 +502,12 @@ def test_tile_paints_without_error_when_state_and_degradation_chips_active(self, client.cameraModel.add_camera(rec) tile = CameraTile("cam-1", client, frame_source=None) try: - # The tile must actually own paint methods for both chips (not just - # tolerate their absence) — proves the HUD is wired, not merely that - # painting doesn't crash without them. + # The state chip is the tile's status surface; the old bare "×N" + # degradation chip is deliberately GONE (it was cryptic and stacked + # onto the target label) — its info lives in the "?" per-stage + # tooltip and the Properties caption instead. assert hasattr(tile, "_paint_state_chip") - assert hasattr(tile, "_paint_degradation_chip") + assert not hasattr(tile, "_paint_degradation_chip") tile.resize(320, 240) tile.grab() # forces paintEvent offscreen; must not raise finally: @@ -1862,10 +1863,10 @@ def test_status_logs_button_controls_logs_dock(self, tmp_path) -> None: result = _run_ui_smoke(code, cwd=Path(__file__).resolve().parents[1], env=env, timeout=30) assert result.returncode == 0, result.stderr or result.stdout - def test_engine_menu_opens_experimental_features_dialog(self, tmp_path) -> None: - """Engine menu must expose "Experimental Features..." and triggering it - must actually open the (registry-driven) ExperimentalFeaturesDialog — - the dialog class exists and is exported but was never mounted anywhere. + def test_help_menu_opens_experimental_features_dialog(self, tmp_path) -> None: + """Experimental Features lives in the Help menu (not Engine), between + "Run AutoPTZ Mark…" and "About AutoPTZ", and triggering it opens the + (registry-driven) ExperimentalFeaturesDialog. """ code = f""" import os @@ -1887,13 +1888,26 @@ def test_engine_menu_opens_experimental_features_dialog(self, tmp_path) -> None: assert act is not None, "MainWindow has no _act_experimental action" assert "Experimental Features" in act.text() - engine_menu = None + engine_actions = None + help_actions = None for menu_action in win.menuBar().actions(): - if menu_action.text().replace("&", "") == "Engine": - engine_menu = menu_action.menu() - break - assert engine_menu is not None, "no Engine menu found" - assert act in engine_menu.actions(), "_act_experimental is not in the Engine menu" + sub = menu_action.menu() + if sub is None: + continue + name = menu_action.text().replace("&", "") + if name == "Engine": + engine_actions = list(sub.actions()) + elif name == "Help": + help_actions = list(sub.actions()) + assert engine_actions is not None and help_actions is not None + assert act not in engine_actions, "experimental must NOT be in Engine" + assert act in help_actions, "_act_experimental is not in the Help menu" + + texts = [a.text() for a in help_actions] + mark_i = next(i for i, t in enumerate(texts) if "Run AutoPTZ Mark" in (t or "")) + exp_i = help_actions.index(act) + about_i = next(i for i, t in enumerate(texts) if "About AutoPTZ" in (t or "")) + assert mark_i < exp_i < about_i, texts opened = {{}} orig_exec = ExperimentalFeaturesDialog.exec @@ -2052,8 +2066,11 @@ def test_tracking_state_row_hidden_when_idle(self, qapp, tmp_path) -> None: camera_id=cid, seq=1, tracking_status=TrackingStatusInfo(state="idle") ) panel._on_telemetry() - assert panel._track_state.isHidden() - assert panel._track_state.text() == "" + # The caption stays visible with a blank placeholder — reserving its + # line so the panel below never jumps when the state flips (the old + # show/hide made the whole Tracking section move under load). + assert not panel._track_state.isHidden() + assert panel._track_state.text() == " " finally: panel.deleteLater() @@ -2223,8 +2240,8 @@ def test_construction_reads_state_and_apply_persists(self, tmp_path) -> None: # Untouched pump flag is preserved. assert saved.get("AUTOPTZ_PTZ_PUMP") == "1" - # Every env flag has a row widget. - assert set(dlg._bool_boxes) | set(dlg._choice_combos) == {{ + # Every env flag has a row widget (bool / choice / text|path). + assert set(dlg._bool_boxes) | set(dlg._choice_combos) | set(dlg._text_fields) == {{ f.env_key for f in EXPERIMENTAL_FLAGS }} diff --git a/tools/fetch_models.py b/tools/fetch_models.py index 59035b21..100dd842 100644 --- a/tools/fetch_models.py +++ b/tools/fetch_models.py @@ -106,20 +106,17 @@ def main(argv: list[str] | None = None) -> int: # faces. Best-effort — a failure here is a warning, never a hard exit, since # face recognition is optional (manual click-to-track still works). if not args.detector_only: - from autoptz.engine.pipeline.identify import ensure_face_model - - # Fetch INTO the model cache dir (…/insightface) — the same tree the build - # scripts pass via --cache-dir autoptz/models, so the pack is bundled by - # packaging/autoptz.spec and resolved by insightface_root() at runtime. - # (Plain ensure_face_model() would write to ~/.insightface, which is NOT - # bundled — the gap that left installer users without face weights.) - face_root = str(mgr.cache_dir / "insightface") - log.info("Fetching face model (insightface buffalo_l) into %s ...", face_root) - face_err = ensure_face_model(root=face_root) - if face_err: + # Delegate to the same ModelManager method the GUI's Manage Models dialog + # uses, so there is one path that downloads the face pack INTO the app-data + # cache (…/insightface) — the tree the build scripts pass via + # --cache-dir autoptz/models, bundled by packaging/autoptz.spec and resolved + # by insightface_root() at runtime. + log.info("Fetching face model (insightface buffalo_l) into %s ...", mgr.cache_dir) + results = mgr.ensure_face_pack() + if results and results[0].get("state") == "failed": log.warning( " → face model NOT available (offline face enrolment will be disabled): %s", - face_err, + results[0].get("error", ""), ) else: log.info(" → ready: insightface buffalo_l")