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.

source
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 as Size2 with 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). max may be 0.2short_side (a fraction of the short side) or nothing (default: 20% of the short side). The default min is 2·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 about 1/scales_per_octave octave (see calc_blob_scales).
  • seed: Random seed for reproducible patterns
  • scales_per_octave: Number of scales per octave (default 3)
  • α: Scale sampling bias (default 1.2)
  • cutoff: Gaussian truncation radius in units of σ (default DEFAULT_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 :light
  • dir: Output directory for saving files (optional)

Returns (pattern, blobs, config).

source
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 the pixel_type form)
  • Other arguments same as blob_pattern(pixel_type, pattern_resolution; kwargs...), except the pixel type: it is positional, so supply blob_pattern(T, Size2(…)) (a pixel_type keyword is an ArgumentError)

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)
source
blob_pattern(pattern_resolution::Size2; kwargs...)

Generate a blob pattern with specified resolution.

Arguments

  • pattern_resolution: Pattern size as Size2 with pixel or logical units

Keyword Arguments

  • blob_sizes: disc diameter range (see the pixel_type form)
  • Other arguments same as blob_pattern(pixel_type, pattern_resolution; kwargs...), except the pixel type: it is positional, blob_pattern(T, pattern_resolution) (a pixel_type keyword is an ArgumentError; the default is DEFAULT_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)
source
BlobBoards.short_side — Constant
short_side

Marker 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).

source
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.

source
BlobBoards.calc_blob_scales — Function
calc_blob_scales(min_scale, max_scale; scales_per_octave = 3) -> Vector

The 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.

source

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) -> BoardBundle

Generate 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 of BlobBoards.PRINT_SIZES — "A0"…"A5", "ANSI A"…"ANSI E1", "Letter", "US Letter", "Legal", "Tabloid", "Ledger" (portrait, except "Ledger"). Anything else is an ArgumentError.
  • width, height or canvas_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} (default Gray{N0f8}). It is positional only — pixel_type = … as a keyword is an ArgumentError.
  • 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's render block, 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 complete BlobBoards.BoardPreamble and its BlobBoards.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. Writes blob_board_<uid>.<format> and blob_board_<uid>.json (the sidecar that reproduces the board), overwriting. nothing writes no file and only returns the bundle; giving format, pdf_standard, icc_profile or id_label without a dir is then an ArgumentError. The JSON also records, outside the board's identity, how the file was rendered (its render block).
  • format = "pdf": "pdf" — the print master (vector frame, pattern embedded pixel for pixel, lossless, rewritten by Ghostscript to pdf_standard; PDF/X-4 needs Ghostscript ≥ 10.07); "tiff" (or "tif") — the whole sheet at exactly print_density, 8-bit grey, LZW, density in the resolution tags and the ICC profile embedded (needs using Libtiff_jll); "png" — single-channel grey, linear gamma, for digital use. Case-insensitive; anything else is an ArgumentError.
  • 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 an ArgumentError.
  • icc_profile = nothing: the grey profile declaring how the values print — nothing or :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 with pdf_standard = :none is an ArgumentError.
  • 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 through print_density. max may be a length, a fraction of the pattern's short side (0.2short_side) or nothing (20% of the short side). The min may not be nothing or a fraction; its default is 2·cutoff·1mm (4 mm at the default cutoff). Both ends are exact. The smallest disc must be at least 2·cutoff·2 printer 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 random UInt64, recorded in the bundle). Same seed and keywords → the same board.
  • polarity = :dark: :dark blobs on white or :light blobs 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, a Size2 of 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 is board_size − 2·frame_width. 0mm gives 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 / 2 unless frame_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. Requires 0 < minor_tick_width ≤ major_tick_width ≤ frame_width − 2·frame_padding, else an ArgumentError.

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

A BlobBoards.BoardBundle:

  • bundle.preamble: the BoardConfig (board_config(bundle)) and PatternConfig (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); use blobs(bundle, frame; units) for :board, :canvas or :world coordinates, or mm.
  • bundle.saddle_points: the frame's saddle points (frame_style = :saddle only; empty otherwise), via saddle_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.

source

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)
source
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 blobs
  • reference_frame::Symbol: Coordinate system origin
    • :pattern, :board, :canvas - 2D image frames (y-down)
    • :world - 3D world frame (centered, y-up, unitless mm; ignores units parameter)
  • units: Target unit for coordinates (default: px)
    • Logical: px - pixel units
    • Physical: mm, cm, inch, etc. - length units

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)
source
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 points
  • reference_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

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)
source

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...) -> Vector

