2. Detection¶
Input images¶
detect() takes a NumPy uint8 array, C-contiguous:
(H, W)or(H, W, 1)grayscale — the fastest input;(H, W, 3)BGR (OpenCV's order) — converted to gray internally.
The array is wrapped without copying. Wrong dtype or a non-contiguous view
(a slice with a step, a transposed array, ...) raises TypeError instead of
being copied behind your back — use np.ascontiguousarray once if needed.
import numpy as np
import nanofractal as nf
det = nf.FractalDetector("FRACTAL_5L_6")
page = np.full((900, 900), 255, np.uint8)
page[100:800, 100:800] = nf.generate_fractal("FRACTAL_5L_6", 700)
res = det.detect(page) # grayscale
bgr = np.ascontiguousarray(np.dstack([page] * 3))
assert det.detect(bgr).ids.tolist() == res.ids.tolist()
view = page[:, ::2] # strided view
try:
det.detect(view)
except TypeError:
res2 = det.detect(np.ascontiguousarray(view))
The result¶
DetectionResult holds one entry per visible level of the marker:
res = det.detect(page)
print(len(res), bool(res)) # number of levels found; False if none
print(res.ids) # int32 (N,) level ids, 0 = external
print(res.corners.shape) # float32 (N,4,2) sub-pixel corners, (x, y) pixels
for level, corners in res:
print(level, corners[0]) # first corner of each level
Corners are listed in a fixed order relative to the marker, not the image:
the marker's top-left, top-right, bottom-right, bottom-left (as printed,
looking at it). That order is stable under any in-plane rotation, so
corners[0] always identifies the same physical corner. Pixel coordinates use
OpenCV's convention: the centre of pixel (i, j) is at x = j, y = i.
res.points_2d / res.points_3d stay None unless you ask for inner corners
(see Pose).
How well each level was read — to weigh a detection rather than trust a yes/no:
print(res.cell_px) # float32 (N,) pixels per cell (~3 is the limit)
print(res.contrast) # float32 (N,) bright minus dark cells, grey levels
print(res.bit_errors) # int32 (N,) cells read wrong: 0 unless read through `prior`
Two copies of the same marker¶
The detector reports each level once. If the same marker is visible twice
(two printed copies, a reflection, a screen showing the marker), only the
largest copy of each level is returned and res.multiple_instances is
True — the levels may then come from different copies, so treat the pose as
unreliable:
twice = np.full((900, 1800), 255, np.uint8)
twice[100:800, 100:800] = twice[100:800, 1000:1700] = nf.generate_fractal("FRACTAL_5L_6", 700)
print(det.detect(twice).multiple_instances, det.detect(page).multiple_instances) # True False
With FractalTracker or an ROI only the copies inside the searched region
are seen.
To use every copy, ask for all of them: all_instances=True returns the
levels of all copies, res.instances says which copy each level belongs to
(0 = the largest) and res.instance(k) is copy k alone — pass it to
estimate_pose for that copy's pose. Levels are grouped by geometry (the
largest level of a copy predicts where its smaller levels are), not by
distance.
both = det.detect(twice, all_instances=True)
print(both.n_instances, both.instances) # 2 [0 0 0 0 1 1 1 1]
first = both.instance(0) # a DetectionResult of copy 0
print(first.ids, first.corners[0, 0])
Levels that do not fit¶
All levels of one marker lie on one plane, so the corners of a larger level
predict where each smaller level must be. A level found far from that place
(a valid id decoded somewhere else: a second copy, a printout of one level, a
false read) is dropped and its id listed in res.inconsistent_ids; the
remaining levels agree with each other. The check only catches gross
misplacement (more than half the level's side): lens distortion and perspective
never trigger it.
print(det.detect(page).inconsistent_ids) # [] -- every level in its place
Regions of interest¶
When you know where the marker can be, restrict the search. The ROI is a zero-copy view; corners come back in full-image coordinates:
res = det.detect(page, roi=(50, 50, 800, 800)) # (x, y, width, height)
print(res.corners[0][0]) # full-image pixels
For video, FractalTracker does this automatically from the previous frame
(Video).
Many images at once¶
frames = [page] * 8
results = det.detect_batch(frames) # all cores, one frame per core
results = det.detect_batch(frames, num_threads=4) # explicit
print([len(r) for r in results])
detect_batch releases the GIL and runs one frame per worker thread; every
frame gets exactly the result detect() would give. Use it for offline
processing; for a live stream prefer detect() on each frame, which already
uses all cores within the frame.
Refining corners yourself¶
detect() already returns sub-pixel corners. refine_corners re-runs the
refinement with a different window, e.g. on a sharper frame of the same
scene:
refined = nf.refine_corners(page, res.corners, win_size=5)
print(refined.shape, refined.dtype) # (N, 4, 2) float32
Threads and the GIL¶
- The GIL is released during detection: other Python threads keep running.
- A
FractalDetectorreuses internal buffers, so one detector per thread if you call it from several threads at once (creating one costs ~10 µs). nf.set_num_threads(n)sets how many cores onedetect()may use (default: all). See Performance.