From 1a7ba9afe23630b1e3c4dfad63baae4361a20db2 Mon Sep 17 00:00:00 2001 From: Ankle Date: Thu, 24 Sep 2026 14:38:42 +0800 Subject: [PATCH] pintubug --- README.md | 8 ++ scan-panorama/README.md | 15 +++- scan-panorama/stitch_scan.py | 169 ++++++++++++++++++++++++++++++----- 3 files changed, 171 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index b4a8b11..d2f6af4 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,10 @@ cd /home/zhangw/scan-calibrator 运行`install.sh`会自动补齐。ImageMagick安装在RK3588宿主机,而不是smart-cam容器内, 因为验证拼图程序运行在宿主机。 +验证拼图不会再把全部4K小图同时交给ImageMagick。程序会逐张缩图、逐行合并,最后只合并 +已经缩小的行图;如果仍检测到缓存或可用内存不足,会自动缩小单格尺寸后重试两次。因此大型 +扫描网格不需要现场修改ImageMagick策略,也不会靠放宽内存上限增加盒子OOM风险。 + 常用输入: - `1`:确认、执行或选择野外部署; @@ -355,6 +359,10 @@ docker exec smart-cam curl -fsS -X POST \ 拼图完成后,终端返回大图的绝对存储路径,然后结束对话。回复`3`则不拼图并结束。 +大型网格采用分阶段低内存拼图。若终端提示缓存不足,程序会自行降低验证图尺寸并重新拼图, +部署人员不需要重新扫图或手工重复第⑧步;只有源图缺失、磁盘空间耗尽或图片损坏时才会停止并 +给出明确错误。 + 拼图结果统一保存在: ```text diff --git a/scan-panorama/README.md b/scan-panorama/README.md index e8ba2e6..6f7a40d 100644 --- a/scan-panorama/README.md +++ b/scan-panorama/README.md @@ -23,7 +23,7 @@ scan-panorama/ ## 依赖 - Python 3 -- ImageMagick的`identify`和`montage` +- ImageMagick的`identify`、`convert`和`montage` 当前RK3588设备已经安装这些依赖,可用下面的命令检查: @@ -31,6 +31,7 @@ scan-panorama/ python3 --version identify -version montage -version +convert -version ``` ## 最简单的使用方法 @@ -131,6 +132,18 @@ find /userdata/data/scan -mindepth 1 -maxdepth 1 -type d -printf '%f\n' | sort 任一检查失败时不会生成拼图,并会在终端中说明具体问题。 +## 大型网格和内存保护 + +程序不会一次性打开全部4K原图,而是按以下顺序处理: + +1. 每次只读取一张原图并生成缩略图; +2. 每一行的小缩略图合并完成后立即删除该行临时缩略图; +3. 最后把已经缩小的行图纵向合并为验证大图。 + +若ImageMagick仍报告`cache resources exhausted`、内存分配失败或临时缓存空间不足,程序会 +自动把单格宽度降到原计划的75%、50%并重新尝试。临时目录无论成功失败都会删除,不需要提高 +ImageMagick的系统缓存上限,也不会影响原始扫描图片。 + ## 如何查看结果 桌面环境下可以直接打开: diff --git a/scan-panorama/stitch_scan.py b/scan-panorama/stitch_scan.py index 46030d3..32a2c4d 100755 --- a/scan-panorama/stitch_scan.py +++ b/scan-panorama/stitch_scan.py @@ -11,12 +11,19 @@ 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": @@ -105,6 +112,102 @@ def identify_dimensions(images: list[Path]) -> tuple[int, int]: 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, @@ -134,28 +237,50 @@ def build_mosaic( 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), - ] + 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: - result = subprocess.run(command, text=True, capture_output=True) - if result.returncode != 0: - fail(f"拼图失败:{result.stderr.strip()}") + 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("拼图命令未生成有效结果") + fail("拼图程序未生成有效结果") temporary.replace(output) finally: temporary.unlink(missing_ok=True) @@ -167,9 +292,13 @@ def build_mosaic( f",实测pan方向:{pan_direction}(列={'反向' 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}") + 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