Find 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 by insert!(::BoardGallery, board_paths, network) (BoardGallery). Only these boards are searched for.
  • descriptor_network: a DescriptorNetwork (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 from extract_image_features to skip the image stages.

Keywords

  • board_bundles (required): Dict(uid => BoardBundle) for every board in board_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.geometry selects the estimator: HomographyGeometry (no camera), PoseGeometry(camera) (calibrated P3P) or RadialHomographyGeometry(image_size) (unknown radially distorted camera). Build it from a TOML file with FindBoardsConfig(path; camera).
  • device = default_device(): the KernelAbstractions backend for detection, adaptation and description — KA.CPU(), or CUDABackend() once CUDA is loaded (the default picks the GPU when one is functional).
  • gpu_index = nothing: a prebuilt GalleryIndex of the gallery on device; built per call when nothing. Build it once to reuse across images.
  • matching_strategy = MutualNN(): the first matching phase's nearest-neighbour rule (a permissive KnnMatch(3) cleanup phase follows).
  • exclude_polys = nothing: image polygons whose detections are excluded before matching (regions already claimed elsewhere).
  • telemetry = nothing: a Telemetry sink 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 of board_bundles).
  • model: with HomographyGeometry, the homography mapping the board's :world plane (mm, z = 0) to image pixels. With PoseGeometry, the board → camera pose (model.R, model.t in mm). With RadialHomographyGeometry, the (H, k) division-model fit.
  • quality: the fit's RANSAC score.
  • scored: the correspondences (board blob → image detection) with their posterior weights in scored.weights; count(is_map_inlier, scored.weights) is the inlier count.

See also

track_boards (video), FindBoardsConfig, BoardGallery, DescriptorNetwork.

source
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.

source
find_boards(board_feature_dict, image_features::StructArray{<:DescribedFeature};
            kwargs...) -> Vector

Match 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 features
  • image_features: Pre-extracted StructArray{DescribedFeature} from extract_image_features

Keyword Arguments

  • config: FindBoardsConfig. config.geometry selects the path — HomographyGeometry() (no camera) or PoseGeometry(camera) (calibrated P3P).
  • board_bundles: Dict{uid => BoardBundle} — required for spatial claiming and guided search (enforced with an ArgumentError).
  • device: KA device (KA.CPU() or CUDABackend())
  • gpu_index: Pre-built GalleryIndex for the device-resident board gallery

Returns (uid, model, quality, scored) for each verified board (IRLS weights in scored.weights), concatenated across phases.

source
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.

source
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 (0 disables periodic discovery after frame 1). NB between keyframes only boards ALREADY tracked are searched for, so a board first appearing at frame k is not looked for until the next keyframe — correct for video, and the reason unrelated stills belong in find_boards instead.
  • gpu_index: prebuilt GalleryIndex, as on find_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 called on_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 with trace = 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.

source
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 for detect_blobs parameters 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_boards for why the shared detection stage must stay uncapped. Single-polarity sets (every real set) behave exactly like a detect_blobs(; max_detections=…) cap.

Adaptation (blob fitting)

  • adapt::AdaptConfig = AdaptConfig(): Adaptation configuration

Geometry estimator

  • geometry::GeometryConfig = HomographyGeometry(): per-estimator RANSAC + inlier-gate knobs. Use HomographyGeometry() for the no-camera path or PoseGeometry(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 named min_similarity — a different quantity at a different stage that happens to share the value (see appearance_support in 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) — see fit_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 = false

This 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.

source
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.

source
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.

source
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.

source
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.

source
BlobBoards.load_board — Function
load_board(board_path) -> NamedTuple

Parse 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 BoardBundle
  • config_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 coordinates
  • world_frames::Vector{Detection} — GT blobs in world coordinates (mm, unitless)
source
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.

source
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).

source

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 (see DetectConfig). 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)
source
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 threshold
  • edge_threshold = 5.0: edge-rejection ratio
  • polarity = :dark: which polarity to keep (:dark, :light, or :none for 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 convention configs/default.toml uses)
  • nms = EllipseNms(cutoff = 2.0f0): cross-warp NMS strategy (MxmNms(), EllipseNms(), or nothing to 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.

source
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.

source
BlobBoards.AdaptConfig — Type
AdaptConfig(; kwargs...)

Configuration for blob adaptation (paraboloid fitting).

IRLS

  • spatial_weighting::Bool = true — adaptive spatial weights from current shape
  • cutoff::Float64 = 2.0 — pixel window in units of σ
  • max_irls_iter::Int = 20 — maximum IRLS iterations
  • rtol::Float64 = 1e-6 — relative parameter convergence threshold

Filtering

  • min_intensity::Float64 = 1e-6 — minimum inverted intensity to include pixel
  • max_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 this
  • scan_padding::Float64 = 1.0 — per-blob scan box halfwidth = cutoff · scan_padding · σ (decoupled from max_scale_ratio)
  • max_clip_ratio::Float64 = 0.2 — reject if blob is clipped by image boundary
  • max_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.)

source
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 it
  • device = default_device(): Computation device
  • supersample=1: Anti-aliasing supersample factor. When >1, the patch is sampled on a supersample× finer grid and box-averaged down to patch_size. At large outer_extent the 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 as supersample². (See out_debug/logpolar_aliasing_oe96.png.)
