Files
appRobotRender/setup/generateTabletopPDF/a3_aruco.py
2026-06-10 07:13:19 +02:00

417 lines
12 KiB
Python

#!/usr/bin/env python3
from __future__ import annotations
import json
import random
from dataclasses import dataclass
from pathlib import Path
from typing import List, Tuple
from reportlab.pdfgen import canvas
from reportlab.lib.units import mm
from reportlab.pdfbase import pdfmetrics
try:
import cv2
except ImportError as exc:
raise SystemExit(
"OpenCV ist erforderlich. Installiere es mit: pip install opencv-contrib-python"
) from exc
# =========================
# Header / Parameter
# =========================
PAGE_FORMAT = "A3" # z.B. A0, A1, A2, A3, A4
ORIENTATION = "portrait" # "portrait" oder "landscape"
NUM_ARUCOS = 10
ARUCO_SIZE_MM = 50.0
ARUCO_START_ID = 46 # erster Marker aus DICT_4X4_250
SEED = 224 # Zufalls-Seed für reproduzierbare Verteilung
PAGE_BORDER_MARGIN_MM = 10.0 # Abstand aller Marker vom Seitenrand
FORBIDDEN_RECT_W_MM = 10.0
FORBIDDEN_RECT_H_MM = 10.0
FORBIDDEN_RECT_MARGIN_MM = 30.0 # keine ArUcos innerhalb dieses Abstands
LINE_WIDTH_MM = 1.0 # Linienstärke des Rechtecks
TEXT_FONT = "Times-Roman"
TEXT_SIZE_PT = 8
TEXT_GAP_MM = 4.0
OUTPUT_BASENAME = f"{PAGE_FORMAT}_{NUM_ARUCOS}Arucos_{int(ARUCO_SIZE_MM)}mm_Seet{SEED}"
# =========================
# DIN-Formate
# =========================
DIN_SIZES_MM = {
"A0": (841.0, 1189.0),
"A1": (594.0, 841.0),
"A2": (420.0, 594.0),
"A3": (297.0, 420.0),
"A4": (210.0, 297.0),
}
@dataclass(frozen=True)
class RectMM:
x: float
y: float
w: float
h: float
def intersects(self, other: "RectMM") -> bool:
return not (
self.x + self.w <= other.x
or other.x + other.w <= self.x
or self.y + self.h <= other.y
or other.y + other.h <= self.y
)
def mm_to_pt(value_mm: float) -> float:
return value_mm * mm
def get_page_size_mm(page_format: str, orientation: str) -> Tuple[float, float]:
if page_format not in DIN_SIZES_MM:
raise ValueError(f"Unbekanntes Format: {page_format}. Unterstützt: {sorted(DIN_SIZES_MM)}")
w_mm, h_mm = DIN_SIZES_MM[page_format]
if orientation.lower() == "portrait":
return w_mm, h_mm
if orientation.lower() == "landscape":
return h_mm, w_mm
raise ValueError("ORIENTATION muss 'portrait' oder 'landscape' sein.")
def centered_rect(page_w_mm: float, page_h_mm: float, rect_w_mm: float, rect_h_mm: float) -> RectMM:
return RectMM(
x=(page_w_mm - rect_w_mm) / 2.0,
y=(page_h_mm - rect_h_mm) / 2.0,
w=rect_w_mm,
h=rect_h_mm,
)
def expand_rect(rect: RectMM, margin_mm: float) -> RectMM:
return RectMM(
x=rect.x - margin_mm,
y=rect.y - margin_mm,
w=rect.w + 2.0 * margin_mm,
h=rect.h + 2.0 * margin_mm,
)
def get_aruco_dictionary():
# DICT_4X4_250 hat IDs 0..249
return cv2.aruco.getPredefinedDictionary(cv2.aruco.DICT_4X4_250)
def marker_module_pattern(marker_id: int) -> List[List[int]]:
"""
Liefert ein 6x6-Raster (inkl. schwarzem Rand).
1 = schwarz, 0 = weiss
"""
aruco_dict = get_aruco_dictionary()
img = cv2.aruco.generateImageMarker(aruco_dict, marker_id, 600) # nur zum Abtasten
modules = 6
cell = img.shape[0] // modules
pattern: List[List[int]] = []
for r in range(modules):
row: List[int] = []
for c in range(modules):
cy = int((r + 0.5) * cell)
cx = int((c + 0.5) * cell)
pixel = int(img[cy, cx]) # 0 = schwarz, 255 = weiss
row.append(1 if pixel < 128 else 0)
pattern.append(row)
return pattern
def draw_aruco_vector(
c: canvas.Canvas,
x_mm: float,
y_mm: float,
size_mm: float,
marker_id: int,
page_h_mm: float,
) -> None:
"""
Zeichnet den Marker als Vektor-Rechtecke.
x_mm, y_mm = linke obere Ecke in mm.
"""
pattern = marker_module_pattern(marker_id)
modules = len(pattern)
cell_mm = size_mm / modules
for r in range(modules):
for col in range(modules):
if pattern[r][col] == 1:
cell_x_mm = x_mm + col * cell_mm
#cell_y_mm = y_mm + (modules - 1 - r) * cell_mm
cell_y_mm = y_mm + r * cell_mm
c.rect(
mm_to_pt(cell_x_mm),
mm_to_pt(page_h_mm - cell_y_mm - cell_mm),
mm_to_pt(cell_mm),
mm_to_pt(cell_mm),
stroke=0,
fill=1,
)
def draw_aruco_label(
c: canvas.Canvas,
x_mm: float,
y_mm: float,
size_mm: float,
page_h_mm: float,
marker_id: int,
) -> None:
c.setFont(TEXT_FONT, TEXT_SIZE_PT)
text = str(marker_id)
text_width_pt = pdfmetrics.stringWidth(text, TEXT_FONT, TEXT_SIZE_PT)
text_x_pt = mm_to_pt(x_mm + size_mm / 2.0) - text_width_pt / 2.0
font = pdfmetrics.getFont(TEXT_FONT)
ascent_mm = (font.face.ascent / 1000.0) * TEXT_SIZE_PT * 0.352777777777778
text_baseline_y_mm = y_mm + size_mm + TEXT_GAP_MM + ascent_mm
c.drawString(text_x_pt, mm_to_pt(page_h_mm - text_baseline_y_mm), text)
def place_markers(
page_w_mm: float,
page_h_mm: float,
num_markers: int,
marker_size_mm: float,
border_margin_mm: float,
forbidden_area: RectMM,
forbidden_margin_mm: float,
seed: int,
) -> List[dict]:
rng = random.Random(seed)
placed: List[RectMM] = []
result: List[dict] = []
allowed_x_min = border_margin_mm
allowed_y_min = border_margin_mm
allowed_x_max = page_w_mm - border_margin_mm - marker_size_mm
allowed_y_max = page_h_mm - border_margin_mm - marker_size_mm
if allowed_x_max < allowed_x_min or allowed_y_max < allowed_y_min:
raise RuntimeError("Das Poster ist zu klein für Randabstand und Markergrösse.")
excluded = expand_rect(forbidden_area, forbidden_margin_mm)
attempts = 0
max_attempts = 200000
while len(result) < num_markers and attempts < max_attempts:
attempts += 1
x = rng.uniform(allowed_x_min, allowed_x_max)
y = rng.uniform(allowed_y_min, allowed_y_max)
candidate = RectMM(x=x, y=y, w=marker_size_mm, h=marker_size_mm)
if any(candidate.intersects(other) for other in placed):
continue
if candidate.intersects(excluded):
continue
placed.append(candidate)
result.append(
{
"id": ARUCO_START_ID + len(result),
"x_mm": round(x, 2),
"y_mm": round(y, 2),
"size_mm": marker_size_mm,
}
)
if len(result) < num_markers:
raise RuntimeError(
f"Nicht alle Marker konnten platziert werden: {len(result)} von {num_markers} "
f"(nach {attempts} Versuchen)."
)
return result
def main() -> None:
page_w_mm, page_h_mm = get_page_size_mm(PAGE_FORMAT, ORIENTATION)
if ARUCO_START_ID < 0 or ARUCO_START_ID + NUM_ARUCOS > 250:
raise ValueError("ARUCO_START_ID + NUM_ARUCOS muss in den Bereich 0..249 von DICT_4X4_250 fallen.")
forbidden_rect = centered_rect(page_w_mm, page_h_mm, FORBIDDEN_RECT_W_MM, FORBIDDEN_RECT_H_MM)
# Neues lokales Koordinatensystem
origin_x_mm = forbidden_rect.x + forbidden_rect.w - 90.0
origin_y_mm = forbidden_rect.y
placements = place_markers(
page_w_mm=page_w_mm,
page_h_mm=page_h_mm,
num_markers=NUM_ARUCOS,
marker_size_mm=ARUCO_SIZE_MM,
border_margin_mm=PAGE_BORDER_MARGIN_MM,
forbidden_area=forbidden_rect,
forbidden_margin_mm=FORBIDDEN_RECT_MARGIN_MM,
seed=SEED,
)
pdf_path = Path(f"{OUTPUT_BASENAME}.pdf")
json_path = Path(f"{OUTPUT_BASENAME}.json")
c = canvas.Canvas(str(pdf_path), pagesize=(mm_to_pt(page_w_mm), mm_to_pt(page_h_mm)))
c.setTitle(pdf_path.stem)
c.setAuthor("OpenAI")
c.setSubject("A0 poster with random ArUco placement")
# Weisser Hintergrund
c.setFillColorRGB(1, 1, 1)
c.rect(0, 0, mm_to_pt(page_w_mm), mm_to_pt(page_h_mm), stroke=0, fill=1)
# Rechteck mit 1 mm schwarzer Linie
c.setStrokeColorRGB(0, 0, 0)
c.setLineWidth(mm_to_pt(LINE_WIDTH_MM))
c.rect(
mm_to_pt(forbidden_rect.x),
mm_to_pt(page_h_mm - forbidden_rect.y - forbidden_rect.h),
mm_to_pt(forbidden_rect.w),
mm_to_pt(forbidden_rect.h),
stroke=1,
fill=0,
)
# Koordinatensystem
ARROW_LEN_MM = 50.0
# X-Achse (rot, nach unten)
c.setStrokeColorRGB(1, 0, 0)
c.setLineWidth(mm_to_pt(1.0))
c.line(
mm_to_pt(origin_x_mm),
mm_to_pt(page_h_mm - origin_y_mm),
mm_to_pt(origin_x_mm),
mm_to_pt(page_h_mm - (origin_y_mm + ARROW_LEN_MM)),
)
# Pfeilspitze
c.line(
mm_to_pt(origin_x_mm),
mm_to_pt(page_h_mm - (origin_y_mm + ARROW_LEN_MM)),
mm_to_pt(origin_x_mm - 4),
mm_to_pt(page_h_mm - (origin_y_mm + ARROW_LEN_MM - 4)),
)
c.line(
mm_to_pt(origin_x_mm),
mm_to_pt(page_h_mm - (origin_y_mm + ARROW_LEN_MM)),
mm_to_pt(origin_x_mm + 4),
mm_to_pt(page_h_mm - (origin_y_mm + ARROW_LEN_MM - 4)),
)
# Y-Achse (grün, nach rechts)
c.setStrokeColorRGB(0, 0.7, 0)
c.line(
mm_to_pt(origin_x_mm),
mm_to_pt(page_h_mm - origin_y_mm),
mm_to_pt(origin_x_mm + ARROW_LEN_MM),
mm_to_pt(page_h_mm - origin_y_mm),
)
# Pfeilspitze
c.line(
mm_to_pt(origin_x_mm + ARROW_LEN_MM),
mm_to_pt(page_h_mm - origin_y_mm),
mm_to_pt(origin_x_mm + ARROW_LEN_MM - 4),
mm_to_pt(page_h_mm - origin_y_mm - 4),
)
c.line(
mm_to_pt(origin_x_mm + ARROW_LEN_MM),
mm_to_pt(page_h_mm - origin_y_mm),
mm_to_pt(origin_x_mm + ARROW_LEN_MM - 4),
mm_to_pt(page_h_mm - origin_y_mm + 4),
)
c.setStrokeColorRGB(0, 0, 0)
# ArUcos zeichnen
c.setFillColorRGB(0, 0, 0)
for item in placements:
draw_aruco_vector(
c=c,
x_mm=item["x_mm"],
y_mm=item["y_mm"],
size_mm=item["size_mm"],
marker_id=item["id"],
page_h_mm=page_h_mm,
)
draw_aruco_label(
c=c,
x_mm=item["x_mm"],
y_mm=item["y_mm"],
size_mm=item["size_mm"],
page_h_mm=page_h_mm,
marker_id=item["id"],
)
c.showPage()
c.save()
# JSON mit Positionen
with json_path.open("w", encoding="utf-8") as f:
meta = {
"page_format": PAGE_FORMAT,
"orientation": ORIENTATION,
"page_size_mm": {"width": page_w_mm, "height": page_h_mm},
"seed": SEED,
"num_arucos": NUM_ARUCOS,
"aruco_size_mm": ARUCO_SIZE_MM,
"aruco_dictionary": "DICT_4X4_250",
"aruco_start_id": ARUCO_START_ID,
"page_border_margin_mm": PAGE_BORDER_MARGIN_MM,
"forbidden_rectangle_mm": {
"x": round(forbidden_rect.x, 2),
"y": round(forbidden_rect.y, 2),
"w": forbidden_rect.w,
"h": forbidden_rect.h,
},
"forbidden_rectangle_margin_mm": FORBIDDEN_RECT_MARGIN_MM,
}
f.write(json.dumps(meta, indent=2, ensure_ascii=False)[:-2])
f.write(',\n "placements": [\n')
for index, p in enumerate(placements):
item = {
"id": p["id"],
"position": [
round((p["y_mm"] + ARUCO_SIZE_MM / 2) - origin_y_mm, 2),
-1*round(origin_x_mm - (p["x_mm"] + ARUCO_SIZE_MM / 2), 2),
-27.3,
],
"normal": [0, 0, 1],
"spin": 90,
}
line = json.dumps(item, ensure_ascii=False)
if index < len(placements) - 1:
line += ","
f.write(f" {line}\n")
f.write(" ]\n}\n")
print(f"PDF geschrieben: {pdf_path.resolve()}")
print(f"JSON geschrieben: {json_path.resolve()}")
if __name__ == "__main__":
main()