图片与媒体批处理实战

系统讲解 ImageMagick 与 ffmpeg 的批量处理:尺寸与格式转换、并行与断点续跑、EXIF 元数据读写与剥离、转码校验与失败重试的工程化实践。

1. 媒体批处理的特点

一句话总结: 媒体处理是「CPU 密集 + 长耗时 + 单文件可独立重试」的典型场景,这三个特征决定了脚本必须并行、必须能续跑。

一张图片缩放几百毫秒,一段视频转码可能几十分钟。这意味着脚本的瓶颈永远是 CPU 而不是磁盘 IO,因此并发度可以贴着核数走;同时因为任务粒度高、相互独立,任何失败都可以只重跑单个文件,不需要从头来。

# 串行处理 100 张图片
time for f in *.jpg; do convert "$f" -resize 800x "${f%.jpg}_small.jpg"; done

# 并行处理:立刻快好几倍
time ls *.jpg | xargs -P "$(nproc)" -I {} sh -c \
  'convert "$1" -resize 800x "${1%.jpg}_small.jpg"' _ {}

1.1 幂等与断点续跑

一句话总结: 「输出存在且比输入新」就跳过,是最简单可靠的续跑判据。

# 幂等处理:输出比输入新则跳过
process_if_needed() {
  local src="$1" dst="$2"
  if [[ -f "$dst" && "$dst" -nt "$src" ]]; then
    return 0        # 已处理且更新,跳过
  fi
  convert "$src" -resize 800x "$dst"
}

1.2 依赖探测

一句话总结: 入口处检查 magick/convert 与 ffmpeg/ffprobe 是否存在,缺依赖立即失败而不是跑到一半崩。

need() {
  command -v "$1" >/dev/null 2>&1 || { echo "缺少依赖: $1" >&2; exit 1; }
}
need ffmpeg
need ffprobe
command -v magick >/dev/null 2>&1 && IM=magick || IM=convert

2. ImageMagick 批量处理

一句话总结: ImageMagick 的 mogrify 是「原地批量」、convert 是「一对一」,选错会覆盖原图。

2.1 尺寸与格式转换

一句话总结: 缩放用 -resize WxH 保持比例,> 与 ^ 修饰符控制「只缩小」与「铺满裁切」。

# 只缩小不放大(大图变小,小图不动)
mogrify -resize '1600x1600>' *.jpg
# 铺满裁切:先按短边缩放再裁中心
convert in.jpg -resize '800x800^' -gravity center -extent 800x800 out.jpg
# 转 WebP,质量 82
convert in.jpg -quality 82 out.webp
# 批量转换格式(mogrify 原地改名)
mogrify -format webp -quality 82 *.png

2.2 水印与合成

一句话总结: -gravity 定位置、-geometry 定偏移、-dissolve 定透明度,三件套搞定水印。

# 右下角半透明水印
convert in.jpg watermark.png \
  -gravity southeast -geometry +20+20 -compose dissolve -define compose:args=40 \
  -composite out.jpg

# 批量加水印
for f in *.jpg; do
  convert "$f" watermark.png -gravity southeast -geometry +20+20 \
    -compose dissolve -define compose:args=40 -composite "wm_$f"
done

2.3 联系表与缩略图

一句话总结: montage 把多图拼成一张联系表,适合快速人工审片。

# 生成 4x4 缩略图联系表
montage *.jpg -thumbnail 200x200 -tile 4x4 -geometry +4+4 contact.jpg

# 用占位色填充缺失的格子
montage *.jpg -thumbnail 200x200 -tile 4x4 -background '#eeeeee' contact.jpg

2.4 资源限制

一句话总结: ImageMagick 默认会吃掉大量内存与磁盘,批量处理前必须设上限,否则整机被拖垮。

