diff --git a/.idea/grpc_client.iml b/.idea/grpc_client.iml
index 1675d09..9c1850b 100644
--- a/.idea/grpc_client.iml
+++ b/.idea/grpc_client.iml
@@ -5,7 +5,7 @@
-
+
\ No newline at end of file
diff --git a/.idea/misc.xml b/.idea/misc.xml
index accb38f..6ce4790 100644
--- a/.idea/misc.xml
+++ b/.idea/misc.xml
@@ -3,5 +3,5 @@
-
+
\ No newline at end of file
diff --git a/requirements.txt b/requirements.txt
index fe040b0..0f18092 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -8,3 +8,6 @@ PyQt5==5.15.11
requests==2.32.5
yourdfpy
uvicorn==0.38.0
+
+numpy==1.26.4
+opencv-contrib-python==4.10.0.84
diff --git a/scripts/__pycache__/generate_apriltag_single.cpython-313.pyc b/scripts/__pycache__/generate_apriltag_single.cpython-313.pyc
new file mode 100644
index 0000000..c1d9870
Binary files /dev/null and b/scripts/__pycache__/generate_apriltag_single.cpython-313.pyc differ
diff --git a/scripts/apriltag_id0_A4.pdf b/scripts/apriltag_id0_A4.pdf
new file mode 100644
index 0000000..8da7495
Binary files /dev/null and b/scripts/apriltag_id0_A4.pdf differ
diff --git a/scripts/apriltags_border_20x15cm.png b/scripts/apriltags_border_20x15cm.png
new file mode 100644
index 0000000..02dc61b
Binary files /dev/null and b/scripts/apriltags_border_20x15cm.png differ
diff --git a/scripts/apriltags_border_20x15cm_A4.pdf b/scripts/apriltags_border_20x15cm_A4.pdf
new file mode 100644
index 0000000..d405961
Binary files /dev/null and b/scripts/apriltags_border_20x15cm_A4.pdf differ
diff --git a/scripts/generate_apriltag_single.py b/scripts/generate_apriltag_single.py
new file mode 100644
index 0000000..8ce3bae
--- /dev/null
+++ b/scripts/generate_apriltag_single.py
@@ -0,0 +1,272 @@
+#!/usr/bin/env python3
+"""Generate a single AprilTag image with configurable id and physical size."""
+
+import argparse
+import os
+from pathlib import Path
+import sys
+from typing import Tuple
+
+try:
+ import numpy as np
+except ImportError as exc:
+ raise SystemExit(
+ "Missing dependency 'numpy'. Install it with: pip install numpy"
+ ) from exc
+
+try:
+ import cv2
+except ImportError as exc:
+ raise SystemExit(
+ "Missing dependency 'opencv-contrib-python'. Install it with: pip install opencv-contrib-python"
+ ) from exc
+
+# Force non-GUI backend to avoid Qt/xcb plugin issues in headless environments.
+os.environ.setdefault("MPLBACKEND", "Agg")
+try:
+ import matplotlib
+
+ matplotlib.use("Agg", force=True)
+ import matplotlib.pyplot as plt
+except ImportError as exc:
+ raise SystemExit(
+ "Missing dependency 'matplotlib'. Install it with: pip install matplotlib"
+ ) from exc
+
+
+APRILTAG_FAMILIES = {
+ "tag16h5": ("DICT_APRILTAG_16h5", 30),
+ "tag25h9": ("DICT_APRILTAG_25h9", 35),
+ "tag36h10": ("DICT_APRILTAG_36h10", 2320),
+ "tag36h11": ("DICT_APRILTAG_36h11", 587),
+}
+
+A4_WIDTH_CM = 21.0
+A4_HEIGHT_CM = 29.7
+
+
+def cm_to_px(cm: float, dpi: int) -> int:
+ return int(round(cm / 2.54 * dpi))
+
+
+def parse_ratio_text(value: str) -> float:
+ text = value.strip()
+ if not text:
+ raise ValueError("empty ratio")
+ if ":" in text:
+ parts = text.split(":")
+ if len(parts) != 2:
+ raise ValueError(f"invalid ratio '{value}', expected like 1:4")
+ left = float(parts[0].strip())
+ right = float(parts[1].strip())
+ if left < 0 or right <= 0:
+ raise ValueError(f"invalid ratio '{value}', left must be >= 0 and right > 0")
+ return left / right
+
+ ratio = float(text)
+ if ratio < 0:
+ raise ValueError(f"invalid ratio '{value}', must be >= 0")
+ return ratio
+
+
+def resolve_aruco_dict(family: str) -> Tuple["cv2.aruco_Dictionary", int]:
+ if family not in APRILTAG_FAMILIES:
+ supported = ", ".join(sorted(APRILTAG_FAMILIES))
+ raise ValueError(f"Unsupported family '{family}'. Supported: {supported}")
+
+ dict_name, max_tags = APRILTAG_FAMILIES[family]
+ dict_id = getattr(cv2.aruco, dict_name, None)
+ if dict_id is None:
+ raise RuntimeError(
+ f"OpenCV build does not include {dict_name}. "
+ "Please install opencv-contrib-python."
+ )
+
+ return cv2.aruco.getPredefinedDictionary(dict_id), max_tags
+
+
+def make_marker(aruco_dict: "cv2.aruco_Dictionary", marker_id: int, size_px: int) -> np.ndarray:
+ if hasattr(cv2.aruco, "generateImageMarker"):
+ marker = cv2.aruco.generateImageMarker(aruco_dict, marker_id, size_px)
+ if marker is not None:
+ return marker
+
+ marker = np.zeros((size_px, size_px), dtype=np.uint8)
+ cv2.aruco.drawMarker(aruco_dict, marker_id, size_px, marker, 1)
+ return marker
+
+
+def save_printable_png(output_path: Path, canvas: np.ndarray, width_cm: float, height_cm: float, dpi: int) -> None:
+ fig = plt.figure(figsize=(width_cm / 2.54, height_cm / 2.54), dpi=dpi, frameon=False)
+ ax = fig.add_axes([0, 0, 1, 1])
+ ax.imshow(canvas, cmap="gray", vmin=0, vmax=255, interpolation="nearest")
+ ax.axis("off")
+ fig.savefig(output_path, dpi=dpi, pad_inches=0)
+ plt.close(fig)
+
+
+def save_a4_pdf(
+ output_path: Path,
+ canvas: np.ndarray,
+ width_cm: float,
+ height_cm: float,
+ dpi: int,
+ landscape: bool,
+) -> None:
+ if landscape:
+ page_w_cm = A4_HEIGHT_CM
+ page_h_cm = A4_WIDTH_CM
+ else:
+ page_w_cm = A4_WIDTH_CM
+ page_h_cm = A4_HEIGHT_CM
+
+ if width_cm > page_w_cm + 1e-9 or height_cm > page_h_cm + 1e-9:
+ raise SystemExit(
+ f"Tag size {width_cm}x{height_cm} cm does not fit A4 "
+ f"({'landscape' if landscape else 'portrait'}) {page_w_cm}x{page_h_cm} cm."
+ )
+
+ fig = plt.figure(figsize=(page_w_cm / 2.54, page_h_cm / 2.54), dpi=dpi, frameon=False)
+ x0 = (page_w_cm - width_cm) / 2.0 / page_w_cm
+ y0 = (page_h_cm - height_cm) / 2.0 / page_h_cm
+ w = width_cm / page_w_cm
+ h = height_cm / page_h_cm
+ ax = fig.add_axes([x0, y0, w, h])
+ ax.imshow(canvas, cmap="gray", vmin=0, vmax=255, interpolation="nearest")
+ ax.axis("off")
+ fig.savefig(output_path, format="pdf", dpi=dpi, pad_inches=0)
+ plt.close(fig)
+
+
+def parse_args() -> argparse.Namespace:
+ parser = argparse.ArgumentParser(description="Generate single AprilTag image")
+ parser.add_argument("--id", type=int, default=0, help="AprilTag ID (default: 0)")
+ parser.add_argument(
+ "--size-cm",
+ "--tag-size-cm",
+ dest="tag_size_cm",
+ type=float,
+ default=15,
+ help="AprilTag marker side length in cm (excluding per-tag white border)",
+ )
+ parser.add_argument(
+ "--family",
+ type=str,
+ default="tag36h11",
+ choices=sorted(APRILTAG_FAMILIES.keys()),
+ help="AprilTag family",
+ )
+ parser.add_argument(
+ "--tag-white-ratio",
+ type=str,
+ default="1:4",
+ help="Total white border to marker ratio (e.g. 1:4 means total white width is 1/4 of marker size)",
+ )
+ parser.add_argument(
+ "--tag-white-border-cm",
+ type=float,
+ default=None,
+ help="Optional override: white margin on each side in cm; if omitted, auto-computed from --tag-white-ratio",
+ )
+ parser.add_argument(
+ "--margin-cm",
+ type=float,
+ default=0.0,
+ help="Extra white margin around the full tag tile (cm)",
+ )
+ parser.add_argument("--dpi", type=int, default=300, help="Output DPI")
+ parser.add_argument(
+ "--a4-landscape",
+ action="store_true",
+ help="When output is PDF, use A4 landscape instead of portrait",
+ )
+ parser.add_argument(
+ "--output",
+ type=Path,
+ default=Path("apriltag_id0_A4.pdf"),
+ help="Output path (.pdf for centered A4 page, .png for exact-size image)",
+ )
+ return parser.parse_args()
+
+
+def main() -> int:
+ args = parse_args()
+
+ if args.id < 0:
+ raise SystemExit("id must be >= 0")
+ if args.tag_size_cm <= 0:
+ raise SystemExit("size-cm/tag-size-cm must be > 0")
+ if args.margin_cm < 0:
+ raise SystemExit("margin-cm must be >= 0")
+
+ if args.tag_white_border_cm is None:
+ try:
+ tag_white_ratio = parse_ratio_text(args.tag_white_ratio)
+ except ValueError as exc:
+ raise SystemExit(f"tag-white-ratio parse error: {exc}") from exc
+ tag_white_border_cm = args.tag_size_cm * tag_white_ratio / 2.0
+ else:
+ if args.tag_white_border_cm < 0:
+ raise SystemExit("tag-white-border-cm must be >= 0")
+ tag_white_border_cm = args.tag_white_border_cm
+ tag_white_ratio = 2.0 * tag_white_border_cm / args.tag_size_cm
+
+ tag_tile_size_cm = args.tag_size_cm + 2.0 * tag_white_border_cm
+ canvas_size_cm = tag_tile_size_cm + 2.0 * args.margin_cm
+
+ aruco_dict, max_tags = resolve_aruco_dict(args.family)
+ if args.id >= max_tags:
+ raise SystemExit(
+ f"Tag ID {args.id} exceeds family max ({max_tags - 1}) for family {args.family}."
+ )
+
+ canvas_px = cm_to_px(canvas_size_cm, args.dpi)
+ tag_tile_px = cm_to_px(tag_tile_size_cm, args.dpi)
+ marker_px = cm_to_px(args.tag_size_cm, args.dpi)
+
+ if canvas_px <= 0 or tag_tile_px <= 0 or marker_px <= 0:
+ raise SystemExit("At current DPI, tag size is too small to draw")
+ if canvas_px < tag_tile_px:
+ raise SystemExit("Canvas size is smaller than the tag tile")
+
+ canvas = np.full((canvas_px, canvas_px), 255, dtype=np.uint8)
+
+ tile_x_px = cm_to_px(args.margin_cm, args.dpi)
+ tile_y_px = cm_to_px(args.margin_cm, args.dpi)
+ tile_x_px = min(max(tile_x_px, 0), canvas_px - tag_tile_px)
+ tile_y_px = min(max(tile_y_px, 0), canvas_px - tag_tile_px)
+
+ marker_x_px = cm_to_px(args.margin_cm + tag_white_border_cm, args.dpi)
+ marker_y_px = cm_to_px(args.margin_cm + tag_white_border_cm, args.dpi)
+ marker_x_px = min(max(marker_x_px, tile_x_px), tile_x_px + tag_tile_px - marker_px)
+ marker_y_px = min(max(marker_y_px, tile_y_px), tile_y_px + tag_tile_px - marker_px)
+
+ marker = make_marker(aruco_dict, args.id, marker_px)
+ canvas[marker_y_px : marker_y_px + marker_px, marker_x_px : marker_x_px + marker_px] = marker
+
+ args.output.parent.mkdir(parents=True, exist_ok=True)
+ if args.output.suffix.lower() == ".pdf":
+ save_a4_pdf(
+ args.output,
+ canvas,
+ canvas_size_cm,
+ canvas_size_cm,
+ args.dpi,
+ landscape=args.a4_landscape,
+ )
+ else:
+ save_printable_png(args.output, canvas, canvas_size_cm, canvas_size_cm, args.dpi)
+
+ print(f"Saved single tag image: {args.output}")
+ print(
+ f"family={args.family}, id={args.id}, marker={args.tag_size_cm}cm, "
+ f"tag_white_ratio={round(tag_white_ratio, 6)}, "
+ f"tag_white_border={round(tag_white_border_cm, 6)}cm, "
+ f"tag_tile={round(tag_tile_size_cm, 6)}cm, "
+ f"margin={args.margin_cm}cm, canvas={round(canvas_size_cm, 6)}cm"
+ )
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/scripts/generate_apriltags_border.py b/scripts/generate_apriltags_border.py
new file mode 100644
index 0000000..d754c30
--- /dev/null
+++ b/scripts/generate_apriltags_border.py
@@ -0,0 +1,384 @@
+#!/usr/bin/env python3
+"""Generate an AprilTag border around a rectangle and save a printable image."""
+
+import argparse
+import csv
+import math
+import os
+from pathlib import Path
+import sys
+from typing import List, Tuple
+
+try:
+ import numpy as np
+except ImportError as exc:
+ raise SystemExit(
+ "Missing dependency 'numpy'. Install it with: pip install numpy"
+ ) from exc
+
+try:
+ import cv2
+except ImportError as exc:
+ raise SystemExit(
+ "Missing dependency 'opencv-contrib-python'. Install it with: pip install opencv-contrib-python"
+ ) from exc
+
+# Force non-GUI backend to avoid Qt/xcb plugin issues in headless environments.
+os.environ.setdefault("MPLBACKEND", "Agg")
+try:
+ import matplotlib
+ matplotlib.use("Agg", force=True)
+ import matplotlib.pyplot as plt
+except ImportError as exc:
+ raise SystemExit(
+ "Missing dependency 'matplotlib'. Install it with: pip install matplotlib"
+ ) from exc
+
+
+APRILTAG_FAMILIES = {
+ "tag16h5": ("DICT_APRILTAG_16h5", 30),
+ "tag25h9": ("DICT_APRILTAG_25h9", 35),
+ "tag36h10": ("DICT_APRILTAG_36h10", 2320),
+ "tag36h11": ("DICT_APRILTAG_36h11", 587),
+}
+
+
+A4_WIDTH_CM = 29.7
+A4_HEIGHT_CM = 21.0
+# A4_HEIGHT_CM = 29.7
+
+
+def cm_to_px(cm: float, dpi: int) -> int:
+ return int(round(cm / 2.54 * dpi))
+
+
+def parse_ratio_text(value: str) -> float:
+ text = value.strip()
+ if not text:
+ raise ValueError("empty ratio")
+ if ":" in text:
+ parts = text.split(":")
+ if len(parts) != 2:
+ raise ValueError(f"invalid ratio '{value}', expected like 1:4")
+ left = float(parts[0].strip())
+ right = float(parts[1].strip())
+ if left < 0 or right <= 0:
+ raise ValueError(f"invalid ratio '{value}', left must be >= 0 and right > 0")
+ return left / right
+ ratio = float(text)
+ if ratio < 0:
+ raise ValueError(f"invalid ratio '{value}', must be >= 0")
+ return ratio
+
+
+def border_positions(total_cm: float, tag_cm: float, pitch_cm: float) -> List[float]:
+ if total_cm + 1e-9 < tag_cm:
+ raise ValueError("rectangle side is smaller than one tag")
+
+ count = int(math.floor((total_cm - tag_cm) / pitch_cm)) + 1
+ count = max(1, count)
+ used_cm = tag_cm + (count - 1) * pitch_cm
+ margin_cm = (total_cm - used_cm) / 2.0
+ return [margin_cm + i * pitch_cm for i in range(count)]
+
+
+def side_positions_excluding_corners(total_cm: float, tag_cm: float, pitch_cm: float) -> List[float]:
+ inner_cm = total_cm - 2.0 * tag_cm
+ if inner_cm + 1e-9 < tag_cm:
+ return []
+
+ count = int(math.floor((inner_cm - tag_cm) / pitch_cm)) + 1
+ count = max(1, count)
+ used_cm = tag_cm + (count - 1) * pitch_cm
+ margin_cm = (inner_cm - used_cm) / 2.0
+ start_cm = tag_cm + margin_cm
+ return [start_cm + i * pitch_cm for i in range(count)]
+
+
+def resolve_aruco_dict(family: str) -> Tuple["cv2.aruco_Dictionary", int]:
+ if family not in APRILTAG_FAMILIES:
+ supported = ", ".join(sorted(APRILTAG_FAMILIES))
+ raise ValueError(f"Unsupported family '{family}'. Supported: {supported}")
+
+ dict_name, max_tags = APRILTAG_FAMILIES[family]
+ dict_id = getattr(cv2.aruco, dict_name, None)
+ if dict_id is None:
+ raise RuntimeError(
+ f"OpenCV build does not include {dict_name}. "
+ "Please install opencv-contrib-python."
+ )
+
+ return cv2.aruco.getPredefinedDictionary(dict_id), max_tags
+
+
+def make_marker(aruco_dict: "cv2.aruco_Dictionary", marker_id: int, size_px: int) -> np.ndarray:
+ if hasattr(cv2.aruco, "generateImageMarker"):
+ marker = cv2.aruco.generateImageMarker(aruco_dict, marker_id, size_px)
+ if marker is not None:
+ return marker
+
+ marker = np.zeros((size_px, size_px), dtype=np.uint8)
+ cv2.aruco.drawMarker(aruco_dict, marker_id, size_px, marker, 1)
+ return marker
+
+
+def save_printable_png(output_path: Path, canvas: np.ndarray, width_cm: float, height_cm: float, dpi: int) -> None:
+ fig = plt.figure(figsize=(width_cm / 2.54, height_cm / 2.54), dpi=dpi, frameon=False)
+ ax = fig.add_axes([0, 0, 1, 1])
+ ax.imshow(canvas, cmap="gray", vmin=0, vmax=255, interpolation="nearest")
+ ax.axis("off")
+ fig.savefig(output_path, dpi=dpi, pad_inches=0)
+ plt.close(fig)
+
+
+def save_a4_pdf(
+ output_path: Path,
+ canvas: np.ndarray,
+ width_cm: float,
+ height_cm: float,
+ dpi: int,
+ landscape: bool,
+) -> None:
+ if landscape:
+ page_w_cm = A4_HEIGHT_CM
+ page_h_cm = A4_WIDTH_CM
+ else:
+ page_w_cm = A4_WIDTH_CM
+ page_h_cm = A4_HEIGHT_CM
+
+ if width_cm > page_w_cm + 1e-9 or height_cm > page_h_cm + 1e-9:
+ raise SystemExit(
+ f"Pattern size {width_cm}x{height_cm} cm does not fit A4 "
+ f"({'landscape' if landscape else 'portrait'}) {page_w_cm}x{page_h_cm} cm."
+ )
+
+ fig = plt.figure(figsize=(page_w_cm / 2.54, page_h_cm / 2.54), dpi=dpi, frameon=False)
+ x0 = (page_w_cm - width_cm) / 2.0 / page_w_cm
+ y0 = (page_h_cm - height_cm) / 2.0 / page_h_cm
+ w = width_cm / page_w_cm
+ h = height_cm / page_h_cm
+ ax = fig.add_axes([x0, y0, w, h])
+ ax.imshow(canvas, cmap="gray", vmin=0, vmax=255, interpolation="nearest")
+ ax.axis("off")
+ fig.savefig(output_path, format="pdf", dpi=dpi, pad_inches=0)
+ plt.close(fig)
+
+
+def write_layout_csv(path: Path, rows: List[Tuple[int, float, float, int, int]]) -> None:
+ with path.open("w", newline="", encoding="utf-8") as f:
+ writer = csv.writer(f)
+ writer.writerow(["tag_id", "x_cm", "y_cm", "x_px", "y_px"])
+ for row in rows:
+ writer.writerow(row)
+
+
+def parse_args() -> argparse.Namespace:
+ parser = argparse.ArgumentParser(description="Generate AprilTag border image")
+ parser.add_argument("--width-cm", type=float, default=26.0, help="Rectangle width in cm")
+ parser.add_argument("--height-cm", type=float, default=17.0, help="Rectangle height in cm")
+ parser.add_argument(
+ "--tag-size-cm",
+ type=float,
+ default=2.0,
+ help="AprilTag marker side length in cm (excluding per-tag white border)",
+ )
+ parser.add_argument(
+ "--pitch-cm",
+ type=float,
+ default=None,
+ help="Distance between adjacent tag tile top-left positions in cm (default: auto = tag tile size)",
+ )
+ parser.add_argument(
+ "--family",
+ type=str,
+ default="tag36h11",
+ choices=sorted(APRILTAG_FAMILIES.keys()),
+ help="AprilTag family",
+ )
+ parser.add_argument("--start-id", type=int, default=0, help="First tag ID")
+ parser.add_argument(
+ "--wrap-ids",
+ action="store_true",
+ help="Wrap around if IDs exceed family max",
+ )
+ parser.add_argument(
+ "--white-border-cm",
+ type=float,
+ default=0.0,
+ help="Keep this much white margin on all four sides (cm)",
+ )
+ parser.add_argument(
+ "--tag-white-ratio",
+ type=str,
+ default="1:4",
+ help="Total white border to marker ratio (e.g. 1:4 means total white width is 1/4 of marker size)",
+ )
+ parser.add_argument(
+ "--tag-white-border-cm",
+ type=float,
+ default=None,
+ help="Optional override: white margin on each side in cm; if omitted, auto-computed from --tag-white-ratio",
+ )
+ parser.add_argument("--dpi", type=int, default=300, help="Output DPI")
+ parser.add_argument(
+ "--a4-landscape",
+ action="store_true",
+ help="When output is PDF, use A4 landscape instead of portrait",
+ )
+ parser.add_argument(
+ "--output",
+ type=Path,
+ default=Path("apriltags_border_20x15cm_A4.pdf"),
+ help="Output path (.pdf for A4 page, .png for exact-size image)",
+ )
+ parser.add_argument(
+ "--layout-csv",
+ type=Path,
+ default=None,
+ help="Optional CSV path to save tag placements",
+ )
+ return parser.parse_args()
+
+
+def main() -> int:
+ args = parse_args()
+
+ if args.width_cm <= 0 or args.height_cm <= 0 or args.tag_size_cm <= 0:
+ raise SystemExit("width/height/tag-size must all be > 0")
+ if args.white_border_cm < 0:
+ raise SystemExit("white-border-cm must be >= 0")
+
+ if args.tag_white_border_cm is None:
+ try:
+ tag_white_ratio = parse_ratio_text(args.tag_white_ratio)
+ except ValueError as exc:
+ raise SystemExit(f"tag-white-ratio parse error: {exc}") from exc
+ tag_white_border_cm = args.tag_size_cm * tag_white_ratio / 2.0
+ else:
+ if args.tag_white_border_cm < 0:
+ raise SystemExit("tag-white-border-cm must be >= 0")
+ tag_white_border_cm = args.tag_white_border_cm
+ tag_white_ratio = 2.0 * tag_white_border_cm / args.tag_size_cm
+
+ tag_tile_size_cm = args.tag_size_cm + 2.0 * tag_white_border_cm
+ pitch_cm = tag_tile_size_cm if args.pitch_cm is None else args.pitch_cm
+ if pitch_cm <= 0:
+ raise SystemExit("pitch-cm must be > 0")
+ if pitch_cm + 1e-9 < tag_tile_size_cm:
+ raise SystemExit(
+ f"pitch-cm ({pitch_cm}) is smaller than tag-size-cm + 2*tag-white-border-cm "
+ f"({tag_tile_size_cm}). Increase pitch-cm to keep white border around each tag."
+ )
+
+ inner_width_cm = args.width_cm - 2.0 * args.white_border_cm
+ inner_height_cm = args.height_cm - 2.0 * args.white_border_cm
+
+ if inner_width_cm + 1e-9 < tag_tile_size_cm or inner_height_cm + 1e-9 < tag_tile_size_cm:
+ raise SystemExit(
+ "After white border is reserved, inner area must still fit at least one full tag tile in both dimensions"
+ )
+
+ aruco_dict, max_tags = resolve_aruco_dict(args.family)
+
+ width_px = cm_to_px(args.width_cm, args.dpi)
+ height_px = cm_to_px(args.height_cm, args.dpi)
+ tag_tile_px = cm_to_px(tag_tile_size_cm, args.dpi)
+ marker_px = cm_to_px(args.tag_size_cm, args.dpi)
+
+ if width_px < tag_tile_px or height_px < tag_tile_px:
+ raise SystemExit("At current DPI, tag tile is larger than the canvas")
+ if marker_px <= 0:
+ raise SystemExit("At current DPI, tag-size-cm leaves no drawable marker pixels")
+
+ canvas = np.full((height_px, width_px), 255, dtype=np.uint8)
+
+ top_x_local = border_positions(inner_width_cm, tag_tile_size_cm, pitch_cm)
+ side_y_local = side_positions_excluding_corners(inner_height_cm, tag_tile_size_cm, pitch_cm)
+
+ placements_cm: List[Tuple[float, float]] = []
+
+ # Clockwise: top -> right -> bottom -> left
+ top_y = args.white_border_cm
+ left_x = args.white_border_cm
+ right_x = args.white_border_cm + inner_width_cm - tag_tile_size_cm
+ bottom_y = args.white_border_cm + inner_height_cm - tag_tile_size_cm
+
+ for x_local in top_x_local:
+ placements_cm.append((args.white_border_cm + x_local, top_y))
+ for y_local in side_y_local:
+ placements_cm.append((right_x, args.white_border_cm + y_local))
+ for x_local in reversed(top_x_local):
+ placements_cm.append((args.white_border_cm + x_local, bottom_y))
+ for y_local in reversed(side_y_local):
+ placements_cm.append((left_x, args.white_border_cm + y_local))
+
+ placements_csv: List[Tuple[int, float, float, int, int]] = []
+ current_id = args.start_id
+
+ for x_cm, y_cm in placements_cm:
+ if current_id >= max_tags:
+ if args.wrap_ids:
+ tag_id = current_id % max_tags
+ else:
+ raise SystemExit(
+ f"Tag ID {current_id} exceeds family max ({max_tags - 1}). "
+ "Use --wrap-ids or lower --start-id."
+ )
+ else:
+ tag_id = current_id
+
+ x_px = cm_to_px(x_cm, args.dpi)
+ y_px = cm_to_px(y_cm, args.dpi)
+
+ # Clamp to keep full tag tile in canvas despite rounding.
+ x_px = min(max(x_px, 0), width_px - tag_tile_px)
+ y_px = min(max(y_px, 0), height_px - tag_tile_px)
+
+ marker = make_marker(aruco_dict, tag_id, marker_px)
+
+ marker_x_px = cm_to_px(x_cm + tag_white_border_cm, args.dpi)
+ marker_y_px = cm_to_px(y_cm + tag_white_border_cm, args.dpi)
+
+ # Keep marker fully inside its tag tile despite rounding.
+ marker_x_px = min(max(marker_x_px, x_px), x_px + tag_tile_px - marker_px)
+ marker_y_px = min(max(marker_y_px, y_px), y_px + tag_tile_px - marker_px)
+
+ canvas[marker_y_px : marker_y_px + marker_px, marker_x_px : marker_x_px + marker_px] = marker
+ placements_csv.append((tag_id, round(x_cm, 4), round(y_cm, 4), x_px, y_px))
+
+ current_id += 1
+
+ args.output.parent.mkdir(parents=True, exist_ok=True)
+ if args.output.suffix.lower() == ".pdf":
+ save_a4_pdf(
+ args.output,
+ canvas,
+ args.width_cm,
+ args.height_cm,
+ args.dpi,
+ landscape=args.a4_landscape,
+ )
+ else:
+ save_printable_png(args.output, canvas, args.width_cm, args.height_cm, args.dpi)
+
+ if args.layout_csv is not None:
+ args.layout_csv.parent.mkdir(parents=True, exist_ok=True)
+ write_layout_csv(args.layout_csv, placements_csv)
+
+ print(f"Saved border image: {args.output}")
+ print(
+ f"family={args.family}, tags={len(placements_csv)}, "
+ f"size={args.width_cm}cm x {args.height_cm}cm, tag_marker={args.tag_size_cm}cm, "
+ f"pitch={round(pitch_cm, 6)}cm, white_border={args.white_border_cm}cm, "
+ f"tag_white_ratio={round(tag_white_ratio, 6)}, "
+ f"tag_white_border={round(tag_white_border_cm, 6)}cm, "
+ f"tag_tile={round(tag_tile_size_cm, 6)}cm, output={args.output.suffix.lower()}"
+ )
+ if args.layout_csv is not None:
+ print(f"Saved layout CSV: {args.layout_csv}")
+
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())