Skip to content

Commit ad2fe52

Browse files
Moved ArUco and chessboard detectors to marker-detection
commit-id:cdfa0908
1 parent fc6be98 commit ad2fe52

File tree

4 files changed

+1010
-4
lines changed

4 files changed

+1010
-4
lines changed

packages/marker-detection/pyproject.toml

+15-4
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,22 @@ name = "marker-detection"
33
version = "0.1.0"
44
description = "Add your description here"
55
readme = "README.md"
6-
authors = [
7-
{ name = "Jan Smółka", email = "[email protected]" }
8-
]
6+
authors = [{ name = "Jan Smółka", email = "[email protected]" }]
97
requires-python = ">=3.12.7"
10-
dependencies = []
8+
dependencies = [
9+
"attrs>=25.1.0",
10+
"numpy>=1.26.4",
11+
"opencv-python>=4.11.0.86",
12+
"pyserde>=0.23.0",
13+
"scipy>=1.15.1",
14+
"video-io",
15+
]
16+
17+
[dependency-groups]
18+
dev = ["pytest>=8.3.4", "syrupy>=4.8.1"]
19+
20+
[tool.uv.sources]
21+
video-io = { workspace = true }
1122

1223
[build-system]
1324
requires = ["hatchling"]
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,347 @@
1+
import typing
2+
from dataclasses import dataclass
3+
from enum import IntEnum
4+
from typing import TypedDict
5+
6+
import cv2 as opencv
7+
import cv2.aruco as opencv_aruco
8+
import numpy
9+
import scipy.spatial.transform as scipy_transform
10+
from jaxtyping import Float, Int
11+
from numpy.typing import NDArray
12+
from video_io import annotation
13+
from video_io.frame import ArrayRgbFrame
14+
15+
type Array = NDArray[numpy.floating[typing.Any]]
16+
17+
type IntrinsicsMatrix = Float[Array, '3 3']
18+
type DistortionCoefficients = Float[Array, '5']
19+
20+
21+
# TODO: Add serialization to `Dictionary`.
22+
23+
24+
class Dictionary(IntEnum):
25+
ARUCO_ORIGINAL = opencv_aruco.DICT_ARUCO_ORIGINAL
26+
27+
ARUCO_4X4_100 = opencv_aruco.DICT_4X4_100
28+
ARUCO_4X4_200 = opencv_aruco.DICT_4X4_250
29+
ARUCO_4X4_1000 = opencv_aruco.DICT_4X4_1000
30+
31+
ARUCO_5X5_50 = opencv_aruco.DICT_5X5_50
32+
ARUCO_5X5_100 = opencv_aruco.DICT_5X5_100
33+
ARUCO_5X5_250 = opencv_aruco.DICT_5X5_250
34+
ARUCO_5X5_1000 = opencv_aruco.DICT_5X5_1000
35+
36+
ARUCO_6X6_50 = opencv_aruco.DICT_6X6_50
37+
ARUCO_6X6_100 = opencv_aruco.DICT_6X6_100
38+
ARUCO_6X6_250 = opencv_aruco.DICT_6X6_250
39+
ARUCO_6X6_1000 = opencv_aruco.DICT_6X6_1000
40+
41+
ARUCO_7X7_50 = opencv_aruco.DICT_7X7_50
42+
ARUCO_7X7_100 = opencv_aruco.DICT_7X7_100
43+
ARUCO_7X7_250 = opencv_aruco.DICT_7X7_250
44+
ARUCO_7X7_1000 = opencv_aruco.DICT_7X7_1000
45+
46+
ARUCO_MIP_36H12 = opencv_aruco.DICT_ARUCO_MIP_36H12
47+
48+
APRIL_16H5 = opencv_aruco.DICT_APRILTAG_16H5
49+
APRIL_25H9 = opencv_aruco.DICT_APRILTAG_25H9
50+
APRIL_36H10 = opencv_aruco.DICT_APRILTAG_36H10
51+
APRIL_36H11 = opencv_aruco.DICT_APRILTAG_36H11
52+
53+
@classmethod
54+
def parse(cls, input: str) -> 'Dictionary | None':
55+
return _DICTIONARY_NAMES_TO_VARIANTS.get(input)
56+
57+
58+
_DICTIONARY_NAMES_TO_VARIANTS: dict[str, Dictionary] = {
59+
'aruco-original': Dictionary.ARUCO_ORIGINAL,
60+
'aruco-4x4-100': Dictionary.ARUCO_4X4_100,
61+
'aruco-4x4-200': Dictionary.ARUCO_4X4_200,
62+
'aruco-4x4-1000': Dictionary.ARUCO_4X4_1000,
63+
'aruco-5x5-50': Dictionary.ARUCO_5X5_50,
64+
'aruco-5x5-100': Dictionary.ARUCO_5X5_100,
65+
'aruco-5x5-250': Dictionary.ARUCO_5X5_250,
66+
'aruco-5x5-1000': Dictionary.ARUCO_5X5_1000,
67+
'aruco-6x6-50': Dictionary.ARUCO_6X6_50,
68+
'aruco-6x6-100': Dictionary.ARUCO_6X6_100,
69+
'aruco-6x6-250': Dictionary.ARUCO_6X6_250,
70+
'aruco-6x6-1000': Dictionary.ARUCO_6X6_1000,
71+
'aruco-7x7-50': Dictionary.ARUCO_7X7_50,
72+
'aruco-7x7-100': Dictionary.ARUCO_7X7_100,
73+
'aruco-7x7-250': Dictionary.ARUCO_7X7_250,
74+
'aruco-7x7-1000': Dictionary.ARUCO_7X7_1000,
75+
'aruco-mip-36h12': Dictionary.ARUCO_MIP_36H12,
76+
'april-16h5': Dictionary.APRIL_16H5,
77+
'april-25h9': Dictionary.APRIL_25H9,
78+
'april-36h10': Dictionary.APRIL_36H10,
79+
'april-36h11': Dictionary.APRIL_36H11,
80+
}
81+
82+
_DICTIONARY_VARIANTS_TO_NAMES: dict[Dictionary, str] = dict(
83+
map(lambda entry: (entry[1], entry[0]), _DICTIONARY_NAMES_TO_VARIANTS.items())
84+
)
85+
86+
87+
class RigidModel:
88+
square_size: float
89+
coordinates: Float[Array, '3 4']
90+
91+
def __init__(self, square_size: float, depth: float) -> None:
92+
self.square_size = square_size
93+
94+
self.coordinates = numpy.array(
95+
[
96+
[-square_size / 2.0, square_size / 2.0, depth],
97+
[square_size / 2.0, square_size / 2.0, depth],
98+
[square_size / 2.0, -square_size / 2.0, depth],
99+
[-square_size / 2.0, -square_size / 2.0, depth],
100+
],
101+
dtype=numpy.float32,
102+
)
103+
104+
105+
class VisualizationParameters(TypedDict):
106+
intrinsics: IntrinsicsMatrix
107+
marker_draw_masks: bool
108+
marker_draw_ids: bool
109+
marker_draw_axes: bool
110+
marker_draw_angles: bool
111+
marker_mask_color: tuple[float, float, float, float]
112+
marker_axis_length: int
113+
marker_axis_thickness: int
114+
115+
116+
@dataclass(frozen=True, slots=True)
117+
class Transformation:
118+
rotation: Float[Array, '3 3']
119+
translation: Float[Array, '3']
120+
121+
def euler_angles(self) -> Float[Array, '3']:
122+
return ( # type: ignore[no-any-return]
123+
scipy_transform.Rotation.from_matrix(self.rotation).as_euler(
124+
'xyz',
125+
degrees=False,
126+
)
127+
)
128+
129+
130+
@dataclass(frozen=True)
131+
class Result:
132+
corners: Int[Array, 'n 4']
133+
ids: Int[Array, ' n']
134+
transformations: list[Transformation]
135+
136+
def visualize(
137+
self, frame: ArrayRgbFrame, properties: VisualizationParameters
138+
) -> ArrayRgbFrame:
139+
if len(self.ids) == 0:
140+
return frame
141+
142+
draw_boxes = properties['marker_draw_masks']
143+
draw_ids = properties['marker_draw_ids']
144+
draw_axes = properties['marker_draw_axes']
145+
draw_angles = properties['marker_draw_angles']
146+
147+
# TODO: Get rid of nested parts. It's better to have the same loop a few times.
148+
149+
if draw_boxes:
150+
r, g, b, _ = properties['marker_mask_color']
151+
color = (int(r), int(g), int(b))
152+
153+
for marker_corners_raw in self.corners:
154+
marker_corners = marker_corners_raw.reshape(-1, 2).astype(numpy.int32)
155+
156+
annotation.draw_filled_polygon_with_opacity(
157+
frame,
158+
marker_corners,
159+
color=color,
160+
opacity=0.5,
161+
)
162+
163+
corner_pixels: list[tuple[int, int]] = list(map(tuple, marker_corners))
164+
upper_left, upper_right, lower_right, lower_left = corner_pixels
165+
166+
annotation.draw_point_with_description(
167+
frame,
168+
upper_left,
169+
'upper_left',
170+
point_radius=1,
171+
font_scale=0.4,
172+
text_location='above',
173+
)
174+
annotation.draw_point_with_description(
175+
frame,
176+
upper_right,
177+
'upper_right',
178+
point_radius=1,
179+
font_scale=0.4,
180+
text_location='above',
181+
)
182+
annotation.draw_point_with_description(
183+
frame,
184+
lower_right,
185+
'lower_right',
186+
point_radius=1,
187+
font_scale=0.4,
188+
)
189+
annotation.draw_point_with_description(
190+
frame,
191+
lower_left,
192+
'lower_left',
193+
point_radius=1,
194+
font_scale=0.4,
195+
)
196+
197+
if draw_axes:
198+
length = properties['marker_axis_length']
199+
thickness = properties['marker_axis_thickness']
200+
201+
x_color = (255, 0, 0)
202+
y_color = (0, 255, 0)
203+
z_color = (0, 0, 255)
204+
205+
intrinsics = properties['intrinsics']
206+
207+
basis_points = numpy.array(
208+
[
209+
[0.0, 0.0, 0.0],
210+
[length, 0.0, 0.0],
211+
[0.0, length, 0.0],
212+
[0.0, 0.0, length],
213+
],
214+
dtype=numpy.float32,
215+
).T
216+
217+
for transformation in self.transformations:
218+
rotation = transformation.rotation
219+
translation = transformation.translation.reshape(-1, 1)
220+
221+
transformed_basis = rotation @ basis_points + translation
222+
transformed_basis /= transformed_basis[2]
223+
projected_basis = intrinsics @ transformed_basis
224+
225+
projected_basis_pixel_coordinates: list[tuple[int, int]] = (
226+
projected_basis.T[:, :2].astype(int).tolist()
227+
)
228+
o, x, y, z = projected_basis_pixel_coordinates
229+
230+
opencv.line(frame, o, x, x_color, thickness)
231+
opencv.line(frame, o, y, y_color, thickness)
232+
opencv.line(frame, o, z, z_color, thickness)
233+
234+
if draw_angles:
235+
angles: list[float] = (
236+
180.0 / numpy.pi * transformation.euler_angles()
237+
).tolist()
238+
239+
x_angle, y_angle, z_angle = angles
240+
241+
annotation.draw_point_with_description(
242+
frame,
243+
x,
244+
f'x: {x_angle:.2f}',
245+
font_scale=0.3,
246+
point_radius=1,
247+
point_color=x_color,
248+
)
249+
annotation.draw_point_with_description(
250+
frame,
251+
y,
252+
f'y: {y_angle:.2f}',
253+
font_scale=0.3,
254+
point_radius=1,
255+
point_color=y_color,
256+
)
257+
annotation.draw_point_with_description(
258+
frame,
259+
z,
260+
f'z: {z_angle:.2f}',
261+
font_scale=0.3,
262+
point_radius=1,
263+
point_color=z_color,
264+
)
265+
266+
if draw_ids:
267+
for id, marker_corners in zip(self.ids, self.corners):
268+
marker_corners = marker_corners.reshape(-1, 2)
269+
center: tuple[int, int] = (
270+
numpy.mean(marker_corners, axis=0).astype(int).tolist()
271+
)
272+
273+
annotation.draw_text_within_box(
274+
frame,
275+
f'marker {id}',
276+
center,
277+
font_scale=0.3,
278+
)
279+
280+
return frame
281+
282+
283+
class Detector:
284+
dictionary: Dictionary
285+
raw_dictionary: opencv_aruco.Dictionary
286+
detector: opencv_aruco.ArucoDetector
287+
288+
marker_size: float
289+
marker_local_coordinates: Float[Array, '3 4']
290+
291+
intrinsics: IntrinsicsMatrix
292+
distortion: DistortionCoefficients
293+
294+
def __init__(
295+
self,
296+
*,
297+
model: RigidModel,
298+
dictionary: Dictionary,
299+
detector_parameters: opencv_aruco.DetectorParameters,
300+
intrinsics: IntrinsicsMatrix,
301+
distortion: DistortionCoefficients,
302+
) -> None:
303+
self.dictionary = dictionary
304+
305+
raw_dictionary = opencv_aruco.getPredefinedDictionary(dictionary.value)
306+
self.raw_dictionary = raw_dictionary
307+
308+
self.detector = opencv_aruco.ArucoDetector(
309+
raw_dictionary,
310+
detector_parameters,
311+
)
312+
313+
self.model = model
314+
315+
def predict(self, frame: ArrayRgbFrame) -> Result | None:
316+
corners_dirty, ids_dirty, _rejected = self.detector.detectMarkers(frame)
317+
318+
corners = typing.cast(list[Float[Array, 'n 4']], corners_dirty)
319+
ids = typing.cast(Int[Array, ' n'], ids_dirty)
320+
321+
if len(corners) == 0 or len(ids) == 0:
322+
return None
323+
324+
intrinsics = self.intrinsics
325+
distortion = self.distortion
326+
327+
marker_rigid_coordinates = self.model.coordinates
328+
329+
results = [
330+
opencv.solvePnP(
331+
marker_rigid_coordinates,
332+
camera_coordinates,
333+
intrinsics,
334+
distortion,
335+
useExtrinsicGuess=True,
336+
flags=opencv.SOLVEPNP_IPPE_SQUARE,
337+
)
338+
for camera_coordinates in corners
339+
]
340+
341+
transformations = [
342+
Transformation(opencv.Rodrigues(rotation)[0], numpy.squeeze(translation)) # type: ignore # OpenCV types... ;v
343+
for success, rotation, translation in results
344+
if success
345+
]
346+
347+
return Result(numpy.stack(corners), ids.reshape(-1), transformations)

0 commit comments

Comments
 (0)