Skip to content

ds_msp.detect

Detection adapters (checkerboard / ChArUco / AprilGrid) — the OpenCV-facing layer that turns raw images into 3D↔2D correspondences. See detecting every AprilGrid tag for the multi-scale detection deep-dive.

ds_msp.detect

Detection adapters (ChArUco / AprilGrid).

The OpenCV-facing layer: turn images into 3D<->2D correspondences as :mod:ds_msp.data records. This is the only place under the calibration stack (besides io) where cv2 is allowed — the geometry/solver path stays NumPy-native. Depends on data + core.

BoardSpec dataclass

The ChArUco geometry from MC-Calib's config (one entry per board).

Source code in ds_msp/detect/charuco.py
@dataclass
class BoardSpec:
    """The ChArUco geometry from MC-Calib's config (one entry per board)."""
    n_x: int
    n_y: int
    length_square: float          # marker-generation square length (e.g. 0.04)
    length_marker: float          # marker-generation marker length (e.g. 0.03)
    square_size: float            # metric spacing used for the 3-D points (e.g. 0.192)

    @property
    def n_corners(self) -> int:
        """Interior chessboard corner count, ``(n_x - 1) * (n_y - 1)``."""
        return (self.n_x - 1) * (self.n_y - 1)

    @property
    def n_markers(self) -> int:
        """ArUco marker count on this board, ``floor(n_x * n_y / 2)`` (markers fill the
        non-chessboard cells of the checker pattern)."""
        # DICT markers fill the non-chessboard cells: floor(n_x*n_y / 2).
        return (self.n_x * self.n_y) // 2

n_corners property

n_corners: int

Interior chessboard corner count, (n_x - 1) * (n_y - 1).

n_markers property

n_markers: int

ArUco marker count on this board, floor(n_x * n_y / 2) (markers fill the non-chessboard cells of the checker pattern).

CheckerboardSpec dataclass

Plain checkerboard geometry: interior-corner counts + metric spacing.

Source code in ds_msp/detect/checkerboard.py
@dataclass
class CheckerboardSpec:
    """Plain checkerboard geometry: interior-corner counts + metric spacing."""
    cols: int                     # interior corners along the first (fastest-varying) axis
    rows: int                     # interior corners along the second axis
    square_size: float            # metric spacing used for the 3-D points (e.g. 0.025)
    larger: bool = False          # CALIB_CB_LARGER -- the board may exceed the image frame
    marker: bool = False          # CALIB_CB_MARKER -- board has an asymmetric marker cell

    @property
    def n_corners(self) -> int:
        """Total interior corner count, ``cols * rows``."""
        return self.cols * self.rows

n_corners property

n_corners: int

Total interior corner count, cols * rows.

board_object_points

board_object_points(spec: BoardSpec) -> np.ndarray

(n_corners, 3) interior-corner positions at square_size spacing, row-major — identical to MC-Calib's single-board calibrated_objects_data.yml.

