pintubug
This commit is contained in:
1 parent
5746fa10db
commit
1a7ba9afe2
3 files changed
+171
-21
No files matched your search
+14
-1
@@ -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的系统缓存上限,也不会影响原始扫描图片。
|
||||
|
||||
## 如何查看结果
|
||||
|
||||
桌面环境下可以直接打开:
|
||||
|
||||
+149
-20
@@ -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
|
||||
|
||||
|
||||
Reference in new issue
Block a user