diff --git a/docs-site/building/builder-window.md b/docs-site/building/builder-window.md index 67d38190..b7ee4d04 100644 --- a/docs-site/building/builder-window.md +++ b/docs-site/building/builder-window.md @@ -15,7 +15,7 @@ of the window: | **Experiment** | Metadata, mice, zones, lab vocabulary and plugins. Where you say what this experiment *is*. | | **Dashboard** | This page: the node graph, hardware, camera and properties. | | **Run** | The operator view — run control, device states, camera, experiment info. | -| **Analyze** | Behavior Analysis, Batch Pose Tracking, Session Review, Multi-Camera Recording and the device check. Each opens in its own window. | +| **Analyze** | Batch Pose Tracking, Behavior Analysis, Session Review, Multi-Camera Recording and the device check. Each opens in its own window. | New and Open both land you on **Experiment**, because naming the experiment and entering its animals comes before wiring anything up. @@ -28,7 +28,7 @@ From top to bottom: |---|---| | **Menu bar** | Five menus: **File**, **Edit**, **Experiment**, **View**, **Help**. | | **Chrome strip** | One row carrying the experiment name, one dot per board, the centred tabs, the run-state pill once a run is under way, and the ++ctrl+k++ hint. The two panel toggles appear on this tab only, since they drive the panels below. | -| **Left panel** | Tabs: **Nodes**, **Hardware**, **Control**, **Files**. | +| **Left panel** | Tabs: **Nodes**, **Hardware**, **Control**. | | **The canvas** | The node graph itself. Everything else gets out of its way. | | **Right panel** | Tabs: **Properties**, **Camera**. | | **Status bar** | The thin line along the bottom: connection indicator, session state, and transient messages. | @@ -47,7 +47,7 @@ panels, one on each side of the canvas. | Panel | Tabs | |---|---| -| Left | **Nodes** (the node library), **Hardware** (boards and devices), **Control** (drive a device by hand), **Files** (New, Open, Save, Save As) | +| Left | **Nodes** (the node library), **Hardware** (boards and devices), **Control** (drive a device by hand) | | Right | **Properties** (settings for the selected node), **Camera** (the live camera panel) | Click a tab at the top of a panel to switch to it. Only one tab per side is @@ -71,7 +71,7 @@ buttons**, one per tab, down the outside edge of the window: | Panel | Rail buttons | |---|---| -| Left | **N** Nodes, **H** Hardware, **D** Control, **F** Files | +| Left | **N** Nodes, **H** Hardware, **D** Control | | Right | **P** Properties, **C** Camera | Hovering a rail button names the tab. Clicking one does two things at once: it @@ -226,7 +226,7 @@ about. |---|---|---| | **Hardware** | Add Board…, Add Device…, New Custom Device Type…, Connect All, Disconnect All | **Add Board** and **Add Device** are buttons in the **Hardware** tab, which is where you were probably already looking. The rest: ++ctrl+k++ | | **Run** | Start (++f5++), Stop (++shift+f5++), Emergency Stop (++ctrl+shift+escape++) | The toolbar, the **Run** tab, the shortcuts, or ++ctrl+k++ | -| **Tools** | Behavior Analysis…, Batch Pose Tracking…, Session Review…, Multi-Camera Recording…, GPU / Device Check… | The **Analyze** tab, which has a card for each — including, when a tool needs an optional dependency stack, the exact install line. **Plugins** moved to the **Experiment** tab instead | +| **Tools** | Batch Pose Tracking…, Behavior Analysis…, Session Review…, Multi-Camera Recording…, GPU / Device Check… | The **Analyze** tab, which has a card for each — including, when a tool needs an optional dependency stack, an **Install** button that adds it and offers to restart GLIDER. **Plugins** moved to the **Experiment** tab instead | Every row in the palette names the menu it came from, so if you remember that something "was under Tools", the palette still tells you so. diff --git a/docs-site/camera-behavior/camera.md b/docs-site/camera-behavior/camera.md index 0ac7675a..462456e5 100644 --- a/docs-site/camera-behavior/camera.md +++ b/docs-site/camera-behavior/camera.md @@ -145,35 +145,38 @@ The `source` column tells you what kind of event each row is: The `frame` column holds the most recent camera frame seen when the event fired, so events line up with the video too. -## Calibration - -Calibration teaches GLIDER how many pixels correspond to a real-world distance, so -tracking can report movement in millimeters instead of pixels. - -Open the camera settings dialog, go to the **Tools** tab, and click **Open -Calibration…**. In the calibration window: - -1. Click **Capture Frame** to grab a still from the camera (an object of known - size should be visible — for example the width of your arena). -2. Click **Draw Line**, then click two points to draw a measurement line across - that known distance. Clicking near an existing endpoint snaps to it. -3. Enter a **Name** (for example `Apparatus Width`), the real **Length**, and the - **Unit**. Supported units are millimeters (`mm`), centimeters (`cm`), meters - (`m`), inches (`in`), and feet (`ft`). -4. Click **Add Line**. You can add several lines; GLIDER averages them to compute - a **pixels/mm** ratio, shown in the info panel. - -Use **Save…** / **Load…** to store a calibration as a JSON file and reuse it later. - -Once a calibration is active, the tracking CSV's `distance_mm` and `cumulative_mm` -columns are filled with real distances. Without calibration, those columns simply -mirror the pixel values. - -!!! note "Calibration is tied to resolution, not to a specific camera" - A calibration is stored against the frame size it was drawn at and scales if - the resolution changes. There is a single active calibration per session - rather than one per camera, so in multi-camera setups it applies to the view - you drew it on. +## Arena (calibration) + +The arena tells GLIDER where the floor is and how big it is. That one outline +also sets the scale, so tracking reports movement in millimeters instead of +pixels. There is no separate measuring step. + +Open the camera settings dialog, go to the **Tools** tab, and click **Arena…**. +In the arena window: + +1. Pick the **Shape**: **Rectangle** or **Circle**. +2. Enter the floor's real size: **Width** and **Height** for a rectangle, or the + **Diameter** for a circle. +3. Outline the floor on the live frame, clicking where the bedding meets the + wall: + - **Rectangle:** click the four corners in order (top-left, top-right, + bottom-right, bottom-left). Corners outside the picture can be placed in + the margin around it. + - **Circle:** click the centre, then a point on the edge, then a second edge + point about a quarter-turn round. The outline is an ellipse, so it still + fits a camera that is slightly tilted. Drag the centre to move it, or an + edge point to fit it. +4. Check the status line. It shows the scale at the centre, and for a rectangle + it warns when the corners look misplaced. + +The arena is remembered between sessions. Once it is set, the tracking CSV's +`distance_mm` and `cumulative_mm` columns are filled with real distances. +Without one, those columns simply mirror the pixel values. + +!!! note "The scale is tied to resolution, not to a specific camera" + An arena is stored against the frame size it was drawn at and scales if the + resolution changes. There is a single arena per session rather than one per + camera, so in multi-camera setups it applies to the view you drew it on. ## Zones (regions of interest) diff --git a/docs-site/camera-behavior/index.md b/docs-site/camera-behavior/index.md index 8bf13019..13f2f5f1 100644 --- a/docs-site/camera-behavior/index.md +++ b/docs-site/camera-behavior/index.md @@ -43,9 +43,3 @@ only install them if you need them: followed the [Installation](../getting-started/installation.md) guide you already have it. Preview, tracking, and data logging work without it. -!!! tip "Two different things are called 'Behavior Analysis'" - GLIDER has a **live, movement-based** behavior classifier (built into the - camera settings, described in [Tracking](tracking.md)) and a **supervised - machine-learning** workflow (a separate window, described in - [Behavior Analysis](behavior.md)). They are unrelated tools. The pages below - point out which is which so you don't confuse them. diff --git a/docs-site/camera-behavior/pup-retrieval.md b/docs-site/camera-behavior/pup-retrieval.md index 14a70b93..20195d6c 100644 --- a/docs-site/camera-behavior/pup-retrieval.md +++ b/docs-site/camera-behavior/pup-retrieval.md @@ -119,8 +119,8 @@ click one to jump to it, or select it and press **Remove**. **Trial start.** Scrub to the moment the dam is released and press **Set at playhead**. Everything is timed from here. -**Scale.** **Set scale…** opens the calibration tool on a frame of the video: -measure something of known length. The scale lives in that video's setup. If +**Scale.** **Set scale…** opens the [arena](camera.md#arena-calibration) tool on a +frame of the video: outline the floor and give its real size. The scale lives in that video's setup. If more than one pup video is open it asks whether to use it for every session in the cohort, and afterwards tells you about any session whose file couldn't be read and so kept its old scale. diff --git a/docs-site/camera-behavior/tracking.md b/docs-site/camera-behavior/tracking.md index bada732f..828df5fa 100644 --- a/docs-site/camera-behavior/tracking.md +++ b/docs-site/camera-behavior/tracking.md @@ -103,7 +103,7 @@ the `_tracking.csv` file. Its columns are: frame, timestamp, elapsed_ms, flow_elapsed_ms, object_id, class, x, y, w, h, confidence, center_x, center_y, distance_px, distance_mm, cumulative_mm, -zone_ids, behavioral_state, velocity_px_frame +zone_ids, velocity_px_frame ``` Key columns: @@ -116,14 +116,13 @@ Key columns: | `x, y, w, h` | Bounding box in pixels (top-left corner, width, height) | | `center_x, center_y` | Bounding-box center, in pixels | | `distance_px` | Distance moved since the previous frame, in pixels | -| `distance_mm`, `cumulative_mm` | The same distances in millimeters, when a [calibration](camera.md#calibration) is active (otherwise they mirror pixels) | +| `distance_mm`, `cumulative_mm` | The same distances in millimeters, when an [arena](camera.md#arena-calibration) is set (otherwise they mirror pixels) | | `zone_ids` | Names of the [zones](camera.md#zones-regions-of-interest) containing the object | -| `behavioral_state` | Movement state, when the live classifier is on (see below) | | `velocity_px_frame` | Speed in pixels per frame | !!! note "Coordinates are in pixels" Positions and box sizes are always in pixels. The millimeter columns are only - real-world distances when you have [calibrated](camera.md#calibration) the + real-world distances when you have [set an arena](camera.md#arena-calibration) for the camera; otherwise they carry the pixel values unchanged. A row with `object_id` of `-1` and class `motion` marks a frame where motion was @@ -155,20 +154,6 @@ A batch run writes several files into the chosen folder: (`frame ÷ fps`), so the results are reproducible regardless of how fast the pass runs. -## Live movement-based behavioral state - -The camera settings dialog has an **Enable Behavior Analysis** option (with -**Freeze**, **Immobile**, and **Darting** thresholds measured in pixels per -frame). This is a simple, rule-based classifier: it labels each tracked object's -movement each frame as freezing, immobile, moving, or darting, and writes that -into the `behavioral_state` column. - -!!! note "This is not the machine-learning workflow" - This live, threshold-based classifier is separate from the supervised - [Behavior Analysis](behavior.md) tool, which learns behaviors from your own - labeled examples. If you want to score complex behaviors (rearing, grooming, - and so on), that is the tool to use. - ## Pose and keypoint tracking GLIDER can also run YOLO **pose** models, which return body **keypoints** (for diff --git a/docs-site/reference/ecosystem-reference.md b/docs-site/reference/ecosystem-reference.md index 97314e90..73aa4408 100644 --- a/docs-site/reference/ecosystem-reference.md +++ b/docs-site/reference/ecosystem-reference.md @@ -200,14 +200,6 @@ The Camera panel can process a recorded video without a live camera. The batch r ## Behavior analysis: annotation, training, and application -GLIDER has two distinct behavior features that should not be conflated. - -### Live movement-state labels - -The camera settings support a light-weight, rule-based live state classifier. It uses movement thresholds to label tracked objects as states such as freezing, immobile, moving, or darting. These labels are useful for immediate feedback and are stored in tracking output when enabled. They are not a trained model for complex behavior categories. - -### Supervised behavior workflow - The Behavior Analysis tool is an optional workflow built around pose data. It proceeds in three stages: 1. **Annotate:** create a vocabulary and label selected video clips. Labels are saved alongside pose data. diff --git a/docs-site/runner/runner.md b/docs-site/runner/runner.md index 08770b5f..51189960 100644 --- a/docs-site/runner/runner.md +++ b/docs-site/runner/runner.md @@ -83,13 +83,13 @@ message won't bury it; tap it once you've read it. ## The Experiment tab The Experiment tab is where you say what this experiment *is*. A row of -**section chips** runs across the top — **Metadata**, **Mice**, **Zones**, **Lab +**section chips** runs across the top — **Metadata**, **Animals**, **Zones**, **Lab Vocabulary**, and **Plugins** — and tapping one shows that section at full width below. Each section scrolls by dragging with a finger. These are the same editors the desktop's **Experiment** tab uses, so what you change here is saved in the same `.glider` file. See -[Subjects & Lab Vocabulary](../building/subjects.md) for the mice and the +[Subjects & Lab Vocabulary](../building/subjects.md) for the animals and the vocabulary lists and [Plugins](../building/plugins.md) for the plugin catalogue; zones are covered under [Camera & Recording](../camera-behavior/camera.md#zones-regions-of-interest). diff --git a/src/glider/analysis/behavior/scale_guard.py b/src/glider/analysis/behavior/scale_guard.py index d92441ce..9f8d9f51 100644 --- a/src/glider/analysis/behavior/scale_guard.py +++ b/src/glider/analysis/behavior/scale_guard.py @@ -165,7 +165,8 @@ def calibration_spread_warning( return None by_resolution: dict[tuple[int, int], list[float]] = {} - for calibration in cal_set.entries.values(): + # Arenas carry the line's scale interface, and win where a video has both. + for calibration in {**cal_set.entries, **cal_set.arenas}.values(): scale = float(getattr(calibration, "pixels_per_mm", 0.0) or 0.0) width = int(getattr(calibration, "calibration_width", 0) or 0) height = int(getattr(calibration, "calibration_height", 0) or 0) diff --git a/src/glider/analysis/behavior/units.py b/src/glider/analysis/behavior/units.py index 284ff110..a581964d 100644 --- a/src/glider/analysis/behavior/units.py +++ b/src/glider/analysis/behavior/units.py @@ -223,7 +223,8 @@ def _borrow_from_folder_mate(cal_set, video: Path) -> float | None: folder = _folder_name(video) candidates = [] - for stored, calibration in cal_set.entries.items(): + # An arena carries the same scale interface as a line, and wins where both exist. + for stored, calibration in {**cal_set.entries, **cal_set.arenas}.items(): if not folder or _folder_name(stored) != folder: continue if (calibration.calibration_width, calibration.calibration_height) != resolution: diff --git a/src/glider/core/glider_core.py b/src/glider/core/glider_core.py index 5a415b9f..115afe6b 100644 --- a/src/glider/core/glider_core.py +++ b/src/glider/core/glider_core.py @@ -26,8 +26,8 @@ from glider.core.flow_engine import FlowEngine from glider.core.hardware_manager import HardwareManager from glider.core.live_signals import LiveSignalBus +from glider.vision.arena import ArenaCalibration from glider.vision.audio_recorder import AudioRecorder, mux_audio_video -from glider.vision.calibration import CameraCalibration from glider.vision.camera_manager import CameraManager from glider.vision.cv_processor import CVProcessor, CVSettings from glider.vision.multi_camera_manager import MultiCameraManager @@ -90,7 +90,7 @@ def __init__(self): self._video_recorder = VideoRecorder(self._camera_manager) self._tracking_logger = TrackingDataLogger() self._audio_recorder = AudioRecorder() - self._calibration = CameraCalibration() + self._calibration: ArenaCalibration | None = None # Multi-camera support self._multi_camera_manager = MultiCameraManager() @@ -232,10 +232,14 @@ def event_logger(self) -> DeviceEventLogger: return self._event_logger @property - def calibration(self) -> CameraCalibration: - """Camera calibration instance.""" + def calibration(self) -> ArenaCalibration | None: + """The live camera's arena, which carries its real-world scale (#212).""" return self._calibration + @calibration.setter + def calibration(self, arena: ArenaCalibration | None) -> None: + self._calibration = arena + @property def multi_camera_manager(self) -> MultiCameraManager: """Multi-camera manager instance.""" diff --git a/src/glider/gui/behavior/analysis_window.py b/src/glider/gui/behavior/analysis_window.py index 6a12282d..b04285cc 100644 --- a/src/glider/gui/behavior/analysis_window.py +++ b/src/glider/gui/behavior/analysis_window.py @@ -1486,37 +1486,45 @@ def _redraw_nest(self) -> None: self._refresh_retrieval() def _run_scale_dialog(self, video: Path) -> tuple[float, bool] | None: - """Lines measured on *video*: ``(px_per_mm, for_the_whole_cohort)``, or None.""" - from glider.gui.dialogs.calibration_dialog import CalibrationDialog - from glider.vision.calibration import CameraCalibration + """The arena outlined on *video*: ``(px_per_mm, for_the_whole_cohort)``, or None.""" + from glider.gui.dialogs.arena_dialog import ArenaDialog from glider.vision.frame_provider import VideoFrameProvider provider = VideoFrameProvider(video) - if not provider.is_connected: + grabbed = provider.get_frame() if provider.is_connected else None + if grabbed is None: provider.release() QMessageBox.warning(self, "Set scale", f"{video.name} could not be opened.") return None + + def scrub(index: int): + provider.seek(index) + frame = provider.get_frame() + return None if frame is None else frame[0] + dialog = None try: - dialog = CalibrationDialog( - frame_provider=provider, - calibration=CameraCalibration(), + dialog = ArenaDialog( + grabbed[0], + title=video.name, parent=self, - show_file_buttons=False, + frame_count=provider.frame_count, + on_scrub=scrub, ) - dialog.setWindowTitle(f"Set scale — {video.name}") if dialog.exec() != QDialog.DialogCode.Accepted: return None - calibration = dialog.get_calibration() + calibration = dialog.calibration() finally: if dialog is not None: dialog.deleteLater() provider.release() + if calibration is None: + return None px_per_mm = float(calibration.pixels_per_mm) if calibration.is_calibrated else 0.0 if px_per_mm <= 0: return None # Asked after the measuring rather than as a box inside it: the - # calibration dialog is shared with the rig and the batch tool. + # arena dialog is shared with the rig and the batch tool. whole = sum(view.pups is not None for _path, view in self._cohort) > 1 and ( QMessageBox.question( self, diff --git a/src/glider/gui/dialogs/arena_dialog.py b/src/glider/gui/dialogs/arena_dialog.py index e9e3d60e..37dbbb73 100644 --- a/src/glider/gui/dialogs/arena_dialog.py +++ b/src/glider/gui/dialogs/arena_dialog.py @@ -1,6 +1,7 @@ -"""Arena Dialog - click the floor perimeter to calibrate perspective. +"""Arena Dialog - outline the floor to calibrate scale and perspective. -The operator clicks the four floor corners of an arena of known size. That +The operator clicks the four floor corners of an arena of known size, or, for +a round arena, its centre and the ends of two radii. That fixes a homography (see :mod:`glider.vision.arena`), which in turn gives a centre zone meaning the same patch of floor in every video, and a scale that varies correctly across the floor instead of being one number for all of it. @@ -31,6 +32,7 @@ from PyQt6.QtCore import QPoint, QRect, Qt, pyqtSignal from PyQt6.QtGui import QColor, QFont, QImage, QMouseEvent, QPainter, QPen, QPixmap from PyQt6.QtWidgets import ( + QComboBox, QDialog, QDialogButtonBox, QDoubleSpinBox, @@ -65,6 +67,12 @@ _CORNER_COLOUR = QColor("#fbbf24") _SUSPECT_COLOUR = QColor("#f87171") +#: What each click places, per shape, in order. +_POINT_NAMES = { + "rectangle": CORNER_NAMES, + "ellipse": ("centre", "one edge", "a second edge, about a quarter-turn round"), +} + class ArenaCanvas(QWidget): """Frame plus a draggable four-corner overlay. @@ -89,6 +97,7 @@ def __init__(self, parent=None): self._dragging: int | None = None self._zone_cm = 10.0 self._arena_cm = (30.0, 30.0) + self._shape = "rectangle" # -- state --------------------------------------------------------- @@ -98,10 +107,25 @@ def set_frame(self, frame: np.ndarray | None) -> None: self.update() def set_corners(self, corners) -> None: - self._corners = [(float(x), float(y)) for x, y in corners][:4] + self._corners = [(float(x), float(y)) for x, y in corners][: self.needed] self.update() self.corners_changed.emit() + def set_shape(self, shape: str) -> None: + """Switch between a rectangle and a round arena. Clears the points.""" + if shape != self._shape: + self._shape = shape + self.clear() + + @property + def shape(self) -> str: + return self._shape + + @property + def needed(self) -> int: + """Clicks this shape takes: four corners, or centre plus two radii.""" + return len(_POINT_NAMES[self._shape]) + def set_arena_size(self, width_cm: float, height_cm: float) -> None: self._arena_cm = (width_cm, height_cm) self.update() @@ -116,7 +140,7 @@ def corners(self) -> list[tuple[float, float]]: @property def is_complete(self) -> bool: - return len(self._corners) == 4 + return len(self._corners) == self.needed def clear(self) -> None: self._corners = [] @@ -135,6 +159,13 @@ def calibration(self) -> ArenaCalibration | None: if not self.is_complete or self._frame is None: return None h, w = self._frame.shape[:2] + if self._shape == "ellipse": + return ArenaCalibration.from_ellipse( + *self._corners, + width_cm=self._arena_cm[0], + height_cm=self._arena_cm[1], + frame_size=(w, h), + ) return ArenaCalibration( corners=self._corners, width_cm=self._arena_cm[0], @@ -189,7 +220,7 @@ def mousePressEvent(self, event: QMouseEvent) -> None: if (self._to_canvas(corner) - pos).manhattanLength() <= _GRAB_RADIUS: self._dragging = i return - if len(self._corners) < 4: + if len(self._corners) < self.needed: norm = self._to_norm(pos) if norm is not None: self._corners.append(norm) @@ -201,6 +232,11 @@ def mouseMoveEvent(self, event: QMouseEvent) -> None: return super().mouseMoveEvent(event) norm = self._to_norm(event.pos()) if norm is not None: + if self._shape == "ellipse" and self._dragging == 0: + # Moving the centre carries the whole ellipse with it. + dx = norm[0] - self._corners[0][0] + dy = norm[1] - self._corners[0][1] + self._corners = [(x + dx, y + dy) for x, y in self._corners] self._corners[self._dragging] = norm self.update() self.corners_changed.emit() @@ -244,10 +280,23 @@ def _paint_outline(self, painter: QPainter) -> None: colour = _SUSPECT_COLOUR if suspect else _ARENA_COLOUR painter.setPen(QPen(colour, 2)) - for i in range(len(points) - 1): - painter.drawLine(points[i], points[i + 1]) - if self.is_complete: - painter.drawLine(points[-1], points[0]) + if self._shape == "ellipse": + if cal is not None: + c, u, v = (np.array(p) for p in self._corners) + rim = [ + self._to_canvas(c + (u - c) * np.cos(t) + (v - c) * np.sin(t)) + for t in np.linspace(0, 2 * np.pi, 72, endpoint=False) + ] + for i in range(len(rim)): + painter.drawLine(rim[i], rim[(i + 1) % len(rim)]) + else: + for point in points[1:]: + painter.drawLine(points[0], point) + else: + for i in range(len(points) - 1): + painter.drawLine(points[i], points[i + 1]) + if self.is_complete: + painter.drawLine(points[-1], points[0]) if cal is None: return @@ -256,8 +305,8 @@ def _paint_outline(self, painter: QPainter) -> None: except (DegenerateArenaError, ValueError): return painter.setPen(QPen(_ZONE_COLOUR, 2)) - for i in range(4): - painter.drawLine(zone[i], zone[(i + 1) % 4]) + for i in range(len(zone)): + painter.drawLine(zone[i], zone[(i + 1) % len(zone)]) def _paint_corners(self, painter: QPainter) -> None: font = QFont() @@ -271,35 +320,30 @@ def _paint_corners(self, painter: QPainter) -> None: painter.drawLine(point.x() - 9, point.y(), point.x() + 9, point.y()) painter.drawLine(point.x(), point.y() - 9, point.x(), point.y() + 9) painter.setPen(QColor(colors.TEXT_PRIMARY)) - painter.drawText(point.x() + 10, point.y() - 8, str(i + 1)) + label = ("C", "1", "2")[i] if self._shape == "ellipse" else str(i + 1) + painter.drawText(point.x() + 10, point.y() - 8, label) class ArenaDialog(QDialog): - """Click the four floor corners of one video's arena.""" + """Outline one video's arena floor: four corners, or a circle's rim.""" def __init__(self, frame: np.ndarray, title: str = "", parent=None, **kwargs): super().__init__(parent) - self.setWindowTitle(f"Arena perimeter - {title}" if title else "Arena perimeter") + self.setWindowTitle(f"Arena - {title}" if title else "Arena") self.setModal(True) self._on_scrub = kwargs.pop("on_scrub", None) self._frame_count = kwargs.pop("frame_count", 0) self._build_ui() self.canvas.set_frame(frame) - self._refresh() + self._shape_changed() def _build_ui(self) -> None: layout = QVBoxLayout(self) - instructions = QLabel( - "Click the four floor corners in order: " - + ", ".join(CORNER_NAMES) - + ".\nClick where the bedding meets the wall, not the top of the wall. " - "Corners outside the picture can be clicked in the margin. " - "Drag any corner to adjust." - ) - instructions.setWordWrap(True) - instructions.setStyleSheet(f"color: {colors.TEXT_SECONDARY};") - layout.addWidget(instructions) + self.instructions = QLabel() + self.instructions.setWordWrap(True) + self.instructions.setStyleSheet(f"color: {colors.TEXT_SECONDARY};") + layout.addWidget(self.instructions) self.canvas = ArenaCanvas(self) self.canvas.corners_changed.connect(self._refresh) @@ -314,12 +358,25 @@ def _build_ui(self) -> None: controls = QHBoxLayout() size_box = QGroupBox("Arena") form = QFormLayout(size_box) + self._form = form + self.shape_combo = QComboBox() + self.shape_combo.addItem("Rectangle", "rectangle") + self.shape_combo.addItem("Circle", "ellipse") + self.shape_combo.currentIndexChanged.connect(self._shape_changed) + form.addRow("Shape", self.shape_combo) + # `arena_spin` is the width, or the diameter of a circle. self.arena_spin = QDoubleSpinBox() self.arena_spin.setRange(1.0, 500.0) self.arena_spin.setValue(30.0) self.arena_spin.setSuffix(" cm") self.arena_spin.valueChanged.connect(self._size_changed) - form.addRow("Side", self.arena_spin) + form.addRow("Width", self.arena_spin) + self.height_spin = QDoubleSpinBox() + self.height_spin.setRange(1.0, 500.0) + self.height_spin.setValue(30.0) + self.height_spin.setSuffix(" cm") + self.height_spin.valueChanged.connect(self._size_changed) + form.addRow("Height", self.height_spin) self.zone_spin = QDoubleSpinBox() self.zone_spin.setRange(0.5, 500.0) self.zone_spin.setValue(10.0) @@ -348,11 +405,45 @@ def _build_ui(self) -> None: buttons.addWidget(self.box) layout.addLayout(buttons) + def _shape_changed(self) -> None: + shape = self.shape_combo.currentData() + self.canvas.set_shape(shape) + round_arena = shape == "ellipse" + self._form.labelForField(self.arena_spin).setText("Diameter" if round_arena else "Width") + self._form.setRowVisible(self.height_spin, not round_arena) + if round_arena: + self.instructions.setText( + "Click the centre of the floor, then a point on its edge, then a second " + "edge point about a quarter-turn round. Click where the bedding meets " + "the wall. Drag the centre to move the circle, an edge point to fit it." + ) + else: + self.instructions.setText( + "Click the four floor corners in order: " + + ", ".join(CORNER_NAMES) + + ".\nClick where the bedding meets the wall, not the top of the wall. " + "Corners outside the picture can be clicked in the margin. " + "Drag any corner to adjust." + ) + self._size_changed() + def _size_changed(self) -> None: - self.canvas.set_arena_size(self.arena_spin.value(), self.arena_spin.value()) + height = ( + self.arena_spin.value() if self.canvas.shape == "ellipse" else self.height_spin.value() + ) + self.canvas.set_arena_size(self.arena_spin.value(), height) self.canvas.set_zone_size(self.zone_spin.value()) self._refresh() + def set_arena(self, arena: ArenaCalibration) -> None: + """Reopen an existing arena: its shape, its size and its points.""" + self.shape_combo.setCurrentIndex(self.shape_combo.findData(arena.shape)) + self.arena_spin.setValue(arena.width_cm) + self.height_spin.setValue(arena.height_cm) + self.canvas.set_corners( + arena.ellipse_handles() if arena.shape == "ellipse" else arena.corners + ) + def _scrub_to(self) -> None: frame = self._on_scrub(self.scrub.value()) if frame is not None: @@ -362,8 +453,9 @@ def _refresh(self) -> None: ok_button = self.box.button(QDialogButtonBox.StandardButton.Ok) cal = self.canvas.calibration() if cal is None: - remaining = 4 - len(self.canvas.corners) - self.status.setText(f"Click {remaining} more corner(s): {CORNER_NAMES[4 - remaining]}") + names = _POINT_NAMES[self.canvas.shape] + placed = len(self.canvas.corners) + self.status.setText(f"Click {len(names) - placed} more point(s): {names[placed]}") self.status.setStyleSheet(f"color: {colors.TEXT_SECONDARY};") ok_button.setEnabled(False) return @@ -376,11 +468,12 @@ def _refresh(self) -> None: ok_button.setEnabled(False) return - lines = [ - f"Scale at centre: {scale:.2f} px/cm", - f"Opposite edges: {residuals['edge_ratio']:.2f}x", - f"Scale across floor: {residuals['scale_ratio']:.2f}x", - ] + lines = [f"Scale at centre: {scale:.2f} px/cm"] + if cal.shape == "rectangle": # an ellipse's fit is affine: both are 1.00x + lines += [ + f"Opposite edges: {residuals['edge_ratio']:.2f}x", + f"Scale across floor: {residuals['scale_ratio']:.2f}x", + ] if residuals["clipped"]: lines.append("Arena extends beyond the frame (expected on some videos).") if residuals["suspect"]: diff --git a/src/glider/gui/dialogs/calibration_dialog.py b/src/glider/gui/dialogs/calibration_dialog.py deleted file mode 100644 index 93b1c97c..00000000 --- a/src/glider/gui/dialogs/calibration_dialog.py +++ /dev/null @@ -1,763 +0,0 @@ -""" -Calibration Dialog - Draw measurement lines on camera view. - -Allows users to draw lines on the camera preview and assign -real-world measurements for pixel-to-distance calibration. -""" - -import logging -import math -from typing import TYPE_CHECKING - -import cv2 -import numpy as np -from PyQt6.QtCore import QPoint, Qt, pyqtSignal -from PyQt6.QtGui import QImage, QMouseEvent, QPixmap -from PyQt6.QtWidgets import ( - QComboBox, - QDialog, - QDoubleSpinBox, - QFileDialog, - QFormLayout, - QGroupBox, - QHBoxLayout, - QHeaderView, - QLabel, - QLineEdit, - QMessageBox, - QPushButton, - QSizePolicy, - QSlider, - QTableWidget, - QTableWidgetItem, - QVBoxLayout, -) - -from glider.gui.styles import colors, radius -from glider.vision.calibration import CameraCalibration, LengthUnit - -if TYPE_CHECKING: - from glider.vision.frame_provider import FrameProvider - -logger = logging.getLogger(__name__) - - -class CalibrationPreviewWidget(QLabel): - """ - Widget for drawing calibration lines on camera preview. - - Handles mouse clicks to define line endpoints. - """ - - line_defined = pyqtSignal(tuple, tuple) # start, end points - - def __init__(self, parent=None): - super().__init__(parent) - self.setMinimumSize(640, 480) - self.setAlignment(Qt.AlignmentFlag.AlignCenter) - self.setStyleSheet(f""" - QLabel {{ - background-color: {colors.CANVAS}; - border: 2px solid {colors.BORDER}; - border-radius: {radius.SMALL}px; - }} - """) - self.setMouseTracking(True) - self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) - - self._frame: np.ndarray | None = None - self._calibration: CameraCalibration | None = None - self._drawing_mode = False - self._start_point: QPoint | None = None - self._current_point: QPoint | None = None - self._image_rect = None # Actual image area within widget - self._snap_threshold = 15 # Pixels - snap to endpoint if within this distance - - def set_frame(self, frame: np.ndarray) -> None: - """Set the current frame to display.""" - self._frame = frame.copy() - self._update_display() - - def set_calibration(self, calibration: CameraCalibration) -> None: - """Set calibration data to display existing lines.""" - self._calibration = calibration - num_lines = len(calibration.lines) if calibration else 0 - logger.info(f"set_calibration called with {num_lines} lines") - self._update_display() - - def set_drawing_mode(self, enabled: bool) -> None: - """Enable/disable line drawing mode.""" - self._drawing_mode = enabled - self._start_point = None - self._current_point = None - if enabled: - self.setCursor(Qt.CursorShape.CrossCursor) - else: - self.setCursor(Qt.CursorShape.ArrowCursor) - self._update_display() - - def _update_display(self) -> None: - """Update the display with frame and calibration lines.""" - if self._frame is None: - self.setText("No frame available") - return - - # Draw on a copy of the frame - display_frame = self._frame.copy() - h, w = display_frame.shape[:2] - - # Draw existing calibration lines - num_lines = len(self._calibration.lines) if self._calibration else 0 - logger.info(f"_update_display: frame={w}x{h}, calibration lines={num_lines}") - - if self._calibration and self._calibration.lines: - for i, line in enumerate(self._calibration.lines): - x1, y1, x2, y2 = line.get_pixel_coords(w, h) - logger.info(f"Drawing line {i}: ({x1},{y1}) to ({x2},{y2})") - # Use bright cyan color for visibility - color = (255, 255, 0) # Cyan in BGR - cv2.line(display_frame, (x1, y1), (x2, y2), color, 3) - - # Draw endpoints - larger and highlighted when in drawing mode - endpoint_radius = 12 if self._drawing_mode else 8 - endpoint_color = ( - (0, 255, 255) if self._drawing_mode else color - ) # Yellow when drawing - cv2.circle(display_frame, (x1, y1), endpoint_radius, endpoint_color, -1) - cv2.circle(display_frame, (x2, y2), endpoint_radius, endpoint_color, -1) - # Add outline for better visibility - cv2.circle(display_frame, (x1, y1), endpoint_radius, (255, 255, 255), 2) - cv2.circle(display_frame, (x2, y2), endpoint_radius, (255, 255, 255), 2) - - # Draw label - mid_x = (x1 + x2) // 2 - mid_y = (y1 + y2) // 2 - label = f"{line.name}: {line.length:.1f} {line.unit.value}" - cv2.putText( - display_frame, - label, - (mid_x + 5, mid_y - 5), - cv2.FONT_HERSHEY_SIMPLEX, - 0.5, - color, - 1, - ) - - # Convert to QImage - must copy data to avoid dangling reference - rgb_frame = cv2.cvtColor(display_frame, cv2.COLOR_BGR2RGB) - rgb_frame = np.ascontiguousarray(rgb_frame) - bytes_per_line = 3 * w - q_image = QImage( - rgb_frame.data, w, h, bytes_per_line, QImage.Format.Format_RGB888 - ).copy() # Copy to own the data - - # Scale to fit widget - pixmap = QPixmap.fromImage(q_image) - scaled = pixmap.scaled( - self.size(), - Qt.AspectRatioMode.KeepAspectRatio, - Qt.TransformationMode.SmoothTransformation, - ) - - # Calculate image rect for mouse coordinate mapping - x_offset = (self.width() - scaled.width()) // 2 - y_offset = (self.height() - scaled.height()) // 2 - self._image_rect = (x_offset, y_offset, scaled.width(), scaled.height()) - - # Clear any existing content and set new pixmap - self.clear() - self.setPixmap(scaled) - self.update() - - def _widget_to_image_coords(self, pos: QPoint) -> tuple[int, int] | None: - """Convert widget coordinates to image coordinates.""" - if self._image_rect is None or self._frame is None: - return None - - x_off, y_off, img_w, img_h = self._image_rect - frame_h, frame_w = self._frame.shape[:2] - - # Check if within image bounds - rel_x = pos.x() - x_off - rel_y = pos.y() - y_off - - if rel_x < 0 or rel_y < 0 or rel_x >= img_w or rel_y >= img_h: - return None - - # Scale to frame coordinates - img_x = int(rel_x * frame_w / img_w) - img_y = int(rel_y * frame_h / img_h) - - return (img_x, img_y) - - def _find_nearest_endpoint(self, x: int, y: int) -> tuple[int, int] | None: - """ - Find the nearest existing line endpoint within snap threshold. - - Args: - x, y: Click coordinates in image space - - Returns: - Endpoint coordinates if within threshold, else None - """ - if not self._calibration or not self._calibration.lines or self._frame is None: - return None - - h, w = self._frame.shape[:2] - min_dist = float("inf") - nearest = None - - for line in self._calibration.lines: - x1, y1, x2, y2 = line.get_pixel_coords(w, h) - - # Check start point - dist1 = math.sqrt((x - x1) ** 2 + (y - y1) ** 2) - if dist1 < min_dist and dist1 <= self._snap_threshold: - min_dist = dist1 - nearest = (x1, y1) - - # Check end point - dist2 = math.sqrt((x - x2) ** 2 + (y - y2) ** 2) - if dist2 < min_dist and dist2 <= self._snap_threshold: - min_dist = dist2 - nearest = (x2, y2) - - return nearest - - def mousePressEvent(self, event: QMouseEvent) -> None: - """Handle mouse press for line drawing.""" - logger.info(f"mousePressEvent: drawing_mode={self._drawing_mode}, button={event.button()}") - - if not self._drawing_mode: - super().mousePressEvent(event) - return - - if event.button() != Qt.MouseButton.LeftButton: - super().mousePressEvent(event) - return - - coords = self._widget_to_image_coords(event.pos()) - logger.info(f"Click coords: widget={event.pos()}, image={coords}") - - if coords is None: - logger.warning("Click outside image area") - return - - # Check for snap to existing endpoint - snapped = self._find_nearest_endpoint(coords[0], coords[1]) - if snapped: - coords = snapped - logger.info(f"Snapped to endpoint: {coords}") - - if self._start_point is None: - # First click - start point - self._start_point = QPoint(coords[0], coords[1]) - self._current_point = self._start_point - logger.info(f"First point set: {coords}") - else: - # Second click - end point - end_point = QPoint(coords[0], coords[1]) - logger.info(f"Second point set: {coords}, emitting line_defined signal") - self.line_defined.emit( - (self._start_point.x(), self._start_point.y()), (end_point.x(), end_point.y()) - ) - self._start_point = None - self._current_point = None - - self._update_display() - - def mouseMoveEvent(self, event: QMouseEvent) -> None: - """Handle mouse move for line preview.""" - if self._drawing_mode and self._start_point is not None: - coords = self._widget_to_image_coords(event.pos()) - if coords: - self._current_point = QPoint(coords[0], coords[1]) - self._draw_preview_line() - super().mouseMoveEvent(event) - - def _draw_preview_line(self) -> None: - """Draw preview line while drawing.""" - if self._frame is None or self._start_point is None or self._current_point is None: - return - - display_frame = self._frame.copy() - h, w = display_frame.shape[:2] - - # Draw existing calibration lines with highlighted endpoints - if self._calibration and self._calibration.lines: - for line in self._calibration.lines: - x1, y1, x2, y2 = line.get_pixel_coords(w, h) - color = (255, 255, 0) # Cyan - cv2.line(display_frame, (x1, y1), (x2, y2), color, 3) - # Highlighted snap points - cv2.circle(display_frame, (x1, y1), 12, (0, 255, 255), -1) - cv2.circle(display_frame, (x2, y2), 12, (0, 255, 255), -1) - cv2.circle(display_frame, (x1, y1), 12, (255, 255, 255), 2) - cv2.circle(display_frame, (x2, y2), 12, (255, 255, 255), 2) - - # Check if current point would snap - end_x, end_y = self._current_point.x(), self._current_point.y() - snapped = self._find_nearest_endpoint(end_x, end_y) - if snapped: - end_x, end_y = snapped - - # Draw preview line in different color (magenta) - start = (self._start_point.x(), self._start_point.y()) - end = (end_x, end_y) - cv2.line(display_frame, start, end, (255, 0, 255), 3) - cv2.circle(display_frame, start, 8, (255, 0, 255), -1) - - # Show snap indicator if snapping - if snapped: - cv2.circle(display_frame, end, 15, (0, 255, 0), 3) # Green ring = will snap - cv2.circle(display_frame, end, 8, (0, 255, 0), -1) - else: - cv2.circle(display_frame, end, 8, (255, 0, 255), -1) - - # Convert and display - must copy to avoid dangling reference - rgb_frame = cv2.cvtColor(display_frame, cv2.COLOR_BGR2RGB) - rgb_frame = np.ascontiguousarray(rgb_frame) - bytes_per_line = 3 * w - q_image = QImage(rgb_frame.data, w, h, bytes_per_line, QImage.Format.Format_RGB888).copy() - pixmap = QPixmap.fromImage(q_image) - scaled = pixmap.scaled( - self.size(), - Qt.AspectRatioMode.KeepAspectRatio, - Qt.TransformationMode.SmoothTransformation, - ) - self.clear() - self.setPixmap(scaled) - self.update() - - -class CalibrationDialog(QDialog): - """ - Dialog for calibration with measurement lines. - - Draws over any :class:`~glider.vision.frame_provider.FrameProvider`, so the - same UI serves the live camera and a recorded video file. Allows users to: - - Draw lines on a captured frame - - Assign real-world measurements to lines - - Save/load calibration files - """ - - def __init__( - self, - frame_provider: "FrameProvider", - calibration: CameraCalibration, - parent=None, - *, - show_file_buttons: bool = True, - ): - super().__init__(parent) - self._provider = frame_provider - self._calibration = calibration - self._pending_line: tuple[tuple[int, int], tuple[int, int]] | None = None - self._show_file_buttons = show_file_buttons - # A provider with a timeline (a video file) gets a scrubber; the live - # camera has no timeline to scrub. - self._seekable = ( - hasattr(frame_provider, "seek") and getattr(frame_provider, "frame_count", 0) > 1 - ) - self._scrubber: QSlider | None = None - self._frame_label: QLabel | None = None - self._save_btn: QPushButton | None = None - self._load_btn: QPushButton | None = None - - self._setup_ui() - self._update_table() - - self._capture_frame(announce=False) - self._preview.set_calibration(self._calibration) - - def _setup_ui(self) -> None: - self.setWindowTitle("Camera Calibration") - self.setMinimumSize(900, 600) - - layout = QHBoxLayout(self) - - # Left side - preview - left_layout = QVBoxLayout() - - # Preview widget - self._preview = CalibrationPreviewWidget() - self._preview.line_defined.connect(self._on_line_defined) - left_layout.addWidget(self._preview, 1) - - # Drawing controls - draw_layout = QHBoxLayout() - - self._draw_btn = QPushButton("Draw Line") - self._draw_btn.setCheckable(True) - self._draw_btn.toggled.connect(self._on_draw_toggled) - draw_layout.addWidget(self._draw_btn) - - self._capture_btn = QPushButton("Capture Frame") - # Not connected to _capture_frame directly: clicked() emits its - # `checked` bool, which is always False for a non-checkable button and - # would land in `announce`, silencing the "no frame" warning. - self._capture_btn.clicked.connect(self._on_capture_clicked) - draw_layout.addWidget(self._capture_btn) - - draw_layout.addStretch() - - left_layout.addLayout(draw_layout) - - # Frame scrubber, for sources with a timeline (video files). - if self._seekable: - scrub_row = QHBoxLayout() - self._scrubber = QSlider(Qt.Orientation.Horizontal) - # Tracking (the default) fires valueChanged for every intermediate - # value while the slider is being dragged; each one is a - # seek+decode from the nearest keyframe plus two full redraws, - # which visibly stalls the dialog on long recordings. With - # tracking off, dragging only seeks on release; setValue() and - # arrow-key stepping are unaffected and still emit immediately. - self._scrubber.setTracking(False) - self._scrubber.setRange(0, max(0, self._provider.frame_count - 1)) - self._frame_label = QLabel(f"1 / {self._provider.frame_count}") - self._scrubber.valueChanged.connect(self._on_scrub) - scrub_row.addWidget(QLabel("Frame:")) - scrub_row.addWidget(self._scrubber, 1) - scrub_row.addWidget(self._frame_label) - left_layout.addLayout(scrub_row) - self._capture_btn.setText("Use This Frame") - - layout.addLayout(left_layout, 2) - - # Right side - controls and table - right_layout = QVBoxLayout() - - # New line group (initially hidden) - self._new_line_group = QGroupBox("New Measurement") - new_line_layout = QFormLayout(self._new_line_group) - - self._name_edit = QLineEdit() - self._name_edit.setPlaceholderText("e.g., 'Apparatus Width'") - new_line_layout.addRow("Name:", self._name_edit) - - self._length_spin = QDoubleSpinBox() - self._length_spin.setRange(0.001, 100000) - self._length_spin.setDecimals(2) - self._length_spin.setValue(100) - new_line_layout.addRow("Length:", self._length_spin) - - self._unit_combo = QComboBox() - for unit in LengthUnit: - self._unit_combo.addItem(unit.value, unit) - self._unit_combo.setCurrentIndex(1) # Default to cm - new_line_layout.addRow("Unit:", self._unit_combo) - - btn_layout = QHBoxLayout() - self._add_btn = QPushButton("Add Line") - self._add_btn.clicked.connect(self._add_pending_line) - self._add_btn.setEnabled(False) - btn_layout.addWidget(self._add_btn) - - self._cancel_btn = QPushButton("Cancel") - self._cancel_btn.clicked.connect(self._cancel_pending_line) - btn_layout.addWidget(self._cancel_btn) - - new_line_layout.addRow(btn_layout) - - self._new_line_group.hide() - right_layout.addWidget(self._new_line_group) - - # Existing lines table - table_group = QGroupBox("Calibration Lines") - table_layout = QVBoxLayout(table_group) - - self._table = QTableWidget() - self._table.setColumnCount(4) - self._table.setHorizontalHeaderLabels(["Name", "Length", "Unit", ""]) - self._table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch) - self._table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.Fixed) - self._table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.Fixed) - self._table.horizontalHeader().setSectionResizeMode(3, QHeaderView.ResizeMode.Fixed) - self._table.setColumnWidth(1, 80) - self._table.setColumnWidth(2, 60) - self._table.setColumnWidth(3, 60) - self._table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows) - table_layout.addWidget(self._table) - - right_layout.addWidget(table_group, 1) - - # Calibration info - info_group = QGroupBox("Calibration Info") - info_layout = QFormLayout(info_group) - - self._ppm_label = QLabel("--") - info_layout.addRow("Pixels/mm:", self._ppm_label) - - self._res_label = QLabel("--") - info_layout.addRow("Resolution:", self._res_label) - - right_layout.addWidget(info_group) - - # File operations. Suppressed in the batch tool, where the master - # calibration file is the only artifact and a per-file save would - # be mistaken for it. - # Built once so "Clear All exists in both branches" holds structurally, - # not just because both branches happen to repeat the same code. - self._clear_btn = QPushButton("Clear All") - self._clear_btn.clicked.connect(self._clear_calibration) - - if self._show_file_buttons: - file_layout = QHBoxLayout() - - self._save_btn = QPushButton("Save...") - self._save_btn.clicked.connect(self._save_calibration) - file_layout.addWidget(self._save_btn) - - self._load_btn = QPushButton("Load...") - self._load_btn.clicked.connect(self._load_calibration) - file_layout.addWidget(self._load_btn) - - file_layout.addWidget(self._clear_btn) - - right_layout.addLayout(file_layout) - else: - clear_layout = QHBoxLayout() - clear_layout.addWidget(self._clear_btn) - clear_layout.addStretch() - right_layout.addLayout(clear_layout) - - # Dialog buttons - dialog_btn_layout = QHBoxLayout() - dialog_btn_layout.addStretch() - - self._ok_btn = QPushButton("OK") - self._ok_btn.clicked.connect(self.accept) - dialog_btn_layout.addWidget(self._ok_btn) - - self._cancel_dialog_btn = QPushButton("Cancel") - self._cancel_dialog_btn.clicked.connect(self.reject) - dialog_btn_layout.addWidget(self._cancel_dialog_btn) - - right_layout.addLayout(dialog_btn_layout) - - layout.addLayout(right_layout, 1) - - def _on_draw_toggled(self, checked: bool) -> None: - """Handle draw button toggle.""" - self._preview.set_drawing_mode(checked) - if checked: - self._draw_btn.setText("Cancel Drawing") - else: - self._draw_btn.setText("Draw Line") - # Only clear pending line if user cancelled (not if line was just defined) - # _on_line_defined will have already set _pending_line before unchecking - # So if _pending_line exists, don't clear it - if self._pending_line is None: - self._new_line_group.hide() - self._add_btn.setEnabled(False) - - def _on_line_defined(self, start: tuple[int, int], end: tuple[int, int]) -> None: - """Handle line definition from preview widget.""" - logger.info(f"_on_line_defined: start={start}, end={end}") - self._pending_line = (start, end) - self._new_line_group.show() - self._add_btn.setEnabled(True) - self._draw_btn.setChecked(False) - - # Calculate pixel length for reference - import math - - dx = end[0] - start[0] - dy = end[1] - start[1] - pixel_length = math.sqrt(dx * dx + dy * dy) - self._new_line_group.setTitle(f"New Measurement ({pixel_length:.0f} pixels)") - logger.info(f"Pending line set, pixel_length={pixel_length:.0f}") - - def _add_pending_line(self) -> None: - """Add the pending line to calibration.""" - if self._pending_line is None: - logger.warning("No pending line to add") - return - - start, end = self._pending_line - - if start == end: - QMessageBox.warning( - self, - "Zero-Length Line", - "The start and end points are the same. Draw the line across a " - "distance you know the real length of.", - ) - return - - name = self._name_edit.text().strip() or f"Line {len(self._calibration.lines) + 1}" - length = self._length_spin.value() - unit = self._unit_combo.currentData() - - resolution = (self._calibration.calibration_width, self._calibration.calibration_height) - logger.info(f"Adding line: start={start}, end={end}, resolution={resolution}") - - if resolution[0] == 0 or resolution[1] == 0: - logger.error("Calibration resolution is 0! Cannot add line.") - QMessageBox.warning(self, "Error", "No frame captured. Click 'Capture Frame' first.") - return - - self._calibration.add_line(start, end, length, unit, name, resolution) - logger.info(f"Calibration now has {len(self._calibration.lines)} lines") - - self._pending_line = None - self._new_line_group.hide() - self._name_edit.clear() - self._update_table() - self._preview.set_calibration(self._calibration) - self._preview._update_display() - - def _cancel_pending_line(self) -> None: - """Cancel pending line addition.""" - self._pending_line = None - self._new_line_group.hide() - self._name_edit.clear() - self._preview._update_display() - - def _on_capture_clicked(self) -> None: - """Capture button handler; swallows clicked()'s bool so it can't mute warnings.""" - self._capture_frame(announce=True) - - def _on_scrub(self, value: int) -> None: - """Seek and re-capture so the drawing surface is the frame you see. - - The label only advances on a successful capture: `read_frame` can - silently fail (announce=False) and if the label moved regardless it - would read "8 / 12" while the preview still showed frame 7 - a silent - lie. The preview already holds this exact calibration object from - construction, so there's no need to reassign it here - that was just - an extra redraw on top of the seek+decode. - """ - self._provider.seek(value) - if self._capture_frame(announce=False): - self._frame_label.setText(f"{value + 1} / {self._provider.frame_count}") - - def _capture_frame(self, announce: bool = True) -> bool: - """Pull a fresh frame from the provider into the preview. - - Returns True if a frame was captured, False otherwise. - """ - if not self._provider.is_connected: - if announce: - QMessageBox.warning( - self, - "No Frame", - "No frame is available from this source.", - ) - return False - - result = self._provider.get_frame() - if result is None: - if announce: - QMessageBox.warning(self, "No Frame", "The source did not return a frame.") - return False - - frame, _timestamp = result - self._preview.set_frame(frame) - self._calibration.calibration_width = frame.shape[1] - self._calibration.calibration_height = frame.shape[0] - self._update_info() - return True - - def _update_table(self) -> None: - """Update the calibration lines table.""" - num_lines = len(self._calibration.lines) - logger.info(f"_update_table: showing {num_lines} lines") - self._table.setRowCount(num_lines) - - for i, line in enumerate(self._calibration.lines): - # Name - name_item = QTableWidgetItem(line.name) - self._table.setItem(i, 0, name_item) - - # Length - length_item = QTableWidgetItem(f"{line.length:.2f}") - length_item.setTextAlignment( - Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter - ) - self._table.setItem(i, 1, length_item) - - # Unit - unit_item = QTableWidgetItem(line.unit.value) - self._table.setItem(i, 2, unit_item) - - # Delete button - delete_btn = QPushButton("X") - delete_btn.setFixedWidth(40) - delete_btn.clicked.connect(lambda checked, idx=i: self._remove_line(idx)) - self._table.setCellWidget(i, 3, delete_btn) - - self._update_info() - - def _remove_line(self, index: int) -> None: - """Remove a calibration line.""" - if self._calibration.remove_line(index): - self._update_table() - self._preview.set_calibration(self._calibration) - self._preview._update_display() - - def _update_info(self) -> None: - """Update calibration info display.""" - ppm = self._calibration.pixels_per_mm - if ppm > 0: - self._ppm_label.setText(f"{ppm:.2f}") - else: - self._ppm_label.setText("--") - - if self._calibration.calibration_width > 0: - self._res_label.setText( - f"{self._calibration.calibration_width} x {self._calibration.calibration_height}" - ) - else: - self._res_label.setText("--") - - def _save_calibration(self) -> None: - """Save calibration to file.""" - from pathlib import Path - - path, _ = QFileDialog.getSaveFileName( - self, "Save Calibration", "", "JSON Files (*.json);;All Files (*)" - ) - if path: - self._calibration.save(Path(path)) - QMessageBox.information(self, "Saved", f"Calibration saved to {path}") - - def _load_calibration(self) -> None: - """Load calibration from file.""" - from pathlib import Path - - path, _ = QFileDialog.getOpenFileName( - self, "Load Calibration", "", "JSON Files (*.json);;All Files (*)" - ) - if path: - if self._calibration.load(Path(path)): - self._update_table() - self._preview.set_calibration(self._calibration) - self._preview._update_display() - QMessageBox.information( - self, "Loaded", f"Loaded {len(self._calibration.lines)} calibration lines" - ) - else: - QMessageBox.warning(self, "Error", "Failed to load calibration file") - - def _clear_calibration(self) -> None: - """Clear all calibration lines.""" - if not self._calibration.lines: - return - - reply = QMessageBox.question( - self, - "Clear Calibration", - "Remove all calibration lines?", - QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, - ) - if reply == QMessageBox.StandardButton.Yes: - self._calibration.clear() - self._update_table() - self._preview.set_calibration(self._calibration) - self._preview._update_display() - - def get_calibration(self) -> CameraCalibration: - """Get the calibration data.""" - return self._calibration diff --git a/src/glider/gui/dialogs/camera_settings_dialog.py b/src/glider/gui/dialogs/camera_settings_dialog.py index 19d258ad..6e81f6b3 100644 --- a/src/glider/gui/dialogs/camera_settings_dialog.py +++ b/src/glider/gui/dialogs/camera_settings_dialog.py @@ -557,7 +557,7 @@ def _create_tools_tab_content(self) -> QWidget: # Touch mode applied via QSS property # Calibration group - calibration_group = QGroupBox("Camera Calibration") + calibration_group = QGroupBox("Arena") self._apply_touch_group_property(calibration_group) calibration_layout = QVBoxLayout(calibration_group) if self._is_touch_mode: @@ -565,14 +565,14 @@ def _create_tools_tab_content(self) -> QWidget: calibration_layout.setContentsMargins(12, 20, 12, 12) calibration_desc = QLabel( - "Draw measurement lines on the camera view to calibrate\n" - "pixel-to-distance conversion for tracking data." + "Outline the arena floor (square or round) and give its size.\n" + "It sets the pixel-to-distance scale for tracking data." ) calibration_desc.setWordWrap(True) calibration_desc.setProperty("textRole", "muted") calibration_layout.addWidget(calibration_desc) - self._calibrate_btn = QPushButton("Open Calibration...") + self._calibrate_btn = QPushButton("Arena...") if self._is_touch_mode: self._calibrate_btn.setMinimumHeight(50) self._calibrate_btn.setProperty("touchMode", True) diff --git a/src/glider/gui/dialogs/plugin_manager_dialog.py b/src/glider/gui/dialogs/plugin_manager_dialog.py index 45908a50..d46d007b 100644 --- a/src/glider/gui/dialogs/plugin_manager_dialog.py +++ b/src/glider/gui/dialogs/plugin_manager_dialog.py @@ -30,7 +30,7 @@ from pathlib import Path from typing import TYPE_CHECKING, Any -from PyQt6.QtCore import Qt +from PyQt6.QtCore import Qt, pyqtSignal from PyQt6.QtWidgets import ( QButtonGroup, QDialog, @@ -170,6 +170,10 @@ class PluginManagerDialog(QDialog): glider_version: The running version, for the compatibility gate. """ + #: Emitted after plugins were (re)loaded, so views listing plugin + #: components can rebuild without a restart. + plugins_changed = pyqtSignal() + def __init__( self, index: ResolvedIndex, @@ -425,6 +429,7 @@ async def _rediscover(self) -> None: await self._plugin_manager.load_plugins() except Exception as exc: logger.warning("Rediscovery after install failed: %s", exc) + self.plugins_changed.emit() def _refresh_row(self, name: str, *, fallback_message: str = "") -> None: """Recompute one row from disk. Deliberately not a full rebuild -- @@ -487,6 +492,7 @@ async def _load_enabled(self) -> None: await self._plugin_manager.load_plugins() except Exception as exc: logger.warning("Loading newly enabled plugins failed: %s", exc) + self.plugins_changed.emit() def _on_reload_requested(self, name: str) -> None: card = self._cards_by_name.get(name) @@ -523,6 +529,7 @@ async def _reload(self, name: str, targets: list[str]) -> None: except Exception as exc: logger.warning("Reloading %s failed: %s", plugin, exc) failures.append(plugin) + self.plugins_changed.emit() if failures: card.set_message(f"Could not reload: {', '.join(failures)}. A restart will.") else: diff --git a/src/glider/gui/dialogs/subject_dialog.py b/src/glider/gui/dialogs/subject_dialog.py index 4891b91c..a2e37f83 100644 --- a/src/glider/gui/dialogs/subject_dialog.py +++ b/src/glider/gui/dialogs/subject_dialog.py @@ -204,7 +204,7 @@ def _create_basic_tab(self) -> QWidget: # Name self._name_edit = QLineEdit() - self._name_edit.setPlaceholderText("e.g., Mouse 1") + self._name_edit.setPlaceholderText("e.g., Animal 1") form.addRow("Name:", self._name_edit) # Group/Treatment diff --git a/src/glider/gui/main_window.py b/src/glider/gui/main_window.py index dc742c1e..1fd54bd8 100644 --- a/src/glider/gui/main_window.py +++ b/src/glider/gui/main_window.py @@ -6,8 +6,10 @@ """ import asyncio +import json import logging import re +import sys from dataclasses import replace from datetime import date from pathlib import Path @@ -41,7 +43,6 @@ from glider.core.config import get_config from glider.gui.commands import UndoStack from glider.gui.device_status import link_status_text, link_strip_state -from glider.gui.dialogs.calibration_dialog import CalibrationDialog from glider.gui.dialogs.camera_settings_dialog import CameraSettingsDialog from glider.gui.dialogs.experiment_dialog import ExperimentDialog from glider.gui.dialogs.help_dialog import HelpDialog @@ -487,7 +488,7 @@ def __init__( # set -- test is_runner_mode, not this, for "on the Pi". self._pi_dashboard: PiDashboard | None = None self._finger_scroll: FingerScroll | None = None # runner mode - # The editor behind the Metadata and Mice sections, and the two pages + # The editor behind the Metadata and Animals sections, and the two pages # it was split into. Both are rebuilt per session; see # _experiment_detail_pages. self._experiment_details: ExperimentDialog | None = None @@ -495,7 +496,6 @@ def __init__( # The node editor's properties form lives inside this; see PropertiesHost. self._properties_host: PropertiesHost | None = None self._properties_widget: QWidget | None = None - self._files_panel: QWidget | None = None # View menu panel toggles, kept as attributes so their checked state can # follow a panel collapsed by any other route. self._left_panel_action: QAction | None = None @@ -881,7 +881,7 @@ def _create_experiment_page(self) -> None: self._experiment_page = ExperimentPage( { "metadata": lambda: self._experiment_detail_pages()[0], - "mice": lambda: self._experiment_detail_pages()[1], + "animals": lambda: self._experiment_detail_pages()[1], "zones": self._build_zones_section, "vocabulary": self._build_vocabulary_section, "plugins": self._build_plugins_section, @@ -891,11 +891,11 @@ def _create_experiment_page(self) -> None: ) def _experiment_detail_pages(self) -> tuple[QWidget, QWidget]: - """The Metadata and Mice pages, from one shared dialog. + """The Metadata and Animals pages, from one shared dialog. Both come out of a single :class:`ExperimentDialog`, built on first use: the two group boxes are separate pages on the rail but the same - editor underneath, so a subject added on Mice and the protocol typed on + editor underneath, so a subject added on Animals and the protocol typed on Metadata reach the session through one object that knows about both. Two dialogs would mean two ``set_session`` targets and two ideas of which subject is active. @@ -1027,28 +1027,16 @@ def _create_analyze_page(self) -> None: ) def _install_line(extra: str, missing) -> str: - return ( - f"Needs the {extra} extra. Install it with:\n" - f" pip install 'glider[{extra}]'\n" - f"Missing: {', '.join(missing)}" - ) + return f"Needs the {extra} extra. Missing: {', '.join(missing)}" + + # A frozen build has no pip to install with, so no button to offer. + installable = not getattr(sys, "frozen", False) has_behavior = behavior_available() has_pose = pose_batch_available() behavior_reason = "" if has_behavior else _install_line("behavior", missing_behavior_deps()) cards = [ - ToolCard( - key="behavior", - title="Behavior Analysis", - description=( - "Cluster and label behaviour from pose tracks, and train a " - "classifier on what you label." - ), - glyph="behavior", - available=has_behavior, - unavailable_reason=behavior_reason, - ), ToolCard( key="pose", title="Batch Pose Tracking", @@ -1060,6 +1048,19 @@ def _install_line(extra: str, missing) -> str: unavailable_reason=( "" if has_pose else _install_line("vision", missing_pose_batch_deps()) ), + install_extra="vision" if installable else "", + ), + ToolCard( + key="behavior", + title="Behavior Analysis", + description=( + "Cluster and label behaviour from pose tracks, and train a " + "classifier on what you label." + ), + glyph="behavior", + available=has_behavior, + unavailable_reason=behavior_reason, + install_extra="behavior" if installable else "", ), ToolCard( key="review", @@ -1071,6 +1072,7 @@ def _install_line(extra: str, missing) -> str: available=has_behavior, # The same probe: this reads the behavior tool's own outputs. unavailable_reason=behavior_reason, + install_extra="behavior" if installable else "", ), ToolCard( key="multicam", @@ -1091,6 +1093,58 @@ def _install_line(extra: str, missing) -> str: self._analyze_page = ToolsPage(cards, parent=self) self._analyze_page.tool_chosen.connect(self._on_tool_chosen) + self._analyze_page.install_requested.connect(self._on_install_extra) + + def _on_install_extra(self, extra: str) -> None: + """Install an extra from its card, then offer the restart that loads it. + + A restart rather than a live re-probe: the availability probes are + memoized, and the tool windows import their stacks once. + """ + from glider.plugins.installer import install_extra + + page = self._analyze_page + page.set_install_status(extra, f"Installing the {extra} extra…", busy=True) + + async def _install() -> None: + result = await install_extra( + extra, + on_output=lambda line: page.set_install_status(extra, line[-120:], busy=True), + ) + if not result.ok: + page.set_install_status(extra, f"Install failed: {result.message}", busy=False) + box = QMessageBox( + QMessageBox.Icon.Warning, "Install failed", result.message, parent=self + ) + box.setDetailedText(result.output[-8000:]) + box.exec() + return + page.set_install_status(extra, "Installed. Restart GLIDER to use it.", busy=True) + answer = QMessageBox.question( + self, + "Restart GLIDER?", + f"The {extra} extra is installed. GLIDER needs a restart to load it. Restart now?", + ) + if answer == QMessageBox.StandardButton.Yes: + self._restart() + + self._run_async(_install()) + + def _restart(self) -> None: + """Close through the normal path (save prompt included), then relaunch. + + The relaunch waits for interpreter exit, so the old process has let go + of its serial ports and cameras before the new one reaches for them. + """ + if self.close(): + import atexit + import subprocess + + atexit.register( + subprocess.Popen, + [sys.executable, "-m", "glider", *sys.argv[1:]], + start_new_session=True, + ) def _on_tool_chosen(self, key: str) -> None: """Open the window a card stands for. @@ -1581,6 +1635,8 @@ def _build_camera_panel(self) -> CameraPanel: camera_panel.set_event_logger(self._core.event_logger) # Lets Behavior Input nodes in the flow react to live classification. camera_panel.set_live_signals(self._core.live_signals) + if self._core.calibration is None: + self._restore_camera_arena() camera_panel.set_calibration(self._core.calibration) camera_panel._preview.set_calibration(self._core.calibration) camera_panel.set_zone_configuration(self._zone_config) @@ -1619,7 +1675,7 @@ def _populate_builder_panels(self) -> None: Each widget here is the same object the Builder's dock widgets used to wrap — this method re-hosts rather than rewrites. Left panel: Nodes, - Hardware, Control, Files. Right panel: Properties, Camera. + Hardware, Control. Right panel: Properties, Camera. """ self._build_builder_panels() self._host_builder_panels_in_shell() @@ -1693,10 +1749,10 @@ def session_fn(): self._move_camera_to_builder() def _host_builder_panels_in_shell(self) -> None: - """Put the shared panels in the desktop frame, with Control and Files. + """Put the shared panels in the desktop frame, with Control. - Control and Files are desktop-only and are built here, on the first - call; later calls do nothing. + Control is desktop-only and is built here, on the first call; later + calls do nothing. """ if self._device_control_panel is not None: return @@ -1712,52 +1768,12 @@ def _host_builder_panels_in_shell(self) -> None: # to the Builder's own _device_control_panel (and the strip, above). self._hardware_panel.hardware_changed.connect(self._device_control_panel.refresh_devices) - # --- Files --- - from PyQt6.QtWidgets import QFrame, QPushButton, QScrollArea - - files_scroll = QScrollArea() - files_scroll.setWidgetResizable(True) - files_scroll.setFrameShape(QFrame.Shape.NoFrame) - files_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) - - files_widget = QWidget() - files_layout = QVBoxLayout(files_widget) - files_layout.setContentsMargins(4, 4, 4, 4) - files_layout.setSpacing(4) - - new_btn = QPushButton("New") - new_btn.setFixedHeight(36) - new_btn.clicked.connect(self._on_new) - files_layout.addWidget(new_btn) - - open_btn = QPushButton("Open") - open_btn.setFixedHeight(36) - open_btn.clicked.connect(self._on_open) - files_layout.addWidget(open_btn) - - save_btn = QPushButton("Save") - save_btn.setFixedHeight(36) - save_btn.clicked.connect(self._on_save) - files_layout.addWidget(save_btn) - - save_as_btn = QPushButton("Save As...") - save_as_btn.setFixedHeight(36) - save_as_btn.clicked.connect(self._on_save_as) - files_layout.addWidget(save_as_btn) - - files_layout.addStretch() - - files_scroll.setWidget(files_widget) - self._files_panel = files_scroll - - # --- Into the shell. Files was a dock the desktop kept hidden by - # default; as a tab it costs nothing to leave in place, and the rail - # keeps it discoverable rather than hidden behind a menu item. + # --- Into the shell. No Files tab: the desktop has File in the menu + # bar and the palette; only the Pi runner, which has neither, needs one. left = self._builder_view.left left.add_tab("nodes", "Nodes", self._node_library_panel, "N") left.add_tab("hardware", "Hardware", self._hardware_panel, "H") left.add_tab("control", "Control", self._device_control_panel, "D") - left.add_tab("files", "Files", self._files_panel, "F") right = self._builder_view.right right.add_tab("properties", "Properties", self._properties_host, "P") @@ -2219,7 +2235,6 @@ def _setup_menu(self) -> None: "Install the behavior extra: pip install glider[behavior] " f"(missing: {', '.join(missing_behavior_deps())})" ) - tools_menu.addAction(behavior_action) pose_batch_action = QAction("Batch &Pose Tracking…", self) pose_batch_action.setStatusTip( @@ -2238,7 +2253,9 @@ def _setup_menu(self) -> None: "Install the vision extra: pip install glider[vision] " f"(missing: {', '.join(missing_pose_batch_deps())})" ) + # Pose first: Behavior Analysis consumes the keypoints it writes. tools_menu.addAction(pose_batch_action) + tools_menu.addAction(behavior_action) multi_camera_action = QAction("&Multi-Camera Recording…", self) multi_camera_action.setStatusTip( @@ -3648,19 +3665,50 @@ def _on_camera_settings(self) -> None: logger.exception("Could not apply the Computer Vision settings") QMessageBox.warning(self, "Computer Vision", str(exc)) + #: QSettings key for the live camera's arena. The rig's camera stays put + #: between sessions, so its arena should too. + _ARENA_KEY = "camera/arena" + def _on_camera_calibration(self) -> None: - dialog = CalibrationDialog( - frame_provider=CameraFrameProvider(self._core.camera_manager), - calibration=self._core.calibration, - parent=self, - ) - if dialog.exec() == QDialog.DialogCode.Accepted: - calibration = dialog.get_calibration() - if calibration.is_calibrated: - logger.info( - f"Camera calibrated: {calibration.pixels_per_mm:.2f} pixels/mm " - f"({len(calibration.lines)} lines)" + """Outline the live camera's arena; it supplies the px/mm scale (#212).""" + from glider.gui.dialogs.arena_dialog import ArenaDialog + + grabbed = CameraFrameProvider(self._core.camera_manager).get_frame() + if grabbed is None: + QMessageBox.information( + self, "Arena", "Connect the camera first: the arena is drawn on a live frame." + ) + return + dialog = ArenaDialog(grabbed[0], title="Live camera", parent=self) + if self._core.calibration is not None: + dialog.set_arena(self._core.calibration) + try: + if dialog.exec() == QDialog.DialogCode.Accepted and dialog.calibration(): + self._set_camera_arena(dialog.calibration()) + self._settings.setValue( + self._ARENA_KEY, json.dumps(self._core.calibration.to_dict()) ) + logger.info("Camera arena set: %.2f px/mm", self._core.calibration.pixels_per_mm) + finally: + dialog.deleteLater() + + def _set_camera_arena(self, arena) -> None: + self._core.calibration = arena + if self._camera_panel is not None: + self._camera_panel.set_calibration(arena) + self._camera_panel._preview.set_calibration(arena) + + def _restore_camera_arena(self) -> None: + """The arena saved last session, if there is a readable one.""" + from glider.vision.arena import ArenaCalibration + + raw = self._settings.value(self._ARENA_KEY) + if not raw: + return + try: + self._core.calibration = ArenaCalibration.from_dict(json.loads(raw)) + except (ValueError, KeyError, TypeError): + logger.warning("Ignoring an unreadable saved camera arena") def _on_zones_requested(self) -> None: self._open_zone_dialog(frame=None) @@ -3794,6 +3842,13 @@ def _on_plugins_window_ready(self, task) -> None: return self._plugins_dialog = dialog dialog.finished.connect(self._on_plugins_window_closed) + # An install registers its nodes in place; the library lists them only + # when asked, so without this they appeared only after a restart (#205). + dialog.plugins_changed.connect(self._on_plugins_changed) + + def _on_plugins_changed(self) -> None: + if self._node_library_panel is not None: + self._node_library_panel.refresh_plugin_nodes() def _on_plugins_window_closed(self, _result: int = 0) -> None: self._plugins_dialog = None diff --git a/src/glider/gui/panels/camera_panel.py b/src/glider/gui/panels/camera_panel.py index a5921bbf..8d5b9d8b 100644 --- a/src/glider/gui/panels/camera_panel.py +++ b/src/glider/gui/panels/camera_panel.py @@ -40,7 +40,7 @@ from glider.gui.qthreads import retire_thread if TYPE_CHECKING: - from glider.vision.calibration import CameraCalibration + from glider.vision.arena import ArenaCalibration from glider.vision.camera_manager import CameraManager from glider.vision.cv_processor import CVProcessor from glider.vision.multi_camera_manager import MultiCameraManager @@ -159,7 +159,7 @@ def set_calibration(self, calibration) -> None: self._calibration = calibration def set_show_calibration(self, show: bool) -> None: - """Toggle calibration line display.""" + """Toggle the arena outline.""" self._show_calibration = show def set_zone_configuration(self, config: "ZoneConfiguration") -> None: @@ -219,29 +219,10 @@ def update_frame(self, frame: np.ndarray) -> None: """Update display with new frame.""" self._placeholder = False - # Draw calibration lines if enabled + # Draw the arena outline if enabled display_frame = frame - if self._show_calibration and self._calibration and self._calibration.lines: - display_frame = frame.copy() - h, w = display_frame.shape[:2] - for line in self._calibration.lines: - x1, y1, x2, y2 = line.get_pixel_coords(w, h) - cv2.line(display_frame, (x1, y1), (x2, y2), line.color, 2) - cv2.circle(display_frame, (x1, y1), 4, line.color, -1) - cv2.circle(display_frame, (x2, y2), 4, line.color, -1) - # Draw label - mid_x = (x1 + x2) // 2 - mid_y = (y1 + y2) // 2 - label = f"{line.length:.1f}{line.unit.value}" - cv2.putText( - display_frame, - label, - (mid_x + 5, mid_y - 5), - cv2.FONT_HERSHEY_SIMPLEX, - 0.4, - line.color, - 1, - ) + if self._show_calibration and self._calibration is not None: + display_frame = self._calibration.draw(frame) # Draw zones if enabled if self._show_zones and self._zone_config and self._zone_config.zones: @@ -354,7 +335,7 @@ def __init__( # same reason as the two above: standalone panels and unit tests build # a CameraPanel without a core. self._live_signals: Any | None = None - self._calibration: CameraCalibration | None = None + self._calibration: ArenaCalibration | None = None self._zone_config: ZoneConfiguration | None = None self._preview_active = False self._multi_camera_mode = False @@ -1081,7 +1062,7 @@ def _process_frame_on_main_thread( # Write annotated frame to video recorder if recording if annotated_frame is not None and self._video_recorder is not None: # Draw calibration lines on the annotated frame for the video - if self._calibration and self._calibration.lines: + if self._calibration is not None: annotated_frame = self._draw_calibration_lines(annotated_frame) self._video_recorder.write_annotated_frame(annotated_frame) @@ -1094,8 +1075,7 @@ def _process_frame_on_main_thread( if self._tracking_logger is not None and self._tracking_logger.is_recording: h, w = frame.shape[:2] self._tracking_logger.set_frame_size(w, h) - if self._calibration: - self._tracking_logger.set_calibration(self._calibration) + self._tracking_logger.set_calibration(self._calibration) motion_detected = motion.motion_detected if motion else False motion_area = motion.motion_area if motion else 0.0 @@ -1150,29 +1130,10 @@ def _process_cv_results_on_main_thread( self._process_frame_on_main_thread(frame_data, detections, tracked, motion) def _draw_calibration_lines(self, frame: np.ndarray) -> np.ndarray: - """Draw calibration lines on a frame for video recording.""" - if not self._calibration or not self._calibration.lines: + """Draw the arena outline on a frame for video recording.""" + if self._calibration is None: return frame - - output = frame.copy() - h, w = output.shape[:2] - - for line in self._calibration.lines: - x1, y1, x2, y2 = line.get_pixel_coords(w, h) - # Draw the line - cv2.line(output, (x1, y1), (x2, y2), line.color, 2) - # Draw endpoint circles - cv2.circle(output, (x1, y1), 4, line.color, -1) - cv2.circle(output, (x2, y2), 4, line.color, -1) - # Draw measurement label at midpoint - mid_x = (x1 + x2) // 2 - mid_y = (y1 + y2) // 2 - label = f"{line.length:.1f}{line.unit.value}" - cv2.putText( - output, label, (mid_x + 5, mid_y - 5), cv2.FONT_HERSHEY_SIMPLEX, 0.4, line.color, 1 - ) - - return output + return self._calibration.draw(frame) def _update_fps_display(self) -> None: """Update FPS display.""" @@ -1427,11 +1388,11 @@ def _process_multi_frame_on_main_thread( # Write annotated frame to video recorder if recording if annotated_frame is not None: if self._multi_video_recorder is not None: - if self._calibration and self._calibration.lines: + if self._calibration is not None: annotated_frame = self._draw_calibration_lines(annotated_frame) self._multi_video_recorder.write_annotated_frame(annotated_frame) elif self._video_recorder is not None: - if self._calibration and self._calibration.lines: + if self._calibration is not None: annotated_frame = self._draw_calibration_lines(annotated_frame) self._video_recorder.write_annotated_frame(annotated_frame) @@ -1444,8 +1405,7 @@ def _process_multi_frame_on_main_thread( if self._tracking_logger is not None and self._tracking_logger.is_recording: h, w = frame.shape[:2] self._tracking_logger.set_frame_size(w, h) - if self._calibration: - self._tracking_logger.set_calibration(self._calibration) + self._tracking_logger.set_calibration(self._calibration) motion_detected = motion.motion_detected if motion else False motion_area = motion.motion_area if motion else 0.0 @@ -1540,8 +1500,8 @@ def _dispatch_frame_tick(self, frame_no: int, frame_ts: float) -> None: except Exception: logger.exception("event_logger.set_current_frame failed") - def set_calibration(self, calibration: "CameraCalibration") -> None: - """Set the camera calibration for real-world measurements.""" + def set_calibration(self, calibration: "ArenaCalibration | None") -> None: + """Set the arena that gives the camera its real-world scale.""" self._calibration = calibration def set_zone_configuration(self, zone_config: "ZoneConfiguration") -> None: diff --git a/src/glider/gui/panels/experiment_page.py b/src/glider/gui/panels/experiment_page.py index 805df4dc..54c3e12a 100644 --- a/src/glider/gui/panels/experiment_page.py +++ b/src/glider/gui/panels/experiment_page.py @@ -75,17 +75,17 @@ #: The sections, in rail order. These strings are the API: the window supplies #: one builder per key. #: -#: Metadata and Mice were one section, and splitting them is what the rail is +#: Metadata and Animals were one section, and splitting them is what the rail is #: for: they are edited at different times by different people -- the protocol #: and experimenter once when the experiment is designed, the animal list again #: before every cohort -- and stacking them meant scrolling past seven fields #: you were not there to change to reach the table you were. -SECTION_KEYS: tuple[str, ...] = ("metadata", "mice", "zones", "vocabulary", "plugins") +SECTION_KEYS: tuple[str, ...] = ("metadata", "animals", "zones", "vocabulary", "plugins") #: What each section is called on the rail. SECTION_LABELS: dict[str, str] = { "metadata": "Metadata", - "mice": "Mice", + "animals": "Animals", "zones": "Zones", "vocabulary": "Lab Vocabulary", "plugins": "Plugins", @@ -212,7 +212,7 @@ def _build_rail(self) -> QWidget: # Full rail width, so the hit target is the row rather than the # words. A QToolButton sizes itself to its label by default, which # made every entry a different width and left most of the rail - # looking clickable but inert -- you aim at "Mice" and hit nothing, + # looking clickable but inert -- you aim at "Animals" and hit nothing, # because the button stops where the text does. button.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) button.setIcon(glyph_icon(button, key, ICON_PX)) diff --git a/src/glider/gui/panels/tools_page.py b/src/glider/gui/panels/tools_page.py index 219650c4..64b07ec1 100644 --- a/src/glider/gui/panels/tools_page.py +++ b/src/glider/gui/panels/tools_page.py @@ -34,6 +34,7 @@ QFrame, QHBoxLayout, QLabel, + QPushButton, QScrollArea, QVBoxLayout, QWidget, @@ -65,6 +66,8 @@ class ToolCard: unavailable_reason: Shown in place of the description when ``available`` is False. Say what to install, not that something is missing -- the card exists to end the question, not to restate it. + install_extra: The GLIDER extra that makes an unavailable tool + available. When set, the card offers an Install button for it. """ key: str @@ -73,6 +76,7 @@ class ToolCard: glyph: str available: bool = True unavailable_reason: str = "" + install_extra: str = "" class ToolsPage(QWidget): @@ -85,15 +89,19 @@ class ToolsPage(QWidget): Signals: tool_chosen: Emitted with a card's ``key`` when an *available* card is clicked. An unavailable one never emits: it has nothing to open. + install_requested: Emitted with an extra's name when a card's Install + button is pressed. """ tool_chosen = pyqtSignal(str) + install_requested = pyqtSignal(str) def __init__(self, cards: list[ToolCard], parent: QWidget | None = None) -> None: super().__init__(parent) self.setObjectName("toolsPage") self._cards = list(cards) self._frames: dict[str, QFrame] = {} + self._install_rows: dict[str, tuple[QLabel, QPushButton]] = {} outer = QVBoxLayout(self) outer.setContentsMargins(0, 0, 0, 0) @@ -170,8 +178,29 @@ def _build_card(self, card: ToolCard) -> QFrame: text.addWidget(body) row.addLayout(text, 1) + if not card.available and card.install_extra: + button = QPushButton("Install") + button.setObjectName("toolCardInstall") + button.clicked.connect( + lambda _=False, extra=card.install_extra: self.install_requested.emit(extra) + ) + row.addWidget(button, 0, Qt.AlignmentFlag.AlignTop) + self._install_rows[card.key] = (body, button) return frame + def set_install_status(self, extra: str, message: str, *, busy: bool) -> None: + """Show an install's progress on every card waiting on *extra*.""" + for card in self._cards: + if card.install_extra == extra and card.key in self._install_rows: + body, button = self._install_rows[card.key] + body.setText(message) + button.setEnabled(not busy) + + def install_button(self, key: str) -> QPushButton | None: + """The card's Install button, if it has one. For tests.""" + row = self._install_rows.get(key) + return row[1] if row else None + # ------------------------------------------------------------- selection def _on_clicked(self, key: str) -> None: diff --git a/src/glider/gui/pose_batch/arena_actions.py b/src/glider/gui/pose_batch/arena_actions.py index e90a6282..d1a8567e 100644 --- a/src/glider/gui/pose_batch/arena_actions.py +++ b/src/glider/gui/pose_batch/arena_actions.py @@ -75,6 +75,7 @@ def copy_arena_to( width_cm=arena.width_cm, height_cm=arena.height_cm, frame_size=resolution, + shape=arena.shape, ), confirmed=False, ) diff --git a/src/glider/gui/pose_batch/calibration_table.py b/src/glider/gui/pose_batch/calibration_table.py index 98b7b878..128e2604 100644 --- a/src/glider/gui/pose_batch/calibration_table.py +++ b/src/glider/gui/pose_batch/calibration_table.py @@ -22,8 +22,7 @@ _COLUMNS = ("Video", "Resolution", "px/mm", "Arena", "Status") -#: Index of the arena column. Double-clicking it asks for the perimeter rather -#: than the line, so both dialogs are reachable without a modifier key. +#: Index of the arena column. ARENA_COLUMN = 3 _STATUS_COLUMN = 4 @@ -31,7 +30,6 @@ class CalibrationTable(QTableWidget): """One row per video: resolution, scale, and whether it still needs work.""" - calibrate_requested = pyqtSignal(object) # Path arena_requested = pyqtSignal(object) # Path def __init__(self, parent=None): @@ -126,7 +124,11 @@ def _arena_cell(self, video: Path) -> tuple[str, str]: except DegenerateArenaError as exc: return "⚠ unusable", str(exc) - size = f"{arena.width_cm:g}x{arena.height_cm:g} cm" + size = ( + f"⌀{arena.width_cm:g} cm" + if arena.shape == "ellipse" + else f"{arena.width_cm:g}x{arena.height_cm:g} cm" + ) lines = [ f"{size} floor", f"opposite edges {residuals['edge_ratio']:.2f}x", @@ -158,7 +160,4 @@ def _set_cell(self, row: int, col: int, text: str, *, tooltip: str = "") -> None def _on_double_click(self, item: QTableWidgetItem) -> None: row = item.row() if 0 <= row < len(self._videos): - signal = ( - self.arena_requested if item.column() == ARENA_COLUMN else self.calibrate_requested - ) - signal.emit(self._videos[row]) + self.arena_requested.emit(self._videos[row]) diff --git a/src/glider/gui/pose_batch/window.py b/src/glider/gui/pose_batch/window.py index ebaaab26..d7b65211 100644 --- a/src/glider/gui/pose_batch/window.py +++ b/src/glider/gui/pose_batch/window.py @@ -58,7 +58,6 @@ set_text_role, ) from glider.vision.arena import ArenaCalibration, DegenerateArenaError -from glider.vision.calibration import CameraCalibration from glider.vision.calibration_set import CalibrationSet, CalibrationSetError from glider.vision.pose import batch as batch_core from glider.vision.pose.spec import read_pose_model_meta @@ -350,7 +349,7 @@ def _build_model_group(self) -> Card: self._animals_spin.setToolTip( "How many animals are in each video. This is not detected -- it is " "the constraint that lets tracking stitch a fragmented track back " - "into one animal. Too high and one mouse is split into two that " + "into one animal. Too high and one animal is split into two that " "each vanish for half the video; too low and a real animal is " "dropped entirely." ) @@ -447,48 +446,33 @@ def _build_sources_group(self) -> Card: return card def _build_calibration_group(self) -> Card: - card = Card("Pixel-to-distance calibration", "required for every video") + card = Card("Arena", "required for every video") self._calibration_card = card card.setToolTip( - "Every video needs its floor perimeter drawn before the batch can " - "run; the arena carries the scale, so a measurement line is optional. " - "The DLC CSVs stay in pixels; the scale is written to the master " - "calibration file." + "Every video needs its arena floor outlined before the batch can " + "run; the arena carries the scale. The DLC CSVs stay in pixels; the " + "scale is written to the master calibration file." ) self._cal_table = CalibrationTable() self._cal_table.set_calibration_set(self._calibrations) - self._cal_table.calibrate_requested.connect(self._open_calibration) self._cal_table.arena_requested.connect(self._open_arena) # ~4-5 rows: enough to see calibration status at a glance even if the # splitter above gets dragged down to its floor. self._cal_table.setMinimumHeight(140) card.add(self._cal_table, 1) - # The arena leads this row: it is what Run requires, and it supplies the - # scale as a by-product. The line is the optional cross-check now. + # The arena is the calibration: it is what Run requires, and it supplies + # the scale as a by-product (#212 retired the separate line tool here). arena = QPushButton("Arena…") set_button_role(arena, "primary") arena.setToolTip( - "Required. Draw the floor perimeter of the selected video (or " - "double-click its Arena cell).\nGives a centre zone that means the " + "Required. Outline the arena floor of the selected video, square or " + "round (or double-click its row).\nGives a centre zone that means the " "same patch of floor in every video, and a scale that accounts for " "perspective." ) arena.clicked.connect(self._arena_selected) - calibrate = QPushButton("Calibrate…") - calibrate.setToolTip( - "Optional. Draw a measurement line on the selected video (or " - "double-click its row).\nThe arena already carries the scale; a line " - "is a second opinion, not a substitute." - ) - calibrate.clicked.connect(self._calibrate_selected) - copy_btn = QPushButton("Copy to Selected") - copy_btn.setToolTip( - "Stamp one measurement line onto the other selected videos — for " - "videos shot on the same rig at the same camera height." - ) - copy_btn.clicked.connect(self._copy_calibration_to_selected) copy_arena_btn = QPushButton("Copy Arena…") copy_arena_btn.setToolTip( "Stamp one video's floor perimeter onto the other selected videos.\n" @@ -503,7 +487,7 @@ def _build_calibration_group(self) -> Card: buttons = QHBoxLayout() buttons.setSpacing(8) - for button in (arena, copy_arena_btn, calibrate, copy_btn, clear_btn): + for button in (arena, copy_arena_btn, clear_btn): buttons.addWidget(button) buttons.addStretch(1) card.add(buttons) @@ -693,65 +677,6 @@ def _refresh_videos(self) -> None: # calibration # ------------------------------------------------------------------ - def _open_calibration(self, video: Path) -> None: - """Draw measurement lines on a frame scrubbed out of *video*.""" - from glider.gui.dialogs.calibration_dialog import CalibrationDialog - from glider.vision.frame_provider import VideoFrameProvider - - provider = VideoFrameProvider(video) - if not provider.is_connected: - provider.release() - QMessageBox.warning( - self, - "Cannot Open Video", - f"{video.name} could not be opened for calibration.", - ) - return - - # Edit a copy: Cancel must leave the stored calibration untouched. - existing = self._calibrations.get(video) - working = ( - CameraCalibration.from_dict(existing.to_dict()) - if existing is not None - else CameraCalibration() - ) - dialog = None - try: - dialog = CalibrationDialog( - frame_provider=provider, - calibration=working, - parent=self, - show_file_buttons=False, - ) - dialog.setWindowTitle(f"Calibrate — {video.name}") - if dialog.exec() == QDialog.DialogCode.Accepted: - self._calibrations.set(video, dialog.get_calibration()) - ppm = self._calibrations.px_per_mm(video) - self._log.appendPlainText( - f"{video.name}: {ppm:.3f} px/mm" - if ppm - else f"{video.name}: no usable scale drawn" - ) - finally: - if dialog is not None: - # parent=self hands ownership to the window, so dropping the - # Python reference leaves the C++ dialog — and the full-res - # frame and pixmap it holds — alive for the session. - dialog.deleteLater() - provider.release() - - self._cal_table.refresh() - self._validate() - - def _calibrate_selected(self) -> None: - selected = self._cal_table.selected_videos() - if not selected: - QMessageBox.information( - self, "Calibrate", "Select a video in the calibration table first." - ) - return - self._open_calibration(selected[0]) - def _open_arena(self, video: Path) -> None: """Click the four floor corners of *video*'s arena on a scrubbed frame.""" from glider.gui.dialogs.arena_dialog import ArenaDialog @@ -780,8 +705,7 @@ def _open_arena(self, video: Path) -> None: dialog = ArenaDialog(frame, title=video.name, parent=self) existing = self._calibrations.get_arena(video) if existing is not None: - dialog.arena_spin.setValue(existing.width_cm) - dialog.canvas.set_corners(existing.corners) + dialog.set_arena(existing) if dialog.exec() == QDialog.DialogCode.Accepted: arena = dialog.calibration() if arena is not None: @@ -860,94 +784,13 @@ def _zone_configs(self) -> dict[Path, ZoneConfiguration]: configs[Path(video).resolve()] = config return configs - def _retarget_calibration( - self, template: CameraCalibration, video: Path - ) -> CameraCalibration | None: - """A copy of *template* reconstructed at *video*'s own resolution. - - Lines are stored normalized, so on a same-rig video the ruler spans the - same fraction of the frame whatever the recording resolution — but - ``pixels_per_mm`` reconstructs pixels at ``calibration_width``. Carrying - the source's resolution over would report the source's scale for a video - that does not have it. Returns None when the video will not open, since - guessing its resolution is exactly the error being fixed. - """ - from glider.vision.video_source import VideoFileSource - - reader = VideoFileSource() - try: - if not reader.load(video): - return None - width, height = reader.resolution - finally: - reader.release() - if width <= 0 or height <= 0: - return None - - # from_dict(to_dict()) is a deep copy: the videos must not share - # a CameraCalibration, or editing one silently edits the others. - copy = CameraCalibration.from_dict(template.to_dict()) - copy.calibration_width = width - copy.calibration_height = height - return copy - - def _copy_calibration_to_selected(self) -> None: - """Stamp the one calibrated selected video onto the rest of the selection.""" - selected = self._cal_table.selected_videos() - sources = [v for v in selected if self._calibrations.px_per_mm(v) is not None] - if not sources: - QMessageBox.information( - self, - "Copy Calibration", - "Select one calibrated video plus the videos to copy it to.", - ) - return - - source = sources[0] - template = self._calibrations.get(source) - targets = [v for v in selected if v != source] - already = {v for v in targets if self._calibrations.px_per_mm(v) is not None} - - if already and not self._confirm_overwrite(source, len(already)): - # Declined: filling the blanks is what "copy to selected" is for, - # and it is the half of the job that cannot destroy anything. - targets = [v for v in targets if v not in already] - - filled = 0 - overwritten = 0 - skipped: list[Path] = [] - for video in targets: - retargeted = self._retarget_calibration(template, video) - if retargeted is None: - skipped.append(video) - continue - self._calibrations.set(video, retargeted) - if video in already: - overwritten += 1 - else: - filled += 1 - - self._log.appendPlainText( - f"Copied {source.name}'s calibration: {filled} uncalibrated video(s) filled, " - f"{overwritten} existing calibration(s) overwritten." - ) - if skipped: - names = ", ".join(v.name for v in skipped) - self._log.appendPlainText( - f"Not copied to {len(skipped)} video(s) that could not be opened to " - f"read their resolution: {names}" - ) - self._cal_table.refresh() - self._validate() - def _copy_arena_to_selected(self) -> None: """Stamp the first selected video's arena onto the rest of the selection. The copies land unconfirmed, so Run stays blocked until each has been opened and checked — see :func:`arena_actions.copy_arena_to` for why. - Asks before replacing an arena that is already there, the way - :meth:`_copy_calibration_to_selected` does. Selecting a whole cohort + Asks before replacing an arena that is already there. Selecting a whole cohort and clicking Copy is one action with no undo, and what it would otherwise destroy is exactly the per-video human judgement this window now makes mandatory. diff --git a/src/glider/gui/shell/command_palette.py b/src/glider/gui/shell/command_palette.py index 507cca33..c39defc2 100644 --- a/src/glider/gui/shell/command_palette.py +++ b/src/glider/gui/shell/command_palette.py @@ -520,7 +520,12 @@ def _build_row(self, command: Command) -> QFrame: layout.addStretch(1) - shortcut = QLabel(command.shortcut, row) + # Stored portable ("Ctrl+N") so it compares the same everywhere; shown + # native, so a Mac reads "⌘N" rather than a Ctrl key it does not have. + shortcut = QLabel( + QKeySequence(command.shortcut).toString(QKeySequence.SequenceFormat.NativeText), + row, + ) shortcut.setObjectName("commandPaletteRowShortcut") shortcut.setVisible(bool(command.shortcut)) layout.addWidget(shortcut) diff --git a/src/glider/gui/shell/status_strip.py b/src/glider/gui/shell/status_strip.py index e2ef5d36..cec210e9 100644 --- a/src/glider/gui/shell/status_strip.py +++ b/src/glider/gui/shell/status_strip.py @@ -84,6 +84,7 @@ from PyQt6.QtGui import ( QIcon, QIconEngine, + QKeySequence, QMouseEvent, QPainter, QPainterPath, @@ -491,8 +492,9 @@ def __init__(self, parent: QWidget | None = None) -> None: self._palette_hint = QToolButton(self) self._palette_hint.setObjectName("statusStripPaletteHint") - self._palette_hint.setText("Ctrl K") - self._palette_hint.setToolTip("Command palette (Ctrl+K)") + native = QKeySequence("Ctrl+K").toString(QKeySequence.SequenceFormat.NativeText) + self._palette_hint.setText(native.replace("+", " ")) + self._palette_hint.setToolTip(f"Command palette ({native})") self._palette_hint.setCursor(Qt.CursorShape.PointingHandCursor) self._palette_hint.clicked.connect(lambda _checked=False: self.palette_requested.emit()) outer.addWidget(self._palette_hint) diff --git a/src/glider/gui/styles/colors.py b/src/glider/gui/styles/colors.py index a737d26a..d82424c7 100644 --- a/src/glider/gui/styles/colors.py +++ b/src/glider/gui/styles/colors.py @@ -156,7 +156,7 @@ def qcolor_with_alpha(color: QColor, alpha: float) -> QColor: # For small illustrative icons -- currently the Experiment tab's section rail. # Deliberately apart from every palette above, and the one place in the theme # where colour identifies a *thing* rather than a state: the point of these is -# that you find "Mice" by its pink mouse without reading four labels, which is +# that you find "Animals" by its pink glyph without reading four labels, which is # how a rail of icons earns its space over a rail of words. # # Each is a pair: a soft fill and a deeper line of the same hue, so a glyph can diff --git a/src/glider/gui/styles/icons/lucide/mice.svg b/src/glider/gui/styles/icons/lucide/animals.svg similarity index 100% rename from src/glider/gui/styles/icons/lucide/mice.svg rename to src/glider/gui/styles/icons/lucide/animals.svg diff --git a/src/glider/gui/widgets/pastel_glyphs.py b/src/glider/gui/widgets/pastel_glyphs.py index e7621eed..86fd576d 100644 --- a/src/glider/gui/widgets/pastel_glyphs.py +++ b/src/glider/gui/widgets/pastel_glyphs.py @@ -16,7 +16,7 @@ each icon is recoloured to a pastel from :mod:`~glider.gui.styles.colors` on the way to the painter. That is not decoration: -* Colour is what makes the rail readable at a glance. You find Mice by the pink +* Colour is what makes the rail readable at a glance. You find Animals by the pink rat rather than by reading five labels, which is the whole reason an icon beats a word there. * It is deliberately *not* a state. The icon keeps its hue when its row is @@ -69,7 +69,7 @@ GLYPH_COLOURS: dict[str, str] = { # Experiment rail "metadata": colors.PASTEL_LILAC, - "mice": colors.PASTEL_ROSE, + "animals": colors.PASTEL_ROSE, "zones": colors.PASTEL_MINT, "vocabulary": colors.PASTEL_PEACH, "plugins": colors.PASTEL_SKY, diff --git a/src/glider/plugins/installer.py b/src/glider/plugins/installer.py index c3e8ad56..bcbf3d35 100644 --- a/src/glider/plugins/installer.py +++ b/src/glider/plugins/installer.py @@ -9,6 +9,7 @@ import asyncio import importlib +import importlib.metadata import importlib.util import logging import re @@ -360,3 +361,51 @@ async def install( return InstallResult( ok=True, message=f"Installed {entry.get('name') or package}.", output=output ) + + +def extra_requirements(extra: str, distribution: str = "GLIDER") -> list[str]: + """What GLIDER's own metadata lists for *extra*, as plain requirements. + + Read from the installed distribution rather than asking pip for + ``glider[extra]``: that would resolve the name ``glider`` against PyPI, + which is not this project, and reinstall GLIDER itself on the way. Markers + are resolved here, so a Linux-only requirement is dropped elsewhere. + """ + from packaging.requirements import Requirement + from packaging.utils import canonicalize_name + + requirements = [] + for line in importlib.metadata.requires(distribution) or (): + req = Requirement(line) + if req.marker is None or not req.marker.evaluate({"extra": extra}): + continue + # ponytail: a nested self-extra (`GLIDER[audio]`) is skipped, not + # expanded; none of the extras the Analyze cards install uses one. + if canonicalize_name(req.name) == canonicalize_name(distribution): + continue + requirements.append(f"{req.name}{req.specifier}") + return requirements + + +async def install_extra( + extra: str, + runner=None, + on_output: Callable[[str], None] | None = None, +) -> InstallResult: + """Install one of GLIDER's optional extras into this interpreter.""" + requirements = extra_requirements(extra) + if not requirements: + return InstallResult(ok=False, message=f"GLIDER lists nothing to install for {extra!r}.") + try: + args = installer_command(requirements[0], requirements[1:]) + except (NoInstallerError, MalformedEntryError) as exc: + return InstallResult(ok=False, message=str(exc)) + + returncode, output = await (runner or _default_runner)(args, on_output) + if returncode != 0: + tool = "uv" if args[0] != sys.executable else "pip" + return InstallResult( + ok=False, message=f"{tool} exited with code {returncode}.", output=output + ) + importlib.invalidate_caches() + return InstallResult(ok=True, message=f"Installed the {extra} extra.", output=output) diff --git a/src/glider/vision/arena.py b/src/glider/vision/arena.py index 001046f8..58781621 100644 --- a/src/glider/vision/arena.py +++ b/src/glider/vision/arena.py @@ -46,7 +46,14 @@ SCHEMA_VERSION = 1 -__all__ = ["SCHEMA_VERSION", "ArenaCalibration", "DegenerateArenaError"] +__all__ = ["SCHEMA_VERSION", "SHAPES", "ArenaCalibration", "DegenerateArenaError"] + +#: ``rectangle``: the corners are the floor's corners. ``ellipse``: the floor is +#: the ellipse inscribed in the corners' parallelogram -- a round arena, seen +#: under a mild tilt. An ellipse is the affine image of a circle, so the same +#: homography maps it onto a ``width_cm`` x ``height_cm`` box with the floor as +#: that box's inscribed ellipse; only containment and the centre zone differ. +SHAPES = ("rectangle", "ellipse") #: Corner order, as the operator is asked to click them. CORNER_NAMES = ("top-left", "top-right", "bottom-right", "bottom-left") @@ -78,7 +85,7 @@ def _cross(o, a, b) -> float: @dataclass class ArenaCalibration: - """The floor of a rectangular arena, located in one camera's image. + """The floor of a rectangular or round arena, located in one camera's image. Args: corners: Four image points in normalized (0-1) coordinates, in the order @@ -95,6 +102,7 @@ class ArenaCalibration: width_cm: float = 30.0 height_cm: float = 30.0 frame_size: tuple[int, int] = (640, 480) + shape: str = "rectangle" _homography: np.ndarray | None = field(default=None, init=False, repr=False, compare=False) @@ -104,6 +112,51 @@ def __post_init__(self) -> None: raise ValueError(f"An arena needs exactly four corners, got {len(self.corners)}") if self.width_cm <= 0 or self.height_cm <= 0: raise ValueError("Arena dimensions must be positive") + if self.shape not in SHAPES: + raise ValueError(f"Arena shape must be one of {SHAPES}, got {self.shape!r}") + + @classmethod + def from_ellipse( + cls, + centre: tuple[float, float], + axis_u: tuple[float, float], + axis_v: tuple[float, float], + *, + width_cm: float, + height_cm: float | None = None, + frame_size: tuple[int, int] = (640, 480), + ) -> ArenaCalibration: + """A round arena from its centre and the ends of two semi-axes. + + All three points are normalized image coordinates. The axes need not be + perpendicular in the image (conjugate diameters describe the same + ellipse), which is what lets a tilted view be fitted by dragging. + """ + c, u, v = (np.asarray(p, dtype=np.float64) for p in (centre, axis_u, axis_v)) + du, dv = u - c, v - c + corners = [c - du - dv, c + du - dv, c + du + dv, c - du + dv] + return cls( + corners=[tuple(p) for p in corners], + width_cm=width_cm, + height_cm=width_cm if height_cm is None else height_cm, + frame_size=frame_size, + shape="ellipse", + ) + + def ellipse_handles(self) -> list[tuple[float, float]]: + """``[centre, axis_u end, axis_v end]``: the inverse of :meth:`from_ellipse`.""" + tl, tr, br, bl = _as_array(self.corners) + c = (tl + tr + br + bl) / 4 + return [tuple(map(float, p)) for p in (c, (tr + br) / 2, (bl + br) / 2)] + + def outside_cm(self, x, y, margin_cm: float = 0.0): + """Whether floor points (in cm, arrays allowed) lie beyond the arena plus *margin_cm*.""" + w, h = self.width_cm, self.height_cm + if self.shape == "ellipse": + return ((x - w / 2) / (w / 2 + margin_cm)) ** 2 + ( + (y - h / 2) / (h / 2 + margin_cm) + ) ** 2 > 1 + return (x < -margin_cm) | (x > w + margin_cm) | (y < -margin_cm) | (y > h + margin_cm) # ------------------------------------------------------------------ # geometry @@ -208,6 +261,14 @@ def centre_zone_vertices(self, size_cm: float = 10.0) -> list[tuple[float, float ) cx, cy = self.width_cm / 2, self.height_cm / 2 half = size_cm / 2 + if self.shape == "ellipse": + # A round arena gets a round centre zone, as a 48-gon. + return self.to_image( + [ + (cx + half * math.cos(t), cy + half * math.sin(t)) + for t in np.linspace(0, 2 * math.pi, 48, endpoint=False) + ] + ) square = [ (cx - half, cy - half), (cx + half, cy - half), @@ -255,6 +316,50 @@ def px_per_mm_centre(self) -> float: """Centre scale in the units :mod:`glider.analysis.behavior.units` uses.""" return self.px_per_cm_centre / 10.0 + # The scalar interface the tracking logger reads off a calibration, so a + # live rig's arena drops in where a measurement line used to (#212). + + is_calibrated = True + + @property + def pixels_per_mm(self) -> float: + return self.px_per_mm_centre + + @property + def calibration_width(self) -> int: + return int(self.frame_size[0]) + + @property + def calibration_height(self) -> int: + return int(self.frame_size[1]) + + def pixels_to_mm(self, pixels: float, current_width: int | None = None, _height=None) -> float: + """*pixels* at the centre scale, rescaled if the frame is not the one drawn on.""" + ppm = self.pixels_per_mm + if current_width: + ppm *= current_width / self.calibration_width + return pixels / ppm + + def outline(self, width: int, height: int) -> list[tuple[int, int]]: + """The floor's outline as pixel points on a *width* x *height* frame.""" + if self.shape == "ellipse": + c, u, v = (np.array(p) for p in self.ellipse_handles()) + norm = [ + c + (u - c) * math.cos(t) + (v - c) * math.sin(t) + for t in np.linspace(0, 2 * math.pi, 72, endpoint=False) + ] + else: + norm = self.corners + return [(int(round(x * width)), int(round(y * height))) for x, y in norm] + + def draw(self, frame: np.ndarray, colour=(248, 189, 56)) -> np.ndarray: + """A copy of *frame* with the arena outline drawn on it (BGR).""" + out = frame.copy() + h, w = out.shape[:2] + points = np.array(self.outline(w, h), dtype=np.int32).reshape(-1, 1, 2) + cv2.polylines(out, [points], True, colour, 2, cv2.LINE_AA) + return out + # ------------------------------------------------------------------ # quality # ------------------------------------------------------------------ @@ -320,6 +425,7 @@ def to_dict(self) -> dict[str, Any]: "width_cm": self.width_cm, "height_cm": self.height_cm, "frame_size": list(self.frame_size), + "shape": self.shape, } @classmethod @@ -329,4 +435,5 @@ def from_dict(cls, data: dict[str, Any]) -> ArenaCalibration: width_cm=float(data.get("width_cm", 30.0)), height_cm=float(data.get("height_cm", 30.0)), frame_size=tuple(data.get("frame_size", (640, 480))), + shape=str(data.get("shape", "rectangle")), ) diff --git a/src/glider/vision/arena_gate.py b/src/glider/vision/arena_gate.py index 102cd60c..713919df 100644 --- a/src/glider/vision/arena_gate.py +++ b/src/glider/vision/arena_gate.py @@ -164,12 +164,8 @@ def _outside(arena, xy_px, resolution, margin_cm) -> np.ndarray: w = projected[2] with np.errstate(invalid="ignore", divide="ignore"): x, y = projected[0] / w, projected[1] / w - beyond = ( - (x < -margin_cm) - | (x > arena.width_cm + margin_cm) - | (y < -margin_cm) - | (y > arena.height_cm + margin_cm) - ) + # An ellipse lies inside its bounding box, so the argument above holds. + beyond = arena.outside_cm(x, y, margin_cm) return beyond.reshape(np.asarray(xy_px).shape[:2]) diff --git a/src/glider/vision/behavior_analyzer.py b/src/glider/vision/behavior_analyzer.py deleted file mode 100644 index 92a2f11c..00000000 --- a/src/glider/vision/behavior_analyzer.py +++ /dev/null @@ -1,246 +0,0 @@ -""" -Behavioral Analysis Module - Detect behavioral states from tracking data. - -Analyzes centroid movement patterns to classify behavioral states: -- FREEZE: Complete stillness for sustained duration -- IMMOBILE: Minor movements (grooming, adjusting) -- MOVING: Normal locomotion -- DARTING: Rapid escape/flight response -""" - -import logging -import math -from dataclasses import dataclass, field -from enum import Enum, auto - -logger = logging.getLogger(__name__) - - -class BehaviorState(Enum): - """Behavioral state classifications.""" - - UNKNOWN = auto() - FREEZE = auto() - IMMOBILE = auto() - MOVING = auto() - DARTING = auto() - - def __str__(self) -> str: - return self.name - - -@dataclass -class BehaviorSettings: - """Configuration for behavior detection thresholds.""" - - enabled: bool = True - freeze_threshold: float = 1.0 # Max pixels/frame for freeze - immobile_threshold: float = 5.0 # Max pixels/frame for immobile - dart_threshold: float = 50.0 # Min pixels/frame for darting - freeze_duration: int = 15 # Frames required to confirm freeze - smoothing_window: int = 5 # Frames for velocity smoothing - - def to_dict(self) -> dict: - return { - "enabled": self.enabled, - "freeze_threshold": self.freeze_threshold, - "immobile_threshold": self.immobile_threshold, - "dart_threshold": self.dart_threshold, - "freeze_duration": self.freeze_duration, - "smoothing_window": self.smoothing_window, - } - - @classmethod - def from_dict(cls, data: dict) -> "BehaviorSettings": - return cls( - enabled=data.get("enabled", True), - freeze_threshold=data.get("freeze_threshold", 1.0), - immobile_threshold=data.get("immobile_threshold", 5.0), - dart_threshold=data.get("dart_threshold", 50.0), - freeze_duration=data.get("freeze_duration", 15), - smoothing_window=data.get("smoothing_window", 5), - ) - - -@dataclass -class ObjectBehaviorState: - """Tracks behavioral state for a single object.""" - - current_state: BehaviorState = BehaviorState.UNKNOWN - velocity: float = 0.0 # Current smoothed velocity in pixels/frame - low_movement_frames: int = 0 # Consecutive frames below freeze threshold - velocities: list[float] = field(default_factory=list) # Recent velocity history - - -class BehaviorAnalyzer: - """ - Analyzes object tracking data to determine behavioral states. - - Uses centroid trail history to calculate velocity and classify - behavior based on configurable thresholds. - """ - - def __init__(self, settings: BehaviorSettings | None = None): - """ - Initialize the behavior analyzer. - - Args: - settings: Behavior detection settings, uses defaults if None - """ - self._settings = settings or BehaviorSettings() - # Track state per object ID - self._object_states: dict[int, ObjectBehaviorState] = {} - - @property - def settings(self) -> BehaviorSettings: - """Get current settings.""" - return self._settings - - @settings.setter - def settings(self, value: BehaviorSettings) -> None: - """Update settings.""" - self._settings = value - - def update_settings(self, settings: BehaviorSettings) -> None: - """ - Update behavior detection settings. - - Args: - settings: New settings to apply - """ - self._settings = settings - - def analyze( - self, - track_id: int, - trail_history: list[tuple[int, int]], - ) -> tuple[BehaviorState, float]: - """ - Analyze trail history to determine current behavioral state. - - Args: - track_id: Unique identifier for the tracked object - trail_history: List of (x, y) centroid positions, newest last - - Returns: - Tuple of (BehaviorState, velocity in pixels/frame) - """ - if not self._settings.enabled: - return BehaviorState.UNKNOWN, 0.0 - - # Get or create state tracker for this object - if track_id not in self._object_states: - self._object_states[track_id] = ObjectBehaviorState() - obj_state = self._object_states[track_id] - - # Need at least 2 points to calculate velocity - if len(trail_history) < 2: - return BehaviorState.UNKNOWN, 0.0 - - # Calculate instantaneous velocity (distance between last two points) - x1, y1 = trail_history[-2] - x2, y2 = trail_history[-1] - dx = x2 - x1 - dy = y2 - y1 - instant_velocity = math.sqrt(dx * dx + dy * dy) - - # Add to velocity history - obj_state.velocities.append(instant_velocity) - - # Keep only the smoothing window size - window = self._settings.smoothing_window - if len(obj_state.velocities) > window: - obj_state.velocities = obj_state.velocities[-window:] - - # Calculate smoothed velocity (average over window) - smoothed_velocity = sum(obj_state.velocities) / len(obj_state.velocities) - obj_state.velocity = smoothed_velocity - - # Classify behavior based on velocity thresholds - state = self._classify_state(smoothed_velocity, obj_state) - obj_state.current_state = state - - return state, smoothed_velocity - - def _classify_state(self, velocity: float, obj_state: ObjectBehaviorState) -> BehaviorState: - """ - Classify behavioral state based on velocity. - - Args: - velocity: Smoothed velocity in pixels/frame - obj_state: Object's state tracker - - Returns: - Classified BehaviorState - """ - settings = self._settings - - # Check for darting (highest priority - sudden fast movement) - if velocity >= settings.dart_threshold: - obj_state.low_movement_frames = 0 - return BehaviorState.DARTING - - # Check for freeze (requires sustained low movement) - if velocity < settings.freeze_threshold: - obj_state.low_movement_frames += 1 - if obj_state.low_movement_frames >= settings.freeze_duration: - return BehaviorState.FREEZE - # Not yet frozen, but very low movement - return BehaviorState.IMMOBILE - - # Reset freeze counter when movement detected - obj_state.low_movement_frames = 0 - - # Check for immobile (low but not freeze-level movement) - if velocity < settings.immobile_threshold: - return BehaviorState.IMMOBILE - - # Normal movement - return BehaviorState.MOVING - - def get_state(self, track_id: int) -> tuple[BehaviorState, float]: - """ - Get the current behavioral state for an object. - - Args: - track_id: Object's track ID - - Returns: - Tuple of (BehaviorState, velocity) or (UNKNOWN, 0.0) if not tracked - """ - if track_id in self._object_states: - state = self._object_states[track_id] - return state.current_state, state.velocity - return BehaviorState.UNKNOWN, 0.0 - - def remove_object(self, track_id: int) -> None: - """ - Remove tracking data for an object. - - Args: - track_id: Object's track ID to remove - """ - self._object_states.pop(track_id, None) - - def clear(self) -> None: - """Clear all tracked object states.""" - self._object_states.clear() - - def get_state_color(self, state: BehaviorState) -> tuple[int, int, int]: - """ - Get BGR color for a behavioral state. - - Args: - state: The behavioral state - - Returns: - BGR color tuple for OpenCV - """ - colors = { - BehaviorState.UNKNOWN: (128, 128, 128), # Gray - BehaviorState.FREEZE: (255, 0, 0), # Blue - BehaviorState.IMMOBILE: (0, 255, 255), # Yellow - BehaviorState.MOVING: (0, 255, 0), # Green - BehaviorState.DARTING: (0, 0, 255), # Red - } - return colors.get(state, (128, 128, 128)) diff --git a/src/glider/vision/cv_processor.py b/src/glider/vision/cv_processor.py index 6813024c..a52f9ff5 100644 --- a/src/glider/vision/cv_processor.py +++ b/src/glider/vision/cv_processor.py @@ -15,6 +15,7 @@ from collections.abc import Callable from dataclasses import dataclass, field, replace from enum import Enum, auto +from itertools import pairwise from typing import TYPE_CHECKING, Any # Disable Ultralytics' runtime auto-installer BEFORE the package is imported @@ -32,12 +33,6 @@ import numpy as np # noqa: E402 from scipy.spatial import distance as dist # noqa: E402 -from glider.vision.behavior_analyzer import ( # noqa: E402 - BehaviorAnalyzer, - BehaviorSettings, - BehaviorState, -) - if TYPE_CHECKING: from glider.vision.zones import ZoneConfiguration, ZoneState, ZoneTracker @@ -252,7 +247,6 @@ class TrackedObject: centroid: tuple[int, int] age: int = 0 # Frames since first seen disappeared: int = 0 # Frames since last seen - behavioral_state: str = "unknown" # Current behavioral state velocity: float = 0.0 # Current velocity in pixels/frame keypoints: np.ndarray | None = None # Nx2 or Nx3 array of keypoint coords @@ -264,7 +258,6 @@ def to_dict(self) -> dict[str, Any]: "confidence": self.confidence, "centroid": self.centroid, "age": self.age, - "behavioral_state": self.behavioral_state, "velocity": self.velocity, } if self.keypoints is not None: @@ -312,12 +305,6 @@ class CVSettings: # count — the logger falls back to the positional index. keypoint_names: list[str] = field(default_factory=list) process_every_n_frames: int = 1 # Process CV every N frames (1 = every frame) - # Behavior analysis settings - behavior_enabled: bool = True - freeze_threshold: float = 1.0 # Max pixels/frame for freeze - immobile_threshold: float = 5.0 # Max pixels/frame for immobile - dart_threshold: float = 50.0 # Min pixels/frame for darting - freeze_duration: int = 15 # Frames required to confirm freeze smoothing_window: int = 5 # Frames for velocity smoothing # Vision cone settings vision_cone_enabled: bool = False @@ -359,11 +346,6 @@ def to_dict(self) -> dict[str, Any]: "keypoint_min_confidence": self.keypoint_min_confidence, "keypoint_names": list(self.keypoint_names), "process_every_n_frames": self.process_every_n_frames, - "behavior_enabled": self.behavior_enabled, - "freeze_threshold": self.freeze_threshold, - "immobile_threshold": self.immobile_threshold, - "dart_threshold": self.dart_threshold, - "freeze_duration": self.freeze_duration, "smoothing_window": self.smoothing_window, "vision_cone_enabled": self.vision_cone_enabled, "vision_cone_fov": self.vision_cone_fov, @@ -401,11 +383,6 @@ def from_dict(cls, data: dict[str, Any]) -> "CVSettings": keypoint_min_confidence=data.get("keypoint_min_confidence", 0.3), keypoint_names=[str(n) for n in data.get("keypoint_names", [])], process_every_n_frames=data.get("process_every_n_frames", 1), - behavior_enabled=data.get("behavior_enabled", True), - freeze_threshold=data.get("freeze_threshold", 1.0), - immobile_threshold=data.get("immobile_threshold", 5.0), - dart_threshold=data.get("dart_threshold", 50.0), - freeze_duration=data.get("freeze_duration", 15), smoothing_window=data.get("smoothing_window", 5), vision_cone_enabled=data.get("vision_cone_enabled", False), vision_cone_fov=data.get("vision_cone_fov", 120.0), @@ -539,7 +516,7 @@ def _visible(self) -> list[TrackedObject]: Unmatched tracks are kept (up to ``max_disappeared``) so the ID survives a brief occlusion, but they are not reported: their bbox is where the animal *was*, and logging it as live froze the position, - zeroed velocity (-> FREEZE) and kept the animal in its last zone. + zeroed velocity and kept the animal in its last zone. """ return [obj for obj in self._objects.values() if obj.disappeared == 0] @@ -615,9 +592,6 @@ def __init__(self, settings: CVSettings | None = None): self._zone_config: ZoneConfiguration | None = None self._zone_callbacks: list[Callable[[dict[str, ZoneState]], None]] = [] - # Behavior analysis - self._behavior_analyzer = BehaviorAnalyzer(self._get_behavior_settings()) - @property def settings(self) -> CVSettings: """The operator's CV configuration. Safe to persist and to display. @@ -676,22 +650,6 @@ def _notify_degradation(self) -> None: except Exception: logger.exception("Backend degradation callback raised") - @property - def behavior_analyzer(self) -> BehaviorAnalyzer: - """Get the behavior analyzer instance.""" - return self._behavior_analyzer - - def _get_behavior_settings(self) -> BehaviorSettings: - """Create BehaviorSettings from CVSettings.""" - return BehaviorSettings( - enabled=self._settings.behavior_enabled, - freeze_threshold=self._settings.freeze_threshold, - immobile_threshold=self._settings.immobile_threshold, - dart_threshold=self._settings.dart_threshold, - freeze_duration=self._settings.freeze_duration, - smoothing_window=self._settings.smoothing_window, - ) - @property def is_initialized(self) -> bool: """Whether CV processor is initialized.""" @@ -957,8 +915,7 @@ def process_frame( # Update trail history self._update_trails(tracked) - # Analyze behavioral states - self._analyze_behavior(tracked) + self._update_velocities(tracked) # Run motion detection (reuse fg_mask to avoid double-apply) motion = self._detect_motion(fg_mask) @@ -1244,16 +1201,14 @@ def _update_trails(self, tracked: list[TrackedObject]) -> None: for track_id in list(self._trail_history.keys()): if track_id not in current_ids: del self._trail_history[track_id] - # Also remove from behavior analyzer - self._behavior_analyzer.remove_object(track_id) - def _analyze_behavior(self, tracked: list[TrackedObject]) -> None: - """Analyze behavioral states for tracked objects.""" + def _update_velocities(self, tracked: list[TrackedObject]) -> None: + """Set each object's speed: mean step length over the last few trail points.""" + window = max(1, self._settings.smoothing_window) for obj in tracked: - trail = self._trail_history.get(obj.track_id, []) - state, velocity = self._behavior_analyzer.analyze(obj.track_id, trail) - obj.behavioral_state = str(state) - obj.velocity = velocity + trail = self._trail_history.get(obj.track_id, [])[-(window + 1) :] + steps = [math.dist(a, b) for a, b in pairwise(trail)] + obj.velocity = sum(steps) / len(steps) if steps else 0.0 def draw_overlays( self, @@ -1326,10 +1281,6 @@ def draw_overlays( if tracked and self._settings.vision_cone_enabled: self._draw_vision_cones(output, tracked) - # Draw behavioral state overlay in top-left corner - if tracked and self._settings.behavior_enabled: - self._draw_behavior_overlay(output, tracked) - return output def _draw_keypoints(self, frame: np.ndarray, tracked: list[TrackedObject]) -> None: @@ -1417,73 +1368,6 @@ def _draw_vision_cones(self, frame: np.ndarray, tracked: list[TrackedObject]) -> cv2.addWeighted(overlay, alpha, frame, 1 - alpha, 0, frame) - def _draw_behavior_overlay(self, frame: np.ndarray, tracked: list[TrackedObject]) -> None: - """ - Draw behavioral state information in corner of frame. - - Args: - frame: Frame to draw on (modified in place) - tracked: List of tracked objects with behavioral states - """ - if not tracked: - return - - # Get the primary tracked object (first one, or could be most prominent) - obj = tracked[0] - - # Parse behavior state from string - try: - state = BehaviorState[obj.behavioral_state] - except (KeyError, ValueError): - state = BehaviorState.UNKNOWN - - # Get color for state - state_color = self._behavior_analyzer.get_state_color(state) - - # Draw background box - label = f"{state.name}" - velocity_label = f"{obj.velocity:.1f} px/f" - - font = cv2.FONT_HERSHEY_SIMPLEX - font_scale = 0.7 - font_thickness = 2 - - # Calculate text sizes - (label_w, label_h), _ = cv2.getTextSize(label, font, font_scale, font_thickness) - (vel_w, vel_h), _ = cv2.getTextSize(velocity_label, font, 0.5, 1) - - # Position in top-left corner with padding - padding = 10 - box_w = max(label_w, vel_w) + padding * 2 - box_h = label_h + vel_h + padding * 3 - - # Draw semi-transparent background - overlay = frame.copy() - cv2.rectangle(overlay, (5, 5), (5 + box_w, 5 + box_h), (0, 0, 0), -1) - cv2.addWeighted(overlay, 0.6, frame, 0.4, 0, frame) - - # Draw state label with color - cv2.putText( - frame, - label, - (5 + padding, 5 + padding + label_h), - font, - font_scale, - state_color, - font_thickness, - ) - - # Draw velocity below - cv2.putText( - frame, - velocity_label, - (5 + padding, 5 + padding * 2 + label_h + vel_h), - font, - 0.5, - (200, 200, 200), - 1, - ) - def on_detection(self, callback: Callable[[list[Detection], float], None]) -> None: """Register callback for detection results.""" self._detection_callbacks.append(callback) @@ -1527,9 +1411,6 @@ def update_settings(self, settings: CVSettings) -> None: if self._tracker: self._tracker._max_disappeared = settings.max_disappeared - # Update behavior analyzer settings - self._behavior_analyzer.update_settings(self._get_behavior_settings()) - def reset(self) -> None: """Reset the processor state.""" if self._tracker: @@ -1549,8 +1430,6 @@ def reset(self) -> None: # Reset zone tracker if self._zone_tracker: self._zone_tracker.reset() - # Reset behavior analyzer - self._behavior_analyzer.clear() if self._bg_subtractor: # Recreate background subtractor to reset learning self._bg_subtractor = cv2.createBackgroundSubtractorMOG2( diff --git a/src/glider/vision/frame_provider.py b/src/glider/vision/frame_provider.py index 52cfc032..5202a473 100644 --- a/src/glider/vision/frame_provider.py +++ b/src/glider/vision/frame_provider.py @@ -1,10 +1,8 @@ """A single frame-source interface over the live camera and recorded video. -:class:`glider.gui.dialogs.calibration_dialog.CalibrationDialog` needs one -still frame to draw measurement lines on. It used to reach into CameraManager -directly, which made it unusable for a batch over recorded files. These -adapters expose the two members it actually needs, plus an optional seek -capability so a video source can be scrubbed for a frame worth measuring. +The arena dialog needs one still frame to outline the floor on, from the live +camera or from a recording. These adapters expose the two members that takes, +plus an optional seek capability so a video can be scrubbed for a good frame. The Protocols are for typing only — deliberately not runtime_checkable. Consumers test for seekability with ``hasattr(provider, "seek")``. diff --git a/src/glider/vision/tracking_logger.py b/src/glider/vision/tracking_logger.py index 9db2be5e..fe373501 100644 --- a/src/glider/vision/tracking_logger.py +++ b/src/glider/vision/tracking_logger.py @@ -23,7 +23,7 @@ if TYPE_CHECKING: from glider.core.experiment_session import ExperimentSession - from glider.vision.calibration import CameraCalibration + from glider.vision.arena import ArenaCalibration from glider.vision.cv_processor import TrackedObject from glider.vision.zones import ZoneConfiguration @@ -37,7 +37,7 @@ class TrackingDataLogger: Output format matches DataRecorder pattern with columns: frame, timestamp, elapsed_ms, object_id, class, x, y, w, h, confidence, center_x, center_y, distance_px, distance_mm, cumulative_mm, zone_ids, - behavioral_state, velocity_px_frame + velocity_px_frame Pose backends additionally produce a sibling ``*_keypoints.csv`` in long format — one row per keypoint per object per frame: @@ -85,7 +85,7 @@ def __init__(self, output_dir: Path | None = None): self._flow_anchor: float | None = None self._frame_count = 0 self._recording = False - self._calibration: CameraCalibration | None = None + self._calibration: ArenaCalibration | None = None self._zone_config: ZoneConfiguration | None = None self._frame_width: int = 0 self._frame_height: int = 0 @@ -257,12 +257,11 @@ def set_flow_anchor(self, timestamp: float) -> None: """ self._flow_anchor = float(timestamp) - def set_calibration(self, calibration: "CameraCalibration") -> None: - """ - Set calibration for real-world distance calculations. + def set_calibration(self, calibration: "ArenaCalibration | None") -> None: + """Set the arena whose scale converts distances to millimetres. - Args: - calibration: CameraCalibration instance + Anything with the same scalar interface works (``is_calibrated``, + ``pixels_per_mm``, ``calibration_width/height``, ``pixels_to_mm``). """ self._calibration = calibration @@ -679,7 +678,6 @@ def _write_header_and_metadata(self, experiment_name: str, session) -> None: "distance_mm", "cumulative_mm", "zone_ids", - "behavioral_state", "velocity_px_frame", ] ) @@ -786,8 +784,6 @@ def log_frame( # Get zone IDs for this object's position zone_ids = self._get_zones_for_point(center_x, center_y) - # Get behavioral state and velocity from tracked object - behavioral_state = getattr(obj, "behavioral_state", "unknown") velocity = getattr(obj, "velocity", 0.0) if not self._safe_writerow( @@ -809,7 +805,6 @@ def log_frame( f"{distance_mm:.2f}", f"{cumulative_mm:.2f}", zone_ids, - behavioral_state, f"{velocity:.2f}", ], context="log_frame[object]", @@ -843,7 +838,6 @@ def log_frame( "", "", # Empty distance fields "", # Empty zone_ids - "", # Empty behavioral_state "", # Empty velocity ], context="log_frame[motion]", @@ -873,7 +867,6 @@ def log_frame( "", "", # Empty distance fields "", # Empty zone_ids - "", # Empty behavioral_state "", # Empty velocity ], context="log_frame[heartbeat]", diff --git a/tests/unit/analysis/behavior/test_legacy_and_folder_scale.py b/tests/unit/analysis/behavior/test_legacy_and_folder_scale.py index 8ea2b67a..1fc90b73 100644 --- a/tests/unit/analysis/behavior/test_legacy_and_folder_scale.py +++ b/tests/unit/analysis/behavior/test_legacy_and_folder_scale.py @@ -92,6 +92,24 @@ def test_borrows_from_a_calibrated_video_in_the_same_folder(self, tmp_path): assert load_px_per_mm(master, uncalibrated) == pytest.approx(4.0, abs=0.05) + def test_borrows_from_a_folder_mate_that_has_only_an_arena(self, tmp_path): + """#212: Batch Pose now draws arenas, not lines; borrowing must see them.""" + from glider.vision.arena import ArenaCalibration + + folder = tmp_path / "rig" + folder.mkdir() + drawn = _clip(folder / "a.avi", 64, 48) + bare = _clip(folder / "b.avi", 64, 48) + cal_set = CalibrationSet() + arena = ArenaCalibration.from_ellipse( + (0.5, 0.5), (0.75, 0.5), (0.5, 0.75), width_cm=4.0, frame_size=(64, 48) + ) + cal_set.set_arena(drawn, arena, confirmed=True) + master = folder / "pose_calibration.json" + cal_set.save(master) + + assert load_px_per_mm(master, bare) == pytest.approx(arena.px_per_mm_centre, rel=1e-6) + def test_an_exact_entry_still_wins(self, tmp_path): folder = tmp_path / "rig" folder.mkdir() diff --git a/tests/unit/gui/pose_batch/test_calibration_table.py b/tests/unit/gui/pose_batch/test_calibration_table.py index 292731eb..9fbd6bb8 100644 --- a/tests/unit/gui/pose_batch/test_calibration_table.py +++ b/tests/unit/gui/pose_batch/test_calibration_table.py @@ -79,9 +79,9 @@ def test_selected_videos_reports_the_selection(table, tmp_path): assert table.selected_videos() == [b] -def test_double_click_requests_calibration(table, qtbot, tmp_path): +def test_double_click_requests_the_arena(table, qtbot, tmp_path): video = tmp_path / "a.mp4" table.set_videos([video]) - with qtbot.waitSignal(table.calibrate_requested, timeout=1000) as blocker: + with qtbot.waitSignal(table.arena_requested, timeout=1000) as blocker: table.itemDoubleClicked.emit(table.item(0, 0)) assert blocker.args[0] == video diff --git a/tests/unit/gui/pose_batch/test_calibration_table_arena.py b/tests/unit/gui/pose_batch/test_calibration_table_arena.py index 8c682ade..66c16adc 100644 --- a/tests/unit/gui/pose_batch/test_calibration_table_arena.py +++ b/tests/unit/gui/pose_batch/test_calibration_table_arena.py @@ -134,8 +134,9 @@ def test_double_click_on_the_arena_column_asks_for_the_arena(self, table, video, table.itemDoubleClicked.emit(table.item(0, ARENA_COLUMN)) assert blocker.args == [video] - def test_double_click_elsewhere_still_asks_for_the_line(self, table, video, qtbot): + def test_double_click_elsewhere_asks_for_the_arena_too(self, table, video, qtbot): + """#212: there is no separate line tool left to ask for.""" table.set_videos([video]) - with qtbot.waitSignal(table.calibrate_requested, timeout=500) as blocker: + with qtbot.waitSignal(table.arena_requested, timeout=500) as blocker: table.itemDoubleClicked.emit(table.item(0, 0)) assert blocker.args == [video] diff --git a/tests/unit/gui/pose_batch/test_window.py b/tests/unit/gui/pose_batch/test_window.py index d52ccfa4..98cbff99 100644 --- a/tests/unit/gui/pose_batch/test_window.py +++ b/tests/unit/gui/pose_batch/test_window.py @@ -10,8 +10,6 @@ from pathlib import Path import pytest -from PyQt6.QtCore import QEvent -from PyQt6.QtWidgets import QApplication, QDialog, QMessageBox from glider.gui.pose_batch.window import PoseBatchWindow, _DropList @@ -295,121 +293,6 @@ def test_table_lists_the_discovered_videos(window, tmp_path): # -------------------------------------------------------------------------- -def test_copy_calibration_stamps_the_other_selected_videos(window, tmp_path): - a = _clip(tmp_path / "a.avi") - b = _clip(tmp_path / "b.avi") - window._sources.add_paths([tmp_path]) - assert window._videos == [a, b] - window._calibrations.set(a, _cal(640, 100.0)) - window._cal_table.selectAll() - window._copy_calibration_to_selected() - assert window._calibrations.px_per_mm(b) is not None - # A copy, not a shared object — editing one must not mutate the other. - assert window._calibrations.get(b) is not window._calibrations.get(a) - - -def test_copy_does_not_silently_destroy_drawn_calibrations(window, tmp_path, monkeypatch): - """Four individually calibrated videos must survive selectAll() + Copy.""" - for name in ("v1.avi", "v2.avi", "v3.avi", "v4.avi"): - _clip(tmp_path / name) - window._sources.add_paths([tmp_path]) - scales = (1.0, 2.0, 3.0, 4.0) - for video, scale in zip(window._videos, scales, strict=True): - window._calibrations.set(video, _cal(640, 640.0 / scale)) - - prompts = _answer_overwrite(monkeypatch, QMessageBox.StandardButton.No) - window._cal_table.selectAll() - window._copy_calibration_to_selected() - - assert prompts, "the operator must be asked before calibrations are replaced" - for video, scale in zip(window._videos, scales, strict=True): - assert window._calibrations.px_per_mm(video) == pytest.approx(scale) - - -def test_copy_still_fills_the_uncalibrated_videos_when_overwrite_is_declined( - window, tmp_path, monkeypatch -): - source = _clip(tmp_path / "a.avi") - drawn = _clip(tmp_path / "b.avi") - blank = _clip(tmp_path / "c.avi") - window._sources.add_paths([tmp_path]) - window._calibrations.set(source, _cal(640, 640.0)) # 1.0 px/mm - window._calibrations.set(drawn, _cal(640, 320.0)) # 2.0 px/mm - - _answer_overwrite(monkeypatch, QMessageBox.StandardButton.No) - window._cal_table.selectAll() - window._copy_calibration_to_selected() - - assert window._calibrations.px_per_mm(drawn) == pytest.approx(2.0) - assert window._calibrations.px_per_mm(blank) == pytest.approx(1.0) - log = window._log.toPlainText() - assert "1 uncalibrated video(s) filled" in log - assert "0 existing calibration(s) overwritten" in log - - -def test_copy_overwrites_only_when_the_operator_confirms(window, tmp_path, monkeypatch): - source = _clip(tmp_path / "a.avi") - drawn = _clip(tmp_path / "b.avi") - window._sources.add_paths([tmp_path]) - window._calibrations.set(source, _cal(640, 640.0)) # 1.0 px/mm - window._calibrations.set(drawn, _cal(640, 320.0)) # 2.0 px/mm - - _answer_overwrite(monkeypatch, QMessageBox.StandardButton.Yes) - window._cal_table.selectAll() - window._copy_calibration_to_selected() - - assert window._calibrations.px_per_mm(drawn) == pytest.approx(1.0) - assert "1 existing calibration(s) overwritten" in window._log.toPlainText() - - -def test_copy_retargets_the_calibration_to_the_destination_resolution(window, tmp_path): - """Same rig, different recording resolution: the scale must follow the pixels.""" - a = _clip(tmp_path / "a.avi", 640, 480) - b = _clip(tmp_path / "b.avi", 1280, 960) - window._sources.add_paths([tmp_path]) - window._calibrations.set(a, _cal(320, 100.0, (640, 480))) # 3.2 px/mm - - window._cal_table.selectAll() - window._copy_calibration_to_selected() - - copied = window._calibrations.get(b) - assert (copied.calibration_width, copied.calibration_height) == (1280, 960) - assert window._calibrations.px_per_mm(b) == pytest.approx(2 * window._calibrations.px_per_mm(a)) - # The source itself is untouched by the retarget. - assert window._calibrations.px_per_mm(a) == pytest.approx(3.2) - - -def test_copy_skips_targets_whose_resolution_cannot_be_read(window, tmp_path): - a = _clip(tmp_path / "a.avi") - broken = _touch(tmp_path / "b.avi").resolve() # zero bytes: not decodable - window._sources.add_paths([tmp_path]) - window._calibrations.set(a, _cal(640, 100.0)) - - window._cal_table.selectAll() - window._copy_calibration_to_selected() - - # Guessing a resolution is exactly the error being avoided. - assert window._calibrations.get(broken) is None - assert "could not be opened" in window._log.toPlainText() - assert "b.avi" in window._log.toPlainText() - - -def test_calibration_dialogs_are_not_retained(window, tmp_path, monkeypatch): - """Each dialog holds a full-res frame and pixmap; 50 of them is GBs.""" - _clip(tmp_path / "a.avi", 320, 240) - window._sources.add_paths([tmp_path]) - from glider.gui.dialogs.calibration_dialog import CalibrationDialog - - monkeypatch.setattr(CalibrationDialog, "exec", lambda self: QDialog.DialogCode.Rejected) - for _ in range(6): - window._open_calibration(window._videos[0]) - QApplication.processEvents() - # No event loop is running here, and DeferredDelete is only delivered by one; - # in the app the deletion happens as soon as the slot returns to it. - QApplication.sendPostedEvents(None, QEvent.Type.DeferredDelete) - assert window.findChildren(QDialog) == [] - - def test_clear_removes_the_selected_calibration(window, tmp_path): _ready(window, tmp_path) window._cal_table.selectAll() diff --git a/tests/unit/gui/pose_batch/test_window_arena.py b/tests/unit/gui/pose_batch/test_window_arena.py index 5f012876..6695dc9a 100644 --- a/tests/unit/gui/pose_batch/test_window_arena.py +++ b/tests/unit/gui/pose_batch/test_window_arena.py @@ -87,8 +87,10 @@ def release(self): class _Dialog: def __init__(self, frame, title="", parent=None, **kwargs): - self.arena_spin = SimpleNamespace(setValue=lambda value: None) - self.canvas = SimpleNamespace(set_corners=lambda corners: None) + pass + + def set_arena(self, arena): + pass def exec(self): return QDialog.DialogCode.Accepted @@ -369,7 +371,7 @@ def test_an_unreadable_target_is_skipped_not_guessed(self, window, tmp_path, mon def test_a_copy_does_not_silently_destroy_drawn_arenas(self, window, tmp_path, monkeypatch): """selectAll() + Copy Arena is one click away, and there is no undo. - Its sibling ``_copy_calibration_to_selected`` has asked before + The line-calibration copy it replaced had asked before replacing drawn work since it shipped; the arena path had no guard at all, so it replaced per-video human judgement -- the thing this branch makes mandatory -- with unconfirmed copies of the first video's. diff --git a/tests/unit/gui/shell/test_command_palette.py b/tests/unit/gui/shell/test_command_palette.py index 9835af38..ac4f9bcf 100644 --- a/tests/unit/gui/shell/test_command_palette.py +++ b/tests/unit/gui/shell/test_command_palette.py @@ -285,7 +285,9 @@ def test_a_row_carries_its_source_menu_and_its_shortcut(palette): assert palette.row_text(row) == "New" assert palette.row_category(row) == "File" - assert palette.row_shortcut(row) == "Ctrl+N" + # Native, so a Mac reads "⌘N" rather than a Ctrl key it does not have. + native = QKeySequence("Ctrl+N").toString(QKeySequence.SequenceFormat.NativeText) + assert palette.row_shortcut(row) == native def test_enter_triggers_the_selected_command_exactly_once(qtbot, palette): diff --git a/tests/unit/gui/shell/test_main_window_shell.py b/tests/unit/gui/shell/test_main_window_shell.py index 418a12c0..a9c4aa35 100644 --- a/tests/unit/gui/shell/test_main_window_shell.py +++ b/tests/unit/gui/shell/test_main_window_shell.py @@ -28,7 +28,7 @@ pytestmark = pytest.mark.usefixtures("qtbot") -LEFT_KEYS = ["nodes", "hardware", "control", "files"] +LEFT_KEYS = ["nodes", "hardware", "control"] RIGHT_KEYS = ["properties", "camera"] @@ -129,7 +129,6 @@ def test_panels_are_re_hosted_not_rebuilt(qtbot, main_window_factory): assert shell.left.widget_for("nodes") is window._node_library_panel assert shell.left.widget_for("hardware") is window._hardware_panel assert shell.left.widget_for("control") is window._device_control_panel - assert shell.left.widget_for("files") is window._files_panel # Properties is hosted behind a swappable container -- the node editor # replaces its contents on every selection -- so identity is asserted on @@ -250,7 +249,6 @@ def test_nothing_lingers_over_the_operator_view(qtbot, main_window_factory): window._node_library_panel, window._hardware_panel, window._device_control_panel, - window._files_panel, window._properties_host, window._camera_panel, ) diff --git a/tests/unit/gui/shell/test_shell_navigation.py b/tests/unit/gui/shell/test_shell_navigation.py index 091376bf..6a1dd64b 100644 --- a/tests/unit/gui/shell/test_shell_navigation.py +++ b/tests/unit/gui/shell/test_shell_navigation.py @@ -386,6 +386,28 @@ def test_an_unavailable_tool_card_opens_nothing(qtbot): assert chosen == ["ok"] +def test_an_unavailable_card_offers_to_install_its_extra(qtbot): + """Issue #206: the card names what is missing *and* fixes it in a click.""" + from glider.gui.panels.tools_page import ToolCard, ToolsPage + + page = ToolsPage( + [ + ToolCard("ok", "Fine", "works", "devices", install_extra="vision"), + ToolCard("no", "Pose", "", "pose", available=False, install_extra="vision"), + ] + ) + qtbot.addWidget(page) + asked: list[str] = [] + page.install_requested.connect(asked.append) + + assert page.install_button("ok") is None # nothing to install for a working tool + page.install_button("no").click() + assert asked == ["vision"] + + page.set_install_status("vision", "Installing…", busy=True) + assert not page.install_button("no").isEnabled() + + def test_landing_title_has_no_subtitle_or_letter_spacing(qtbot, tmp_path): from PyQt6.QtCore import QSettings from PyQt6.QtWidgets import QLabel diff --git a/tests/unit/gui/shell/test_status_strip.py b/tests/unit/gui/shell/test_status_strip.py index c9d6b59b..1c03223f 100644 --- a/tests/unit/gui/shell/test_status_strip.py +++ b/tests/unit/gui/shell/test_status_strip.py @@ -24,7 +24,7 @@ import pytest from PyQt6.QtCore import Qt -from PyQt6.QtGui import QPalette +from PyQt6.QtGui import QKeySequence, QPalette from PyQt6.QtWidgets import QVBoxLayout, QWidget from glider.gui.shell.status_strip import STRIP_HEIGHT, StatusStrip @@ -398,10 +398,10 @@ def test_the_palette_hint_is_a_button_and_not_a_decoration(strip, qtbot): def test_the_palette_hint_names_the_shortcut(strip): - text = strip.palette_hint().text() + native = QKeySequence("Ctrl+K").toString(QKeySequence.SequenceFormat.NativeText) - assert "Ctrl" in text - assert "K" in text + assert strip.palette_hint().text() == native.replace("+", " ") + assert native in strip.palette_hint().toolTip() # --------------------------------------------------------------- the toggles diff --git a/tests/unit/gui/test_arena_dialog.py b/tests/unit/gui/test_arena_dialog.py index 43dd63d2..e6f91149 100644 --- a/tests/unit/gui/test_arena_dialog.py +++ b/tests/unit/gui/test_arena_dialog.py @@ -146,3 +146,31 @@ def _press(pos: QPoint): _Qt.MouseButton.LeftButton, _Qt.KeyboardModifier.NoModifier, ) + + +class TestRoundArena: + """Issue #212: a round apparatus is outlined as an ellipse, not four corners.""" + + def test_three_clicks_make_a_circle_with_its_diameter(self, qtbot, frame): + dialog = ArenaDialog(frame) + qtbot.addWidget(dialog) + dialog.shape_combo.setCurrentIndex(dialog.shape_combo.findData("ellipse")) + dialog.arena_spin.setValue(40.0) + + dialog.canvas.set_corners([(0.5, 0.5), (0.8, 0.5), (0.5, 0.9)]) + + arena = dialog.calibration() + assert arena.shape == "ellipse" + assert (arena.width_cm, arena.height_cm) == (40.0, 40.0) + assert dialog.height_spin.isHidden() or not dialog.height_spin.isVisibleTo(dialog) + + def test_reopening_restores_shape_and_size(self, qtbot, frame): + from glider.vision.arena import ArenaCalibration + + dialog = ArenaDialog(frame) + qtbot.addWidget(dialog) + dialog.set_arena( + ArenaCalibration(corners=SQUARE, width_cm=40.0, height_cm=25.0, frame_size=(640, 480)) + ) + + assert dialog.calibration().height_cm == 25.0 # the old "Side" box lost this diff --git a/tests/unit/gui/test_calibration_dialog_providers.py b/tests/unit/gui/test_calibration_dialog_providers.py deleted file mode 100644 index 99957606..00000000 --- a/tests/unit/gui/test_calibration_dialog_providers.py +++ /dev/null @@ -1,261 +0,0 @@ -"""CalibrationDialog drives from any FrameProvider, camera or video.""" - -from __future__ import annotations - -import numpy as np -from PyQt6.QtWidgets import QMessageBox - -from glider.gui.dialogs.calibration_dialog import CalibrationDialog -from glider.vision.calibration import CameraCalibration - - -class _StillProvider: - """Not seekable — stands in for the live camera.""" - - def __init__(self, connected=True, w=640, h=480): - self.is_connected = connected - self._frame = np.zeros((h, w, 3), dtype=np.uint8) - - def get_frame(self): - return (self._frame, 0.0) if self.is_connected else None - - -class _ScrubProvider(_StillProvider): - """Seekable — stands in for a video file.""" - - frame_count = 12 - - def __init__(self, **kw): - super().__init__(**kw) - self.position = 0 - - def seek(self, frame_index): - self.position = max(0, min(int(frame_index), self.frame_count - 1)) - - -class _GhostProvider(_StillProvider): - """Reports disconnected but would happily hand back a frame if asked. - - `_StillProvider.get_frame()` returns None when disconnected, so it can't - distinguish a guarded `_capture_frame` (checks `is_connected` first, never - calls `get_frame`) from an unguarded one (calls `get_frame`, gets None, - bails the same way). This provider makes the two paths diverge: a bug - that dropped the guard would call `get_frame` and get a real frame back. - """ - - def __init__(self, **kw): - super().__init__(connected=False, **kw) - self.get_frame_calls = 0 - - def get_frame(self): - self.get_frame_calls += 1 - return (self._frame, 0.0) - - -class _DistinctFrameScrubProvider(_ScrubProvider): - """Seekable provider whose frame content encodes the current position. - - Lets a test tell "the preview redrew with whatever frame was already - there" apart from "the preview actually received a new frame for this - position" — a plain zeros frame looks the same before and after a seek. - """ - - def __init__(self, **kw): - super().__init__(**kw) - self.get_frame_calls = 0 - - def get_frame(self): - self.get_frame_calls += 1 - if not self.is_connected: - return None - h, w = self._frame.shape[:2] - frame = np.full((h, w, 3), self.position, dtype=np.uint8) - return (frame, 0.0) - - -def _dialog(qtbot, provider, **kw): - dialog = CalibrationDialog(frame_provider=provider, calibration=CameraCalibration(), **kw) - qtbot.addWidget(dialog) - return dialog - - -class TestProviderWiring: - def test_captures_the_initial_frame_from_any_provider(self, qtbot): - dialog = _dialog(qtbot, _StillProvider(w=800, h=600)) - assert dialog._calibration.calibration_width == 800 - assert dialog._calibration.calibration_height == 600 - - def test_disconnected_provider_leaves_resolution_unset(self, qtbot): - dialog = _dialog(qtbot, _StillProvider(connected=False)) - assert dialog._calibration.calibration_width == 0 - - -class TestScrubber: - def test_absent_for_a_still_provider(self, qtbot): - assert _dialog(qtbot, _StillProvider())._scrubber is None - - def test_present_for_a_seekable_provider(self, qtbot): - dialog = _dialog(qtbot, _ScrubProvider()) - assert dialog._scrubber is not None - assert dialog._scrubber.maximum() == _ScrubProvider.frame_count - 1 - - def test_moving_it_seeks_the_provider(self, qtbot): - provider = _ScrubProvider() - dialog = _dialog(qtbot, provider) - dialog._scrubber.setValue(7) - assert provider.position == 7 - - -class TestFileButtons: - def test_shown_by_default(self, qtbot): - assert _dialog(qtbot, _StillProvider())._save_btn is not None - - def test_hidden_when_suppressed(self, qtbot): - # Next to a batch master file, a per-file save is a trap. - dialog = _dialog(qtbot, _StillProvider(), show_file_buttons=False) - assert dialog._save_btn is None - assert dialog._load_btn is None - - -class TestCaptureButton: - def test_clicking_it_with_a_dead_source_still_warns(self, qtbot, monkeypatch): - # clicked() emits `checked`, always False for a non-checkable button. - # Connected straight to _capture_frame it would land in `announce` and - # silently swallow this warning. - dialog = _dialog(qtbot, _StillProvider(connected=False)) - warned = {} - monkeypatch.setattr( - "glider.gui.dialogs.calibration_dialog.QMessageBox.warning", - lambda *a, **k: warned.setdefault("shown", True), - ) - dialog._capture_btn.click() - assert warned.get("shown") is True - - def test_construction_is_silent_for_a_dead_source(self, qtbot, monkeypatch): - warned = {} - monkeypatch.setattr( - "glider.gui.dialogs.calibration_dialog.QMessageBox.warning", - lambda *a, **k: warned.setdefault("shown", True), - ) - _dialog(qtbot, _StillProvider(connected=False)) - assert warned == {} - - -class TestZeroLengthLine: - def test_a_zero_length_line_is_refused(self, qtbot, monkeypatch): - dialog = _dialog(qtbot, _StillProvider()) - warned = {} - monkeypatch.setattr( - "glider.gui.dialogs.calibration_dialog.QMessageBox.warning", - lambda *a, **k: warned.setdefault("shown", True), - ) - dialog._on_line_defined((100, 100), (100, 100)) - dialog._add_pending_line() - assert warned.get("shown") is True - assert dialog._calibration.lines == [] - - def test_a_real_line_is_accepted(self, qtbot): - dialog = _dialog(qtbot, _StillProvider()) - dialog._on_line_defined((0, 240), (640, 240)) - dialog._length_spin.setValue(100.0) - dialog._add_pending_line() - assert len(dialog._calibration.lines) == 1 - - -class TestCaptureFrameGuard: - def test_disconnected_provider_is_never_asked_for_a_frame(self, qtbot): - # Pins the `is_connected` guard in `_capture_frame` itself: a - # `_StillProvider` can't tell a guarded miss from an unguarded one - # since its `get_frame()` also returns None while disconnected. This - # provider would return a real frame if `get_frame` were ever called. - provider = _GhostProvider() - dialog = _dialog(qtbot, provider) - assert dialog._calibration.calibration_width == 0 - assert provider.get_frame_calls == 0 - - def test_explicit_capture_on_disconnected_provider_skips_get_frame(self, qtbot): - provider = _GhostProvider() - dialog = _dialog(qtbot, provider) - assert dialog._capture_frame(announce=False) is False - assert provider.get_frame_calls == 0 - - -class TestFrameLabel: - def test_shows_initial_position(self, qtbot): - dialog = _dialog(qtbot, _ScrubProvider()) - assert dialog._frame_label.text() == "1 / 12" - - def test_updates_after_scrubbing(self, qtbot): - dialog = _dialog(qtbot, _ScrubProvider()) - dialog._scrubber.setValue(7) - assert dialog._frame_label.text() == "8 / 12" - - def test_does_not_advance_past_a_failed_seek(self, qtbot): - # A failed read (announce=False) is silent; the label must not lie - # about which frame the preview is actually showing. - provider = _ScrubProvider() - dialog = _dialog(qtbot, provider) - provider.is_connected = False - dialog._scrubber.setValue(7) - assert dialog._frame_label.text() == "1 / 12" - - -class TestCaptureButtonLabel: - def test_relabeled_for_a_seekable_provider(self, qtbot): - dialog = _dialog(qtbot, _ScrubProvider()) - assert dialog._capture_btn.text() == "Use This Frame" - - def test_stays_capture_frame_for_a_still_provider(self, qtbot): - dialog = _dialog(qtbot, _StillProvider()) - assert dialog._capture_btn.text() == "Capture Frame" - - -class TestClearButtonWithoutFileButtons: - def test_exists_and_works_when_file_buttons_are_hidden(self, qtbot, monkeypatch): - # The trap `show_file_buttons=False` exists to avoid: losing the - # clear button entirely instead of just moving it to its own row. - dialog = _dialog(qtbot, _StillProvider(), show_file_buttons=False) - assert dialog._clear_btn is not None - - dialog._on_line_defined((0, 240), (640, 240)) - dialog._length_spin.setValue(100.0) - dialog._add_pending_line() - assert len(dialog._calibration.lines) == 1 - - monkeypatch.setattr( - "glider.gui.dialogs.calibration_dialog.QMessageBox.question", - lambda *a, **k: QMessageBox.StandardButton.Yes, - ) - dialog._clear_btn.click() - assert dialog._calibration.lines == [] - - -class TestScrubRecapture: - def test_scrubbing_recaptures_a_new_frame(self, qtbot): - # Distinguishes "seeks the provider" from "actually re-pulls and - # redraws a frame for the new position" — a regression could seek - # without recapturing and this would still look fine on a still frame. - provider = _DistinctFrameScrubProvider() - dialog = _dialog(qtbot, provider) - calls_before = provider.get_frame_calls - - dialog._scrubber.setValue(7) - - assert provider.get_frame_calls == calls_before + 1 - assert (dialog._preview._frame == 7).all() - - def test_calibration_lines_survive_a_scrub(self, qtbot): - # Issue 3 removed a redundant `set_calibration` call from `_on_scrub`. - # The preview must still be drawing over the *current* calibration - # (same object, still holding its lines) after scrubbing, not a stale - # or emptied one. - dialog = _dialog(qtbot, _ScrubProvider()) - dialog._on_line_defined((0, 240), (640, 240)) - dialog._length_spin.setValue(100.0) - dialog._add_pending_line() - assert len(dialog._calibration.lines) == 1 - - dialog._scrubber.setValue(7) - - assert dialog._preview._calibration is dialog._calibration - assert len(dialog._preview._calibration.lines) == 1 diff --git a/tests/unit/gui/test_dashboard_integration.py b/tests/unit/gui/test_dashboard_integration.py index 49132e86..d5bc82f8 100644 --- a/tests/unit/gui/test_dashboard_integration.py +++ b/tests/unit/gui/test_dashboard_integration.py @@ -128,7 +128,6 @@ def _builder_panels(window): window._node_library_panel, window._hardware_panel, window._device_control_panel, - window._files_panel, window._properties_host, ) @@ -153,20 +152,18 @@ def test_builder_panels_do_not_linger_over_the_operator_view(qtbot, main_window_ def test_switch_to_runner_preserves_the_chosen_panel_tabs(qtbot, main_window_factory): """A round trip must not rearrange the Builder the user set up. - The predecessor of this test guarded a Files *dock* the desktop kept hidden - by default. Files is a tab now and costs nothing to leave in place, so the - arrangement worth preserving is which tab each panel is showing. + The arrangement worth preserving is which tab each panel is showing. """ window = main_window_factory(desktop_mode=True) window.show() window.switch_to_builder() - window._builder_view.left.set_current("files") + window._builder_view.left.set_current("control") window._builder_view.right.set_current("camera") window.switch_to_runner() window.switch_to_builder() - assert window._builder_view.left.current_key() == "files" + assert window._builder_view.left.current_key() == "control" assert window._builder_view.right.current_key() == "camera" diff --git a/tests/unit/gui/test_experiment_page.py b/tests/unit/gui/test_experiment_page.py index be07e65b..30766aa3 100644 --- a/tests/unit/gui/test_experiment_page.py +++ b/tests/unit/gui/test_experiment_page.py @@ -103,7 +103,7 @@ def test_the_rail_switches_sections(qtbot, counting_builders): def test_experiment_dialog_splits_into_two_pages(qtbot, main_window_factory): - """Metadata and Mice are separate pages backed by one editor -- so a + """Metadata and Animals are separate pages backed by one editor -- so a subject added on one and a protocol typed on the other reach the session through a single object that knows about both.""" from glider.gui.dialogs.experiment_dialog import ExperimentDialog @@ -165,16 +165,16 @@ def test_the_window_builds_the_metadata_section_on_first_visit(qtbot, main_windo assert "metadata" in window._experiment_page.built_sections() -def test_metadata_and_mice_share_one_editor(qtbot, main_window_factory): +def test_metadata_and_animals_share_one_editor(qtbot, main_window_factory): """Two rail entries, one ExperimentDialog underneath.""" window = main_window_factory(desktop_mode=True) window.show() window._show_experiment_tab() - window._experiment_page.show_section("mice") + window._experiment_page.show_section("animals") built = window._experiment_page.built_sections() - assert {"metadata", "mice"} <= set(built) - assert built["metadata"] is not built["mice"] + assert {"metadata", "animals"} <= set(built) + assert built["metadata"] is not built["animals"] assert window._experiment_details is not None @@ -281,7 +281,7 @@ def test_the_rail_offers_plugins(qtbot, counting_builders): def test_every_rail_entry_is_the_full_width_of_the_rail(qtbot, counting_builders): """The reported miss: a QToolButton sizes itself to its label, so each entry was a different width and most of the rail looked clickable but was inert. - You aim at "Mice" and hit nothing, because the button stops with the text. + You aim at "Animals" and hit nothing, because the button stops with the text. """ _calls, builders = counting_builders page = ExperimentPage(builders) diff --git a/tests/unit/gui/test_experiment_page_compact.py b/tests/unit/gui/test_experiment_page_compact.py index 08b48029..5207dcef 100644 --- a/tests/unit/gui/test_experiment_page_compact.py +++ b/tests/unit/gui/test_experiment_page_compact.py @@ -35,8 +35,8 @@ def test_compact_page_has_chips_above_the_sections(qtbot): def test_compact_sections_scroll_vertically_only(qtbot): page = ExperimentPage({k: (lambda k=k: QLabel(k)) for k in SECTION_KEYS}, compact=True) qtbot.addWidget(page) - page.show_section("mice") - section = page.built_sections()["mice"] # the section itself, not its wrapper + page.show_section("animals") + section = page.built_sections()["animals"] # the section itself, not its wrapper assert isinstance(section, QLabel) wrapper = section.parentWidget().parentWidget() assert isinstance(wrapper, QScrollArea) diff --git a/tests/unit/gui/test_plugin_manager_dialog.py b/tests/unit/gui/test_plugin_manager_dialog.py index f7488809..d3e9a305 100644 --- a/tests/unit/gui/test_plugin_manager_dialog.py +++ b/tests/unit/gui/test_plugin_manager_dialog.py @@ -268,6 +268,24 @@ async def test_a_plugin_that_installs_but_will_not_import_does_not_read_as_enabl assert manager.loads == 1 +async def test_an_install_announces_that_plugins_changed(qtbot, monkeypatch): + """Issue #205: the node library lists plugin nodes only when asked, so an + install that never said so left a new node invisible until a restart.""" + manager = _FakeManager([PluginInfo(name="harp", enabled=True, loaded=True)]) + _fake_pip(monkeypatch, InstallResult(ok=True, message="Installed glider-harp.", output="done")) + _fake_site_packages(monkeypatch, versions={"glider-harp": "0.1.0"}, entry_points=["harp"]) + + dialog = PluginManagerDialog(index=INDEX, installed={}, plugin_manager=manager) + qtbot.addWidget(dialog) + seen = [] + dialog.plugins_changed.connect(lambda: seen.append(manager.loads)) + + _card(dialog, "glider-harp").buttons()[0].click() + await _drain(dialog) + + assert seen == [1] # once, and only after the load + + async def test_the_load_error_is_shown_on_the_row_that_owns_it(qtbot, monkeypatch): """`load_plugin` writes the diagnosis onto `PluginInfo.error` and the window threw it away, so it died in `logger.error` where nobody was looking.""" diff --git a/tests/unit/gui/test_touch_stylesheet.py b/tests/unit/gui/test_touch_stylesheet.py index 234b67d0..542c65d5 100644 --- a/tests/unit/gui/test_touch_stylesheet.py +++ b/tests/unit/gui/test_touch_stylesheet.py @@ -103,14 +103,17 @@ def _scroll_areas(panel): def _finger_scrolled(w) -> dict[str, list[bool]]: """Whether each scrollable viewport of the Pi's panels has a scroller.""" page = w._experiment_page - for key in ("metadata", "mice", "vocabulary", "zones"): + for key in ("metadata", "animals", "vocabulary", "zones"): page.show_section(key) panels = { "nodes": w._node_library_panel, "properties": w._properties_host, "hardware": w._hardware_panel, "camera": w._camera_panel, - **{key: page.built_sections()[key] for key in ("metadata", "mice", "vocabulary", "zones")}, + **{ + key: page.built_sections()[key] + for key in ("metadata", "animals", "vocabulary", "zones") + }, } found = { key: [QScroller.hasScroller(area.viewport()) for area in _scroll_areas(panel)] @@ -126,19 +129,19 @@ def _finger_scrolled(w) -> dict[str, list[bool]]: def test_the_pi_scrolls_its_panels_by_finger_and_desktop_does_not(qtbot, main_window_factory): w = _pi(qtbot, main_window_factory) pi = _finger_scrolled(w) - # Every scroll area and item view: the Mice table and the Zones table, + # Every scroll area and item view: the Animals table and the Zones table, # the vocabulary lists, the hardware tree, and Properties' own area too. - for key in ("nodes", "properties", "hardware", "camera", "mice", "vocabulary", "zones"): + for key in ("nodes", "properties", "hardware", "camera", "animals", "vocabulary", "zones"): assert pi[key] and all(pi[key]), key assert pi["metadata_wrapper"] == [True] # The Zones section itself: its preview is drawn on by dragging. assert pi["zones_wrapper"] == [False] - mice = w._experiment_page.built_sections()["mice"].findChild(QAbstractItemView) - assert mice.verticalScrollMode() == QAbstractItemView.ScrollMode.ScrollPerPixel + animals = w._experiment_page.built_sections()["animals"].findChild(QAbstractItemView) + assert animals.verticalScrollMode() == QAbstractItemView.ScrollMode.ScrollPerPixel # Headers are left alone: a drag on one resizes a column. from PyQt6.QtWidgets import QHeaderView, QScroller - headers = w._hardware_panel.findChildren(QHeaderView) + mice.parentWidget().findChildren( + headers = w._hardware_panel.findChildren(QHeaderView) + animals.parentWidget().findChildren( QHeaderView ) assert headers and not any(QScroller.hasScroller(h.viewport()) for h in headers) @@ -283,7 +286,7 @@ def test_taps_still_reach_controls_inside_the_finger_scrolled_areas( # Experiment: a chip tap switches the section. w._show_experiment_tab() - chip = w._experiment_page._buttons["mice"] + chip = w._experiment_page._buttons["animals"] qtbot.waitUntil(chip.isVisible) qtbot.mouseClick(chip, Qt.MouseButton.LeftButton) - qtbot.waitUntil(lambda: w._experiment_page.current_section() == "mice") + qtbot.waitUntil(lambda: w._experiment_page.current_section() == "animals") diff --git a/tests/unit/plugins/test_installer.py b/tests/unit/plugins/test_installer.py index 12edddc0..89669676 100644 --- a/tests/unit/plugins/test_installer.py +++ b/tests/unit/plugins/test_installer.py @@ -20,8 +20,10 @@ MalformedEntryError, NoInstallerError, _default_runner, + extra_requirements, incompatibility_message, install, + install_extra, installer_command, is_compatible, ) @@ -669,3 +671,40 @@ def test_a_real_install_is_unaffected(self): pip_available=lambda: True, ) assert args[1:4] == ["-m", "pip", "install"] + + +def test_an_extra_is_read_from_glider_metadata_not_pypi(monkeypatch): + """`pip install glider[x]` would resolve `glider` on PyPI, a different + project; the extra's own requirements are installed instead (#206).""" + import importlib.metadata + + monkeypatch.setattr( + importlib.metadata, + "requires", + lambda dist: [ + "numpy>=1.24", + 'ultralytics>=8.4; extra == "vision"', + 'spidev>=3.5; platform_system == "Nowhere" and extra == "vision"', + 'GLIDER[audio]; extra == "vision"', + 'umap-learn; extra == "behavior"', + ], + ) + + assert extra_requirements("vision") == ["ultralytics>=8.4"] + + +async def test_install_extra_hands_the_requirements_to_the_installer(monkeypatch): + import glider.plugins.installer as installer + + monkeypatch.setattr(installer, "extra_requirements", lambda extra: ["a>=1", "b"]) + monkeypatch.setattr(installer, "installer_command", lambda pkg, reqs: ["pip", pkg, *reqs]) + seen = [] + + async def run(args, on_output=None): + seen.append(args) + return 0, "" + + result = await install_extra("vision", runner=run) + + assert result.ok + assert seen == [["pip", "a>=1", "b"]] diff --git a/tests/unit/vision/test_arena.py b/tests/unit/vision/test_arena.py index 4bfacb22..2a5b21d7 100644 --- a/tests/unit/vision/test_arena.py +++ b/tests/unit/vision/test_arena.py @@ -4,6 +4,7 @@ import math +import numpy as np import pytest from glider.vision.arena import ArenaCalibration, DegenerateArenaError @@ -202,3 +203,43 @@ def test_dict_is_json_safe(self): import json json.dumps(_cal().to_dict()) + + +# ---------------------------------------------------------------- round arenas + + +def test_a_round_arena_seen_from_above_scales_and_contains_like_a_circle(): + """Issue #212: a 40 cm circle drawn as a 400 px-wide ellipse on 800x800.""" + arena = ArenaCalibration.from_ellipse( + (0.5, 0.5), (0.75, 0.5), (0.5, 0.75), width_cm=40.0, frame_size=(800, 800) + ) + + assert arena.px_per_cm_centre == pytest.approx(10.0) + # The box corner is inside the bounding square but outside the floor. + assert arena.outside_cm(np.array([2.0]), np.array([2.0]))[0] + assert not arena.outside_cm(np.array([20.0]), np.array([1.0]))[0] + assert len(arena.centre_zone_vertices(10.0)) == 48 + + +def test_a_round_arena_survives_a_round_trip_and_reopens_on_its_handles(): + arena = ArenaCalibration.from_ellipse( + (0.4, 0.5), (0.7, 0.55), (0.42, 0.8), width_cm=35.0, frame_size=(640, 480) + ) + again = ArenaCalibration.from_dict(arena.to_dict()) + + assert again.shape == "ellipse" + handles = np.array(again.ellipse_handles()) + assert handles == pytest.approx(np.array([(0.4, 0.5), (0.7, 0.55), (0.42, 0.8)])) + + +def test_an_arena_stands_in_for_the_line_calibration_the_logger_reads(): + """#212: the live rig's scale comes from its arena, through the same calls.""" + arena = ArenaCalibration.from_ellipse( + (0.5, 0.5), (0.75, 0.5), (0.5, 0.75), width_cm=40.0, frame_size=(800, 800) + ) + + assert arena.is_calibrated + assert arena.pixels_per_mm == pytest.approx(1.0) + assert arena.pixels_to_mm(10.0, 800) == pytest.approx(10.0) + assert arena.pixels_to_mm(10.0, 400) == pytest.approx(20.0) # half-res frame + assert arena.draw(np.zeros((800, 800, 3), np.uint8)).any() diff --git a/tests/unit/vision/test_cv_processor.py b/tests/unit/vision/test_cv_processor.py index 48a6d969..facffe3e 100644 --- a/tests/unit/vision/test_cv_processor.py +++ b/tests/unit/vision/test_cv_processor.py @@ -48,13 +48,23 @@ def _tracked_with_keypoints(keypoints: np.ndarray) -> TrackedObject: ) +def test_velocity_is_the_mean_step_over_the_smoothing_window(): + """Speed is the mean of the last ``smoothing_window`` trail steps, in px/frame.""" + proc = CVProcessor(CVSettings(smoothing_window=2)) + obj = _tracked_with_keypoints(None) + proc._trail_history[obj.track_id] = [(0, 0), (100, 0), (103, 4), (103, 6)] + + proc._update_velocities([obj]) + + assert obj.velocity == 3.5 # steps 5 and 2; the 100 px one is out of window + + def test_draw_overlays_renders_keypoint_dots(): """A confident keypoint paints a dot of keypoint_color at its location.""" settings = CVSettings( keypoint_color=(0, 0, 255), keypoint_radius=4, show_labels=False, - behavior_enabled=False, ) proc = CVProcessor(settings) frame = np.zeros((100, 100, 3), dtype=np.uint8) @@ -72,7 +82,6 @@ def test_draw_overlays_skips_low_confidence_keypoints(): settings = CVSettings( keypoint_min_confidence=0.5, show_labels=False, - behavior_enabled=False, ) proc = CVProcessor(settings) frame = np.zeros((100, 100, 3), dtype=np.uint8) @@ -86,7 +95,7 @@ def test_draw_overlays_skips_low_confidence_keypoints(): def test_draw_overlays_skips_zero_keypoints(): """(0, 0) placeholder keypoints (undetected) are not drawn at the origin.""" - settings = CVSettings(show_labels=False, behavior_enabled=False) + settings = CVSettings(show_labels=False) proc = CVProcessor(settings) frame = np.zeros((100, 100, 3), dtype=np.uint8) kp = np.array([[0.0, 0.0, 0.99]], dtype=np.float32) @@ -102,7 +111,6 @@ def test_draw_overlays_respects_show_keypoints_toggle(): settings = CVSettings( show_keypoints=False, show_labels=False, - behavior_enabled=False, ) proc = CVProcessor(settings) frame = np.zeros((100, 100, 3), dtype=np.uint8) @@ -120,7 +128,6 @@ def test_draw_overlays_handles_ultralytics_instance_shape(): keypoint_color=(0, 0, 255), keypoint_radius=4, show_labels=False, - behavior_enabled=False, ) proc = CVProcessor(settings) frame = np.zeros((100, 100, 3), dtype=np.uint8) @@ -140,7 +147,6 @@ def test_draw_overlays_handles_2d_keypoints_without_confidence(): keypoint_color=(0, 0, 255), keypoint_radius=4, show_labels=False, - behavior_enabled=False, ) proc = CVProcessor(settings) frame = np.zeros((100, 100, 3), dtype=np.uint8) diff --git a/tests/unit/vision/test_tracking_logger_keypoints.py b/tests/unit/vision/test_tracking_logger_keypoints.py index a224f3bc..d8a93930 100644 --- a/tests/unit/vision/test_tracking_logger_keypoints.py +++ b/tests/unit/vision/test_tracking_logger_keypoints.py @@ -115,7 +115,6 @@ async def test_tracking_csv_is_unchanged_by_pose_data(tmp_path): "distance_mm", "cumulative_mm", "zone_ids", - "behavioral_state", "velocity_px_frame", ] diff --git a/tools/arena_zones.py b/tools/arena_zones.py index f4e7c4fb..6b52de25 100644 --- a/tools/arena_zones.py +++ b/tools/arena_zones.py @@ -291,7 +291,7 @@ def main() -> int: dialog.arena_spin.setValue(args.arena_cm) dialog.zone_spin.setValue(args.zone_cm) if video.stem in arenas: - dialog.canvas.set_corners(arenas[video.stem].corners) + dialog.set_arena(arenas[video.stem]) if dialog.exec() != dialog.DialogCode.Accepted: print("Stopped. Progress saved.")