-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy patharuco.py
566 lines (438 loc) · 18.7 KB
/
aruco.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
# pylint: disable=C0103, too-few-public-methods, locally-disabled
"""Work with aruco markers
Use the Detected class for detecting markers in an image.
The Marker class represent individual markers detected
from Detected.
"""
import os.path as _path
from enum import Enum as _Enum
import math as _math
import numpy as _np
import cv2 as _cv2
from sympy.geometry import centroid as _centroid
# We use sympy because Point2D supports multiplication by floats,
# plus lots of other good stuff
from sympy.geometry import Point2D as _Point2D
import funclite.iolib as _iolib
import funclite.baselib as _baselib
import opencvlib.geom as _geom
from opencvlib import getimg as _getimg # noqa
from opencvlib.common import draw_str as _draw_str
from opencvlib.common import draw_points as _draw_points
import opencvlib.color as _color
import opencvlib.view as _view
from opencvlib.geom import order_points as _order_points
_dictionary = _cv2.aruco.getPredefinedDictionary(_cv2.aruco.DICT_ARUCO_ORIGINAL)
MARKERS = {0: 'DICT_4X4_50', 1: 'DICT_4X4_100', 2: 'DICT_4X4_250',
3: 'DICT_4X4_1000', 4: 'DICT_5X5_50', 5: 'DICT_5X5_100', 6: 'DICT_5X5_250', 7: 'DICT_5X5_1000',
8: 'DICT_6X6_50', 9: 'DICT_6X6_100', 10: 'DICT_6X6_250', 11: 'DICT_6X6_1000', 12: 'DICT_7X7_50',
13: 'DICT_7X7_100', 14: 'DICT_7X7_250', 15: 'DICT_7X7_1000', 16: 'DICT_ARUCO_ORIGINAL'}
class Marker:
"""
Represents a single detected marker
Can be created with points in any order.
Set the enum Marker.mode to Marker.Mode.mm to get points in real world units.
Methods:
diagonal_length_mm: the geommetrically calculated diagonal length of the marker in mm (e.g. 1.41421 ... for a 1cm square)
side_length_mm: the side length in mm
vertices_...: sympy Point2D representations of the corners
mode: Toggle between pixel mode and mm mode. Affect Marker.points
Raises:
ErrMarkerExpectes4Points: If instance in initialised with an array of pts of len != 4
Examples:
Instantiate a marker print points in real world mm units
>>> M = Marker([[0,0], [10,10], [10,0], [0,10]], side_length_mm=20)
>>> M.mode = Marker.Mode.mm
>>> M.points
[[0,0], [20,20], [20,0], [0,20]]
"""
class Mode(_Enum):
px = 2
mm = 1
def __init__(self, pts: (_np.ndarray, list, tuple), markerid, side_length_mm: float):
if len(pts) != 4:
raise ErrMarkerExpectes4Points('Marker requires 4 points to create instance, got %s' % str(pts))
self.side_length_mm = side_length_mm
self.diagonal_length_mm = diagonal(side_length_mm)
self.mode = Marker.Mode.px
p = _order_points(pts)
self._vertices_topleft = _Point2D(p[0][0], p[0][1])
self._vertices_topright = _Point2D(p[1][0], p[1][1])
self._vertices_bottomright = _Point2D(p[2][0], p[2][1])
self._vertices_bottomleft = _Point2D(p[3][0], p[3][1])
self.markerid = markerid
def __repr__(self):
"""pretty print"""
info = ['Marker "%s"' % self.markerid, str(tuple(self._vertices_topleft * self._factor) if isinstance(self._vertices_topleft, _Point2D) else ''),
str(tuple(self._vertices_topright * self._factor) if isinstance(self._vertices_topright, _Point2D) else ''),
str(tuple(self._vertices_bottomright * self._factor) if isinstance(self._vertices_bottomright, _Point2D) else ''),
str(tuple(self._vertices_bottomleft * self._factor) if isinstance(self._vertices_bottomleft, _Point2D) else '')]
return ' '.join(info)
@property
def vertices_topleft(self):
"""
Coordinates are give x,y BUT uses opencv origin at top left.
Obeys Mode.
Returns:
sympy.Point2D: top left vertext
"""
return self._vertices_topleft * self._factor
@property
def vertices_topright(self):
"""
Coordinates are give x,y BUT uses opencv origin at top left.
Obeys mode
Returns:
sympy.Point2D: top right vertext
"""
return self._vertices_topright * self._factor
@property
def vertices_bottomright(self):
"""
Coordinates are give x,y BUT uses opencv origin at top left.
Obeys mode
Returns:
sympy.Point2D: Bottom right vertext
"""
return self._vertices_bottomright * self._factor
@property
def vertices_bottomleft(self):
"""
Coordinates are give x,y BUT uses opencv origin at top left.
Obeys mode
Returns:
sympy.Point2D: Bottom left vertext
"""
return self._vertices_bottomleft * self._factor
@property
def diagonal_px(self):
"""mean diagonal length"""
if isinstance(self._vertices_topleft, _Point2D) and isinstance(self._vertices_bottomright, _Point2D):
x = abs(self._vertices_topleft.distance(self._vertices_bottomright).evalf())
y = abs(self._vertices_topleft.distance(self._vertices_bottomright).evalf())
return (x + y) / 2
return None
@property
def side_px(self):
"""mean side length in px"""
if isinstance(self._vertices_topleft, _Point2D) and isinstance(self._vertices_bottomright, _Point2D):
a = abs(self._vertices_topleft.distance(self._vertices_topright).evalf())
b = abs(self._vertices_topleft.distance(self._vertices_bottomleft).evalf())
c = abs(self._vertices_bottomright.distance(self._vertices_topright).evalf())
d = abs(self._vertices_bottomright.distance(self._vertices_bottomleft).evalf())
return (a + b + c + d) / 4
return None
@property
def angle_horizontal(self) -> float:
"""
Mean angle in degrees of two vertical lines.
Invariant to Marker.mode
Returns:
float: Mean angle in degrees
"""
a1 = _geom.angle_between_pts(self._vertices_topleft, self._vertices_topright)
a2 = _geom.angle_between_pts(self._vertices_bottomleft, self._vertices_bottomright)
return (a1 + a2) / 2
@property
def angle_vertical(self) -> float:
"""
Mean angle in degrees of two horizontal lines.
Invariant to Marker.mode
Returns:
float: Mean angle in degrees
"""
a1 = _geom.angle_between_pts(self._vertices_topleft, self._vertices_bottomleft)
a2 = _geom.angle_between_pts(self._vertices_topright, self._vertices_bottomright)
return (a1 + a2) / 2
@property
def side_difference_vertical(self) -> float:
"""
Vertical length diff between two vertical sides.
Respects Marker.mode
Returns:
float: Vertical difference between left and right verticals, respects Marker.mode
"""
return ((self._vertices_bottomleft[1] - self._vertices_topleft[1]) - (self._vertices_bottomright[1] - self._vertices_topright[1])) * self._factor
@property
def side_difference_horizontal(self) -> float:
"""
Horizontal length diff between two vertical sides.
Respects Marker.mode.
Returns:
float: Vertical difference between left and right verticals, respects Marker.mode
"""
return ((self._vertices_topright[0] - self._vertices_topleft[0]) - (self._vertices_bottomright[0] - self._vertices_bottomleft[0])) * self._factor
def px_length_mm(self, use_side=False) -> float:
"""
Estimated pixel length in mm, i.e. the length of a pixel in mm.
Args:
use_side (bool): use the mean side pixel length rather than the mean diagonal length
Returns: float: Size of marker in mm
"""
if use_side:
return self.side_length_mm / self.side_px
return self.diagonal_length_mm / self.diagonal_px
@property
def points(self) -> list:
"""
Get as list of xy points.
Coordinates are given x,y BUT uses opencv origin at top left.
Order clockwise from top left as first point.
Respects Marker.mode.
Returns:
list: list of points
Examples:
>>> Marker.points
[[0,10],[10,10],[10,0],[0,0]]
"""
return [list(self.vertices_topleft), list(self.vertices_topright), list(self.vertices_bottomright), list(self.vertices_bottomleft)]
@property
def points_flattened(self) -> list:
"""
Get as a list of depth 0 x and y values.
i.e. ['x1', 'y1', 'x2', 'y2', 'x3', 'y3', 'x4', 'y4']
Useful for writing out to pandas dataframes etc.
Respects Marker.mode
Returns:
list: list of fully flattened points
Notes:
Cooerces into into ints if mode == Mode.px, else coerces to float.
Examples:
>>> Marker.points_flattened # noqa
[0,10,10,10,10,0,0,0]
"""
coerce_type = int if self.mode == Marker.Mode.px else float
pts = _baselib.list_flatten(self.points, coerce_type=coerce_type)
return pts
@property
def centroid(self) -> list:
"""centroid of points, pixel based
Respects Marker.mode.
Returns:
list: centroid point as list, [12.3, 10]
"""
return list(_centroid(self._vertices_bottomleft * self._factor,
self._vertices_bottomright * self._factor,
self._vertices_topleft * self._factor,
self._vertices_topright * self._factor).evalf())
@property
def _factor(self) -> float:
"""Set factor for returning results as mm or px
Raises:
ErrInvalidMarkerMode: If self.Mode is no a member of Marker.Mode
Returns:
float: Factor to convert to mm, else 1 for pixels (i.e. as-is)
"""
if self.mode == Marker.Mode.px:
return 1
elif self.mode == Marker.Mode.mm:
return self.px_length_mm()
else:
raise ErrInvalidMarkerMode('Marker mode was invald. Ensure you use a member of class Marker.Mode')
class Detected:
"""Detect aruco markers in an image.
Initalise an instance with an image and then detect
markers by calling detect on the instance.
Members:
image: The original image as an ndarray or a path
image_with_detections: The image with detections drawn on it
Markers: A list containing Marker instances. A Marker instance is a detected marker.
Examples:
>>> D = Detected('c:/myimg.jpg')
"""
def __init__(self, img, side_length_mm: float, detect=True):
self.Markers = []
self.image = _getimg(img)
self.image_with_detections = _np.copy(self.image)
self.side_length_mm = side_length_mm
if detect:
self.detect()
def export(self, fld: str, override_name: str = None) -> str:
"""
Export image to fld, with detections (if made)
Creates a random name for the image
Args:
fld (str): folder to export to
override_name (str, None): Explicitly pass the file to save the image to
Returns: str: File name
"""
if override_name:
out = _path.normpath(override_name)
else:
fld = _path.normpath(fld)
_iolib.create_folder(fld)
tmp = _iolib.get_temp_fname(suffix='.jpg', name_only=True)
out = _path.normpath(_path.join(fld, tmp))
_cv2.imwrite(out, self.image_with_detections)
return out
def detect(self, expected=()):
"""
Detect markers, returning those detected
as a list of Marker class instances
Args:
expected (list, tuple): Marker ids, numbers
Returns:
list of Marker class instances, represented detected markers
"""
self.Markers = []
res = _cv2.aruco.detectMarkers(self.image, _dictionary)
# res[0]: List of ndarrays of detected corners [][0]=topleft [1]=topright [2]=bottomright [3]=bottomleft. each ndarray is shape 1,4,2
# res[1]: List containing an ndarray of detected MarkerIDs, eg ([[12, 10, 256]]). Shape n, 1
# res[2]: Rejected Candidates, list of ndarrays, each ndarray is shape 1,4,2
if res[0]:
# print(res[0],res[1],len(res[2]))
P = _np.array(res[0]).squeeze().astype('int32')
for ind, markerid in enumerate(res[1]):
markerid = markerid[0]
if expected and markerid not in expected:
continue
if len(P.shape) == 2:
pts = P
else:
pts = P[ind]
M = Marker([pts[0], pts[1], pts[2], pts[3]], markerid, side_length_mm=self.side_length_mm)
self.Markers.append(M)
# px per mm
s = '{0} mm. Px:{1:.2f} mm'.format(int(M.side_length_mm), M.px_length_mm())
_draw_str(self.image_with_detections, pts[0][0], pts[0][1], s, color=(0, 255, 0), scale=0.6)
# The markerid, centre of marker
_draw_str(self.image_with_detections, M.centroid[0], M.centroid[1], str(markerid), color=(255, 255, 255), scale=0.7, box_background=(0, 0, 0), centre_box_at_xy=True)
# the points
self.image_with_detections = _draw_points(pts, self.image_with_detections, join=True, line_color=(0, 255, 0), show_labels=False)
# point coords in pixels at each vertext
for pt in M.points:
sPt = '(%s,%s)' % (pt[0], pt[1])
_draw_str(self.image_with_detections, pt[0] + 10, pt[1] + 12, sPt, color=(0, 255, 0), scale=0.5)
else:
self.Markers = []
self.image_with_detections = _np.copy(self.image)
return self.Markers
def show(self):
"""View detections. detect needs to have been first called.
Returns: numpy.ndarray
"""
_cv2.imshow('aruco detections', self.image_with_detections)
_cv2.waitKey(0)
_cv2.destroyAllWindows()
def write(self, fname: str):
"""write image to file system
Args:
fname (str): save name
Returns: None
"""
fname = _path.normpath(fname)
_cv2.imwrite(fname, self.image_with_detections)
def height(self, as_mm=False) -> (int, float):
"""
Image height. Uses mean pixel length calculated
across all detected markers for real-world units.
Args:
as_mm (bool): Return as mm or pixels
Returns: image height in pixels or mm
"""
return self.image.shape[0] if not as_mm else self.image.shape[0] * self.px_length_mm
def width(self, as_mm: bool = False) -> int:
"""
Image width. Uses mean pixel length calculated
across all detected markers for real-world units.
Args:
as_mm (bool): Return as mm or pixels
Returns: image height in pixels or mm
"""
return self.image.shape[1] if not as_mm else self.image.shape[1] * self.px_length_mm
def channels(self) -> int:
"""
Property channels
Returns: channel nr.
"""
return self.image.shape[2]
@property
def px_length_mm(self) -> (float, None):
"""
Mean pixel length in mm for all detected markers
in the image
Returns:
float: The mean pixel length
None: If no detected markers
"""
if self.Markers:
px = [mk.px_length_mm() for mk in self.Markers]
return sum(px) / len(px)
return None # noqa
def getmarker(markerid: int, sz_pixels: int = 500, border_sz: int = 0,
border_color: tuple = _color.CVColors.white,
borderBits=1, orientation_marker_sz=0,
orientation_marker_color=_color.CVColors.black, saveas='') -> _np.ndarray:
"""
Get marker image, i.e. the actual marker
for use in other applications, for printing
and saving as a jpg.
Args:
markerid (int):
Dictionary lookup for MARKERS
sz_pixels (int):
Size of the whole marker in pixels, including the border as defined by borderBits
border_sz (int):
Border added around the marker, usually white, in pixels.
This is added after the library has created the marker.
The final image side length will be sz_pixels + border_sz
border_color:
tuple (0,0,0)
borderBits:
the black padding around the marker, in image pixels, where an
image pixel is an single aruco marker building block, a
marker is a 5 x 5 block. This is added by the library and included in the
sz_pixels
orientation_marker_sz:
draw an orientation_point in top left of edge pixel size=orientation_marker_sz
orientation_marker_color:
marker color (3-tuple)
saveas:
filename to dump img to
Returns:
Image as an ndarray
"""
m = _cv2.aruco.drawMarker(_dictionary, id=markerid, sidePixels=sz_pixels, borderBits=borderBits)
m = _cv2.cvtColor(m, _cv2.COLOR_GRAY2BGR)
if border_sz > 0:
m = _view.pad_image(m, border_sz=border_sz, pad_color=border_color, pad_mode=_view.ePadColorMode.tuple_)
if orientation_marker_sz > 0:
m[0:orientation_marker_sz, 0:orientation_marker_sz, :] = orientation_marker_color
if saveas:
_cv2.imwrite(saveas, m)
return m
def diagonal(x: float) -> float:
"""Length of diagonal of square of side length of x
Args:
x (float): length of side of marker
Returns:
length of diagonal
"""
return _math.sqrt(x ** 2 + x ** 2)
def dump_markers(markerids: any, fld: str, **kwargs):
"""
Dump markers to folder fld
Args:
markerids (any): tuple, list of even a range function, which is the markerids to dump
fld:
**kwargs: keyword arguments, passed to aruco.getmarker
Returns: None
Notes:
markerids can accept a range function, e.g. range(10)
"""
fld = _path.normpath(fld)
_iolib.create_folder(fld)
for id_ in markerids:
saveas = '%s.jpg' % id_
saveas = _path.normpath(_path.join(fld, saveas))
_ = getmarker(id_, saveas=saveas, **kwargs)
# -----------------------
# Errors
# -----------------------
class ErrMarkerExpectes4Points(Exception):
"""Marker requires 4 points"""
class ErrInvalidMarkerMode(Exception):
"""Marker mode was invald. Ensure you use a member of class Marker.Mode"""
# -----------------------
if __name__ == '__main__':
D = Detected(r'C:\development\peat-auto-level\autolevel\bin\images\v2\rulers_15mm.jpg', 10.05)