fix: track scan-panorama as normal directory
This commit is contained in:
1 parent
a52d0029b4
commit
7e9ecfe01e
5 files changed
+351
-1
No files matched your search
Submodule scan-panorama deleted from 9c8bfcb0bf.
@@ -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信息,
|
||||||
|
完成球面投影及无缝融合。
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
Executable
+6
@@ -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" "$@"
|
||||||
Executable
+195
@@ -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()
|
||||||
Reference in new issue
Block a user