Public API
This page lists the public surface of BlobBoards.jl. Exported names are listed bare; public (supported, unexported) names are listed qualified and are reached as BlobBoards.name.
Pattern Generation
BlobBoards.blob_pattern — Function
blob_pattern(config::PatternConfig; dir=nothing)Generate a deterministic blob pattern from a PatternConfig. RNG is MersenneTwister(config.seed) for reproducible output.
Returns (pattern, blobs) where pattern is the rendered image and blobs is a vector of placed blobs. If dir is provided, saves the pattern as PNG with JSON sidecar.
blob_pattern(pixel_type::Type, pattern_resolution::Size2; kwargs...)Generate a blob pattern with specified pixel type and resolution.
Arguments
pixel_type: Output pixel type (e.g.,Gray{Float64},Gray{N0f16})pattern_resolution: Pattern size asSize2with logical units
Keyword Arguments
blob_sizes:(min, max), the smallest and largest disc DIAMETER (2·cutoff·σ) in logical units (12px; a bare number is px).maxmay be0.2short_side(a fraction of the short side) ornothing(default: 20% of the short side). The default min is2·cutoff·2px(8 px at the default cutoff), which is also the floor (σ ≥ 2 px). Sizes are stored as σ in px (min_scale,max_scale), both exact; the sizes between are spaced evenly in log at about1/scales_per_octaveoctave (seecalc_blob_scales).seed: Random seed for reproducible patternsscales_per_octave: Number of scales per octave (default 3)α: Scale sampling bias (default 1.2)cutoff: Gaussian truncation radius in units of σ (defaultDEFAULT_CUTOFF= 2; use it)max_trials: Maximum placement trials per blob (default 5000)raster_aligned: Align blob centers to raster grid (default true)polarity: Blob polarity,:dark(default) or:lightdir: Output directory for saving files (optional)
Returns (pattern, blobs, config).
blob_pattern(width, height; kwargs...)Generate a blob pattern with specified dimensions in pixels.
Arguments
width,height: Pattern dimensions in pixels (Integer) or logical units (px quantities)
Keyword Arguments
blob_sizes: disc diameter range (see thepixel_typeform)- Other arguments same as
blob_pattern(pixel_type, pattern_resolution; kwargs...), except the pixel type: it is positional, so supplyblob_pattern(T, Size2(…))(apixel_typekeyword is anArgumentError)
Returns (pattern, blobs, config).
Example
# Generate 800x600 pattern: discs from 12 px across to 20% of the short sidepattern, blobs, config = blob_pattern(800, 600; blob_sizes=(12px, 0.2short_side), cutoff=2.0, seed=0x1234)blob_pattern(pattern_resolution::Size2; kwargs...)Generate a blob pattern with specified resolution.
Arguments
pattern_resolution: Pattern size asSize2with pixel or logical units
Keyword Arguments
blob_sizes: disc diameter range (see thepixel_typeform)- Other arguments same as
blob_pattern(pixel_type, pattern_resolution; kwargs...), except the pixel type: it is positional,blob_pattern(T, pattern_resolution)(apixel_typekeyword is anArgumentError; the default isDEFAULT_PIXEL_TYPE)
Returns (pattern, blobs, config).
Example
using VisualGeometryCore: Size2, pxpattern, blobs, config = blob_pattern(Size2(width=800px, height=600px); blob_sizes=(12px, 120px), cutoff=2.0)BlobBoards.short_side — Constant
short_sideMarker for a size relative to the pattern's short side: 0.2short_side is 20% of it. Valid as the MAX of blob_sizes, a disc diameter like any other size (blob_sizes = (2mm, 0.2short_side) makes the largest disc's diameter 20% of the short side).
BlobBoards.ShortSideFraction — Type
ShortSideFraction(fraction)A size given as a fraction of the pattern's short side — what fraction * short_side builds. Resolved to px once the pattern size is known; never stored.
BlobBoards.calc_blob_scales — Function
calc_blob_scales(min_scale, max_scale; scales_per_octave = 3) -> VectorThe blob-scale ladder from min_scale to max_scale, both exact: k + 1 scales, k the whole number of 1/scales_per_octave-octave steps nearest the span, spaced evenly in log. When max_scale lies on the 2^(1/scales_per_octave) ladder the scales are exactly min_scale · 2^(i / scales_per_octave). Unitless px.
Board Generation
BlobBoards.blob_board — Function
blob_board(paper_size::String, print_density; kwargs...) -> BoardBundle
blob_board(width, height, print_density; kwargs...) -> BoardBundle
blob_board(canvas_size::Size2, print_density; kwargs...) -> BoardBundle
blob_board(pixel_type, paper_size | width, height | canvas_size, print_density; kwargs...)
blob_board(json_config::String; dir, format, pdf_standard, icc_profile, id_label) -> BoardBundle
blob_board(preamble::BoardPreamble, board_sizes::LogicalBoardSizes;
dir, format, pdf_standard, icc_profile, id_label) -> BoardBundleGenerate a printable calibration board (blob pattern, ruler frame, QR code and uid corners) at an exact physical size, and optionally write the print file and its JSON.
Sizes are physical (mm, inch) and print_density is a density (300dpi); the pattern is rasterized at that density, one pattern pixel per printer dot. Requires using CairoMakie (it loads BlobBoardsAuthoringExt); format = "tiff" also needs using Libtiff_jll.
Arguments
paper_size: a key ofBlobBoards.PRINT_SIZES—"A0"…"A5","ANSI A"…"ANSI E1","Letter","US Letter","Legal","Tabloid","Ledger"(portrait, except"Ledger"). Anything else is anArgumentError.width,heightorcanvas_size = Size2(width = …, height = …): the page (canvas) size, physical lengths.print_density: the print resolution, e.g.300dpi.pixel_type(optional, leading): the pattern's pixel type, e.g.Gray{N0f16}(defaultGray{N0f8}). It is positional only —pixel_type = …as a keyword is anArgumentError.json_config: the text of a board JSON sidecar; re-generates that board bit-exactly from its stored configuration (placement is re-run from the seed), under its stored identity (hashes.config: same uid and file name). The output keywords default to the JSON'srenderblock, how the sheet was printed (a file without one: PDF/X-4, no id label); a keyword overrides it. The sidecar is written in the current schema, and the QR encodes the same preamble.preamble,board_sizes: the low-level form the others reduce to — a completeBlobBoards.BoardPreambleand itsBlobBoards.LogicalBoardSizes.
Keywords
The paper-size, width/height and Size2 forms (and their pixel_type variants) take every keyword below; the JSON and preamble forms take the Output group only. A keyword outside this list is an ArgumentError, never silently ignored.
Output (validated before placement)
dir = nothing: output directory, created if missing. Writesblob_board_<uid>.<format>andblob_board_<uid>.json(the sidecar that reproduces the board), overwriting.nothingwrites no file and only returns the bundle; givingformat,pdf_standard,icc_profileorid_labelwithout adiris then anArgumentError. The JSON also records, outside the board's identity, how the file was rendered (itsrenderblock).format = "pdf":"pdf"— the print master (vector frame, pattern embedded pixel for pixel, lossless, rewritten by Ghostscript topdf_standard; PDF/X-4 needs Ghostscript ≥ 10.07);"tiff"(or"tif") — the whole sheet at exactlyprint_density, 8-bit grey, LZW, density in the resolution tags and the ICC profile embedded (needsusing Libtiff_jll);"png"— single-channel grey, linear gamma, for digital use. Case-insensitive; anything else is anArgumentError.pdf_standard = :x4::x4(PDF/X-4, what most print shops ask for),:x3,:x1a(PDF 1.3, for older RIPs) or:none(a plain lossless PDF with no OutputIntent)."pdf"only: giving it with another format is anArgumentError.icc_profile = nothing: the grey profile declaring how the values print —nothingor:linear(linear reflectance, the default) or the path of a single-channel ICC file. Used as the PDF/X OutputIntent and embedded in a TIFF (which embeds the linear profile by default); giving it with"png"or withpdf_standard = :noneis anArgumentError.id_label = true: print the board's identity (file stem, full config hash, BlobBoards version, size @ dpi) in one 6 pt line under the board, in the sheet margin outside the TrimBox. Skipped when the margin has no room (with a warning for a frameless board, which then carries no printed identity). A rendering option, not part of the identity.
Pattern (recorded in the board's PatternConfig; see blob_pattern)
blob_sizes = (4.0mm, nothing): the smallest and largest printed disc DIAMETER (2·cutoff·σ), physical lengths converted throughprint_density.maxmay be a length, a fraction of the pattern's short side (0.2short_side) ornothing(20% of the short side). The min may not benothingor a fraction; its default is2·cutoff·1mm(4 mm at the default cutoff). Both ends are exact. The smallest disc must be at least2·cutoff·2printer dots (σ ≥ 2 px: 0.68 mm at 300 dpi), reported in mm when violated, and the largest must be smaller than the pattern's short side. Stored as σ in px (min_scale,max_scale; those names are not keywords).seed: the placement RNG seed (any integer; default a randomUInt64, recorded in the bundle). Same seed and keywords → the same board.polarity = :dark::darkblobs on white or:lightblobs on black.scales_per_octave = 3: rungs per octave of the blob-scale ladder.α = 1.2: scale-sampling bias (how many blobs each scale gets).cutoff = 2.0(DEFAULT_CUTOFF): the disc radius in σ; keep the default.max_trials = 5000: placement attempts per blob before a scale gives up.
A board's blob centres always sit on pixel centres (raster_aligned is recorded as true in its PatternConfig and is not a keyword).
Board, frame and rulers (recorded in the board's BoardConfig)
board_size = canvas: the board area, aSize2of lengths ≤ the canvas in both dimensions, centred on the page with white margins; the PDF TrimBox marks it.frame_width = 0.075 · max(width, height): the frame band around the pattern; its square corners hold the QR code (top left; omitted with a warning when the encoded preamble does not fit the corner at this density) and the three uid characters. The pattern isboard_size − 2·frame_width.0mmgives a frameless board: the pattern fills the board, with no rulers, QR code or uid.frame_style = :ruler: what fills the frame —:ruler(tick marks),:saddle(a two-row checkerboard; the bundle then carries its saddle points) or:none(blank, corners only).frame_padding = 0mm: white inset between the frame's content (ticks, saddle squares, QR code, uid glyphs) and its edges; must be< frame_width / 2unlessframe_style = :none.major_tick_spacing = 10mm,minor_tick_spacing = 5mm: ruler tick intervals. The ticks are centred along each side; check the printed scale against them.major_tick_width = frame_width − 2·frame_padding,minor_tick_width = major_tick_width / 2: each tick's full drawn length across the frame band (physical units, centred in the band). The defaults fill the padded band with major ticks and draw minor ticks half as long. Requires0 < minor_tick_width ≤ major_tick_width ≤ frame_width − 2·frame_padding, else anArgumentError.
Never keywords: canvas_size, print_density, pattern_resolution (derived from the positional arguments), min_scale, max_scale (use blob_sizes), pixel_type (positional), raster_aligned, blob_measure (sizes are always diameters) and ruler_width (removed: set the tick widths) are each an ArgumentError.
Returns
bundle.preamble: theBoardConfig(board_config(bundle)) andPatternConfig(pattern_config(bundle)) — everything needed to re-generate the board; its hash is the uid printed in the corners.bundle.blobs: the placed blobs (Discs, px, pattern frame); useblobs(bundle, frame; units)for:board,:canvasor:worldcoordinates, or mm.bundle.saddle_points: the frame's saddle points (frame_style = :saddleonly; empty otherwise), viasaddle_points.
Examples
using BlobBoards, CairoMakie# An A4 board at 300 dpi, written as PDF/X-4 + JSONbundle = blob_board("A4", 300dpi; seed = 0xBEEF, dir = "./out")# A custom page, a 180 mm square board on it, 2–12 mm printed discsbundle = blob_board(200mm, 250mm, 600dpi; board_size = Size2(width = 180mm, height = 180mm), blob_sizes = (2mm, 12mm), frame_style = :saddle, frame_padding = 1mm, dir = "./out")# 16-bit pattern, TIFF print master for a photo labusing Libtiff_jllbundle = blob_board(Gray{N0f16}, "A3", 1200dpi; format = "tiff", dir = "./out")# Re-generate a stored boardbundle = blob_board(read("./out/blob_board_abc_12345.json", String); dir = "./out2")See also
blob_pattern (the pattern alone, in pixels), PatternConfig, BoardConfig, BlobBoards.PRINT_SIZES. BlobBoards.render_board re-renders a stored bundle without re-running placement.
Blob and Feature Extraction
BlobBoards.blobs — Function
blobs(pb::PatternBundle) -> Vector{Disc}Get blobs from a pattern bundle in logical units (pixels, COLMAP convention).
Blobs are stored and returned in COLMAP convention (pixel centers at 0.5, 0.5). Use EuclideanMap(PixelFrame{:colmap}, PixelFrame{:julia}) to convert to other conventions if needed.
Returns
Vector of Disc objects in logical units (pixels)
Example
b = blobs(pattern_bundle)# Convert to Julia convention if needed for array indexing# (Disc is a primitive, so wrap the frame map's offset in a Translation —# EuclideanMap has no primitive-action methods yet; VGC 0.7.1 candidate.)b_julia = Translation(EuclideanMap(PixelFrame{:colmap}, PixelFrame{:julia}).t).(b)blobs(bundle::BoardBundle, reference_frame::Symbol; units=px) -> Vector{Disc}Get blobs from a board bundle with specified coordinate origin and units.
Coordinates are always returned in COLMAP convention (pixel centers at 0.5, 0.5). Use EuclideanMap(PixelFrame{:colmap}, PixelFrame{:julia}) to convert to other conventions if needed.
Arguments
bundle::BoardBundle: The board bundle containing blobsreference_frame::Symbol: Coordinate system origin:pattern,:board,:canvas- 2D image frames (y-down):world- 3D world frame (centered, y-up, unitless mm; ignoresunitsparameter)
units: Target unit for coordinates (default:px)- Logical:
px- pixel units - Physical:
mm,cm,inch, etc. - length units
- Logical:
Examples
# Logical units (pixels) in canvas frameb = blobs(bundle, :canvas)# Physical units in pattern frameb = blobs(bundle, :pattern; units=mm)# World coordinates for 3D/P3P (centered, y-up, unitless mm)b = blobs(bundle, :world)X = Point3.(vcat.(centroid.(b), 0.0)) # 3D points on z=0 plane# Compose transforms for full pipelineT = EuclideanMap(PixelFrame{:colmap}, PixelFrame{:julia}) ∘ Translation(board_config(bundle), :pattern, :canvas)BlobBoards.saddle_points — Function
saddle_points(bundle::BoardBundle, reference_frame::Symbol; units=px) -> Vector{Point2}Get saddle points from a board bundle with specified coordinate origin and units.
Saddle points are at pixel boundaries (integer coordinates in COLMAP convention). Use EuclideanMap(PixelFrame{:colmap}, PixelFrame{:julia}) to convert to other conventions if needed.
Arguments
bundle::BoardBundle: The board bundle containing saddle pointsreference_frame::Symbol: Coordinate system origin (:pattern,:board, or:canvas)units: Target unit for coordinates (default:px)- Logical:
px- returns points with pixel units - Physical:
mm,cm,inch, etc. - returns scaled unitless points
- Logical:
Examples
# Logical units in canvas framepts = saddle_points(bundle, :canvas)# Physical units in pattern framepts = saddle_points(bundle, :pattern; units=mm)pts = saddle_points(bundle, :pattern; units=inch)Detection and Pose
find_boards is the entry point: it detects blobs, fits elliptical frames, canonicalizes patches, describes them, matches against a board gallery, and estimates pose. The camera lives inside config.geometry — PoseGeometry for the calibrated P3P path, HomographyGeometry for the uncalibrated one — so a camera/geometry mismatch is unrepresentable rather than a runtime error.
Adaptation is backend-generic (KernelAbstractions): device = KA.CPU() runs the threaded host implementation, CUDABackend() (or any KA GPU backend) the device kernels. Results agree to within the pipeline's own run-to-run jitter; CUDA additionally provides cuBLAS/libdevice fast paths.
BlobBoards.find_boards — Function
find_boards(board_feature_dict, descriptor_network, img::AbstractMatrix;
board_bundles, config = FindBoardsConfig(), device = default_device(),
gpu_index = nothing, matching_strategy = MutualNN(),
exclude_polys = nothing, telemetry = nothing) -> Vector
find_boards(board_feature_dict, descriptor_network, frames; kwargs...) -> Vector{Vector}
find_boards(board_feature_dict, image_features; kwargs...) -> VectorFind known boards in an image and estimate each one's homography or pose; returns one (uid, model, quality, scored) tuple per verified board.
The image method runs the whole pipeline once per image: detect blobs, adapt them to ellipses, canonicalize and describe them, then for every board match, fit (RANSAC + local optimization), gate on inlier count and appearance, and claim the board's image region so that no detection serves two boards.
Arguments
board_feature_dict:Dict(uid => features)of the gallery boards' descriptors, as returned byinsert!(::BoardGallery, board_paths, network)(BoardGallery). Only these boards are searched for.descriptor_network: aDescriptorNetwork(or the path of its ONNX file), the same network that described the gallery.img: a grey image (AbstractMatrix, e.g.Gray.(load(path))). Give a vector of frames to run each independently with the gallery staged once. Give features fromextract_image_featuresto skip the image stages.
Keywords
board_bundles(required):Dict(uid => BoardBundle)for every board inboard_feature_dict(load_board(path).bundle). Polarity, cutoff and the board outline used for spatial claiming come from them.config = FindBoardsConfig(): every pipeline setting (FindBoardsConfig).config.geometryselects the estimator:HomographyGeometry(no camera),PoseGeometry(camera)(calibrated P3P) orRadialHomographyGeometry(image_size)(unknown radially distorted camera). Build it from a TOML file withFindBoardsConfig(path; camera).device = default_device(): the KernelAbstractions backend for detection, adaptation and description —KA.CPU(), orCUDABackend()once CUDA is loaded (the default picks the GPU when one is functional).gpu_index = nothing: a prebuiltGalleryIndexof the gallery ondevice; built per call whennothing. Build it once to reuse across images.matching_strategy = MutualNN(): the first matching phase's nearest-neighbour rule (a permissiveKnnMatch(3)cleanup phase follows).exclude_polys = nothing: image polygons whose detections are excluded before matching (regions already claimed elsewhere).telemetry = nothing: aTelemetrysink that records per-stage diagnostics.
Returns
A vector with one (uid, model, quality, scored) tuple per accepted board, in acceptance order:
uid: the board's 3-character uid (the corner glyphs, a key ofboard_bundles).model: withHomographyGeometry, the homography mapping the board's:worldplane (mm, z = 0) to image pixels. WithPoseGeometry, the board → camera pose (model.R,model.tin mm). WithRadialHomographyGeometry, the(H, k)division-model fit.quality: the fit's RANSAC score.scored: the correspondences (board blob → image detection) with their posterior weights inscored.weights;count(is_map_inlier, scored.weights)is the inlier count.
See also
track_boards (video), FindBoardsConfig, BoardGallery, DescriptorNetwork.
find_boards(board_feature_dict, descriptor_network,
frames::AbstractVector{<:AbstractMatrix}; kwargs...) -> Vector{Vector}Batch entry point: run board detection on a SEQUENCE of frames with one-time setup amortization — the gallery GPU index (and each frame's detection plan) is built once and reused. Per-frame results are independent (no temporal association — see track_boards for that); element i is exactly what find_boards(..., frames[i]) returns.
find_boards(board_feature_dict, image_features::StructArray{<:DescribedFeature};
kwargs...) -> VectorMatch pre-extracted image features against all boards, estimate geometry, gate on inlier count + appearance, and spatially claim/suppress. Runs round-based spatial suppression: every round fits all live boards on the current claim state, commits the single best, removes it from the gallery, and claims the detections inside its projected quad; surviving boards re-resolve their matches against the new claim state and refit, until a round accepts nothing. Two matching phases run this loop — strict MutualNN, then a permissive KnnMatch(3) cleanup on the depleted pool.
Arguments
board_feature_dict:Dict{uid => StructArray{DescribedFeature}}of board featuresimage_features: Pre-extractedStructArray{DescribedFeature}fromextract_image_features
Keyword Arguments
config:FindBoardsConfig.config.geometryselects the path —HomographyGeometry()(no camera) orPoseGeometry(camera)(calibrated P3P).board_bundles:Dict{uid => BoardBundle}— required for spatial claiming and guided search (enforced with anArgumentError).device: KA device (KA.CPU()orCUDABackend())gpu_index: Pre-builtGalleryIndexfor the device-resident board gallery
Returns (uid, model, quality, scored) for each verified board (IRLS weights in scored.weights), concatenated across phases.
BlobBoards.track_boards — Function
track_boards(board_feature_dict, descriptor_network, video_path::AbstractString; kwargs...)Track boards through a video file. Opens video_path and streams it frame by frame — nothing is materialized, since the sequence method only ever enumerates its input.
This mirrors annotate_blobs and save_trace, which already take video paths directly; VideoIO is a hard dependency, so accepting one here costs nothing and saves every caller an openvideo + close dance.
track_boards(board_feature_dict, descriptor_network, images;
board_bundles, config=FindBoardsConfig(),
iou_threshold=config.track.iou_threshold,
support_fraction=config.track.support_fraction,
patience=config.track.patience,
keyframe_every=config.track.keyframe_every, device=default_device(),
gpu_index=nothing, on_frame=nothing)
-> Dict{String, Vector{BoardDetection}}Track boards across a sequence of images: motion-guided association plus a per-frame RANSAC over the associated correspondences (see the file header for the algorithm). Calibration (if any) lives in config.geometry.
iou_threshold: per-blob predicted↔detected ellipse IoU gate ([find_boards.track]; tolerates roughly ½σ of center prediction error but rejects wrong-octave scale).support_fraction: fraction of predicted blobs that must associate for the warm bet to proceed to estimation.patience: consecutive failed frames before a track stops being re-targeted every frame (it stays eligible for discovery keyframes).keyframe_every: discovery cadence for boards not currently tracked (0disables periodic discovery after frame 1). NB between keyframes only boards ALREADY tracked are searched for, so a board first appearing at framekis not looked for until the next keyframe — correct for video, and the reason unrelated stills belong infind_boardsinstead.gpu_index: prebuiltGalleryIndex, as onfind_boards. Built here when omitted; supply one to reuse the staged gallery across several media instead of re-uploading it per call. When supplied, the INDEX defines which boards are matched — an index from a different gallery matches the wrong boards silently.on_frame: a sink calledon_frame(frame::TrackFrame)after every frame with that frame's record, pixels and configuration of record (a failing sink warns and tracking continues). Capture a replayable trace withtrace = TrackTrace(); track_boards(…; on_frame = f -> push!(trace, f)).
Returns Dict{uid => Vector{BoardDetection}}; each detection's source records whether the frame was won by the :track association path, a targeted :init, or :detect.
BlobBoards.FindBoardsConfig — Type
FindBoardsConfig(; kwargs...)Configuration for find_boards pipeline. The geometry field (HomographyGeometry or PoseGeometry) groups the per-estimator knobs (outlier half-width, min inliers) and determines which path runs.
Detection
detect::DetectConfig = DetectConfig(): Detection configuration (single source of truth fordetect_blobsparameters across all backends).max_detections=typemax(Int): post-detection response-rank cap. On the grouped image path it is applied PER POLARITY GROUP (after_polarity_subset), so a mixed dark+light board set can process up to 2× this many detections in total — see_detect_for_boardsfor why the shared detection stage must stay uncapped. Single-polarity sets (every real set) behave exactly like adetect_blobs(; max_detections=…)cap.
Adaptation (blob fitting)
adapt::AdaptConfig = AdaptConfig(): Adaptation configuration
Geometry estimator
geometry::GeometryConfig = HomographyGeometry(): per-estimator RANSAC + inlier-gate knobs. UseHomographyGeometry()for the no-camera path orPoseGeometry(camera)(the camera lives in the geometry) for the calibrated P3P path.
Matching
min_similarity=0.6: Minimum descriptor cosine similarity. The derived gates move with it: phase 2's permissive gate (_phase2_similarity, 0.1 below), which is not configured separately so it cannot drift apart under a sweep. The agenda counts every resolved match — there is no second similarity bar (see_n_resolved_matches).
Appearance verification (mandatory)
verify_similarity=0.6: an inlier correspondence "agrees in appearance" when its gallery and image descriptors reach this cosine similarity. Deliberately NOT namedmin_similarity— a different quantity at a different stage that happens to share the value (seeappearance_supportin verify.jl).min_appearance_support=0.5: minimum fraction of a candidate board's inliers that must agree in appearance for the board to be accepted.
Geometry cannot make this call. Guided association is descriptor-blind, and at measured detection densities (13,844/MP, 5 px band) 66% of arbitrary positions have SOME detection in range — a fabricated board fills its inlier set geometrically, so counts and fractions satisfy it. The inliers' APPEARANCE separates real from fabricated with zero measured overlap (real ≥ 0.993 support, false = 0.0 at a 0.8 sim gate; see verify.jl). This gate replaced the geometry structs' min_inlier_fraction (a proxy for the same attack, removed 2026-08-13).
verify_similarity was calibrated DOWN twice on the 260527 corpus (2026-08-14), each time because the real population's frontier sat under the gate while false boards never came near it (median 0.07–0.37): 0.8 → 0.7 on the strided 12-frame slice — distant real boards carry median inlier sims 0.73–0.80; 0.8 rejected 8 GT-verified real boards. 0.7 → 0.6 on the FULL corpus — the inter-slice 2–3 m frames the stride never sampled dip to median sims 0.66–0.71, so 0.7 gated 13 more real csponds (probe: support 0.30–0.47, deterministic). At the default support of 0.5 the gate is exactly a MEDIAN test, so the bar must sit under the real population's frontier median. NB appearance is structurally blind to POSE FLIPS (correct correspondences, mirrored rotation): that is resolve_pose_ambiguity's job, not this gate's — and loosening this bar re-admits flipped poses along with good ones.
Reproducibility
ransac_seed=0x0b10b5eed: base seed for the per-fit RANSAC streams (Xoshiro(ransac_seed ⊻ board uid)— seefit_rng). Identical inputs give identical results, independent of thread scheduling, call order, and ambient RNG state; sweep it only to MEASURE fit-stage RNG sensitivity. CUDA feature extraction remains nondeterministic (adapt's atomic pixel slots), so end-to-end reproducibility is "given features".
Every field is CONCRETE by design: this config is read inside the per-board fitting loop, so an abstract field (adapt::AdaptConfig, geometry) would force boxing and dynamic dispatch on every access. Keep it that way when adding fields.
Overriding one knob
The struct is immutable and find_boards has no per-knob keyword — you supply a whole config. To change one setting, use Accessors.@set, which reaches nested fields and rebuilds only what changed:
Accessors is NOT a dependency of BlobBoards — add it to your own project (pkg> add Accessors). No library code is needed for this to work: ConstructionBase's generic setproperties already handles these structs.
using Accessors # your dep, not BlobBoards'cfg = FindBoardsConfig("configs/default.toml"; camera)cfg = @set cfg.detect.peak_threshold = 0.02cfg = @set cfg.detect.upsample = falseThis works through ConstructionBase's generic setproperties, which is correct here only because every one of these structs is a plain @kwdef with no inner constructor — so its positional constructor matches its field order. Adding a validating inner constructor to FindBoardsConfig, DetectConfig, AdaptConfig or a GeometryConfig silently breaks the override idiom; test/test_config_toml.jl gates that, including the type-changing case (@set cfg.detect.nms = EllipseNms(...), which alters a type parameter).
Sweeps should override rather than re-list: a hand-built FindBoardsConfig in a driver duplicates the knobs of the config of record, and the two drift. See bmvc2026/scripts/tune_invariance.jl.
BlobBoards.PoseGeometry — Type
PoseGeometry(camera; outlier_halfwidth=5.0, min_inliers=6, local_optimization=StudentTUniformIrls())Calibrated P3P estimator config. Holds the camera (a CameraModel carrying the lens/distortion model used to undistort the image). The camera lives here — not as a separate find_boards argument — so calibrated-ness is encoded in one place and illegal camera/geometry mismatches are unrepresentable.
undistortion selects where the pose is measured: :none (the default) detects, adapts and describes on the RAW image and the estimator verifies through the FULL camera — RANSAC scoring, the Student-t M-step and the Welsch lift all measure the reprojection through the lens in the pixels the detector produced (RobustVisualGeometry ≥ 0.19.9), and guided association transfers each blob's conic through the plane homography exactly and the lens's affinity at its centre (transfer_ellipse(::Camera, _)). :global undistorts the IMAGE (VGC's fused warp) and runs the whole pipeline on the result through the pinhole twin — the historical path, and what the BMVC runs used (their configs state it). Why :none: a residual in the undistorted plane is the detection error magnified by the lens's inverse slope — 84× at the rim of an 8 mm fisheye — so a 5 px gate there rejected every rim point; in raw pixels the gate means what it says everywhere.
3 is the geometric minimum for p3p; the default 6 leaves three outlier-tolerated points above the minimal sample. P3P is 2 DOF tighter than homography, so it needs one fewer support point than the homography path (mininliers=7). (The gate is INCLUSIVE — `nin >= mininliers` — since 2026-08-11; the default went 5 → 6 in the same change, keeping the effective tuned threshold: a 5-inlier P3P fit against a 50-board gallery measured as a false accept on DSC02847.) Half-width is 5.0 px — the same inlier band as the homography path — since BB residuals come from adapt-refined sub-pixel detections.
BlobBoards.HomographyGeometry — Type
HomographyGeometry(; outlier_halfwidth=5.0, min_inliers=7, local_optimization=StudentTUniformIrls())Homography (no-camera) estimator config. 4 is the geometric minimum for find_homography; the default 7 leaves three outlier-tolerated points above the minimal sample — homography is 2 DOF looser than P3P, so it needs more support to reject coincidental 4-point matches against a large board gallery. (The gate is INCLUSIVE — nin >= min_inliers — since 2026-08-11; the default went 6 → 7 in the same change, so the effective threshold the pipeline was tuned under is unchanged.) Half-width is tighter than RVG's default (20) since BB residuals come from adapt-refined sub-pixel detections.
There is no min_inlier_fraction anymore. It gated on the fraction of the correspondence set with posterior weight above ½ (is_map_inlier) — a geometric proxy against dense low-quality match clouds (guided sets of 400+ csponds carrying 6 accidental inliers satisfy any absolute count). The mandatory appearance gate (FindBoardsConfig.verify_similarity / min_appearance_support, see verify.jl) rejects that attack directly and with total measured separation, so the proxy was removed (2026-08-13) rather than kept as a second, weaker knob on the same failure mode.
BlobBoards.RadialHomographyGeometry — Type
RadialHomographyGeometry(image_size; outlier_halfwidth=5.0, min_inliers=7, local_optimization=StudentTUniformIrls())Uncalibrated radially distorted homography config — the one-sided division-model (H, k) path (RVG find_radial_homography; Wadenbäck et al., 3DV 2026). For captures with an UNKNOWN camera whose lens bends straight lines: the distortion coefficient is estimated jointly with H per fit — no calibration, no image undistortion (the detector runs on the raw image, exactly like the plain homography arm).
image_size = (width, height) is DATA, not configuration — the sensor extent that fixes the distortion centre (the image middle), which the arm hands its estimator as center — so like PoseGeometry's camera it lives in the geometry and arrives as a FindBoardsConfig keyword, never as a TOML value.
min_inliers defaults to the homography arm's 7: the model has one extra DOF, and the count gate stays inclusive (nin >= min_inliers).
v1 scope: the guided final fit and track_boards are NOT wired for this arm (both project through a single HomographyMap, which cannot express the distortion); the RANSAC+LO fit is the result.
BlobBoards.prepare_image_features — Function
prepare_image_features(descriptor_network, img;
board_bundles, uids = keys(board_bundles), config::FindBoardsConfig, device)
-> (; detect_img, adapt_img, image_features, shared_cutoff, config)The camera (for the BrownConrady undistort) is read from config.geometry — PoseGeometry carries it, HomographyGeometry has none (undistort becomes a no-op).
Detect-once seam for annotate_blobs, track_boards and the sweeps: the SAME stages find_boards(…, img) runs — undistort (two-step: a KA undistort kernel for BrownConrady, then a storage conversion via an identity-kernel _undistort_adapt), detect at the polarity the boards imply, cap, printed- profile scale correction, adapt, describe at the gallery's shared extent (_shared_image_cutoff over uids) — producing ONE feature set shared by every board. There is no cutoff keyword: the gallery decides the extent, and a caller-supplied one could only make query and gallery descriptors silently incomparable. The returned config carries the derived detection polarity; shared_cutoff is the extent the features were described at.
NEVER run two undistort kernels on the same image — the per-stage numerical drift between kernels (hardware bilinear vs manual bilinear) propagates through adapt → canonicalize → descriptors and shifts RANSAC inliers enough to drop borderline boards. The second _undistort_adapt(…, nothing) call here is a storage convert (Pinhole/nothing path), not a second kernel.
BlobBoards.load_board — Function
load_board(board_path) -> NamedTupleParse board JSON metadata: extract UID, GT blob positions, and world coordinates. No PDF rasterization — just metadata.
Returns a NamedTuple with:
uid::String— unique board identifier (SHA-256 hash)bundle— parsed BoardBundleconfig_hash::String— the full SHA-256 the uid abbreviates (a caller that needs both no longer re-reads the bundle for it)image_path::String— path to the image/PDF (for deferred rasterization)canvas_frames::Vector{Detection}— GT blob detections in canvas pixel coordinatesworld_frames::Vector{Detection}— GT blobs in world coordinates (mm, unitless)
BlobBoards.load_bundle — Function
load_bundle(path) -> (bundle, image_path, config_hash)Parse the JSON sidecar to get the typed bundle (BoardBundle or PatternBundle), resolve the image path, and return the stored configuration hash.
The config_hash is the hex-encoded SHA-256 hash stored at write time in the hashes.config field of the JSON. This is the authoritative board identity — use uid_from_hash(config_hash) to derive the 3-character UID rather than recomputing from the deserialized config (which may differ after format migrations).
See also: load for loading bundle + image together.
The staged gallery
BlobBoards.GalleryIndex — Type
GalleryIndex(device, board_feature_dict)The board gallery staged on device as ONE block.
The board set is one gallery, so there is one staging and one matching path (see match_boards). stacked is the host half (StackedGallery): the concatenated features and the prefix sums that map a row of the block back to (board, local blob). stacked_index is the device half, VGF's StackedDescriptorIndex over those same descriptors. Built once per find_boards call and threaded as gpu_index.
It used to stage the descriptors TWICE, adding a per-board blocking for KnnMatch. That was ~85 MB of duplicate device memory for nothing: in VGF's stacked matcher the blocking (offsets/sizes) reaches exactly one kernel, the REVERSE reduction _ka_top1_cols_blocked!. The forward reduction runs per gallery row over all features, and KnnMatch's _ka_top4_rows! likewise — "the top-4 reduction is per row and there is no column reduction to keep block-local". So only MutualNN can see the blocking at all, and for it the per-board answer is the wrong question (below).
Pipeline stages
Each stage is usable on its own when you want to inspect or replace part of the chain.
BlobBoards.detect_blobs — Function
detect_blobs(img; config=DetectConfig(),
max_detections=typemax(Int)) -> Vector{Detection}Detect blobs in an image via the KernelAbstractions ASIFT pipeline, then apply a BB-side response-rank cap.
Returns raw detections. Use adapt for shape fitting and filter_blobs for quality filtering.
Keyword Arguments
config::DetectConfig: detector-stage parameters (seeDetectConfig). These are read by the detector backend.max_detections=typemax(Int): Final cap by response magnitude. Default is effectively no cap; supply a finite integer to enable.
max_detections is a BB-side post-processing knob, not an input the detector backend consumes — that's why it lives here rather than on DetectConfig.
There is deliberately no cutoff: detection is scale-space extrema and is cutoff-INDEPENDENT. The kwarg existed "for API parity" and was a documented no-op, which made cutoff look like a detection parameter when it is actually a property of the BOARD, consumed downstream by adapt, filter_blobs and canonicalize. That misreading is what let a run config state a cutoff that disagreed with the boards.
Cross-warp NMS (ellipse-aware) is configured through config.nms (MxmNms / EllipseNms); there is no BB-side geometric NMS stage over the detector's elliptic output (the legacy suppress_overlapping path only knows Disc bbox IoU and is bypassed).
Example
detections = detect_blobs(img)frames = adapt(detections, img; cutoff=2.0)frames = filter_blobs(frames, detections; cutoff=2.0)BlobBoards.DetectConfig — Type
DetectConfig(; kwargs...)Single source of truth for detect_blobs parameters across all backends (KA.CPU(), CUDABackend(), …). Each device implementation reads the fields it understands and ignores the rest; ASIFT-specific fields are no-ops for non-ASIFT backends.
Defaults match examples/pipeline/find_boards.jl — the canonical tuned invocation.
Generic fields (all backends)
peak_threshold = 0.01: DoG/intensity response thresholdedge_threshold = 5.0: edge-rejection ratiopolarity = :dark: which polarity to keep (:dark,:light, or:nonefor the unfiltered superset). NB:none— VGF throws on:both.
The per-call detection cap (max_detections) lives as a top-level kwarg of detect_blobs, not here — it's a BB-side post-NMS cap on the returned vector, not something the detector device consumes.
ASIFT-specific (no-ops for non-ASIFT backends)
tilts = []: ASIFT tilt schedule; EMPTY means the IMAS-25 schedule (the same conventionconfigs/default.tomluses)nms = EllipseNms(cutoff = 2.0f0): cross-warp NMS strategy (MxmNms(),EllipseNms(), ornothingto disable and let downstream RANSAC handle the duplicates).
Parameterized on the NMS config type N (Nothing, MxmNms, or EllipseNms) so the field stays concrete rather than an abstract union.
BlobBoards.adapt — Function
adapt(detections, img; config=AdaptConfig(), device = default_device()) -> Vector{BlobDetection}Fit elliptical frames to raw blob detections in img — the detections are the shapes being refined, so they lead. Both are typed, so a call in the retired (img, detections) order is a MethodError.
Uses a unified IRLS loop with Student-t posterior weights. The solve step is the Student-t/uniform mixture: WLS in log-space with f² correction and the sort-and-sweep posterior partition. It is not configurable.
Accepts Vector{Detection} (from detect_blobs) or Vector{Disc} (ground truth).
Returns
Vector{BlobDetection{Float64}} — fitted elliptical frames with .id indexing into the original detection vector.
BlobBoards.AdaptConfig — Type
AdaptConfig(; kwargs...)Configuration for blob adaptation (paraboloid fitting).
IRLS
spatial_weighting::Bool = true— adaptive spatial weights from current shapecutoff::Float64 = 2.0— pixel window in units of σmax_irls_iter::Int = 20— maximum IRLS iterationsrtol::Float64 = 1e-6— relative parameter convergence threshold
Filtering
min_intensity::Float64 = 1e-6— minimum inverted intensity to include pixelmax_scale_fraction::Float64 = 0.1— skip detections whose σ exceeds this FRACTION of the image's √area, i.e.σ > f·√(W·H). A resource guard: a blob's fitted support is ~(2·cutoff·σ)²pixels, so an unbounded σ is an OOM. It is a fraction rather than a pixel count because the same absolute cap means different things on different sensors — 200 px was ~5.8% of √area at 12 MP and larger than the whole frame at VGA, so an absolute value could not transfer across cameras. √area (the side of the equivalent square) is used rather than min(W,H) or the diagonal so the cap is aspect-neutral.max_scale_ratio::Float64 = 4.0— reject if adapted σ / detection σ exceeds thisscan_padding::Float64 = 1.0— per-blob scan box halfwidth =cutoff · scan_padding · σ(decoupled frommax_scale_ratio)max_clip_ratio::Float64 = 0.2— reject if blob is clipped by image boundarymax_aspect_ratio::Float64 = 10.0— reject if ellipse axis ratio exceeds this
Accumulation and the 6×6 solve run in Float64 wherever the backend supports it (Float32 on Metal); per-pixel staging is pinned Float32. (store was deleted 2026-09: inert since the KA port — no kernel read it.)
BlobBoards.canonicalize — Function
canonicalize(frames, img; mode=:logpolar, device=default_device(), kwargs...)
-> Vector{Matrix{Gray{Float32}}}Extract normalized patches of img at frames (Detection, EllipticFrame, Disc, etc.) — the frames are the shapes the patches are sampled on, so they lead. Both are typed, so a call in the retired (img, frames) order is a MethodError.
Keyword Arguments
mode=:logpolar: patch parameterization —:logpolar,:cartesian, or:original(see this file's header for the exact geometry of each)patch_size=64: Output patch size (square)inner_extent=2.0: Inner radius in units of σ (only used by:logpolar)outer_extent=DEFAULT_OUTER_EXTENT(96σ): outer radius in units of σ (field-of-view for all modes); gallery and query patches must share itdevice = default_device(): Computation devicesupersample=1: Anti-aliasing supersample factor. When>1, the patch is sampled on asupersample× finer grid and box-averaged down topatch_size. At largeouter_extentthe log-polar (and cartesian/original) maps minify the source — one output texel spans many input pixels — so a single interpolated tap aliases; supersampling averages that footprint. Cost scales assupersample². (Seeout_debug/logpolar_aliasing_oe96.png.)
BlobBoards.describe — Function
describe(model::DescriptorNetwork, patches) -> Matrix{Float32}Run ONNX descriptor model on patches in batched mode. Returns an N × D descriptor matrix (L2-normalized by the model).
The per-call batch is model.max_batch, set at construction — it is not a call-site keyword, because it also fixes the arena this network reserved (see reserve_workspace!). Measured on 3247 real 64×64 patches (RTX 4090, log-polar s96 network, 3 GiB arena cap), µs/patch by max_batch:
32 → 61.7 64 → 63.1 128 → 76.8 256 → 91.7 512 → 442.4 0 → 472.0Hence the default of 64. Read the right-hand columns with care: those runs predate the batch-derived cap, and batch 512 wants 4.1 GiB against the 3 GiB ceiling they were measured under, so part of that cliff is the arena thrashing rather than the convolutions. Batching is numerically exact (each patch is normalized independently; the graph has no BatchNorm), so this only ever trades throughput.
Descriptors and the board gallery
BoardGallery caches per-board features on disk. It stores the descriptor model's hash, so switching networks invalidates the cache and it rebuilds.
BlobBoards.DescriptorNetwork — Type
DescriptorNetwork <: AbstractDescriptorNetworkONNX-based descriptor model. Loads a pre-exported ONNX file for inference via ONNXRunTime.
Constructors
DescriptorNetwork(path::AbstractString)— load from ONNX file
max_batch is a FIELD rather than a describe keyword because it fixes the size of the CUDA arena this network reserves (reserve_workspace!). A reservation made for one batch size that a later call could exceed would not be a reservation at all.
BlobBoards.BoardGallery — Type
BoardGalleryPersistent store of board descriptors keyed by board UID.
Wraps a JLD2 file with extraction parameters baked into the cache key, so changing the model or extraction settings produces fresh entries. Each board's Gaussian cutoff is read from its bundle metadata.
Fields
path::String— JLD2 file pathnetwork_hash::String— SHA-256 of the ONNX descriptor modelpatch_size::Int— canonical patch sizeouter_extent::Float64— outer sampling radius
Constructor
gallery = BoardGallery("boards.jld2", "model.onnx")gallery = BoardGallery("boards.jld2", model::AbstractDescriptorNetwork)BlobBoards.annotate_blobs — Function
annotate_blobs(board_feature_dict, descriptor_network, img;
board_bundles, device = default_device(), kwargs...) -> NamedTupleDetect boards in img, project GT blobs into the image, and extract canonical patches labeled by (board_uid, blob_id).
Also extracts confuser patches: image detections that were not matched to any board blob. These are canonicalized using the same pipeline and returned separately for use as negatives during contrastive training.
Returns (; board_uids, blob_ids, patches, confuser_patches).
annotate_blobs(board_feature_dict, descriptor_network, video_path;
output_dir, board_bundles, board_paths, kwargs...)Process video frames, detect boards, and write per-frame JLD2 files into output_dir. Reference patches from the board PDF are written to output_dir/refs/<uid>.jld2 on first detection.
Writes output_dir/<video_basename>.done after all frames are processed. Resumable: existing per-frame files are skipped.
Non-maximum suppression
BlobBoards.MxmNms — Type
MxmNms(; window_half=3, band_octaves=1.0f0, band_reach=1)M×M centroid-window cross-warp NMS. Two ASIFT features collide if their centers fall inside an M = 2*window_half + 1 pixel box in input-frame coordinates; the lower-response one is dropped. Cheap; ignores ellipse shape. band_octaves/band_reach restrict suppression to features of comparable scale (see EllipseNms).
BlobBoards.EllipseNms — Type
EllipseNms(; cutoff=2.0f0, band_octaves=1.0f0, band_reach=1)Mahalanobis-footprint cross-warp NMS. Two ASIFT features collide if their cutoff-σ footprints share a pixel; the lower-response one is dropped. Respects ellipse shape. Suppression is SCALE-BANDED: a feature can only be suppressed by one whose geometric-mean scale band ⌊log2 √det S / band_octaves⌋ is within band_reach of its own (1 octave ± 1 ⇒ scale ratio < 4), so board-sized extrema and tilted-warp streaks cannot erase the small blobs inside them. band_octaves = 0 restores the whole-pool rule.
Board Geometry
Use Rect(bundle, reference_frame) to get bounding rectangles in different coordinate systems:
using GeometryBasics: Rect# Pattern coords (y-down, origin at pattern top-left)rect = Rect(bundle, :pattern) # Pattern area onlyrect = Rect(bundle, :board) # Full board (includes frame)rect = Rect(bundle, :canvas) # Full canvas (includes margins)# World coords (y-up, centered at origin)rect = Rect(bundle, :world) # For 3D visualizationThe :world frame returns a centered rect with unitless mm values, suitable for 3D visualization where the board origin is at the centroid.
Coordinate Transforms
BlobBoards extends CoordinateTransformations constructors:
Translation(bc::BoardConfig, from, to)— reference-frame translations (:pattern/:board/:canvas)AffineMap(bundle::BoardBundle, from, to; image_origin)— coordinate-system transforms (:logical/:physical/:world)
For image-origin conventions (:colmap/:julia/:opencv), use Translation(:colmap, :julia) (defined in VisualGeometryCore — returns a px-unit-bearing Translation).
Unit Conversion Helpers
BlobBoards.px_per_mm — Function
px_per_mm(print_density) -> Float64Convert print density to pixels per millimeter.
BlobBoards.mm_per_px — Function
mm_per_px(print_density) -> VgcQuantityThe pixel pitch of a print density, as a mm/px quantity — the inverse of the density, converted (1 / 300dpi is 0.0847 mm/px). Until 2026-09 it returned a bare length in mm, dropping the px dimension its callers then had to supply by convention.
Training Pipeline
BlobBoards.build_tracks_for_sequences — Function
build_tracks_for_sequences(board_dir, descriptor_model_path, video_dir; kwargs...)
-> output_pathBuild multi-modal training tracks for every media file in video_dir against every board .json in board_dir, accumulating into $output_name.tracks.
Videos (.mp4, .mov, .avi, .mkv, .m4v) are sampled into frames; photos (.jpg, .jpeg, .png, .tif, .tiff, .bmp) are treated as a single frame. A photo yields at most one observation per tracked blob (paired against its GT anchor via the anchor sequence); n_frames/stride are ignored for photos.
For each media item the boards are detected + tracked across frames (track_boards), then blob correspondences are established per frame by transferring the board's GT blobs through the estimated world→image homography and validating by IoU. Each surviving track stores, per observation, a single patch view (patch_type) at a single scale, a per-pixel board∩in-frame mask, and the per-frame affine shape. Tracks hold only tracked observations; the GT anchor patches are not included.
One .tracks file therefore holds exactly one patch type at one scale — the output_name is auto-suffixed _<patch_type>_s<scale> so logpolar/cartesian runs (or different scales) never collide in the same file.
Each patch carries a masks companion (same P×P×N shape): 1 where the pixel back-projects onto the board's world rectangle AND onto a real image pixel, 0 over off-board background, image edges, and perspective-warp gray-fill voids. Confuser (untracked) patches carry an in-frame-only mask (no board); anchor patches carry an in-frame mask against the rendered board image.
Two independent augmentations may fire per frame, both before extraction: a resolution-loss down/up-resample (probability p_downsample, coordinate- preserving) and a perspective warp (p_perspective) that strengthens the primary board's real foreshortening. The perspective warp composes its homography into every board's world→image map, so correspondences stay valid on the warped frame. A third, blur_sigma_max, adds Gaussian blur and is OFF by default.
Additionally, one anchor sequence per board is written, named by the board uid (rather than $(video)_$(uid)), holding every GT reference blob keyed by the same blob_track_id used by the per-video tracks — so each track's GT anchor is recoverable by id.
Boards and their gallery features are keyed by uid (the board's SHA-256 hash, as produced by load_board) — the same key track_boards returns — so lookups stay consistent end to end.
Already-processed sequences present in an existing output file are skipped (both $(video)_$(uid) video sequences and $(uid) anchor sequences), so the call is resumable.
Keyword Arguments
n_frames/stride: frame sampling (mutually exclusive; default = every frame)iou_threshold=0.6: minimum symmetric IoU for a valid blob correspondence. Kept fairly strict: a board's homography extrapolates its grid over nearby near-coplanar boards in a stack, and a loose threshold lets the dense fractal pattern produce coincidental cross-board false matches.0.6removes almost all of them (a handful survive per board vs hundreds of true matches) at a modest recall cost on fully-visible boards.max_detections=typemax(Int): per-frame detection cap (set it, e.g.5000, on high-resolution footage to keep the GPU descriptor-match GEMM in memory)max_image_pixels=nothing: optional cap on each frame's total pixel count (aspect ratio preserved), e.g.3840*2160(≈8.3 MP). The default keeps the native resolution. The detector allocates a full image pyramid sized to the frame, so set this on high-res stills (a 24 MP phone photo). Applied at read time so detect/track/adapt/canonicalize all share one coordinate system.min_inliers=20: board-acceptance threshold applied AFTER tracking — a tracked detection is kept only when its inlier count reaches this. It is a post-filter ontrack_boards' output, not aHomographyGeometrysetting; the tracker itself runs the config-of-record gate (min_inliers = 7).patch_type=:logpolar: the single patch parameterization written to this file —:logpolar,:cartesian, or:original(seecanonicalize).scale=32: the single patch scale — half the patch width/height in units of σ. Patches are storedP×P×N;patch_type/scaleare recorded as attributes on each sequence'stracksgroup.confuser_factor=1.0: cap total confusers (untracked patches) atconfuser_factor × tracked patches, sampled at random. Each confuser stores the chosen patch view + its in-frame mask underuntracked/{patches,masks}. SupplyInfto keep all confusers (no cap).debug_dir=nothing: when set, save one PNG per processed frame into this directory — the frame the detector ran on, with every board detected in it overlaid in its own color: that board's matched GT blobs as projected ellipses plus its projected board rectangle. Colors are stable per board across frames and printed once at run start. Purely diagnostic;nothingdisables it.
Turning off the memory caps (large GPU)
Three knobs bound GPU/host memory on constrained hardware; on a large GPU set them wide to process everything:
max_detections=typemax(Int)(default) — no per-frame detection cap. Lower it (e.g.5000) only if the all-boards × all-detections match GEMM OOMs.max_image_pixels=nothing— keep native frame resolution (the default).confuser_factor=Inf— keep every confuser patch.supersample=1: anti-aliasing supersample factor forwarded tocanonicalizefor every patch view and scale. At the large scales (64/96/128) one output texel spans many source pixels, so the default single-tap sampling aliases;>1samples on a finer grid and box-averages down. Cost scales assupersample²and is paid at every scale, including the small ones that do not alias.3–4is the quality sweet spot.p_downsample=0.75: probability of the resolution-loss augmentation firing on a frame (down- then up-sample to simulate blur/scale loss; conditional on firing, the three tiers 0.5×/0.25×/0.125× are equally likely). Independent of the perspective warp — both can fire on one frame.0disables it.p_perspective=0.5: probability of applying the perspective augmentation to a frame. It warps the frame with a homography that strengthens the primary (highest-quality) board's existing foreshortening, keeping the board centroid fixed, and composes the same warp into every board's homography. Independent of the resolution-loss augmentation — both can fire on one frame.0disables it.perspective_strengthen_max=1.0: upper bound of the strengthening factor. The board plane's vanishing line is scaled by1+γwithγdrawn uniformly from[0, perspective_strengthen_max], so0reproduces the frame's real perspective and larger values exaggerate it along the board's own tilt.perspective_jitter_max=0.1: upper bound of the extra random-direction tilt added on top of the strengthening, in a board-normalized frame (world extent →[-1,1]²), so the magnitude is unit- and board-size-invariant.blur_sigma_max=0: upper bound of the Gaussian blur augmentation. When positive, every frame is blurred withσ ~ U[0, blur_sigma_max](px) before extraction;0disables it. Unlike the other two augmentations this one is unconditional once enabled — there is no separate probability — so it shifts the whole training distribution rather than sampling from it. Independent of the resolution-loss and perspective augmentations.write_anchors=true: write one per-board GT anchor sequence (named by uid) before processing media. Setfalseto skip them — useful for false-board diagnostics that only need the<media>_<uid>tracked sequences, since the anchor galleries are large and slow to compress.writer_buffer=1: depth of the async-writer queue. A single background task owns the output file and does all HDF5 compression/writes while the GPU thread builds the next sequence (overlap needsjulia -t ≥2). Larger values give more slack but hold more finished (large) sequences in memory;0= rendezvous.async_writer=true: overlap HDF5 compression with GPU compute via the writer task above. Setfalseto write synchronously on the GPU thread (no overlap) — for-t 1/ memory-constrained runs.output_dir=".",output_name="tracks": output$output_name.trackslocationbackend=default_device(): compute backend for adapt/canonicalize/describe (CUDABackend()when CUDA is loaded, otherwiseKA.CPU())
BlobBoards.build_absolute_tracks — Function
build_absolute_tracks(boards, model, video_path; board_feature_dict,
board_bundles, kwargs...) -> output_pathBuild multi-modal tracks for one video against a set of boards (see build_tracks_for_sequences for the higher-level driver).
boards is a Dict{uid => board} (each a load_board NamedTuple with an extra image field), board_feature_dict a Dict{uid => features} and board_bundles a Dict{uid => BoardBundle} — all keyed by the same uid that track_boards returns. One .tracks sequence is written per board that gets tracked, named $(video)_$(uid).
Sampling, filtering, patch and augmentation keywords (n_frames/stride, iou_threshold, max_detections, max_image_pixels, patch_type, scale, confuser_factor, supersample, p_downsample, p_perspective, perspective_strengthen_max, perspective_jitter_max, blur_sigma_max) behave as documented on build_tracks_for_sequences, which forwards them here. patch_size=64 is local to this function. output_name is used verbatim (the higher-level driver adds the _<patch_type>_s<scale> suffix). sink receives each finished sequence as (name, tracks, confusers, is_anchor); the default writes it synchronously. on_view_stats, when given, receives this media item's viewing-angle records once, as on_view_stats(stats::Vector{ViewAngleStat}) (sorted by (uid, frame_idx)) — the driver collects them into its run-wide chart.
BlobBoards.save_tracks — Function
save_tracks(path, tracks; sequence_name, untracked=nothing, patch_type=nothing,
scale=nothing, is_anchor=false) -> pathSave tracks (and optionally untracked/confuser patches) to a .tracks file (HDF5 format). Each media item is stored as a separate sequence group so new items can be appended incrementally to the same file.
HDF5 layout
Dict (single-mode — one patch type at one scale, written by build_absolute_tracks / save_board_anchors):
/sequences/<name>— group attributeis_anchor(UInt8, 1 for the per-board GT anchor sequences, 0 for the per-media tracked sequences)/sequences/<name>/tracks/patches—(P, P, N)float32, chunked+compressed/sequences/<name>/tracks/masks—(P, P, N)float32; per-pixel board∩in-frame mask (1 where the patch pixel lands on the board sheet AND on a real image pixel; 0 over off-board background, image edges and perspective-warp voids)/sequences/<name>/tracks/affine_shapes—(2, 2, N)float32/sequences/<name>/tracks/frame_id—(N,)int32; source frame of each observation. Tracked sequences only (GT-anchor sequences omit it)./sequences/<name>/tracks/homographies—(3, 3, F)float64; ONE world→image homographyH_wiper frame the board was detected in (F frames, not N observations). Tracked sequences only./sequences/<name>/tracks/view_angles—(F,)float64; per-frame viewing obliquity in radians (0 = fronto-parallel, → π/2 edge-on), from the local affine tilt ofH_wiat the board centroid (intrinsics-free; metric because the board is square). Aligned withhomographies./sequences/<name>/tracks/homography_frame_ids—(F,)int32; the frame index of eachhomographies/view_anglescolumn. Join an observation to its pose viaframe_id→ this table./sequences/<name>/tracks/track_id,n_tracks,track_lengths/sequences/<name>/tracks— group attributespatch_type(string) andscale(float64)/sequences/<name>/untracked/patches,untracked/masks—(P, P, M)float32 (present only when confusers were supplied)
A second, Vector{Track} layout used to live here for build_tracks; both it and its reader went with that pipeline (2026-08-19).
BlobBoards.plot_tracks — Function
Render one frame of a TrackTrace (detections, predictions, associations). Requires a Makie backend.
File I/O
BlobBoards.load — Function
load(path::AbstractString) -> (bundle, image)Load a blob board or pattern from disk, including both the JSON metadata and image.
Accepts any of the paired files (image or JSON) and automatically finds the sidecar:
.png→ finds.jsonsidecar.pdf→ finds.jsonsidecar (renders at correct DPI from metadata).json→ finds image (.pngor.pdf)
Returns a tuple (bundle, image) where:
bundle: EitherBoardBundleorPatternBundle(automatically detected from JSON)image: Loaded image array
See also: load_bundle for metadata-only loading (no rasterization).
BlobBoards.save — Function
save(pattern::AbstractMatrix, bundle::PatternBundle; dir, format="png", overwrite=false, pretty_json=true)Save a grayscale pattern image directly and its JSON sidecar. Saves the colorant array directly as a grayscale PNG (single channel, no alpha). Returns (img_path, json_path).
BlobBoards.canonical_config_json — Function
canonical_config_json(config) -> StringThe blobboards canonical (v1) encoding of a config's lowered tree — the byte string board identities are minted from. Stable across JSON3/serializer versions by construction.
.tracks files
.tracks is plain HDF5 and is not registered with FileIO — the format"BlobBoardTracks" hooks were removed in 2026-08 (the format had in fact never been registered, so FileIO.load("x.tracks") never worked). Write them with build_tracks_for_sequences; read them with HDF5.jl, with h5py, or with the Python helper blobboards.load_tracks. The layout is documented in examples/training/TRACKS_FORMAT.md.
Units
The following units are re-exported for convenience:
| Unit | Description |
|---|---|
px | Pixels (logical) |
mm | Millimeters |
cm | Centimeters |
m | Meters |
inch | Inches |
ft | Feet |
dpi | Dots per inch |
pt | Points (1/72 inch) |
μm | Micrometers |
Types
The main types used in BlobBoards are:
Size2- Width/height dimensions (from VisualGeometryCore)Disc- Isotropic blob/feature type (from VisualGeometryCore)PatternConfig- Pattern generation configurationBoardConfig- Board layout configurationPatternBundle- Pattern with metadata (returned byblob_pattern)BoardBundle- Board with metadata (returned byblob_board)
Media enumeration
BlobBoards.walk_media — Function
walk_media(root; videos = true, images = true) -> Vector{String}Every image and video under root, recursively, sorted for reproducible order.
root may also be a single file (returned as a one-element vector if it is media) so callers need not special-case it. Supply videos = false or images = false to restrict the kind — a still corpus belongs in find_boards and a video corpus in track_boards, so they are usually walked separately.
BlobBoards.media_resolution — Function
media_resolution(path) -> Size2Frame size of the image or video at path, as a Size2 (width, height).
Videos report it from stream metadata without decoding; images fall back to a decode, since Julia's image stack exposes no general header-only probe. Costs ~0.6 s for a video and ~0.8 s for a 24 MP still, so it is a preliminary probe, not something to call per frame.
Size2 rather than a tuple ON PURPOSE: VideoIO.out_frame_size is (width, height) while Base.size is (rows, cols), and a bare tuple cannot say which it holds — that ambiguity produced a real transposition bug here. The named fields make both branches state the same thing.
BlobBoards.group_by_resolution — Function
group_by_resolution(paths; probe = media_resolution)
-> Vector{Pair{Size2, Vector{String}}}Group media by frame resolution, largest group first, order preserved within each group.
Process each group together and call VisualGeometryFeatures.clear_asift_plans!() at the boundary: one plan build per group instead of one per resolution change. Also stage the gallery once (GalleryIndex) outside the loop and supply it as gpu_index — it is independent of resolution, so rebuilding it per medium re-uploads the whole descriptor set for nothing.
ORDER IS SAFE AT MEDIA GRANULARITY ONLY. Never reorder frames within one medium: track_boards predicts each frame from the previous two, so shuffling a sequence destroys the temporal association. Every frame of a video shares one resolution anyway, so a video is intrinsically one group.
BlobBoards.is_video — Function
is_video(path) -> BoolWhether path names a video source (by extension) — the classification walk_media and the annotation drivers share.
BlobBoards.example_data — Function
example_data(name::Symbol) -> StringReturn the directory of one of the package's example-data artifacts, fetched on first use.
name is :boards (50 board-definition JSONs), :still (one calibrated 4240x2832 image + cameras.json) or :video (one calibrated 1080p clip + cameras.json). Each is fetched from the Hugging Face examples dataset through the package catalog (Artifacts.toml) into the shared artifact store. The pipeline examples run entirely on these: a fresh ] add BlobBoards needs no dataset, no data_dir, no configuration.