"""Извлечение таблицы каналов Pandar128E3X из руководства (Приложение A). Даёт для каждого из 128 каналов: проектный азимутальный сдвиг и угол места, диапазон измерения, признак ближнего поля, максимальную дальность при 10 % отражения, признак «дальнобойного» канала и минимальную различимую отражательную способность. Зачем: поканальная дальность делает модель сенсора в синтетическом полигоне честной (каналы 34–65 видят 200 м, а 98–128 — только ближнее поле), а проектные углы служат независимой проверкой калибровки решётки по данным. python tools/extract_channel_table.py --pdf <путь> --out ros2_ws/src/flyguard/flyguard/data/pandar128_channels.csv """ from __future__ import annotations import argparse import csv import re from pathlib import Path ANGLE = re.compile(r"^-?\d+\.\d+°$") METERS = re.compile(r"^\(?(\d+(?:\.\d+)?)\s*m\)?$") # в таблице встречаются типографские варианты: минус, полноширинные скобки _NORMALISE = str.maketrans({"–": "-", "—": "-", "−": "-", "(": "(", ")": ")", " ": " "}) def _clean(s: str) -> str: return s.translate(_NORMALISE).strip() def parse(pdf_path: str) -> list[dict]: import fitz doc = fitz.open(pdf_path) tokens: list[str] = [] for page in doc: text = page.get_text() if "Appendix A: Channel distribution data" not in text: continue if "Chann" not in text and not re.search(r"^\d+\s*$", text, re.M): continue tokens.extend(_clean(line) for line in text.splitlines() if line.strip()) # строки собираются в словарь по номеру канала, а не последовательно: # один сбой синхронизации на границе страницы иначе обрывает весь разбор found: dict[int, dict] = {} i = 0 while i < len(tokens) - 9: # строка начинается с номера канала, за которым идут два угла if (tokens[i].isdigit() and ANGLE.match(tokens[i + 1] or "") and ANGLE.match(tokens[i + 2] or "")): ch = int(tokens[i]) if not 1 <= ch <= 128 or ch in found: i += 1 continue chunk = tokens[i:i + 10] m_min = METERS.match(chunk[3]) m_max = METERS.match(chunk[4]) near = chunk[5].upper() == "YES" m_10 = METERS.match(chunk[6]) far = chunk[7].upper() == "YES" refl = chunk[8] highres = chunk[9].upper() == "YES" if not (m_min and m_max and m_10): i += 1 continue found[ch] = dict( channel=ch, az_offset_deg=float(chunk[1].rstrip("°")), elevation_deg=float(chunk[2].rstrip("°")), range_min_m=float(m_min.group(1)), range_max_m=float(m_max.group(1)), near_field=int(near), max_range_10pct_m=float(m_10.group(1)), far_field=int(far), min_reflectivity=refl, high_res=int(highres), ) i += 10 else: i += 1 return [found[k] for k in sorted(found)] def main() -> None: ap = argparse.ArgumentParser(description=__doc__) ap.add_argument("--pdf", required=True) ap.add_argument("--out", required=True) args = ap.parse_args() rows = parse(args.pdf) if len(rows) != 128: print(f"ВНИМАНИЕ: разобрано {len(rows)} каналов вместо 128") out = Path(args.out) out.parent.mkdir(parents=True, exist_ok=True) with open(out, "w", newline="", encoding="utf-8") as f: w = csv.DictWriter(f, fieldnames=list(rows[0])) w.writeheader() w.writerows(rows) import numpy as np az = np.array([r["az_offset_deg"] for r in rows]) el = np.array([r["elevation_deg"] for r in rows]) r10 = np.array([r["max_range_10pct_m"] for r in rows]) print(f"разобрано каналов: {len(rows)} → {out}") print(f" азимутальный сдвиг: {az.min():+.3f}°…{az.max():+.3f}° " f"(размах {az.ptp() if hasattr(az,'ptp') else np.ptp(az):.3f}°)") print(f" угол места: {el.min():+.3f}°…{el.max():+.3f}°") print(f" макс. дальность @10%: {r10.min():.0f}…{r10.max():.0f} м, " f"каналов с 200 м: {(r10 >= 200).sum()}") print(f" ближнего поля: {sum(r['near_field'] for r in rows)}, " f"дальнобойных: {sum(r['far_field'] for r in rows)}, " f"высокого разрешения: {sum(r['high_res'] for r in rows)}") if __name__ == "__main__": main()