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 pythonManual 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 plotsThe 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
| Python | Julia | What it does |
|---|---|---|
blob_board(paper, dpi, **kw) | blob_board | a printable board (PDF/X, TIFF or PNG + JSON) |
blob_pattern(width, height, **kw) | blob_pattern | a blob pattern in pixels |
load_board(json_path) | load_board / BlobBoards.load_bundle | a stored board, under its stored identity |
render_board(json_path, dir=…, **kw) | BlobBoards.render_board | re-render a stored board without re-placing it |
blobs(json_path, …), saddle_points(json_path, …) | BlobBoards.blobs, BlobBoards.saddle_points | coordinates in a frame (pattern, board, canvas, world) and unit |
find_boards(board_paths, model_path, img, …) | find_boards | boards in an image: homography or pose per board |
track_boards(board_paths, model_path, frames, …) | track_boards | boards through a video or a frame sequence |
device(requested="auto") | default_device | where detection runs: "cuda" or "cpu" |
example_data(name) | BlobBoards.example_data | the bundled example boards, still and clip |
descriptor_network_path(config=None) | descriptor_network_path | the descriptor network a run configuration names |
PRINT_SIZES | BlobBoards.PRINT_SIZES | paper names → (width, height) in mm |
short_side, ShortSideFraction | short_side, BlobBoards.ShortSideFraction | a blob size as a fraction of the pattern's short side |
BoardResult, BoardPreamble, BoardConfig, PatternResult, PatternConfig | BoardBundle, BoardPreamble, BoardConfig, …, PatternConfig | results 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:
uidmodel: a 3×3 homography from the board's world plane (mm) to pixels, or{"R", "t"}, the board → camera pose, whencamera_jsonis givenquality,n_inliers,n_csponds,weights(a NumPy array)deviceandexecution_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"raisesRuntimeErrorwhen 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 machineThe 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=yesandPYTHON_JULIACALL_THREADS=autobefore juliacall is first imported. If Python handles Julia's signals, GPU work crashes the process. Importblobboardsbefore anything else that importsjuliacall.
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 samplingimage_origin | First pixel centre | Use |
|---|---|---|
"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_frame | Origin | Description |
|---|---|---|
"pattern" | Pattern top-left | Excludes the frame (default) |
"board" | Board top-left | Includes the frame |
"canvas" | Page top-left | Includes the margins |
"world" | Board centre, y up, mm | What 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 sideBlob 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.