# 限制内存、磁盘与线程
export MAGICK_MEMORY_LIMIT=512MiB
export MAGICK_MAP_LIMIT=1GiB
export MAGICK_DISK_LIMIT=2GiB
export MAGICK_THREAD_LIMIT=2     # 每进程 2 线程,配合外层并发

mogrify -resize '1200x1200>' *.jpg

3. ffmpeg 批量转码

一句话总结: ffmpeg 参数顺序决定一切——输入在前、输出在后,-c copy 能复制流就不重编码。

# 无重编码封装转换(秒级完成)
ffmpeg -i in.mkv -c copy out.mp4

# H.264 转码,CRF 23 是画质与体积的平衡点
ffmpeg -i in.mov -c:v libx264 -crf 23 -preset medium -c:a aac -b:a 128k out.mp4

# 提取音频
ffmpeg -i in.mp4 -vn -c:a libmp3lame -q:a 2 out.mp3

3.1 常用批量模式

一句话总结: 批量转码的核心是「遍历 + 输出命名 + 并发」,与图片处理套路完全一致。

# 批量转 720p
find . -name '*.mov' -print0 | xargs -0 -P 2 -I {} sh -c '
  ffmpeg -nostdin -y -i "$1" -vf scale=-2:720 -c:v libx264 -crf 23 \
         -c:a aac -b:a 128k "${1%.mov}_720p.mp4" </dev/null
' _ {}
# 批量生成缩略图(第 1 秒的帧)
find . -name '*.mp4' -print0 | xargs -0 -P "$(nproc)" -I {} sh -c '
  ffmpeg -nostdin -y -ss 1 -i "$1" -frames:v 1 "${1%.mp4}.jpg" </dev/null
' _ {}

3.2 关键参数解析

一句话总结: -nostdin 防 ffmpeg 抢 stdin、-y 覆盖输出、-vf scale=-2:H 保证宽高为偶数。

# -nostdin:并发时防止多个 ffmpeg 争抢终端输入
# -y:已存在输出直接覆盖,配合幂等判断使用
# scale=-2:720:高度 720,宽度按比例取整到偶数(H.264 要求偶数)
ffmpeg -nostdin -y -i in.mp4 -vf scale=-2:720 -c:v libx264 out.mp4

3.3 硬件加速

一句话总结: 有 GPU 时用 h264_nvenc 或 h264_videotoolbox,速度可提升数倍,代价是画质略降。

# NVIDIA NVENC
ffmpeg -hwaccel cuda -i in.mp4 -c:v h264_nvenc -preset p4 -cq 23 out.mp4

# macOS VideoToolbox
ffmpeg -i in.mp4 -c:v h264_videotoolbox -b:v 4M out.mp4

# 探测可用编码器
ffmpeg -hide_banner -encoders | grep -E 'nvenc|videotoolbox|qsv'

4. 并行与断点续跑

一句话总结: 并发度按核数与内存算,任务清单落地成文件,失败项单独记录后重跑。

4.1 并发度计算

一句话总结: 视频转码建议并发 = 核数的一半(每任务本身多线程),图片处理可以等于核数。

# 视频:每任务自带多线程,并发数取核数一半
jobs=$(( $(nproc) / 2 ))
[[ $jobs -lt 1 ]] && jobs=1

# 图片:单线程任务,可跑满核数
img_jobs=$(nproc)

4.2 失败清单与重跑

一句话总结: 每个任务把失败写到独立文件,汇总后只重跑失败项,比全量重来快得多。

# 每个任务独立记录状态
run_task() {
  local src="$1" dst="$2"
  if ffmpeg -nostdin -y -loglevel error -i "$src" -c:v libx264 "$dst" </dev/null; then
    printf '%s\n' "$src" >> done.txt
  else
    printf '%s\n' "$src" >> failed.txt
  fi
}
export -f run_task
# 重跑:只处理失败清单
[[ -f failed.txt ]] && xargs -a failed.txt -P "$jobs" -I {} ./retry.sh {}

4.3 断点续跑脚本骨架

