Python Interface

The blobboards Python package wraps this Julia package through juliacall. It has one function per Julia entry point, with the same names, keywords and semantics, and with NumPy arrays and dataclasses in place of Julia types. Each Python function's docstring (help(blobboards.blob_board)) is its full reference: parameters, defaults, units, return fields, errors and an example. This page covers installation and tasks.

Installation

With pixi (recommended)

pixi install            # Julia tools, Ghostscript, Python, the blobboards package
pixi run -e py python

Manual setup

Requirements:

  • Python 3.8+
  • Julia 1.12+ (juliacall uses it)
  • Ghostscript ≥ 10.07 to write the PDF/X-4 print master. pixi provides it; distribution packages are often older (Ubuntu 24.04 ships 10.02, which is refused with an error).
cd python
pip install -e .        # juliacall, numpy; `pip install pint matplotlib` for units and plots

The package runs Julia in the checkout's authoring/ environment. That environment provides CairoMakie, Libtiff_jll and PythonCall, plus CUDA and cuDNN, which are loaded only when detection first runs. The package activates it on first use; the first call can take minutes on a fresh machine.

Functions

PythonJuliaWhat it does
blob_board(paper, dpi, **kw)blob_boarda printable board (PDF/X, TIFF or PNG + JSON)
blob_pattern(width, height, **kw)blob_patterna blob pattern in pixels
load_board(json_path)load_board / BlobBoards.load_bundlea stored board, under its stored identity
render_board(json_path, dir=…, **kw)BlobBoards.render_boardre-render a stored board without re-placing it
blobs(json_path, …), saddle_points(json_path, …)BlobBoards.blobs, BlobBoards.saddle_pointscoordinates in a frame (pattern, board, canvas, world) and unit
find_boards(board_paths, model_path, img, …)find_boardsboards in an image: homography or pose per board
track_boards(board_paths, model_path, frames, …)track_boardsboards through a video or a frame sequence
device(requested="auto")default_devicewhere detection runs: "cuda" or "cpu"
example_data(name)BlobBoards.example_datathe bundled example boards, still and clip
descriptor_network_path(config=None)descriptor_network_paththe descriptor network a run configuration names
PRINT_SIZESBlobBoards.PRINT_SIZESpaper names → (width, height) in mm
short_side, ShortSideFractionshort_side, BlobBoards.ShortSideFractiona blob size as a fraction of the pattern's short side
BoardResult, BoardPreamble, BoardConfig, PatternResult, PatternConfigBoardBundle, BoardPreamble, BoardConfig, …, PatternConfigresults and configurations

Two Julia options have no Python form: the radially distorted geometry (RadialHomographyGeometry) and the leading pixel-type argument of blob_board / blob_pattern (patterns and boards are 8-bit grey from Python).

Quick start

from blobboards import blob_pattern, blob_board

pattern = blob_pattern(800, 600, blob_sizes=(12, None), seed=0x1234)   # discs from 12 px across
print(pattern.pattern.shape, pattern.blobs.shape)                     # (600, 800) (N, 3): x, y, σ

board = blob_board("A4", 150, blob_sizes=(4.0, None), seed=0x1234,     # discs from 4 mm across
                   frame_style="saddle", dir="./output")
print(board.uid, board.blobs.shape, board.saddle_points.shape)

blob_sizes are the smallest and largest disc diameters: mm for boards (bare numbers; Pint quantities in any length unit work too), px for patterns. Board-generation keywords mirror Julia's (alpha is Julia's α). A keyword Julia removed (blob_measure, ruler_width, raster_aligned on boards) is not a parameter, so giving it raises TypeError.

Finding boards

find_boards(board_paths, model_path, img, *, gallery_path, camera_json=None, device="auto") returns one dict per verified board:

  • uid
  • model: a 3×3 homography from the board's world plane (mm) to pixels, or {"R", "t"}, the board → camera pose, when camera_json is given
  • quality, n_inliers, n_csponds, weights (a NumPy array)
  • device and execution_provider: where it ran, "cuda" or "cpu"

The network, the gallery features (cached on disk at gallery_path) and the bundles are built once per process and reused.

GPU or CPU

device="auto" (the default) runs on the GPU when a usable NVIDIA GPU is present. Otherwise it runs on the CPU and logs a warning on the blobboards logger.

  • device="cuda" raises RuntimeError when there is no usable GPU.
  • device="cpu" always uses the CPU.

