"""Build a resolution-independent :class:`Drawing` for each of the four patterns. Geometry is derived from OpenCV itself (``getObjPoints``) wherever a board is involved, so the rendered layout always matches what the OpenCV detector expects -- including the ChArUco chessboard square parity, which is read from real marker centres and never hardcoded (spec 8.3). Each ``build_*`` function returns a :class:`PatternResult` whose ``drawing`` is the *core* layout (pattern body + margin only). Format-specific decorations (caption band, centre marks) are added later by :func:`compose_single_canvas` (PNG/SVG) or by ``render_pdf`` (A4), because their placement depends on the final canvas. """ from __future__ import annotations from dataclasses import dataclass, field import cv2 import numpy as np from .aruco_utils import get_marker_module_matrix from .drawing import Drawing, Line, Rect, Text # --- Layout constants (mm) --------------------------------------------------- DEFAULT_MARK_LEN_MM = 5.0 # fallback centre-tick length (used if no margin) # Centre-alignment ticks default to this fraction of the quiet zone (margin), so # they always stay well inside the margin and scale with it. CENTER_MARK_QUIET_FRACTION = 0.3 DEFAULT_MARK_WIDTH_MM = 0.2 # stroke width of centre-alignment ticks DEFAULT_CUT_LINE_WIDTH_MM = 0.25 # stroke width of the outer cut/trim rectangle DEFAULT_CAPTION_FONT_MM = 2.6 # caption text em-size CAPTION_LINE_SPACING = 1.35 # caption line pitch as a multiple of font size CAPTION_PAD_MM = 2.5 # padding around the caption band ID_FONT_MAX_MM = 6.0 # upper clamp for ID-label font size ID_FONT_MIN_MM = 0.8 # below this an ID label is omitted (illegible) INFO_LABEL_FONT_MAX_MM = 4.0 # max font for the bottom-right OpenCV-input label INFO_LABEL_FONT_MIN_MM = 1.2 # below this the bottom-right label is omitted # U+00D7 multiplication sign. Present in DejaVu (PNG), browser fonts (SVG) and # the PDF base-14 fonts via WinAnsi/Latin-1, so it is safe in all three # renderers -- unlike CJK glyphs, which only the PNG font carries. MULT_SIGN = "×" @dataclass class PatternResult: """Everything needed to render a pattern and describe it. ``drawing`` is the core (body + margin) layout. ``summary`` carries computed values for the UI and caption. ``yaml_fields`` / ``yaml_mats`` feed the FileStorage YAML writer. ``warnings`` holds user-facing notes (in Japanese). """ drawing: Drawing body_w_mm: float body_h_mm: float margin_mm: float yaml_fields: dict yaml_mats: dict summary: dict warnings: list[str] = field(default_factory=list) def _obj_xy(marker_obj_points: np.ndarray) -> np.ndarray: """Return a marker's 4 corner ``(x, y)`` coordinates as a ``(4, 2)`` array.""" return np.asarray(marker_obj_points, dtype=float).reshape(-1, 3)[:, :2] def _marker_black_rects( dictionary: "cv2.aruco.Dictionary", marker_id: int, x0_mm: float, y0_mm: float, side_mm: float, ) -> list[Rect]: """Lay a marker's module matrix into the square box ``[x0, x0+side]^2``. Adjacent black modules in a row are merged into a single ``Rect`` so that output files stay small and vector edges stay crisp. Row 0 / column 0 of the matrix map to the top / left of the box (no flip; see ``aruco_utils``). """ if side_mm <= 0: # degenerate box -> nothing to draw return [] mat = get_marker_module_matrix(dictionary, marker_id) n = mat.shape[0] ms = side_mm / n # module size in mm rects: list[Rect] = [] for r in range(n): c = 0 while c < n: if mat[r, c]: run_start = c while c < n and mat[r, c]: c += 1 rects.append( Rect( x0_mm + run_start * ms, y0_mm + r * ms, (c - run_start) * ms, ms, "black", ) ) else: c += 1 return rects def _id_label( text: str, cx_mm: float, top_mm: float, band_h_mm: float, max_font_mm: float, ) -> Text | None: """Build a centred, top-anchored monospace ID label inside a safe band. ``top_mm`` / ``band_h_mm`` describe a horizontal strip known to be free of any marker quiet zone. Returns ``None`` if the strip is too thin to hold a legible label, so callers can record that the label was skipped. """ font = min(band_h_mm * 0.85, max_font_mm) if font < ID_FONT_MIN_MM: return None return Text( cx_mm, top_mm + (band_h_mm - font) * 0.5, text, font, h_anchor="middle", v_anchor="top", monospace=True, ) def _bottom_right_label( draw: Drawing, body_w: float, body_h: float, margin_mm: float, text: str ) -> bool: """Place a right-aligned info label in the bottom margin, below the board. Used by the chessboard and ChArUco builders to print the values an OpenCV detector needs (inner-corner count plus the relevant lengths). It lives in the bottom quiet margin so it never overlaps the board. The font scales with the margin but is also shrunk to fit the canvas width; the label is omitted only when that would make it illegible. Returns whether a label was added. """ inset = margin_mm * 0.2 avail_w = body_w + 2 * margin_mm - 2 * inset # ~0.6 em average glyph advance keeps the right-anchored text on-canvas. fit_w = avail_w / max(1.0, len(text) * 0.6) font = min(margin_mm * 0.5, fit_w, INFO_LABEL_FONT_MAX_MM) if font < INFO_LABEL_FONT_MIN_MM: return False draw.add( Text( body_w + 2 * margin_mm - inset, margin_mm + body_h + margin_mm / 2.0, text, font, h_anchor="end", v_anchor="middle", ) ) return True # --- ArUco single marker ----------------------------------------------------- def build_aruco_single( dictionary: "cv2.aruco.Dictionary", dict_name: str, marker_id: int, marker_length_mm: float, margin_mm: float, show_id: bool, add_quiet_zone: bool, ) -> PatternResult: """ArUco single marker. Input is ``marker_length_mm`` (the marker itself, not including margin). The canvas is ``marker_length_mm + 2*margin_mm`` square.""" if marker_length_mm <= 0: raise ValueError(f"markerLength ({marker_length_mm} mm) は正の値にしてください。") total_mm = marker_length_mm + 2.0 * margin_mm warnings: list[str] = [] n = int(dictionary.markerSize) + 2 module_mm = marker_length_mm / n if add_quiet_zone and margin_mm < module_mm: warnings.append( f"余白 {margin_mm:.2f}mm が 1 モジュール幅 {module_mm:.2f}mm 未満です。" "検出用の quiet zone を確保するため余白を広げることを推奨します。" ) draw = Drawing(total_mm, total_mm) draw.extend(_marker_black_rects(dictionary, marker_id, margin_mm, margin_mm, marker_length_mm)) if show_id: label = _id_label( f"id={marker_id}", cx_mm=total_mm / 2.0, top_mm=margin_mm + marker_length_mm, band_h_mm=margin_mm, max_font_mm=ID_FONT_MAX_MM, ) if label is not None: draw.add(label) else: warnings.append("余白が狭いため ID ラベルを省略しました。") summary = { "pattern": "aruco_single", "dictionary": dict_name, "id": marker_id, "markerLength": marker_length_mm, "total": total_mm, "margin": margin_mm, } yaml_fields = { "pattern_type": "aruco_single", "unit": "mm", "dictionary": dict_name, "id": int(marker_id), "markerLength": float(marker_length_mm), "margin": float(margin_mm), } return PatternResult(draw, marker_length_mm, marker_length_mm, margin_mm, yaml_fields, {}, summary, warnings) # --- ArUco GridBoard --------------------------------------------------------- def build_gridboard( dictionary: "cv2.aruco.Dictionary", dict_name: str, markers_x: int, markers_y: int, marker_length: float, marker_separation: float, first_marker: int, margin_mm: float, show_ids: bool, add_quiet_zone: bool, marker_ids: list[int] | None = None, ) -> PatternResult: """ArUco GridBoard. Marker ids are passed to OpenCV as an explicit ids array (4.13's ``GridBoard`` constructor dropped the ``firstMarker`` parameter; its 5th positional arg is now the ids Mat). If ``marker_ids`` is given it is used verbatim (arbitrary, possibly non-consecutive ids); otherwise ids are consecutive from ``first_marker``.""" n_markers = markers_x * markers_y if marker_ids is not None: ids = np.asarray(marker_ids, dtype=np.int32).reshape(-1, 1) if ids.size != n_markers: raise ValueError( f"GridBoard には {n_markers} 個の ID が必要ですが {ids.size} 個指定されました。" ) else: ids = np.arange(first_marker, first_marker + n_markers, dtype=np.int32).reshape(-1, 1) board = cv2.aruco.GridBoard( (markers_x, markers_y), float(marker_length), float(marker_separation), dictionary, ids ) obj_points = board.getObjPoints() board_ids = np.asarray(board.getIds(), dtype=np.int32).ravel() body_w = markers_x * marker_length + (markers_x - 1) * marker_separation body_h = markers_y * marker_length + (markers_y - 1) * marker_separation draw = Drawing(body_w + 2 * margin_mm, body_h + 2 * margin_mm) warnings: list[str] = [] module_mm = marker_length / (int(dictionary.markerSize) + 2) if add_quiet_zone and marker_separation < module_mm: warnings.append( f"マーカー間隔 {marker_separation:.2f}mm が 1 モジュール幅 " f"{module_mm:.2f}mm 未満です。検出が不安定になる恐れがあります。" ) skipped_label = False for mk, mid in zip(obj_points, board_ids): xy = _obj_xy(mk) min_x, min_y = xy.min(axis=0) max_x, max_y = xy.max(axis=0) side = max_x - min_x x0 = margin_mm + min_x y0 = margin_mm + min_y draw.extend(_marker_black_rects(dictionary, int(mid), x0, y0, side)) if show_ids: # The label sits below the marker. Keep one module of clearance from # this marker (bottom) and from the next marker (top) so we never # touch either marker's quiet zone (spec 4-4 / 8.2). is_bottom_row = (max_y + marker_separation) > body_h gap_below = margin_mm if is_bottom_row else marker_separation clearance_top = module_mm # clear this marker's quiet zone clearance_bottom = 0.0 if is_bottom_row else module_mm # clear next marker band_top = y0 + side + clearance_top band_h = max(0.0, gap_below - clearance_top - clearance_bottom) label = _id_label(str(int(mid)), x0 + side / 2.0, band_top, band_h, max_font_mm=side * 0.25) if label is not None: draw.add(label) else: skipped_label = True if show_ids and skipped_label: warnings.append("一部のマーカーは間隔が狭いため ID ラベルを省略しました。") summary = { "pattern": "gridboard", "dictionary": dict_name, "markersX": markers_x, "markersY": markers_y, "markerLength": marker_length, "markerSeparation": marker_separation, "firstMarker": int(board_ids[0]), "ids": board_ids.tolist(), "margin": margin_mm, "bodyW": body_w, "bodyH": body_h, } yaml_fields = { "pattern_type": "gridboard", "unit": "mm", "dictionary": dict_name, "markersX": int(markers_x), "markersY": int(markers_y), "markerLength": float(marker_length), "markerSeparation": float(marker_separation), "firstMarker": int(board_ids[0]), "margin": float(margin_mm), } yaml_mats = {"ids": board_ids.reshape(1, -1)} return PatternResult(draw, body_w, body_h, margin_mm, yaml_fields, yaml_mats, summary, warnings) # --- ChArUco board ----------------------------------------------------------- def build_charuco( dictionary: "cv2.aruco.Dictionary", dict_name: str, squares_x: int, squares_y: int, square_length: float, marker_length: float, margin_mm: float, show_ids: bool, add_quiet_zone: bool, first_marker: int = 0, marker_ids: list[int] | None = None, ) -> PatternResult: """ChArUco board. Marker placement and chessboard parity are both derived from ``getObjPoints``. The board always uses OpenCV's modern (post-4.6) pattern -- the legacy pattern is intentionally not supported. Marker ids: if ``marker_ids`` is given it is used verbatim (arbitrary, possibly non-consecutive); otherwise ids are consecutive from ``first_marker``. Both go through an explicit ids array -- the same mechanism used for GridBoard.""" if square_length <= 0: raise ValueError(f"squareLength ({square_length}mm) は正の値にしてください。") if marker_length >= square_length: raise ValueError( f"markerLength ({marker_length}mm) は squareLength " f"({square_length}mm) より小さくしてください。" ) board = cv2.aruco.CharucoBoard( (squares_x, squares_y), float(square_length), float(marker_length), dictionary ) # Read the marker count from this board before choosing the ids. count = len(board.getObjPoints()) ids: np.ndarray | None = None if marker_ids is not None: ids = np.asarray(marker_ids, dtype=np.int32).reshape(-1, 1) if ids.size != count: raise ValueError( f"この ChArUco ボードには {count} 個の ID が必要ですが {ids.size} 個指定されました。" ) elif first_marker != 0: ids = np.arange(first_marker, first_marker + count, dtype=np.int32).reshape(-1, 1) if ids is not None: board = cv2.aruco.CharucoBoard( (squares_x, squares_y), float(square_length), float(marker_length), dictionary, ids ) obj_points = board.getObjPoints() board_ids = np.asarray(board.getIds(), dtype=np.int32).ravel() body_w = squares_x * square_length body_h = squares_y * square_length draw = Drawing(body_w + 2 * margin_mm, body_h + 2 * margin_mm) # Derive the parity of marker-bearing (white) squares from a real marker # centre, then paint every black square. Never hardcode the parity. centres = [(_obj_xy(mk).mean(axis=0)) for mk in obj_points] gx0, gy0 = int(centres[0][0] // square_length), int(centres[0][1] // square_length) white_parity = (gx0 + gy0) % 2 for j in range(squares_y): for i in range(squares_x): if (i + j) % 2 != white_parity: # black square draw.add( Rect( margin_mm + i * square_length, margin_mm + j * square_length, square_length, square_length, "black", ) ) for mk, mid in zip(obj_points, board_ids): xy = _obj_xy(mk) min_x, min_y = xy.min(axis=0) max_x, max_y = xy.max(axis=0) side = max_x - min_x draw.extend( _marker_black_rects(dictionary, int(mid), margin_mm + min_x, margin_mm + min_y, side) ) warnings: list[str] = [] ring = (square_length - marker_length) / 2.0 # white quiet ring inside each square if add_quiet_zone and ring < marker_length / (int(dictionary.markerSize) + 2): warnings.append( f"白マス内の quiet zone (リング幅 {ring:.2f}mm) が狭いため、" "markerLength を小さくすると検出が安定します。" ) if show_ids: # Any in-square placement would intrude on the marker's quiet ring, so # per-marker ID labels are intentionally omitted (spec 4-4 / 8.3, safety # first). The YAML carries the full ids array instead. warnings.append("ChArUco の ID 併記は quiet zone 保護のため省略しています(ID は YAML に出力されます)。") inner_x, inner_y = squares_x - 1, squares_y - 1 # Bottom-right label with the values an OpenCV CharucoBoard needs: the # inner-corner count plus the square and marker lengths. Same placement as # the chessboard label so the two read consistently. _bottom_right_label( draw, body_w, body_h, margin_mm, f"inner {inner_x}{MULT_SIGN}{inner_y} | square {square_length:g} mm | marker {marker_length:g} mm", ) summary = { "pattern": "charuco", "dictionary": dict_name, "squaresX": squares_x, "squaresY": squares_y, "squareLength": square_length, "markerLength": marker_length, "firstMarker": int(board_ids[0]), "ids": board_ids.tolist(), "margin": margin_mm, "bodyW": body_w, "bodyH": body_h, "innerCornersX": inner_x, "innerCornersY": inner_y, } yaml_fields = { "pattern_type": "charuco", "unit": "mm", "dictionary": dict_name, "squaresX": int(squares_x), "squaresY": int(squares_y), "squareLength": float(square_length), "markerLength": float(marker_length), "firstMarker": int(board_ids[0]), "margin": float(margin_mm), } yaml_mats = {"ids": board_ids.reshape(1, -1)} return PatternResult(draw, body_w, body_h, margin_mm, yaml_fields, yaml_mats, summary, warnings) def charuco_marker_count( dictionary: "cv2.aruco.Dictionary", squares_x: int, squares_y: int ) -> int: """Number of ArUco markers a ChArUco board of this size carries. Independent of the marker/square lengths, so dummy valid lengths are used. Lets the UI validate an explicit id list up front. """ board = cv2.aruco.CharucoBoard((squares_x, squares_y), 10.0, 7.0, dictionary) return len(board.getObjPoints()) # --- Chessboard -------------------------------------------------------------- def build_chessboard( squares_x: int, squares_y: int, square_length: float, margin_mm: float, ) -> PatternResult: """Plain checkerboard. The top-left square is white; ``(i+j)`` odd is black. Reports both square count and inner-corner count to avoid the classic ``findChessboardCorners`` mix-up (spec 8.4). """ body_w = squares_x * square_length body_h = squares_y * square_length draw = Drawing(body_w + 2 * margin_mm, body_h + 2 * margin_mm) for j in range(squares_y): for i in range(squares_x): if (i + j) % 2 == 1: # black square (top-left is white) draw.add( Rect( margin_mm + i * square_length, margin_mm + j * square_length, square_length, square_length, "black", ) ) inner_x, inner_y = squares_x - 1, squares_y - 1 # Bottom-right label carrying exactly what OpenCV's findChessboardCorners # needs: the inner-corner count (patternSize) and the square length (spec 8.4). _bottom_right_label( draw, body_w, body_h, margin_mm, f"inner {inner_x}{MULT_SIGN}{inner_y} | square {square_length:g} mm", ) summary = { "pattern": "chessboard", "squaresX": squares_x, "squaresY": squares_y, "squareLength": square_length, "innerCornersX": inner_x, "innerCornersY": inner_y, "margin": margin_mm, "bodyW": body_w, "bodyH": body_h, } yaml_fields = { "pattern_type": "chessboard", "unit": "mm", "squaresX": int(squares_x), "squaresY": int(squares_y), "innerCornersX": int(inner_x), "innerCornersY": int(inner_y), "squareLength": float(square_length), "margin": float(margin_mm), } return PatternResult(draw, body_w, body_h, margin_mm, yaml_fields, {}, summary, []) # --- Caption text ------------------------------------------------------------ def _format_ids(ids: list[int]) -> str: """Compact description of a marker-id set: a range if consecutive, else a (possibly truncated) list, always with the total count.""" consecutive = all(ids[i + 1] - ids[i] == 1 for i in range(len(ids) - 1)) if consecutive: return f"{ids[0]}..{ids[-1]} (consecutive, {len(ids)} total)" shown = ", ".join(str(x) for x in ids) if len(shown) > 60: shown = ", ".join(str(x) for x in ids[:10]) + ", ..." return f"{shown} (custom, {len(ids)} total)" def build_caption_lines( result: PatternResult, opencv_version: str, timestamp: str, dpi: int | None = None, ) -> list[str]: """Compose the footer caption lines (spec 11). Only present fields appear. All caption text is English (ASCII) so it renders correctly in every output format: the PNG font (DejaVu) and the PDF base-14 fonts have no CJK glyphs, which would otherwise turn Japanese text into tofu boxes.""" s = result.summary pt = s["pattern"] label = { "aruco_single": "ArUco single marker", "gridboard": "ArUco GridBoard", "charuco": "ChArUco board", "chessboard": "Chessboard", }[pt] lines: list[str] = [f"Type: {label}"] if pt == "aruco_single": lines.append(f"Dictionary: {s['dictionary']} / id: {s['id']}") lines.append(f"markerLength: {s['markerLength']:.3g} mm / total: {s['total']:.3g} mm") elif pt == "gridboard": lines.append(f"Dictionary: {s['dictionary']} / {s['markersX']}x{s['markersY']} markers") lines.append( f"markerLength: {s['markerLength']:.3g} mm / separation: {s['markerSeparation']:.3g} mm" ) lines.append(f"id: {_format_ids(s['ids'])}") elif pt == "charuco": lines.append(f"Dictionary: {s['dictionary']} / {s['squaresX']}x{s['squaresY']} squares") lines.append( f"squareLength: {s['squareLength']:.3g} mm / markerLength: {s['markerLength']:.3g} mm" ) lines.append(f"inner corners: {s['innerCornersX']}x{s['innerCornersY']}") lines.append(f"id: {_format_ids(s['ids'])}") elif pt == "chessboard": lines.append(f"{s['squaresX']}x{s['squaresY']} squares / squareLength: {s['squareLength']:.3g} mm") lines.append(f"inner corners: {s['innerCornersX']}x{s['innerCornersY']}") tail = f"margin: {s['margin']:.3g} mm" if dpi is not None: tail += f" / DPI: {dpi}" tail += f" / OpenCV {opencv_version} / {timestamp}" lines.append(tail) return lines # --- Single-canvas composition (PNG / SVG) ----------------------------------- def compose_single_canvas( result: PatternResult, center_marks: bool, caption_lines: list[str] | None, cut_lines: bool = False, mark_len_mm: float | None = None, mark_width_mm: float = DEFAULT_MARK_WIDTH_MM, caption_font_mm: float = DEFAULT_CAPTION_FONT_MM, cut_width_mm: float = DEFAULT_CUT_LINE_WIDTH_MM, ) -> Drawing: """Wrap the core drawing into a tight single canvas with optional caption band, centre-alignment ticks, and an outer cut/trim rectangle (used by the PNG and SVG renderers). ``mark_len_mm`` defaults to ``CENTER_MARK_QUIET_FRACTION`` of the margin (quiet zone) so the ticks always stay inside the margin. ``cut_lines`` draws a solid rectangle along the pattern+margin edge (never the caption band).""" core = result.drawing pattern_w, pattern_h = core.width_mm, core.height_mm # pattern + margin extent band_h = 0.0 if caption_lines: line_pitch = caption_font_mm * CAPTION_LINE_SPACING band_h = 2 * CAPTION_PAD_MM + len(caption_lines) * line_pitch out = Drawing(pattern_w, pattern_h + band_h, list(core.items)) # Cut rectangle hugs the pattern+margin edge (above any caption band). if cut_lines: out.extend(_cut_rect_lines(pattern_w, pattern_h, cut_width_mm)) # Centre marks sit on the edges of the pattern+margin rectangle so they mark # the true pattern centre even when a caption band is appended below. if center_marks: length = mark_len_mm if mark_len_mm is not None else CENTER_MARK_QUIET_FRACTION * result.margin_mm out.extend(_center_marks(pattern_w, pattern_h, length, mark_width_mm)) if caption_lines: out.add(Line(0, pattern_h, pattern_w, pattern_h, 0.15, "black")) line_pitch = caption_font_mm * CAPTION_LINE_SPACING y = pattern_h + CAPTION_PAD_MM for ln in caption_lines: out.add(Text(CAPTION_PAD_MM, y, ln, caption_font_mm, "start", "top")) y += line_pitch return out def _center_marks(w: float, h: float, length: float, width: float) -> list[Line]: """Four inward ticks at the midpoints of the rectangle ``[0,w] x [0,h]``.""" cx, cy = w / 2.0, h / 2.0 return [ Line(cx, 0, cx, length, width), # top Line(cx, h - length, cx, h, width), # bottom Line(0, cy, length, cy, width), # left Line(w - length, cy, w, cy, width), # right ] def _cut_rect_lines(w: float, h: float, width: float) -> list[Line]: """Solid rectangle outline along the outer edge of ``[0,w] x [0,h]``. The stroke is inset by half its width so the whole line stays inside the canvas. It marks where to trim the printout; sitting on the pattern+margin boundary, it never enters the quiet zone (which is the margin itself).""" half = width / 2.0 x0, y0, x1, y1 = half, half, w - half, h - half return [ Line(x0, y0, x1, y0, width), # top Line(x0, y1, x1, y1, width), # bottom Line(x0, y0, x0, y1, width), # left Line(x1, y0, x1, y1, width), # right ]