first commit
This commit is contained in:
346
image_test/process.py
Normal file
346
image_test/process.py
Normal file
@@ -0,0 +1,346 @@
|
||||
"""签名图片处理诊断脚本(独立调试用)。
|
||||
|
||||
工作流:
|
||||
把待测试的图片 (.png / .jpg / .jpeg) 丢到 image_test/input/ 下,
|
||||
直接双击 run.bat 或 `python image_test/process.py` 即可。
|
||||
脚本会自动遍历 input/ 下所有图片,每张图都跑完整流水线并把
|
||||
每一步中间结果存到 image_test/output/<图片名>/ 子目录。
|
||||
|
||||
参数调整: 直接改下方 CONFIG 段,不用命令行。
|
||||
|
||||
输出结构 (每张图一个子目录):
|
||||
image_test/output/<stem>/
|
||||
00_original.png 原图
|
||||
01_binarized.png 二值化后(仅 is_signature=true 时)
|
||||
02_white_removed.png 白底转透明后
|
||||
03_border_cropped.png 透明边框裁剪后
|
||||
04_scaled.png 按高度等比缩放后
|
||||
05_noised.png 边缘噪声后
|
||||
06_rotated.png 旋转后
|
||||
07_final_cropped.png 紧致裁剪后(最终输出)
|
||||
final.png 最终输出(等价于插入 Word 的那张)
|
||||
diagnostics.txt 每步尺寸 / 内容像素占比 / bbox
|
||||
"""
|
||||
import io
|
||||
import random
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image, ImageChops
|
||||
|
||||
# ============================================================================
|
||||
# 配置区:直接改这里,不用命令行
|
||||
# ============================================================================
|
||||
CONFIG = {
|
||||
# 处理对象
|
||||
"is_signature": True, # True: 手写签字(先二值化);False: 普通签章(不二值化)
|
||||
|
||||
# 处理参数(对应正式流水线的全局参数)
|
||||
"height_cm": 1, # 目标高度(cm);None 表示不缩放
|
||||
"noise_level": 0, # 边缘噪声 0~10
|
||||
"rotate_min": 0, # 旋转最小角度
|
||||
"rotate_max": 0, # 旋转最大角度
|
||||
"dpi": 96, # 缩放 DPI
|
||||
|
||||
# 算法阈值(出问题时优先调这几个)
|
||||
"alpha_threshold": 30, # 紧致裁剪: alpha 高于此值视为有内容
|
||||
"brightness_threshold": 210, # 紧致裁剪: 亮度低于此值视为暗(笔画)
|
||||
"binarize_threshold": 128, # 二值化阈值
|
||||
"white_threshold": 240, # 抠白底: RGB 都高于此值视为白底
|
||||
|
||||
# 调试控制
|
||||
"stop_at": "all", # all / original / binarize / remove_white / crop_border / scale / noise / rotate
|
||||
"seed": 42, # 随机种子(让噪声/旋转结果可复现;None=随机)
|
||||
"clear_output": True, # 每次运行前清空 output 目录
|
||||
}
|
||||
|
||||
# 输入输出目录(相对脚本位置,固定)
|
||||
BASE_DIR = Path(__file__).resolve().parent
|
||||
INPUT_DIR = BASE_DIR / "input"
|
||||
OUTPUT_DIR = BASE_DIR / "output"
|
||||
|
||||
IMAGE_EXTS = {".png", ".jpg", ".jpeg"}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 以下函数从 lib/image_processor.py 原样复制,方便独立调参
|
||||
# ============================================================================
|
||||
|
||||
def binarize(img, threshold=128):
|
||||
"""手写签字黑白二值化:>threshold -> 255(白),否则 0(黑)。"""
|
||||
if img.mode != 'L':
|
||||
img = img.convert('L')
|
||||
img = img.point(lambda p: 255 if p > threshold else 0)
|
||||
img = img.convert('RGBA')
|
||||
return img
|
||||
|
||||
|
||||
def remove_white_background(img, white_threshold=240):
|
||||
"""接近白色的像素转为全透明。"""
|
||||
if img.mode != 'RGBA':
|
||||
img = img.convert('RGBA')
|
||||
datas = img.getdata()
|
||||
new_data = []
|
||||
for item in datas:
|
||||
r, g, b, a = item
|
||||
if r > white_threshold and g > white_threshold and b > white_threshold:
|
||||
new_data.append((255, 255, 255, 0))
|
||||
else:
|
||||
new_data.append((r, g, b, a))
|
||||
img.putdata(new_data)
|
||||
return img
|
||||
|
||||
|
||||
def crop_transparent_border(img):
|
||||
"""按 alpha 通道 getbbox 裁掉四周完全透明区域。"""
|
||||
if img.mode != 'RGBA':
|
||||
img = img.convert('RGBA')
|
||||
bbox = img.getbbox()
|
||||
if bbox:
|
||||
img = img.crop(bbox)
|
||||
return img
|
||||
|
||||
|
||||
def scale_by_height(img, height_cm, dpi=96):
|
||||
"""按目标高度(厘米)等比缩放。"""
|
||||
try:
|
||||
from PIL.Image import Resampling
|
||||
resample = Resampling.LANCZOS
|
||||
except ImportError:
|
||||
resample = Image.LANCZOS
|
||||
target_h = int(height_cm * dpi / 2.54)
|
||||
if target_h <= 0:
|
||||
return img
|
||||
w, h = img.size
|
||||
if h == 0:
|
||||
return img
|
||||
ratio = target_h / h
|
||||
new_w = max(1, int(w * ratio))
|
||||
new_h = target_h
|
||||
return img.resize((new_w, new_h), resample)
|
||||
|
||||
|
||||
def add_edge_noise(img, noise_level):
|
||||
"""在笔画边缘添加随机噪点,模拟印章不均匀。"""
|
||||
if img.mode != 'RGBA':
|
||||
img = img.convert('RGBA')
|
||||
w, h = img.size
|
||||
pixels = img.load()
|
||||
intensity = noise_level * 2
|
||||
for x in range(w):
|
||||
for y in range(h):
|
||||
r, g, b, a = pixels[x, y]
|
||||
if a == 0:
|
||||
continue
|
||||
is_edge = False
|
||||
for dx in range(-2, 3):
|
||||
for dy in range(-2, 3):
|
||||
nx, ny = x + dx, y + dy
|
||||
if 0 <= nx < w and 0 <= ny < h:
|
||||
if pixels[nx, ny][3] == 0:
|
||||
is_edge = True
|
||||
break
|
||||
if is_edge:
|
||||
break
|
||||
if is_edge and random.random() < noise_level / 10.0:
|
||||
offset_x = random.randint(-intensity, intensity)
|
||||
offset_y = random.randint(-intensity, intensity)
|
||||
nx = x + offset_x
|
||||
ny = y + offset_y
|
||||
if 0 <= nx < w and 0 <= ny < h:
|
||||
nr = min(255, max(0, r + random.randint(-20, 20)))
|
||||
ng = min(255, max(0, g + random.randint(-20, 20)))
|
||||
nb = min(255, max(0, b + random.randint(-20, 20)))
|
||||
na = min(255, max(0, a + random.randint(-30, 10)))
|
||||
if pixels[nx, ny][3] == 0:
|
||||
pixels[nx, ny] = (nr, ng, nb, na)
|
||||
return img
|
||||
|
||||
|
||||
def rotate_image(img, rotate_min, rotate_max):
|
||||
"""随机角度旋转,expand=True 扩展画布以容纳旋转后内容。"""
|
||||
try:
|
||||
from PIL.Image import Resampling
|
||||
resample = Resampling.BICUBIC
|
||||
except ImportError:
|
||||
resample = Image.BICUBIC
|
||||
if rotate_min > rotate_max:
|
||||
rotate_min, rotate_max = rotate_max, rotate_min
|
||||
angle = random.randint(rotate_min, rotate_max)
|
||||
return img.rotate(angle, resample=resample, expand=True,
|
||||
fillcolor=(0, 0, 0, 0))
|
||||
|
||||
|
||||
def crop_to_content(img, alpha_threshold=30, brightness_threshold=210):
|
||||
"""末尾紧致裁剪:(alpha > th) AND (亮度 < th)。"""
|
||||
if img.mode != 'RGBA':
|
||||
img = img.convert('RGBA')
|
||||
r, g, b, a = img.split()
|
||||
luma = Image.merge('RGB', (r, g, b)).convert('L')
|
||||
alpha_mask = a.point(lambda p: 255 if p > alpha_threshold else 0)
|
||||
luma_mask = luma.point(lambda p: 255 if p < brightness_threshold else 0)
|
||||
combined = ImageChops.darker(alpha_mask, luma_mask)
|
||||
bbox = combined.getbbox()
|
||||
if bbox and bbox != (0, 0, img.size[0], img.size[1]):
|
||||
img = img.crop(bbox)
|
||||
return img, bbox
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 诊断辅助
|
||||
# ============================================================================
|
||||
|
||||
def content_stats(img, alpha_threshold=30, brightness_threshold=210):
|
||||
if img.mode != 'RGBA':
|
||||
img = img.convert('RGBA')
|
||||
r, g, b, a = img.split()
|
||||
luma = Image.merge('RGB', (r, g, b)).convert('L')
|
||||
alpha_mask = a.point(lambda p: 255 if p > alpha_threshold else 0)
|
||||
luma_mask = luma.point(lambda p: 255 if p < brightness_threshold else 0)
|
||||
combined = ImageChops.darker(alpha_mask, luma_mask)
|
||||
total = img.size[0] * img.size[1] or 1
|
||||
ha, hl, hc = alpha_mask.histogram(), luma_mask.histogram(), combined.histogram()
|
||||
return {
|
||||
'size': img.size,
|
||||
'alpha_pct': 100.0 * sum(ha[1:]) / total,
|
||||
'dark_pct': 100.0 * sum(hl[1:]) / total,
|
||||
'content_pct': 100.0 * sum(hc[1:]) / total,
|
||||
'bbox': combined.getbbox(),
|
||||
}
|
||||
|
||||
|
||||
def save_step(out_dir, idx, name, img, diag_lines, alpha_th, bright_th):
|
||||
if img is None:
|
||||
diag_lines.append(f"[{idx:02d}] {name:18s} SKIPPED")
|
||||
return
|
||||
path = out_dir / f"{idx:02d}_{name}.png"
|
||||
img.save(path)
|
||||
s = content_stats(img, alpha_th, bright_th)
|
||||
line = (f"[{idx:02d}] {name:18s} size={str(s['size']):14s} "
|
||||
f"alpha={s['alpha_pct']:5.1f}% dark={s['dark_pct']:5.1f}% "
|
||||
f"content={s['content_pct']:5.1f}% bbox={s['bbox']}")
|
||||
diag_lines.append(line)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 单张图处理
|
||||
# ============================================================================
|
||||
|
||||
def process_one(input_path, out_dir, cfg):
|
||||
out_dir = Path(out_dir)
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
diag = []
|
||||
img = Image.open(input_path)
|
||||
diag.append(f"输入: {input_path.name}")
|
||||
diag.append(f"模式: {img.mode} 尺寸: {img.size}")
|
||||
diag.append(f"参数: is_sig={cfg['is_signature']} height_cm={cfg['height_cm']} "
|
||||
f"noise={cfg['noise_level']} rotate=[{cfg['rotate_min']},{cfg['rotate_max']}] dpi={cfg['dpi']}")
|
||||
diag.append(f"阈值: alpha>{cfg['alpha_threshold']} 亮度<{cfg['brightness_threshold']} "
|
||||
f"二值化>{cfg['binarize_threshold']} 白底>{cfg['white_threshold']}")
|
||||
diag.append("-" * 80)
|
||||
|
||||
save_step(out_dir, 0, "original", img, diag,
|
||||
cfg['alpha_threshold'], cfg['brightness_threshold'])
|
||||
if cfg['stop_at'] == 'original':
|
||||
return diag
|
||||
|
||||
if cfg['is_signature']:
|
||||
img = binarize(img, threshold=cfg['binarize_threshold'])
|
||||
save_step(out_dir, 1, "binarized",
|
||||
img if cfg['is_signature'] else None, diag,
|
||||
cfg['alpha_threshold'], cfg['brightness_threshold'])
|
||||
if cfg['stop_at'] == 'binarize':
|
||||
return diag
|
||||
|
||||
img = remove_white_background(img, white_threshold=cfg['white_threshold'])
|
||||
save_step(out_dir, 2, "white_removed", img, diag,
|
||||
cfg['alpha_threshold'], cfg['brightness_threshold'])
|
||||
if cfg['stop_at'] == 'remove_white':
|
||||
return diag
|
||||
|
||||
img = crop_transparent_border(img)
|
||||
save_step(out_dir, 3, "border_cropped", img, diag,
|
||||
cfg['alpha_threshold'], cfg['brightness_threshold'])
|
||||
if cfg['stop_at'] == 'crop_border':
|
||||
return diag
|
||||
|
||||
h = cfg['height_cm']
|
||||
if h is not None and h > 0:
|
||||
img = scale_by_height(img, h, dpi=cfg['dpi'])
|
||||
save_step(out_dir, 4, "scaled", img if (h is not None and h > 0) else None,
|
||||
diag, cfg['alpha_threshold'], cfg['brightness_threshold'])
|
||||
if cfg['stop_at'] == 'scale':
|
||||
return diag
|
||||
|
||||
if cfg['noise_level'] > 0:
|
||||
img = add_edge_noise(img, cfg['noise_level'])
|
||||
save_step(out_dir, 5, "noised", img if cfg['noise_level'] > 0 else None,
|
||||
diag, cfg['alpha_threshold'], cfg['brightness_threshold'])
|
||||
if cfg['stop_at'] == 'noise':
|
||||
return diag
|
||||
|
||||
if cfg['rotate_min'] != 0 or cfg['rotate_max'] != 0:
|
||||
img = rotate_image(img, cfg['rotate_min'], cfg['rotate_max'])
|
||||
save_step(out_dir, 6, "rotated",
|
||||
img if (cfg['rotate_min'] != 0 or cfg['rotate_max'] != 0) else None,
|
||||
diag, cfg['alpha_threshold'], cfg['brightness_threshold'])
|
||||
if cfg['stop_at'] == 'rotate':
|
||||
return diag
|
||||
|
||||
img, _ = crop_to_content(img,
|
||||
alpha_threshold=cfg['alpha_threshold'],
|
||||
brightness_threshold=cfg['brightness_threshold'])
|
||||
save_step(out_dir, 7, "final_cropped", img, diag,
|
||||
cfg['alpha_threshold'], cfg['brightness_threshold'])
|
||||
|
||||
final_buf = io.BytesIO()
|
||||
img.save(final_buf, format='PNG')
|
||||
with open(out_dir / "final.png", 'wb') as f:
|
||||
f.write(final_buf.getvalue())
|
||||
diag.append("-" * 80)
|
||||
diag.append(f"最终输出: final.png size={img.size}")
|
||||
return diag
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 主入口:扫描 input/ 下所有图片
|
||||
# ============================================================================
|
||||
|
||||
def main():
|
||||
if not INPUT_DIR.exists():
|
||||
INPUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if CONFIG.get('seed') is not None:
|
||||
random.seed(CONFIG['seed'])
|
||||
|
||||
images = sorted([p for p in INPUT_DIR.iterdir()
|
||||
if p.suffix.lower() in IMAGE_EXTS and p.is_file()])
|
||||
|
||||
if not images:
|
||||
print(f"未在 {INPUT_DIR} 下找到 png/jpg 图片,请先把测试图片放进去")
|
||||
return
|
||||
|
||||
if CONFIG.get('clear_output') and OUTPUT_DIR.exists():
|
||||
shutil.rmtree(OUTPUT_DIR)
|
||||
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
print(f"找到 {len(images)} 张图: {[p.name for p in images]}")
|
||||
print(f"输出目录: {OUTPUT_DIR}\n")
|
||||
|
||||
for img_path in images:
|
||||
out_sub = OUTPUT_DIR / img_path.stem
|
||||
print(f"=== 处理: {img_path.name} ===")
|
||||
try:
|
||||
diag = process_one(img_path, out_sub, CONFIG)
|
||||
for line in diag:
|
||||
print(line)
|
||||
with open(out_sub / "diagnostics.txt", 'w', encoding='utf-8') as f:
|
||||
f.write("\n".join(diag))
|
||||
print(f"-> 输出: {out_sub}\n")
|
||||
except Exception as e:
|
||||
print(f"处理失败: {e}\n")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Reference in New Issue
Block a user