Skip to content

Commit 10381ea

Browse files
committed
Add unit tests for utils/display and utils/stats
Add comprehensive unit tests for dlclivegui.utils.display and dlclivegui.utils.stats, covering tiling geometry, tiled frame creation, drawing utilities (bbox, keypoints, pose), and stats formatting. Introduce property-based tests using Hypothesis for recorder and DLC stats formatting and several exact-case tests. Also update pyproject.toml to include hypothesis>=6.0 in dev and test dependencies.
1 parent c3d23e3 commit 10381ea

File tree

3 files changed

+474
-0
lines changed

3 files changed

+474
-0
lines changed

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,12 +52,14 @@ dev = [
5252
"pytest-mock>=3.10",
5353
"pytest-qt>=4.2",
5454
"pre-commit",
55+
"hypothesis>=6.0",
5556
]
5657
test = [
5758
"pytest>=7.0",
5859
"pytest-cov>=4.0",
5960
"pytest-mock>=3.10",
6061
"pytest-qt>=4.2",
62+
"hypothesis>=6.0",
6163
]
6264

6365
[project.urls]

tests/utils/test_display.py

Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
import numpy as np
2+
import pytest
3+
4+
from dlclivegui.utils.display import ( # noqa: E402
5+
compute_tile_info,
6+
compute_tiling_geometry,
7+
create_tiled_frame,
8+
draw_bbox,
9+
draw_keypoints,
10+
draw_pose,
11+
)
12+
13+
pytestmark = pytest.mark.unit
14+
15+
16+
def _frame(h, w, c=3, value=0, dtype=np.uint8):
17+
"""Helper to create test frames with predictable content."""
18+
if c == 1:
19+
return (np.ones((h, w), dtype=dtype) * value).astype(dtype)
20+
return (np.ones((h, w, c), dtype=dtype) * value).astype(dtype)
21+
22+
23+
def test_compute_tiling_geometry_empty():
24+
cam_ids, rows, cols, tile_w, tile_h = compute_tiling_geometry({})
25+
assert cam_ids == []
26+
assert (rows, cols) == (1, 1)
27+
assert (tile_w, tile_h) == (640, 480)
28+
29+
30+
def test_compute_tiling_geometry_single_frame_respects_max_canvas_and_min_tile():
31+
frames = {"camA": _frame(480, 640, 3)}
32+
cam_ids, rows, cols, tile_w, tile_h = compute_tiling_geometry(frames, max_canvas=(1200, 800))
33+
assert cam_ids == ["camA"]
34+
assert (rows, cols) == (1, 1)
35+
assert tile_w >= 160
36+
assert tile_h >= 120
37+
assert tile_w <= 1200
38+
assert tile_h <= 800
39+
40+
41+
def test_compute_tiling_geometry_two_frames_is_1x2():
42+
frames = {"camB": _frame(480, 640, 3), "camA": _frame(480, 640, 3)}
43+
cam_ids, rows, cols, tile_w, tile_h = compute_tiling_geometry(frames, max_canvas=(1200, 800))
44+
assert cam_ids == ["camA", "camB"] # sorted
45+
assert (rows, cols) == (1, 2)
46+
assert tile_w >= 160 and tile_h >= 120
47+
48+
49+
def test_compute_tiling_geometry_three_frames_is_2x2():
50+
frames = {"c3": _frame(480, 640, 3), "c1": _frame(480, 640, 3), "c2": _frame(480, 640, 3)}
51+
cam_ids, rows, cols, tile_w, tile_h = compute_tiling_geometry(frames, max_canvas=(1200, 800))
52+
assert cam_ids == ["c1", "c2", "c3"]
53+
assert (rows, cols) == (2, 2)
54+
assert tile_w >= 160 and tile_h >= 120
55+
56+
57+
def test_compute_tiling_geometry_reference_aspect_is_first_sorted_cam():
58+
# camA has aspect 2.0 (w/h), camB has aspect 0.5
59+
frames = {
60+
"camB": _frame(400, 200, 3),
61+
"camA": _frame(200, 400, 3),
62+
}
63+
cam_ids, rows, cols, tile_w, tile_h = compute_tiling_geometry(frames, max_canvas=(1200, 800))
64+
assert cam_ids == ["camA", "camB"]
65+
66+
# For 2 cams, rows=1 cols=2 => initial tile_w=600 tile_h=800 => tile_aspect=0.75
67+
# frame_aspect for camA = 400/200 = 2.0 > 0.75 => tile_h adjusted to tile_w/frame_aspect = 600/2 = 300
68+
assert (rows, cols) == (1, 2)
69+
assert tile_w == 600
70+
assert tile_h == 300
71+
72+
73+
def test_create_tiled_frame_empty_returns_default_canvas():
74+
out = create_tiled_frame({})
75+
assert out.shape == (480, 640, 3)
76+
assert out.dtype == np.uint8
77+
assert np.all(out == 0)
78+
79+
80+
def test_create_tiled_frame_grayscale_converted_and_labeled():
81+
# Use a zero grayscale frame; any nonzero in output likely comes from putText label
82+
frames = {"camA": _frame(120, 160, c=1, value=0)}
83+
out = create_tiled_frame(frames, max_canvas=(320, 240))
84+
85+
assert out.ndim == 3 and out.shape[2] == 3
86+
# Label should introduce some nonzero (green) pixels
87+
assert np.any(out != 0)
88+
89+
90+
def test_create_tiled_frame_bgra_converted_and_labeled():
91+
# BGRA frame
92+
bgra = _frame(120, 160, c=4, value=0)
93+
frames = {"camA": bgra}
94+
out = create_tiled_frame(frames, max_canvas=(320, 240))
95+
96+
assert out.ndim == 3 and out.shape[2] == 3
97+
assert np.any(out != 0)
98+
99+
100+
def test_create_tiled_frame_canvas_shape_matches_geometry():
101+
frames = {
102+
"camA": _frame(200, 400, 3, value=0),
103+
"camB": _frame(200, 400, 3, value=0),
104+
}
105+
cam_ids, rows, cols, tile_w, tile_h = compute_tiling_geometry(frames, max_canvas=(800, 400))
106+
out = create_tiled_frame(frames, max_canvas=(800, 400))
107+
assert out.shape == (rows * tile_h, cols * tile_w, 3)
108+
# both tiles should get labels (nonzero pixels)
109+
assert np.any(out != 0)
110+
111+
112+
def test_compute_tile_info_offset_and_scale_matches_tiling():
113+
# 2 frames => 1x2 tiling, cam ids sorted: ["cam1", "cam2"]
114+
frames = {"cam2": _frame(200, 400, 3), "cam1": _frame(200, 400, 3)}
115+
cam_ids, rows, cols, tile_w, tile_h = compute_tiling_geometry(frames, max_canvas=(1200, 800))
116+
117+
original = _frame(200, 400, 3)
118+
(ox, oy), (sx, sy) = compute_tile_info("cam2", original, frames, max_canvas=(1200, 800))
119+
120+
# cam2 is index 1 -> row 0 col 1
121+
assert (rows, cols) == (1, 2)
122+
assert ox == tile_w
123+
assert oy == 0
124+
assert sx == pytest.approx(tile_w / 400)
125+
assert sy == pytest.approx(tile_h / 200)
126+
127+
128+
def test_draw_bbox_invalid_bbox_returns_same_object():
129+
frame = _frame(100, 100, 3)
130+
out = draw_bbox(frame, (10, 10, 10, 20), (0, 255, 0)) # x0 == x1 invalid
131+
assert out is frame # passthrough for invalid bbox
132+
133+
134+
def test_draw_bbox_draws_rectangle_and_clips():
135+
frame = _frame(60, 60, 3, value=0)
136+
color = (0, 0, 255) # red in BGR
137+
138+
# bbox partially outside original; with scale/offset it will be shifted/clipped
139+
out = draw_bbox(
140+
frame,
141+
bbox_xyxy=(-10, -10, 50, 50),
142+
color_bgr=color,
143+
offset=(5, 5),
144+
scale=(1.0, 1.0),
145+
)
146+
147+
assert out is not frame
148+
# Should have drawn something
149+
assert np.any(out != frame)
150+
# At least some red pixels should exist (allowing for thickness)
151+
assert np.any((out[:, :, 2] > 0) & (out[:, :, 0] == 0) & (out[:, :, 1] == 0))
152+
153+
154+
def test_draw_keypoints_filters_by_cutoff_and_nans_and_draws():
155+
overlay = _frame(80, 80, 3, value=0).copy()
156+
cmap = __import__("matplotlib.pyplot").pyplot.get_cmap("viridis")
157+
158+
# keypoints: (x, y, conf)
159+
kpts = np.array(
160+
[
161+
[10.0, 10.0, 0.2], # below cutoff -> ignored
162+
[np.nan, 15.0, 0.99], # NaN -> ignored
163+
[20.0, np.nan, 0.99], # NaN -> ignored
164+
[30.0, 30.0, 0.99], # should draw
165+
],
166+
dtype=float,
167+
)
168+
169+
draw_keypoints(
170+
overlay=overlay,
171+
p_cutoff=0.9,
172+
sx=1.0,
173+
ox=0,
174+
sy=1.0,
175+
oy=0,
176+
radius=3,
177+
cmap=cmap,
178+
keypoints=kpts,
179+
marker=None, # circle
180+
)
181+
182+
assert np.any(overlay != 0) # something drawn
183+
184+
185+
def test_draw_pose_single_animal_draws_when_conf_above_cutoff():
186+
frame = _frame(100, 100, 3, value=0)
187+
pose = np.array(
188+
[
189+
[10.0, 10.0, 0.95],
190+
[20.0, 20.0, 0.95],
191+
],
192+
dtype=float,
193+
)
194+
out = draw_pose(frame, pose, p_cutoff=0.9, colormap="viridis", offset=(0, 0), scale=(1.0, 1.0))
195+
assert out is not frame
196+
assert np.any(out != frame)
197+
198+
199+
def test_draw_pose_single_animal_no_draw_below_cutoff():
200+
frame = _frame(100, 100, 3, value=0)
201+
pose = np.array([[10.0, 10.0, 0.1]], dtype=float)
202+
out = draw_pose(frame, pose, p_cutoff=0.9, colormap="viridis", offset=(0, 0), scale=(1.0, 1.0))
203+
# overlay returned, but should be identical if nothing is drawn
204+
assert np.array_equal(out, frame)
205+
206+
207+
def test_draw_pose_multi_animal_draws_distinct_markers():
208+
frame = _frame(120, 120, 3, value=0)
209+
# A x N x 3 : 2 animals, 1 keypoint each
210+
pose = np.array(
211+
[
212+
[[30.0, 30.0, 0.99]],
213+
[[60.0, 60.0, 0.99]],
214+
],
215+
dtype=float,
216+
)
217+
out = draw_pose(frame, pose, p_cutoff=0.9, colormap="viridis", offset=(0, 0), scale=(1.0, 1.0))
218+
assert out is not frame
219+
assert np.any(out != frame)

0 commit comments

Comments
 (0)