Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
d3a2bcf
feat(ui): move Experimental Features to Help menu; drop Services foot…
TCVinNYC Jul 4, 2026
9915287
fix(app): persist pre-Mark engine state when quitting from a Mark run
TCVinNYC Jul 4, 2026
09493bf
refactor(engine): retire process-per-camera alias; plain-language mod…
TCVinNYC Jul 4, 2026
1ea832d
feat(ui): per-camera group-framing checkbox in Properties → PTZ
TCVinNYC Jul 4, 2026
3c106e4
feat(mark): rename preflight source group to "Test video"; add fideli…
TCVinNYC Jul 4, 2026
a3aa684
feat(ui): reorganize Experimental dialog into sections; surface model…
TCVinNYC Jul 4, 2026
8c4a57b
feat(models): first-class face-pack download/remove in Model Manager
TCVinNYC Jul 4, 2026
1cc8c4d
docs: refresh for the simplification pass (flags/config/install/troub…
TCVinNYC Jul 4, 2026
096de4f
fix(models): address adversarial-review findings on the face-pack work
TCVinNYC Jul 4, 2026
bfe1f82
feat(engine): shared framing target so physical PTZ frames like Cente…
TCVinNYC Jul 4, 2026
ff00adf
feat(output): per-camera NDI output for the Center Stage feed
TCVinNYC Jul 4, 2026
207bffa
fix(ui): resolve issues found in live GUI validation
TCVinNYC Jul 4, 2026
d38633a
docs: virtual PTZ simulation design note (closed-loop control-quality…
TCVinNYC Jul 4, 2026
db924b0
fix(engine): auto-start persists the user's intent, not the running s…
TCVinNYC Jul 4, 2026
12202a7
fix(ui): face-pack deletable + moved up, Mark NDI label, panel fit, d…
TCVinNYC Jul 4, 2026
9dcbba6
fix(ui/engine): tile HUD overlap + ×N chip, EP label flap, log spam, …
TCVinNYC Jul 4, 2026
7928cb3
fix(ui): enforce panel minimum widths after layout restore; captions …
TCVinNYC Jul 4, 2026
4ac0818
fix(ui): run dock-minimum enforcement after first show (docks invisib…
TCVinNYC Jul 4, 2026
0943415
fix(ui): tracking captions must not inflate the Properties body width
TCVinNYC Jul 4, 2026
881cbb4
fix(ui): Properties width floor accounts for scrollbar + widest track…
TCVinNYC Jul 4, 2026
19b867e
fix(engine/ui): output sinks off the capture thread; long addresses c…
TCVinNYC Jul 4, 2026
c54285e
fix(ui): measured layout audit — address elide timing, Services width…
TCVinNYC Jul 4, 2026
bf02508
fix(ui): no-horizontal-clip invariant — wrap the fps caption, true wi…
TCVinNYC Jul 4, 2026
32e75aa
feat(engine): model-server label shows the real EP; silence insightfa…
TCVinNYC Jul 4, 2026
3f47a90
fix(engine): late-added cameras attach to the model-server instead of…
TCVinNYC Jul 4, 2026
686adc8
fix(engine): model-server children get the real face DB + live identi…
TCVinNYC Jul 4, 2026
307d24f
fix(engine): capture loop reopens a dead or stalled frame source (was…
TCVinNYC Jul 4, 2026
82ee086
fix(engine): adding/re-adding a camera no longer leaves the model-ser…
TCVinNYC Jul 4, 2026
0527f1d
fix(engine): Center Stage honors 'Ignore arms' — crop frames the pose…
TCVinNYC Jul 4, 2026
07ce683
fix(engine): arm-invariant aim on BOTH actuators; one shared composit…
TCVinNYC Jul 4, 2026
a3b38c0
chore(tests): drop unused time import (pre-existing ruff F401)
TCVinNYC Jul 4, 2026
8733b34
fix(engine): pose-first framing — sticky good-pose cache, true body-e…
TCVinNYC Jul 4, 2026
cf85b15
fix(engine): continuous pose-box smoothing + head-recovery hysteresis…
TCVinNYC Jul 4, 2026
08628e1
fix(engine): pose was silently DEAD in model-server mode — aim dot fo…
TCVinNYC Jul 5, 2026
9933154
fix(engine): Center Stage crop stays arm-invariant when the hips are …
TCVinNYC Jul 5, 2026
04fdf09
feat(engine): Center Stage crops only while tracking is on; crop foll…
TCVinNYC Jul 5, 2026
2a52718
feat(engine): Center Stage composes the tracking dot — dot-anchored c…
TCVinNYC Jul 5, 2026
381b2c7
chore: ruff format compliance; fix group-framing tests for the tracki…
TCVinNYC Jul 5, 2026
6535716
fix(engine): Windows CI failures + 3 review findings before v2.2 merge
TCVinNYC Jul 5, 2026
67bcdf4
fix(engine): late-camera model-server attach no longer blocks the GUI…
TCVinNYC Jul 5, 2026
0aec040
fix(ui): Windows-only panel-width-clip CI failures — compute floors f…
TCVinNYC Jul 5, 2026
7c1f37d
fix(ui): PropertiesPanel width floor + long identity names get a tooltip
TCVinNYC Jul 5, 2026
eb0024c
fix(engine): model-server mode — Manage Models mutations now reach ca…
TCVinNYC Jul 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion autoptz/config/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <camera name>"
digital_output_w: int = Field(default=1280, ge=320, le=3840)
digital_output_h: int = Field(default=720, ge=240, le=2160)

Expand Down
794 changes: 702 additions & 92 deletions autoptz/engine/camera_worker.py

Large diffs are not rendered by default.

26 changes: 26 additions & 0 deletions autoptz/engine/discovery/ndi.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 "
)
140 changes: 140 additions & 0 deletions autoptz/engine/framing_target.py
Original file line number Diff line number Diff line change
@@ -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)
29 changes: 29 additions & 0 deletions autoptz/engine/pipeline/digital_framer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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*.

Expand All @@ -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)
Expand All @@ -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))
Expand Down Expand Up @@ -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,
Expand All @@ -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)
Expand Down
Loading
Loading