diff --git a/src/glider/gui/behavior/analysis_window.py b/src/glider/gui/behavior/analysis_window.py index c52cbf71..733eb78e 100644 --- a/src/glider/gui/behavior/analysis_window.py +++ b/src/glider/gui/behavior/analysis_window.py @@ -1550,7 +1550,9 @@ def _run_zone_dialog(self, frame, config): """The zone editor on *frame*: the edited *config*, or None if cancelled.""" from glider.gui.dialogs.zone_dialog import ZoneDialog - dialog = ZoneDialog(None, config, parent=self, frame=frame) + dialog = ZoneDialog( + None, config, parent=self, frame=frame, calibration=self._zone_calibration() + ) try: if dialog.exec() != QDialog.DialogCode.Accepted: return None @@ -1558,6 +1560,22 @@ def _run_zone_dialog(self, frame, config): finally: dialog.deleteLater() + def _zone_calibration(self): + """What lets the zone editor size zones in cm on the shown session's video. + + The session's arena when one is drawn, so sizes follow the camera's + perspective; else its px/mm as a plain scale; else None, and sizes stay + in pixels. + """ + found = self._arena_for(self._shown) + if found is not None: + return found[0] + if self._view is not None and self._view.px_per_mm: + from glider.gui.dialogs.zone_dialog import FrameScale + + return FrameScale(float(self._view.px_per_mm)) + return None + def _redraw_nest(self) -> None: """The nest outlined on the frame on screen, in force from the playhead on. @@ -2675,7 +2693,9 @@ def _draw_zones(self) -> None: from glider.vision.zones import ZoneConfiguration config = self._zones if self._zones is not None else ZoneConfiguration() - dialog = ZoneDialog(None, config, parent=self, frame=frame) + dialog = ZoneDialog( + None, config, parent=self, frame=frame, calibration=self._zone_calibration() + ) try: if dialog.exec() != QDialog.DialogCode.Accepted: return diff --git a/src/glider/gui/dialogs/zone_dialog.py b/src/glider/gui/dialogs/zone_dialog.py index 18331d6c..ff7879bd 100644 --- a/src/glider/gui/dialogs/zone_dialog.py +++ b/src/glider/gui/dialogs/zone_dialog.py @@ -9,6 +9,7 @@ import math import uuid from collections.abc import Callable +from dataclasses import dataclass from typing import TYPE_CHECKING import cv2 @@ -20,6 +21,7 @@ QButtonGroup, QCheckBox, QColorDialog, + QComboBox, QDialog, QDoubleSpinBox, QFileDialog, @@ -82,6 +84,25 @@ #: (cm) and on a 640x480 frame (px). DEFAULT_SIZE = {"cm": (10.0, 10.0), "px": (100.0, 100.0)} +#: The units a fixed size can be given in, as the unit picker lists them. +UNITS = ("px", "cm") + + +@dataclass(frozen=True) +class FrameScale: + """A scale known only as a number, on the frame the zones are drawn on. + + What a recorded session carries (its px/mm) when there is no arena + drawn for it: enough to turn centimetres into pixels, with no perspective. + """ + + pixels_per_mm: float + + @property + def is_calibrated(self) -> bool: + return self.pixels_per_mm > 0 + + #: A stamp turns a normalized click position and the frame size into the shape #: and vertices of a fixed-size zone; ValueError means "nothing to place". Stamp = Callable[[tuple[float, float], tuple[int, int]], tuple[ZoneShape, list]] @@ -665,6 +686,7 @@ def __init__( self._preview.set_frame(initial) self._zone_config.config_width = initial.shape[1] self._zone_config.config_height = initial.shape[0] + self._update_info() self._preview.set_zone_configuration(self._zone_config) @@ -720,8 +742,9 @@ def _setup_ui(self) -> None: self._fixed_size_check = QCheckBox("Fixed size") self._fixed_size_check.setToolTip( "Place rectangles and circles of a set size with one click, centred where " - "you click.\nWith an arena calibration the size is in cm on the floor and " - "the zone follows the camera's perspective (it is stored as a polygon)." + "you click.\nThe size is in frame pixels, or in cm once a calibration is " + "known; through an arena, cm are on the floor and the zone follows the " + "camera's perspective (it is stored as a polygon)." ) self._fixed_size_check.toggled.connect(self._on_size_changed) size_layout.addWidget(self._fixed_size_check) @@ -740,8 +763,12 @@ def _setup_ui(self) -> None: spin.setKeyboardTracking(False) spin.valueChanged.connect(self._on_size_changed) - self._unit_label = QLabel("px") - size_layout.addWidget(self._unit_label) + # Pixels always work; centimetres only once a calibration can convert + # them, so the cm entry is disabled until then (see _update_size_controls). + self._unit_combo = QComboBox() + self._unit_combo.addItems(UNITS) + self._unit_combo.currentTextChanged.connect(self._on_unit_changed) + size_layout.addWidget(self._unit_combo) size_layout.addStretch() left_layout.addLayout(size_layout) @@ -836,6 +863,9 @@ def _setup_ui(self) -> None: self._res_label = QLabel("--") info_layout.addRow("Resolution:", self._res_label) + self._scale_label = QLabel("--") + info_layout.addRow("Scale:", self._scale_label) + right_layout.addWidget(info_group) # File operations @@ -898,6 +928,7 @@ def set_calibration(self, calibration) -> None: self._calibration = calibration self._load_size_settings() self._update_size_controls() + self._update_info() def _arena(self): """The calibration, if it can map image points to the floor and back.""" @@ -919,12 +950,31 @@ def _scalar_px_per_cm(self, frame_width: int) -> float | None: ppm *= frame_width / cal_width return ppm * 10.0 + def _cm_available(self) -> bool: + """Whether a calibration can turn centimetres into pixels.""" + return self._arena() is not None or self._scalar_px_per_cm(0) is not None + def _size_unit(self) -> str: - """``"cm"`` when a calibration can turn centimetres into pixels, else ``"px"``.""" - if self._arena() is not None or self._scalar_px_per_cm(0) is not None: + """The unit fixed sizes are given in: the one picked, but never cm uncalibrated. + + Centimetres are the default whenever they are possible; a choice of + pixels is remembered and survives a calibration arriving. + """ + try: + picked = str(self._settings.value(self._size_key("unit"), "cm")) + except (TypeError, ValueError): + picked = "cm" + if picked == "cm" and self._cm_available(): return "cm" return "px" + def _on_unit_changed(self, unit: str) -> None: + if unit not in UNITS or unit == self._size_unit(): + return + self._settings.setValue(self._size_key("unit"), unit) + self._load_size_settings() + self._update_size_controls() + def _size_key(self, name: str) -> str: return f"{SETTINGS_GROUP}/{name}" @@ -985,13 +1035,23 @@ def _update_size_controls(self) -> None: circle = shape == ZoneShape.CIRCLE self._fixed_size_check.setEnabled(sizable) enabled = sizable and self._fixed_size_check.isChecked() - for w in (self._width_label, self._width_spin, self._height_spin, self._unit_label): + for w in (self._width_label, self._width_spin, self._height_spin, self._unit_combo): w.setEnabled(enabled) self._height_label.setEnabled(enabled) self._width_label.setText("Diameter:" if circle else "Width:") self._height_label.setVisible(not circle) self._height_spin.setVisible(not circle) - self._unit_label.setText(self._size_unit()) + + cm_item = self._unit_combo.model().item(UNITS.index("cm")) + cm_item.setEnabled(self._cm_available()) + cm_item.setToolTip( + "Centimetres on the arena floor" + if self._cm_available() + else "Needs a calibration: draw an arena or set the scale first" + ) + self._unit_combo.blockSignals(True) + self._unit_combo.setCurrentText(self._size_unit()) + self._unit_combo.blockSignals(False) self._preview.set_stamp(self._stamp if enabled else None) def _stamp( @@ -999,13 +1059,16 @@ def _stamp( ) -> tuple[ZoneShape, list[tuple[float, float]]]: """The fixed-size zone centred on *centre*: its shape and normalized vertices. - Through an arena the size is laid out on the floor and the result is a - polygon (see :func:`arena_sized_zone_vertices`); with a scalar - calibration or none it stays a rectangle or circle. + In pixels the size is taken as-is. In centimetres through an arena the + size is laid out on the floor and the result is a polygon (see + :func:`arena_sized_zone_vertices`); with a scalar calibration it stays a + rectangle or circle. """ shape = self._get_current_shape() width = self._width_spin.value() height = width if shape == ZoneShape.CIRCLE else self._height_spin.value() + if self._size_unit() == "px": + return shape, sized_zone_vertices(shape, centre, width, height, frame_size) arena = self._arena() if arena is not None: return ZoneShape.POLYGON, arena_sized_zone_vertices(arena, shape, centre, width, height) @@ -1225,6 +1288,16 @@ def _update_info(self) -> None: ) else: self._res_label.setText("--") + self._scale_label.setText(self._scale_text()) + + def _scale_text(self) -> str: + """The calibration's px/cm on this frame, for the information box.""" + arena = self._arena() + if arena is not None: + centre = getattr(arena, "px_per_cm_centre", None) + return f"{centre:.2f} px/cm (arena centre)" if centre else "arena" + scale = self._scalar_px_per_cm(self._zone_config.config_width) + return f"{scale:.2f} px/cm" if scale else "none (sizes in px)" def _save_config(self) -> None: """Save zone configuration to file.""" diff --git a/tests/unit/gui/behavior/test_analysis_window.py b/tests/unit/gui/behavior/test_analysis_window.py index e5bca444..87df7243 100644 --- a/tests/unit/gui/behavior/test_analysis_window.py +++ b/tests/unit/gui/behavior/test_analysis_window.py @@ -158,6 +158,26 @@ def test_an_uncalibrated_session_says_so_rather_than_reporting_pixels(self, qtbo win._bar.set_selection(0, 200) assert "no calibration" in win._summary.text().lower() + def test_zones_are_sized_in_pixels_without_a_calibration(self, qtbot, tmp_path): + win = self._win(qtbot, tmp_path) + assert win._zone_calibration() is None + + def test_the_session_scale_lets_zones_be_sized_in_cm(self, qtbot, tmp_path): + from glider.gui.dialogs.zone_dialog import FrameScale + + win = self._win(qtbot, tmp_path) + win._view.px_per_mm = 2.5 + assert win._zone_calibration() == FrameScale(pixels_per_mm=2.5) + + def test_a_drawn_arena_beats_the_plain_scale_for_zone_sizes(self, qtbot, tmp_path): + from glider.vision.arena import ArenaCalibration + + win = self._win(qtbot, tmp_path) + win._view.px_per_mm = 2.5 + arena = ArenaCalibration(corners=[(0, 0), (1, 0), (1, 1), (0, 1)]) + win._arenas[win._shown] = (arena, "test") + assert win._zone_calibration() is arena + def test_select_whole_session(self, qtbot, tmp_path): win = self._win(qtbot, tmp_path) win._select_all() diff --git a/tests/unit/gui/test_zone_dialog_move_and_size.py b/tests/unit/gui/test_zone_dialog_move_and_size.py index a6b0b110..26e9c141 100644 --- a/tests/unit/gui/test_zone_dialog_move_and_size.py +++ b/tests/unit/gui/test_zone_dialog_move_and_size.py @@ -7,7 +7,7 @@ from PyQt6.QtCore import QPointF, QSettings, Qt from PyQt6.QtGui import QKeyEvent, QMouseEvent -from glider.gui.dialogs.zone_dialog import ZoneDialog +from glider.gui.dialogs.zone_dialog import FrameScale, ZoneDialog from glider.vision.arena import ArenaCalibration from glider.vision.zones import Zone, ZoneConfiguration, ZoneShape @@ -146,7 +146,7 @@ def test_a_zone_cannot_be_dragged_off_the_frame(self, qtbot, settings): class TestFixedSize: def test_one_click_places_a_rectangle_of_the_set_pixel_size(self, qtbot, settings): dialog, preview = _editor(qtbot, settings) - assert dialog._unit_label.text() == "px" + assert dialog._unit_combo.currentText() == "px" dialog._fixed_size_check.setChecked(True) dialog._width_spin.setValue(64) dialog._height_spin.setValue(96) @@ -201,7 +201,7 @@ def test_with_an_arena_the_size_is_in_cm_on_the_floor(self, qtbot, settings): frame_size=(640, 480), ) dialog, preview = _editor(qtbot, settings, calibration=arena) - assert dialog._unit_label.text() == "cm" + assert dialog._unit_combo.currentText() == "cm" dialog._fixed_size_check.setChecked(True) dialog._width_spin.setValue(10) dialog._height_spin.setValue(5) @@ -223,7 +223,7 @@ class Scalar: calibration_width = 320 dialog, preview = _editor(qtbot, settings, calibration=Scalar()) - assert dialog._unit_label.text() == "cm" + assert dialog._unit_combo.currentText() == "cm" dialog._fixed_size_check.setChecked(True) dialog._width_spin.setValue(2) dialog._height_spin.setValue(1) @@ -245,5 +245,66 @@ def test_sizes_are_remembered_per_unit(self, qtbot, settings): # Centimetres are a different quantity: they keep their own default. arena = ArenaCalibration(corners=[(0, 0), (1, 0), (1, 1), (0, 1)]) again.set_calibration(arena) - assert again._unit_label.text() == "cm" + assert again._unit_combo.currentText() == "cm" assert again._width_spin.value() == 10.0 + + +class TestSizeUnit: + def test_without_a_calibration_cm_cannot_be_picked(self, qtbot, settings): + dialog, _preview = _editor(qtbot, settings) + cm = dialog._unit_combo.model().item(dialog._unit_combo.findText("cm")) + assert not cm.isEnabled() + assert dialog._unit_combo.currentText() == "px" + + def test_pixels_can_be_picked_over_an_arena(self, qtbot, settings): + arena = ArenaCalibration( + corners=[(0, 0), (1, 0), (1, 1), (0, 1)], + width_cm=40.0, + height_cm=30.0, + frame_size=(640, 480), + ) + dialog, preview = _editor(qtbot, settings, calibration=arena) + dialog._fixed_size_check.setChecked(True) + dialog._unit_combo.setCurrentText("px") + dialog._width_spin.setValue(64) + dialog._height_spin.setValue(48) + dialog._draw_btn.setChecked(True) + + _mouse(preview, "press", 320, 240) + + zone = dialog._pending_zone + assert zone.shape == ZoneShape.RECTANGLE + (x1, y1), (x2, y2) = zone.vertices + assert ((x2 - x1) * 640, (y2 - y1) * 480) == pytest.approx((64, 48)) + + def test_the_picked_unit_is_remembered_and_keeps_its_own_size(self, qtbot, settings): + scale = FrameScale(pixels_per_mm=4.0) + dialog, _preview = _editor(qtbot, settings, calibration=scale) + dialog._fixed_size_check.setChecked(True) + assert dialog._unit_combo.currentText() == "cm" + dialog._width_spin.setValue(3) + + dialog._unit_combo.setCurrentText("px") + assert dialog._width_spin.value() == 100.0 # the px default, not 3 px + dialog._width_spin.setValue(250) + + again, _ = _editor(qtbot, settings, calibration=scale) + assert again._unit_combo.currentText() == "px" + assert again._width_spin.value() == 250 + again._unit_combo.setCurrentText("cm") + assert again._width_spin.value() == 3 + + def test_a_frame_scale_sizes_cm_on_the_frame_as_given(self, qtbot, settings): + dialog, preview = _editor(qtbot, settings, calibration=FrameScale(pixels_per_mm=4.0)) + assert dialog._scale_label.text() == "40.00 px/cm" + dialog._fixed_size_check.setChecked(True) + dialog._width_spin.setValue(2) + dialog._height_spin.setValue(1) + dialog._draw_btn.setChecked(True) + _mouse(preview, "press", 320, 240) + (x1, y1), (x2, y2) = dialog._pending_zone.vertices + assert ((x2 - x1) * 640, (y2 - y1) * 480) == pytest.approx((80, 40)) + + def test_an_uncalibrated_frame_scale_leaves_sizes_in_pixels(self, qtbot, settings): + dialog, _preview = _editor(qtbot, settings, calibration=FrameScale(pixels_per_mm=0.0)) + assert dialog._unit_combo.currentText() == "px"