一句话总结: 「已完成清单」是续跑的唯一真相来源,启动时先加载它再过滤任务列表。

#!/usr/bin/env bash
set -euo pipefail

DONE=done.txt; touch "$DONE"

# 生成待处理清单:排除已完成
pending() {
  comm -23 <(find . -name '*.mov' -printf '%p\n' | sort) <(sort -u "$DONE")
}
pending | xargs -P "$jobs" -I {} ./process-one.sh {}

5. 元数据与 EXIF

一句话总结: 元数据既是资产也是隐私风险,发布前要剥离 GPS 与设备信息,归档时则要保留时间与方向。

# 查看 EXIF(exiftool 比 identify -verbose 友好得多)
exiftool -s -DateTimeOriginal -GPSLatitude -GPSLongitude -Model photo.jpg

# 剥离所有元数据
exiftool -all= -overwrite_original photo.jpg

# 只保留时间,剥离 GPS 与设备
exiftool -GPS:all= -Model= -Software= -overwrite_original photo.jpg

5.1 批量读取与导出

一句话总结: exiftool 支持 -csv 与 -json 输出,批量审计元数据时直接生成可分析的表。

# 导出所有图片的关键元数据为 CSV
exiftool -csv -r -DateTimeOriginal -Model -ImageSize -GPSPosition . > meta.csv
# 按拍摄时间重命名(需先安装 Time::Piece)
exiftool '-FileName<DateTimeOriginal' -d '%Y%m%d_%H%M%S%%-c.%%e' *.jpg

5.2 时间戳一致性

一句话总结: 文件系统时间与 EXIF 时间经常不一致,归档前统一用 EXIF 时间校正 mtime。

# 用 EXIF 时间设置文件 mtime
exiftool -overwrite_original -P '-FileModifyDate<DateTimeOriginal' *.jpg
# 校验:比较两者差异
exiftool -T -DateTimeOriginal -FileModifyDate *.jpg | awk '$1 != $2'

6. 校验与质量检查

一句话总结: 转码后必须校验产物完整性,否则「成功」的输出可能是 0 字节或截断文件。

# 校验图片可解码
identify -regard-warnings in.jpg >/dev/null && echo "图片有效"

# 校验视频完整性
ffprobe -v error -show_entries format=duration -of default=nw=1 in.mp4

# 检查 0 字节与损坏文件
find . -name '*.jpg' -size 0 -print
find . -name '*.jpg' -print0 | xargs -0 -P 4 -I {} sh -c \
  'identify -regard-warnings "$1" >/dev/null 2>&1 || echo "损坏: $1"' _ {}

6.1 尺寸与体积对比

一句话总结: 转码前后的体积比是发现「参数写错导致输出暴涨」的最快指标。

# 统计压缩比
total_in=0; total_out=0
while IFS= read -r f; do
  out="${f%.jpg}.webp"
  [[ -f "$out" ]] || continue
  total_in=$(( total_in + $(stat -c%s "$f") ))
  total_out=$(( total_out + $(stat -c%s "$out") ))
done < <(find . -name '*.jpg')

echo "原始 $(( total_in / 1024 ))KB -> 输出 $(( total_out / 1024 ))KB"

6.2 视觉回归抽样

一句话总结: 自动对比原图与处理后图的感知哈希,差异过大的样本人工复核。

# 计算感知哈希(需 ImageMagick 支持 phash)
phash_of() { convert "$1" -resize 32x32 -colorspace gray -format '%#' info:; }
for f in *.jpg; do
  out="${f%.jpg}_small.jpg"; [[ -f "$out" ]] || continue
  d=$(compare -metric PHASH "$f" "$out" null: 2>&1 || true)
  awk -v d="$d" -v f="$f" 'BEGIN { if (d+0 > 15) print "差异大: " f, d }'
done

7. 实战:可续跑的媒体处理流水线

