Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 22 additions & 2 deletions src/glider/gui/behavior/analysis_window.py
Original file line number Diff line number Diff line change
Expand Up @@ -1550,14 +1550,32 @@ 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
return dialog.get_zone_configuration()
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.

Expand Down Expand Up @@ -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
Expand Down
95 changes: 84 additions & 11 deletions src/glider/gui/dialogs/zone_dialog.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import math
import uuid
from collections.abc import Callable
from dataclasses import dataclass
from typing import TYPE_CHECKING

import cv2
Expand All @@ -20,6 +21,7 @@
QButtonGroup,
QCheckBox,
QColorDialog,
QComboBox,
QDialog,
QDoubleSpinBox,
QFileDialog,
Expand Down Expand Up @@ -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]]
Expand Down Expand Up @@ -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)

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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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."""
Expand All @@ -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}"

Expand Down Expand Up @@ -985,27 +1035,40 @@ 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(
self, centre: tuple[float, float], frame_size: tuple[int, int]
) -> 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)
Expand Down Expand Up @@ -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."""
Expand Down
20 changes: 20 additions & 0 deletions tests/unit/gui/behavior/test_analysis_window.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
71 changes: 66 additions & 5 deletions tests/unit/gui/test_zone_dialog_move_and_size.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

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