#!/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()