Files
scan-calibrator/scan-panorama/stitch_scan.py
T
2026-09-24 14:38:42 +08:00

355 lines
13 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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
import tempfile
PROGRAM_DIR = Path(__file__).resolve().parent
DEFAULT_SCAN_ROOT = Path("/userdata/data/scan")
RESULT_DIR = PROGRAM_DIR / "result"
SAFE_IMAGE_DIMENSION = 16000
RESOURCE_ERROR_MARKERS = (
"cache resources exhausted",
"memory allocation failed",
"unable to extend cache",
"no space left on device",
)
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 run_imagemagick(command: list[str], temporary_dir: Path) -> None:
env = os.environ.copy()
env["MAGICK_TEMPORARY_PATH"] = str(temporary_dir)
result = subprocess.run(command, text=True, capture_output=True, env=env)
if result.returncode == 0:
return
detail = (result.stderr or result.stdout).strip()
raise RuntimeError(detail or f"ImageMagick命令返回{result.returncode}")
def is_resource_error(message: str) -> bool:
lowered = message.lower()
return any(marker in lowered for marker in RESOURCE_ERROR_MARKERS)
def build_staged_mosaic(
images: list[Path],
rows: int,
cols: int,
tile_width: int,
tile_height: int,
quality: int,
output: Path,
) -> None:
"""Resize one source at a time, join rows, then join the finished rows.
A single ImageMagick montage invocation keeps every 4K source image open.
Large scan grids therefore exceed the deliberately conservative cache
policy on edge devices. Staging bounds the working set to one source
image while resizing and one already-small row while joining.
"""
convert = shutil.which("convert")
montage = shutil.which("montage")
if not convert or not montage:
fail("未找到ImageMagick convert/montage,请先安装imagemagick")
with tempfile.TemporaryDirectory(
prefix=f".{output.stem}.stitch.", dir=RESULT_DIR
) as temporary_name:
temporary_dir = Path(temporary_name)
row_outputs: list[Path] = []
for row in range(rows):
tile_outputs: list[Path] = []
for col in range(cols):
source = images[row * cols + col]
tile = temporary_dir / f"tile_{row:04d}_{col:04d}.jpg"
run_imagemagick(
[
convert,
str(source),
"-thumbnail", f"{tile_width}x{tile_height}!",
"-strip",
"-quality", str(quality),
str(tile),
],
temporary_dir,
)
tile_outputs.append(tile)
row_output = temporary_dir / f"row_{row:04d}.jpg"
run_imagemagick(
[
montage,
*map(str, tile_outputs),
"-tile", f"{cols}x1",
"-geometry", "+0+0",
"-background", "black",
"-quality", str(quality),
str(row_output),
],
temporary_dir,
)
if not row_output.is_file() or row_output.stat().st_size == 0:
raise RuntimeError(f"第{row + 1}行没有生成有效结果")
row_outputs.append(row_output)
for tile in tile_outputs:
tile.unlink(missing_ok=True)
temporary_output = temporary_dir / "panorama.jpg"
run_imagemagick(
[
montage,
*map(str, row_outputs),
"-tile", f"1x{rows}",
"-geometry", "+0+0",
"-background", "black",
"-quality", str(quality),
str(temporary_output),
],
temporary_dir,
)
if not temporary_output.is_file() or temporary_output.stat().st_size == 0:
raise RuntimeError("分阶段拼图没有生成有效结果")
shutil.copyfile(temporary_output, output)
def build_mosaic(
scan_dir: Path,
tile_width: int,
quality: int,
mount: str,
pan_direction: str,
) -> Path:
# Office testing: the dome stands on a desk and the camera firmware rotates
# the encoded picture by 180 degrees, which reverses the vertical grid.
# Horizontal ordering is camera/firmware-specific and is measured during
# calibration; it must not be inferred from the physical mount.
reverse_rows = mount == "desktop"
if pan_direction == "auto":
# Backward-compatible behavior for scans made before calibration began
# recording the actual signed pan direction.
pan_direction = "decreasing" if mount == "desktop" else "increasing"
reverse_cols = pan_direction == "decreasing"
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))
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"
attempt_widths: list[int] = []
for scale in (1.0, 0.75, 0.5):
width = max(64, int(tile_width * scale))
if width not in attempt_widths:
attempt_widths.append(width)
final_tile_width = tile_width
last_error = ""
try:
for attempt, candidate_width in enumerate(attempt_widths, start=1):
candidate_height = max(
1, round(source_height * candidate_width / source_width)
)
temporary.unlink(missing_ok=True)
try:
build_staged_mosaic(
images,
rows,
cols,
candidate_width,
candidate_height,
quality,
temporary,
)
final_tile_width = candidate_width
tile_height = candidate_height
break
except RuntimeError as exc:
last_error = str(exc)
if not is_resource_error(last_error) or attempt == len(attempt_widths):
fail(f"拼图失败:{last_error}")
next_width = attempt_widths[attempt]
print(
"提示:盒子可用内存或ImageMagick缓存不足,"
f"程序正在把单格宽度从{candidate_width}缩小到{next_width}后自动重试……",
flush=True,
)
else:
fail(f"拼图失败:{last_error}")
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 '正向'})"
f",实测pan方向:{pan_direction}(列={'反向' if reverse_cols else '正向'})"
)
print(f"小图:{source_width}×{source_height}")
if final_tile_width != requested_tile_width:
print(
f"尺寸保护:单格宽度由{requested_tile_width}自动缩小到"
f"{final_tile_width},避免尺寸或缓存超限"
)
print(f"拼图方式:分阶段低内存处理(逐张缩图 → 逐行合并 → 合并全部行)")
print(f"大图:{cols * final_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(
"--pan-direction",
choices=["auto", "increasing", "decreasing"],
default="auto",
help=(
"画面左到右时pan数值方向;交互式向导会自动传入。"
"旧扫描未记录方向时默认auto沿用历史mount规则"
),
)
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,
args.pan_direction,
)
if __name__ == "__main__":
main()