From e9c8b17f091d135b2a345afa3d89ef710ac2a2bd Mon Sep 17 00:00:00 2001 From: lgv Date: Tue, 17 Mar 2026 15:36:02 +0800 Subject: [PATCH] feat:add tag gene --- .idea/grpc_client.iml | 2 +- .idea/misc.xml | 2 +- requirements.txt | 3 + .../generate_apriltag_single.cpython-313.pyc | Bin 0 -> 12664 bytes scripts/apriltag_id0_A4.pdf | Bin 0 -> 5195 bytes scripts/apriltags_border_20x15cm.png | Bin 0 -> 23580 bytes scripts/apriltags_border_20x15cm_A4.pdf | Bin 0 -> 6786 bytes scripts/generate_apriltag_single.py | 272 +++++++++++++ scripts/generate_apriltags_border.py | 384 ++++++++++++++++++ 9 files changed, 661 insertions(+), 2 deletions(-) create mode 100644 scripts/__pycache__/generate_apriltag_single.cpython-313.pyc create mode 100644 scripts/apriltag_id0_A4.pdf create mode 100644 scripts/apriltags_border_20x15cm.png create mode 100644 scripts/apriltags_border_20x15cm_A4.pdf create mode 100644 scripts/generate_apriltag_single.py create mode 100644 scripts/generate_apriltags_border.py 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 0000000000000000000000000000000000000000..c1d98700e662138240ea49261351aafdf06b3539 GIT binary patch literal 12664 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 literal 0 HcmV?d00001 diff --git a/scripts/apriltag_id0_A4.pdf b/scripts/apriltag_id0_A4.pdf new file mode 100644 index 0000000000000000000000000000000000000000..8da74953d6fc80488ad66a06579184cc38db7022 GIT binary patch literal 5195 zcmeHLO>7%Q6t>Erv~duJMjV-oX_6+M-SzI;jzT2Ij)O@|T{~4Hq6)3;v9qnc>#iq_ z3#te$;ZOoXD9H&`G!i1EkV7OO6ck9f1zANPBqUnIk7$t!DnIQFVcx9m*okxM$p^0` zujkFYH{W~jn_a)>hy;SYf>Yug?_EQeuW$n5kr6-6_4gw@b~;B8_N!FQ7-@tfYMSJc z2n(WUV1Offk}dSqDFh9jBA2xQj~|L3Ckcw&<>63)4#%iU36jc!h)O9jbtJPPhhvj! zQq$8&smz*2B1R~hfNcXofI>nmcO&v4}upkSr$a(kp!p5<8>=8x7#bjhS(3Tg1tQQmi2wQ zZoo)j%Jt!4)75y655umniLND}5qo|;6>a)md)nGW@SIr?si+CJC&YO!PV#ns0b zQxB$si;KaRf4jCcHk+I6f8flpfA&sX`l4;)d~v^h=8?_K6Nk-}v+M6%JUg;Jh>oI1 zZSS{rKg6MkJx31tS>P*{TMf-)riF&UX(jEVYJB6phmI)|v5-o_OJUpp}?rrs?gK)iq<{`M>}D;oy-_>m)Vn zgM@srwRHQZ`CI>eca{IJ{Z{Lt*UZm+bEf;;KR@@i-b&5;Qa(Mj@I60${jpE*1u@e4 z7tzGeldX-*nX9GEwhLp9ORYb9P5OqOzqEBT);dN_dyMA4?Y_rii>ZCS{_MGnzlwVl z>(zqWB}>*ct9n_xVQ2MQ?&RPREt!X6)@qqV%T0|bR;kt%;ZO$Gl>N|{V1;RCSf#m) zK{Hw$opL$7PN@e?Qku*6VVvEbavEm3i^CDqNKPf7&rz?HOQuj`Il%J=y6V6cOp@YwBq$ueeIZ4bT{23cZJ7W)19l<3 zDwAY4v}Jb6c-aH4?vg2dgD$Tqd&oS{g4m_&=ApB+OU5gWd$?rC&#ray9_z$C?8#E2 zJ+}lcsGasCS#H20DXzL0P*c@1#I(A8G3_}5F-16P7>o>7uPUUc3}lhPYHsDJYEmow OlH03r4#&`;Aoo9ngfnLV literal 0 HcmV?d00001 diff --git a/scripts/apriltags_border_20x15cm.png b/scripts/apriltags_border_20x15cm.png new file mode 100644 index 0000000000000000000000000000000000000000..02dc61be32b416e0bf47de0812bbdafb83255d3f GIT binary patch literal 23580 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 a*RqwLfmQYwq*BjA$Nz>BSM%%tJo3LyweW2K literal 0 HcmV?d00001 diff --git a/scripts/apriltags_border_20x15cm_A4.pdf b/scripts/apriltags_border_20x15cm_A4.pdf new file mode 100644 index 0000000000000000000000000000000000000000..d405961c6bc6a4c141725ab7f0d7a63f120a085c GIT binary patch literal 6786 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 literal 0 HcmV?d00001 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())