一句话总结: 把探测、处理、校验、重试串成一条流水线,用状态文件保证任何中断都能接着跑。

#!/usr/bin/env bash
set -euo pipefail

SRC_DIR="${1:?用法: media-pipeline.sh <源目录>}"
OUT_DIR="${OUT_DIR:-./out}"
STATE_DIR=".media-state"
JOBS="${JOBS:-$(( $(nproc) / 2 ))}"
[[ $JOBS -lt 1 ]] && JOBS=1

mkdir -p "$OUT_DIR" "$STATE_DIR"
DONE="$STATE_DIR/done.list"; FAILED="$STATE_DIR/failed.list"
touch "$DONE"; : > "$FAILED"

process_one() {
  local src="$1" dst
  grep -qxF "$src" "$DONE" 2>/dev/null && return 0
  dst="$OUT_DIR/${src#"$SRC_DIR"/}"; dst="${dst%.*}.mp4"

  if ! ffmpeg -nostdin -y -loglevel error -i "$src" \
        -vf scale=-2:720 -c:v libx264 -crf 23 -c:a aac -b:a 128k \
        "$dst" </dev/null; then
    printf '%s\n' "$src" >> "$FAILED"; return 1
  fi
  if ffprobe -v error -show_entries format=duration -of default=nw=1 "$dst" \
       | grep -q '^duration='; then
    printf '%s\n' "$src" >> "$DONE"
  else
    printf '%s\n' "$src" >> "$FAILED"
  fi
}
export -f process_one
export SRC_DIR OUT_DIR DONE FAILED

find "$SRC_DIR" -type f \( -name '*.mov' -o -name '*.mkv' -o -name '*.avi' \) -print0 \
  | xargs -0 -P "$JOBS" -I {} bash -c 'process_one "$@"' _ {}

echo "完成: $(wc -l < "$DONE")  失败: $(wc -l < "$FAILED")"
[[ -s "$FAILED" ]] && { echo "失败清单:"; cat "$FAILED"; exit 1; }

7.1 失败重跑

一句话总结: 流水线跑完只需针对失败清单重跑,成功后从失败清单移除。

# 重跑失败项
if [[ -s "$FAILED" ]]; then
  cp "$FAILED" "$FAILED.bak"
  : > "$FAILED"
  xargs -a "$FAILED.bak" -P "$JOBS" -I {} bash -c 'process_one "$@"' _ {}
fi

7.2 资源与优雅退出

一句话总结: 加 trap 清理临时文件,加 nice/ionice 让流水线不抢占生产服务资源。

# 优雅退出与资源让步
cleanup() { rm -f "$OUT_DIR"/*.tmp.mp4 2>/dev/null || true; }
trap cleanup EXIT INT TERM

# 低优先级运行
nice -n 19 ionice -c3 bash media-pipeline.sh /data/videos

8. 总结

环节要点
场景特征CPU 密集、长耗时、任务独立,天然适合并行
幂等输出比输入新则跳过,是最可靠的续跑判据
ImageMagickmogrify 原地批量、convert 一对一,务必设资源上限
ffmpeg参数顺序敏感,-nostdin -y 是并发必备
并发视频取核数一半、图片取核数,按内存再收紧
状态done/failed 清单落地,失败项单独重跑
元数据发布剥离 GPS 与设备,归档保留时间并校正 mtime
校验ffprobe/identify 验证产物,0 字节与截断都要拦

媒体批处理与文本批处理最大的不同在于成本:一次错误的视频转码可能烧掉几小时 CPU,所以「先校验产物、再记录状态」必须写进流程。把幂等判断、失败清单、资源上限三件事做扎实,流水线就能在无人值守的夜里稳定跑完。下一站我们离开本机,去看云上资源的批量管理。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「shell」更多文章

  1. 任务编排与 Makefile 实战
  2. 文件监控与事件驱动流水线实战
  3. 结构化数据清洗与报表生成实战