From 7e9ecfe01edeb2d386e42dd8b357fbcc996292bc Mon Sep 17 00:00:00 2001 From: Ankle Date: Thu, 23 Jul 2026 18:05:03 +0800 Subject: [PATCH] fix: track scan-panorama as normal directory --- scan-panorama | 1 - scan-panorama/README.md | 149 ++++++++++++++++++++++++++ scan-panorama/result/.gitkeep | 1 + scan-panorama/stitch.sh | 6 ++ scan-panorama/stitch_scan.py | 195 ++++++++++++++++++++++++++++++++++ 5 files changed, 351 insertions(+), 1 deletion(-) delete mode 160000 scan-panorama create mode 100644 scan-panorama/README.md create mode 100644 scan-panorama/result/.gitkeep create mode 100755 scan-panorama/stitch.sh create mode 100755 scan-panorama/stitch_scan.py diff --git a/scan-panorama b/scan-panorama deleted file mode 160000 index 9c8bfcb..0000000 --- a/scan-panorama +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 9c8bfcb0bf47ba451584e8686ace8e44cf766c01 diff --git a/scan-panorama/README.md b/scan-panorama/README.md new file mode 100644 index 0000000..d68a9f6 --- /dev/null +++ b/scan-panorama/README.md @@ -0,0 +1,149 @@ +# scan-panorama 端侧扫描验证拼图 + +这个程序用于在RK3588端快速验证`scan`输出是否完整、网格顺序是否正确。 +它读取指定扫描目录中的`meta.json`,根据每张图片的`row`和`col`恢复二维网格, +生成一张总览大图。 + +这里生成的是**验证用网格拼图**,不会执行后台服务器上的球面投影、特征融合、 +畸变校正和无缝全景优化。输入小图不会被复制、修改或删除。 + +## 目录结构 + +```text +scan-panorama/ +├── README.md +├── stitch.sh +├── stitch_scan.py +└── result/ + └── <扫描ID>_<安装方式>_scan_grid_panorama.jpg +``` + +程序只把最终拼图保存在`result/`,运行中的临时文件会自动删除。 + +## 依赖 + +- Python 3 +- ImageMagick的`identify`和`montage` + +当前RK3588设备已经安装这些依赖,可用下面的命令检查: + +```bash +python3 --version +identify -version +montage -version +``` + +## 最简单的使用方法 + +进入程序目录: + +```bash +cd /home/zhangw/scan-calibrator/scan-panorama +``` + +查看可用扫描目录: + +```bash +find /userdata/data/scan -mindepth 1 -maxdepth 1 -type d -printf '%f\n' | sort +``` + +指定扫描目录名进行拼图: + +```bash +./stitch.sh 2026-07-22_18-25-09 --mount desktop +``` + +必须用`--mount`声明球机安装方式: + +```bash +# 办公室测试:球机正放桌面,摄像头后台将画面旋转180度 +./stitch.sh 2026-07-22_18-25-09 --mount desktop + +# 野外部署:球机物理倒挂 +./stitch.sh 2026-07-22_18-25-09 --mount hanging +``` + +- `desktop`同时反转网格行顺序和列顺序; +- `hanging`按`meta.json`原始row/col顺序排列; +- 两种模式都不会旋转或镜像单张图片,只改变图片在大图中的位置。 + +目录名会自动解析为: + +```text +/userdata/data/scan/2026-07-22_18-25-09 +``` + +也可以直接传绝对路径: + +```bash +./stitch.sh /userdata/data/scan/2026-07-22_18-25-09 --mount desktop +``` + +结果保存在: + +```text +/home/zhangw/scan-calibrator/scan-panorama/result/2026-07-22_18-25-09_desktop_scan_grid_panorama.jpg +``` + +文件名中的`2026-07-22_18-25-09`就是对应的扫描ID。重复执行同一个扫描目录时, +会更新该扫描ID对应的结果,不会生成多个无意义副本。 + +## 输出尺寸 + +默认将每张1280×720小图缩小为640×360。12列×8行时,最终大图为: + +```text +7680×2880 +``` + +这是推荐的快速验证尺寸。 + +如果异常配置产生了很多列或很多行,默认尺寸会使大图超过ImageMagick的尺寸上限, +程序会自动缩小每个单格并打印“尺寸保护”提示,而不是直接报错。 + +如果希望使用小图原始分辨率拼接,传入`--tile-width 0`: + +```bash +./stitch.sh 2026-07-22_18-25-09 --mount desktop --tile-width 0 +``` + +12列×8行的全分辨率结果为15360×5760,占用更多内存、时间和磁盘空间。 + +也可以指定其他单格宽度和JPEG质量: + +```bash +./stitch.sh 2026-07-22_18-25-09 --mount desktop --tile-width 800 --quality 95 +``` + +## 程序会自动检查什么 + +拼图前会检查: + +1. 指定目录和`meta.json`是否存在; +2. `images`记录是否包含有效的`row`、`col`和`file`; +3. row/col是否从0连续编号; +4. 网格中是否存在缺失位置或重复位置; +5. 所有小图是否存在且文件大小不为0; +6. 所有小图的分辨率是否一致; +7. 图片是否可以被ImageMagick正常读取。 + +任一检查失败时不会生成拼图,并会在终端中说明具体问题。 + +## 如何查看结果 + +桌面环境下可以直接打开: + +```bash +xdg-open /home/zhangw/scan-calibrator/scan-panorama/result/2026-07-22_18-25-09_desktop_scan_grid_panorama.jpg +``` + +也可以把`result/`中的图片复制到自己的电脑查看。验证时重点检查: + +- 每一行是否按水平方向连续变化; +- 相邻列是否存在共同画面; +- 行与行之间俯仰方向是否连续; +- 是否有黑图、空图、重复图或明显漏掉的方向; +- 360°首尾方向是否能对应起来。 + +后台正式拼接时,应继续使用同一目录下`meta.json`中的目标PTZ和实际PTZ信息, +完成球面投影及无缝融合。 diff --git a/scan-panorama/result/.gitkeep b/scan-panorama/result/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/scan-panorama/result/.gitkeep @@ -0,0 +1 @@ + diff --git a/scan-panorama/stitch.sh b/scan-panorama/stitch.sh new file mode 100755 index 0000000..270c5d0 --- /dev/null +++ b/scan-panorama/stitch.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +export PYTHONDONTWRITEBYTECODE=1 +exec python3 "$SCRIPT_DIR/stitch_scan.py" "$@" diff --git a/scan-panorama/stitch_scan.py b/scan-panorama/stitch_scan.py new file mode 100755 index 0000000..3baf93f --- /dev/null +++ b/scan-panorama/stitch_scan.py @@ -0,0 +1,195 @@ +#!/usr/bin/env python3 +"""Build a deterministic scan verification mosaic from scan/meta.json.""" + +from __future__ import annotations + +import argparse +import json +import os +from pathlib import Path +import re +import shutil +import subprocess +import sys + + +PROGRAM_DIR = Path(__file__).resolve().parent +DEFAULT_SCAN_ROOT = Path("/userdata/data/scan") +RESULT_DIR = PROGRAM_DIR / "result" +SAFE_IMAGE_DIMENSION = 16000 + + +def fail(message: str) -> "NoReturn": + raise SystemExit(f"错误:{message}") + + +def resolve_scan_dir(value: str) -> Path: + candidate = Path(value).expanduser() + if not candidate.is_absolute(): + candidate = DEFAULT_SCAN_ROOT / candidate + candidate = candidate.resolve() + if not candidate.is_dir(): + fail(f"扫描目录不存在:{candidate}") + return candidate + + +def load_grid(scan_dir: Path, reverse_rows: bool, reverse_cols: bool) -> tuple[str, int, int, list[Path]]: + meta_path = scan_dir / "meta.json" + if not meta_path.is_file(): + fail(f"缺少meta.json:{meta_path}") + + try: + meta = json.loads(meta_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + fail(f"无法读取meta.json:{exc}") + + records = meta.get("images") + if not isinstance(records, list) or not records: + fail("meta.json中没有images记录") + + cells: dict[tuple[int, int], Path] = {} + for index, record in enumerate(records): + try: + row, col = int(record["row"]), int(record["col"]) + relative = Path(record["file"]) + except (KeyError, TypeError, ValueError) as exc: + fail(f"images[{index}]缺少有效的row/col/file:{exc}") + if relative.is_absolute() or ".." in relative.parts: + fail(f"images[{index}]包含不安全的文件路径:{relative}") + key = (row, col) + if key in cells: + fail(f"meta.json存在重复网格位置:row={row}, col={col}") + image = scan_dir / relative + if not image.is_file() or image.stat().st_size == 0: + fail(f"图片缺失或为空:{image}") + cells[key] = image + + rows = sorted({row for row, _ in cells}) + cols = sorted({col for _, col in cells}) + if rows != list(range(len(rows))) or cols != list(range(len(cols))): + fail(f"row/col必须从0连续编号,实际rows={rows} cols={cols}") + missing = [(row, col) for row in rows for col in cols if (row, col) not in cells] + if missing: + fail(f"扫描网格不完整,缺少{len(missing)}个位置,例如:{missing[:8]}") + + scan_id = str(meta.get("scan_id") or scan_dir.name) + safe_id = re.sub(r"[^0-9A-Za-z._-]+", "_", scan_id).strip("._-") + if not safe_id: + safe_id = re.sub(r"[^0-9A-Za-z._-]+", "_", scan_dir.name) + layout_rows = list(reversed(rows)) if reverse_rows else rows + layout_cols = list(reversed(cols)) if reverse_cols else cols + ordered = [cells[(row, col)] for row in layout_rows for col in layout_cols] + return safe_id, len(rows), len(cols), ordered + + +def identify_dimensions(images: list[Path]) -> tuple[int, int]: + identify = shutil.which("identify") + if not identify: + fail("未找到ImageMagick identify,请先安装imagemagick") + result = subprocess.run( + [identify, "-format", "%w %h\\n", *map(str, images)], + text=True, + capture_output=True, + ) + if result.returncode != 0: + fail(f"图片格式校验失败:{result.stderr.strip()}") + dimensions = set() + for line in result.stdout.splitlines(): + try: + width, height = map(int, line.split()) + except ValueError: + fail(f"identify返回了无法识别的尺寸:{line!r}") + dimensions.add((width, height)) + if len(dimensions) != 1: + fail(f"小图尺寸不一致:{sorted(dimensions)}") + return dimensions.pop() + + +def build_mosaic(scan_dir: Path, tile_width: int, quality: int, mount: str) -> Path: + # Office testing: the dome stands on a desk and the camera firmware rotates + # the encoded picture by 180 degrees. Field deployment: the dome is hung. + reverse_rows = mount == "desktop" + reverse_cols = mount == "desktop" + scan_id, rows, cols, images = load_grid(scan_dir, reverse_rows, reverse_cols) + source_width, source_height = identify_dimensions(images) + if tile_width <= 0: + tile_width = source_width + requested_tile_width = tile_width + max_width_for_columns = SAFE_IMAGE_DIMENSION // cols + max_width_for_rows = (SAFE_IMAGE_DIMENSION * source_width) // (rows * source_height) + tile_width = min(tile_width, max_width_for_columns, max_width_for_rows) + if tile_width <= 0: + fail(f"网格{cols}列×{rows}行过大,无法在{SAFE_IMAGE_DIMENSION}px尺寸限制内生成") + tile_height = max(1, round(source_height * tile_width / source_width)) + + montage = shutil.which("montage") + if not montage: + fail("未找到ImageMagick montage,请先安装imagemagick") + RESULT_DIR.mkdir(parents=True, exist_ok=True) + output = RESULT_DIR / f"{scan_id}_{mount}_scan_grid_panorama.jpg" + temporary = RESULT_DIR / f".{output.name}.{os.getpid()}.tmp.jpg" + command = [ + montage, + *map(str, images), + "-thumbnail", f"{tile_width}x{tile_height}!", + "-tile", f"{cols}x{rows}", + "-geometry", "+0+0", + "-background", "black", + "-quality", str(quality), + str(temporary), + ] + try: + result = subprocess.run(command, text=True, capture_output=True) + if result.returncode != 0: + fail(f"拼图失败:{result.stderr.strip()}") + if not temporary.is_file() or temporary.stat().st_size == 0: + fail("拼图命令未生成有效结果") + temporary.replace(output) + finally: + temporary.unlink(missing_ok=True) + + print(f"扫描目录:{scan_dir}") + print(f"网格:{cols}列 × {rows}行,共{len(images)}张") + print(f"安装方式:{mount}(行={'反向' if reverse_rows else '正向'},列={'反向' if reverse_cols else '正向'})") + print(f"小图:{source_width}×{source_height}") + if tile_width != requested_tile_width: + print(f"尺寸保护:单格宽度由{requested_tile_width}自动缩小到{tile_width},避免超过ImageMagick限制") + print(f"大图:{cols * tile_width}×{rows * tile_height}") + print(f"结果:{output}") + return output + + +def main() -> None: + parser = argparse.ArgumentParser( + description="读取scan目录的meta.json,按row/col生成端侧快速验证拼图。" + ) + parser.add_argument( + "scan_dir", + help="扫描目录名(如2026-07-22_18-25-09)或绝对路径", + ) + parser.add_argument( + "--mount", + choices=["desktop", "hanging"], + required=True, + help="desktop=办公室桌面正放且后台旋转180度;hanging=野外倒挂", + ) + parser.add_argument( + "--tile-width", + type=int, + default=640, + help="每张小图在大图中的宽度,默认640;传0使用原图宽度", + ) + parser.add_argument( + "--quality", + type=int, + default=92, + choices=range(1, 101), + metavar="1-100", + help="输出JPEG质量,默认92", + ) + args = parser.parse_args() + build_mosaic(resolve_scan_dir(args.scan_dir), args.tile_width, args.quality, args.mount) + + +if __name__ == "__main__": + main()