Source code in ds_msp/detect/charuco.py
def board_object_points(spec: BoardSpec) -> np.ndarray:
    """``(n_corners, 3)`` interior-corner positions at ``square_size`` spacing, row-major
    — identical to MC-Calib's single-board ``calibrated_objects_data.yml``."""
    ncx, ncy = spec.n_x - 1, spec.n_y - 1
    k = np.arange(ncx * ncy)
    return np.c_[(k % ncx) * spec.square_size,
                 (k // ncx) * spec.square_size,
                 np.zeros(ncx * ncy)].astype(float)

checkerboard_object_points

checkerboard_object_points(spec: CheckerboardSpec) -> np.ndarray

(rows*cols, 3) interior-corner positions at square_size spacing, row-major with cols fastest -- identical convention to :func:ds_msp.detect.charuco.board_object_points, and the direct successor to ds_msp.utils.build_checkerboard_points.

Source code in ds_msp/detect/checkerboard.py
def board_object_points(spec: CheckerboardSpec) -> np.ndarray:
    """``(rows*cols, 3)`` interior-corner positions at ``square_size`` spacing, row-major with
    ``cols`` fastest -- identical convention to
    :func:`ds_msp.detect.charuco.board_object_points`, and the direct successor to
    ``ds_msp.utils.build_checkerboard_points``."""
    k = np.arange(spec.n_corners)
    return np.c_[(k % spec.cols) * spec.square_size,
                 (k // spec.cols) * spec.square_size,
                 np.zeros(spec.n_corners)].astype(float)

detect_aprilgrid

detect_aprilgrid(image_paths: Sequence[str], *, family: str = 't36h11', min_tags: int = 6, refine: bool = True, subpix_window: int = 5, scales: Sequence[float] = (1, 2, 3), target: Optional[object] = None, recover: bool = False, recover_neighbors: int = 6, recover_roi_pad: float = 0.6, recover_roi_px: float = 160.0) -> List[Dict[int, np.ndarray]]

Detect AprilGrid tags in a list of images, robust to the fisheye periphery.

Parameters:

Name Type Description Default
image_paths sequence of str

Paths to the calibration frames.

required
family str

AprilTag family of the board (TUM-VI / Kalibr default is "t36h11").

't36h11'
min_tags int

Skip frames where fewer than this many tags are found (a near-empty frame contributes little and risks outliers).

6
refine bool

Apply cv2.cornerSubPix to each corner at the scale it was detected (window auto-sized to the tag), then map back. The raw detector localizes to ~pixel; subpixel refinement is what brings calibration RMS from ~0.6 px to ~0.2 px (Kalibr does the same). Refining up-scaled peripheral tags this way is what makes multi-scale corners calibration-grade.

True
subpix_window int

Half-window (pixels) for cv2.cornerSubPix.

5
scales sequence of float

Image scales to detect at, unioned by tag id (default (1, 2, 3)). Extra scales recover peripheral tags the fisheye shrinks below the detector's size gate. Pass (1,) for the old single-pass behaviour.

(1, 2, 3)
target AprilGridTarget

Board geometry; required when recover=True.

None
recover bool

Enable board-guided recovery of still-missing tags (needs target). Predicts each missing tag from a local homography of nearby detected tags and re-detects in an up-scaled ROI; only id-verified tags are added.

False
recover_neighbors int

Recovery knobs — nearest detected tags used for the local homography, ROI padding (× tag size), and the pixel size each ROI tag is up-scaled to.

6
recover_roi_pad int

Recovery knobs — nearest detected tags used for the local homography, ROI padding (× tag size), and the pixel size each ROI tag is up-scaled to.

6
recover_roi_px int

Recovery knobs — nearest detected tags used for the local homography, ROI padding (× tag size), and the pixel size each ROI tag is up-scaled to.

6

Returns:

Type Description
list of dict

One {tag_id: (4, 2) float64} per kept frame, corners in the detector's order (bottom-left, bottom-right, top-right, top-left) — matching AprilGridTarget.object_points.

Source code in ds_msp/detect/detect.py
def detect_aprilgrid(
    image_paths: Sequence[str], *,
    family: str = "t36h11", min_tags: int = 6, refine: bool = True,
    subpix_window: int = 5, scales: Sequence[float] = (1, 2, 3),
    target: Optional[object] = None, recover: bool = False,
    recover_neighbors: int = 6, recover_roi_pad: float = 0.6,
    recover_roi_px: float = 160.0,
) -> List[Dict[int, np.ndarray]]:
    """Detect AprilGrid tags in a list of images, robust to the fisheye periphery.

    Parameters
    ----------
    image_paths : sequence of str
        Paths to the calibration frames.
    family : str
        AprilTag family of the board (TUM-VI / Kalibr default is ``"t36h11"``).
    min_tags : int
        Skip frames where fewer than this many tags are found (a near-empty frame
        contributes little and risks outliers).
    refine : bool
        Apply ``cv2.cornerSubPix`` to each corner **at the scale it was detected**
        (window auto-sized to the tag), then map back. The raw detector localizes to
        ~pixel; subpixel refinement is what brings calibration RMS from ~0.6 px to
        ~0.2 px (Kalibr does the same). Refining up-scaled peripheral tags this way is
        what makes multi-scale corners calibration-grade.
    subpix_window : int
        Half-window (pixels) for ``cv2.cornerSubPix``.
    scales : sequence of float
        Image scales to detect at, unioned by tag id (default ``(1, 2, 3)``). Extra
        scales recover peripheral tags the fisheye shrinks below the detector's size
        gate. Pass ``(1,)`` for the old single-pass behaviour.
    target : AprilGridTarget, optional
        Board geometry; required when ``recover=True``.
    recover : bool
        Enable board-guided recovery of still-missing tags (needs ``target``).
        Predicts each missing tag from a local homography of nearby detected tags
        and re-detects in an up-scaled ROI; only id-verified tags are added.
    recover_neighbors, recover_roi_pad, recover_roi_px :
        Recovery knobs — nearest detected tags used for the local homography, ROI
        padding (× tag size), and the pixel size each ROI tag is up-scaled to.

    Returns
    -------
    list of dict
        One ``{tag_id: (4, 2) float64}`` per *kept* frame, corners in the detector's
        order (bottom-left, bottom-right, top-right, top-left) — matching
        ``AprilGridTarget.object_points``.
    """
    if recover and target is None:
        raise ValueError("recover=True needs a `target` (AprilGridTarget) for the board geometry.")
    detector = _make_detector(family)
    out: List[Dict[int, np.ndarray]] = []
    for path in image_paths:
        gray = _load_gray_u8(path)
        found = _detect_union(detector, gray, scales,
                              refine=refine, subpix_window=subpix_window)
        if recover and target is not None:
            found = _recover_missing(detector, gray, found, target,
                                     neighbors=recover_neighbors, roi_pad=recover_roi_pad,
                                     roi_target_px=recover_roi_px,
                                     refine=refine, subpix_window=subpix_window)
        if len(found) < min_tags:
            continue
        out.append({int(tid): c.astype(np.float64) for tid, c in found.items()})
    return out

detect_checkerboard_corners

detect_checkerboard_corners(gray: ndarray, spec: CheckerboardSpec) -> Optional[np.ndarray]

Detect one checkerboard in one grayscale image. Returns (rows*cols, 2) pixel corners in the same row-major, cols-fastest order as :func:board_object_points, or None on failure. findChessboardCornersSB is all-or-nothing per image (no partial detection) -- the same contract as ChArUco's detectBoard per board.

Source code in ds_msp/detect/checkerboard.py
def detect_corners(gray: np.ndarray, spec: CheckerboardSpec) -> Optional[np.ndarray]:
    """Detect one checkerboard in one grayscale image. Returns ``(rows*cols, 2)`` pixel
    corners in the same row-major, ``cols``-fastest order as :func:`board_object_points`, or
    ``None`` on failure. ``findChessboardCornersSB`` is all-or-nothing per image (no partial
    detection) -- the same contract as ChArUco's ``detectBoard`` per board."""
    ok, corners = cv2.findChessboardCornersSB(gray, (spec.cols, spec.rows), flags=_flags(spec))
    if not ok:
        return None
    return corners.reshape(-1, 2).astype(float)

detect_folder

detect_folder(image_dir: str, specs: List[BoardSpec], obj: Object3D, cam_id: int, *, legacy: bool = True, min_corners: int = 4, pattern: str = '*', progress_cb: Optional[Callable[[int, int, int, str], None]] = None) -> List[ObjectObs]

Detect ChArUco corners over every image in image_dir for one camera, sequentially.

Returns one :class:ObjectObs per (frame, board), with point_rows indexing obj.pts_3d via (board_id, corner_id) — the same observation objects the keypoint reader produces, so the rest of the pipeline is untouched. frame_id is the raw filename index; :func:detect_rig rebases it to MC-Calib's 0-indexed convention. progress_cb(cam_id, i, n, path), if given, fires before each image is detected — the per-image OpenCV detection pass is the part of a real calibration run that otherwise stays silent for minutes on a large image set. This is the single-camera building block; :func:detect_rig parallelizes across every camera's images at once rather than calling this once per camera.

Source code in ds_msp/detect/charuco.py
def detect_folder(image_dir: str, specs: List[BoardSpec], obj: Object3D, cam_id: int, *,
                  legacy: bool = True, min_corners: int = 4, pattern: str = "*",
                  progress_cb: Optional[Callable[[int, int, int, str], None]] = None
                  ) -> List[ObjectObs]:
    """Detect ChArUco corners over every image in ``image_dir`` for one camera, sequentially.

    Returns one :class:`ObjectObs` per (frame, board), with ``point_rows`` indexing
    ``obj.pts_3d`` via ``(board_id, corner_id)`` — the same observation objects the
    keypoint reader produces, so the rest of the pipeline is untouched. ``frame_id`` is the
    raw filename index; :func:`detect_rig` rebases it to MC-Calib's 0-indexed convention.
    ``progress_cb(cam_id, i, n, path)``, if given, fires before each image is detected — the
    per-image OpenCV detection pass is the part of a real calibration run that otherwise
    stays silent for minutes on a large image set. This is the single-camera building block;
    :func:`detect_rig` parallelizes across every camera's images at once rather than calling
    this once per camera.
    """
    detectors = make_detectors(specs, legacy=legacy)
    files = sorted(f for f in glob.glob(os.path.join(image_dir, pattern))
                   if f.lower().endswith(_IMG_EXT))
    n = len(files)
    obs: List[ObjectObs] = []
    for i, path in enumerate(files, 1):
        if progress_cb is not None:
            progress_cb(cam_id, i, n, path)
        _, o = _detect_one_image(cam_id, path, obj, min_corners, detectors)
        obs.extend(o)
    return obs

detect_image

detect_image(detectors, gray: ndarray, *, min_corners: int = 4, subpix: bool = False) -> List[Tuple[int, np.ndarray, np.ndarray]]

Detect every board in one image. Returns [(board_id, corner_ids, pts_2d), ...] with corner_ids shape (m,) and pts_2d shape (m, 2).

subpix=True runs cv2.cornerSubPix on the interpolated ChArUco corners. OFF by default: CharucoDetector already sub-pixel-interpolates, and a second pass measurably wandered on this wide-fisheye rig (RMS 0.89→0.93 px) — the 5px window straddles neighbouring corners under heavy distortion. Useful on mild-distortion images.

Source code in ds_msp/detect/charuco.py
def detect_image(detectors, gray: np.ndarray, *, min_corners: int = 4, subpix: bool = False
                 ) -> List[Tuple[int, np.ndarray, np.ndarray]]:
    """Detect every board in one image. Returns ``[(board_id, corner_ids, pts_2d), ...]``
    with ``corner_ids`` shape ``(m,)`` and ``pts_2d`` shape ``(m, 2)``.

    ``subpix=True`` runs ``cv2.cornerSubPix`` on the interpolated ChArUco corners. OFF by
    default: ``CharucoDetector`` already sub-pixel-interpolates, and a second pass measurably
    *wandered* on this wide-fisheye rig (RMS 0.89→0.93 px) — the 5px window straddles
    neighbouring corners under heavy distortion. Useful on mild-distortion images."""
    out = []
    for b, det in enumerate(detectors):
        ch_corners, ch_ids, _, _ = det.detectBoard(gray)
        if ch_ids is None or len(ch_ids) < min_corners:
            continue
        c = ch_corners.reshape(-1, 2).astype(np.float32)
        if subpix and len(c):
            c = cv2.cornerSubPix(gray, c, (5, 5), (-1, -1), _SUBPIX_CRITERIA)
        out.append((b, ch_ids.ravel().astype(int), c.astype(float)))
    return out

detect_rig

detect_rig(root_path: str, cam_ids: List[int], specs: List[BoardSpec], obj: Object3D, *, cam_prefix: str = 'Cam_', legacy: bool = True, min_corners: int = 4, progress_cb: Optional[Callable[[int, int, int, str], None]] = None, workers: Optional[int] = None) -> Tuple[List[ObjectObs], Dict[int, Tuple[int, int]]]

Detect over <root_path>/<cam_prefix><cam+1:03d>/ for every camera (MC-Calib's 1-indexed layout). Returns (object_obs, img_size_per_cam).

Detection is parallelised across cameras and images together: one flat (cam_id, path) task list spanning every camera, run on a single :class:~concurrent.futures.ThreadPoolExecutor — the same pattern (and the same measured ~3.5x on an 8-camera rig) as rig/reconstruct.py::detect_board_obs_images. OpenCV's C++ detection work releases the GIL, so threads scale near-linearly with cores without needing process-pool pickling of the (potentially large) fused Object3D. Flat per-image tasks, not per-camera ones, keep every core busy despite very uneven per-camera image counts (e.g. a 2592px camera with 116 frames next to a 640px one with 58 — a per-camera-only split would leave two workers doing 2x the work of the rest). workers=None (default) uses os.cpu_count(); 1 forces the original serial per-camera loop (useful for debugging, or when there's only one camera / one image total, where pool start-up would only add overhead).

progress_cb(cam_id, i, n, path), if given, still fires once per image, from whichever worker thread is currently detecting it — serialized by an internal lock so terminal output from concurrent workers doesn't interleave mid-line. i counts that camera's own images processed so far (as before). Tasks are round-robin interleaved across cameras (image-index-major, not camera-major): a naive flat list built camera-by-camera drains camera 0's entire directory before camera 1's first task is even queued, so the live progress readout would show a strictly sequential "cam 0 done, then cam 1 starts, ..." sweep even though the underlying work is genuinely concurrent — measured, not assumed (rig/reconstruct.py::detect_board_obs_images had the exact same bug).

Source code in ds_msp/detect/charuco.py
def detect_rig(root_path: str, cam_ids: List[int], specs: List[BoardSpec], obj: Object3D, *,
               cam_prefix: str = "Cam_", legacy: bool = True, min_corners: int = 4,
               progress_cb: Optional[Callable[[int, int, int, str], None]] = None,
               workers: Optional[int] = None
               ) -> Tuple[List[ObjectObs], Dict[int, Tuple[int, int]]]:
    """Detect over ``<root_path>/<cam_prefix><cam+1:03d>/`` for every camera (MC-Calib's
    1-indexed layout). Returns ``(object_obs, img_size_per_cam)``.

    Detection is **parallelised across cameras and images together**: one flat
    ``(cam_id, path)`` task list spanning every camera, run on a single
    :class:`~concurrent.futures.ThreadPoolExecutor` — the same pattern (and the same
    measured ~3.5x on an 8-camera rig) as ``rig/reconstruct.py::detect_board_obs_images``.
    OpenCV's C++ detection work releases the GIL, so threads scale near-linearly with cores
    without needing process-pool pickling of the (potentially large) fused ``Object3D``.
    Flat per-image tasks, not per-camera ones, keep every core busy despite very uneven
    per-camera image counts (e.g. a 2592px camera with 116 frames next to a 640px one with
    58 — a per-camera-only split would leave two workers doing 2x the work of the rest).
    ``workers=None`` (default) uses ``os.cpu_count()``; ``1`` forces the original serial
    per-camera loop (useful for debugging, or when there's only one camera / one image
    total, where pool start-up would only add overhead).

    ``progress_cb(cam_id, i, n, path)``, if given, still fires once per image, from whichever
    worker thread is currently detecting it — serialized by an internal lock so terminal
    output from concurrent workers doesn't interleave mid-line. ``i`` counts that camera's own
    images processed so far (as before). Tasks are **round-robin interleaved across cameras**
    (image-index-major, not camera-major): a naive flat list built camera-by-camera drains
    camera 0's entire directory before camera 1's first task is even queued, so the live
    progress readout would show a strictly sequential "cam 0 done, then cam 1 starts, ..."
    sweep even though the underlying work is genuinely concurrent — measured, not assumed
    (``rig/reconstruct.py::detect_board_obs_images`` had the exact same bug).
    """
    cam_dirs: Dict[int, str] = {}
    for c in cam_ids:
        d = os.path.join(root_path, f"{cam_prefix}{c + 1:03d}")
        if os.path.isdir(d):
            cam_dirs[c] = d

    files_by_cam: Dict[int, List[str]] = {}
    for c, d in cam_dirs.items():
        files = sorted(f for f in glob.glob(os.path.join(d, "*")) if f.lower().endswith(_IMG_EXT))
        if files:
            files_by_cam[c] = files
    tasks: List[Tuple[int, str]] = []          # round-robin (cam_id, path) list, not camera-grouped
    i = 0
    while True:
        row = [(c, files[i]) for c, files in files_by_cam.items() if i < len(files)]
        if not row:
            break
        tasks.extend(row)
        i += 1
    counts = Counter(c for c, _ in tasks)
    seen: Dict[int, int] = Counter()
    lock = threading.Lock()

    def _do(task: Tuple[int, str]):
        c, path = task
        if progress_cb is not None:
            with lock:                          # serialize both the counter and the write
                seen[c] += 1
                progress_cb(c, seen[c], counts[c], path)
        detectors = _thread_detectors(specs, legacy)
        return (c, *_detect_one_image(c, path, obj, min_corners, detectors))

    n_workers = (os.cpu_count() or 4) if workers is None else workers
    if n_workers and n_workers > 1 and len(tasks) > 1:
        with ThreadPoolExecutor(max_workers=n_workers) as ex:
            results = list(ex.map(_do, tasks))
    else:
        results = [_do(t) for t in tasks]

    all_obs: List[ObjectObs] = []
    img_size: Dict[int, Tuple[int, int]] = {}
    for c, wh, obs in results:
        if wh is not None and c not in img_size:
            img_size[c] = wh
        all_obs.extend(obs)
    if all_obs:                       # rebase to MC-Calib's 0-indexed frames
        base = min(o.frame_id for o in all_obs)
        for o in all_obs:
            o.frame_id -= base
    return all_obs, img_size

make_detectors

make_detectors(specs: List[BoardSpec], *, legacy: bool = True, tuned: bool = False)

One :class:cv2.aruco.CharucoDetector per board, with MC-Calib's id offsets.

tuned=True enables the detection tricks that recover more corners a basic detector drops — tryRefineMarkers (re-find markers missed on the first pass via the board layout), minMarkers=1 (accept a ChArUco corner adjacent to a single decoded marker, not two — the board-edge corners), and per-marker CORNER_REFINE_SUBPIX. On the real 8-camera rig it lifts the corner count ~24% (e.g. the 640px fisheyes 87/558 → 397/971).

It is OFF by default because, measured on that rig, the extra corners HURT accuracy: the recovered corners are the strongly-distorted wide-fisheye edge corners, which are noisier — reprojection RMS rose 0.61→0.89 px. Turn it on only for a near-pinhole / mild- distortion rig, or when corner coverage (not accuracy) is the binding constraint.

Source code in ds_msp/detect/charuco.py
def make_detectors(specs: List[BoardSpec], *, legacy: bool = True, tuned: bool = False):
    """One :class:`cv2.aruco.CharucoDetector` per board, with MC-Calib's id offsets.

    ``tuned=True`` enables the detection tricks that *recover more corners* a basic detector
    drops — ``tryRefineMarkers`` (re-find markers missed on the first pass via the board
    layout), ``minMarkers=1`` (accept a ChArUco corner adjacent to a single decoded marker,
    not two — the board-edge corners), and per-marker ``CORNER_REFINE_SUBPIX``. On the real
    8-camera rig it lifts the corner count ~24% (e.g. the 640px fisheyes 87/558 → 397/971).

    **It is OFF by default because, measured on that rig, the extra corners HURT accuracy**:
    the recovered corners are the strongly-distorted wide-fisheye *edge* corners, which are
    noisier — reprojection RMS rose 0.61→0.89 px. Turn it on only for a near-pinhole / mild-
    distortion rig, or when corner coverage (not accuracy) is the binding constraint."""
    dictionary = cv2.aruco.getPredefinedDictionary(_DICT)
    detectors, offset = [], 0
    for spec in specs:
        board = _make_board(spec, dictionary, offset, legacy)
        if tuned:
            cp = cv2.aruco.CharucoParameters()
            cp.tryRefineMarkers = True
            cp.minMarkers = 1
            dp = cv2.aruco.DetectorParameters()
            dp.cornerRefinementMethod = cv2.aruco.CORNER_REFINE_SUBPIX
            detectors.append(cv2.aruco.CharucoDetector(board, cp, dp))
        else:
            detectors.append(cv2.aruco.CharucoDetector(board))
        offset += spec.n_markers
    return detectors

single_board_object

single_board_object(spec: BoardSpec, object_id: int = 0) -> Object3D

Build an :class:Object3D for a single ChArUco board straight from the config.

Source code in ds_msp/detect/charuco.py
def single_board_object(spec: BoardSpec, object_id: int = 0) -> Object3D:
    """Build an :class:`Object3D` for a single ChArUco board straight from the config."""
    xyz = board_object_points(spec)
    n = xyz.shape[0]
    board_ids = np.zeros(n, int)
    corner_ids = np.arange(n)
    b2o = {(0, int(c)): i for i, c in enumerate(corner_ids)}
    return Object3D(
        object_id=object_id, board_ids=[0], ref_board_id=0, T_co_b={0: np.eye(4)},
        pts_3d=xyz, pts_obj_2_board=np.c_[board_ids, corner_ids], pts_board_2_obj=b2o)