This commit is contained in:
Ankle committed 2026-09-24 14:38:42 +08:00
1 parent 5746fa10db
commit 1a7ba9afe2
3 files changed
+171 -21

No files matched your search

+8
View File
@@ -72,6 +72,10 @@ cd /home/zhangw/scan-calibrator
运行`install.sh`会自动补齐。ImageMagick安装在RK3588宿主机,而不是smart-cam容器内, 运行`install.sh`会自动补齐。ImageMagick安装在RK3588宿主机,而不是smart-cam容器内,
因为验证拼图程序运行在宿主机。 因为验证拼图程序运行在宿主机。
验证拼图不会再把全部4K小图同时交给ImageMagick。程序会逐张缩图、逐行合并,最后只合并
已经缩小的行图;如果仍检测到缓存或可用内存不足,会自动缩小单格尺寸后重试两次。因此大型
扫描网格不需要现场修改ImageMagick策略,也不会靠放宽内存上限增加盒子OOM风险。
常用输入: 常用输入:
- `1`:确认、执行或选择野外部署; - `1`:确认、执行或选择野外部署;
@@ -355,6 +359,10 @@ docker exec smart-cam curl -fsS -X POST \
拼图完成后,终端返回大图的绝对存储路径,然后结束对话。回复`3`则不拼图并结束。 拼图完成后,终端返回大图的绝对存储路径,然后结束对话。回复`3`则不拼图并结束。
大型网格采用分阶段低内存拼图。若终端提示缓存不足,程序会自行降低验证图尺寸并重新拼图,
部署人员不需要重新扫图或手工重复第⑧步;只有源图缺失、磁盘空间耗尽或图片损坏时才会停止并
给出明确错误。
拼图结果统一保存在: 拼图结果统一保存在:
```text ```text
+14 -1
View File
@@ -23,7 +23,7 @@ scan-panorama/
## 依赖 ## 依赖
- Python 3 - Python 3
- ImageMagick的`identify`和`montage` - ImageMagick的`identify`、`convert`和`montage`
当前RK3588设备已经安装这些依赖,可用下面的命令检查: 当前RK3588设备已经安装这些依赖,可用下面的命令检查:
@@ -31,6 +31,7 @@ scan-panorama/
python3 --version python3 --version
identify -version identify -version
montage -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的系统缓存上限,也不会影响原始扫描图片。
## 如何查看结果 ## 如何查看结果
桌面环境下可以直接打开: 桌面环境下可以直接打开:
+149 -20
View File
@@ -11,12 +11,19 @@ import re
import shutil import shutil
import subprocess import subprocess
import sys import sys
import tempfile
PROGRAM_DIR = Path(__file__).resolve().parent PROGRAM_DIR = Path(__file__).resolve().parent
DEFAULT_SCAN_ROOT = Path("/userdata/data/scan") DEFAULT_SCAN_ROOT = Path("/userdata/data/scan")
RESULT_DIR = PROGRAM_DIR / "result" RESULT_DIR = PROGRAM_DIR / "result"
SAFE_IMAGE_DIMENSION = 16000 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": def fail(message: str) -> "NoReturn":
@@ -105,6 +112,102 @@ def identify_dimensions(images: list[Path]) -> tuple[int, int]:
return dimensions.pop() 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( def build_mosaic(
scan_dir: Path, scan_dir: Path,
tile_width: int, tile_width: int,
@@ -134,28 +237,50 @@ def build_mosaic(
fail(f"网格{cols}列×{rows}行过大,无法在{SAFE_IMAGE_DIMENSION}px尺寸限制内生成") fail(f"网格{cols}列×{rows}行过大,无法在{SAFE_IMAGE_DIMENSION}px尺寸限制内生成")
tile_height = max(1, round(source_height * tile_width / source_width)) 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) RESULT_DIR.mkdir(parents=True, exist_ok=True)
output = RESULT_DIR / f"{scan_id}_{mount}_scan_grid_panorama.jpg" output = RESULT_DIR / f"{scan_id}_{mount}_scan_grid_panorama.jpg"
temporary = RESULT_DIR / f".{output.name}.{os.getpid()}.tmp.jpg" temporary = RESULT_DIR / f".{output.name}.{os.getpid()}.tmp.jpg"
command = [ attempt_widths: list[int] = []
montage, for scale in (1.0, 0.75, 0.5):
*map(str, images), width = max(64, int(tile_width * scale))
"-thumbnail", f"{tile_width}x{tile_height}!", if width not in attempt_widths:
"-tile", f"{cols}x{rows}", attempt_widths.append(width)
"-geometry", "+0+0",
"-background", "black", final_tile_width = tile_width
"-quality", str(quality), last_error = ""
str(temporary),
]
try: try:
result = subprocess.run(command, text=True, capture_output=True) for attempt, candidate_width in enumerate(attempt_widths, start=1):
if result.returncode != 0: candidate_height = max(
fail(f"拼图失败:{result.stderr.strip()}") 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: if not temporary.is_file() or temporary.stat().st_size == 0:
fail("拼图命令未生成有效结果") fail("拼图程序未生成有效结果")
temporary.replace(output) temporary.replace(output)
finally: finally:
temporary.unlink(missing_ok=True) temporary.unlink(missing_ok=True)
@@ -167,9 +292,13 @@ def build_mosaic(
f",实测pan方向:{pan_direction}(列={'反向' if reverse_cols else '正向'})" f",实测pan方向:{pan_direction}(列={'反向' if reverse_cols else '正向'})"
) )
print(f"小图:{source_width}×{source_height}") print(f"小图:{source_width}×{source_height}")
if tile_width != requested_tile_width: if final_tile_width != requested_tile_width:
print(f"尺寸保护:单格宽度由{requested_tile_width}自动缩小到{tile_width},避免超过ImageMagick限制") print(
print(f"大图:{cols * tile_width}×{rows * tile_height}") f"尺寸保护:单格宽度由{requested_tile_width}自动缩小到"
f"{final_tile_width},避免尺寸或缓存超限"
)
print(f"拼图方式:分阶段低内存处理(逐张缩图 → 逐行合并 → 合并全部行)")
print(f"大图:{cols * final_tile_width}×{rows * tile_height}")
print(f"结果:{output}") print(f"结果:{output}")
return output return output