The detection stages and the descriptor network (ONNX Runtime's CUDA or CPU provider) run on the same device. CUDA is loaded only when detection first asks for a device, so blob_board never pays for it. To see where detection will run without running it:

import blobboards
blobboards.device()          # "cuda" or "cpu", as "auto" resolves on this machine

The GPU requirements are those of the Julia side (see Detection and Pose):

  • an NVIDIA GPU of compute capability 7.5 or newer
  • a driver for CUDA 12 or newer
  • Linux or Windows

CUDA, cuDNN and the CUDA 12.9 runtime come as Julia artifacts with the package's Julia environment, so no CUDA toolkit is needed. Choose a GPU with CUDA_VISIBLE_DEVICES before starting Python; CUDA_VISIBLE_DEVICES="" forces the CPU.

On a B200 with the example still (12 MP, 50 boards), a warm call took 1.8–1.9 s per image on the GPU and 16–17 s on the CPU. The first call also builds the gallery: about 2 minutes, or seconds once its caches are warm.

import os
import numpy as np
from blobboards import find_boards, example_data, descriptor_network_path

boards = example_data("boards")
board_paths = sorted(os.path.join(boards, f) for f in os.listdir(boards) if f.endswith(".json"))
model_path = descriptor_network_path()
# img: a grey image, (H, W) uint8 or float in [0, 1]
found = find_boards(board_paths, model_path, img, gallery_path="gallery.jld2")
for b in found:
    print(b["uid"], b["n_inliers"], b["device"], b["execution_provider"])

Checking the result visually

Each homography maps its board's world plane (mm) to image pixels, so the board's blobs can be drawn straight onto the image with matplotlib:

import matplotlib.pyplot as plt
from blobboards import blobs

uid_to_json = {os.path.basename(p)[11:14]: p for p in board_paths}   # blob_board_<uid>_….json
fig, ax = plt.subplots(figsize=(14, 9.5))
ax.imshow(img, cmap="gray")
for b in found:
    X = blobs(uid_to_json[b["uid"]], reference_frame="world", units="mm")   # (N, 3): x, y, σ
    p = np.c_[X[:, :2], np.ones(len(X))] @ b["model"].T                    # homogeneous pixels
    ax.plot(p[:, 0] / p[:, 2], p[:, 1] / p[:, 2], ".", ms=2)
    ax.annotate(b["uid"], p[0, :2] / p[0, 2], color="yellow")
fig.savefig("found_boards.png", dpi=100)

Video

track_boards takes a video path, or any sequence of grey frames. It returns, per board uid, one dict per frame the board was found in: frame, model, quality, n_inliers and source ("track", "init" or "detect").

from blobboards import track_boards

frames = [img, img]                       # or a video path, e.g. in example_data("video")
tracks = track_boards(board_paths, model_path, frames, gallery_path="gallery.jld2")
for uid, dets in tracks.items():
    print(uid, len(dets), "frames")

Troubleshooting

  • could not load library ".../lib/julia/sys.so" at import: juliacall was given juliaup's launcher instead of a Julia binary. Point it at the real one: export PYTHON_JULIAPKG_EXE=$(julia -e 'print(joinpath(Sys.BINDIR, "julia"))').
  • Unless you set them yourself, the package sets PYTHON_JULIACALL_HANDLE_SIGNALS=yes and PYTHON_JULIACALL_THREADS=auto before juliacall is first imported. If Python handles Julia's signals, GPU work crashes the process. Import blobboards before anything else that imports juliacall.

Stored boards

A board's JSON sidecar is its definition. load_board reads it, in every schema BlobBoards has written, under the stored identity. render_board draws it again from its recorded blobs:

import tempfile
from blobboards import load_board, render_board

board = load_board(board_paths[0])
print(board.uid, board.preamble.board_config.print_density_dpi)
render_board(board_paths[0], dir=tempfile.mkdtemp(), format="png")

Coordinates

from blobboards import blob_board, blobs, saddle_points

out = tempfile.mkdtemp()
blob_board("A5", 100, frame_style="saddle", seed=1, dir=out)
json_path = [os.path.join(out, f) for f in os.listdir(out) if f.endswith(".json")][0]

b = blobs(json_path)                                          # px, pattern frame, COLMAP convention
b_mm = blobs(json_path, units="mm", reference_frame="board")  # mm, board frame
w = blobs(json_path, units="mm", reference_frame="world")     # the board plane: centred, y up
sp = saddle_points(json_path)                                 # (M, 2), saddle frames only
b_cv = blobs(json_path, image_origin="opencv")                # for NumPy/OpenCV sampling
image_originFirst pixel centreUse
"colmap"(0.5, 0.5)COLMAP/SfM, geometry (the default for coordinates)
"opencv"(0, 0)NumPy/OpenCV image sampling (the default for detection results)
"julia"(1, 1)Julia/MATLAB
reference_frameOriginDescription
"pattern"Pattern top-leftExcludes the frame (default)
"board"Board top-leftIncludes the frame
"canvas"Page top-leftIncludes the margins
"world"Board centre, y up, mmWhat a find_boards homography maps to pixels

Pint units

Lengths can be Pint quantities in any unit; bare numbers are mm for boards and px for patterns:

import pint
from blobboards import short_side
ureg = pint.UnitRegistry()

board = blob_board("A4", 300, blob_sizes=(6.0 * ureg.mm, None),
                   frame_width=0.5 * ureg.inch, frame_padding=2 * ureg.mm)
board = blob_board("A4", 300, blob_sizes=(4.0, 0.2 * short_side))   # largest disc 20% of the short side

Blob detection only

detect_blobs exposes the detector alone (advanced API; see its docstring):

from blobboards import detect_blobs

result = detect_blobs(np.random.rand(240, 320))
print(len(result), result.to_array().shape)    # (N, 3): x, y, σ

Training tracks

.tracks files are written from Julia by build_tracks_for_sequences; there is no Python builder. Reading them needs no Julia (h5py only): load_tracks(path) returns a TrackData, and plot_tracks(data, max_tracks=10) plots the longest tracks.