Skip to content

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 FractalDetector reuses 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 one detect() may use (default: all). See Performance.