source
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.0

Hence 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.

source

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 <: AbstractDescriptorNetwork

ONNX-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.

source
BlobBoards.BoardGallery — Type
BoardGallery

Persistent 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 path
  • network_hash::String — SHA-256 of the ONNX descriptor model
  • patch_size::Int — canonical patch size
  • outer_extent::Float64 — outer sampling radius

Constructor

gallery = BoardGallery("boards.jld2", "model.onnx")gallery = BoardGallery("boards.jld2", model::AbstractDescriptorNetwork)
source
BlobBoards.annotate_blobs — Function
annotate_blobs(board_feature_dict, descriptor_network, img;
               board_bundles, device = default_device(), kwargs...) -> NamedTuple

Detect 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).

source
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.

source

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).

source
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.

source

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 visualization

The :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.mm_per_px — Function
mm_per_px(print_density) -> VgcQuantity

The 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.

source

Training Pipeline

BlobBoards.build_tracks_for_sequences — Function
build_tracks_for_sequences(board_dir, descriptor_model_path, video_dir; kwargs...)
    -> output_path

Build 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.6 removes 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 on track_boards' output, not a HomographyGeometry setting; 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 (see canonicalize).
  • scale=32: the single patch scale — half the patch width/height in units of σ. Patches are stored P×P×N; patch_type/scale are recorded as attributes on each sequence's tracks group.
  • confuser_factor=1.0: cap total confusers (untracked patches) at confuser_factor × tracked patches, sampled at random. Each confuser stores the chosen patch view + its in-frame mask under untracked/{patches,masks}. Supply Inf to 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; nothing disables 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 to canonicalize for 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; >1 samples on a finer grid and box-averages down. Cost scales as supersample² and is paid at every scale, including the small ones that do not alias. 3–4 is 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. 0 disables 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. 0 disables it.
  • perspective_strengthen_max=1.0: upper bound of the strengthening factor. The board plane's vanishing line is scaled by 1+γ with γ drawn uniformly from [0, perspective_strengthen_max], so 0 reproduces 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; 0 disables 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. Set false to 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 needs julia -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. Set false to write synchronously on the GPU thread (no overlap) — for -t 1 / memory-constrained runs.
  • output_dir=".", output_name="tracks": output $output_name.tracks location
  • backend=default_device(): compute backend for adapt/canonicalize/describe (CUDABackend() when CUDA is loaded, otherwise KA.CPU())
source
BlobBoards.build_absolute_tracks — Function
build_absolute_tracks(boards, model, video_path; board_feature_dict,
                      board_bundles, kwargs...) -> output_path

Build 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.

source
BlobBoards.save_tracks — Function
save_tracks(path, tracks; sequence_name, untracked=nothing, patch_type=nothing,
            scale=nothing, is_anchor=false) -> path

Save 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 attribute is_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 homography H_wi per 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 of H_wi at the board centroid (intrinsics-free; metric because the board is square). Aligned with homographies.
  • /sequences/<name>/tracks/homography_frame_ids — (F,) int32; the frame index of each homographies/view_angles column. Join an observation to its pose via frame_id → this table.
  • /sequences/<name>/tracks/track_id, n_tracks, track_lengths
  • /sequences/<name>/tracks — group attributes patch_type (string) and scale (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).

source
BlobBoards.plot_tracks — Function

Render one frame of a TrackTrace (detections, predictions, associations). Requires a Makie backend.

source

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 .json sidecar
  • .pdf → finds .json sidecar (renders at correct DPI from metadata)
  • .json → finds image (.png or .pdf)

Returns a tuple (bundle, image) where:

  • bundle: Either BoardBundle or PatternBundle (automatically detected from JSON)
  • image: Loaded image array

See also: load_bundle for metadata-only loading (no rasterization).

source
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).

source
BlobBoards.canonical_config_json — Function
canonical_config_json(config) -> String

The 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.

source

.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:

UnitDescription
pxPixels (logical)
mmMillimeters
cmCentimeters
mMeters
inchInches
ftFeet
dpiDots per inch
ptPoints (1/72 inch)
μmMicrometers

Types

The main types used in BlobBoards are:

  • Size2 - Width/height dimensions (from VisualGeometryCore)
  • Disc - Isotropic blob/feature type (from VisualGeometryCore)
  • PatternConfig - Pattern generation configuration
  • BoardConfig - Board layout configuration
  • PatternBundle - Pattern with metadata (returned by blob_pattern)
  • BoardBundle - Board with metadata (returned by blob_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.

source
BlobBoards.media_resolution — Function
media_resolution(path) -> Size2

Frame 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.

source
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.

source
BlobBoards.is_video — Function
is_video(path) -> Bool

Whether path names a video source (by extension) — the classification walk_media and the annotation drivers share.

source
BlobBoards.example_data — Function
example_data(name::Symbol) -> String

Return 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.

source