From 56e834295c939654be009f07163d36f803b299ad Mon Sep 17 00:00:00 2001 From: tlam Date: Fri, 12 Jun 2026 13:26:35 +0200 Subject: [PATCH 1/3] feat: constraint-aware 3D refinement with fixed/pinned points in correction GUI Imperfect camera calibration means no single 3D point reprojects exactly onto all views, so Edit 3D now supports finalizing per-view points. There is always one internal 3D point per keypoint; a drag re-estimates it by a constrained DLT over the fixed views' locked pixels plus the dragged cursor, and non-fixed views follow its reprojection. So how a point moves in one view when another is dragged depends on which views are fixed. - Dragging a joint pins the dragged view at the drop pixel (a finalized constraint), so it stays exactly where dropped instead of snapping to the reprojection; the live mid-drag re-solve follows the mouse without pinning. - Right-click toggles a view's "fixed" flag directly (e.g. to un-pin). - Fixed points render with a bold yellow ring. The corrections.h5 sidecar gains a pose2d_corrections/fixed (V,T,P) boolean mask (schema v2, backward compatible: v1 files load with it all-False). results.h5 is still never touched. The three outputs of a refined frame are the estimated 3D points, the corrected per-view 2D, and the fixed mask. Adds EditorState refinement tests, sidecar round-trip/v1-compat tests, and headless widget tests covering the pin-on-release gesture. Co-Authored-By: Claude Opus 4.8 --- src/deeperfly/gui/corrections.py | 37 ++++- src/deeperfly/gui/state.py | 154 +++++++++++++++--- src/deeperfly/gui/view.py | 51 +++++- src/deeperfly/gui/window.py | 43 ++++- tests/test_gui.py | 266 +++++++++++++++++++++++++++++++ 5 files changed, 508 insertions(+), 43 deletions(-) diff --git a/src/deeperfly/gui/corrections.py b/src/deeperfly/gui/corrections.py index b69c768..87167d0 100644 --- a/src/deeperfly/gui/corrections.py +++ b/src/deeperfly/gui/corrections.py @@ -8,7 +8,7 @@ NaN-valued correction is still distinguishable from "not edited"), and lets a single point be reset cleanly. -The layout (schema v1): +The layout (schema v2): .. code-block:: text @@ -16,9 +16,13 @@ pose2d_corrections/ points (V, T, P, 2) edited 2D points (NaN where not edited) edited (V, T, P) bool + fixed (V, T, P) bool -- "finalized" per-view points used as + 3D-refinement constraints (subset of edited) pose3d_corrections/ points3d (T, P, 3) edited 3D points (NaN where not edited) edited (T, P) bool + +The ``fixed`` mask is new in v2; v1 sidecars (without it) load with ``fixed`` all-False. """ from __future__ import annotations @@ -34,7 +38,7 @@ __all__ = ["Corrections", "save_corrections", "load_corrections"] -CORRECTIONS_FORMAT_VERSION = 1 +CORRECTIONS_FORMAT_VERSION = 2 @dataclass @@ -42,14 +46,18 @@ class Corrections: """In-memory overlay of manual edits on top of a :class:`PoseResult`. ``points`` arrays hold the edited values (NaN elsewhere); the ``edited`` - masks say which entries are real edits. ``dirty`` tracks unsaved in-memory - changes (set on every edit, cleared by :func:`save_corrections`). + masks say which entries are real edits. ``pts2d_fixed`` marks the per-view + 2D points the operator has "finalized" -- they stay put under further edits + and act as constraints when the 3D point is re-solved (always a subset of + ``pts2d_edited``). ``dirty`` tracks unsaved in-memory changes (set on every + edit, cleared by :func:`save_corrections`). """ pts2d: Float[np.ndarray, "V T P 2"] pts2d_edited: Bool[np.ndarray, "V T P"] pts3d: Float[np.ndarray, "T P 3"] pts3d_edited: Bool[np.ndarray, "T P"] + pts2d_fixed: Bool[np.ndarray, "V T P"] dirty: bool = field(default=False) @classmethod @@ -60,6 +68,7 @@ def empty(cls, n_views: int, n_frames: int, n_points: int) -> Corrections: pts2d_edited=np.zeros((n_views, n_frames, n_points), dtype=bool), pts3d=np.full((n_frames, n_points, 3), np.nan), pts3d_edited=np.zeros((n_frames, n_points), dtype=bool), + pts2d_fixed=np.zeros((n_views, n_frames, n_points), dtype=bool), ) @property @@ -67,10 +76,18 @@ def any_edits(self) -> bool: """Whether any 2D or 3D point has been edited.""" return bool(self.pts2d_edited.any() or self.pts3d_edited.any()) - def set_pts2d(self, view: int, frame: int, point: int, xy) -> None: - """Record a 2D edit of ``point`` in ``view`` at ``frame``.""" + def set_pts2d( + self, view: int, frame: int, point: int, xy, *, fixed: bool = False + ) -> None: + """Record a 2D edit of ``point`` in ``view`` at ``frame``. + + With ``fixed=True`` the point is also marked finalized (a constraint for + 3D refinement); ``fixed=False`` leaves the existing fixed flag untouched. + """ self.pts2d[view, frame, point] = np.asarray(xy, dtype=float) self.pts2d_edited[view, frame, point] = True + if fixed: + self.pts2d_fixed[view, frame, point] = True self.dirty = True def set_pts3d(self, frame: int, point: int, xyz) -> None: @@ -83,6 +100,7 @@ def clear_2d(self, view: int, frame: int, point: int) -> None: """Drop the 2D edit of ``point`` in ``view`` at ``frame`` (back to original).""" self.pts2d[view, frame, point] = np.nan self.pts2d_edited[view, frame, point] = False + self.pts2d_fixed[view, frame, point] = False self.dirty = True def clear_3d(self, frame: int, point: int) -> None: @@ -118,6 +136,7 @@ def save_corrections( g2 = f.create_group("pose2d_corrections") g2.create_dataset("points", data=corrections.pts2d) g2.create_dataset("edited", data=corrections.pts2d_edited) + g2.create_dataset("fixed", data=corrections.pts2d_fixed) g3 = f.create_group("pose3d_corrections") g3.create_dataset("points3d", data=corrections.pts3d) g3.create_dataset("edited", data=corrections.pts3d_edited) @@ -156,6 +175,11 @@ def load_corrections( pts2d_edited = np.asarray(f["pose2d_corrections/edited"][()], dtype=bool) # type: ignore[index] pts3d = np.asarray(f["pose3d_corrections/points3d"][()], dtype=float) # type: ignore[index] pts3d_edited = np.asarray(f["pose3d_corrections/edited"][()], dtype=bool) # type: ignore[index] + # "fixed" is new in schema v2; v1 sidecars load with it all-False. + if "pose2d_corrections/fixed" in f: + pts2d_fixed = np.asarray(f["pose2d_corrections/fixed"][()], dtype=bool) # type: ignore[index] + else: + pts2d_fixed = np.zeros(pts2d_edited.shape, dtype=bool) want2d = (n_views, n_frames, n_points, 2) want3d = (n_frames, n_points, 3) if pts2d.shape != want2d or pts3d.shape != want3d: @@ -168,5 +192,6 @@ def load_corrections( pts2d_edited=pts2d_edited, pts3d=np.asarray(pts3d, dtype=float), pts3d_edited=pts3d_edited, + pts2d_fixed=pts2d_fixed, dirty=False, ) diff --git a/src/deeperfly/gui/state.py b/src/deeperfly/gui/state.py index 5952285..032d4df 100644 --- a/src/deeperfly/gui/state.py +++ b/src/deeperfly/gui/state.py @@ -5,13 +5,22 @@ (corrected-over-original) for the current frame, applies 2D and 3D edits, and holds the dirty/edit-mode flags. -The 3D edit is the interesting one: dragging a point in one view to a pixel must -move the 3D point to the location that (1) reprojects exactly onto that pixel in -that view and (2) is closest to where the point was. That is the orthogonal -projection of the old 3D point onto the back-projection ray of the dragged pixel +The 3D edit is the interesting one. There is always a single internal 3D point +per keypoint; a drag re-estimates it and then refreshes every view's reprojection +with the same forward model used to draw it. Imperfect calibration means no single +3D point reprojects exactly onto all views at once, so a per-view point can be +*fixed* (finalized): a fixed view keeps its locked pixel and acts as a constraint. +Dropping a drag pins the dragged view there (it becomes fixed at the release +pixel), so the placed point stays put instead of snapping to the reprojection. + +On a drag we re-solve the 3D point by a constrained DLT +(:func:`deeperfly.triangulation.triangulate`) over the fixed views' locked pixels +plus the dragged view's cursor; with fewer than two such observations (the common +"nothing fixed yet" case) it falls back to the orthogonal projection of the old 3D +point onto the back-projection ray of the dragged pixel (:func:`deeperfly.geometry.backproject_ray_one` + -:func:`deeperfly.geometry.closest_point_on_ray`), after which every other view's -reprojection is recomputed with the same forward model used to draw it. +:func:`deeperfly.geometry.closest_point_on_ray`), which lands the point exactly +under the cursor. Non-fixed views then follow the new 3D point's reprojection. """ from __future__ import annotations @@ -25,6 +34,7 @@ from ..geometry import closest_point_on_ray from ..results import PoseResult +from ..triangulation import triangulate from .corrections import Corrections __all__ = ["EditMode", "EditorState"] @@ -127,6 +137,24 @@ def display_pts3d_projected( return None return np.asarray(self.result.cameras.project(pts3d)) + def display_pts2d_refine( + self, frame: int | None = None + ) -> Float[np.ndarray, "V P 2"] | None: + """The per-view 2D drawn in Edit 3D: the 3D point reprojected into every + view, with each *fixed* view overridden by its locked pixel, or ``None``. + + This is the "corrected 2D" result of a refined frame: non-fixed views are + a single 3D point's reprojection while fixed views hold the operator's + finalized pixels (which generally do not all agree with one 3D point). + """ + proj = self.display_pts3d_projected(frame) + if proj is None: + return None + t = self._resolve_frame(frame) + fixed = self.corrections.pts2d_fixed[:, t] # (V, P) + locked = self.corrections.pts2d[:, t] # (V, P, 2) + return np.where(fixed[..., None], locked, proj) + # -- edits ---------------------------------------------------------------- def apply_2d_edit( @@ -136,15 +164,25 @@ def apply_2d_edit( self.corrections.set_pts2d(view, self._resolve_frame(frame), point, xy) def apply_3d_edit( - self, view: int, point: int, xy, frame: int | None = None + self, view: int, point: int, xy, frame: int | None = None, *, fix: bool = False ) -> Float[np.ndarray, "3"] | None: """Re-solve ``point``'s 3D location from a drag to pixel ``xy`` in ``view``. + The 3D point is re-estimated by a constrained DLT over the *fixed* views' + locked pixels plus the dragged view's cursor; non-fixed views then follow + its reprojection. With fewer than two such observations (e.g. nothing is + fixed yet) it falls back to the orthogonal projection of the old 3D point + onto the back-projection ray of ``xy``, which lands the point exactly + under the cursor (the original Edit 3D behavior). + + With ``fix=True`` (a drag *release*) the dragged view is finalized at + ``xy``: it is pinned there as a locked constraint so it stays exactly + where it was dropped instead of snapping to the reprojection. The live + re-solve mid-drag uses ``fix=False`` so a view is only pinned on release + (or if it was already fixed, in which case its lock follows the cursor). + Returns the new 3D point, or ``None`` if there is no 3D point to move - (no triangulation, or the point is NaN at this frame). The returned - point lies on the back-projection ray of ``xy`` through ``view`` (so it - reprojects exactly onto ``xy`` there) and is the closest such point to - the pre-drag 3D location. + (no triangulation, or no usable constraint and the point is NaN here). Parameters ---------- @@ -156,29 +194,95 @@ def apply_3d_edit( The pixel the user dragged the point to, ``(2,)``. frame The frame to edit (defaults to the current frame). + fix + Whether to finalize (pin) the dragged view at ``xy`` -- set on a drag + release so the dropped point persists; left ``False`` for the live + mid-drag re-solve. """ if self.result.pts3d is None: return None t = self._resolve_frame(frame) - pts3d = self.display_pts3d(t) - assert pts3d is not None - x_old = pts3d[point] - if not np.all(np.isfinite(x_old)): + xy = np.asarray(xy, dtype=float) + fixed = self.corrections.pts2d_fixed[:, t, point] # (V,) + + # Observations for the constrained DLT: each fixed view at its locked + # pixel, plus the dragged view at the cursor (overriding if it is fixed). + obs = np.full((self.n_views, 2), np.nan) + obs[fixed] = self.corrections.pts2d[fixed, t, point] + obs[view] = xy + + if int(np.isfinite(obs).all(axis=1).sum()) >= 2: + x_new = np.asarray( + triangulate(self.result.cameras, obs[:, None, :])[0], dtype=float + ) + else: + pts3d = self.display_pts3d(t) + assert pts3d is not None + x_old = pts3d[point] + if not np.all(np.isfinite(x_old)): + return None + camera = list(self.result.cameras)[view] + origin, direction = camera.backproject_ray(xy) + x_new = np.asarray( + closest_point_on_ray( + jnp.asarray(origin), jnp.asarray(direction), jnp.asarray(x_old) + ), + dtype=float, + ) + if not np.all(np.isfinite(x_new)): return None - camera = list(self.result.cameras)[view] - origin, direction = camera.backproject_ray(np.asarray(xy, dtype=float)) - x_new = np.asarray( - closest_point_on_ray( - jnp.asarray(origin), jnp.asarray(direction), jnp.asarray(x_old) - ), - dtype=float, - ) self.corrections.set_pts3d(t, point, x_new) + if fix or bool(fixed[view]): + self.corrections.set_pts2d(view, t, point, xy, fixed=True) return x_new + def toggle_fixed( + self, view: int, point: int, frame: int | None = None + ) -> bool | None: + """Toggle whether ``point`` in ``view`` is finalized (a 3D constraint). + + Fixing snapshots the view's current displayed 2D as a locked pixel; + unfixing drops it back to following the reprojection. Either way the 3D + point is re-estimated from the (new) fixed set so the non-fixed views + update. Returns the new fixed state, or ``None`` if there is no 3D point + to refine or the point is not visible in this view. + """ + if self.result.pts3d is None: + return None + t = self._resolve_frame(frame) + cur2d = self.display_pts2d_refine(t) + if cur2d is None: + return None + now_fixed = not bool(self.corrections.pts2d_fixed[view, t, point]) + if now_fixed: + xy = cur2d[view, point] + if not np.all(np.isfinite(xy)): + return None # cannot fix a point that is not visible in this view + self.corrections.set_pts2d(view, t, point, xy, fixed=True) + else: + self.corrections.clear_2d(view, t, point) + self._resolve_3d_from_fixed(point, t) + return now_fixed + + def _resolve_3d_from_fixed(self, point: int, t: int) -> None: + """Re-triangulate ``point``'s 3D location from its fixed views alone. + + A no-op below two fixed views (the 3D point keeps its current value). + """ + fixed = self.corrections.pts2d_fixed[:, t, point] # (V,) + if int(fixed.sum()) < 2: + return + obs = np.full((self.n_views, 2), np.nan) + obs[fixed] = self.corrections.pts2d[fixed, t, point] + x_new = np.asarray( + triangulate(self.result.cameras, obs[:, None, :])[0], dtype=float + ) + if np.all(np.isfinite(x_new)): + self.corrections.set_pts3d(t, point, x_new) + def reset_point(self, point: int, frame: int | None = None) -> None: - """Drop every correction (all views' 2D and the 3D) of ``point`` at ``frame``.""" + """Drop every correction (all views' 2D, the fixed flags, the 3D) of ``point``.""" t = self._resolve_frame(frame) for view in range(self.n_views): - self.corrections.clear_2d(view, t, point) + self.corrections.clear_2d(view, t, point) # also clears the fixed flag self.corrections.clear_3d(t, point) diff --git a/src/deeperfly/gui/view.py b/src/deeperfly/gui/view.py index a7b41dd..8120565 100644 --- a/src/deeperfly/gui/view.py +++ b/src/deeperfly/gui/view.py @@ -49,6 +49,8 @@ class PoseView(QGraphicsView): pointDragged = Signal(int, int, float, float) #: Emitted continuously while dragging (same payload), for live cross-view updates. pointDragging = Signal(int, int, float, float) + #: Emitted on right-click of a point: ``(view_index, point_index)`` to toggle "fixed". + pointFixToggled = Signal(int, int) def __init__(self, view_index: int, parent=None): super().__init__(parent) @@ -62,6 +64,7 @@ def __init__(self, view_index: int, parent=None): self._colors: list[QColor] = [] self._bones = np.empty((0, 2), dtype=int) self._pts: np.ndarray | None = None + self._fixed: np.ndarray | None = None self._editable = False self._highlight: int | None = None self._dragging: int | None = None @@ -118,10 +121,25 @@ def set_image(self, image: np.ndarray) -> None: self._fit() def set_points(self, pts2d: np.ndarray) -> None: - """Move the joint/bone items to ``pts2d`` (``(P, 2)``); hide NaN points.""" + """Move the joint/bone items to ``pts2d`` (``(P, 2)``); hide NaN points. + + While a drag is in progress the dragged joint is pinned to the cursor and + is *not* overwritten by ``pts2d``. The live 3D re-solve reprojects that + joint a hair off the cursor whenever another view constrains it, and + letting that fight the mouse feels like resistance; the dragged joint + follows the mouse blindly until release, when the model-consistent + position is shown on the next refresh. + """ # A writable copy: projected 3D points arrive as a (read-only) JAX buffer, # and a drag writes the dragged joint straight into this array. - self._pts = np.array(pts2d, dtype=float) + new = np.array(pts2d, dtype=float) + if ( + self._dragging is not None + and self._pts is not None + and self._dragging < len(new) + ): + new[self._dragging] = self._pts[self._dragging] + self._pts = new for i, item in enumerate(self._point_items): self._place_point(i) for j, (a, b) in enumerate(self._bones): @@ -136,6 +154,13 @@ def set_points(self, pts2d: np.ndarray) -> None: def set_editable(self, editable: bool) -> None: self._editable = editable + def set_fixed(self, fixed: np.ndarray | None) -> None: + """Mark which points are "fixed"/finalized (``(P,)`` bool), drawn with a + bold ring; ``None`` clears all fixed marks.""" + self._fixed = None if fixed is None else np.asarray(fixed, dtype=bool) + for i in range(len(self._point_items)): + self._place_point(i) + def set_highlight(self, point: int | None) -> None: """Visually emphasize ``point`` (the active joint), or clear with ``None``.""" self._highlight = point @@ -152,11 +177,18 @@ def _place_point(self, i: int) -> None: x, y = self._pts[i] radius = _POINT_RADIUS * (2.0 if i == self._highlight else 1.0) item.setRect(x - radius, y - radius, 2 * radius, 2 * radius) - item.setPen( - QPen(Qt.GlobalColor.white, 1.5) - if i == self._highlight - else QPen(Qt.GlobalColor.black, 0.5) + fixed = ( + self._fixed is not None + and i < len(self._fixed) + and bool(self._fixed[i]) ) + if fixed: + # A bold yellow ring marks a finalized (locked) per-view point. + item.setPen(QPen(Qt.GlobalColor.yellow, 2.5)) + elif i == self._highlight: + item.setPen(QPen(Qt.GlobalColor.white, 1.5)) + else: + item.setPen(QPen(Qt.GlobalColor.black, 0.5)) item.setVisible(True) def _fit(self) -> None: @@ -192,6 +224,13 @@ def mousePressEvent(self, event) -> None: # noqa: N802 -- Qt override self._dragging = point event.accept() return + if self._editable and event.button() == Qt.MouseButton.RightButton: + scene_pos = self.mapToScene(event.position().toPoint()) + point = self._nearest_point(scene_pos) + if point is not None: + self.pointFixToggled.emit(self._view_index, point) + event.accept() + return super().mousePressEvent(event) def mouseMoveEvent(self, event) -> None: # noqa: N802 -- Qt override diff --git a/src/deeperfly/gui/window.py b/src/deeperfly/gui/window.py index d877e08..f27f5f4 100644 --- a/src/deeperfly/gui/window.py +++ b/src/deeperfly/gui/window.py @@ -4,8 +4,11 @@ to the widgets: it lays out one :class:`~deeperfly.gui.view.PoseView` per camera, scrubs frames, switches between View / Edit 2D / Edit 3D, and routes a drag to the right edit. In Edit 2D a drag moves only that view's 2D point; in Edit 3D a -drag re-solves the 3D point and refreshes every view's reprojection. Save writes -the corrections sidecar; ``results.h5`` is never modified. +drag re-solves the 3D point and refreshes every view's reprojection live, and on +release pins the dragged view at the drop pixel (a finalized constraint that the +re-solve holds while the other views follow). A right-click toggles that "fixed" +flag directly (e.g. to un-pin a view). Save writes the corrections sidecar; +``results.h5`` is never modified. """ from __future__ import annotations @@ -90,6 +93,7 @@ def _build_ui(self) -> None: view.set_skeleton(skeleton.bones, colors) view.pointDragged.connect(self._on_point_dragged) view.pointDragging.connect(self._on_point_dragging) + view.pointFixToggled.connect(self._on_point_fix_toggled) grid.addWidget(self._labelled(name, view), v // cols, v % cols) self._views.append(view) @@ -168,18 +172,30 @@ def _refresh_frame(self) -> None: view.set_image(img) view.set_points(pts[v]) view.set_editable(editable) + self._refresh_fixed_marks() def _refresh_points(self) -> None: """Repaint just the overlays (after an edit) without reloading images.""" pts = self._points_for_mode() for v, view in enumerate(self._views): view.set_points(pts[v]) + self._refresh_fixed_marks() + + def _refresh_fixed_marks(self) -> None: + """Show the "fixed" rings on each view, but only in Edit 3D.""" + fixed = ( + self._state.corrections.pts2d_fixed[:, self._state.frame] + if self._mode == EditMode.edit_3d + else None + ) + for v, view in enumerate(self._views): + view.set_fixed(None if fixed is None else fixed[v]) def _points_for_mode(self): if self._mode == EditMode.edit_3d: - projected = self._state.display_pts3d_projected() - if projected is not None: - return projected + refined = self._state.display_pts2d_refine() + if refined is not None: + return refined return self._state.display_pts2d() # -- signal handlers ------------------------------------------------------ @@ -221,11 +237,26 @@ def _on_point_dragged(self, view: int, point: int, x: float, y: float) -> None: if self._mode == EditMode.edit_2d: self._state.apply_2d_edit(view, point, (x, y)) elif self._mode == EditMode.edit_3d: - self._state.apply_3d_edit(view, point, (x, y)) + # Dropping pins the dragged view at the release pixel so it stays put + # (does not snap to the constrained reprojection) and constrains the + # 3D solve; the live mid-drag re-solve above does not pin. + self._state.apply_3d_edit(view, point, (x, y), fix=True) # Repaint from state: a no-op 3D edit (NaN point) snaps the marker back. self._refresh_points() self._update_title() + def _on_point_fix_toggled(self, view: int, point: int) -> None: + """Right-click in Edit 3D: finalize/unfinalize this view's point. + + Toggling the fixed flag re-solves the 3D point from the fixed views, so + the non-fixed views' reprojections move; the ring updates via the refresh. + """ + if self._mode != EditMode.edit_3d: + return + self._state.toggle_fixed(view, point) + self._refresh_points() + self._update_title() + def _on_reset(self) -> None: point = self._joint_combo.currentData() if point is None or point < 0: diff --git a/tests/test_gui.py b/tests/test_gui.py index 120dbb0..aa79d79 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -86,6 +86,132 @@ def test_reset_point_clears_corrections(result): assert not state.corrections.pts3d_edited[0, 4] +# -- EditorState: 3D refinement (fixed/finalized constraints) ----------------- + + +def test_3d_edit_with_nothing_fixed_lands_on_cursor(result): + # With no fixed views the constrained re-solve must reduce to the original + # single-ray behavior: the dragged view lands exactly under the cursor. + state = EditorState.from_result(result) + view, point, frame = 2, 5, 0 + drag = state.display_pts2d_refine(frame)[view, point] + np.array([10.0, -8.0]) + assert state.apply_3d_edit(view, point, drag, frame) is not None + assert np.allclose(state.display_pts2d_refine(frame)[view, point], drag, atol=1e-4) + + +def test_toggle_fixed_sets_then_clears_mask_and_pixel(result): + state = EditorState.from_result(result) + view, point, frame = 1, 5, 0 + pix = state.display_pts2d_refine(frame)[view, point].copy() + + assert state.toggle_fixed(view, point, frame) is True + assert state.corrections.pts2d_fixed[view, frame, point] + assert np.allclose(state.corrections.pts2d[view, frame, point], pix) + assert np.allclose(state.display_pts2d_refine(frame)[view, point], pix) + + assert state.toggle_fixed(view, point, frame) is False + assert not state.corrections.pts2d_fixed[view, frame, point] + assert not state.corrections.pts2d_edited[view, frame, point] + + +def test_fixed_view_pixel_is_held_when_another_view_is_dragged(result): + state = EditorState.from_result(result) + point, frame = 5, 0 + fixed_view, drag_view = 0, 2 + held = state.display_pts2d_refine(frame)[fixed_view, point].copy() + state.toggle_fixed(fixed_view, point, frame) + + drag = state.display_pts2d_refine(frame)[drag_view, point] + np.array([12.0, -9.0]) + state.apply_3d_edit(drag_view, point, drag, frame) + + # the finalized view never moves, even though the 3D point was re-solved + assert np.allclose(state.display_pts2d_refine(frame)[fixed_view, point], held) + + +def test_fixing_a_view_changes_how_others_follow(result): + point, frame = 5, 0 + fixed_view, drag_view, other = 0, 2, 4 + offset = np.array([14.0, -11.0]) + + free = EditorState.from_result(result) + drag = free.display_pts2d_refine(frame)[drag_view, point] + offset + free.apply_3d_edit(drag_view, point, drag, frame) + free_other = free.display_pts2d_refine(frame)[other, point].copy() + + constrained = EditorState.from_result(result) + constrained.toggle_fixed(fixed_view, point, frame) + drag = constrained.display_pts2d_refine(frame)[drag_view, point] + offset + constrained.apply_3d_edit(drag_view, point, drag, frame) + constrained_other = constrained.display_pts2d_refine(frame)[other, point] + + # the same drag in view 2 moves view 4 differently depending on view 0's fix + assert not np.allclose(free_other, constrained_other) + + +def test_dlt_resolve_from_two_fixed_views_recovers_point(result): + # The synthetic 2D is the exact projection of the 3D, so fixing two views at + # their (consistent) pixels must re-triangulate back to the true 3D point. + state = EditorState.from_result(result) + point, frame = 5, 0 + a, b = 0, 3 + state.toggle_fixed(a, point, frame) + state.toggle_fixed(b, point, frame) + + assert np.allclose( + state.display_pts3d(frame)[point], result.pts3d[frame, point], atol=1e-6 + ) + proj = state.display_pts2d_refine(frame) + assert np.allclose(proj[a, point], result.pts2d[a, frame, point], atol=1e-4) + assert np.allclose(proj[b, point], result.pts2d[b, frame, point], atol=1e-4) + + +def test_dragging_a_fixed_view_keeps_it_under_cursor(result): + state = EditorState.from_result(result) + view, point, frame = 0, 5, 0 + state.toggle_fixed(view, point, frame) + state.toggle_fixed(3, point, frame) # a second fixed view, so the DLT path runs + + drag = state.display_pts2d_refine(frame)[view, point] + np.array([8.0, 6.0]) + state.apply_3d_edit(view, point, drag, frame) + # a fixed view's own drag is the "does not satisfy the model" gesture: it stays put + assert np.allclose(state.display_pts2d_refine(frame)[view, point], drag) + + +def test_3d_drag_release_pins_the_dragged_view(result): + # "Drag pins the view": a release (fix=True) finalizes the dragged view at the + # drop pixel, so it stays there (no snap to the reprojection) and becomes a + # constraint -- even when another fixed view would otherwise pull it off. + state = EditorState.from_result(result) + view, point, frame = 2, 5, 0 + state.toggle_fixed(0, point, frame) # a constraint that would cause a snap + drag = state.display_pts2d_refine(frame)[view, point] + np.array([12.0, -9.0]) + + state.apply_3d_edit(view, point, drag, frame, fix=True) + + assert state.corrections.pts2d_fixed[view, frame, point] + assert np.allclose(state.corrections.pts2d[view, frame, point], drag) + # displayed exactly at the drop pixel -- it does not snap to project(X) + assert np.allclose(state.display_pts2d_refine(frame)[view, point], drag, atol=1e-9) + + +def test_live_3d_drag_does_not_pin(result): + # The live re-solve (fix defaults to False) must NOT finalize the view; only + # the release does, so mid-drag updates never leave a stray pin behind. + state = EditorState.from_result(result) + view, point, frame = 2, 5, 0 + drag = state.display_pts2d_refine(frame)[view, point] + np.array([5.0, 5.0]) + state.apply_3d_edit(view, point, drag, frame) + assert not state.corrections.pts2d_fixed[view, frame, point] + + +def test_reset_point_clears_fixed(result): + state = EditorState.from_result(result) + state.toggle_fixed(1, 4, frame=0) + assert state.corrections.pts2d_fixed[1, 0, 4] + state.reset_point(4, frame=0) + assert not state.corrections.pts2d_fixed[:, 0, 4].any() + + # -- corrections sidecar ------------------------------------------------------ @@ -112,6 +238,39 @@ def test_corrections_roundtrip(tmp_path, result): ) +def test_corrections_roundtrip_preserves_fixed(tmp_path, result): + state = EditorState.from_result(result) + state.toggle_fixed(0, 5, frame=0) + state.toggle_fixed(2, 5, frame=0) + + path = tmp_path / "corrections.h5" + save_corrections(path, state.corrections) + loaded = load_corrections( + path, result.n_views, result.n_frames, result.pts2d.shape[2] + ) + assert loaded is not None + np.testing.assert_array_equal(loaded.pts2d_fixed, state.corrections.pts2d_fixed) + + +def test_load_corrections_v1_without_fixed_defaults_to_false(tmp_path, result): + import h5py + + state = EditorState.from_result(result) + state.apply_2d_edit(0, 1, (5.0, 6.0), frame=0) + path = tmp_path / "corrections.h5" + save_corrections(path, state.corrections) + # simulate a v1 sidecar written before the "fixed" dataset existed + with h5py.File(path, "a") as f: + del f["pose2d_corrections/fixed"] + + loaded = load_corrections( + path, result.n_views, result.n_frames, result.pts2d.shape[2] + ) + assert loaded is not None + assert loaded.pts2d_fixed.shape == state.corrections.pts2d_edited.shape + assert not loaded.pts2d_fixed.any() + + def test_load_corrections_missing_returns_none(tmp_path): assert load_corrections(tmp_path / "absent.h5", 1, 1, 1) is None @@ -167,6 +326,7 @@ def test_corrections_empty_shapes(result): assert corr.pts2d.shape == (*result.pts2d.shape[:2], result.pts2d.shape[2], 2) assert np.isnan(corr.pts2d).all() assert not corr.pts2d_edited.any() + assert not corr.pts2d_fixed.any() assert not corr.any_edits @@ -273,6 +433,112 @@ def test_3d_drag_live_updates_every_view(qapp, result, tmp_path): window.close() +def test_window_right_click_fixes_point_in_edit_3d(qapp, result, tmp_path): + from deeperfly.gui.window import MainWindow + + source = _blank_source(result) + state = EditorState.from_result(result) + window = MainWindow( + state, + source, + results_path=str(tmp_path / "results.h5"), + corrections_path=tmp_path / "corrections.h5", + ) + window._mode_combo.setCurrentIndex(window._mode_combo.findData(EditMode.edit_3d)) + + point = 5 + window._views[1].pointFixToggled.emit(1, point) + assert state.corrections.pts2d_fixed[1, state.frame, point] + # the ring shows on that view + assert window._views[1]._fixed is not None and window._views[1]._fixed[point] + # other views are not marked + assert not window._views[0]._fixed[point] + + # a right-click outside Edit 3D is ignored + window._mode_combo.setCurrentIndex(window._mode_combo.findData(EditMode.edit_2d)) + window._views[2].pointFixToggled.emit(2, point) + assert not state.corrections.pts2d_fixed[2, state.frame, point] + + state.corrections.dirty = False + window.close() + + +def test_dragged_joint_follows_cursor_despite_fixed_constraint(qapp, result, tmp_path): + # Regression: while dragging, the dragged joint must track the mouse exactly. + # With another view fixed, the constrained 3D re-solve reprojects the dragged + # joint a hair off the cursor; that live update must not overwrite the + # cursor-pinned joint mid-drag (it would feel like resistance). + from deeperfly.gui.window import MainWindow + + source = _blank_source(result) + state = EditorState.from_result(result) + window = MainWindow( + state, + source, + results_path=str(tmp_path / "results.h5"), + corrections_path=tmp_path / "corrections.h5", + ) + window._mode_combo.setCurrentIndex(window._mode_combo.findData(EditMode.edit_3d)) + + point = 5 + # Fix view 1 so view 0's drag is genuinely constrained (2-observation DLT). + window._views[1].pointFixToggled.emit(1, point) + + view0 = window._views[0] + target = np.array(view0._pts[point]) + np.array([12.0, -9.0]) + # Reproduce the state a real mouseMoveEvent sets up before emitting. + view0._dragging = point + view0._pts[point] = target + view0.pointDragging.emit(0, point, float(target[0]), float(target[1])) + + # The dragged joint stays exactly under the cursor (no resistance) even though + # the constrained reprojection for view 0 lands elsewhere. + assert np.allclose(view0._pts[point], target, atol=1e-6) + proj = state.display_pts3d_projected()[0, point] + assert not np.allclose(proj, target, atol=1e-3) # the model disagrees, as expected + + view0._dragging = None + state.corrections.dirty = False + window.close() + + +def test_3d_drag_release_pins_view_in_window(qapp, result, tmp_path): + # End-to-end: a real press/drag/release in Edit 3D pins the dropped view at + # the cursor (fixed flag + ring) so it does not snap, even with another view + # already fixed to constrain the solve. + from deeperfly.gui.window import MainWindow + + source = _blank_source(result) + state = EditorState.from_result(result) + window = MainWindow( + state, + source, + results_path=str(tmp_path / "results.h5"), + corrections_path=tmp_path / "corrections.h5", + ) + window._mode_combo.setCurrentIndex(window._mode_combo.findData(EditMode.edit_3d)) + + point = 5 + window._views[0].pointFixToggled.emit(0, point) # a constraining fixed view + + view2 = window._views[2] + target = np.array(view2._pts[point]) + np.array([13.0, -10.0]) + # Reproduce the events a real mouse drag emits: live moves, then a release + # (mouseReleaseEvent clears _dragging before emitting pointDragged). + view2._dragging = point + view2._pts[point] = target + view2.pointDragging.emit(2, point, float(target[0]), float(target[1])) + view2._dragging = None + view2.pointDragged.emit(2, point, float(target[0]), float(target[1])) + + assert state.corrections.pts2d_fixed[2, state.frame, point] # pinned + assert view2._fixed is not None and view2._fixed[point] # ring shows + assert np.allclose(view2._pts[point], target, atol=1e-4) # no snap + + state.corrections.dirty = False + window.close() + + def test_window_save_writes_sidecar(qapp, result, tmp_path): from deeperfly.gui.window import MainWindow From 7b5ec393adfbbefd24791f5d23b8168c0e5dfb83 Mon Sep 17 00:00:00 2001 From: tlam Date: Sat, 13 Jun 2026 11:11:27 +0200 Subject: [PATCH 2/3] feat: replace the Qt correction GUI with a no-build web editor `deeperfly gui` now starts a local FastAPI + uvicorn server and opens a browser editor instead of a PySide6 desktop window. The front-end is plain ES modules served straight from deeperfly/gui/web (no bundler, no build step); the server exposes a small REST + WebSocket API around a Qt-free Session that wraps the existing EditorState corrections logic. This runs headless and can be reached from another machine (default-bound to localhost; tunnel with `ssh -L` for remote use). - FastAPI + uvicorn move from the optional `gui` extra into the core deps (tiny next to torch/jax, and `gui` is a first-class subcommand); the test group swaps PySide6 for httpx to drive the API/WebSocket in-process. `doctor` now checks for FastAPI + uvicorn. - New `gui` options: --host, --port, --no-browser, --keep-alive. Binding a non-loopback host warns (the editor is unauthenticated). By default the server stops a few seconds after the last tab closes; a refresh reconnects. - Edit 3D is now the default mode when a result has 3D, falling back to Edit 2D otherwise. - Removes the old view.py/window.py Qt widgets and their tests; adds test_gui_server.py covering the API, WebSocket edits, and shutdown behavior. Co-Authored-By: Claude Opus 4.8 --- pyproject.toml | 19 +- src/deeperfly/cli/app.py | 50 +- src/deeperfly/cli/gui.py | 39 +- src/deeperfly/cli/report.py | 11 +- src/deeperfly/gui/__init__.py | 209 +++++-- src/deeperfly/gui/server.py | 339 +++++++++++ src/deeperfly/gui/session.py | 71 +++ src/deeperfly/gui/state.py | 12 + src/deeperfly/gui/view.py | 263 --------- src/deeperfly/gui/web/README.md | 33 ++ src/deeperfly/gui/web/index.html | 86 +++ src/deeperfly/gui/web/static/api.js | 78 +++ src/deeperfly/gui/web/static/app.js | 720 +++++++++++++++++++++++ src/deeperfly/gui/web/static/poseView.js | 578 ++++++++++++++++++ src/deeperfly/gui/web/static/scene3d.js | 315 ++++++++++ src/deeperfly/gui/web/static/styles.css | 347 +++++++++++ src/deeperfly/gui/web/static/types.js | 81 +++ src/deeperfly/gui/window.py | 298 ---------- tests/test_gui.py | 250 +------- tests/test_gui_server.py | 305 ++++++++++ uv.lock | 459 ++++++++++++--- 21 files changed, 3611 insertions(+), 952 deletions(-) create mode 100644 src/deeperfly/gui/server.py create mode 100644 src/deeperfly/gui/session.py delete mode 100644 src/deeperfly/gui/view.py create mode 100644 src/deeperfly/gui/web/README.md create mode 100644 src/deeperfly/gui/web/index.html create mode 100644 src/deeperfly/gui/web/static/api.js create mode 100644 src/deeperfly/gui/web/static/app.js create mode 100644 src/deeperfly/gui/web/static/poseView.js create mode 100644 src/deeperfly/gui/web/static/scene3d.js create mode 100644 src/deeperfly/gui/web/static/styles.css create mode 100644 src/deeperfly/gui/web/static/types.js delete mode 100644 src/deeperfly/gui/window.py create mode 100644 tests/test_gui_server.py diff --git a/pyproject.toml b/pyproject.toml index 131ab34..05dff6c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,14 +48,16 @@ dependencies = [ "av>=13", "opencv-python-headless>=4.13", "natsort>=8.4.0", + # The interactive web viewer/corrector (`deeperfly gui`): FastAPI + uvicorn + # serve a browser front-end (the compiled assets ship in the wheel under + # deeperfly/gui/web). Kept in the core deps -- they're tiny next to torch/jax + # and `deeperfly gui` is a first-class subcommand, so a plain install must be + # able to run it. The `[standard]` uvicorn extra pulls the WebSocket + + # http-tools speedups; both are still imported lazily (only `gui` needs them). + "fastapi>=0.115", + "uvicorn[standard]>=0.30", ] -[project.optional-dependencies] -# The interactive viewer/corrector (`deeperfly gui`). Optional: the core package -# and CLI import fine without it. PySide6 is the official Qt for Python binding -# (LGPLv3). Install with `pip install deeperfly[gui]` / `uv sync --extra gui`. -gui = ["PySide6>=6.6"] - [project.urls] Homepage = "https://github.com/NeLy-EPFL/deeperfly" Documentation = "https://nely-epfl.github.io/deeperfly/" @@ -82,7 +84,10 @@ dev = [ test = [ "pytest>=9.0.3", "pytest-cov>=6.0", - "PySide6>=6.6", # exercise the optional GUI tests headlessly (QT_QPA_PLATFORM=offscreen) + # Exercise the web GUI: FastAPI's TestClient (httpx-backed) drives the API + + # WebSocket in-process, no real server or browser needed. FastAPI/uvicorn + # themselves are core deps; only the httpx test client is test-only. + "httpx>=0.27", ] docs = [ # The documentation site (MkDocs + Material). The library API reference is diff --git a/src/deeperfly/cli/app.py b/src/deeperfly/cli/app.py index da39c06..551ba61 100644 --- a/src/deeperfly/cli/app.py +++ b/src/deeperfly/cli/app.py @@ -199,17 +199,53 @@ def gui( "results.h5 no longer resolve", ), ] = None, + host: Annotated[ + str, + typer.Option( + "--host", + help="address to bind the server to; the loopback default keeps the " + "editor private (bind a routable address only behind a trusted " + "network -- it is unauthenticated; prefer an 'ssh -L' tunnel)", + ), + ] = "127.0.0.1", + port: Annotated[ + int, + typer.Option("--port", help="TCP port to serve on (0 picks a free one)"), + ] = 8000, + no_browser: Annotated[ + bool, + typer.Option("--no-browser", help="do not open a browser on startup"), + ] = False, + keep_alive: Annotated[ + bool, + typer.Option( + "--keep-alive", + help="keep the server running after the browser is closed (by default " + "it stops a few seconds after the last tab closes; a refresh reconnects)", + ), + ] = False, log_level: LogLevelOption = LogLevel.info, ) -> None: - """Open the interactive viewer/corrector on a result (needs the 'gui' extra). - - View every camera with its 2D skeleton overlay, drag keypoints to correct - the 2D pose, or switch to 3D mode to drag a reprojected 3D point (the other - views update live). Corrections are written to a corrections.h5 sidecar and - never modify results.h5. Install the viewer with 'pip install deeperfly[gui]'. + """Serve the interactive web viewer/corrector for a result. + + Starts a local server and opens a browser editor. View every camera with its + 2D skeleton overlay, drag keypoints to correct the 2D pose, or switch to 3D + mode to drag a reprojected 3D point (the other views update live). + Corrections are written to a corrections.h5 sidecar and never modify + results.h5. It runs headless and can be reached from another machine's + browser (default-bound to localhost; tunnel with 'ssh -L' for remote use). """ _configure_logging(log_level.value) - _cmd_gui(argparse.Namespace(path=path, footage_dir=footage_dir)) + _cmd_gui( + argparse.Namespace( + path=path, + footage_dir=footage_dir, + host=host, + port=port, + no_browser=no_browser, + keep_alive=keep_alive, + ) + ) def _normalize_overwrite_argv(argv: list[str]) -> list[str]: diff --git a/src/deeperfly/cli/gui.py b/src/deeperfly/cli/gui.py index bfb28aa..060dcac 100644 --- a/src/deeperfly/cli/gui.py +++ b/src/deeperfly/cli/gui.py @@ -1,15 +1,21 @@ -"""The ``gui`` command worker: launch the interactive viewer/corrector. +"""The ``gui`` command worker: launch the interactive web viewer/corrector. -Kept thin and free of any Qt import at module load -- the heavy PySide6 import -happens inside :func:`deeperfly.gui.launch`, so ``deeperfly`` (and this module) -import fine without the optional ``gui`` extra installed. +Kept thin and free of any web import at module load -- the FastAPI/uvicorn +import happens inside :func:`deeperfly.gui.serve`, so importing ``deeperfly`` +(and this module) stays cheap for every command other than ``gui``. """ from __future__ import annotations import argparse +import logging from pathlib import Path +log = logging.getLogger("deeperfly") + +#: Bind addresses that keep the editor on the local machine (no warning). +_LOOPBACK = ("127.0.0.1", "localhost", "::1") + def _find_results(path: Path) -> Path: """Resolve ``path`` to a ``results.h5`` file. @@ -48,24 +54,39 @@ def _find_results(path: Path) -> Path: def _cmd_gui(args: argparse.Namespace) -> None: - """Launch the GUI on the result resolved from ``args.path``. + """Serve the web GUI on the result resolved from ``args.path``. Parameters ---------- args - The ``gui`` namespace (``path``, ``footage_dir``). + The ``gui`` namespace (``path``, ``footage_dir``, ``host``, ``port``, + ``no_browser``, ``keep_alive``). Raises ------ SystemExit - If no result is found, or the optional ``gui`` extra is not installed. + If no result is found, or the web stack fails to import (an incomplete + install -- FastAPI + uvicorn are core dependencies). """ results_path = _find_results(Path(args.path)) + if args.host not in _LOOPBACK: + log.warning( + "binding %s exposes the editor on the network without authentication; " + "prefer the default localhost and an `ssh -L` tunnel for remote use", + args.host, + ) try: - from ..gui import launch + from ..gui import serve except ImportError as exc: # pragma: no cover -- exercised manually raise SystemExit(str(exc)) from exc try: - launch(results_path, footage_dir=args.footage_dir) + serve( + results_path, + footage_dir=args.footage_dir, + host=args.host, + port=args.port, + open_browser=not args.no_browser, + exit_on_close=not args.keep_alive, + ) except ImportError as exc: raise SystemExit(str(exc)) from exc diff --git a/src/deeperfly/cli/report.py b/src/deeperfly/cli/report.py index 78db7f8..a98b23c 100644 --- a/src/deeperfly/cli/report.py +++ b/src/deeperfly/cli/report.py @@ -224,12 +224,15 @@ def _cmd_doctor(args: argparse.Namespace) -> None: _doctor_row("image read", "opencv" if have_cv2 else "opencv not installed") _doctor_header("gui") - have_qt = importlib.util.find_spec("PySide6") is not None + have_web = ( + importlib.util.find_spec("fastapi") is not None + and importlib.util.find_spec("uvicorn") is not None + ) _doctor_row( "deeperfly gui", - "PySide6 available" - if have_qt - else "not installed -- run 'pip install deeperfly[gui]'", + "FastAPI + uvicorn available" + if have_web + else "missing -- core deps absent, reinstall deeperfly", ) _doctor_header("weights") diff --git a/src/deeperfly/gui/__init__.py b/src/deeperfly/gui/__init__.py index 422328f..fff1428 100644 --- a/src/deeperfly/gui/__init__.py +++ b/src/deeperfly/gui/__init__.py @@ -1,29 +1,36 @@ -"""Optional interactive viewer/corrector for deeperfly results (``deeperfly gui``). - -This package is the optional GUI extra (``pip install deeperfly[gui]``). Only the -Qt-free core is imported here -- :class:`~deeperfly.gui.state.EditorState`, the -corrections sidecar, and footage resolution -- so importing -:mod:`deeperfly.gui` (and the rest of the package and CLI) never requires -PySide6. :func:`launch` imports the Qt widgets lazily and raises a friendly hint -if the extra is not installed. - -The GUI shows every camera view with its 2D skeleton overlay and lets keypoints -be dragged. Corrections are written to a ``corrections.h5`` sidecar and never +"""Web viewer/corrector for deeperfly results (``deeperfly gui``). + +FastAPI + uvicorn are core deps, but importing :mod:`deeperfly.gui` only pulls in +the dependency-free core -- :class:`~deeperfly.gui.state.EditorState`, the +corrections sidecar, footage resolution and the +:class:`~deeperfly.gui.session.Session`. :func:`serve` imports FastAPI/uvicorn +lazily so the web stack is loaded only when the ``gui`` command actually runs, +keeping startup cheap for every other command. + +The GUI is a browser app: a local server (FastAPI) serves the result's frames +and 2D overlays to a canvas front-end and applies edits over a WebSocket. It +shows every camera view with its 2D skeleton overlay and lets keypoints be +dragged. Corrections are written to a ``corrections.h5`` sidecar and never overwrite ``results.h5``. In *Edit 3D* mode the triangulated points are -reprojected into each view; dragging one re-solves the 3D point (the point on -the dragged pixel's back-projection ray closest to its old location) and every -other view's reprojection updates. +reprojected into each view; dragging one re-solves the 3D point and every other +view's reprojection updates live. Because it is a web app it runs headless and +can be reached from another machine's browser (default-bound to localhost; tunnel +with ``ssh -L`` for remote correction). """ from __future__ import annotations import logging -import sys +import socket +import threading +import time +import webbrowser from pathlib import Path from ..results import PoseResult, StageStore from .corrections import Corrections, load_corrections, save_corrections from .readers import FrameSource, resolve_camera_files, resolve_footage +from .session import Session from .state import EditMode, EditorState __all__ = [ @@ -35,44 +42,45 @@ "FrameSource", "resolve_footage", "resolve_camera_files", - "launch", + "Session", + "build_session", + "serve", ] log = logging.getLogger("deeperfly") _GUI_IMPORT_HINT = ( - "the deeperfly GUI needs the optional 'gui' extra (PySide6); install it with " - "`pip install deeperfly[gui]` (or `uv sync --extra gui`)" + "the deeperfly web GUI failed to import its server stack (FastAPI + uvicorn); " + "these are core dependencies, so the install looks incomplete -- try " + "reinstalling deeperfly (`pip install --force-reinstall deeperfly`)" ) -def launch(results_path: str | Path, footage_dir: str | Path | None = None) -> None: - """Open the viewer/corrector on a ``results.h5`` file. +def build_session( + results_path: str | Path, footage_dir: str | Path | None = None +) -> Session: + """Load a ``results.h5`` into an editing :class:`Session` (no web deps). Loads the result, resolves each camera's footage (from the paths recorded in - ``results.h5``, then ``footage_dir`` / a dialog if needed), loads any - existing ``corrections.h5`` sidecar, and runs the Qt event loop. + ``results.h5``, then ``footage_dir``), loads any existing ``corrections.h5`` + sidecar, and assembles the :class:`Session`. Cameras whose footage cannot be + found fall back to blank frames (logged), so the overlays still draw. Parameters ---------- results_path Path to a ``results.h5`` file. footage_dir - Optional directory to search for the footage when the recorded paths - no longer resolve. - - Raises - ------ - ImportError - If PySide6 (the ``gui`` extra) is not installed. + Optional directory to search for the footage when the recorded paths no + longer resolve. + + Returns + ------- + Session + The assembled session, ready to hand to :func:`serve` / + :func:`~deeperfly.gui.server.create_app`. """ results_path = Path(results_path) - try: - from PySide6.QtWidgets import QApplication, QFileDialog, QMessageBox - except ImportError as exc: - raise ImportError(_GUI_IMPORT_HINT) from exc - from .window import MainWindow - result = PoseResult.load(results_path) store = StageStore(results_path) footage = store.read_footage() @@ -81,43 +89,126 @@ def launch(results_path: str | Path, footage_dir: str | Path | None = None) -> N results_dir = results_path.parent corrections_path = results_dir / "corrections.h5" - app = QApplication.instance() or QApplication(sys.argv) - resolved, missing = resolve_footage(footage, results_dir, footage_dir) - if missing: - chosen = QFileDialog.getExistingDirectory( - None, f"Select the footage directory for: {', '.join(missing)}" - ) - if chosen: - found, missing = resolve_footage(footage, results_dir, chosen) - resolved.update(found) if not footage: log.warning( "results.h5 records no footage paths; showing overlays on blank frames " "-- re-run 'deeperfly run' to embed them" ) if missing: - log.warning("footage not found for %s (blank frames)", ", ".join(missing)) + log.warning( + "footage not found for %s (blank frames); pass --footage-dir to point at it", + ", ".join(missing), + ) source = FrameSource(resolved, image_sizes=image_sizes) - corrections = None - try: - corrections = load_corrections( - corrections_path, result.n_views, result.n_frames, n_points - ) - except ValueError as exc: - QMessageBox.warning( - None, "Corrections", f"Ignoring existing corrections: {exc}" - ) + corrections = load_corrections( + corrections_path, result.n_views, result.n_frames, n_points + ) state = EditorState.from_result(result, corrections) - - window = MainWindow( + return Session.build( state, source, results_path=str(results_path), corrections_path=corrections_path, + image_sizes=image_sizes, ) - window.resize(1200, 800) - window.show() - app.exec() + + +def serve( + results_path: str | Path, + footage_dir: str | Path | None = None, + *, + host: str = "127.0.0.1", + port: int = 8000, + open_browser: bool = True, + exit_on_close: bool = True, +) -> None: + """Open the viewer/corrector on a ``results.h5`` and run the web server. + + Builds the session (:func:`build_session`), starts the FastAPI app under + uvicorn, and (unless ``open_browser`` is false) opens a browser at the URL + once the server is accepting connections. Blocks until the server stops. + + Parameters + ---------- + results_path + Path to a ``results.h5`` file. + footage_dir + Optional directory to search for the footage if the recorded paths no + longer resolve. + host + Address to bind. The loopback default keeps the editor private; bind a + routable address (e.g. ``0.0.0.0``) only behind a trusted network -- it + is unauthenticated. Prefer an ``ssh -L`` tunnel for remote correction. + port + TCP port to bind; ``0`` picks a free one. + open_browser + Whether to open a browser at the served URL on startup. + exit_on_close + Stop the server once the last browser tab closes (a few seconds after its + socket drops, so a refresh can reconnect first). Set false to keep it + running across tab closes (reconnect later or stop with the Close button / + Ctrl+C). + + Raises + ------ + ImportError + If the web stack (FastAPI + uvicorn) cannot be imported -- these are + core dependencies, so this signals an incomplete install. + """ + session = build_session(results_path, footage_dir) + try: + import uvicorn + + from .server import create_app + except ImportError as exc: + raise ImportError(_GUI_IMPORT_HINT) from exc + + if port == 0: + port = _free_port(host) + + # The GUI's Close button POSTs /api/shutdown, which calls this to stop the + # server: flipping should_exit lets uvicorn finish the in-flight reply, then + # its run loop returns and serve() unblocks (the same as a Ctrl+C). The name + # `server` is bound below, before any request can trigger this. + def request_shutdown() -> None: + server.should_exit = True + + app = create_app( + session, on_shutdown=request_shutdown, exit_on_disconnect=exit_on_close + ) + + display_host = "localhost" if host in ("0.0.0.0", "127.0.0.1", "::", "::1") else host + url = f"http://{display_host}:{port}/" + log.info("deeperfly gui serving %s at %s", session.results_path, url) + if open_browser: + connect_host = "127.0.0.1" if host in ("0.0.0.0", "::") else display_host + threading.Thread( + target=_open_when_ready, args=(url, connect_host, port), daemon=True + ).start() + + config = uvicorn.Config(app, host=host, port=port, log_level="warning") + server = uvicorn.Server(config) + server.run() + + +def _free_port(host: str) -> int: + """Pick a free TCP port on ``host`` (bind to 0, read it back, release).""" + family = socket.AF_INET6 if ":" in host else socket.AF_INET + with socket.socket(family, socket.SOCK_STREAM) as s: + s.bind((host, 0)) + return int(s.getsockname()[1]) + + +def _open_when_ready(url: str, host: str, port: int, *, timeout: float = 15.0) -> None: + """Open ``url`` in a browser once the server is accepting connections.""" + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + try: + with socket.create_connection((host, port), timeout=0.25): + break + except OSError: + time.sleep(0.1) + webbrowser.open(url) diff --git a/src/deeperfly/gui/server.py b/src/deeperfly/gui/server.py new file mode 100644 index 0000000..64787df --- /dev/null +++ b/src/deeperfly/gui/server.py @@ -0,0 +1,339 @@ +"""The FastAPI app: serves frames + 2D overlays and applies edits over a socket. + +:func:`create_app` wraps a :class:`~deeperfly.gui.session.Session` (the Qt-free +editor model) in HTTP + WebSocket handlers. The browser front-end (``web/``) +fetches metadata and per-frame overlays as JSON, pulls each camera's frame as a +JPEG, and streams edits over ``/ws`` -- every edit maps one-to-one onto an +:class:`~deeperfly.gui.state.EditorState` method and replies with the refreshed +per-view points so the canvases repaint (the same flow the old Qt window drove +with signals). Corrections live only in memory until ``POST /api/save`` writes +the ``corrections.h5`` sidecar. + +All state mutations are serialized by a single :class:`asyncio.Lock`: one +operator on one result is the expected case, and the edit ops are fast, +in-process NumPy/JAX, so holding the lock briefly is harmless. +""" + +from __future__ import annotations + +import asyncio +import logging +from collections.abc import Callable +from pathlib import Path + +import cv2 +import numpy as np +from fastapi import ( + FastAPI, + HTTPException, + Request, + Response, + WebSocket, + WebSocketDisconnect, +) +from fastapi.responses import FileResponse +from fastapi.staticfiles import StaticFiles + +from ..visualization._palette import point_colors_rgb +from .corrections import save_corrections +from .session import Session + +__all__ = ["create_app"] + +log = logging.getLogger("deeperfly") + +_WEB_DIR = Path(__file__).parent / "web" + + +def create_app( + session: Session, + *, + on_shutdown: Callable[[], None] | None = None, + exit_on_disconnect: bool = False, + disconnect_grace: float = 5.0, +) -> FastAPI: + """Build the FastAPI app serving and editing ``session``. + + ``on_shutdown``, when given, is invoked to stop the running server -- by + ``POST /api/shutdown`` (the GUI's Close button) and, when ``exit_on_disconnect`` + is set, ``disconnect_grace`` seconds after the last browser drops its ``/ws`` + socket (closing the tab). The grace period lets a page refresh -- which also + drops the socket -- reconnect and cancel the pending shutdown. The browser + holds exactly one socket open for its whole lifetime, so the live socket count + tracks open tabs. :func:`deeperfly.gui.serve` passes a callback that flips + uvicorn's ``should_exit``; tests pass a plain stub. + """ + app = FastAPI(title="deeperfly gui") + lock = asyncio.Lock() + # Open `/ws` sockets (one per browser tab) and the timer that, once the last + # one closes, stops the server after the grace period (cancelled on reconnect). + clients = 0 + pending_exit: asyncio.TimerHandle | None = None + + # The web assets are edited in place (no build step), so without an explicit + # policy a browser's heuristic cache can serve a stale app.js/styles.css + # against freshly changed HTML -- a half-broken editor. Force revalidation on + # every load of the page and its assets; the ETag keeps it cheap (a 304 when + # nothing changed). Frame JPEGs keep their own long max-age (set per-response). + @app.middleware("http") + async def _revalidate_assets(request: Request, call_next): + response = await call_next(request) + path = request.url.path + if path == "/" or path.startswith("/static/"): + response.headers["Cache-Control"] = "no-cache" + return response + + static_dir = _WEB_DIR / "static" + if static_dir.is_dir(): + app.mount("/static", StaticFiles(directory=static_dir), name="static") + else: # pragma: no cover -- the assets ship in the package, so this is unexpected + log.warning("web assets missing at %s (expected alongside server.py)", static_dir) + + @app.get("/") + def index() -> Response: + page = _WEB_DIR / "index.html" + if not page.is_file(): # pragma: no cover + raise HTTPException(500, "web/index.html is missing (build the GUI)") + return FileResponse(page) + + @app.get("/api/meta") + def meta() -> dict: + return _meta_payload(session) + + @app.get("/api/frame/{camera}/{t}") + def frame(camera: str, t: int) -> Response: + img = session.source.frame(camera, t) + if img is None: + raise HTTPException(404, f"no frame for {camera!r} at {t}") + ok, buf = cv2.imencode(".jpg", _to_bgr(img)) + if not ok: # pragma: no cover -- encoder failure is not expected + raise HTTPException(500, "frame encoding failed") + return Response( + content=buf.tobytes(), + media_type="image/jpeg", + headers={"Cache-Control": "max-age=3600"}, + ) + + @app.get("/api/points/{t}") + def points(t: int, mode: str = "view") -> dict: + return _points_payload(session, _clamp_frame(session, t), mode) + + @app.get("/api/scene/{t}") + def scene(t: int) -> dict: + return _scene_payload(session, _clamp_frame(session, t)) + + @app.post("/api/save") + async def save() -> dict: + async with lock: + save_corrections( + session.corrections_path, + session.state.corrections, + source=session.results_path, + ) + return {"dirty": session.state.dirty} + + @app.post("/api/shutdown") + async def shutdown() -> dict: + """Stop the server (the GUI's Close button). + + The browser saves or discards any unsaved corrections before calling this, + so the handler touches no state -- it just signals the run loop to exit. + Returns first; uvicorn finishes this reply, then shuts down on its next + tick. A no-op (still ``200``) when no shutdown hook was wired in. + """ + log.info("gui requested shutdown") + if on_shutdown is not None: + on_shutdown() + return {"ok": True} + + @app.websocket("/ws") + async def ws(websocket: WebSocket) -> None: + nonlocal clients, pending_exit + await websocket.accept() + clients += 1 + if pending_exit is not None: + # A reconnect (typically a page refresh) cancels a pending shutdown. + pending_exit.cancel() + pending_exit = None + try: + while True: + msg = await websocket.receive_json() + try: + async with lock: + payload = _handle_edit(session, msg) + except (KeyError, ValueError, TypeError) as exc: + # A malformed edit must not tear down the editing session. + log.warning("ignoring bad edit message %r: %s", msg, exc) + continue + await websocket.send_json(payload) + except WebSocketDisconnect: + pass + finally: + clients -= 1 + # The last tab closed: stop the server, but give a refresh's reconnect + # the grace period to cancel it first. + if exit_on_disconnect and clients == 0 and on_shutdown is not None: + log.info("browser disconnected; stopping in %ss", disconnect_grace) + pending_exit = asyncio.get_running_loop().call_later( + disconnect_grace, on_shutdown + ) + + return app + + +# -- payload builders --------------------------------------------------------- + + +def _meta_payload(session: Session) -> dict: + """The one-time metadata the front-end needs to lay out and draw the editor.""" + s = session.state + skel = s.result.skeleton + colors = (np.asarray(point_colors_rgb(skel)) * 255).round().astype(int) + return { + "results_path": session.results_path, + "n_views": s.n_views, + "n_frames": session.n_frames, + "n_points": s.n_points, + "has_3d": s.has_3d, + "camera_names": list(s.camera_names), + "image_sizes": { + name: [int(h), int(w)] for name, (h, w) in session.image_sizes.items() + }, + "point_names": list(skel.point_names), + "bones": np.asarray(skel.bones, dtype=int).reshape(-1, 2).tolist(), + "point_colors": colors.tolist(), + "cameras_3d": _cameras_3d(session), + "dirty": bool(s.dirty), + } + + +def _cameras_3d(session: Session) -> list[dict]: + """Each camera's world-frame pose for the on-demand 3D rig plot. + + ``position`` is the camera centre; ``right``/``up``/``forward`` are the unit + world-frame axes of the camera (the rows of the rotation matrix are +x + image-right, +y image-down, +z optical, so ``up`` is the negated middle row). + """ + cams = [] + for name, cam in zip(session.state.camera_names, session.state.result.cameras): + rmat = np.asarray(cam.rmat) + cams.append( + { + "name": name, + "position": [float(v) for v in np.asarray(cam.position)], + "right": [float(v) for v in rmat[0]], + "up": [float(-v) for v in rmat[1]], + "forward": [float(v) for v in rmat[2]], + } + ) + return cams + + +def _points_payload(session: Session, t: int, mode: str) -> dict: + """The per-view 2D overlay (and fixed mask) to draw for frame ``t`` in ``mode``. + + ``proj`` is the current 3D estimate reprojected into every view (with no fixed + overrides) -- the display-only "latent skeleton" the front-end can ghost over + every view; it is ``null`` when the result carries no 3D points. + """ + s = session.state + if mode == "edit_3d" and s.has_3d: + pts = s.display_pts2d_refine(t) + else: + pts = s.display_pts2d(t) + fixed = s.corrections.pts2d_fixed[:, t] # (V, P) + proj = s.display_pts3d_projected(t) if s.has_3d else None + return { + "frame": t, + "mode": mode, + "points": _points_to_json(np.asarray(pts)), + "fixed": fixed.tolist(), + "proj": None if proj is None else _points_to_json(np.asarray(proj)), + "dirty": bool(s.dirty), + } + + +def _scene_payload(session: Session, t: int) -> dict: + """The frame's 3D keypoints (world frame) for the rig plot, or ``null`` if 2D-only.""" + pts3d = session.state.display_pts3d(t) + return { + "frame": t, + "points3d": None if pts3d is None else _points3d_to_json(np.asarray(pts3d)), + } + + +def _points_to_json(pts: np.ndarray) -> list: + """``(V, P, 2)`` points to nested lists, with ``null`` for any NaN point.""" + finite = np.isfinite(pts).all(axis=-1) + return [ + [ + [float(pts[v, p, 0]), float(pts[v, p, 1])] if finite[v, p] else None + for p in range(pts.shape[1]) + ] + for v in range(pts.shape[0]) + ] + + +def _points3d_to_json(pts: np.ndarray) -> list: + """``(P, 3)`` world points to nested lists, with ``null`` for any NaN point.""" + finite = np.isfinite(pts).all(axis=-1) + return [ + [float(pts[p, 0]), float(pts[p, 1]), float(pts[p, 2])] if finite[p] else None + for p in range(pts.shape[0]) + ] + + +# -- edit dispatch ------------------------------------------------------------ + + +def _handle_edit(session: Session, msg: dict) -> dict: + """Apply one edit message to the state and return the refreshed points payload. + + Each ``type`` maps to an :class:`~deeperfly.gui.state.EditorState` op; the + reply is the standard points payload for the message's ``frame`` and ``mode`` + so the client repaints every view (e.g. a live 3D drag moving all views). + """ + s = session.state + t = _clamp_frame(session, int(msg.get("frame", 0))) + mode = str(msg.get("mode", "view")) + typ = msg.get("type") + if typ == "edit_2d": + s.apply_2d_edit(int(msg["view"]), int(msg["point"]), _xy(msg), t) + elif typ == "edit_3d": + s.apply_3d_edit( + int(msg["view"]), + int(msg["point"]), + _xy(msg), + t, + fix=bool(msg.get("fix", False)), + ) + elif typ == "toggle_fixed": + s.toggle_fixed(int(msg["view"]), int(msg["point"]), t) + elif typ == "reset_point": + s.reset_point(int(msg["point"]), t) + elif typ == "reset_point_view": + s.reset_point_view(int(msg["view"]), int(msg["point"]), t) + else: # pragma: no cover -- an unknown type is a client bug; ignore it + log.warning("ignoring unknown edit message type %r", typ) + return _points_payload(session, t, mode) + + +def _xy(msg: dict) -> tuple[float, float]: + return (float(msg["x"]), float(msg["y"])) + + +def _clamp_frame(session: Session, t: int) -> int: + """Keep ``t`` inside ``[0, n_frames)`` (defensive against stray indices).""" + return max(0, min(int(t), session.n_frames - 1)) + + +def _to_bgr(img: np.ndarray) -> np.ndarray: + """An ``(H, W, 3)`` RGB (or ``(H, W)`` gray) frame as the BGR cv2 expects. + + ``FrameSource`` yields RGB; ``cv2.imencode`` reads BGR, so the channels are + reversed (a contiguous copy) before encoding to keep colors correct in the + browser. Grayscale frames pass straight through. + """ + if img.ndim == 2: + return img + return np.ascontiguousarray(img[..., ::-1]) diff --git a/src/deeperfly/gui/session.py b/src/deeperfly/gui/session.py new file mode 100644 index 0000000..f64c88a --- /dev/null +++ b/src/deeperfly/gui/session.py @@ -0,0 +1,71 @@ +"""The editing session shared by the web server: state + footage + paths. + +A :class:`Session` bundles everything a request handler needs to serve and edit +one ``results.h5``: the Qt-free :class:`~deeperfly.gui.state.EditorState` (the +corrections overlay and all the edit logic), the :class:`FrameSource` that +decodes footage, the on-disk paths, and the playable frame count. It carries no +web dependency -- :mod:`deeperfly.gui.server` builds the FastAPI app *around* a +session and adds the request handlers and the mutation lock. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path + +from .readers import FrameSource +from .state import EditorState + +__all__ = ["Session"] + + +@dataclass +class Session: + """One open ``results.h5`` being viewed/corrected over the web. + + Attributes + ---------- + state + The editor model (result + corrections overlay); holds every edit op. + source + The per-camera frame decoder. + results_path + Path to the ``results.h5`` (recorded in the saved sidecar's metadata). + corrections_path + Where :func:`~deeperfly.gui.corrections.save_corrections` writes. + n_frames + The playable frame count: the result's frames clipped to what the + footage actually covers (so scrubbing never runs past the video). + image_sizes + ``camera_name -> (height, width)`` recorded by ``pose2d`` (or ``{}``), + used to size the canvases before the first frame loads. + """ + + state: EditorState + source: FrameSource + results_path: str + corrections_path: Path + n_frames: int + image_sizes: dict[str, tuple[int, int]] = field(default_factory=dict) + + @classmethod + def build( + cls, + state: EditorState, + source: FrameSource, + *, + results_path: str | Path, + corrections_path: str | Path, + image_sizes: dict[str, tuple[int, int]] | None = None, + ) -> Session: + """Assemble a session, clipping ``n_frames`` to the available footage.""" + n_source = source.n_frames() + n_frames = state.n_frames if n_source is None else min(state.n_frames, n_source) + return cls( + state=state, + source=source, + results_path=str(results_path), + corrections_path=Path(corrections_path), + n_frames=int(n_frames), + image_sizes=dict(image_sizes or {}), + ) diff --git a/src/deeperfly/gui/state.py b/src/deeperfly/gui/state.py index 032d4df..bd74a0d 100644 --- a/src/deeperfly/gui/state.py +++ b/src/deeperfly/gui/state.py @@ -286,3 +286,15 @@ def reset_point(self, point: int, frame: int | None = None) -> None: for view in range(self.n_views): self.corrections.clear_2d(view, t, point) # also clears the fixed flag self.corrections.clear_3d(t, point) + + def reset_point_view(self, view: int, point: int, frame: int | None = None) -> None: + """Drop just ``view``'s 2D correction (and fixed flag) for ``point``. + + Unlike :meth:`reset_point`, the shared 3D point is left in place; if two or + more views remain fixed it is re-solved from them so the other views still + agree (a no-op below two fixed views). Use this to revert one view without + discarding the work in the others. + """ + t = self._resolve_frame(frame) + self.corrections.clear_2d(view, t, point) # also clears the fixed flag + self._resolve_3d_from_fixed(point, t) diff --git a/src/deeperfly/gui/view.py b/src/deeperfly/gui/view.py deleted file mode 100644 index 8120565..0000000 --- a/src/deeperfly/gui/view.py +++ /dev/null @@ -1,263 +0,0 @@ -"""A single camera view: a frame with a draggable 2D skeleton overlay. - -:class:`PoseView` is a :class:`~PySide6.QtWidgets.QGraphicsView` showing one -camera's frame as a background pixmap with the skeleton drawn as persistent -ellipse (joint) and line (bone) items in *pixel* scene coordinates. Editing is -handled at the view level (press picks the nearest joint, move drags it, release -emits :attr:`PoseView.pointDragged`) rather than with movable items, so the -window stays in full control of where a dragged point is allowed to go and what -happens to the 3D point behind it. -""" - -from __future__ import annotations - -import numpy as np -from PySide6.QtCore import Qt, Signal -from PySide6.QtGui import QBrush, QColor, QImage, QPainter, QPen, QPixmap -from PySide6.QtWidgets import ( - QGraphicsEllipseItem, - QGraphicsLineItem, - QGraphicsPixmapItem, - QGraphicsScene, - QGraphicsView, -) - -__all__ = ["PoseView"] - -#: Drawn joint radius, in scene (pixel) units. -_POINT_RADIUS = 4.0 -#: How close (in *screen* pixels) a click must be to grab a joint. -_HIT_TOLERANCE_PX = 14.0 - - -def numpy_to_pixmap(image: np.ndarray) -> QPixmap: - """Convert an ``(H, W, 3)`` uint8 RGB (or ``(H, W)`` gray) array to a QPixmap.""" - img = np.ascontiguousarray(image) - height, width = img.shape[:2] - if img.ndim == 2: - qimg = QImage(img.data, width, height, width, QImage.Format.Format_Grayscale8) - else: - qimg = QImage(img.data, width, height, 3 * width, QImage.Format.Format_RGB888) - # Copy detaches the pixmap from the (transient) NumPy buffer. - return QPixmap.fromImage(qimg.copy()) - - -class PoseView(QGraphicsView): - """One camera's frame plus its draggable skeleton overlay.""" - - #: Emitted on drop: ``(view_index, point_index, x, y)`` in pixel coordinates. - pointDragged = Signal(int, int, float, float) - #: Emitted continuously while dragging (same payload), for live cross-view updates. - pointDragging = Signal(int, int, float, float) - #: Emitted on right-click of a point: ``(view_index, point_index)`` to toggle "fixed". - pointFixToggled = Signal(int, int) - - def __init__(self, view_index: int, parent=None): - super().__init__(parent) - self._view_index = view_index - self._scene = QGraphicsScene(self) - self.setScene(self._scene) - self._pixmap_item = QGraphicsPixmapItem() - self._scene.addItem(self._pixmap_item) - self._point_items: list[QGraphicsEllipseItem] = [] - self._bone_items: list[QGraphicsLineItem] = [] - self._colors: list[QColor] = [] - self._bones = np.empty((0, 2), dtype=int) - self._pts: np.ndarray | None = None - self._fixed: np.ndarray | None = None - self._editable = False - self._highlight: int | None = None - self._dragging: int | None = None - self.setRenderHint(QPainter.RenderHint.Antialiasing) - self.setMouseTracking(True) - self.setDragMode(QGraphicsView.DragMode.NoDrag) - self.setMinimumSize(160, 120) - - @property - def view_index(self) -> int: - return self._view_index - - # -- setup ---------------------------------------------------------------- - - def set_skeleton(self, bones: np.ndarray, colors: np.ndarray) -> None: - """Create the persistent joint/bone items (once per skeleton). - - Parameters - ---------- - bones - ``(B, 2)`` point-index pairs. - colors - ``(P, 3)`` RGB floats in ``[0, 1]``, one per point. - """ - for item in self._point_items + self._bone_items: - self._scene.removeItem(item) - self._point_items.clear() - self._bone_items.clear() - self._bones = np.asarray(bones, dtype=int).reshape(-1, 2) - self._colors = [ - QColor(int(r * 255), int(g * 255), int(b * 255)) for r, g, b in colors - ] - for a, _b in self._bones: - line = QGraphicsLineItem() - line.setZValue(1.0) - line.setPen(QPen(self._colors[a], 1.5)) - line.setVisible(False) - self._scene.addItem(line) - self._bone_items.append(line) - for i in range(len(self._colors)): - dot = QGraphicsEllipseItem() - dot.setZValue(2.0) - dot.setBrush(QBrush(self._colors[i])) - dot.setPen(QPen(Qt.GlobalColor.black, 0.5)) - dot.setVisible(False) - self._scene.addItem(dot) - self._point_items.append(dot) - - def set_image(self, image: np.ndarray) -> None: - """Set the background frame and fit it to the widget.""" - pix = numpy_to_pixmap(image) - self._pixmap_item.setPixmap(pix) - self._scene.setSceneRect(self._pixmap_item.boundingRect()) - self._fit() - - def set_points(self, pts2d: np.ndarray) -> None: - """Move the joint/bone items to ``pts2d`` (``(P, 2)``); hide NaN points. - - While a drag is in progress the dragged joint is pinned to the cursor and - is *not* overwritten by ``pts2d``. The live 3D re-solve reprojects that - joint a hair off the cursor whenever another view constrains it, and - letting that fight the mouse feels like resistance; the dragged joint - follows the mouse blindly until release, when the model-consistent - position is shown on the next refresh. - """ - # A writable copy: projected 3D points arrive as a (read-only) JAX buffer, - # and a drag writes the dragged joint straight into this array. - new = np.array(pts2d, dtype=float) - if ( - self._dragging is not None - and self._pts is not None - and self._dragging < len(new) - ): - new[self._dragging] = self._pts[self._dragging] - self._pts = new - for i, item in enumerate(self._point_items): - self._place_point(i) - for j, (a, b) in enumerate(self._bones): - pa, pb = self._pts[a], self._pts[b] - line = self._bone_items[j] - if np.all(np.isfinite(pa)) and np.all(np.isfinite(pb)): - line.setLine(pa[0], pa[1], pb[0], pb[1]) - line.setVisible(True) - else: - line.setVisible(False) - - def set_editable(self, editable: bool) -> None: - self._editable = editable - - def set_fixed(self, fixed: np.ndarray | None) -> None: - """Mark which points are "fixed"/finalized (``(P,)`` bool), drawn with a - bold ring; ``None`` clears all fixed marks.""" - self._fixed = None if fixed is None else np.asarray(fixed, dtype=bool) - for i in range(len(self._point_items)): - self._place_point(i) - - def set_highlight(self, point: int | None) -> None: - """Visually emphasize ``point`` (the active joint), or clear with ``None``.""" - self._highlight = point - for i in range(len(self._point_items)): - self._place_point(i) - - # -- drawing helpers ------------------------------------------------------ - - def _place_point(self, i: int) -> None: - item = self._point_items[i] - if self._pts is None or not np.all(np.isfinite(self._pts[i])): - item.setVisible(False) - return - x, y = self._pts[i] - radius = _POINT_RADIUS * (2.0 if i == self._highlight else 1.0) - item.setRect(x - radius, y - radius, 2 * radius, 2 * radius) - fixed = ( - self._fixed is not None - and i < len(self._fixed) - and bool(self._fixed[i]) - ) - if fixed: - # A bold yellow ring marks a finalized (locked) per-view point. - item.setPen(QPen(Qt.GlobalColor.yellow, 2.5)) - elif i == self._highlight: - item.setPen(QPen(Qt.GlobalColor.white, 1.5)) - else: - item.setPen(QPen(Qt.GlobalColor.black, 0.5)) - item.setVisible(True) - - def _fit(self) -> None: - if not self._pixmap_item.pixmap().isNull(): - self.fitInView(self._pixmap_item, Qt.AspectRatioMode.KeepAspectRatio) - - def resizeEvent(self, event) -> None: # noqa: N802 -- Qt override - super().resizeEvent(event) - self._fit() - - # -- interaction ---------------------------------------------------------- - - def _nearest_point(self, scene_pos) -> int | None: - if self._pts is None: - return None - scale = max(self.transform().m11(), 1e-6) - tol = _HIT_TOLERANCE_PX / scale - cursor = np.array([scene_pos.x(), scene_pos.y()]) - best, best_d = None, tol - for i, p in enumerate(self._pts): - if not np.all(np.isfinite(p)): - continue - d = float(np.hypot(*(p - cursor))) - if d <= best_d: - best, best_d = i, d - return best - - def mousePressEvent(self, event) -> None: # noqa: N802 -- Qt override - if self._editable and event.button() == Qt.MouseButton.LeftButton: - scene_pos = self.mapToScene(event.position().toPoint()) - point = self._nearest_point(scene_pos) - if point is not None: - self._dragging = point - event.accept() - return - if self._editable and event.button() == Qt.MouseButton.RightButton: - scene_pos = self.mapToScene(event.position().toPoint()) - point = self._nearest_point(scene_pos) - if point is not None: - self.pointFixToggled.emit(self._view_index, point) - event.accept() - return - super().mousePressEvent(event) - - def mouseMoveEvent(self, event) -> None: # noqa: N802 -- Qt override - if self._dragging is not None and self._pts is not None: - scene_pos = self.mapToScene(event.position().toPoint()) - self._pts[self._dragging] = [scene_pos.x(), scene_pos.y()] - self._place_point(self._dragging) - for j, (a, b) in enumerate(self._bones): - if self._dragging in (a, b): - pa, pb = self._pts[a], self._pts[b] - if np.all(np.isfinite(pa)) and np.all(np.isfinite(pb)): - self._bone_items[j].setLine(pa[0], pa[1], pb[0], pb[1]) - self.pointDragging.emit( - self._view_index, self._dragging, scene_pos.x(), scene_pos.y() - ) - event.accept() - return - super().mouseMoveEvent(event) - - def mouseReleaseEvent(self, event) -> None: # noqa: N802 -- Qt override - if self._dragging is not None: - scene_pos = self.mapToScene(event.position().toPoint()) - point = self._dragging - self._dragging = None - self.pointDragged.emit( - self._view_index, point, scene_pos.x(), scene_pos.y() - ) - event.accept() - return - super().mouseReleaseEvent(event) diff --git a/src/deeperfly/gui/web/README.md b/src/deeperfly/gui/web/README.md new file mode 100644 index 0000000..19d66d7 --- /dev/null +++ b/src/deeperfly/gui/web/README.md @@ -0,0 +1,33 @@ +# deeperfly web GUI assets + +The browser front-end for `deeperfly gui`, served by +[`server.py`](../server.py). No bundler, no framework, **no build step** — plain +ES modules served straight from `static/`. + +- `index.html` — the page shell, served at `/`. +- `static/` — the source, served at `/static/`: + - `app.js` — controller: layout (grid / focus + thumbnails), frame scrubbing, + Edit 2D / Edit 3D mode switching, the display toggles (skeleton, keypoint + labels, 3D-estimate overlay), hover/selection, the per-view / all-views Reset + buttons, the camera-rig plot + keyboard-shortcut help, and edit routing. + - `poseView.js` — one `` per camera: frame + draggable skeleton overlay, + with wheel-zoom + drag-to-pan on the large view(s); also draws the ghosted + 3D-estimate reprojection and optional per-joint name labels. + - `scene3d.js` — the on-demand 3D camera-rig plot (a hand-rolled orbit camera on + a canvas; no 3D engine). Each camera is an RGB axis triad (x/right=red, + y/down=green, z/optical=blue), as in the bundle-adjustment notebook. + - `api.js` — REST + WebSocket client. + - `types.js` — JSDoc `@typedef`s for the server payloads (comment-only; never + fetched at runtime). + - `styles.css`. + +Press `?` in the page for the full list of keyboard shortcuts. + +## Editing + +Edit the `.js` files directly and reload the page — there is nothing to compile. + +The files start with `// @ts-check` and use JSDoc type annotations, so VS Code's +built-in TypeScript service (no npm install required) type-checks them live and +gives autocomplete on the server payload shapes in `types.js`. The annotations +are purely advisory: they never affect what runs in the browser. diff --git a/src/deeperfly/gui/web/index.html b/src/deeperfly/gui/web/index.html new file mode 100644 index 0000000..e44c3b6 --- /dev/null +++ b/src/deeperfly/gui/web/index.html @@ -0,0 +1,86 @@ + + + + + + deeperfly gui + + + +
+
+
+
+
+
+
+ + + +
+
+
Mode
+
Layout
+ + + + + + + + + + + + + + +
+
+
+ + + + + + + + + + + + diff --git a/src/deeperfly/gui/web/static/api.js b/src/deeperfly/gui/web/static/api.js new file mode 100644 index 0000000..8e5de02 --- /dev/null +++ b/src/deeperfly/gui/web/static/api.js @@ -0,0 +1,78 @@ +// @ts-check +// REST + WebSocket client for the deeperfly gui server (deeperfly/gui/server.py). +// This .js is the source -- there is no build step. VS Code type-checks it via +// `// @ts-check` and the JSDoc payload types in types.js. + +/** @typedef {import("./types.js").Meta} Meta */ +/** @typedef {import("./types.js").PointsPayload} PointsPayload */ +/** @typedef {import("./types.js").ScenePayload} ScenePayload */ +/** @typedef {import("./types.js").EditMode} EditMode */ +/** @typedef {import("./types.js").EditMessage} EditMessage */ + +/** @returns {Promise} */ +export async function fetchMeta() { + const r = await fetch("/api/meta"); + if (!r.ok) throw new Error(`GET /api/meta -> ${r.status}`); + return r.json(); +} + +/** + * @param {number} frame + * @param {EditMode} mode + * @returns {Promise} + */ +export async function fetchPoints(frame, mode) { + const r = await fetch(`/api/points/${frame}?mode=${mode}`); + if (!r.ok) throw new Error(`GET /api/points/${frame} -> ${r.status}`); + return r.json(); +} + +/** + * @param {number} frame + * @returns {Promise} + */ +export async function fetchScene(frame) { + const r = await fetch(`/api/scene/${frame}`); + if (!r.ok) throw new Error(`GET /api/scene/${frame} -> ${r.status}`); + return r.json(); +} + +/** @returns {Promise<{ dirty: boolean }>} */ +export async function saveCorrections() { + const r = await fetch("/api/save", { method: "POST" }); + if (!r.ok) throw new Error(`POST /api/save -> ${r.status}`); + return r.json(); +} + +/** Stop the server. Resolves even if the reply is cut short by the shutdown. */ +export async function shutdownServer() { + await fetch("/api/shutdown", { method: "POST" }).catch(() => {}); +} + +/** + * @param {string} camera + * @param {number} frame + * @returns {string} + */ +export function frameUrl(camera, frame) { + return `/api/frame/${encodeURIComponent(camera)}/${frame}`; +} + +// A tiny request->reply WebSocket client: send an edit, get the refreshed points +// payload back through the `onPoints` callback. +export class EditSocket { + /** @param {(p: PointsPayload) => void} onPoints */ + constructor(onPoints) { + this.onPoints = onPoints; + const proto = location.protocol === "https:" ? "wss" : "ws"; + this.ws = new WebSocket(`${proto}://${location.host}/ws`); + this.ws.onmessage = (ev) => this.onPoints(JSON.parse(ev.data)); + } + + /** @param {EditMessage} msg */ + send(msg) { + if (this.ws.readyState === WebSocket.OPEN) { + this.ws.send(JSON.stringify(msg)); + } + } +} diff --git a/src/deeperfly/gui/web/static/app.js b/src/deeperfly/gui/web/static/app.js new file mode 100644 index 0000000..00d0133 --- /dev/null +++ b/src/deeperfly/gui/web/static/app.js @@ -0,0 +1,720 @@ +// @ts-check +// The editor controller: lays out one PoseView per camera and routes edits to +// the server. It mirrors the old Qt MainWindow -- a 2D drag moves only that +// view's point; a 3D drag re-solves the 3D point and refreshes every view live, +// pinning the dragged view on release; right-click (or a tap in "pin mode") +// toggles a view's fixed flag. There are two correction modes, Edit 2D and Edit +// 3D (the latter only when the result carries 3D points). +// +// Two layouts share the same PoseView instances. "grid" shows every camera in an +// equal grid; "focus" shows one large editable view plus a strip of live, +// clickable thumbnails (the other cameras) -- which keeps each camera big enough +// to correct precisely when there are many cameras. The large view(s) can be +// zoomed (wheel) and panned (drag on empty space); thumbnails always show the +// whole frame. Every view stays live in both layouts, so a 3D re-solve still +// animates the thumbnails. The default is "focus" once the grid would get cramped +// (FOCUS_DEFAULT_VIEWS+ cameras); a toggle and the [ / ] keys switch and cycle. +// +// Hovering a joint emphasizes the same joint in every view; clicking a joint +// selects it (a cyan ring marks the last selection) so the two Reset buttons can +// revert it -- in just its view, or across all views. +// +// Display extras the operator toggles: the editable skeleton itself, per-joint +// name labels, and the read-only "3D estimate" skeleton (the triangulated estimate +// reprojected, ghosted over every view). A separate on-demand modal shows the +// camera rig in 3D (see scene3d.js). Almost everything has a keyboard shortcut; +// `?` opens a help list of them. +// +// This .js is the source -- there is no build step. VS Code type-checks it via +// `// @ts-check` and the JSDoc payload types in types.js. + +import { EditSocket, fetchMeta, fetchPoints, fetchScene, frameUrl, saveCorrections, shutdownServer } from "./api.js"; +import { PoseView } from "./poseView.js"; +import { Scene3D } from "./scene3d.js"; + +/** @typedef {import("./types.js").Meta} Meta */ +/** @typedef {import("./types.js").PointsPayload} PointsPayload */ +/** @typedef {import("./types.js").EditMode} EditMode */ +/** @typedef {"grid" | "focus"} Layout */ +/** @typedef {{ key: string, mod?: boolean, global?: boolean, hidden?: boolean, label: string, desc: string, run: (e: KeyboardEvent) => void }} Binding */ +/** @typedef {{ root: HTMLDivElement, set: (value: string) => void }} Segmented */ + +// Default to the focus layout once the grid would make each cell cramped. +const FOCUS_DEFAULT_VIEWS = 5; + +/** + * @template {HTMLElement} T + * @param {string} id + * @returns {T} + */ +function el(id) { + return /** @type {T} */ (document.getElementById(id)); +} + +/** + * Build a two-choice "switch" -- a row of buttons with exactly one active -- as + * a compact stand-in for a 2-option dropdown. `set(value)` highlights the active + * button; clicking a button calls `onChange` with its value. + * @param {[string, string][]} options [label, value] pairs + * @param {(value: string) => void} onChange + * @returns {Segmented} + */ +function segmented(options, onChange) { + const root = document.createElement("div"); + root.className = "segmented"; + /** @type {Map} */ + const buttons = new Map(); + for (const [label, value] of options) { + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = "seg-btn"; + btn.textContent = label; + btn.addEventListener("click", () => onChange(value)); + buttons.set(value, btn); + root.append(btn); + } + return { + root, + set: (value) => buttons.forEach((btn, v) => btn.classList.toggle("is-active", v === value)), + }; +} + +/** + * Does a keydown event match a binding (modifiers respected)? + * @param {KeyboardEvent} e + * @param {Binding} b + */ +function matches(e, b) { + if (b.mod) return (e.ctrlKey || e.metaKey) && e.key.toLowerCase() === b.key; + if (e.ctrlKey || e.metaKey || e.altKey) return false; + return e.key === b.key; // shift is implied by the key itself (e.g. "?", "R") +} + +class App { + /** @type {Meta} */ + meta; + /** @type {PoseView[]} */ + views = []; + /** @type {HTMLDivElement[]} */ + cells = []; + /** @type {HTMLImageElement[]} */ + images = []; + /** @type {EditSocket} */ + socket; + frame = 0; + /** @type {EditMode} */ + mode = "edit_2d"; + dirty = false; + pinMode = false; + /** @type {Layout} */ + layout = "grid"; + focused = 0; + // The last joint the operator clicked, and the view they clicked it in -- what + // the two Reset buttons act on. + /** @type {number | null} */ + selectedPoint = null; + selectedView = 0; + // On-demand 3D camera-rig plot (built lazily the first time it is opened). + /** @type {Scene3D | null} */ + scene = null; + sceneOpen = false; + helpOpen = false; + helpBuilt = false; + // True once a deliberate Close is under way: stops the unsaved-changes guard + // (beforeunload) from nagging after the operator has already decided. + closing = false; + closeConfirmOpen = false; + /** @type {Binding[]} */ + bindings = []; + + /** @type {HTMLDivElement} */ + viewsEl = el("views"); + /** @type {HTMLDivElement} */ + stageEl = el("stage"); + /** @type {HTMLDivElement} */ + stripEl = el("strip"); + /** @type {HTMLInputElement} */ + slider = el("frame-slider"); + /** @type {HTMLInputElement} */ + number = el("frame-number"); + /** @type {HTMLSpanElement} */ + totalEl = el("frame-total"); + /** @type {Segmented} */ + modeSwitch; + /** @type {HTMLDivElement} */ + modeWrap = el("mode-wrap"); + /** @type {Segmented} */ + layoutSwitch; + /** @type {HTMLDivElement} */ + layoutWrap = el("layout-wrap"); + /** @type {HTMLInputElement} */ + skeletonCheck = el("show-skeleton"); + /** @type {HTMLInputElement} */ + labelsCheck = el("show-labels"); + /** @type {HTMLLabelElement} */ + latentWrap = el("latent-wrap"); + /** @type {HTMLInputElement} */ + latentCheck = el("show-latent"); + /** @type {HTMLLabelElement} */ + pinWrap = el("pin-wrap"); + /** @type {HTMLInputElement} */ + pinCheck = el("pin-mode"); + /** @type {HTMLButtonElement} */ + resetViewBtn = el("reset-view"); + /** @type {HTMLButtonElement} */ + resetAllBtn = el("reset-all"); + /** @type {HTMLButtonElement} */ + camerasBtn = el("cameras"); + /** @type {HTMLButtonElement} */ + helpBtn = el("help"); + /** @type {HTMLButtonElement} */ + saveBtn = el("save"); + /** @type {HTMLButtonElement} */ + closeBtn = el("close-editor"); + /** @type {HTMLSpanElement} */ + statusEl = el("status"); + /** @type {HTMLDivElement} */ + closeOverlay = el("close-overlay"); + /** @type {HTMLButtonElement} */ + closeCancelBtn = el("close-cancel"); + /** @type {HTMLButtonElement} */ + closeDiscardBtn = el("close-discard"); + /** @type {HTMLButtonElement} */ + closeSaveBtn = el("close-save"); + /** @type {HTMLDivElement} */ + stoppedOverlay = el("stopped-overlay"); + /** @type {HTMLDivElement} */ + helpOverlay = el("help-overlay"); + /** @type {HTMLButtonElement} */ + helpClose = el("help-close"); + /** @type {HTMLDivElement} */ + helpBody = el("help-body"); + /** @type {HTMLDivElement} */ + sceneOverlay = el("scene-overlay"); + /** @type {HTMLButtonElement} */ + sceneClose = el("scene-close"); + /** @type {HTMLCanvasElement} */ + sceneCanvas = el("scene-canvas"); + + async init() { + this.meta = await fetchMeta(); + this.dirty = this.meta.dirty; + this.layout = this.meta.n_views >= FOCUS_DEFAULT_VIEWS ? "focus" : "grid"; + this.bindings = this.buildBindings(); + this.buildControls(); + this.buildViews(); + this.relayout(); + this.socket = new EditSocket((p) => this.applyPoints(p)); + await this.goToFrame(0); + this.setMode(this.meta.has_3d ? "edit_3d" : "edit_2d"); + this.updateSelected(); + this.updateDirty(); + // Closing instantly when there is nothing to lose, prompting otherwise: the + // browser shows its generic "leave site?" dialog only while edits are unsaved. + window.addEventListener("beforeunload", (e) => { + if (this.dirty && !this.closing) { + e.preventDefault(); + e.returnValue = ""; + } + }); + window.addEventListener("keydown", (e) => this.onKey(e)); + } + + // -- construction ----------------------------------------------------------- + + buildControls() { + const last = Math.max(0, this.meta.n_frames - 1); + for (const input of [this.slider, this.number]) { + input.min = "0"; + input.max = String(last); + input.value = "0"; + } + this.totalEl.textContent = `/ ${last}`; + this.slider.addEventListener("input", () => this.goToFrame(Number(this.slider.value))); + this.number.addEventListener("change", () => this.goToFrame(Number(this.number.value))); + + /** @type {[string, EditMode][]} */ + const modes = [["Edit 2D", "edit_2d"]]; + if (this.meta.has_3d) modes.push(["Edit 3D", "edit_3d"]); + this.modeSwitch = segmented(modes, (v) => this.setMode(/** @type {EditMode} */ (v))); + this.modeSwitch.set(this.mode); + el("mode-switch").append(this.modeSwitch.root); + // With no 3D there is a single mode -- nothing to switch -- so hide the control. + this.modeWrap.style.display = modes.length > 1 ? "" : "none"; + + // A single camera has nothing to focus, so the layout choice is hidden. + this.layoutWrap.style.display = this.meta.n_views > 1 ? "" : "none"; + this.layoutSwitch = segmented( + [["Focus", "focus"], ["Grid", "grid"]], + (v) => this.setLayout(/** @type {Layout} */ (v)) + ); + this.layoutSwitch.set(this.layout); + el("layout-switch").append(this.layoutSwitch.root); + + this.skeletonCheck.addEventListener("change", () => this.applySkeleton()); + this.labelsCheck.addEventListener("change", () => this.applyLabels()); + // The latent overlay is the reprojected 3D estimate -- meaningless without 3D. + this.latentWrap.style.display = this.meta.has_3d ? "" : "none"; + this.latentCheck.addEventListener("change", () => this.applyLatent()); + + this.pinCheck.addEventListener("change", () => { + this.pinMode = this.pinCheck.checked; + }); + this.resetViewBtn.addEventListener("click", () => this.resetSelectedView()); + this.resetAllBtn.addEventListener("click", () => this.resetSelectedAll()); + this.camerasBtn.addEventListener("click", () => this.toggleScene()); + this.helpBtn.addEventListener("click", () => this.toggleHelp()); + this.helpClose.addEventListener("click", () => this.closeHelp()); + this.sceneClose.addEventListener("click", () => this.closeScene()); + // Click outside the dialog body (on the dim backdrop) closes it. + this.helpOverlay.addEventListener("click", (e) => { + if (e.target === this.helpOverlay) this.closeHelp(); + }); + this.sceneOverlay.addEventListener("click", (e) => { + if (e.target === this.sceneOverlay) this.closeScene(); + }); + this.saveBtn.addEventListener("click", () => this.save()); + this.closeBtn.addEventListener("click", () => this.requestClose()); + this.closeCancelBtn.addEventListener("click", () => this.closeCloseConfirm()); + this.closeDiscardBtn.addEventListener("click", () => this.shutdown()); + this.closeSaveBtn.addEventListener("click", () => this.saveAndShutdown()); + this.closeOverlay.addEventListener("click", (e) => { + if (e.target === this.closeOverlay) this.closeCloseConfirm(); + }); + } + + buildViews() { + const cols = Math.max(1, Math.ceil(Math.sqrt(this.meta.n_views))); + this.viewsEl.style.setProperty("--cols", String(cols)); + /** @type {import("./poseView.js").PoseViewCallbacks} */ + const cb = { + onDragging: (v, p, x, y) => this.onDragging(v, p, x, y), + onDragged: (v, p, x, y) => this.onDragged(v, p, x, y), + onToggleFixed: (v, p) => this.onToggleFixed(v, p), + onSelect: (v, p) => this.onSelect(v, p), + onHover: (p) => this.onHover(p), + }; + this.meta.camera_names.forEach((name, v) => { + const cell = document.createElement("div"); + cell.className = "cell"; + const label = document.createElement("div"); + label.className = "cell-label"; + label.textContent = name; + const canvas = document.createElement("canvas"); + cell.append(label, canvas); + // In the focus layout a thumbnail is non-editable; clicking it promotes it + // to the large editable view. + cell.addEventListener("click", () => { + if (this.layout === "focus" && v !== this.focused) this.setFocused(v); + }); + this.cells.push(cell); + + const view = new PoseView(v, canvas, cb, () => this.pinMode && this.mode === "edit_3d"); + view.setSkeleton(this.meta.bones, this.meta.point_colors); + view.setPointNames(this.meta.point_names); + const size = this.meta.image_sizes[name]; + if (size) view.setImageSize(size[0], size[1]); + this.views.push(view); + + const img = new Image(); + img.onload = () => view.setImage(img); + this.images.push(img); + }); + } + + // -- layout ----------------------------------------------------------------- + + /** @param {Layout} layout */ + setLayout(layout) { + this.layout = layout; + this.layoutSwitch.set(layout); + this.relayout(); + } + + /** @param {number} view */ + setFocused(view) { + this.focused = view; + this.relayout(); + } + + // Reparent the persistent cells into the stage/strip for the current layout. + // Moving a cell resizes its canvas, so each PoseView re-fits via its + // ResizeObserver -- no points need re-fetching. + relayout() { + this.viewsEl.classList.toggle("layout-focus", this.layout === "focus"); + this.viewsEl.classList.toggle("layout-grid", this.layout === "grid"); + if (this.layout === "grid") { + this.stageEl.replaceChildren(...this.cells); + this.stripEl.replaceChildren(); + } else { + this.stageEl.replaceChildren(this.cells[this.focused]); + this.stripEl.replaceChildren(...this.cells.filter((_, v) => v !== this.focused)); + } + this.cells.forEach((cell, v) => { + cell.classList.toggle("is-focused", this.layout === "focus" && v === this.focused); + }); + this.updateViewRoles(); + } + + // Only the large view(s) are editable and zoomable: every view in the grid, or + // just the focused view in the focus layout. Thumbnails stay live but read-only + // and unzoomed (so they always show the whole frame). + updateViewRoles() { + this.views.forEach((view, v) => { + const large = this.layout === "grid" || v === this.focused; + view.setEditable(large); + view.setZoomable(large); + }); + } + + // -- frame / mode ----------------------------------------------------------- + + /** @param {number} t */ + async goToFrame(t) { + const last = Math.max(0, this.meta.n_frames - 1); + t = Math.max(0, Math.min(Math.round(t), last)); + this.frame = t; + this.slider.value = String(t); + this.number.value = String(t); + this.meta.camera_names.forEach((name, v) => { + this.images[v].src = frameUrl(name, t); + }); + await this.refreshPoints(); + if (this.sceneOpen) this.refreshScene(); + } + + async refreshPoints() { + this.applyPoints(await fetchPoints(this.frame, this.mode)); + } + + /** @param {PointsPayload} p */ + applyPoints(p) { + if (p.frame !== this.frame) return; // a stale reply after a fast scrub + const showFixed = this.mode === "edit_3d"; + this.views.forEach((view, v) => { + view.setPoints(p.points[v]); + view.setFixed(showFixed ? p.fixed[v] : null); + view.setLatent(p.proj ? p.proj[v] : null); + }); + this.dirty = p.dirty; + this.updateDirty(); + } + + /** @param {EditMode} mode */ + setMode(mode) { + this.mode = mode; + this.modeSwitch.set(mode); + this.pinWrap.style.display = mode === "edit_3d" ? "" : "none"; + this.refreshPoints(); + } + + // -- display toggles -------------------------------------------------------- + + applySkeleton() { + const visible = this.skeletonCheck.checked; + this.views.forEach((view) => view.setOverlayVisible(visible)); + } + + applyLabels() { + const visible = this.labelsCheck.checked; + this.views.forEach((view) => view.setLabelsVisible(visible)); + } + + applyLatent() { + const visible = this.latentCheck.checked; + this.views.forEach((view) => view.setLatentVisible(visible)); + } + + /** @param {HTMLInputElement} check flip a checkbox from a shortcut, then apply */ + toggleCheck(check, apply) { + check.checked = !check.checked; + apply(); + } + + // -- hover / selection ------------------------------------------------------ + + /** @param {number | null} point the hovered joint, emphasized in every view */ + onHover(point) { + this.views.forEach((view) => view.setHighlight(point)); + } + + /** + * @param {number} view + * @param {number} point + */ + onSelect(view, point) { + this.selectedView = view; + this.selectedPoint = point; + this.updateSelected(); + } + + // Mark the selected joint (a cyan ring in its own view only) and enable the + // Reset buttons once there is something to reset. + updateSelected() { + this.views.forEach((view, v) => { + view.setSelected(v === this.selectedView ? this.selectedPoint : null); + }); + const has = this.selectedPoint !== null; + this.resetViewBtn.disabled = !has; + this.resetAllBtn.disabled = !has; + } + + // -- edit routing ----------------------------------------------------------- + + /** + * @param {number} view + * @param {number} point + * @param {number} x + * @param {number} y + */ + onDragging(view, point, x, y) { + // Only 3D needs a live re-solve; a 2D drag is local to its own view. + if (this.mode === "edit_3d") { + this.socket.send({ type: "edit_3d", view, point, x, y, frame: this.frame, fix: false, mode: this.mode }); + } + } + + /** + * @param {number} view + * @param {number} point + * @param {number} x + * @param {number} y + */ + onDragged(view, point, x, y) { + if (this.mode === "edit_2d") { + this.socket.send({ type: "edit_2d", view, point, x, y, frame: this.frame, mode: this.mode }); + } else if (this.mode === "edit_3d") { + // Releasing pins the dragged view at the drop pixel (a finalized constraint). + this.socket.send({ type: "edit_3d", view, point, x, y, frame: this.frame, fix: true, mode: this.mode }); + } + } + + /** + * @param {number} view + * @param {number} point + */ + onToggleFixed(view, point) { + if (this.mode === "edit_3d") { + this.socket.send({ type: "toggle_fixed", view, point, frame: this.frame, mode: this.mode }); + } + } + + // Revert the last-selected joint in just the view it was selected in. + resetSelectedView() { + if (this.selectedPoint === null) return; + this.socket.send({ + type: "reset_point_view", + view: this.selectedView, + point: this.selectedPoint, + frame: this.frame, + mode: this.mode, + }); + } + + // Revert the last-selected joint across every view (and its 3D point). + resetSelectedAll() { + if (this.selectedPoint === null) return; + this.socket.send({ type: "reset_point", point: this.selectedPoint, frame: this.frame, mode: this.mode }); + } + + async save() { + const r = await saveCorrections(); + this.dirty = r.dirty; + this.updateDirty(); + this.statusEl.textContent = "saved"; + setTimeout(() => (this.statusEl.textContent = ""), 3000); + } + + updateDirty() { + document.title = `deeperfly gui — ${this.meta.results_path}${this.dirty ? " *" : ""}`; + this.saveBtn.disabled = !this.dirty; + } + + // -- close / shutdown ------------------------------------------------------- + + // The Close button: stop the server outright when nothing is at stake, else + // ask whether to save the pending corrections first. + requestClose() { + if (this.dirty) this.openCloseConfirm(); + else this.shutdown(); + } + + openCloseConfirm() { + this.closeOverlay.hidden = false; + this.closeConfirmOpen = true; + } + + closeCloseConfirm() { + this.closeOverlay.hidden = true; + this.closeConfirmOpen = false; + } + + // "Save & close": only stop the server once the save actually lands, so a + // failed write leaves the editor open with the corrections intact. + async saveAndShutdown() { + try { + await this.save(); + } catch (_) { + this.closeCloseConfirm(); + this.statusEl.textContent = "save failed"; + return; + } + await this.shutdown(); + } + + // Stop the server and replace the editor with a "stopped" notice. The socket is + // closed first so uvicorn's graceful shutdown isn't held up by the live WS, and + // the request error (the server may drop the connection mid-reply) is ignored. + async shutdown() { + this.closing = true; // the close is deliberate -- don't nag on unload + this.closeCloseConfirm(); + this.socket?.ws.close(); + await shutdownServer(); + this.stoppedOverlay.hidden = false; + } + + // -- camera-rig 3D plot ----------------------------------------------------- + + ensureScene() { + if (this.scene) return this.scene; + this.scene = new Scene3D(this.sceneCanvas); + this.scene.setCameras(this.meta.cameras_3d); + this.scene.setSkeleton(this.meta.bones, this.meta.point_colors); + return this.scene; + } + + async refreshScene() { + if (!this.scene) return; + const s = await fetchScene(this.frame); + this.scene.setPoints3d(s.points3d); + } + + async openScene() { + const scene = this.ensureScene(); + this.sceneOverlay.hidden = false; + this.sceneOpen = true; + scene.resize(); // the canvas only has a size now that the modal is visible + await this.refreshScene(); + scene.resetView(); // frame the rig once the pose points are loaded + } + + closeScene() { + this.sceneOverlay.hidden = true; + this.sceneOpen = false; + } + + toggleScene() { + if (this.sceneOpen) this.closeScene(); + else this.openScene(); + } + + // -- keyboard help ---------------------------------------------------------- + + /** @returns {Binding[]} the active shortcut bindings (some depend on the result) */ + buildBindings() { + const has3d = this.meta.has_3d; + const multi = this.meta.n_views > 1; + /** @type {Binding[]} */ + const b = [ + { key: "ArrowLeft", label: "← / →", desc: "Previous / next frame (Shift: ±10)", run: (e) => this.step(e.shiftKey ? -10 : -1) }, + { key: "ArrowRight", hidden: true, label: "→", desc: "", run: (e) => this.step(e.shiftKey ? 10 : 1) }, + { key: "2", label: "2", desc: "Edit 2D mode", run: () => this.setMode("edit_2d") }, + ]; + if (has3d) b.push({ key: "3", label: "3", desc: "Edit 3D mode", run: () => this.setMode("edit_3d") }); + if (multi) { + b.push({ key: "g", label: "g", desc: "Grid layout", run: () => this.setLayout("grid") }); + b.push({ key: "f", label: "f", desc: "Focus layout", run: () => this.setLayout("focus") }); + b.push({ key: "[", label: "[ / ]", desc: "Focus the previous / next camera", run: () => this.cycleFocus(-1) }); + b.push({ key: "]", hidden: true, label: "]", desc: "", run: () => this.cycleFocus(1) }); + } + b.push({ key: "s", label: "s", desc: "Toggle skeleton", run: () => this.toggleCheck(this.skeletonCheck, () => this.applySkeleton()) }); + b.push({ key: "n", label: "n", desc: "Toggle keypoint labels", run: () => this.toggleCheck(this.labelsCheck, () => this.applyLabels()) }); + if (has3d) { + b.push({ key: "p", label: "p", desc: "Toggle 3D estimate overlay", run: () => this.toggleCheck(this.latentCheck, () => this.applyLatent()) }); + b.push({ key: "x", label: "x", desc: "Toggle pin-on-tap (Edit 3D)", run: () => this.togglePin() }); + } + b.push({ key: "r", label: "r", desc: "Reset selected point in its view", run: () => this.resetSelectedView() }); + b.push({ key: "R", label: "Shift+R", desc: "Reset selected point in all views", run: () => this.resetSelectedAll() }); + b.push({ key: "c", label: "c", desc: "Show / hide the camera rig in 3D", run: () => this.toggleScene() }); + b.push({ key: "s", mod: true, global: true, label: "Ctrl/⌘+S", desc: "Save corrections", run: () => this.save() }); + b.push({ key: "?", label: "?", desc: "Toggle this help", run: () => this.toggleHelp() }); + return b; + } + + buildHelp() { + const rows = this.bindings + .filter((b) => !b.hidden) + .map((b) => `${b.label}${b.desc}`); + rows.push(`EscClose this dialog or the camera view`); + this.helpBody.innerHTML = `${rows.join("")}
`; + } + + openHelp() { + if (!this.helpBuilt) { + this.buildHelp(); + this.helpBuilt = true; + } + this.helpOverlay.hidden = false; + this.helpOpen = true; + } + + closeHelp() { + this.helpOverlay.hidden = true; + this.helpOpen = false; + } + + toggleHelp() { + if (this.helpOpen) this.closeHelp(); + else this.openHelp(); + } + + // -- keyboard dispatch ------------------------------------------------------ + + /** @param {number} d */ + step(d) { + this.goToFrame(this.frame + d); + } + + /** @param {number} d switch to the focus layout and move the focus by d cameras */ + cycleFocus(d) { + if (this.meta.n_views < 2) return; + if (this.layout !== "focus") this.setLayout("focus"); + const n = this.meta.n_views; + this.setFocused((this.focused + d + n) % n); + } + + togglePin() { + if (this.mode !== "edit_3d") return; + this.pinCheck.checked = !this.pinCheck.checked; + this.pinMode = this.pinCheck.checked; + } + + /** @param {KeyboardEvent} e */ + onKey(e) { + // Escape always backs out of an open dialog first. + if (e.key === "Escape") { + if (this.closeConfirmOpen) { + this.closeCloseConfirm(); + e.preventDefault(); + } else if (this.helpOpen) { + this.closeHelp(); + e.preventDefault(); + } else if (this.sceneOpen) { + this.closeScene(); + e.preventDefault(); + } + return; + } + const tag = (document.activeElement?.tagName ?? "").toLowerCase(); + const typing = tag === "input" || tag === "select" || tag === "textarea"; + for (const b of this.bindings) { + if (!matches(e, b)) continue; + if (typing && !b.global) return; // let the focused control keep the key + e.preventDefault(); + b.run(e); + return; + } + } +} + +new App().init(); diff --git a/src/deeperfly/gui/web/static/poseView.js b/src/deeperfly/gui/web/static/poseView.js new file mode 100644 index 0000000..ae17a99 --- /dev/null +++ b/src/deeperfly/gui/web/static/poseView.js @@ -0,0 +1,578 @@ +// @ts-check +// One camera's frame plus its draggable 2D skeleton overlay, on a . +// +// A port of the old Qt PoseView, grown a few editor conveniences: the frame is +// drawn fit-to-canvas (letterboxed) and can be zoomed (wheel, toward the cursor) +// and panned (drag on empty space); the skeleton is drawn in image-pixel +// coordinates mapped through that fit+zoom. Pointer events pick the nearest joint +// within a screen-pixel tolerance. A press on a joint selects it; an actual drag +// (past a small threshold) moves it, emitting a throttled `onDragging` and a final +// `onDragged` -- a click without movement just selects, so it never creates a +// spurious edit. Right-click (or a tap while "pin mode" is on) toggles a point's +// fixed flag. Hovering a joint reports it via `onHover` so the app can emphasize +// the same point across every view. The app stays in control of what a drag does +// to the 3D point behind it. +// +// Beyond the editable overlay the view can also draw two read-only extras that the +// app toggles: the "latent" skeleton (the current 3D estimate reprojected and drawn +// as a bright dashed overlay on top, so you can see where triangulation puts each +// point even when it lands on the editable skeleton) and per-joint name labels. +// +// This .js is the source -- no build step; VS Code type-checks it via +// `// @ts-check` and the JSDoc types. + +/** @typedef {import("./types.js").Point} Point */ + +/** + * @typedef {object} PoseViewCallbacks + * @property {(view: number, point: number, x: number, y: number) => void} onDragging + * @property {(view: number, point: number, x: number, y: number) => void} onDragged + * @property {(view: number, point: number) => void} onToggleFixed + * @property {(view: number, point: number) => void} onSelect a joint was clicked/grabbed + * @property {(point: number | null) => void} onHover the hovered joint changed (cross-view) + */ + +const POINT_RADIUS_PX = 4; // drawn joint radius in screen px (constant under zoom) +const HOVER_SCALE = 1.9; // how much a hovered joint grows +const HIT_TOLERANCE_PX = 14; // how close a click must be to grab a joint, screen px +const DRAG_THRESHOLD_PX = 3; // movement (screen px) before a press becomes a drag +const MAX_ZOOM = 10; // cap on the user wheel-zoom factor over fit +const WHEEL_ZOOM_RATE = 0.0015; // wheel delta -> zoom factor sensitivity + +const FIXED_COLOR = "#7CFC00"; // ring on a fixed (finalized) point (lime green) +const SELECT_COLOR = "#3fd0ff"; // ring on the last-selected point (cyan; lime = fixed) +const LATENT_COLOR = "rgba(255,176,64,0.95)"; // the latent-skeleton overlay (amber, drawn on top) + +export class PoseView { + /** + * @param {number} viewIndex + * @param {HTMLCanvasElement} canvas + * @param {PoseViewCallbacks} cb + * @param {() => boolean} pinMode whether a tap toggles fixed instead of dragging + */ + constructor(viewIndex, canvas, cb, pinMode) { + /** @type {HTMLImageElement | null} */ + this.img = null; + /** @type {[number, number][]} */ + this.bones = []; + /** @type {string[]} */ + this.colors = []; + /** @type {Point[]} */ + this.pts = []; + /** @type {Point[] | null} */ + this.latent = null; // latent 3D reprojection (display only), drawn when latentVisible + /** @type {boolean[] | null} */ + this.fixed = null; + /** @type {string[]} */ + this.pointNames = []; + /** @type {number | null} */ + this.highlight = null; // hovered joint (set by the app across all views) + /** @type {number | null} */ + this.selected = null; // last-selected joint, shown only in its own view + this.editable = false; + this.zoomable = false; + this.overlayVisible = true; + this.latentVisible = false; + this.labelsVisible = false; + /** @type {number | null} */ + this.dragging = null; + this.panning = false; + this.moved = false; // has the current press moved past the drag threshold? + + // image -> CSS-pixel fit (recomputed on resize / new image) + this.imgW = 1; + this.imgH = 1; + // user zoom/pan applied on top of the fit + this.zoom = 1; + this.panX = 0; + this.panY = 0; + // effective transform (fit * zoom/pan), recomputed by applyTransform() + this.fitScale = 1; + this.fitOffX = 0; + this.fitOffY = 0; + this.scale = 1; + this.offX = 0; + this.offY = 0; + + // press bookkeeping (CSS px) for the click-vs-drag threshold and panning + this.downX = 0; + this.downY = 0; + this.panOrigX = 0; + this.panOrigY = 0; + /** @type {number | null} */ + this._hover = null; // last hover reported, to debounce onHover + + /** @type {{ x: number, y: number } | null} */ + this.pendingDrag = null; + this.rafId = 0; + + this.viewIndex = viewIndex; + this.canvas = canvas; + this.ctx = /** @type {CanvasRenderingContext2D} */ (canvas.getContext("2d")); + this.cb = cb; + this.pinMode = pinMode; + + canvas.addEventListener("pointerdown", (e) => this.onPointerDown(e)); + canvas.addEventListener("pointermove", (e) => this.onPointerMove(e)); + canvas.addEventListener("pointerup", (e) => this.onPointerUp(e)); + canvas.addEventListener("pointercancel", (e) => this.onPointerUp(e)); + canvas.addEventListener("pointerleave", () => this.onPointerLeave()); + canvas.addEventListener("contextmenu", (e) => e.preventDefault()); + canvas.addEventListener("wheel", (e) => this.onWheel(e), { passive: false }); + canvas.addEventListener("dblclick", (e) => this.onDblClick(e)); + new ResizeObserver(() => this.layoutAndDraw()).observe(canvas); + } + + // -- setup ------------------------------------------------------------------ + + /** + * @param {[number, number][]} bones + * @param {[number, number, number][]} colors + */ + setSkeleton(bones, colors) { + this.bones = bones; + this.colors = colors.map(([r, g, b]) => `rgb(${r},${g},${b})`); + } + + /** @param {string[]} names per-point labels, drawn when labels are visible */ + setPointNames(names) { + this.pointNames = names; + } + + /** @param {HTMLImageElement} img */ + setImage(img) { + this.img = img; + this.imgW = img.naturalWidth || this.imgW; + this.imgH = img.naturalHeight || this.imgH; + this.layoutAndDraw(); + } + + // Image size hint so the canvas keeps the right aspect before the first frame. + /** + * @param {number} height + * @param {number} width + */ + setImageSize(height, width) { + this.imgH = height; + this.imgW = width; + this.layoutAndDraw(); + } + + /** @param {Point[]} pts */ + setPoints(pts) { + // Keep the actively dragged joint pinned to the cursor: the server's live + // re-solve reprojects it a hair off, and letting that fight the mouse feels + // like resistance (mirrors the old Qt PoseView.set_points). + const held = this.dragging !== null ? this.pts[this.dragging] : null; + this.pts = pts.slice(); + if (this.dragging !== null && held) this.pts[this.dragging] = held; + this.draw(); + } + + /** @param {boolean[] | null} fixed */ + setFixed(fixed) { + this.fixed = fixed; + this.draw(); + } + + /** @param {Point[] | null} pts the latent 3D reprojection to ghost, or null */ + setLatent(pts) { + this.latent = pts; + if (this.latentVisible) this.draw(); + } + + /** @param {number | null} point */ + setHighlight(point) { + if (this.highlight === point) return; + this.highlight = point; + this.draw(); + } + + /** @param {number | null} point the selected joint, or null if not selected in this view */ + setSelected(point) { + if (this.selected === point) return; + this.selected = point; + this.draw(); + } + + /** @param {boolean} editable */ + setEditable(editable) { + this.editable = editable; + } + + /** @param {boolean} zoomable whether wheel-zoom + pan are allowed (large views only) */ + setZoomable(zoomable) { + if (this.zoomable === zoomable) return; + this.zoomable = zoomable; + if (!zoomable) this.resetZoom(); // thumbnails always show the whole frame + } + + /** @param {boolean} visible whether the editable skeleton + joints are drawn */ + setOverlayVisible(visible) { + if (this.overlayVisible === visible) return; + this.overlayVisible = visible; + this.draw(); + } + + /** @param {boolean} visible whether the latent 3D reprojection is ghosted on top */ + setLatentVisible(visible) { + if (this.latentVisible === visible) return; + this.latentVisible = visible; + this.draw(); + } + + /** @param {boolean} visible whether per-joint name labels are drawn */ + setLabelsVisible(visible) { + if (this.labelsVisible === visible) return; + this.labelsVisible = visible; + this.draw(); + } + + resetZoom() { + this.zoom = 1; + this.panX = 0; + this.panY = 0; + this.applyTransform(); + this.draw(); + } + + // -- layout + drawing ------------------------------------------------------- + + layoutAndDraw() { + const dpr = window.devicePixelRatio || 1; + const cssW = this.canvas.clientWidth || 1; + const cssH = this.canvas.clientHeight || 1; + this.canvas.width = Math.round(cssW * dpr); + this.canvas.height = Math.round(cssH * dpr); + this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0); // draw in CSS pixels + this.fitScale = Math.min(cssW / this.imgW, cssH / this.imgH); + this.fitOffX = (cssW - this.imgW * this.fitScale) / 2; + this.fitOffY = (cssH - this.imgH * this.fitScale) / 2; + this.applyTransform(); + this.draw(); + } + + // Fold the user zoom/pan into the effective image->canvas transform. + applyTransform() { + this.scale = this.fitScale * this.zoom; + this.offX = this.fitOffX + this.panX; + this.offY = this.fitOffY + this.panY; + } + + /** + * @param {number} x + * @param {number} y + * @returns {[number, number]} + */ + toCanvas(x, y) { + return [this.offX + x * this.scale, this.offY + y * this.scale]; + } + + draw() { + const ctx = this.ctx; + const cssW = this.canvas.clientWidth || 1; + const cssH = this.canvas.clientHeight || 1; + ctx.clearRect(0, 0, cssW, cssH); + ctx.fillStyle = "#000"; + ctx.fillRect(0, 0, cssW, cssH); + if (this.img) { + ctx.drawImage(this.img, this.offX, this.offY, this.imgW * this.scale, this.imgH * this.scale); + } + if (this.overlayVisible) { + // bones first, joints on top + ctx.lineWidth = 1.5; + for (const [a, b] of this.bones) { + const pa = this.pts[a]; + const pb = this.pts[b]; + if (!pa || !pb) continue; + const [ax, ay] = this.toCanvas(pa[0], pa[1]); + const [bx, by] = this.toCanvas(pb[0], pb[1]); + ctx.strokeStyle = this.colors[a] || "#fff"; + ctx.beginPath(); + ctx.moveTo(ax, ay); + ctx.lineTo(bx, by); + ctx.stroke(); + } + for (let i = 0; i < this.pts.length; i++) { + const p = this.pts[i]; + if (!p) continue; + const [cx, cy] = this.toCanvas(p[0], p[1]); + const isHover = i === this.highlight; + const r = POINT_RADIUS_PX * (isHover ? HOVER_SCALE : 1); + ctx.beginPath(); + ctx.arc(cx, cy, r, 0, Math.PI * 2); + ctx.fillStyle = this.colors[i] || "#fff"; + ctx.fill(); + const isFixed = this.fixed != null && i < this.fixed.length && this.fixed[i]; + if (isFixed) { + ctx.strokeStyle = FIXED_COLOR; + ctx.lineWidth = 2.5; + } else if (isHover) { + ctx.strokeStyle = "white"; + ctx.lineWidth = 2; + } else { + ctx.strokeStyle = "rgba(0,0,0,0.6)"; + ctx.lineWidth = 1; + } + ctx.stroke(); + // The last-selected joint gets an extra outer ring (only its own view sets + // `selected`), distinct from the lime "fixed" ring so the two can coexist. + if (i === this.selected) { + ctx.beginPath(); + ctx.arc(cx, cy, r + 3, 0, Math.PI * 2); + ctx.strokeStyle = SELECT_COLOR; + ctx.lineWidth = 2; + ctx.stroke(); + } + if (this.labelsVisible) this.drawLabel(i, cx, cy, r); + } + } + // The latent reprojection is read-only, so draw it *on top* of the editable + // overlay: when calibration is good it lands right on the skeleton, and drawing + // it underneath an opaque overlay would hide it. It can also show on its own + // (e.g. with the skeleton toggled off). + if (this.latentVisible && this.latent) this.drawLatent(); + } + + // The latent 3D reprojection: a dashed skeleton with small hollow joints in one + // bright colour, so it reads as a distinct reference over the editable overlay. + drawLatent() { + const ctx = this.ctx; + const latent = this.latent; + if (!latent) return; + ctx.save(); + ctx.strokeStyle = LATENT_COLOR; + ctx.lineWidth = 1.5; + ctx.setLineDash([5, 3]); + for (const [a, b] of this.bones) { + const pa = latent[a]; + const pb = latent[b]; + if (!pa || !pb) continue; + const [ax, ay] = this.toCanvas(pa[0], pa[1]); + const [bx, by] = this.toCanvas(pb[0], pb[1]); + ctx.beginPath(); + ctx.moveTo(ax, ay); + ctx.lineTo(bx, by); + ctx.stroke(); + } + ctx.setLineDash([]); + for (const p of latent) { + if (!p) continue; + const [cx, cy] = this.toCanvas(p[0], p[1]); + ctx.beginPath(); + ctx.arc(cx, cy, 2.5, 0, Math.PI * 2); + ctx.stroke(); + } + ctx.restore(); + } + + /** + * @param {number} i point index + * @param {number} cx joint centre, canvas x + * @param {number} cy joint centre, canvas y + * @param {number} r the joint's drawn radius (the label clears it) + */ + drawLabel(i, cx, cy, r) { + const name = this.pointNames[i]; + if (!name) return; + const ctx = this.ctx; + ctx.save(); + ctx.font = "11px system-ui, sans-serif"; + ctx.textBaseline = "middle"; + ctx.lineWidth = 3; + ctx.strokeStyle = "rgba(0,0,0,0.85)"; // outline for legibility over any frame + ctx.fillStyle = "#fff"; + const tx = cx + r + 3; + ctx.strokeText(name, tx, cy); + ctx.fillText(name, tx, cy); + ctx.restore(); + } + + // -- interaction ------------------------------------------------------------ + + /** + * @param {PointerEvent} e + * @returns {[number, number]} pointer position in CSS pixels relative to the canvas + */ + cssXY(e) { + const rect = this.canvas.getBoundingClientRect(); + return [e.clientX - rect.left, e.clientY - rect.top]; + } + + /** + * @param {number} mx + * @param {number} my + * @returns {[number, number]} + */ + toImage(mx, my) { + return [(mx - this.offX) / this.scale, (my - this.offY) / this.scale]; + } + + /** + * @param {number} ix + * @param {number} iy + * @returns {number | null} + */ + nearestPoint(ix, iy) { + const tol = HIT_TOLERANCE_PX / Math.max(this.scale, 1e-6); + /** @type {number | null} */ + let best = null; + let bestD = tol; + for (let i = 0; i < this.pts.length; i++) { + const p = this.pts[i]; + if (!p) continue; + const d = Math.hypot(p[0] - ix, p[1] - iy); + if (d <= bestD) { + best = i; + bestD = d; + } + } + return best; + } + + /** @param {PointerEvent} e */ + onPointerDown(e) { + const [mx, my] = this.cssXY(e); + this.downX = mx; + this.downY = my; + this.moved = false; + const [ix, iy] = this.toImage(mx, my); + const canGrab = this.editable && this.overlayVisible; + const point = canGrab ? this.nearestPoint(ix, iy) : null; + + if (point !== null) { + // Tap-to-pin (touch-friendly) or right-click both toggle fixed, no drag. + if (this.pinMode() || e.button === 2) { + e.preventDefault(); + this.cb.onSelect(this.viewIndex, point); + this.cb.onToggleFixed(this.viewIndex, point); + return; + } + if (e.button !== 0) return; // only the primary button drags + e.preventDefault(); + this.dragging = point; + this.cb.onSelect(this.viewIndex, point); // selecting happens on press, not release + this.canvas.setPointerCapture(e.pointerId); + return; + } + + // Empty space (or a non-editable / overlay-hidden view): pan, if allowed. + if (this.zoomable && (e.button === 0 || e.button === 1)) { + e.preventDefault(); + this.panning = true; + this.panOrigX = this.panX; + this.panOrigY = this.panY; + this.canvas.setPointerCapture(e.pointerId); + this.canvas.style.cursor = "grabbing"; + } + } + + /** @param {PointerEvent} e */ + onPointerMove(e) { + const [mx, my] = this.cssXY(e); + if (!this.moved && Math.hypot(mx - this.downX, my - this.downY) > DRAG_THRESHOLD_PX) { + this.moved = true; + } + + if (this.dragging !== null) { + if (!this.moved) return; // a press that has not yet become a drag + e.preventDefault(); + const [ix, iy] = this.toImage(mx, my); + this.pts[this.dragging] = [ix, iy]; + this.draw(); + // Throttle the network round-trip to one per animation frame. + this.pendingDrag = { x: ix, y: iy }; + if (!this.rafId) { + this.rafId = requestAnimationFrame(() => { + this.rafId = 0; + if (this.dragging !== null && this.pendingDrag) { + this.cb.onDragging(this.viewIndex, this.dragging, this.pendingDrag.x, this.pendingDrag.y); + } + }); + } + return; + } + + if (this.panning) { + e.preventDefault(); + this.panX = this.panOrigX + (mx - this.downX); + this.panY = this.panOrigY + (my - this.downY); + this.applyTransform(); + this.draw(); + return; + } + + // Idle: report hover so the app can emphasize this joint in every view. + if (this.editable && this.overlayVisible) { + const [ix, iy] = this.toImage(mx, my); + const point = this.nearestPoint(ix, iy); + if (point !== this._hover) { + this._hover = point; + this.cb.onHover(point); + } + this.canvas.style.cursor = point !== null ? "pointer" : this.zoomable ? "grab" : "default"; + } + } + + /** @param {PointerEvent} e */ + onPointerUp(e) { + if (this.dragging !== null) { + e.preventDefault(); + const point = this.dragging; + this.dragging = null; + if (this.rafId) { + cancelAnimationFrame(this.rafId); + this.rafId = 0; + } + // A genuine drag commits the move; a click without movement only selected. + if (this.moved) { + const [ix, iy] = this.toImage(...this.cssXY(e)); + this.pts[point] = [ix, iy]; + this.cb.onDragged(this.viewIndex, point, ix, iy); + } + return; + } + if (this.panning) { + e.preventDefault(); + this.panning = false; + this.canvas.style.cursor = this.zoomable ? "grab" : "default"; + } + } + + onPointerLeave() { + // Drop the hover when the cursor leaves so no view stays falsely emphasized. + if (this.dragging === null && this.panning === false && this._hover !== null) { + this._hover = null; + this.cb.onHover(null); + } + } + + /** @param {WheelEvent} e */ + onWheel(e) { + if (!this.zoomable) return; + e.preventDefault(); + const [mx, my] = this.cssXY(e); + const z0 = this.zoom; + let z1 = Math.min(MAX_ZOOM, Math.max(1, z0 * Math.exp(-e.deltaY * WHEEL_ZOOM_RATE))); + if (z1 === z0) return; + if (z1 <= 1.0001) { + this.resetZoom(); // snap cleanly back to the letterboxed fit + return; + } + // Keep the image point under the cursor fixed while the zoom changes. + const [ix, iy] = this.toImage(mx, my); + const newScale = this.fitScale * z1; + this.zoom = z1; + this.panX = mx - ix * newScale - this.fitOffX; + this.panY = my - iy * newScale - this.fitOffY; + this.applyTransform(); + this.draw(); + } + + /** @param {MouseEvent} e */ + onDblClick(e) { + if (!this.zoomable) return; + e.preventDefault(); + this.resetZoom(); + } +} diff --git a/src/deeperfly/gui/web/static/scene3d.js b/src/deeperfly/gui/web/static/scene3d.js new file mode 100644 index 0000000..17b34d1 --- /dev/null +++ b/src/deeperfly/gui/web/static/scene3d.js @@ -0,0 +1,315 @@ +// @ts-check +// A small, dependency-free 3D plot of the camera rig (and the current frame's +// triangulated pose) on a . It exists so the operator can see where each +// camera sits and which way it looks -- shown on demand in a modal so it never +// crowds the editor. +// +// There is no 3D engine: world points are projected by a hand-rolled orbit +// camera (yaw/pitch/distance around a target) with a simple perspective divide, +// which is plenty for a schematic. Drag to orbit, wheel to zoom, double-click to +// reframe. Each camera is drawn as an RGB axis triad at its centre (x/right=red, +// y/down=green, z/optical=blue) -- the same schematic the bundle-adjustment +// notebook uses -- labelled with its name; the pose is the palette-coloured skeleton. +// +// This .js is the source -- no build step; VS Code type-checks it via `// @ts-check`. + +/** @typedef {import("./types.js").Camera3D} Camera3D */ +/** @typedef {import("./types.js").Point3} Point3 */ + +/** @typedef {[number, number, number]} Vec3 */ + +const WORLD_UP = /** @type {Vec3} */ ([0, 0, 1]); +const ORBIT_RATE = 0.01; // radians of orbit per pixel dragged +const WHEEL_ZOOM_RATE = 0.0015; // wheel delta -> distance factor +const PITCH_LIMIT = (Math.PI / 2) * 0.98; // clamp to avoid the gimbal pole +const NEAR = 1e-3; // points at/behind the eye are clipped + +const sub = (/** @type {Vec3} */ a, /** @type {Vec3} */ b) => + /** @type {Vec3} */ ([a[0] - b[0], a[1] - b[1], a[2] - b[2]]); +const add = (/** @type {Vec3} */ a, /** @type {Vec3} */ b) => + /** @type {Vec3} */ ([a[0] + b[0], a[1] + b[1], a[2] + b[2]]); +const scale = (/** @type {Vec3} */ a, /** @type {number} */ s) => + /** @type {Vec3} */ ([a[0] * s, a[1] * s, a[2] * s]); +const dot = (/** @type {Vec3} */ a, /** @type {Vec3} */ b) => + a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; +const cross = (/** @type {Vec3} */ a, /** @type {Vec3} */ b) => + /** @type {Vec3} */ ([ + a[1] * b[2] - a[2] * b[1], + a[2] * b[0] - a[0] * b[2], + a[0] * b[1] - a[1] * b[0], + ]); +const norm = (/** @type {Vec3} */ a) => { + const n = Math.hypot(a[0], a[1], a[2]) || 1; + return /** @type {Vec3} */ ([a[0] / n, a[1] / n, a[2] / n]); +}; + +export class Scene3D { + /** @param {HTMLCanvasElement} canvas */ + constructor(canvas) { + /** @type {Camera3D[]} */ + this.cameras = []; + /** @type {[number, number][]} */ + this.bones = []; + /** @type {string[]} */ + this.colors = []; + /** @type {Point3[] | null} */ + this.pts3d = null; + + // orbit state + this.yaw = 0.7; + this.pitch = 0.5; + this.dist = 5; + /** @type {Vec3} */ + this.target = [0, 0, 0]; + this.extent = 1; // scene radius; sizes the axis triads + the zoom range + this.focal = 1; // pixels; set per resize + + this.dragging = false; + this.lastX = 0; + this.lastY = 0; + + this.canvas = canvas; + this.ctx = /** @type {CanvasRenderingContext2D} */ (canvas.getContext("2d")); + canvas.addEventListener("pointerdown", (e) => this.onPointerDown(e)); + canvas.addEventListener("pointermove", (e) => this.onPointerMove(e)); + canvas.addEventListener("pointerup", (e) => this.onPointerUp(e)); + canvas.addEventListener("pointercancel", (e) => this.onPointerUp(e)); + canvas.addEventListener("wheel", (e) => this.onWheel(e), { passive: false }); + canvas.addEventListener("dblclick", () => this.resetView()); + new ResizeObserver(() => this.resize()).observe(canvas); + } + + /** @param {Camera3D[] | undefined} cameras */ + setCameras(cameras) { + this.cameras = cameras ?? []; // an older server may omit cameras_3d + } + + /** + * @param {[number, number][]} bones + * @param {[number, number, number][]} colors + */ + setSkeleton(bones, colors) { + this.bones = bones; + this.colors = colors.map(([r, g, b]) => `rgb(${r},${g},${b})`); + } + + /** @param {Point3[] | null} pts */ + setPoints3d(pts) { + this.pts3d = pts; + this.draw(); + } + + // Frame the whole rig: centre on everything and back the eye off proportionally. + resetView() { + /** @type {Vec3[]} */ + const pts = this.cameras.map((c) => c.position); + for (const p of this.pts3d ?? []) if (p) pts.push(p); + if (pts.length === 0) { + this.target = [0, 0, 0]; + this.extent = 1; + } else { + /** @type {Vec3} */ + let c = [0, 0, 0]; + for (const p of pts) c = add(c, p); + this.target = scale(c, 1 / pts.length); + let r = 0; + for (const p of pts) r = Math.max(r, Math.hypot(...sub(p, this.target))); + this.extent = Math.max(r, 1e-3); + } + this.yaw = 0.7; + this.pitch = 0.5; + this.dist = this.extent * 2.4; + this.draw(); + } + + resize() { + const dpr = window.devicePixelRatio || 1; + const cssW = this.canvas.clientWidth || 1; + const cssH = this.canvas.clientHeight || 1; + this.canvas.width = Math.round(cssW * dpr); + this.canvas.height = Math.round(cssH * dpr); + this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0); // draw in CSS pixels + this.focal = 0.5 * Math.min(cssW, cssH); + this.draw(); + } + + // -- projection ------------------------------------------------------------- + + // The current eye position and orthonormal view basis from the orbit angles. + viewBasis() { + const cp = Math.cos(this.pitch); + const sp = Math.sin(this.pitch); + /** @type {Vec3} */ + const dir = [cp * Math.sin(this.yaw), cp * Math.cos(this.yaw), sp]; // target -> eye + const eye = add(this.target, scale(dir, this.dist)); + const forward = norm(scale(dir, -1)); // eye -> target + const right = norm(cross(forward, WORLD_UP)); + const up = cross(right, forward); + return { eye, forward, right, up }; + } + + /** + * @param {Vec3} p a world point + * @param {{eye: Vec3, forward: Vec3, right: Vec3, up: Vec3}} basis + * @returns {[number, number] | null} canvas px, or null if behind the eye + */ + project(p, basis) { + const rel = sub(p, basis.eye); + const z = dot(rel, basis.forward); + if (z <= NEAR) return null; + const x = dot(rel, basis.right); + const y = dot(rel, basis.up); + const cssW = this.canvas.clientWidth || 1; + const cssH = this.canvas.clientHeight || 1; + return [cssW / 2 + (x / z) * this.focal, cssH / 2 - (y / z) * this.focal]; + } + + // -- drawing ---------------------------------------------------------------- + + draw() { + const ctx = this.ctx; + const cssW = this.canvas.clientWidth || 1; + const cssH = this.canvas.clientHeight || 1; + ctx.clearRect(0, 0, cssW, cssH); + ctx.fillStyle = "#111"; + ctx.fillRect(0, 0, cssW, cssH); + if (this.cameras.length === 0) return; + const basis = this.viewBasis(); + this.drawAxes(basis); + this.drawPose(basis); + this.cameras.forEach((cam) => this.drawCamera(cam, basis)); + } + + /** @param {{eye: Vec3, forward: Vec3, right: Vec3, up: Vec3}} basis */ + drawAxes(basis) { + const ctx = this.ctx; + const len = this.extent * 0.25; + /** @type {[Vec3, string][]} */ + const axes = [ + [[len, 0, 0], "#ff5555"], + [[0, len, 0], "#55ff55"], + [[0, 0, len], "#5599ff"], + ]; + const o = this.project(this.target, basis); + if (!o) return; + ctx.lineWidth = 1.5; + for (const [axis, color] of axes) { + const tip = this.project(add(this.target, axis), basis); + if (!tip) continue; + ctx.strokeStyle = color; + ctx.beginPath(); + ctx.moveTo(o[0], o[1]); + ctx.lineTo(tip[0], tip[1]); + ctx.stroke(); + } + } + + /** @param {{eye: Vec3, forward: Vec3, right: Vec3, up: Vec3}} basis */ + drawPose(basis) { + const pts = this.pts3d; + if (!pts) return; + const ctx = this.ctx; + const screen = pts.map((p) => (p ? this.project(p, basis) : null)); + ctx.lineWidth = 2; + for (const [a, b] of this.bones) { + const sa = screen[a]; + const sb = screen[b]; + if (!sa || !sb) continue; + ctx.strokeStyle = this.colors[a] || "#fff"; + ctx.beginPath(); + ctx.moveTo(sa[0], sa[1]); + ctx.lineTo(sb[0], sb[1]); + ctx.stroke(); + } + for (let i = 0; i < screen.length; i++) { + const s = screen[i]; + if (!s) continue; + ctx.fillStyle = this.colors[i] || "#fff"; + ctx.beginPath(); + ctx.arc(s[0], s[1], 3, 0, Math.PI * 2); + ctx.fill(); + } + } + + /** + * Draw one camera as an RGB axis triad at its centre -- x/right=red, + * y/down=green, z/optical=blue (the rows of its rotation matrix, the same + * schematic the bundle-adjustment notebook uses) -- labelled with its name. + * @param {Camera3D} cam + * @param {{eye: Vec3, forward: Vec3, right: Vec3, up: Vec3}} basis + */ + drawCamera(cam, basis) { + const ctx = this.ctx; + const centre = this.project(cam.position, basis); + if (!centre) return; + // Triad length tracks each camera's distance from the target (like the + // notebook's `norm(tvec) * 0.2`), so the axes read at any rig scale. + const L = (Math.hypot(...sub(cam.position, this.target)) || this.extent) * 0.2; + // `up` is the negated image-y row, so image-down (the green axis) is -up. + /** @type {[Vec3, string][]} */ + const axes = [ + [cam.right, "#ff5b5b"], // image x (red) + [scale(cam.up, -1), "#5bff5b"], // image y, pointing down (green) + [cam.forward, "#5b9bff"], // optical axis -- the way it looks (blue) + ]; + ctx.lineWidth = 2; + for (const [axis, color] of axes) { + const tip = this.project(add(cam.position, scale(axis, L)), basis); + if (!tip) continue; + ctx.strokeStyle = color; + ctx.beginPath(); + ctx.moveTo(centre[0], centre[1]); + ctx.lineTo(tip[0], tip[1]); + ctx.stroke(); + } + ctx.fillStyle = "#ddd"; + ctx.beginPath(); + ctx.arc(centre[0], centre[1], 3, 0, Math.PI * 2); + ctx.fill(); + + ctx.fillStyle = "#fff"; + ctx.font = "12px system-ui, sans-serif"; + ctx.textBaseline = "middle"; + ctx.lineWidth = 3; + ctx.strokeStyle = "rgba(0,0,0,0.85)"; + ctx.strokeText(cam.name, centre[0] + 6, centre[1]); + ctx.fillText(cam.name, centre[0] + 6, centre[1]); + } + + // -- interaction ------------------------------------------------------------ + + /** @param {PointerEvent} e */ + onPointerDown(e) { + this.dragging = true; + this.lastX = e.clientX; + this.lastY = e.clientY; + this.canvas.setPointerCapture(e.pointerId); + } + + /** @param {PointerEvent} e */ + onPointerMove(e) { + if (!this.dragging) return; + this.yaw -= (e.clientX - this.lastX) * ORBIT_RATE; + this.pitch += (e.clientY - this.lastY) * ORBIT_RATE; + this.pitch = Math.max(-PITCH_LIMIT, Math.min(PITCH_LIMIT, this.pitch)); + this.lastX = e.clientX; + this.lastY = e.clientY; + this.draw(); + } + + /** @param {PointerEvent} e */ + onPointerUp(e) { + this.dragging = false; + if (this.canvas.hasPointerCapture(e.pointerId)) { + this.canvas.releasePointerCapture(e.pointerId); + } + } + + /** @param {WheelEvent} e */ + onWheel(e) { + e.preventDefault(); + this.dist *= Math.exp(e.deltaY * WHEEL_ZOOM_RATE); + this.dist = Math.max(this.extent * 0.2, Math.min(this.extent * 20, this.dist)); + this.draw(); + } +} diff --git a/src/deeperfly/gui/web/static/styles.css b/src/deeperfly/gui/web/static/styles.css new file mode 100644 index 0000000..f885577 --- /dev/null +++ b/src/deeperfly/gui/web/static/styles.css @@ -0,0 +1,347 @@ +:root { + color-scheme: dark; + --bg: #1e1e1e; + --panel: #2a2a2a; + --border: #3a3a3a; + --fg: #e0e0e0; +} + +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + height: 100%; +} + +body { + background: var(--bg); + color: var(--fg); + font: 13px/1.4 system-ui, -apple-system, sans-serif; +} + +#app { + display: flex; + flex-direction: column; + height: 100vh; +} + +#views { + flex: 1; + display: flex; + gap: 6px; + padding: 6px; + min-height: 0; + min-width: 0; +} + +#stage { + flex: 1; + min-width: 0; + min-height: 0; +} + +/* Grid layout: the stage holds every cell in an equal grid that fills the + viewport (grid-auto-rows: 1fr); the thumbnail strip is hidden. */ +#views.layout-grid #stage { + display: grid; + grid-template-columns: repeat(var(--cols, 1), 1fr); + grid-auto-rows: 1fr; + gap: 6px; +} + +#views.layout-grid #strip { + display: none; +} + +/* Focus layout: the stage is one large editable view; the strip is a column of + live, clickable thumbnails (the other cameras), each shrinking to share the + height so all stay visible. */ +#views.layout-focus #stage { + display: flex; +} + +#views.layout-focus #stage > .cell { + flex: 1; +} + +#views.layout-focus #stage > .cell.is-focused { + border-color: #4a90d9; +} + +#views.layout-focus #strip { + display: flex; + flex-direction: column; + gap: 6px; + flex: 0 0 clamp(140px, 18vw, 240px); + min-height: 0; + overflow-y: auto; +} + +#views.layout-focus #strip > .cell { + flex: 1 1 0; + min-height: 64px; + cursor: pointer; +} + +#views.layout-focus #strip > .cell:hover { + border-color: #5a5a5a; +} + +.cell { + display: flex; + flex-direction: column; + min-height: 0; + min-width: 0; + background: #000; + border: 1px solid var(--border); + border-radius: 4px; + overflow: hidden; +} + +.cell-label { + padding: 2px 6px; + text-align: center; + background: var(--panel); + border-bottom: 1px solid var(--border); + font-size: 12px; +} + +.cell canvas { + flex: 1; + min-height: 0; + width: 100%; + display: block; + touch-action: none; /* let pointer drags work on touch without scrolling */ +} + +#controls { + display: flex; + flex-direction: column; + align-items: stretch; + gap: 8px; + padding: 8px 12px; + background: var(--panel); + border-top: 1px solid var(--border); +} + +.control-row { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} + +#controls label, +#controls .field { + display: inline-flex; + align-items: center; + gap: 4px; +} + +/* The frame slider gets a row to itself, so let it span the full width. */ +.frame-row label { + flex: 1; +} + +.frame-row #frame-slider { + flex: 1; + width: auto; + min-width: 0; +} + +/* Two-choice "switch": a row of buttons, exactly one active -- a compact + stand-in for a 2-option dropdown (Mode, Layout). */ +.segmented { + display: inline-flex; + border: 1px solid var(--border); + border-radius: 4px; + overflow: hidden; +} + +.segmented .seg-btn { + border: none; + border-radius: 0; + background: #333; + padding: 3px 10px; +} + +.segmented .seg-btn + .seg-btn { + border-left: 1px solid var(--border); +} + +.segmented .seg-btn:not(.is-active):hover { + background: #3d3d3d; +} + +.segmented .seg-btn.is-active { + background: #4a90d9; + color: #fff; +} + +#frame-number { + width: 70px; +} + +#controls .spacer { + flex: 1; +} + +#controls .sep { + width: 1px; + align-self: stretch; + background: var(--border); +} + +#status { + color: #7ec699; + min-width: 48px; + text-align: right; +} + +select, +input, +button { + background: #333; + color: var(--fg); + border: 1px solid var(--border); + border-radius: 4px; + padding: 3px 6px; + font: inherit; +} + +button { + cursor: pointer; +} + +button:disabled { + opacity: 0.5; + cursor: default; +} + +/* On-demand modal overlays (keyboard help, camera-rig plot). */ +.overlay { + position: fixed; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.6); + z-index: 10; +} + +.overlay[hidden] { + display: none; +} + +.modal { + display: flex; + flex-direction: column; + background: var(--panel); + border: 1px solid var(--border); + border-radius: 8px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); + max-width: 90vw; + max-height: 90vh; + overflow: hidden; +} + +.modal-wide { + width: 80vw; + height: 80vh; +} + +.modal-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 10px 14px; + background: #333; + border-bottom: 1px solid var(--border); + font-weight: 600; +} + +.icon-btn { + padding: 2px 8px; + line-height: 1; +} + +.modal-body { + padding: 14px; + overflow: auto; + min-height: 0; +} + +.modal-canvas-body { + flex: 1; + padding: 0; + display: flex; + background: #111; +} + +#scene-canvas { + flex: 1; + width: 100%; + height: 100%; + display: block; + touch-action: none; +} + +.modal-foot { + padding: 8px 14px; + border-top: 1px solid var(--border); + color: #999; + text-align: center; + font-size: 12px; +} + +/* A right-aligned button row (e.g. the unsaved-changes close prompt). */ +.modal-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + padding: 12px 14px; + border-top: 1px solid var(--border); +} + +button.primary { + background: #4a90d9; + border-color: #4a90d9; + color: #fff; +} + +button.danger { + background: #c0392b; + border-color: #c0392b; + color: #fff; +} + +/* Keyboard-shortcut help table. */ +table.shortcuts { + border-collapse: collapse; + width: 100%; +} + +table.shortcuts td { + padding: 4px 10px; + vertical-align: top; +} + +table.shortcuts td.key { + text-align: right; + white-space: nowrap; + color: #9cd1ff; +} + +table.shortcuts kbd { + display: inline-block; + padding: 1px 6px; + background: #1b1b1b; + border: 1px solid var(--border); + border-radius: 4px; + font-family: ui-monospace, monospace; + font-size: 12px; +} diff --git a/src/deeperfly/gui/web/static/types.js b/src/deeperfly/gui/web/static/types.js new file mode 100644 index 0000000..3f6590c --- /dev/null +++ b/src/deeperfly/gui/web/static/types.js @@ -0,0 +1,81 @@ +// @ts-check +// Shared payload shapes exchanged with the deeperfly gui server (see +// deeperfly/gui/server.py). This file holds only JSDoc @typedef declarations -- +// there is no runtime code, and it is never imported at run time (the other +// modules reference these types via `import("./types.js")` in JSDoc, which the +// type system erases). VS Code's built-in TypeScript service uses them to +// type-check the rest of the GUI; no build step or npm is involved. + +/** @typedef {"edit_2d" | "edit_3d"} EditMode */ + +/** + * A drawn 2D point `[x, y]`, or null when the keypoint is not visible in a view. + * @typedef {[number, number] | null} Point + */ + +/** + * A world-frame 3D point `[x, y, z]`, or null when not triangulated. + * @typedef {[number, number, number] | null} Point3 + */ + +/** + * One camera's world-frame pose, for the 3D rig plot. `right`/`up`/`forward` are + * unit axes; `forward` is the optical axis (the direction it looks). + * @typedef {object} Camera3D + * @property {string} name + * @property {[number, number, number]} position camera centre in world coords + * @property {[number, number, number]} right + * @property {[number, number, number]} up + * @property {[number, number, number]} forward + */ + +/** + * One-time metadata the front-end needs to lay out and draw the editor. + * @typedef {object} Meta + * @property {string} results_path + * @property {number} n_views + * @property {number} n_frames + * @property {number} n_points + * @property {boolean} has_3d + * @property {string[]} camera_names + * @property {Record} image_sizes camera -> [height, width] + * @property {string[]} point_names + * @property {[number, number][]} bones + * @property {[number, number, number][]} point_colors 0-255 RGB, one per point + * @property {Camera3D[]} cameras_3d per-camera world poses for the rig plot + * @property {boolean} dirty + */ + +/** + * The per-view 2D overlay (and fixed mask) to draw for one frame. + * @typedef {object} PointsPayload + * @property {number} frame + * @property {EditMode} mode + * @property {Point[][]} points [view][point] + * @property {boolean[][]} fixed [view][point] + * @property {Point[][] | null} proj [view][point] latent 3D reprojection (display only), or null + * @property {boolean} dirty + */ + +/** + * The current frame's triangulated 3D keypoints, for the rig plot. + * @typedef {object} ScenePayload + * @property {number} frame + * @property {Point3[] | null} points3d one per point, or null when 2D-only + */ + +/** + * An edit sent over the WebSocket; the server dispatches on `type` and replies + * with a refreshed {@link PointsPayload}. + * @typedef {object} EditMessage + * @property {"edit_2d" | "edit_3d" | "toggle_fixed" | "reset_point" | "reset_point_view"} type + * @property {number} [view] + * @property {number} [point] + * @property {number} [x] + * @property {number} [y] + * @property {number} frame + * @property {boolean} [fix] + * @property {EditMode} mode + */ + +export {}; diff --git a/src/deeperfly/gui/window.py b/src/deeperfly/gui/window.py deleted file mode 100644 index f27f5f4..0000000 --- a/src/deeperfly/gui/window.py +++ /dev/null @@ -1,298 +0,0 @@ -"""The main editor window: a grid of camera views, a timeline, and edit modes. - -:class:`MainWindow` wires the Qt-free :class:`~deeperfly.gui.state.EditorState` -to the widgets: it lays out one :class:`~deeperfly.gui.view.PoseView` per camera, -scrubs frames, switches between View / Edit 2D / Edit 3D, and routes a drag to -the right edit. In Edit 2D a drag moves only that view's 2D point; in Edit 3D a -drag re-solves the 3D point and refreshes every view's reprojection live, and on -release pins the dragged view at the drop pixel (a finalized constraint that the -re-solve holds while the other views follow). A right-click toggles that "fixed" -flag directly (e.g. to un-pin a view). Save writes the corrections sidecar; -``results.h5`` is never modified. -""" - -from __future__ import annotations - -import logging -import math -from pathlib import Path - -from PySide6.QtCore import Qt -from PySide6.QtWidgets import ( - QComboBox, - QGridLayout, - QHBoxLayout, - QLabel, - QMainWindow, - QMessageBox, - QPushButton, - QSlider, - QSpinBox, - QVBoxLayout, - QWidget, -) - -from ..visualization._palette import point_colors_rgb -from .corrections import save_corrections -from .readers import FrameSource -from .state import EditMode, EditorState -from .view import PoseView - -__all__ = ["MainWindow"] - -log = logging.getLogger("deeperfly") - -_MODE_LABELS = [("View", EditMode.view), ("Edit 2D", EditMode.edit_2d)] -_MODE_3D = ("Edit 3D", EditMode.edit_3d) - - -class MainWindow(QMainWindow): - """The interactive viewer/corrector window.""" - - def __init__( - self, - state: EditorState, - source: FrameSource, - *, - results_path: str | Path, - corrections_path: str | Path, - parent=None, - ): - super().__init__(parent) - self._state = state - self._source = source - self._results_path = str(results_path) - self._corrections_path = Path(corrections_path) - - n_source = source.n_frames() - self._n_frames = ( - state.n_frames if n_source is None else min(state.n_frames, n_source) - ) - - self._build_ui() - self._update_title() - self._refresh_frame() - - # -- construction --------------------------------------------------------- - - def _build_ui(self) -> None: - central = QWidget() - self.setCentralWidget(central) - outer = QVBoxLayout(central) - - grid = QGridLayout() - outer.addLayout(grid, stretch=1) - - skeleton = self._state.result.skeleton - colors = point_colors_rgb(skeleton) - names = self._state.camera_names - cols = max(1, math.ceil(math.sqrt(len(names)))) - self._views: list[PoseView] = [] - for v, name in enumerate(names): - view = PoseView(v) - view.set_skeleton(skeleton.bones, colors) - view.pointDragged.connect(self._on_point_dragged) - view.pointDragging.connect(self._on_point_dragging) - view.pointFixToggled.connect(self._on_point_fix_toggled) - grid.addWidget(self._labelled(name, view), v // cols, v % cols) - self._views.append(view) - - outer.addLayout(self._build_controls()) - - def _labelled(self, name: str, view: PoseView) -> QWidget: - box = QWidget() - layout = QVBoxLayout(box) - layout.setContentsMargins(0, 0, 0, 0) - label = QLabel(name) - label.setAlignment(Qt.AlignmentFlag.AlignCenter) - layout.addWidget(label) - layout.addWidget(view, stretch=1) - return box - - def _build_controls(self) -> QHBoxLayout: - controls = QHBoxLayout() - - controls.addWidget(QLabel("Frame")) - self._slider = QSlider(Qt.Orientation.Horizontal) - self._slider.setRange(0, max(0, self._n_frames - 1)) - self._slider.valueChanged.connect(self._on_frame_changed) - controls.addWidget(self._slider, stretch=1) - - self._spin = QSpinBox() - self._spin.setRange(0, max(0, self._n_frames - 1)) - self._spin.valueChanged.connect(self._on_frame_changed) - controls.addWidget(self._spin) - self._frame_total = QLabel(f"/ {max(0, self._n_frames - 1)}") - controls.addWidget(self._frame_total) - - controls.addSpacing(16) - controls.addWidget(QLabel("Mode")) - self._mode_combo = QComboBox() - modes = list(_MODE_LABELS) - if self._state.has_3d: - modes.append(_MODE_3D) - for label, mode in modes: - self._mode_combo.addItem(label, mode) - self._mode_combo.currentIndexChanged.connect(self._on_mode_changed) - controls.addWidget(self._mode_combo) - - controls.addSpacing(16) - controls.addWidget(QLabel("Joint")) - self._joint_combo = QComboBox() - self._joint_combo.addItem("(none)", -1) - for i, pname in enumerate(self._state.result.skeleton.point_names): - self._joint_combo.addItem(pname, i) - self._joint_combo.currentIndexChanged.connect(self._on_joint_changed) - controls.addWidget(self._joint_combo) - - self._reset_btn = QPushButton("Reset joint") - self._reset_btn.clicked.connect(self._on_reset) - controls.addWidget(self._reset_btn) - - controls.addStretch(1) - self._save_btn = QPushButton("Save") - self._save_btn.clicked.connect(self._on_save) - controls.addWidget(self._save_btn) - return controls - - # -- current mode / frame ------------------------------------------------- - - @property - def _mode(self) -> EditMode: - return self._mode_combo.currentData() - - def _refresh_frame(self) -> None: - """Reload images + points for the current frame and mode.""" - t = self._state.frame - editable = self._mode in (EditMode.edit_2d, EditMode.edit_3d) - pts = self._points_for_mode() - for v, view in enumerate(self._views): - img = self._source.frame(self._state.camera_names[v], t) - if img is not None: - view.set_image(img) - view.set_points(pts[v]) - view.set_editable(editable) - self._refresh_fixed_marks() - - def _refresh_points(self) -> None: - """Repaint just the overlays (after an edit) without reloading images.""" - pts = self._points_for_mode() - for v, view in enumerate(self._views): - view.set_points(pts[v]) - self._refresh_fixed_marks() - - def _refresh_fixed_marks(self) -> None: - """Show the "fixed" rings on each view, but only in Edit 3D.""" - fixed = ( - self._state.corrections.pts2d_fixed[:, self._state.frame] - if self._mode == EditMode.edit_3d - else None - ) - for v, view in enumerate(self._views): - view.set_fixed(None if fixed is None else fixed[v]) - - def _points_for_mode(self): - if self._mode == EditMode.edit_3d: - refined = self._state.display_pts2d_refine() - if refined is not None: - return refined - return self._state.display_pts2d() - - # -- signal handlers ------------------------------------------------------ - - def _on_frame_changed(self, value: int) -> None: - if value == self._state.frame: - return - self._state.frame = value - # keep slider and spinbox in lockstep without re-entrancy - for widget in (self._slider, self._spin): - widget.blockSignals(True) - widget.setValue(value) - widget.blockSignals(False) - self._refresh_frame() - - def _on_mode_changed(self, _index: int) -> None: - self._state.mode = self._mode - self._refresh_frame() - - def _on_joint_changed(self, _index: int) -> None: - point = self._joint_combo.currentData() - highlight = None if point is None or point < 0 else int(point) - for view in self._views: - view.set_highlight(highlight) - - def _on_point_dragging(self, view: int, point: int, x: float, y: float) -> None: - """Live 3D re-solve mid-drag so every view's reprojection follows the cursor. - - Only meaningful in Edit 3D (a 2D edit is local to its own view, already - updated by the drag itself). Re-solving the 3D point and reprojecting on - each mouse-move keeps all the other views in sync as the point moves. - """ - if self._mode != EditMode.edit_3d: - return - self._state.apply_3d_edit(view, point, (x, y)) - self._refresh_points() - - def _on_point_dragged(self, view: int, point: int, x: float, y: float) -> None: - if self._mode == EditMode.edit_2d: - self._state.apply_2d_edit(view, point, (x, y)) - elif self._mode == EditMode.edit_3d: - # Dropping pins the dragged view at the release pixel so it stays put - # (does not snap to the constrained reprojection) and constrains the - # 3D solve; the live mid-drag re-solve above does not pin. - self._state.apply_3d_edit(view, point, (x, y), fix=True) - # Repaint from state: a no-op 3D edit (NaN point) snaps the marker back. - self._refresh_points() - self._update_title() - - def _on_point_fix_toggled(self, view: int, point: int) -> None: - """Right-click in Edit 3D: finalize/unfinalize this view's point. - - Toggling the fixed flag re-solves the 3D point from the fixed views, so - the non-fixed views' reprojections move; the ring updates via the refresh. - """ - if self._mode != EditMode.edit_3d: - return - self._state.toggle_fixed(view, point) - self._refresh_points() - self._update_title() - - def _on_reset(self) -> None: - point = self._joint_combo.currentData() - if point is None or point < 0: - return - self._state.reset_point(int(point)) - self._refresh_points() - self._update_title() - - def _on_save(self) -> None: - save_corrections( - self._corrections_path, self._state.corrections, source=self._results_path - ) - self.statusBar().showMessage(f"saved {self._corrections_path}", 5000) - self._update_title() - - # -- misc ----------------------------------------------------------------- - - def _update_title(self) -> None: - mark = " *" if self._state.dirty else "" - self.setWindowTitle(f"deeperfly gui — {self._results_path}{mark}") - self._save_btn.setEnabled(self._state.dirty) - - def closeEvent(self, event) -> None: # noqa: N802 -- Qt override - if self._state.dirty: - choice = QMessageBox.question( - self, - "Unsaved corrections", - "Save corrections before closing?", - QMessageBox.StandardButton.Save - | QMessageBox.StandardButton.Discard - | QMessageBox.StandardButton.Cancel, - ) - if choice == QMessageBox.StandardButton.Cancel: - event.ignore() - return - if choice == QMessageBox.StandardButton.Save: - self._on_save() - self._source.close() - super().closeEvent(event) diff --git a/tests/test_gui.py b/tests/test_gui.py index aa79d79..8a8247c 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -1,21 +1,18 @@ -"""Tests for the optional GUI's Qt-free core and (headless) widgets. +"""Tests for the GUI's editor core: state, corrections sidecar, footage resolution. The editor logic -- :class:`EditorState`, the corrections sidecar, footage -resolution -- carries no Qt dependency and is tested directly. The widget tests -are guarded by ``pytest.importorskip("PySide6")`` and run on the ``offscreen`` -Qt platform (set below), so they work in CI without a display. +resolution -- carries no web/Qt dependency and is tested directly here. The +FastAPI server that drives it over HTTP/WebSocket is tested in +``test_gui_server.py``. """ from __future__ import annotations -import os - import numpy as np import pytest from deeperfly.gui import ( Corrections, - EditMode, EditorState, load_corrections, resolve_footage, @@ -86,6 +83,16 @@ def test_reset_point_clears_corrections(result): assert not state.corrections.pts3d_edited[0, 4] +def test_reset_point_view_clears_only_that_view(result): + state = EditorState.from_result(result) + state.apply_2d_edit(0, 4, (1.0, 2.0), frame=0) + state.apply_2d_edit(1, 4, (3.0, 4.0), frame=0) + + state.reset_point_view(0, 4, frame=0) + assert not state.corrections.pts2d_edited[0, 0, 4] # reverted + assert state.corrections.pts2d_edited[1, 0, 4] # the other view is left alone + + # -- EditorState: 3D refinement (fixed/finalized constraints) ----------------- @@ -328,232 +335,3 @@ def test_corrections_empty_shapes(result): assert not corr.pts2d_edited.any() assert not corr.pts2d_fixed.any() assert not corr.any_edits - - -# -- headless widgets --------------------------------------------------------- - - -@pytest.fixture -def qapp(): - # The offscreen platform must be selected before the QApplication is created. - os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") - qtwidgets = pytest.importorskip("PySide6.QtWidgets") - return qtwidgets.QApplication.instance() or qtwidgets.QApplication([]) - - -def _blank_source(result): - from helpers import HEIGHT, WIDTH - - from deeperfly.gui.readers import FrameSource - - image_sizes = {name: (HEIGHT, WIDTH) for name in result.cameras.names} - return FrameSource({}, image_sizes=image_sizes) - - -def test_window_builds_with_blank_frames(qapp, result, tmp_path): - from deeperfly.gui.window import MainWindow - - source = _blank_source(result) - state = EditorState.from_result(result) - window = MainWindow( - state, - source, - results_path=str(tmp_path / "results.h5"), - corrections_path=tmp_path / "corrections.h5", - ) - assert len(window._views) == result.n_views - state.corrections.dirty = False # avoid the unsaved-changes dialog on close - window.close() - - -def test_window_2d_drag_updates_state(qapp, result, tmp_path): - from deeperfly.gui.window import MainWindow - - source = _blank_source(result) - state = EditorState.from_result(result) - window = MainWindow( - state, - source, - results_path=str(tmp_path / "results.h5"), - corrections_path=tmp_path / "corrections.h5", - ) - window._mode_combo.setCurrentIndex(window._mode_combo.findData(EditMode.edit_2d)) - window._views[0].pointDragged.emit(0, 2, 100.0, 50.0) - - assert state.dirty - assert np.allclose(state.display_pts2d(0)[0, 2], [100.0, 50.0]) - - state.corrections.dirty = False - window.close() - - -def test_set_points_keeps_a_writable_array(qapp): - # Projected 3D points arrive as a (read-only) JAX buffer; the view must hold a - # writable copy so a drag can write the dragged joint straight into it. - import jax.numpy as jnp - - from deeperfly.gui.view import PoseView - - view = PoseView(0) - pts = jnp.asarray(np.zeros((5, 2))) # read-only - assert not np.asarray(pts).flags.writeable - view.set_points(pts) - assert view._pts.flags.writeable - view._pts[0] = [1.0, 2.0] # would raise "assignment destination is read-only" - assert np.allclose(view._pts[0], [1.0, 2.0]) - - -def test_3d_drag_live_updates_every_view(qapp, result, tmp_path): - from deeperfly.gui.window import MainWindow - - source = _blank_source(result) - state = EditorState.from_result(result) - window = MainWindow( - state, - source, - results_path=str(tmp_path / "results.h5"), - corrections_path=tmp_path / "corrections.h5", - ) - window._mode_combo.setCurrentIndex(window._mode_combo.findData(EditMode.edit_3d)) - - point = 5 - before = [np.array(v._pts[point]) for v in window._views] - target = before[0] + np.array([10.0, -7.0]) - # A mid-drag move (not a release) must already move every view's reprojection. - window._views[0].pointDragging.emit(0, point, float(target[0]), float(target[1])) - after = [np.array(v._pts[point]) for v in window._views] - - assert np.allclose(after[0], target, atol=1e-3) # dragged view lands on cursor - assert any( # at least one other view followed live - not np.allclose(before[i], after[i]) for i in range(1, len(window._views)) - ) - assert state.dirty - - state.corrections.dirty = False - window.close() - - -def test_window_right_click_fixes_point_in_edit_3d(qapp, result, tmp_path): - from deeperfly.gui.window import MainWindow - - source = _blank_source(result) - state = EditorState.from_result(result) - window = MainWindow( - state, - source, - results_path=str(tmp_path / "results.h5"), - corrections_path=tmp_path / "corrections.h5", - ) - window._mode_combo.setCurrentIndex(window._mode_combo.findData(EditMode.edit_3d)) - - point = 5 - window._views[1].pointFixToggled.emit(1, point) - assert state.corrections.pts2d_fixed[1, state.frame, point] - # the ring shows on that view - assert window._views[1]._fixed is not None and window._views[1]._fixed[point] - # other views are not marked - assert not window._views[0]._fixed[point] - - # a right-click outside Edit 3D is ignored - window._mode_combo.setCurrentIndex(window._mode_combo.findData(EditMode.edit_2d)) - window._views[2].pointFixToggled.emit(2, point) - assert not state.corrections.pts2d_fixed[2, state.frame, point] - - state.corrections.dirty = False - window.close() - - -def test_dragged_joint_follows_cursor_despite_fixed_constraint(qapp, result, tmp_path): - # Regression: while dragging, the dragged joint must track the mouse exactly. - # With another view fixed, the constrained 3D re-solve reprojects the dragged - # joint a hair off the cursor; that live update must not overwrite the - # cursor-pinned joint mid-drag (it would feel like resistance). - from deeperfly.gui.window import MainWindow - - source = _blank_source(result) - state = EditorState.from_result(result) - window = MainWindow( - state, - source, - results_path=str(tmp_path / "results.h5"), - corrections_path=tmp_path / "corrections.h5", - ) - window._mode_combo.setCurrentIndex(window._mode_combo.findData(EditMode.edit_3d)) - - point = 5 - # Fix view 1 so view 0's drag is genuinely constrained (2-observation DLT). - window._views[1].pointFixToggled.emit(1, point) - - view0 = window._views[0] - target = np.array(view0._pts[point]) + np.array([12.0, -9.0]) - # Reproduce the state a real mouseMoveEvent sets up before emitting. - view0._dragging = point - view0._pts[point] = target - view0.pointDragging.emit(0, point, float(target[0]), float(target[1])) - - # The dragged joint stays exactly under the cursor (no resistance) even though - # the constrained reprojection for view 0 lands elsewhere. - assert np.allclose(view0._pts[point], target, atol=1e-6) - proj = state.display_pts3d_projected()[0, point] - assert not np.allclose(proj, target, atol=1e-3) # the model disagrees, as expected - - view0._dragging = None - state.corrections.dirty = False - window.close() - - -def test_3d_drag_release_pins_view_in_window(qapp, result, tmp_path): - # End-to-end: a real press/drag/release in Edit 3D pins the dropped view at - # the cursor (fixed flag + ring) so it does not snap, even with another view - # already fixed to constrain the solve. - from deeperfly.gui.window import MainWindow - - source = _blank_source(result) - state = EditorState.from_result(result) - window = MainWindow( - state, - source, - results_path=str(tmp_path / "results.h5"), - corrections_path=tmp_path / "corrections.h5", - ) - window._mode_combo.setCurrentIndex(window._mode_combo.findData(EditMode.edit_3d)) - - point = 5 - window._views[0].pointFixToggled.emit(0, point) # a constraining fixed view - - view2 = window._views[2] - target = np.array(view2._pts[point]) + np.array([13.0, -10.0]) - # Reproduce the events a real mouse drag emits: live moves, then a release - # (mouseReleaseEvent clears _dragging before emitting pointDragged). - view2._dragging = point - view2._pts[point] = target - view2.pointDragging.emit(2, point, float(target[0]), float(target[1])) - view2._dragging = None - view2.pointDragged.emit(2, point, float(target[0]), float(target[1])) - - assert state.corrections.pts2d_fixed[2, state.frame, point] # pinned - assert view2._fixed is not None and view2._fixed[point] # ring shows - assert np.allclose(view2._pts[point], target, atol=1e-4) # no snap - - state.corrections.dirty = False - window.close() - - -def test_window_save_writes_sidecar(qapp, result, tmp_path): - from deeperfly.gui.window import MainWindow - - source = _blank_source(result) - state = EditorState.from_result(result) - corrections_path = tmp_path / "corrections.h5" - window = MainWindow( - state, - source, - results_path=str(tmp_path / "results.h5"), - corrections_path=corrections_path, - ) - state.apply_2d_edit(0, 1, (3.0, 4.0), frame=0) - window._on_save() - - assert corrections_path.exists() - assert not state.dirty - window.close() # not dirty -> no dialog diff --git a/tests/test_gui_server.py b/tests/test_gui_server.py new file mode 100644 index 0000000..198e575 --- /dev/null +++ b/tests/test_gui_server.py @@ -0,0 +1,305 @@ +"""Tests for the web GUI server (:mod:`deeperfly.gui.server`), driven in-process. + +FastAPI's ``TestClient`` exercises the HTTP API and the edit WebSocket against a +:class:`~deeperfly.gui.session.Session` built on the synthetic 7-camera fixture +with blank frames (no footage), so there is no real server, browser, or video +decoding involved. The edit ops themselves are covered in ``test_gui.py``; these +tests check the request/response wiring on top of them. +""" + +from __future__ import annotations + +import socket +import threading +import time + +import numpy as np +import pytest +import uvicorn +from fastapi.testclient import TestClient +from helpers import HEIGHT, WIDTH +from websockets.sync.client import connect as ws_connect + +from deeperfly.gui import EditorState, FrameSource, Session +from deeperfly.gui.server import create_app + + +@pytest.fixture +def session(result, tmp_path): + image_sizes = {name: (HEIGHT, WIDTH) for name in result.cameras.names} + source = FrameSource({}, image_sizes=image_sizes) # blank frames, no footage + state = EditorState.from_result(result) + return Session.build( + state, + source, + results_path=str(tmp_path / "results.h5"), + corrections_path=tmp_path / "corrections.h5", + image_sizes=image_sizes, + ) + + +@pytest.fixture +def client(session): + return TestClient(create_app(session)) + + +# -- metadata + frames -------------------------------------------------------- + + +def test_meta_payload(client, result): + meta = client.get("/api/meta").json() + assert meta["n_views"] == result.n_views + assert meta["n_frames"] == result.n_frames + assert meta["n_points"] == result.pts2d.shape[2] + assert meta["has_3d"] is True + assert list(meta["camera_names"]) == list(result.cameras.names) + assert len(meta["point_colors"]) == result.pts2d.shape[2] + assert len(meta["bones"]) == len(result.skeleton.bones) + assert meta["dirty"] is False + + +def test_meta_cameras_3d(client, result): + meta = client.get("/api/meta").json() + cams = meta["cameras_3d"] + assert [c["name"] for c in cams] == list(result.cameras.names) + for name, cam in zip(result.cameras.names, result.cameras): + entry = next(c for c in cams if c["name"] == name) + assert np.allclose(entry["position"], cam.position, atol=1e-6) + # forward is the camera's optical axis (third row of the rotation matrix) + assert np.allclose(entry["forward"], cam.rmat[2], atol=1e-6) + # the reported axes are unit length + for axis in ("right", "up", "forward"): + assert np.isclose(np.linalg.norm(entry[axis]), 1.0, atol=1e-6) + + +def test_frame_returns_jpeg(client, result): + cam = result.cameras.names[0] + r = client.get(f"/api/frame/{cam}/0") + assert r.status_code == 200 + assert r.headers["content-type"] == "image/jpeg" + assert r.content[:2] == b"\xff\xd8" # JPEG start-of-image marker + + +def test_frame_unknown_camera_404(client): + assert client.get("/api/frame/nope/0").status_code == 404 + + +# -- points ------------------------------------------------------------------- + + +def test_points_payload_shapes(client, result): + payload = client.get("/api/points/0?mode=view").json() + assert payload["frame"] == 0 + n_points = result.pts2d.shape[2] + assert len(payload["points"]) == result.n_views + assert all(len(row) == n_points for row in payload["points"]) + assert len(payload["fixed"]) == result.n_views + assert payload["dirty"] is False + # every drawn point is either null or an [x, y] pair (NaN serializes to null) + for row in payload["points"]: + for pt in row: + assert pt is None or len(pt) == 2 + + +def test_points_payload_carries_latent_projection(client, result): + # The latent overlay (the 3D estimate reprojected into every view) ships on + # the points payload whenever the result has 3D, shaped like `points`. + payload = client.get("/api/points/0?mode=edit_2d").json() + proj = payload["proj"] + assert proj is not None + assert len(proj) == result.n_views + assert all(len(row) == result.pts2d.shape[2] for row in proj) + for row in proj: + for pt in row: + assert pt is None or len(pt) == 2 + + +def test_scene_payload_has_3d_points(client, result): + payload = client.get("/api/scene/0").json() + assert payload["frame"] == 0 + pts3d = payload["points3d"] + assert pts3d is not None + assert len(pts3d) == result.pts2d.shape[2] + for pt in pts3d: + assert pt is None or len(pt) == 3 + + +# -- edits over the websocket ------------------------------------------------- + + +def test_ws_edit_3d_updates_all_views_and_sets_dirty(client, result): + view, point = 2, 5 + base = client.get("/api/points/0?mode=edit_3d").json()["points"] + target = [base[view][point][0] + 10.0, base[view][point][1] - 8.0] + + with client.websocket_connect("/ws") as ws: + ws.send_json( + { + "type": "edit_3d", + "view": view, + "point": point, + "x": target[0], + "y": target[1], + "frame": 0, + "fix": False, + "mode": "edit_3d", + } + ) + reply = ws.receive_json() + + assert reply["dirty"] is True + # the dragged view's reprojection lands on the cursor + assert np.allclose(reply["points"][view][point], target, atol=1e-3) + # at least one other view's reprojection moved as the 3D point was re-solved + other = (view + 1) % result.n_views + assert not np.allclose(reply["points"][other][point], base[other][point]) + + +def test_ws_edit_2d_is_local_to_its_view(client): + view, point = 0, 3 + with client.websocket_connect("/ws") as ws: + ws.send_json( + { + "type": "edit_2d", + "view": view, + "point": point, + "x": 12.0, + "y": 34.0, + "frame": 1, + "mode": "edit_2d", + } + ) + reply = ws.receive_json() + assert reply["frame"] == 1 + assert np.allclose(reply["points"][view][point], [12.0, 34.0]) + assert reply["dirty"] is True + + +def test_ws_reset_point_view_reverts_one_view(client): + point = 3 + with client.websocket_connect("/ws") as ws: + for view, xy in ((0, (12.0, 34.0)), (1, (56.0, 78.0))): + ws.send_json( + {"type": "edit_2d", "view": view, "point": point, + "x": xy[0], "y": xy[1], "frame": 0, "mode": "edit_2d"} + ) + ws.receive_json() + ws.send_json( + {"type": "reset_point_view", "view": 0, "point": point, "frame": 0, "mode": "edit_2d"} + ) + reply = ws.receive_json() + # View 0 reverts off its edit; view 1's edit is untouched. + assert not np.allclose(reply["points"][0][point], [12.0, 34.0]) + assert np.allclose(reply["points"][1][point], [56.0, 78.0]) + + +def test_save_writes_sidecar_and_clears_dirty(client, session): + with client.websocket_connect("/ws") as ws: + ws.send_json( + { + "type": "edit_2d", + "view": 0, + "point": 1, + "x": 5.0, + "y": 6.0, + "frame": 0, + "mode": "edit_2d", + } + ) + ws.receive_json() + assert session.state.dirty + + resp = client.post("/api/save").json() + assert resp["dirty"] is False + assert session.corrections_path.exists() + assert not session.state.dirty + + +# -- shutdown ----------------------------------------------------------------- + + +def test_shutdown_invokes_hook(session): + # The Close button POSTs /api/shutdown; the server calls the wired-in hook + # (serve() flips uvicorn's should_exit) and replies before stopping. + calls = [] + client = TestClient(create_app(session, on_shutdown=lambda: calls.append(True))) + resp = client.post("/api/shutdown") + assert resp.status_code == 200 + assert resp.json() == {"ok": True} + assert calls == [True] + + +def test_shutdown_without_hook_is_a_noop(client): + # No hook wired in (e.g. in tests): the endpoint still answers cleanly. + assert client.post("/api/shutdown").status_code == 200 + + +# -- auto-shutdown when the browser disconnects ------------------------------- +# +# These run a real uvicorn server in a daemon thread (the in-process TestClient +# tears its event loop down with each websocket, so it can't exercise the +# grace-period timer) and drive it with a real WebSocket client. + + +def _serve(session, **create_kw): + """Run ``create_app(session, **create_kw)`` under uvicorn on a free port. + + Returns ``(server, thread, port)``. ``on_shutdown`` flips the server's + ``should_exit`` exactly as :func:`deeperfly.gui.serve` wires it. + """ + holder: dict = {} + app = create_app( + session, + on_shutdown=lambda: setattr(holder["server"], "should_exit", True), + **create_kw, + ) + with socket.socket() as s: + s.bind(("127.0.0.1", 0)) + port = s.getsockname()[1] + server = uvicorn.Server(uvicorn.Config(app, host="127.0.0.1", port=port, log_level="warning")) + holder["server"] = server + thread = threading.Thread(target=server.run, daemon=True) + thread.start() + deadline = time.monotonic() + 5 + while not server.started and time.monotonic() < deadline: + time.sleep(0.02) + assert server.started, "server did not start" + return server, thread, port + + +def test_closing_the_last_tab_stops_the_server(session): + server, thread, port = _serve(session, exit_on_disconnect=True, disconnect_grace=0.3) + with ws_connect(f"ws://127.0.0.1:{port}/ws"): + pass # a browser tab opens its socket, then closes it (the tab is closed) + thread.join(timeout=5) + assert not thread.is_alive(), "the server should stop once the last tab closes" + + +def test_refresh_reconnect_cancels_the_shutdown(session): + grace = 1.0 + server, thread, port = _serve(session, exit_on_disconnect=True, disconnect_grace=grace) + url = f"ws://127.0.0.1:{port}/ws" + + ws1 = ws_connect(url) # the page loads, holding its socket + ws1.close() # a refresh drops it... + ws2 = ws_connect(url) # ...and the reload reconnects within the grace period + time.sleep(grace * 2) # past when the (now-cancelled) shutdown would have fired + assert not server.should_exit, "a reconnect within the grace period cancels the shutdown" + assert thread.is_alive() + + ws2.close() # closing the reconnected tab finally stops the server + thread.join(timeout=5) + assert not thread.is_alive() + + +def test_keep_alive_survives_a_tab_close(session): + # exit_on_disconnect off (the --keep-alive opt-out): the server stays up. + grace = 0.2 + server, thread, port = _serve(session, exit_on_disconnect=False, disconnect_grace=grace) + with ws_connect(f"ws://127.0.0.1:{port}/ws"): + pass + time.sleep(grace * 3) + assert not server.should_exit + assert thread.is_alive() + server.should_exit = True # tidy up the daemon server + thread.join(timeout=5) diff --git a/uv.lock b/uv.lock index 8633325..1f580c7 100644 --- a/uv.lock +++ b/uv.lock @@ -16,6 +16,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, ] +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, +] + [[package]] name = "appnope" version = "0.1.4" @@ -497,6 +519,7 @@ version = "0.1.0" source = { editable = "." } dependencies = [ { name = "av" }, + { name = "fastapi" }, { name = "h5py" }, { name = "jax" }, { name = "jaxtyping" }, @@ -509,11 +532,7 @@ dependencies = [ { name = "torch" }, { name = "torchvision" }, { name = "typer" }, -] - -[package.optional-dependencies] -gui = [ - { name = "pyside6" }, + { name = "uvicorn", extra = ["standard"] }, ] [package.dev-dependencies] @@ -529,7 +548,7 @@ docs = [ { name = "ruff" }, ] test = [ - { name = "pyside6" }, + { name = "httpx" }, { name = "pytest" }, { name = "pytest-cov" }, ] @@ -537,6 +556,7 @@ test = [ [package.metadata] requires-dist = [ { name = "av", specifier = ">=13" }, + { name = "fastapi", specifier = ">=0.115" }, { name = "h5py", specifier = ">=3.11" }, { name = "jax", specifier = ">=0.10.1" }, { name = "jaxtyping", specifier = ">=0.3.10" }, @@ -544,14 +564,13 @@ requires-dist = [ { name = "numpy", specifier = ">=2.4.6" }, { name = "opencv-python-headless", specifier = ">=4.13" }, { name = "platformdirs", specifier = ">=4.0" }, - { name = "pyside6", marker = "extra == 'gui'", specifier = ">=6.6" }, { name = "rich", specifier = ">=14.0" }, { name = "scipy", specifier = ">=1.14" }, { name = "torch", specifier = ">=2.2" }, { name = "torchvision", specifier = ">=0.17" }, { name = "typer", specifier = ">=0.26" }, + { name = "uvicorn", extras = ["standard"], specifier = ">=0.30" }, ] -provides-extras = ["gui"] [package.metadata.requires-dev] dev = [ @@ -566,7 +585,7 @@ docs = [ { name = "ruff", specifier = ">=0.6" }, ] test = [ - { name = "pyside6", specifier = ">=6.6" }, + { name = "httpx", specifier = ">=0.27" }, { name = "pytest", specifier = ">=9.0.3" }, { name = "pytest-cov", specifier = ">=6.0" }, ] @@ -589,6 +608,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317, upload-time = "2025-09-01T09:48:08.5Z" }, ] +[[package]] +name = "fastapi" +version = "0.136.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/81/2d/ff8d91d7b564d464629a0fd50a4489c97fcb836ac230bf3a7269232a9b1f/fastapi-0.136.3.tar.gz", hash = "sha256:e487fae93ad408e6f47641ee4dfe389864fd7bec92e547ea8498fc13f43e83ab", size = 396410, upload-time = "2026-05-23T18:53:15.192Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/82/45359b62a067409bd929ae8a56b8ed13e5a8c8a61194b3c236920999ab83/fastapi-0.136.3-py3-none-any.whl", hash = "sha256:3d2a69bdf04b7e9f3afa292c3bc7a98816bbfafa10bc9b45f3f3700d2f761620", size = 117481, upload-time = "2026-05-23T18:53:16.924Z" }, +] + [[package]] name = "fastjsonschema" version = "2.21.2" @@ -670,6 +705,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/11/8c/c9138d881c79aa0ea9ed83cbd58d5ca75624378b38cee225dcf5c42cc91f/griffelib-2.0.2-py3-none-any.whl", hash = "sha256:925c857658fb1ba40c0772c37acbc2ab650bd794d9c1b9726922e36ea4117ea1", size = 142357, upload-time = "2026-03-27T11:34:46.275Z" }, ] +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + [[package]] name = "h5py" version = "3.16.0" @@ -705,6 +749,63 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a5/23/bb8647521d4fd770c30a76cfc6cb6a2f5495868904054e92f2394c5a78ff/h5py-3.16.0-cp313-cp313-win_arm64.whl", hash = "sha256:656f00e4d903199a1d58df06b711cf3ca632b874b4207b7dbec86185b5c8c7d4", size = 2647362, upload-time = "2026-03-06T13:48:33.411Z" }, ] +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httptools" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/e5/d471fcb0e14523fe1c3f4ba58ca52480e7bd70ad7109a3846bc75892f7fb/httptools-0.8.0.tar.gz", hash = "sha256:6b2a32f18d97e16e90827d7a819ffa8dbd8cc245fc4e1fa9d1095b54ef4bd999", size = 271342, upload-time = "2026-05-25T22:17:48.841Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/d2/c3eedaef57de65c3cc5f8dc244cf12d09c84ad258a479055aad6db23206c/httptools-0.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ed377e64805bdba4943c82717333f8f8603a13b09aff9cead2717c6c817fb168", size = 208428, upload-time = "2026-05-25T22:16:59.717Z" }, + { url = "https://files.pythonhosted.org/packages/f1/94/dfe435d90d0ef61ec0f2cc3d480eef78c59727c6c2ce039f433882f6131a/httptools-0.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9518c406d7b310f05adb1a37f80acabac40504a575d7c0da6d3e365c695ac20d", size = 113366, upload-time = "2026-05-25T22:17:00.795Z" }, + { url = "https://files.pythonhosted.org/packages/cc/d4/13025f1a56e615dcb331e0bbe2d9a1143212b58c263385fc5d2e558f5bac/httptools-0.8.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:57278e6fa0424c42a8a3e454828ab4f0aff27b40cddf9679579b98c6dce6a376", size = 464676, upload-time = "2026-05-25T22:17:02.014Z" }, + { url = "https://files.pythonhosted.org/packages/bf/95/4c1c26c0b985f8a3331682d802598f14e32dc41bf7509266eb2c04ad4801/httptools-0.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bbb8caadb2b742d293169d2b458b5c001ef70e3158704aa3d3ef9597624c5d1d", size = 464235, upload-time = "2026-05-25T22:17:03.109Z" }, + { url = "https://files.pythonhosted.org/packages/a2/82/6735be2b0ca527718c431cdb8e5f70c3862c0844a687df0f572c51e11497/httptools-0.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:52dd695b865fe96d9d2b16b64a895f3f57bf3cb064e8383cd3b5713a069e8085", size = 449809, upload-time = "2026-05-25T22:17:04.443Z" }, + { url = "https://files.pythonhosted.org/packages/b5/f9/5811c74f37a758c8a4aa3dc430375119d335947e883efc4664d8f3559a41/httptools-0.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:20b4aac66ff65f7db06a375808b78f42a94970aa22e826b3cb2b43eb09174124", size = 452174, upload-time = "2026-05-25T22:17:05.476Z" }, + { url = "https://files.pythonhosted.org/packages/cc/94/97b75870dea07b71e3ec535cebe525b08d723152e4c7d13fa887e51f4de2/httptools-0.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:a1b4c8e7a489a0d750d91894e9a8cdc295838f1924c0ca903ae993456fddec07", size = 90991, upload-time = "2026-05-25T22:17:06.75Z" }, + { url = "https://files.pythonhosted.org/packages/14/88/1d21a36da8f5cb0fa49eafd4b169eba5608d57e75bbcf61845cbc6243216/httptools-0.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:880490234c10f70a9830743097e8958d6e4b9f5a0ffc24515023afeef984054d", size = 208247, upload-time = "2026-05-25T22:17:07.843Z" }, + { url = "https://files.pythonhosted.org/packages/a5/42/cc4feea2945cb3051038f090c9b36bd5b8a9d7f5a894a506a8983e33fd1c/httptools-0.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5931891fb7b441b8a3853cf1b85c82c903defce084dd5f6771ca46e31bf862c5", size = 113064, upload-time = "2026-05-25T22:17:09.136Z" }, + { url = "https://files.pythonhosted.org/packages/e3/a6/febbb8b8db0f58b38e44ad6cb946e6a255ae49b55f2e8543408fb7501ccd/httptools-0.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b15fc622b0f869d19207c4089a501d9bcc63ca5e071ffdd2f03f922df882dcb2", size = 523851, upload-time = "2026-05-25T22:17:10.106Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e4/f90a0df0b83beff265b7e3b65f2a4cefd95792d4be0ac3e16049f2acd3c2/httptools-0.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:425f83884fd6343828d8c565f046cb72b6d19063f6924093e11bcd8e1548cd09", size = 518842, upload-time = "2026-05-25T22:17:11.218Z" }, + { url = "https://files.pythonhosted.org/packages/9e/2d/0c9ac76dd2c893841fbf6498d6acec4f2442e1b7067f6e3e316a80e494e8/httptools-0.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ef7c3c97f4311c7be57e2986629df89d49cb434dbff78eafcd48c2bff986b15a", size = 501238, upload-time = "2026-05-25T22:17:12.728Z" }, + { url = "https://files.pythonhosted.org/packages/ca/42/906adc91ae3a5fa9c59c0a2f21c139725bd7e5b41ae6acd485cd14123ebf/httptools-0.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a1afd7c9fbff0d9f5d489c4ce2768bd09c84a46ddefc7161e6aa82ae35c85745", size = 509567, upload-time = "2026-05-25T22:17:13.842Z" }, + { url = "https://files.pythonhosted.org/packages/05/0b/4240efeb672751ee5b9b380cb0e3fdc050bc05f68adc7a8aefc4fcd9a69a/httptools-0.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:cd96f29b4bab1d42fa6e3d008711c75e0f79e94e06827330160e3a304227f150", size = 90918, upload-time = "2026-05-25T22:17:15.155Z" }, + { url = "https://files.pythonhosted.org/packages/5e/e5/8cfcabc5546e8022f168be28bcdaa128a240a0befdd03b59d558b4f18bd6/httptools-0.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:614ceea8ea606848bece2338ac03b3ce5324bcb4be8dc7d377ed708012fa4db8", size = 205148, upload-time = "2026-05-25T22:17:16.333Z" }, + { url = "https://files.pythonhosted.org/packages/2a/0e/0fb14848c19a686c8062ff9067c1a48793e3224b47bc5b201535b6036fce/httptools-0.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2d689918c15a013c65ef52d9fd495d766893ab831a2c8d89f2ac5940a5df847c", size = 111368, upload-time = "2026-05-25T22:17:17.586Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1b/46f1cecf06b9bbde8e4b8c88034ac7908989e5ff7a3a388ef38392949c1f/httptools-0.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:eb3028cca2fc0a6d720e52ef61d8ebb62fcbfeb1de56874546d858d3f25a26b7", size = 486447, upload-time = "2026-05-25T22:17:18.564Z" }, + { url = "https://files.pythonhosted.org/packages/77/00/258bfc0837221f81d9725c45f9b948a6a6b2994a147a4fb66e85100c668f/httptools-0.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:88bdd940f2b5d487b4d032c6afa5489a7dc4694410d43de3c38c4fb3af0dc45d", size = 482448, upload-time = "2026-05-25T22:17:19.912Z" }, + { url = "https://files.pythonhosted.org/packages/04/ab/d1cef3b5523f4d272a70f42a776c3169a2dddfe3a54de4b2ce4a36341528/httptools-0.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6a43c9dd399758ccc0531acb0a3c4a6c299ee893ee9400e9c893b7bdcfae0681", size = 464460, upload-time = "2026-05-25T22:17:20.882Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/5d1d072442277bb2b3434e0e60690b8e8c23840ef7de8b6ea54040a536d3/httptools-0.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0770728beb05094c809b98e814edff5fef69d26ad7d21185f2f6d5884a0ba683", size = 471312, upload-time = "2026-05-25T22:17:22.085Z" }, + { url = "https://files.pythonhosted.org/packages/0d/66/b96623b27e51a68199ef4efdda0613cced9233fe3062ac74e50749c5ad37/httptools-0.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:7685df791fad561384bfb139e77fde27a1ffd93134e016f95a0db424ffbf77b1", size = 90117, upload-time = "2026-05-25T22:17:23.074Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + [[package]] name = "idna" version = "3.18" @@ -1907,6 +2008,93 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, ] +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" }, + { url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" }, + { url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" }, + { url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" }, + { url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" }, + { url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" }, + { url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" }, + { url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" }, + { url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" }, + { url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" }, + { url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" }, + { url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" }, + { url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" }, + { url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" }, + { url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, + { url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" }, + { url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" }, + { url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" }, + { url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" }, + { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, +] + [[package]] name = "pygments" version = "2.20.0" @@ -1938,54 +2126,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" }, ] -[[package]] -name = "pyside6" -version = "6.11.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pyside6-addons" }, - { name = "pyside6-essentials" }, - { name = "shiboken6" }, -] -wheels = [ - { url = "https://files.pythonhosted.org/packages/da/a6/27ba5947ed48918f7b74b7c43a1e280aac069e36f25adeb4c9adfac835c4/pyside6-6.11.1-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:537682c3b7530817203e667c1f5a2f00486b37bf52c52eeab438544c7a0917f6", size = 571921, upload-time = "2026-05-13T09:47:36.402Z" }, - { url = "https://files.pythonhosted.org/packages/d8/de/af89d71410c83b10654d86ff9aff2a4f87c30163658f1cc145242e222526/pyside6-6.11.1-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:b1fc521ba2bb5109425ab8add06bddbdd524abcad06cfa012cc39a22a189feb2", size = 572102, upload-time = "2026-05-13T09:47:38.249Z" }, - { url = "https://files.pythonhosted.org/packages/b6/0e/d583bd3f7bf5046a4497b36f3902cfb64aa29554489a5a25c18e6b4ac0ac/pyside6-6.11.1-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:75f0005c3eb95c07cfb65522ec50d0815ac007a96482c21dc3cb4b4c04895d84", size = 572098, upload-time = "2026-05-13T09:47:39.44Z" }, - { url = "https://files.pythonhosted.org/packages/57/f2/d9d8ce1373dabb37e5919f63cd18446556079631d3f2eea3ada03c29f6b8/pyside6-6.11.1-cp310-abi3-win_amd64.whl", hash = "sha256:0968877ab1fb4ef3587a284da6fe05e8647ada56a6a3750b6395188e01f4aba6", size = 578377, upload-time = "2026-05-13T09:47:40.76Z" }, - { url = "https://files.pythonhosted.org/packages/96/02/a6057d8bd2bdb1940820fff2d627fdf4013148c9c57adf69fa40d3452ac3/pyside6-6.11.1-cp310-abi3-win_arm64.whl", hash = "sha256:acee467cb5f256cc47ebb9d815a054c1d8416da380c191b247a76d164aa3f805", size = 561765, upload-time = "2026-05-13T09:47:41.9Z" }, -] - -[[package]] -name = "pyside6-addons" -version = "6.11.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pyside6-essentials" }, - { name = "shiboken6" }, -] -wheels = [ - { url = "https://files.pythonhosted.org/packages/3f/6b/8bc94aff48b63f788f2d84e5467c12362d68906ba742c0942f46cb04c879/pyside6_addons-6.11.1-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:54733c77f789bef5f03c6aff4ad3bec8b2eff021f0cfcbc53d5e6c250ded24f9", size = 331714589, upload-time = "2026-05-13T09:39:12.36Z" }, - { url = "https://files.pythonhosted.org/packages/dd/62/fb1428a523b2a4541e232aab50d9e789e6b4526f37fd9593452a7ea5b6b3/pyside6_addons-6.11.1-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:8e6c65fbd73a512d6f72cda8d8277444a85a34dc99dd1dae9c21d35b8671bb1f", size = 175063224, upload-time = "2026-05-13T09:39:34.185Z" }, - { url = "https://files.pythonhosted.org/packages/ee/9b/2ccd52f66db55c06de65d0501170a1935d04d64d0a230c0d892284a02ce3/pyside6_addons-6.11.1-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:bf1c6c4e954e5eba3d2a7c661ad4b9689e8f09c7f4a16bdf29713371d11af993", size = 170553429, upload-time = "2026-05-13T09:39:54.424Z" }, - { url = "https://files.pythonhosted.org/packages/9a/bd/8adc4d350b3b363f3dfc8fccdcf5bfed25f7e36c2fff30c64e106f4f1572/pyside6_addons-6.11.1-cp310-abi3-win_amd64.whl", hash = "sha256:0d13c4dfd671b050a48e4f8d8ddc724b7248f9c0437e7fc47fdf316278572923", size = 168816308, upload-time = "2026-05-13T09:40:13.541Z" }, - { url = "https://files.pythonhosted.org/packages/65/b7/9a840d97f0f0f04e372a87e205dd30ee285b4e3b021b188459a917c9dc76/pyside6_addons-6.11.1-cp310-abi3-win_arm64.whl", hash = "sha256:3494f480dee92f415be2f2d989c0b3f4755ac332b28045cbf4ba0f5c5a22ba37", size = 35759347, upload-time = "2026-05-13T09:40:21.199Z" }, -] - -[[package]] -name = "pyside6-essentials" -version = "6.11.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "shiboken6" }, -] -wheels = [ - { url = "https://files.pythonhosted.org/packages/b3/da/10d9197e7370eb4fed8df5fc547b7548dec88e5c5949e2d450db4ae96feb/pyside6_essentials-6.11.1-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:228de53c2bc26b07e5021fbe3614fc44ca08e4dab9999af08c2b389d2c239957", size = 110352945, upload-time = "2026-05-13T09:43:08.006Z" }, - { url = "https://files.pythonhosted.org/packages/5c/49/0e1237c4400bec7e335d2c4eeb49bc40d9fd88a9ac44ca9083ce1abdc308/pyside6_essentials-6.11.1-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:e3ef7027b41e4e55fadb56e3b3257dc8ee92154b639fe67fc4c8e05e9d976c60", size = 79908535, upload-time = "2026-05-13T09:43:24.836Z" }, - { url = "https://files.pythonhosted.org/packages/4c/c5/da4c5f23c6540ac5211a1f60177c8dee84b1bf40f2719479587ab8c60731/pyside6_essentials-6.11.1-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:a039b6da68a3a4b9d243217b2b98d475eed3f617159ef6be925badab53c11b0d", size = 78960051, upload-time = "2026-05-13T09:43:35.423Z" }, - { url = "https://files.pythonhosted.org/packages/64/0e/b663ecc96ca57b5c91b83b6615d6b174380b0faf30338125c26e053d6aa7/pyside6_essentials-6.11.1-cp310-abi3-win_amd64.whl", hash = "sha256:63311bd48e32c584599ab04b9ef7c324082374cd2c9fa533f978fb893bb47e40", size = 77549267, upload-time = "2026-05-13T09:43:44.92Z" }, - { url = "https://files.pythonhosted.org/packages/f1/12/eb6723faf5cb7fa581145da1c15f40d641b96e080f0491af2f1859fdeedb/pyside6_essentials-6.11.1-cp310-abi3-win_arm64.whl", hash = "sha256:11253ea52aabecefe9febddbbe78b43a824129e3af1cec98431028fba7fa954f", size = 57964512, upload-time = "2026-05-13T09:43:52.968Z" }, -] - [[package]] name = "pytest" version = "9.0.3" @@ -2028,6 +2168,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, ] +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + [[package]] name = "pyyaml" version = "6.0.3" @@ -2340,18 +2489,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, ] -[[package]] -name = "shiboken6" -version = "6.11.1" -source = { registry = "https://pypi.org/simple" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/17/f3/f2b63df0251e7cd3172ea28e32ede52739de9566bcefcd0178681538ac81/shiboken6-6.11.1-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:1a16867f103ef1c662a5f09dfed03273a9f81688b174555162c58e83650a3f02", size = 476874, upload-time = "2026-05-13T09:47:01.091Z" }, - { url = "https://files.pythonhosted.org/packages/c7/9b/e0355d8897b5c150770f1d95718aad17d432fcc9c035c04f3f58427d4693/shiboken6-6.11.1-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9a8bccfafc8805254cabcfa1edfaf55cd52889f4998c91ad0d9a4433fb1bcdbe", size = 272222, upload-time = "2026-05-13T09:47:02.653Z" }, - { url = "https://files.pythonhosted.org/packages/57/d5/dd4f1defed400be03340f2ede34b61f846776650b4e7ed9ebaf4c71979a2/shiboken6-6.11.1-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:1bd2f4314414df2d122d9f646e03b731bc6d6b5f77a5f53f99a4fe4e97d84e6f", size = 270350, upload-time = "2026-05-13T09:47:04.02Z" }, - { url = "https://files.pythonhosted.org/packages/52/b5/3f6fb2ee65b534193fb4ef713dd619dc31dadff5d12c16979a7699ad58be/shiboken6-6.11.1-cp310-abi3-win_amd64.whl", hash = "sha256:c2c6863aa80ec18c0f82cea3417837b279cdc60024ac17123461dc9042577df7", size = 1223647, upload-time = "2026-05-13T09:47:05.924Z" }, - { url = "https://files.pythonhosted.org/packages/98/d1/f15ca0e1666faae02c945f48e745ea35f8fcd8243b176109b4e2c4251f47/shiboken6-6.11.1-cp310-abi3-win_arm64.whl", hash = "sha256:7c8d9af17db4495d4fa5b1c393f218311c4855546b9dfa6a0bd21bcd66b55e9d", size = 1784170, upload-time = "2026-05-13T09:47:07.617Z" }, -] - [[package]] name = "six" version = "1.17.0" @@ -2384,6 +2521,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695", size = 24521, upload-time = "2023-09-30T13:58:03.53Z" }, ] +[[package]] +name = "starlette" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" }, +] + [[package]] name = "sympy" version = "1.14.0" @@ -2577,6 +2727,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, ] +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + [[package]] name = "urllib3" version = "2.7.0" @@ -2586,6 +2748,56 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, ] +[[package]] +name = "uvicorn" +version = "0.49.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c4/1f/fa18009dea8469069cca78a4e877a008ab78f08b064bfc9ab891579077ff/uvicorn-0.49.0.tar.gz", hash = "sha256:ebf4271aa580d9de97f93192d4595176df6e91f9aae919ca73e4fc07df1e66a3", size = 91284, upload-time = "2026-06-03T22:01:30.448Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/fa/e1388bbcf24ef3274f45c0c1c7b501fd14971037c1b6ee23610553307497/uvicorn-0.49.0-py3-none-any.whl", hash = "sha256:ba3d14c3ee7e41c6c654c46c9eb489d33213cdd30aa1696eab1374337c13f68f", size = 71376, upload-time = "2026-06-03T22:01:29.037Z" }, +] + +[package.optional-dependencies] +standard = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "httptools" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" }, + { name = "watchfiles" }, + { name = "websockets" }, +] + +[[package]] +name = "uvloop" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/d5/69900f7883235562f1f50d8184bb7dd84a2fb61e9ec63f3782546fdbd057/uvloop-0.22.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c60ebcd36f7b240b30788554b6f0782454826a0ed765d8430652621b5de674b9", size = 1352420, upload-time = "2025-10-16T22:16:21.187Z" }, + { url = "https://files.pythonhosted.org/packages/a8/73/c4e271b3bce59724e291465cc936c37758886a4868787da0278b3b56b905/uvloop-0.22.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b7f102bf3cb1995cfeaee9321105e8f5da76fdb104cdad8986f85461a1b7b77", size = 748677, upload-time = "2025-10-16T22:16:22.558Z" }, + { url = "https://files.pythonhosted.org/packages/86/94/9fb7fad2f824d25f8ecac0d70b94d0d48107ad5ece03769a9c543444f78a/uvloop-0.22.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53c85520781d84a4b8b230e24a5af5b0778efdb39142b424990ff1ef7c48ba21", size = 3753819, upload-time = "2025-10-16T22:16:23.903Z" }, + { url = "https://files.pythonhosted.org/packages/74/4f/256aca690709e9b008b7108bc85fba619a2bc37c6d80743d18abad16ee09/uvloop-0.22.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56a2d1fae65fd82197cb8c53c367310b3eabe1bbb9fb5a04d28e3e3520e4f702", size = 3804529, upload-time = "2025-10-16T22:16:25.246Z" }, + { url = "https://files.pythonhosted.org/packages/7f/74/03c05ae4737e871923d21a76fe28b6aad57f5c03b6e6bfcfa5ad616013e4/uvloop-0.22.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:40631b049d5972c6755b06d0bfe8233b1bd9a8a6392d9d1c45c10b6f9e9b2733", size = 3621267, upload-time = "2025-10-16T22:16:26.819Z" }, + { url = "https://files.pythonhosted.org/packages/75/be/f8e590fe61d18b4a92070905497aec4c0e64ae1761498cad09023f3f4b3e/uvloop-0.22.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:535cc37b3a04f6cd2c1ef65fa1d370c9a35b6695df735fcff5427323f2cd5473", size = 3723105, upload-time = "2025-10-16T22:16:28.252Z" }, + { url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" }, + { url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" }, + { url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" }, + { url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" }, + { url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" }, + { url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" }, + { url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" }, + { url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" }, + { url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" }, + { url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" }, +] + [[package]] name = "wadler-lindig" version = "0.1.7" @@ -2622,6 +2834,74 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" }, ] +[[package]] +name = "watchfiles" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/41/5e1a4bb12aac5f1493fa1bdc11154eca3b258ca4eba65d39c473fe19d8e9/watchfiles-1.2.0.tar.gz", hash = "sha256:c995fba777f1ea992f090f9236e9284cf7a5d1a0130dd5a3d82c598cacd76838", size = 108252, upload-time = "2026-05-18T04:32:04.251Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/3d/8024c801df84d1587740d0359e7fdd80afeae3d159011f3d5376dd82f18e/watchfiles-1.2.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:704fd259e332e01f9b9c178f4bce9e49027e5587cc2600eeeaf8e76e1c846201", size = 400242, upload-time = "2026-05-18T04:31:19.014Z" }, + { url = "https://files.pythonhosted.org/packages/87/5b/f4dfd45323e949984a3a7f9dc31d1cbb049921e7d98253488dda72ccdaa9/watchfiles-1.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6543cf55d170003296d185c0af981f3e1311564907e1f4e08671fc7693a890a5", size = 394562, upload-time = "2026-05-18T04:30:08.46Z" }, + { url = "https://files.pythonhosted.org/packages/98/d8/19483ef075d601c409bce8bcbb5c0f81a10876fff870400568f08ce484a1/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:89d8c2394a065ca86f5d2910ff263ae67c127e1376ccc4f9fc35c71db879f80a", size = 456611, upload-time = "2026-05-18T04:30:45.723Z" }, + { url = "https://files.pythonhosted.org/packages/b1/6a/cc81fbe7ee42f2f22e661a6e12def7807e01b14b2f39e0ff83fd373fd307/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:772b80df316480d894a0e3165fdd19cf77f5d17f9a787f94029465ad0e3529d1", size = 461379, upload-time = "2026-05-18T04:31:29.292Z" }, + { url = "https://files.pythonhosted.org/packages/b1/57/7e669002082c0a0f4fb5113bb70125f7110124b846b0a11bc5ae8e90eac1/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d158cd89df6053823533e06fb1d73c549133bff5f0396170c0e53d9559340717", size = 493556, upload-time = "2026-05-18T04:30:05.44Z" }, + { url = "https://files.pythonhosted.org/packages/45/7d/f60a2b19807b21fe8281f3a8da4f59eef0d5f96825ac4680ba2d4f2ebf91/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d516b3283a758e087841aedb8031549fb41ced08f3db10aa6d2bf32dc042525b", size = 575255, upload-time = "2026-05-18T04:30:40.568Z" }, + { url = "https://files.pythonhosted.org/packages/bd/49/77f5b5e6efbcd57482f74948ebb1b97e5c0046d6b61475042d830c84b3ff/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:53b2290c92e0506d102cd448fbc610d87079553f86caa39d67440856a8b8bba5", size = 467052, upload-time = "2026-05-18T04:31:17.942Z" }, + { url = "https://files.pythonhosted.org/packages/ee/5a/73e2959af1b97fd5d556f9a8bdba017be23ceeef731869d5eaa0a753d5a3/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a711b51aec4370d0dcda5b6c09463206f133a5759341d7744b953a7b62e1100e", size = 456858, upload-time = "2026-05-18T04:30:30.182Z" }, + { url = "https://files.pythonhosted.org/packages/50/57/1bc8c27fad7e6c19bddee15d276dbb6ab72480ec01c127afff1673aee417/watchfiles-1.2.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:e2ca07fa7d89195ec0865d3d285666286740bfa83d83e5cee204043a31ecc165", size = 467579, upload-time = "2026-05-18T04:32:15.897Z" }, + { url = "https://files.pythonhosted.org/packages/09/6c/3c2e44edba3553c5e3c3b8c8a2a6dee6b9e12ae2cf4bd2378bebf9dc3038/watchfiles-1.2.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e0618518f282c4ebff60f5e5b1247b6d91bb8b9f4476947563a1e74acc66f3c6", size = 633253, upload-time = "2026-05-18T04:31:37.123Z" }, + { url = "https://files.pythonhosted.org/packages/30/c2/d8c84a882ab39bbefcc4915ab3e91830b7a7e990c5570b0b69075aba3faf/watchfiles-1.2.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:0d191c054d0715c3c95c99df9b8dbf6fd096d8c1e021e8f212e1bd8bc444ccb5", size = 660713, upload-time = "2026-05-18T04:31:24.62Z" }, + { url = "https://files.pythonhosted.org/packages/a9/07/f97736a5fc605364fe67b25e9fa4a6965dfd4840d50c406ada507e9d735f/watchfiles-1.2.0-cp311-cp311-win32.whl", hash = "sha256:9342472aff9b093c5acd4f6d8f70ae0937964ab56542502bcf5579782da69ae8", size = 277222, upload-time = "2026-05-18T04:31:21.131Z" }, + { url = "https://files.pythonhosted.org/packages/cf/99/2b04981977fc2608afd60360d928c6aecf6b950292ca221d98f4005f6694/watchfiles-1.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:dbd6c97045dad81227c8d040173da044c1de08de64a5ea8b555da4aee1d5fa22", size = 290274, upload-time = "2026-05-18T04:31:45.966Z" }, + { url = "https://files.pythonhosted.org/packages/3c/74/f7f58a7075ee9cf612b0cfcddb78b8cd8234f0742d6f0075cf0da2dde1c6/watchfiles-1.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:57a2d9fa4fb4c2ecae57b13dfff2c7ab53e21a2ba674fe9f05506680fcdcc0d7", size = 283460, upload-time = "2026-05-18T04:31:39.126Z" }, + { url = "https://files.pythonhosted.org/packages/b8/2f/e42c992d2afda3108ea1c02acecc991b9f31d05c14adc2a7cee9ee211fc4/watchfiles-1.2.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:bc13eb17538be00c874699dc0abe4ee2bc8d50bb1166a6b9e175ef3fd7eb8f26", size = 400115, upload-time = "2026-05-18T04:32:02.06Z" }, + { url = "https://files.pythonhosted.org/packages/5f/8f/6af2ea19065c91d8b0ea3516fdfc8c0d349f407e8e9fbf4e5a17360de8ad/watchfiles-1.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2d95ddc1eb6914154253d239089900813f6a767e174b8e6a50e7fdacb7e4236c", size = 393659, upload-time = "2026-05-18T04:30:50.951Z" }, + { url = "https://files.pythonhosted.org/packages/13/01/b32a967c56fb3e3e5be3db52c3d3b87fa4513aa367d8ed1ad96d42952e5f/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f70d8b291ef6e88d19b1f297a6905ddb978888d9272b0d05e6f53309856bcfc", size = 453207, upload-time = "2026-05-18T04:31:04.231Z" }, + { url = "https://files.pythonhosted.org/packages/04/98/97557a812180338cb1abd32e1cffcc4588f59b5f23e0cb006b2ba95ba64a/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56d8641cf834c2836922899105bd3ce3d0dfc69291d52edf0b4d0436829b34c0", size = 459273, upload-time = "2026-05-18T04:31:50.377Z" }, + { url = "https://files.pythonhosted.org/packages/e8/a8/b4b08dcb7653b8087c6586f7ce649505900e866bbcfe40dc9587af02e686/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2581a94056e55d7d0a31a823ea92bf73749c489ca2285bfdc0fbe6b2bb49d50c", size = 489927, upload-time = "2026-05-18T04:31:42.485Z" }, + { url = "https://files.pythonhosted.org/packages/50/94/3dceea03545d2e5ddfd839f0ddd5e1cecbf1697b5a428d5ba11cef6af95d/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:41bc1199f7523b3f82843c88cbb979180c949caef0342cf90968f178e5d49b01", size = 570476, upload-time = "2026-05-18T04:31:03.071Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f2/d39a5450c3532092b91f81d274360e613c2371bc874a89c7a1a3c5e8d138/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7571e4464cb6e434958f867f7f730b8ab0b75e3f8e5eac0499168486ab3c33a8", size = 465650, upload-time = "2026-05-18T04:30:12.701Z" }, + { url = "https://files.pythonhosted.org/packages/22/24/ed72f68cbc1333ca9b9f2200aa048bb6658ae41709bc1caad4310f4bdffd/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e53a384f76b631c3ae5334ce6a52f0baa3a911eb94a4eac7f160079868b716d5", size = 456398, upload-time = "2026-05-18T04:30:13.784Z" }, + { url = "https://files.pythonhosted.org/packages/0d/64/982ef4a4e5bab5b6e5b6becc8cd5e732f6130a78b855f0abec6439a9a135/watchfiles-1.2.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:d20029a60a71a052a24c4db7673bc4de39ab89adbaccbfb5d67987c5d73f424d", size = 465140, upload-time = "2026-05-18T04:31:52.111Z" }, + { url = "https://files.pythonhosted.org/packages/a0/0c/95282abf4ed680b6096010bcfc30c5fa7a041fc5aa5a2ad17a2cc6c75bba/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:2cb93af48550faf1cea04c303107c8b75833de7013e57ce27d3b8d21d8d0f58c", size = 630259, upload-time = "2026-05-18T04:31:25.676Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/607c1de1530c4bdcf2cf1d1ecc2505ddba5d96bd43ba9f2b0e79876f850f/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2995c176de7692b86a2e4c58d9ec718f753150a979cb4a754e2b4ffa38e70906", size = 659859, upload-time = "2026-05-18T04:30:24.333Z" }, + { url = "https://files.pythonhosted.org/packages/fa/08/d9e2e0f9e8e6791d33aefc694ad7eefa7f901f63caff84a81ded38692f9c/watchfiles-1.2.0-cp312-cp312-win32.whl", hash = "sha256:7a2cffd17d27d2ecbb310c2b1d8174f222a5495b1a721894afa88ec11e25b898", size = 275480, upload-time = "2026-05-18T04:30:31.307Z" }, + { url = "https://files.pythonhosted.org/packages/1c/e6/9d42569c0102645cc8cea5d8c7d8a1e9d4ada2cb7f05f75e554b8aa2202a/watchfiles-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:f155b3a1b2a5fc89cdc70d47ee5d54e3b75e88efa34982028a35daef9ba00379", size = 288718, upload-time = "2026-05-18T04:32:10.745Z" }, + { url = "https://files.pythonhosted.org/packages/0a/26/88e0dc6ee3898169d7fa22bb6a69cabf2502d2ee25cb8c876d1262d204f8/watchfiles-1.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:8fa585ede612ee9f9e91b18bebf9ba11b9ae29a4e3a0d0cf6fca3e382133f0d5", size = 281026, upload-time = "2026-05-18T04:30:22.23Z" }, + { url = "https://files.pythonhosted.org/packages/d1/4d/70a7feced9f87e2ff26dba42667290f41694fc64646c67261fbb8cab5d5c/watchfiles-1.2.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:01ea8d66f0693b9b60a6541c8d10263091ca9a9060d242f3c1f3143f9aad2c98", size = 399730, upload-time = "2026-05-18T04:31:38.162Z" }, + { url = "https://files.pythonhosted.org/packages/31/3a/0da302f2307aee316922806ebd5726c542cbd787c938271cf14a074c7daf/watchfiles-1.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7ba0480b9a74af058f43b337e937a451e109295c420916d68ad24e3dc02f5e44", size = 392842, upload-time = "2026-05-18T04:30:27.051Z" }, + { url = "https://files.pythonhosted.org/packages/db/ef/d5bdb705c224dbc256aa0c1ec47bf4e61ec52558f2afb44a71a1fe4d7015/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f34e26a19f91f710c08e0183429f0d1d15df734e6bc78c31e77b9ea9c433658", size = 452989, upload-time = "2026-05-18T04:31:11.945Z" }, + { url = "https://files.pythonhosted.org/packages/71/29/5495f2c1661949ef7a35e4d71111d129cfe7606414a26887a919d0a55406/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b4e77f6a55f858504069abd35d336a637555c09bca453dde1ee1e5ada8a6a1fb", size = 458978, upload-time = "2026-05-18T04:30:52.606Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8c/7f9c07c433811c2fffd93e13fdfb7135de9aab5f2ae41be08960fa0047dc/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0cb4d80e212f116474a545c21c912b445f16bb0cef9e6a73a498164223e14e2f", size = 490248, upload-time = "2026-05-18T04:31:36.003Z" }, + { url = "https://files.pythonhosted.org/packages/3c/11/d93632febc52fbc21be90231bb7c17fd5387f46c9076fd40a5f9c2ae6910/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b974946a10af379d425e2eef5b62f5c6ebeaccf91d45eaad6f5b27ecd4f91aa0", size = 571847, upload-time = "2026-05-18T04:31:10.862Z" }, + { url = "https://files.pythonhosted.org/packages/55/b4/383173e73aabb07ad1d9c7aa859d95437ac46a6d6a1e11005facda0c9d19/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86bc13c25a8d1fcd70b51d0ce7c9b65e90de5666fcbfd3e34957cc73ee19aeb5", size = 465974, upload-time = "2026-05-18T04:30:17.006Z" }, + { url = "https://files.pythonhosted.org/packages/a7/6c/89b1a230a78f57c52dd8893adb1f92f94411721b6ec12596c56d98c74356/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca148d73dea36c9763aaa351e4d7a51780ec1584217c45276f4fe8239c768b71", size = 454782, upload-time = "2026-05-18T04:30:35.656Z" }, + { url = "https://files.pythonhosted.org/packages/24/62/1732118367cfff0a9fce3bf62ff4bfded09ef5df21d9d446b858b3f70a96/watchfiles-1.2.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:c525543d91961c6955b2636b308569e84a1d1c5f5f2932041ab9ef46422f43e3", size = 465182, upload-time = "2026-05-18T04:30:20.846Z" }, + { url = "https://files.pythonhosted.org/packages/28/96/716f7e5f51339bf22963f3345f9f27d7f3b30e2eadc597e257c881dd3c53/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:a204794696ffb8f9b10fba6f7cb5216d42f3b2b71860ccac6b6e42f5f10973b0", size = 629841, upload-time = "2026-05-18T04:31:05.397Z" }, + { url = "https://files.pythonhosted.org/packages/4c/fe/c40783950fd771ccf66ab3ec2722d188a9af1c7f96c6e811f36e40c6e03f/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:10d86db20695afe7997ac9e1717637d6714a8d0220458c33f3d2061f54cec427", size = 658028, upload-time = "2026-05-18T04:31:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/71/72/4508db1856d1d87fcbb3b63f4839bab1b5682cb0e8d224d122263c09654a/watchfiles-1.2.0-cp313-cp313-win32.whl", hash = "sha256:eb283ee99e21ad6443c8cdb06ac5b34b1308c329cbdf03fa02b445363714c799", size = 275183, upload-time = "2026-05-18T04:30:59.57Z" }, + { url = "https://files.pythonhosted.org/packages/f9/36/14b76ca57652e5cc5fd1c11f32a261292c08a0d19a00351013c2549cbfb2/watchfiles-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:a0f27f01bee51861392bb6b7c4fdb290b27d1eb194e9e28788d68102a0e898d9", size = 288059, upload-time = "2026-05-18T04:32:07.937Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8d/0a85e395398d8d20fadfe5c5d32c726eee17a519e78fb356f2cf7531bffe/watchfiles-1.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:3651aa7058595e9cfb75d35dd5ada2bf9f48a5b8a0f3562821d3e210c507e077", size = 280186, upload-time = "2026-05-18T04:31:54.484Z" }, + { url = "https://files.pythonhosted.org/packages/37/68/36db056f1fdcc5f07302f56e631774d6835bcd6fa3ace402304621d5f9e5/watchfiles-1.2.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:faea288b6f0ab1902ef08f4ca6de005dccf856c4e0c4f21b8c5fce02d90a1b08", size = 399031, upload-time = "2026-05-18T04:30:44.576Z" }, + { url = "https://files.pythonhosted.org/packages/c1/64/01a9d6f66a82a5c101ce939274106cc72759d62427e153f01edd2b9f87c2/watchfiles-1.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:01859b11fd9fbca670f4d5da00fbac282cfea9bd67a2125d8b2833a3b5617ea9", size = 391205, upload-time = "2026-05-18T04:30:25.413Z" }, + { url = "https://files.pythonhosted.org/packages/84/2c/0a44fe058cb4bb7b8ede6b6670698bbb7c0400740e378d00022189b7b31d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fff610d7bb2256a317bb1e96f0d7862c7aa8076733ee5df0fd41bbe76a24a4f4", size = 451892, upload-time = "2026-05-18T04:32:14.005Z" }, + { url = "https://files.pythonhosted.org/packages/67/a1/351e0d56cd35e6488b5c8b4fb11a809a5bc923e8fe8fed9faf8920be0c89/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b141a4891c995a039cd89e9a49e62df1dc8a559a5d1a6e4c7106d16c12777a55", size = 458867, upload-time = "2026-05-18T04:31:22.279Z" }, + { url = "https://files.pythonhosted.org/packages/d5/7d/9d09605187f1b838998624049fcf8bf47b73c1a3b76901fcac1782f62277/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f22943b7770483f6ea0721c6b11d022947a98eb0acae14694de034f4d0d38925", size = 490217, upload-time = "2026-05-18T04:31:43.657Z" }, + { url = "https://files.pythonhosted.org/packages/60/5d/a17a16eccb182f04188cd308ec24b1a71a9b5c4e7098269cf35d9fa56d02/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1bc6195825b7dcd217968bb1f801a60fd4c16e8eeab5bedc7fe917d7d5995ab4", size = 571458, upload-time = "2026-05-18T04:32:11.875Z" }, + { url = "https://files.pythonhosted.org/packages/d3/3d/4dd457062083ab1938e5dfd45032eb425cee2ac817287ca8ff4356183e5d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4a4b147f5dca2a5d325a06a832fb43f345751adfbc63204aec30e0d9ca965a2", size = 464707, upload-time = "2026-05-18T04:30:43.492Z" }, + { url = "https://files.pythonhosted.org/packages/c6/71/ea8c57b128f5383de74d0c7d2d9c57ad7c9a65a930c451bd25d524b295b7/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4543579a9bdb0c9560039b4ffddbdb39545707659fbc430ce4c10f3f68d557f9", size = 454663, upload-time = "2026-05-18T04:30:16.061Z" }, + { url = "https://files.pythonhosted.org/packages/53/fd/2e812bf938406d7db351f0703ddd3fc6c061cf30d96153a77bc79a943a44/watchfiles-1.2.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:20aa0e708b920bde876a4aa82dc7dd6ebea228a63a67cda6632c2fc87b787efa", size = 463537, upload-time = "2026-05-18T04:31:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/86/56/d17a7f1dd1bc3035f1072694a551301272f1739c2d8e319c927cb9e29b38/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:d413349d565dab74297f2a63e84a097936be69bf8f3b3801f27f380e32040f44", size = 629194, upload-time = "2026-05-18T04:31:14.141Z" }, + { url = "https://files.pythonhosted.org/packages/be/06/f1ff66bf5cae50aa4062779a0ecd0bbaf15e466195719074078947d9a17d/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f28b2725eb8cce327b9b3ab02415c853011dc55c95832fe90de6bc56f5315f72", size = 656194, upload-time = "2026-05-18T04:31:47.14Z" }, + { url = "https://files.pythonhosted.org/packages/23/f4/7513ef1e85fc4c6331b59479d6d72661fc391fbe543678052ac72c8b6c19/watchfiles-1.2.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:4674d49eb94706dfe666c069fc0a1b646ffcf920473492e209f6d5f60d3f0cc2", size = 403050, upload-time = "2026-05-18T04:30:36.753Z" }, + { url = "https://files.pythonhosted.org/packages/27/0b/a54103cfd732bb703c7a749222011a0483ef3705948dae3b203158601119/watchfiles-1.2.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:094b9b70103d4e963499bdea001ee3c2697b144cd9ae6218a62c0f89ec9e31db", size = 396629, upload-time = "2026-05-18T04:32:03.268Z" }, + { url = "https://files.pythonhosted.org/packages/5e/2c/73f31a3b893886206c3f54d73e8ad8dee58cdb2f69ad2622e0a8a9e07f4e/watchfiles-1.2.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b0ef001f8c25ad0fa9529f914c1600647ecd0f542d11c19b7894768c67b6acb7", size = 457318, upload-time = "2026-05-18T04:31:01.932Z" }, + { url = "https://files.pythonhosted.org/packages/e9/f9/45d021e4a5cc7b9dd567f7cbb06d3b75f751a690063fb6cc7ec60f4e46b7/watchfiles-1.2.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a88fc94e647bc4eec523f1caa540258eb71d14278b9daf72fa1e2658a98df0f0", size = 457771, upload-time = "2026-05-18T04:30:56.331Z" }, +] + [[package]] name = "wcwidth" version = "0.7.0" @@ -2639,3 +2919,44 @@ sdist = { url = "https://files.pythonhosted.org/packages/0b/02/ae6ceac1baeda5308 wheels = [ { url = "https://files.pythonhosted.org/packages/f4/24/2a3e3df732393fed8b3ebf2ec078f05546de641fe1b667ee316ec1dcf3b7/webencodings-0.5.1-py2.py3-none-any.whl", hash = "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78", size = 11774, upload-time = "2017-04-05T20:21:32.581Z" }, ] + +[[package]] +name = "websockets" +version = "16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/db/de907251b4ff46ae804ad0409809504153b3f30984daf82a1d84a9875830/websockets-16.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:31a52addea25187bde0797a97d6fc3d2f92b6f72a9370792d65a6e84615ac8a8", size = 177340, upload-time = "2026-01-10T09:22:34.539Z" }, + { url = "https://files.pythonhosted.org/packages/f3/fa/abe89019d8d8815c8781e90d697dec52523fb8ebe308bf11664e8de1877e/websockets-16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:417b28978cdccab24f46400586d128366313e8a96312e4b9362a4af504f3bbad", size = 175022, upload-time = "2026-01-10T09:22:36.332Z" }, + { url = "https://files.pythonhosted.org/packages/58/5d/88ea17ed1ded2079358b40d31d48abe90a73c9e5819dbcde1606e991e2ad/websockets-16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:af80d74d4edfa3cb9ed973a0a5ba2b2a549371f8a741e0800cb07becdd20f23d", size = 175319, upload-time = "2026-01-10T09:22:37.602Z" }, + { url = "https://files.pythonhosted.org/packages/d2/ae/0ee92b33087a33632f37a635e11e1d99d429d3d323329675a6022312aac2/websockets-16.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:08d7af67b64d29823fed316505a89b86705f2b7981c07848fb5e3ea3020c1abe", size = 184631, upload-time = "2026-01-10T09:22:38.789Z" }, + { url = "https://files.pythonhosted.org/packages/c8/c5/27178df583b6c5b31b29f526ba2da5e2f864ecc79c99dae630a85d68c304/websockets-16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7be95cfb0a4dae143eaed2bcba8ac23f4892d8971311f1b06f3c6b78952ee70b", size = 185870, upload-time = "2026-01-10T09:22:39.893Z" }, + { url = "https://files.pythonhosted.org/packages/87/05/536652aa84ddc1c018dbb7e2c4cbcd0db884580bf8e95aece7593fde526f/websockets-16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d6297ce39ce5c2e6feb13c1a996a2ded3b6832155fcfc920265c76f24c7cceb5", size = 185361, upload-time = "2026-01-10T09:22:41.016Z" }, + { url = "https://files.pythonhosted.org/packages/6d/e2/d5332c90da12b1e01f06fb1b85c50cfc489783076547415bf9f0a659ec19/websockets-16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1c1b30e4f497b0b354057f3467f56244c603a79c0d1dafce1d16c283c25f6e64", size = 184615, upload-time = "2026-01-10T09:22:42.442Z" }, + { url = "https://files.pythonhosted.org/packages/77/fb/d3f9576691cae9253b51555f841bc6600bf0a983a461c79500ace5a5b364/websockets-16.0-cp311-cp311-win32.whl", hash = "sha256:5f451484aeb5cafee1ccf789b1b66f535409d038c56966d6101740c1614b86c6", size = 178246, upload-time = "2026-01-10T09:22:43.654Z" }, + { url = "https://files.pythonhosted.org/packages/54/67/eaff76b3dbaf18dcddabc3b8c1dba50b483761cccff67793897945b37408/websockets-16.0-cp311-cp311-win_amd64.whl", hash = "sha256:8d7f0659570eefb578dacde98e24fb60af35350193e4f56e11190787bee77dac", size = 178684, upload-time = "2026-01-10T09:22:44.941Z" }, + { url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" }, + { url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" }, + { url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" }, + { url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" }, + { url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" }, + { url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" }, + { url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" }, + { url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" }, + { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" }, + { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" }, + { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" }, + { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" }, + { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" }, + { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" }, + { url = "https://files.pythonhosted.org/packages/72/07/c98a68571dcf256e74f1f816b8cc5eae6eb2d3d5cfa44d37f801619d9166/websockets-16.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:349f83cd6c9a415428ee1005cadb5c2c56f4389bc06a9af16103c3bc3dcc8b7d", size = 174947, upload-time = "2026-01-10T09:23:36.166Z" }, + { url = "https://files.pythonhosted.org/packages/7e/52/93e166a81e0305b33fe416338be92ae863563fe7bce446b0f687b9df5aea/websockets-16.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:4a1aba3340a8dca8db6eb5a7986157f52eb9e436b74813764241981ca4888f03", size = 175260, upload-time = "2026-01-10T09:23:37.409Z" }, + { url = "https://files.pythonhosted.org/packages/56/0c/2dbf513bafd24889d33de2ff0368190a0e69f37bcfa19009ef819fe4d507/websockets-16.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f4a32d1bd841d4bcbffdcb3d2ce50c09c3909fbead375ab28d0181af89fd04da", size = 176071, upload-time = "2026-01-10T09:23:39.158Z" }, + { url = "https://files.pythonhosted.org/packages/a5/8f/aea9c71cc92bf9b6cc0f7f70df8f0b420636b6c96ef4feee1e16f80f75dd/websockets-16.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0298d07ee155e2e9fda5be8a9042200dd2e3bb0b8a38482156576f863a9d457c", size = 176968, upload-time = "2026-01-10T09:23:41.031Z" }, + { url = "https://files.pythonhosted.org/packages/9a/3f/f70e03f40ffc9a30d817eef7da1be72ee4956ba8d7255c399a01b135902a/websockets-16.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767", size = 178735, upload-time = "2026-01-10T09:23:42.259Z" }, + { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, +] From ffd7f7bd904c33002ef59707186fbdb4515bbfe5 Mon Sep 17 00:00:00 2001 From: tlam Date: Sat, 13 Jun 2026 12:12:44 +0200 Subject: [PATCH 3/3] feat: add an "invisible"/obscured per-view keypoint state to the correction GUI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A per-view 2D keypoint in Edit 3D can now be flagged "obscured" (invisible): the camera genuinely cannot see it, so it is dropped from triangulation and its dot just follows the reprojection. Marking a view invisible re-solves the 3D point from the remaining visible views, so a bad/occluded observation stops dragging the estimate off. Invisible is mutually exclusive with fixed -- a point is at most one of normal / fixed / obscured. Interaction: select a joint, then use the new point-status widget (shows " · " and a Normal/Fixed/Obscured switch) or the l (fix) / o (obscure) keys. Dragging an obscured point un-obscures it back to normal. - corrections.py: new pts2d_invisible mask + set_invisible(); sidecar schema v3 (older sidecars load with it all-False); set_pts2d/clear_2d clear the flag. - state.py: toggle_invisible() + _resolve_3d_from_visible() (fixed views still define the point when >=2 are fixed; otherwise triangulate every non-obscured view's displayed 2D); apply_3d_edit un-obscures the dragged view. - server.py: invisible mask in the points payload + toggle_invisible dispatch. - web: status widget, l/o shortcuts, ghosted (dashed magenta) rendering, and the drag-to-un-obscure flow. Co-Authored-By: Claude Opus 4.8 --- src/deeperfly/gui/corrections.py | 45 +++++++-- src/deeperfly/gui/server.py | 6 +- src/deeperfly/gui/state.py | 57 ++++++++++++ src/deeperfly/gui/web/index.html | 4 + src/deeperfly/gui/web/static/app.js | 105 +++++++++++++++++++-- src/deeperfly/gui/web/static/poseView.js | 40 ++++++-- src/deeperfly/gui/web/static/styles.css | 17 ++++ src/deeperfly/gui/web/static/types.js | 5 +- tests/test_gui.py | 112 +++++++++++++++++++++++ tests/test_gui_server.py | 17 ++++ 10 files changed, 384 insertions(+), 24 deletions(-) diff --git a/src/deeperfly/gui/corrections.py b/src/deeperfly/gui/corrections.py index 87167d0..afd5667 100644 --- a/src/deeperfly/gui/corrections.py +++ b/src/deeperfly/gui/corrections.py @@ -8,7 +8,7 @@ NaN-valued correction is still distinguishable from "not edited"), and lets a single point be reset cleanly. -The layout (schema v2): +The layout (schema v3): .. code-block:: text @@ -18,11 +18,14 @@ edited (V, T, P) bool fixed (V, T, P) bool -- "finalized" per-view points used as 3D-refinement constraints (subset of edited) + invisible (V, T, P) bool -- "obscured" per-view points dropped from + triangulation (disjoint from edited/fixed) pose3d_corrections/ points3d (T, P, 3) edited 3D points (NaN where not edited) edited (T, P) bool -The ``fixed`` mask is new in v2; v1 sidecars (without it) load with ``fixed`` all-False. +The ``fixed`` mask is new in v2 and ``invisible`` in v3; older sidecars (without a +given mask) load with it all-False. """ from __future__ import annotations @@ -38,7 +41,7 @@ __all__ = ["Corrections", "save_corrections", "load_corrections"] -CORRECTIONS_FORMAT_VERSION = 2 +CORRECTIONS_FORMAT_VERSION = 3 @dataclass @@ -49,8 +52,11 @@ class Corrections: masks say which entries are real edits. ``pts2d_fixed`` marks the per-view 2D points the operator has "finalized" -- they stay put under further edits and act as constraints when the 3D point is re-solved (always a subset of - ``pts2d_edited``). ``dirty`` tracks unsaved in-memory changes (set on every - edit, cleared by :func:`save_corrections`). + ``pts2d_edited``). ``pts2d_invisible`` marks the per-view points the operator + has flagged "obscured" -- they are dropped from triangulation and follow the + reprojection (mutually exclusive with ``pts2d_edited``/``pts2d_fixed``). + ``dirty`` tracks unsaved in-memory changes (set on every edit, cleared by + :func:`save_corrections`). """ pts2d: Float[np.ndarray, "V T P 2"] @@ -58,6 +64,7 @@ class Corrections: pts3d: Float[np.ndarray, "T P 3"] pts3d_edited: Bool[np.ndarray, "T P"] pts2d_fixed: Bool[np.ndarray, "V T P"] + pts2d_invisible: Bool[np.ndarray, "V T P"] dirty: bool = field(default=False) @classmethod @@ -69,6 +76,7 @@ def empty(cls, n_views: int, n_frames: int, n_points: int) -> Corrections: pts3d=np.full((n_frames, n_points, 3), np.nan), pts3d_edited=np.zeros((n_frames, n_points), dtype=bool), pts2d_fixed=np.zeros((n_views, n_frames, n_points), dtype=bool), + pts2d_invisible=np.zeros((n_views, n_frames, n_points), dtype=bool), ) @property @@ -83,13 +91,30 @@ def set_pts2d( With ``fixed=True`` the point is also marked finalized (a constraint for 3D refinement); ``fixed=False`` leaves the existing fixed flag untouched. + Placing a 2D point always clears the "obscured" flag -- an edited point is, + by definition, visible. """ self.pts2d[view, frame, point] = np.asarray(xy, dtype=float) self.pts2d_edited[view, frame, point] = True + self.pts2d_invisible[view, frame, point] = False if fixed: self.pts2d_fixed[view, frame, point] = True self.dirty = True + def set_invisible(self, view: int, frame: int, point: int, value: bool) -> None: + """Flag ``point`` in ``view`` "obscured" (``value``), or clear the flag. + + An obscured point is dropped from triangulation and follows the + reprojection, so it is mutually exclusive with an edited/fixed 2D: setting + it drops any 2D edit and fixed flag for that view (back to the original). + """ + self.pts2d_invisible[view, frame, point] = value + if value: + self.pts2d[view, frame, point] = np.nan + self.pts2d_edited[view, frame, point] = False + self.pts2d_fixed[view, frame, point] = False + self.dirty = True + def set_pts3d(self, frame: int, point: int, xyz) -> None: """Record a 3D edit of ``point`` at ``frame``.""" self.pts3d[frame, point] = np.asarray(xyz, dtype=float) @@ -101,6 +126,7 @@ def clear_2d(self, view: int, frame: int, point: int) -> None: self.pts2d[view, frame, point] = np.nan self.pts2d_edited[view, frame, point] = False self.pts2d_fixed[view, frame, point] = False + self.pts2d_invisible[view, frame, point] = False self.dirty = True def clear_3d(self, frame: int, point: int) -> None: @@ -137,6 +163,7 @@ def save_corrections( g2.create_dataset("points", data=corrections.pts2d) g2.create_dataset("edited", data=corrections.pts2d_edited) g2.create_dataset("fixed", data=corrections.pts2d_fixed) + g2.create_dataset("invisible", data=corrections.pts2d_invisible) g3 = f.create_group("pose3d_corrections") g3.create_dataset("points3d", data=corrections.pts3d) g3.create_dataset("edited", data=corrections.pts3d_edited) @@ -175,11 +202,16 @@ def load_corrections( pts2d_edited = np.asarray(f["pose2d_corrections/edited"][()], dtype=bool) # type: ignore[index] pts3d = np.asarray(f["pose3d_corrections/points3d"][()], dtype=float) # type: ignore[index] pts3d_edited = np.asarray(f["pose3d_corrections/edited"][()], dtype=bool) # type: ignore[index] - # "fixed" is new in schema v2; v1 sidecars load with it all-False. + # "fixed" is new in schema v2 and "invisible" in v3; older sidecars load + # with the missing mask all-False. if "pose2d_corrections/fixed" in f: pts2d_fixed = np.asarray(f["pose2d_corrections/fixed"][()], dtype=bool) # type: ignore[index] else: pts2d_fixed = np.zeros(pts2d_edited.shape, dtype=bool) + if "pose2d_corrections/invisible" in f: + pts2d_invisible = np.asarray(f["pose2d_corrections/invisible"][()], dtype=bool) # type: ignore[index] + else: + pts2d_invisible = np.zeros(pts2d_edited.shape, dtype=bool) want2d = (n_views, n_frames, n_points, 2) want3d = (n_frames, n_points, 3) if pts2d.shape != want2d or pts3d.shape != want3d: @@ -193,5 +225,6 @@ def load_corrections( pts3d=np.asarray(pts3d, dtype=float), pts3d_edited=pts3d_edited, pts2d_fixed=pts2d_fixed, + pts2d_invisible=pts2d_invisible, dirty=False, ) diff --git a/src/deeperfly/gui/server.py b/src/deeperfly/gui/server.py index 64787df..94d12ba 100644 --- a/src/deeperfly/gui/server.py +++ b/src/deeperfly/gui/server.py @@ -230,7 +230,7 @@ def _cameras_3d(session: Session) -> list[dict]: def _points_payload(session: Session, t: int, mode: str) -> dict: - """The per-view 2D overlay (and fixed mask) to draw for frame ``t`` in ``mode``. + """The per-view 2D overlay (with the fixed/invisible masks) for frame ``t`` in ``mode``. ``proj`` is the current 3D estimate reprojected into every view (with no fixed overrides) -- the display-only "latent skeleton" the front-end can ghost over @@ -242,12 +242,14 @@ def _points_payload(session: Session, t: int, mode: str) -> dict: else: pts = s.display_pts2d(t) fixed = s.corrections.pts2d_fixed[:, t] # (V, P) + invisible = s.corrections.pts2d_invisible[:, t] # (V, P) proj = s.display_pts3d_projected(t) if s.has_3d else None return { "frame": t, "mode": mode, "points": _points_to_json(np.asarray(pts)), "fixed": fixed.tolist(), + "invisible": invisible.tolist(), "proj": None if proj is None else _points_to_json(np.asarray(proj)), "dirty": bool(s.dirty), } @@ -309,6 +311,8 @@ def _handle_edit(session: Session, msg: dict) -> dict: ) elif typ == "toggle_fixed": s.toggle_fixed(int(msg["view"]), int(msg["point"]), t) + elif typ == "toggle_invisible": + s.toggle_invisible(int(msg["view"]), int(msg["point"]), t) elif typ == "reset_point": s.reset_point(int(msg["point"]), t) elif typ == "reset_point_view": diff --git a/src/deeperfly/gui/state.py b/src/deeperfly/gui/state.py index bd74a0d..51b2964 100644 --- a/src/deeperfly/gui/state.py +++ b/src/deeperfly/gui/state.py @@ -13,6 +13,13 @@ Dropping a drag pins the dragged view there (it becomes fixed at the release pixel), so the placed point stays put instead of snapping to the reprojection. +A per-view point can instead be *invisible* (obscured): a camera that genuinely +cannot see the keypoint is dropped from the triangulation entirely and its dot +just follows the reprojection (it cannot be dragged). Marking a view invisible +re-solves the 3D point from the remaining visible views, so a bad/occluded +observation stops dragging the estimate off. Invisible is mutually exclusive with +fixed (a point is at most one of fixed / invisible / plain). + On a drag we re-solve the 3D point by a constrained DLT (:func:`deeperfly.triangulation.triangulate`) over the fixed views' locked pixels plus the dragged view's cursor; with fewer than two such observations (the common @@ -181,6 +188,9 @@ def apply_3d_edit( re-solve mid-drag uses ``fix=False`` so a view is only pinned on release (or if it was already fixed, in which case its lock follows the cursor). + Dragging an *obscured* view un-obscures it (back to the normal state) and + proceeds: the operator is placing it, so it rejoins the estimate. + Returns the new 3D point, or ``None`` if there is no 3D point to move (no triangulation, or no usable constraint and the point is NaN here). @@ -202,6 +212,10 @@ def apply_3d_edit( if self.result.pts3d is None: return None t = self._resolve_frame(frame) + if self.corrections.pts2d_invisible[view, t, point]: + # Dragging an obscured view un-obscures it: the operator is placing it, + # so it re-enters the normal flow (and contributes to the re-solve below). + self.corrections.set_invisible(view, t, point, False) xy = np.asarray(xy, dtype=float) fixed = self.corrections.pts2d_fixed[:, t, point] # (V,) @@ -280,6 +294,49 @@ def _resolve_3d_from_fixed(self, point: int, t: int) -> None: if np.all(np.isfinite(x_new)): self.corrections.set_pts3d(t, point, x_new) + def toggle_invisible( + self, view: int, point: int, frame: int | None = None + ) -> bool | None: + """Toggle whether ``point`` in ``view`` is obscured (dropped from triangulation). + + An obscured view contributes nothing to the 3D point and simply follows its + reprojection (it cannot be dragged); toggling re-solves the 3D from the + remaining visible views so the estimate updates. Setting it clears any 2D + edit / fixed flag for that view (invisible is mutually exclusive with fixed). + Returns the new invisible state, or ``None`` if there is no 3D to refine. + """ + if self.result.pts3d is None: + return None + t = self._resolve_frame(frame) + now_invisible = not bool(self.corrections.pts2d_invisible[view, t, point]) + self.corrections.set_invisible(view, t, point, now_invisible) + self._resolve_3d_from_visible(point, t) + return now_invisible + + def _resolve_3d_from_visible(self, point: int, t: int) -> None: + """Re-triangulate ``point``'s 3D location from its non-obscured views. + + With two or more fixed views the fixed pixels define the point (deferring + to :meth:`_resolve_3d_from_fixed`, which already ignores the obscured, + non-fixed views); otherwise the point is triangulated from every visible + view's displayed 2D (the detector point unless edited/fixed), with the + obscured views dropped. A no-op below two usable observations. + """ + fixed = self.corrections.pts2d_fixed[:, t, point] # (V,) + if int(fixed.sum()) >= 2: + self._resolve_3d_from_fixed(point, t) + return + invisible = self.corrections.pts2d_invisible[:, t, point] # (V,) + obs = self.display_pts2d(t)[:, point].astype(float) # (V, 2) + obs[invisible] = np.nan + if int(np.isfinite(obs).all(axis=1).sum()) < 2: + return + x_new = np.asarray( + triangulate(self.result.cameras, obs[:, None, :])[0], dtype=float + ) + if np.all(np.isfinite(x_new)): + self.corrections.set_pts3d(t, point, x_new) + def reset_point(self, point: int, frame: int | None = None) -> None: """Drop every correction (all views' 2D, the fixed flags, the 3D) of ``point``.""" t = self._resolve_frame(frame) diff --git a/src/deeperfly/gui/web/index.html b/src/deeperfly/gui/web/index.html index e44c3b6..561f99f 100644 --- a/src/deeperfly/gui/web/index.html +++ b/src/deeperfly/gui/web/index.html @@ -27,6 +27,10 @@ + diff --git a/src/deeperfly/gui/web/static/app.js b/src/deeperfly/gui/web/static/app.js index 00d0133..95b6151 100644 --- a/src/deeperfly/gui/web/static/app.js +++ b/src/deeperfly/gui/web/static/app.js @@ -2,9 +2,12 @@ // The editor controller: lays out one PoseView per camera and routes edits to // the server. It mirrors the old Qt MainWindow -- a 2D drag moves only that // view's point; a 3D drag re-solves the 3D point and refreshes every view live, -// pinning the dragged view on release; right-click (or a tap in "pin mode") -// toggles a view's fixed flag. There are two correction modes, Edit 2D and Edit -// 3D (the latter only when the result carries 3D points). +// pinning the dragged view on release; right-click (or a tap in "pin mode") toggles +// a view's fixed flag. A selected joint's per-view state (normal / fixed / obscured) +// is shown in a status widget and set by clicking it or by the `l` / `o` keys; an +// obscured view is dropped from the triangulation, and dragging it un-obscures it. +// There are two correction modes, Edit 2D and Edit 3D (the latter only when the +// result carries 3D points). // // Two layouts share the same PoseView instances. "grid" shows every camera in an // equal grid; "focus" shows one large editable view plus a strip of live, @@ -114,6 +117,12 @@ class App { /** @type {number | null} */ selectedPoint = null; selectedView = 0; + // The latest per-view fixed/invisible masks (from the points payload), so the + // status widget can report the selected joint's state. Null outside Edit 3D. + /** @type {boolean[][] | null} */ + fixedMask = null; + /** @type {boolean[][] | null} */ + invisibleMask = null; // On-demand 3D camera-rig plot (built lazily the first time it is opened). /** @type {Scene3D | null} */ scene = null; @@ -159,6 +168,12 @@ class App { pinWrap = el("pin-wrap"); /** @type {HTMLInputElement} */ pinCheck = el("pin-mode"); + /** @type {HTMLDivElement} */ + pointStatus = el("point-status"); + /** @type {HTMLSpanElement} */ + pointStatusName = el("point-status-name"); + /** @type {Segmented} */ + stateSwitch; /** @type {HTMLButtonElement} */ resetViewBtn = el("reset-view"); /** @type {HTMLButtonElement} */ @@ -251,6 +266,13 @@ class App { this.layoutSwitch.set(this.layout); el("layout-switch").append(this.layoutSwitch.root); + // The selected joint's per-view state (Edit 3D): click a chip to set it. + this.stateSwitch = segmented( + [["Normal", "normal"], ["Fixed", "fixed"], ["Obscured", "invisible"]], + (v) => this.setSelectedState(v) + ); + el("point-status-states").append(this.stateSwitch.root); + this.skeletonCheck.addEventListener("change", () => this.applySkeleton()); this.labelsCheck.addEventListener("change", () => this.applyLabels()); // The latent overlay is the reprojected 3D estimate -- meaningless without 3D. @@ -289,7 +311,7 @@ class App { /** @type {import("./poseView.js").PoseViewCallbacks} */ const cb = { onDragging: (v, p, x, y) => this.onDragging(v, p, x, y), - onDragged: (v, p, x, y) => this.onDragged(v, p, x, y), + onDragged: (v, p, x, y, wasInvisible) => this.onDragged(v, p, x, y, wasInvisible), onToggleFixed: (v, p) => this.onToggleFixed(v, p), onSelect: (v, p) => this.onSelect(v, p), onHover: (p) => this.onHover(p), @@ -391,13 +413,17 @@ class App { applyPoints(p) { if (p.frame !== this.frame) return; // a stale reply after a fast scrub const showFixed = this.mode === "edit_3d"; + this.fixedMask = showFixed ? p.fixed : null; + this.invisibleMask = showFixed ? p.invisible : null; this.views.forEach((view, v) => { view.setPoints(p.points[v]); view.setFixed(showFixed ? p.fixed[v] : null); + view.setInvisible(showFixed ? p.invisible[v] : null); view.setLatent(p.proj ? p.proj[v] : null); }); this.dirty = p.dirty; this.updateDirty(); + this.updateStatusWidget(); } /** @param {EditMode} mode */ @@ -457,6 +483,48 @@ class App { const has = this.selectedPoint !== null; this.resetViewBtn.disabled = !has; this.resetAllBtn.disabled = !has; + this.updateStatusWidget(); + } + + // -- point status widget ---------------------------------------------------- + + /** @returns {"normal" | "fixed" | "invisible"} the selected joint's state in its view */ + selectedState() { + const v = this.selectedView; + const p = this.selectedPoint; + if (p === null) return "normal"; + if (this.invisibleMask && this.invisibleMask[v][p]) return "invisible"; + if (this.fixedMask && this.fixedMask[v][p]) return "fixed"; + return "normal"; + } + + // Show the selected joint's name, its view, and its per-view state -- only in + // Edit 3D (the state has no meaning in Edit 2D). Hidden when nothing is selected. + updateStatusWidget() { + const p = this.selectedPoint; + const show = this.mode === "edit_3d" && p !== null; + this.pointStatus.hidden = !show; + if (p === null || !show) return; + const name = this.meta.point_names[p] ?? `#${p}`; + const cam = this.meta.camera_names[this.selectedView] ?? `view ${this.selectedView}`; + this.pointStatusName.textContent = `${name} · ${cam}`; + this.stateSwitch.set(this.selectedState()); + } + + // Click a state chip to set the selected joint to that state. The states are + // mutually exclusive, so one toggle takes it anywhere: toggling fixed/obscured + // sets it (clearing the other), and "normal" clears whichever flag is set. + /** @param {string} target "normal" | "fixed" | "invisible" */ + setSelectedState(target) { + if (this.mode !== "edit_3d" || this.selectedPoint === null) return; + const current = this.selectedState(); + if (target === current) return; + const v = this.selectedView; + const p = this.selectedPoint; + if (target === "fixed") this.onToggleFixed(v, p); + else if (target === "invisible") this.onToggleInvisible(v, p); + else if (current === "fixed") this.onToggleFixed(v, p); // -> normal + else if (current === "invisible") this.onToggleInvisible(v, p); // -> normal } // -- edit routing ----------------------------------------------------------- @@ -480,12 +548,14 @@ class App { * @param {number} x * @param {number} y */ - onDragged(view, point, x, y) { + onDragged(view, point, x, y, wasInvisible = false) { if (this.mode === "edit_2d") { this.socket.send({ type: "edit_2d", view, point, x, y, frame: this.frame, mode: this.mode }); } else if (this.mode === "edit_3d") { - // Releasing pins the dragged view at the drop pixel (a finalized constraint). - this.socket.send({ type: "edit_3d", view, point, x, y, frame: this.frame, fix: true, mode: this.mode }); + // Releasing pins the dragged view at the drop pixel (a finalized constraint), + // except when it was obscured: dragging un-obscures it back to the normal + // (reprojection-following) state rather than pinning it. + this.socket.send({ type: "edit_3d", view, point, x, y, frame: this.frame, fix: !wasInvisible, mode: this.mode }); } } @@ -499,6 +569,25 @@ class App { } } + /** + * @param {number} view + * @param {number} point + */ + onToggleInvisible(view, point) { + if (this.mode === "edit_3d") { + this.socket.send({ type: "toggle_invisible", view, point, frame: this.frame, mode: this.mode }); + } + } + + // Keyboard shortcuts (l / o): act on the last-selected joint in its view. + toggleSelectedFixed() { + if (this.selectedPoint !== null) this.onToggleFixed(this.selectedView, this.selectedPoint); + } + + toggleSelectedInvisible() { + if (this.selectedPoint !== null) this.onToggleInvisible(this.selectedView, this.selectedPoint); + } + // Revert the last-selected joint in just the view it was selected in. resetSelectedView() { if (this.selectedPoint === null) return; @@ -632,6 +721,8 @@ class App { if (has3d) { b.push({ key: "p", label: "p", desc: "Toggle 3D estimate overlay", run: () => this.toggleCheck(this.latentCheck, () => this.applyLatent()) }); b.push({ key: "x", label: "x", desc: "Toggle pin-on-tap (Edit 3D)", run: () => this.togglePin() }); + b.push({ key: "l", label: "l", desc: "Fix / unfix the selected point (Edit 3D)", run: () => this.toggleSelectedFixed() }); + b.push({ key: "o", label: "o", desc: "Obscure / reveal the selected point (Edit 3D)", run: () => this.toggleSelectedInvisible() }); } b.push({ key: "r", label: "r", desc: "Reset selected point in its view", run: () => this.resetSelectedView() }); b.push({ key: "R", label: "Shift+R", desc: "Reset selected point in all views", run: () => this.resetSelectedAll() }); diff --git a/src/deeperfly/gui/web/static/poseView.js b/src/deeperfly/gui/web/static/poseView.js index ae17a99..b6ca4ed 100644 --- a/src/deeperfly/gui/web/static/poseView.js +++ b/src/deeperfly/gui/web/static/poseView.js @@ -9,9 +9,10 @@ // (past a small threshold) moves it, emitting a throttled `onDragging` and a final // `onDragged` -- a click without movement just selects, so it never creates a // spurious edit. Right-click (or a tap while "pin mode" is on) toggles a point's -// fixed flag. Hovering a joint reports it via `onHover` so the app can emphasize -// the same point across every view. The app stays in control of what a drag does -// to the 3D point behind it. +// fixed flag. An "invisible" (obscured) joint is drawn ghosted; dragging it is +// allowed and reports `wasInvisible` on `onDragged` so the app can un-obscure it. +// Hovering a joint reports it via `onHover` so the app can emphasize the same point +// across every view. The app stays in control of what a drag does to the 3D point. // // Beyond the editable overlay the view can also draw two read-only extras that the // app toggles: the "latent" skeleton (the current 3D estimate reprojected and drawn @@ -26,7 +27,7 @@ /** * @typedef {object} PoseViewCallbacks * @property {(view: number, point: number, x: number, y: number) => void} onDragging - * @property {(view: number, point: number, x: number, y: number) => void} onDragged + * @property {(view: number, point: number, x: number, y: number, wasInvisible: boolean) => void} onDragged * @property {(view: number, point: number) => void} onToggleFixed * @property {(view: number, point: number) => void} onSelect a joint was clicked/grabbed * @property {(point: number | null) => void} onHover the hovered joint changed (cross-view) @@ -40,6 +41,8 @@ const MAX_ZOOM = 10; // cap on the user wheel-zoom factor over fit const WHEEL_ZOOM_RATE = 0.0015; // wheel delta -> zoom factor sensitivity const FIXED_COLOR = "#7CFC00"; // ring on a fixed (finalized) point (lime green) +const INVISIBLE_COLOR = "#ff5dd0"; // dashed ring on an invisible (obscured) point (magenta) +const INVISIBLE_FILL_ALPHA = 0.35; // an obscured joint's fill is dimmed to read as "ghosted" const SELECT_COLOR = "#3fd0ff"; // ring on the last-selected point (cyan; lime = fixed) const LATENT_COLOR = "rgba(255,176,64,0.95)"; // the latent-skeleton overlay (amber, drawn on top) @@ -63,6 +66,8 @@ export class PoseView { this.latent = null; // latent 3D reprojection (display only), drawn when latentVisible /** @type {boolean[] | null} */ this.fixed = null; + /** @type {boolean[] | null} */ + this.invisible = null; /** @type {string[]} */ this.pointNames = []; /** @type {number | null} */ @@ -76,6 +81,7 @@ export class PoseView { this.labelsVisible = false; /** @type {number | null} */ this.dragging = null; + this.dragInvisible = false; // was the grabbed joint obscured? (reported on release) this.panning = false; this.moved = false; // has the current press moved past the drag threshold? @@ -175,6 +181,12 @@ export class PoseView { this.draw(); } + /** @param {boolean[] | null} invisible per-point "obscured" mask, or null when not in 3D */ + setInvisible(invisible) { + this.invisible = invisible; + this.draw(); + } + /** @param {Point[] | null} pts the latent 3D reprojection to ghost, or null */ setLatent(pts) { this.latent = pts; @@ -298,13 +310,21 @@ export class PoseView { if (!p) continue; const [cx, cy] = this.toCanvas(p[0], p[1]); const isHover = i === this.highlight; + const isFixed = this.fixed != null && i < this.fixed.length && this.fixed[i]; + const isInvisible = this.invisible != null && i < this.invisible.length && this.invisible[i]; const r = POINT_RADIUS_PX * (isHover ? HOVER_SCALE : 1); + // An obscured joint reads as "ghosted": a dimmed fill under a dashed ring. + ctx.globalAlpha = isInvisible ? INVISIBLE_FILL_ALPHA : 1; ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI * 2); ctx.fillStyle = this.colors[i] || "#fff"; ctx.fill(); - const isFixed = this.fixed != null && i < this.fixed.length && this.fixed[i]; - if (isFixed) { + ctx.globalAlpha = 1; + if (isInvisible) { + ctx.strokeStyle = INVISIBLE_COLOR; + ctx.lineWidth = 2; + ctx.setLineDash([3, 2]); + } else if (isFixed) { ctx.strokeStyle = FIXED_COLOR; ctx.lineWidth = 2.5; } else if (isHover) { @@ -315,6 +335,7 @@ export class PoseView { ctx.lineWidth = 1; } ctx.stroke(); + ctx.setLineDash([]); // The last-selected joint gets an extra outer ring (only its own view sets // `selected`), distinct from the lime "fixed" ring so the two can coexist. if (i === this.selected) { @@ -450,8 +471,11 @@ export class PoseView { } if (e.button !== 0) return; // only the primary button drags e.preventDefault(); - this.dragging = point; this.cb.onSelect(this.viewIndex, point); // selecting happens on press, not release + // An obscured joint can still be dragged -- doing so un-obscures it (the app + // un-flags it on release via `wasInvisible`). + this.dragInvisible = this.invisible != null && !!this.invisible[point]; + this.dragging = point; this.canvas.setPointerCapture(e.pointerId); return; } @@ -528,7 +552,7 @@ export class PoseView { if (this.moved) { const [ix, iy] = this.toImage(...this.cssXY(e)); this.pts[point] = [ix, iy]; - this.cb.onDragged(this.viewIndex, point, ix, iy); + this.cb.onDragged(this.viewIndex, point, ix, iy, this.dragInvisible); } return; } diff --git a/src/deeperfly/gui/web/static/styles.css b/src/deeperfly/gui/web/static/styles.css index f885577..671763c 100644 --- a/src/deeperfly/gui/web/static/styles.css +++ b/src/deeperfly/gui/web/static/styles.css @@ -185,6 +185,23 @@ body { width: 70px; } +/* Selected-point status widget: a " · " label plus a 3-way state + switch. The active chip is colour-coded to match the on-canvas rings: Fixed is + lime, Obscured is magenta, Normal keeps the default blue. */ +.point-status-name { + color: #bbb; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +#point-status-states .seg-btn:nth-child(2).is-active { + background: #4f9e2f; +} + +#point-status-states .seg-btn:nth-child(3).is-active { + background: #b5479a; +} + #controls .spacer { flex: 1; } diff --git a/src/deeperfly/gui/web/static/types.js b/src/deeperfly/gui/web/static/types.js index 3f6590c..51cf116 100644 --- a/src/deeperfly/gui/web/static/types.js +++ b/src/deeperfly/gui/web/static/types.js @@ -47,12 +47,13 @@ */ /** - * The per-view 2D overlay (and fixed mask) to draw for one frame. + * The per-view 2D overlay (with the fixed/invisible masks) to draw for one frame. * @typedef {object} PointsPayload * @property {number} frame * @property {EditMode} mode * @property {Point[][]} points [view][point] * @property {boolean[][]} fixed [view][point] + * @property {boolean[][]} invisible [view][point] obscured: dropped from triangulation * @property {Point[][] | null} proj [view][point] latent 3D reprojection (display only), or null * @property {boolean} dirty */ @@ -68,7 +69,7 @@ * An edit sent over the WebSocket; the server dispatches on `type` and replies * with a refreshed {@link PointsPayload}. * @typedef {object} EditMessage - * @property {"edit_2d" | "edit_3d" | "toggle_fixed" | "reset_point" | "reset_point_view"} type + * @property {"edit_2d" | "edit_3d" | "toggle_fixed" | "toggle_invisible" | "reset_point" | "reset_point_view"} type * @property {number} [view] * @property {number} [point] * @property {number} [x] diff --git a/tests/test_gui.py b/tests/test_gui.py index 8a8247c..2380ca0 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -219,6 +219,82 @@ def test_reset_point_clears_fixed(result): assert not state.corrections.pts2d_fixed[:, 0, 4].any() +# -- EditorState: invisible (obscured) views ---------------------------------- + + +def test_toggle_invisible_re_solves_3d_without_the_marked_view(result): + # The fixture's 2D are exact projections of its 3D, so a clean subset + # re-triangulates the true point. Corrupt one view's 2D *and* the stored 3D so + # the displayed point starts wrong; marking the bad view invisible must drop it + # and recover the true 3D from the remaining views (not a no-op). + state = EditorState.from_result(result) + bad_view, point, frame = 0, 5, 0 + true_3d = result.pts3d[frame, point].copy() + result.pts2d[bad_view, frame, point] += np.array([40.0, -30.0]) # garbage detection + result.pts3d[frame, point] = true_3d + np.array([0.5, -0.4, 0.3]) # wrong base 3D + assert not np.allclose(state.display_pts3d(frame)[point], true_3d) + + assert state.toggle_invisible(bad_view, point, frame) is True + assert state.corrections.pts2d_invisible[bad_view, frame, point] + assert np.allclose(state.display_pts3d(frame)[point], true_3d, atol=1e-6) + + +def test_toggle_invisible_then_back_restores_the_view(result): + state = EditorState.from_result(result) + view, point, frame = 0, 5, 0 + assert state.toggle_invisible(view, point, frame) is True + assert state.toggle_invisible(view, point, frame) is False + assert not state.corrections.pts2d_invisible[view, frame, point] + + +def test_invisible_and_fixed_are_mutually_exclusive(result): + state = EditorState.from_result(result) + view, point, frame = 1, 5, 0 + + # fixing then obscuring drops the fixed flag/pixel + state.toggle_fixed(view, point, frame) + assert state.corrections.pts2d_fixed[view, frame, point] + state.toggle_invisible(view, point, frame) + assert state.corrections.pts2d_invisible[view, frame, point] + assert not state.corrections.pts2d_fixed[view, frame, point] + assert not state.corrections.pts2d_edited[view, frame, point] + + # obscuring then fixing drops the invisible flag + state.toggle_fixed(view, point, frame) + assert state.corrections.pts2d_fixed[view, frame, point] + assert not state.corrections.pts2d_invisible[view, frame, point] + + +def test_dragging_an_invisible_view_un_obscures_it(result): + # Dragging an obscured view places it: the flag clears (back to normal) and the + # 3D re-solves so the dragged view lands under the cursor. A release on a + # formerly-obscured view does not pin it (fix=False), so it stays normal. + state = EditorState.from_result(result) + view, point, frame = 2, 5, 0 + state.toggle_invisible(view, point, frame) + assert state.corrections.pts2d_invisible[view, frame, point] + + drag = state.display_pts2d_refine(frame)[view, point] + np.array([8.0, 6.0]) + assert state.apply_3d_edit(view, point, drag, frame, fix=False) is not None + assert not state.corrections.pts2d_invisible[view, frame, point] # back to normal + assert not state.corrections.pts2d_fixed[view, frame, point] + assert np.allclose(state.display_pts2d_refine(frame)[view, point], drag, atol=1e-4) + + +def test_toggle_invisible_unavailable_without_3d(result): + result.pts3d = None + state = EditorState.from_result(result) + assert state.toggle_invisible(0, 0, 0) is None + + +def test_reset_point_clears_invisible(result): + state = EditorState.from_result(result) + state.toggle_invisible(1, 4, frame=0) + assert state.corrections.pts2d_invisible[1, 0, 4] + state.reset_point(4, frame=0) + assert not state.corrections.pts2d_invisible[:, 0, 4].any() + + # -- corrections sidecar ------------------------------------------------------ @@ -259,6 +335,22 @@ def test_corrections_roundtrip_preserves_fixed(tmp_path, result): np.testing.assert_array_equal(loaded.pts2d_fixed, state.corrections.pts2d_fixed) +def test_corrections_roundtrip_preserves_invisible(tmp_path, result): + state = EditorState.from_result(result) + state.toggle_invisible(0, 5, frame=0) + state.toggle_invisible(3, 5, frame=0) + + path = tmp_path / "corrections.h5" + save_corrections(path, state.corrections) + loaded = load_corrections( + path, result.n_views, result.n_frames, result.pts2d.shape[2] + ) + assert loaded is not None + np.testing.assert_array_equal( + loaded.pts2d_invisible, state.corrections.pts2d_invisible + ) + + def test_load_corrections_v1_without_fixed_defaults_to_false(tmp_path, result): import h5py @@ -278,6 +370,25 @@ def test_load_corrections_v1_without_fixed_defaults_to_false(tmp_path, result): assert not loaded.pts2d_fixed.any() +def test_load_corrections_without_invisible_defaults_to_false(tmp_path, result): + import h5py + + state = EditorState.from_result(result) + state.apply_2d_edit(0, 1, (5.0, 6.0), frame=0) + path = tmp_path / "corrections.h5" + save_corrections(path, state.corrections) + # simulate a v2 sidecar written before the "invisible" dataset existed + with h5py.File(path, "a") as f: + del f["pose2d_corrections/invisible"] + + loaded = load_corrections( + path, result.n_views, result.n_frames, result.pts2d.shape[2] + ) + assert loaded is not None + assert loaded.pts2d_invisible.shape == state.corrections.pts2d_edited.shape + assert not loaded.pts2d_invisible.any() + + def test_load_corrections_missing_returns_none(tmp_path): assert load_corrections(tmp_path / "absent.h5", 1, 1, 1) is None @@ -334,4 +445,5 @@ def test_corrections_empty_shapes(result): assert np.isnan(corr.pts2d).all() assert not corr.pts2d_edited.any() assert not corr.pts2d_fixed.any() + assert not corr.pts2d_invisible.any() assert not corr.any_edits diff --git a/tests/test_gui_server.py b/tests/test_gui_server.py index 198e575..60780b4 100644 --- a/tests/test_gui_server.py +++ b/tests/test_gui_server.py @@ -94,6 +94,8 @@ def test_points_payload_shapes(client, result): assert len(payload["points"]) == result.n_views assert all(len(row) == n_points for row in payload["points"]) assert len(payload["fixed"]) == result.n_views + assert len(payload["invisible"]) == result.n_views + assert all(len(row) == n_points for row in payload["invisible"]) assert payload["dirty"] is False # every drawn point is either null or an [x, y] pair (NaN serializes to null) for row in payload["points"]: @@ -155,6 +157,21 @@ def test_ws_edit_3d_updates_all_views_and_sets_dirty(client, result): assert not np.allclose(reply["points"][other][point], base[other][point]) +def test_ws_toggle_invisible_flips_mask_and_sets_dirty(client, result): + view, point = 1, 5 + with client.websocket_connect("/ws") as ws: + ws.send_json( + {"type": "toggle_invisible", "view": view, "point": point, + "frame": 0, "mode": "edit_3d"} + ) + reply = ws.receive_json() + assert reply["dirty"] is True + assert reply["invisible"][view][point] is True + # the marked view drops out of the estimate; the other views stay finite + other = (view + 1) % result.n_views + assert reply["invisible"][other][point] is False + + def test_ws_edit_2d_is_local_to_its_view(client): view, point = 0, 3 with client.websocket_connect("/ws") as ws: