Skip to content

Commit 508b42f

Browse files
Moved ArUco and chessboard detectors to marker-detection
commit-id:cdfa0908
1 parent e8daddc commit 508b42f

File tree

4 files changed

+1011
-4
lines changed

4 files changed

+1011
-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,351 @@
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 icecream import ic
11+
from jaxtyping import Float, Int
12+
from video_io import annotation
13+
from video_io.frame import ArrayRgbFrame
14+
15+
# type Array = NDArray[numpy.float32]
16+
17+
type IntrinsicsMatrix = Float[numpy.ndarray, '3 3']
18+
type DistortionCoefficients = Float[numpy.ndarray, '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[numpy.ndarray, '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 VisualizationContext(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[numpy.ndarray, '3 3']
119+
translation: Float[numpy.ndarray, '3']
120+
121+
def euler_angles(self) -> Float[numpy.ndarray, '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[numpy.ndarray, 'n 4']
133+
ids: Int[numpy.ndarray, ' n']
134+
transformations: list[Transformation]
135+
136+
def draw(
137+
self,
138+
frame: ArrayRgbFrame,
139+
context: VisualizationContext,
140+
) -> ArrayRgbFrame:
141+
if len(self.ids) == 0:
142+
return frame
143+
144+
draw_boxes = context['marker_draw_masks']
145+
draw_ids = context['marker_draw_ids']
146+
draw_axes = context['marker_draw_axes']
147+
draw_angles = context['marker_draw_angles']
148+
149+
# TODO: Get rid of nested parts. It's better to have the same loop a few times.
150+
151+
if draw_boxes:
152+
r, g, b, _ = context['marker_mask_color']
153+
color = (int(r), int(g), int(b))
154+
155+
for marker_corners_raw in self.corners:
156+
marker_corners = marker_corners_raw.reshape(-1, 2).astype(numpy.int32)
157+
158+
annotation.draw_filled_polygon_with_opacity(
159+
frame,
160+
marker_corners,
161+
color=color,
162+
opacity=0.5,
163+
)
164+
165+
corner_pixels: list[tuple[int, int]] = list(map(tuple, marker_corners))
166+
upper_left, upper_right, lower_right, lower_left = corner_pixels
167+
168+
annotation.draw_point_with_description(
169+
frame,
170+
upper_left,
171+
'upper_left',
172+
point_radius=1,
173+
font_scale=0.4,
174+
text_location='above',
175+
)
176+
annotation.draw_point_with_description(
177+
frame,
178+
upper_right,
179+
'upper_right',
180+
point_radius=1,
181+
font_scale=0.4,
182+
text_location='above',
183+
)
184+
annotation.draw_point_with_description(
185+
frame,
186+
lower_right,
187+
'lower_right',
188+
point_radius=1,
189+
font_scale=0.4,
190+
)
191+
annotation.draw_point_with_description(
192+
frame,
193+
lower_left,
194+
'lower_left',
195+
point_radius=1,
196+
font_scale=0.4,
197+
)
198+
199+
if draw_axes:
200+
length = context['marker_axis_length']
201+
thickness = context['marker_axis_thickness']
202+
203+
x_color = (255, 0, 0)
204+
y_color = (0, 255, 0)
205+
z_color = (0, 0, 255)
206+
207+
intrinsics = context['intrinsics']
208+
209+
basis_points = numpy.array(
210+
[
211+
[0.0, 0.0, 0.0],
212+
[length, 0.0, 0.0],
213+
[0.0, length, 0.0],
214+
[0.0, 0.0, length],
215+
],
216+
dtype=numpy.float32,
217+
).T
218+
219+
for transformation in self.transformations:
220+
rotation = transformation.rotation
221+
translation = transformation.translation.reshape(-1, 1)
222+
223+
transformed_basis = rotation @ basis_points + translation
224+
transformed_basis /= transformed_basis[2]
225+
projected_basis = intrinsics @ transformed_basis
226+
227+
projected_basis_pixel_coordinates: list[tuple[int, int]] = (
228+
projected_basis.T[:, :2].astype(int).tolist()
229+
)
230+
o, x, y, z = projected_basis_pixel_coordinates
231+
232+
opencv.line(frame, o, x, x_color, thickness)
233+
opencv.line(frame, o, y, y_color, thickness)
234+
opencv.line(frame, o, z, z_color, thickness)
235+
236+
if draw_angles:
237+
angles: list[float] = (
238+
180.0 / numpy.pi * transformation.euler_angles()
239+
).tolist()
240+
241+
x_angle, y_angle, z_angle = angles
242+
243+
annotation.draw_point_with_description(
244+
frame,
245+
x,
246+
f'x: {x_angle:.2f}',
247+
font_scale=0.3,
248+
point_radius=1,
249+
point_color=x_color,
250+
)
251+
annotation.draw_point_with_description(
252+
frame,
253+
y,
254+
f'y: {y_angle:.2f}',
255+
font_scale=0.3,
256+
point_radius=1,
257+
point_color=y_color,
258+
)
259+
annotation.draw_point_with_description(
260+
frame,
261+
z,
262+
f'z: {z_angle:.2f}',
263+
font_scale=0.3,
264+
point_radius=1,
265+
point_color=z_color,
266+
)
267+
268+
if draw_ids:
269+
for id, marker_corners in zip(self.ids, self.corners):
270+
marker_corners = marker_corners.reshape(-1, 2)
271+
center: tuple[int, int] = (
272+
numpy.mean(marker_corners, axis=0).astype(int).tolist()
273+
)
274+
ic(center)
275+
276+
annotation.draw_text_within_box(
277+
frame,
278+
f'marker {id}',
279+
center,
280+
font_scale=0.3,
281+
)
282+
283+
return frame
284+
285+
286+
class Detector:
287+
dictionary: Dictionary
288+
raw_dictionary: opencv_aruco.Dictionary
289+
detector: opencv_aruco.ArucoDetector
290+
291+
marker_size: float
292+
marker_local_coordinates: Float[numpy.ndarray, '3 4']
293+
294+
intrinsics: IntrinsicsMatrix
295+
distortion: DistortionCoefficients
296+
297+
def __init__(
298+
self,
299+
model: RigidModel,
300+
dictionary: Dictionary,
301+
detector_parameters: opencv_aruco.DetectorParameters,
302+
intrinsics: IntrinsicsMatrix,
303+
distortion: DistortionCoefficients,
304+
) -> None:
305+
self.dictionary = dictionary
306+
307+
raw_dictionary = opencv_aruco.getPredefinedDictionary(dictionary.value)
308+
self.raw_dictionary = raw_dictionary
309+
310+
self.detector = opencv_aruco.ArucoDetector(
311+
raw_dictionary,
312+
detector_parameters,
313+
)
314+
315+
self.model = model
316+
self.intrinsics = intrinsics
317+
self.distortion = distortion
318+
319+
def predict(self, frame: ArrayRgbFrame) -> Result | None:
320+
corners_dirty, ids_dirty, _rejected = self.detector.detectMarkers(frame)
321+
322+
corners = typing.cast(list[Float[numpy.ndarray, 'n 4']], corners_dirty)
323+
ids = typing.cast(Int[numpy.ndarray, ' n'], ids_dirty)
324+
325+
if len(corners) == 0 or len(ids) == 0:
326+
return None
327+
328+
intrinsics = self.intrinsics
329+
distortion = self.distortion
330+
331+
marker_rigid_coordinates = self.model.coordinates
332+
333+
results = [
334+
opencv.solvePnP(
335+
marker_rigid_coordinates,
336+
camera_coordinates,
337+
intrinsics,
338+
distortion,
339+
useExtrinsicGuess=True,
340+
flags=opencv.SOLVEPNP_IPPE_SQUARE,
341+
)
342+
for camera_coordinates in corners
343+
]
344+
345+
transformations = [
346+
Transformation(opencv.Rodrigues(rotation)[0], numpy.squeeze(translation))
347+
for success, rotation, translation in results
348+
if success
349+
]
350+
351+
return Result(numpy.stack(corners), ids.reshape(-1), transformations)

0 commit comments

Comments
 (0)