Files
SignatureSystem/lib/image_processor.py
2026-07-20 13:16:17 +08:00

251 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import io
import random
import math
import logging
import numpy as np
from scipy import ndimage
from PIL import Image, ImageDraw, ImageChops
logger = logging.getLogger(__name__)
def process_image(image_bytes, is_signature=False, noise_level=0,
rotate_min=0, rotate_max=0, log_source='前端操作'):
"""处理签名/印章图片,返回紧致裁剪后的 PNG 字节。
``height_cm`` **不再在此函数处理**:图片处理始终在原始 DPI 下进行,
保证笔画细节不丢失;最终在 Word 里的显示尺寸由 ``field_codes.insert_image_after_field``
的 ``height_cm`` 参数控制Word 渲染时按文档 DPI 高质量地下采样)。
"""
img = Image.open(io.BytesIO(image_bytes))
logger.info("[图片处理] 原始图片: 模式=%s, 尺寸=%s", img.mode, str(img.size), extra={'log_source': log_source})
if is_signature:
img = _binarize(img, log_source)
img = _remove_white_background(img, log_source)
img = _crop_transparent_border(img, log_source)
if noise_level > 0:
img = _add_edge_noise(img, noise_level, log_source)
if rotate_min != 0 or rotate_max != 0:
img = _rotate_image(img, rotate_min, rotate_max, log_source)
# 剔除零散噪点簇(连通域面积过小):二值化可能产生离主体很远的孤立黑像素,
# 它们会撑大后续紧致裁剪的 bbox导致签名在图片里只占一角、四周大片空白。
# 必须在 _crop_to_content 之前执行,否则裁剪已经被噪点污染。
img = _remove_small_clusters(img, log_source)
# 流水线最后再做一次紧致裁剪:噪点和旋转会在签名外围产生低 alpha 像素,
# 撑大图片 bbox让签名不紧贴图片边缘Word 插入时按图片整体定位,
# 签名会被顶出段落可见区域。用 alpha 阈值裁剪,确保最终图片紧贴签名笔画。
img = _crop_to_content(img, log_source)
buf = io.BytesIO()
img.save(buf, format='PNG')
buf.seek(0)
result = buf.read()
logger.info("[图片处理] 处理完成: 最终尺寸=%s, 大小=%d bytes", str(img.size), len(result), extra={'log_source': log_source})
return result
def _remove_small_clusters(img, log_source, alpha_threshold=30, brightness_threshold=210,
min_area_ratio=0.0001, min_area_abs=50):
"""剔除零散噪点簇:用连通域分析找出面积过小的内容块并清除。
判定"内容像素"``(alpha > alpha_threshold) AND (亮度 < brightness_threshold)``
(与 :func:`_crop_to_content` 一致。8-连通域分组后,面积低于
``max(min_area_ratio × 图像总像素, min_area_abs)`` 的簇被视为噪点,
对应像素的 alpha 置 0。
双阈值设计:
- ``min_area_ratio=0.0001``0.01%):高分辨率图(如 2025×921的相对阈值
≈ 186 像素,足以剔除孤立黑像素(实测噪点簇 ≤ 13 px又保留真实笔画
(实测最小真实笔画 285 px
- ``min_area_abs=50``:低分辨率图的兜底,防止相对阈值过小失效
若需更激进清洗(如打印文档无小笔画),可把 ``min_area_ratio`` 调到 0.001。
"""
if img.mode != 'RGBA':
img = img.convert('RGBA')
arr = np.array(img)
alpha = arr[:, :, 3]
rgb_max = arr[:, :, :3].max(axis=2).astype(np.int16)
content_mask = (alpha > alpha_threshold) & (rgb_max < brightness_threshold)
if not content_mask.any():
return img
structure = np.ones((3, 3), dtype=int) # 8-连通
labeled, num_features = ndimage.label(content_mask, structure=structure)
if num_features == 0:
return img
total_pixels = content_mask.size
area_threshold = max(min_area_ratio * total_pixels, min_area_abs)
# sizes[0] 是背景,从 1 开始才是真实簇
sizes = ndimage.sum(content_mask, labeled, range(num_features + 1)).astype(np.int64)
small_labels = np.where(sizes < area_threshold)[0]
small_labels = small_labels[small_labels > 0] # 排除背景
if len(small_labels) == 0:
logger.info("[图片处理] 剔除噪点簇: 共 %d 簇,全部 >= 阈值 %.0f px无需清理",
num_features, area_threshold, extra={'log_source': log_source})
return img
small_mask = np.isin(labeled, small_labels)
removed_pixels = int(small_mask.sum())
removed_content = int(sizes[small_labels].sum())
arr[small_mask] = (0, 0, 0, 0)
cleaned = Image.fromarray(arr, 'RGBA')
logger.info("[图片处理] 剔除噪点簇: 共 %d 簇,移除 %d 簇 (%d 像素,占内容 %.2f%%)"
"阈值=%.0f px (相对 %.3f%% + 绝对 %d px)",
num_features, len(small_labels), removed_pixels,
100.0 * removed_content / max(int(content_mask.sum()), 1),
area_threshold, min_area_ratio * 100, min_area_abs,
extra={'log_source': log_source})
return cleaned
def _crop_to_content(img, log_source, alpha_threshold=30, brightness_threshold=210):
"""末尾紧致裁剪:联合 alpha 和 RGB 亮度判断内容区域。
内容判据:``(alpha > 30) AND (亮度 < 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) # 二值 mask 逐像素 min = AND
bbox = combined.getbbox()
total_px = img.size[0] * img.size[1] or 1
alpha_cnt = sum(alpha_mask.histogram()[1:])
dark_cnt = sum(luma_mask.histogram()[1:])
content_cnt = sum(combined.histogram()[1:])
logger.info("[图片处理] 紧致裁剪诊断: 尺寸=%s, bbox=%s, "
"高alpha像素=%d/%d (%.1f%%), 暗像素=%d/%d (%.1f%%), 内容像素=%d/%d (%.1f%%)",
str(img.size), str(bbox),
alpha_cnt, total_px, 100.0 * alpha_cnt / total_px,
dark_cnt, total_px, 100.0 * dark_cnt / total_px,
content_cnt, total_px, 100.0 * content_cnt / total_px,
extra={'log_source': log_source})
if bbox and bbox != (0, 0, img.size[0], img.size[1]):
before = img.size
img = img.crop(bbox)
logger.info("[图片处理] 紧致裁剪: %s -> %s (bbox=%s)",
str(before), str(img.size), str(bbox), extra={'log_source': log_source})
return img
def _binarize(img, log_source, threshold=128):
logger.info("[图片处理] 执行手写签字黑白二值化", extra={'log_source': log_source})
if img.mode != 'L':
img = img.convert('L')
# 阈值化二值化(保持 L 模式,避免 PIL point + mode='1' 的兼容性问题)
img = img.point(lambda x: 255 if x > threshold else 0)
img = img.convert('RGBA')
return img
def _remove_white_background(img, log_source):
logger.info("[图片处理] 执行白底转透明抠图", extra={'log_source': log_source})
if img.mode != 'RGBA':
img = img.convert('RGBA')
datas = img.getdata()
new_data = []
for item in datas:
r, g, b, a = item
if r > 240 and g > 240 and b > 240:
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, log_source):
logger.info("[图片处理] 执行透明边框裁剪", extra={'log_source': log_source})
if img.mode != 'RGBA':
img = img.convert('RGBA')
bbox = img.getbbox()
if bbox:
img = img.crop(bbox)
return img
def _add_edge_noise(img, noise_level, log_source):
logger.info("[图片处理] 添加边缘噪声: level=%d", noise_level, extra={'log_source': log_source})
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, log_source):
if rotate_min > rotate_max:
rotate_min, rotate_max = rotate_max, rotate_min
angle = random.randint(rotate_min, rotate_max)
logger.info("[图片处理] 旋转: %d度 (范围 %d~%d)", angle, rotate_min, rotate_max, extra={'log_source': log_source})
img = img.rotate(angle, resample=Image.BICUBIC, expand=True, fillcolor=(0, 0, 0, 0))
return img
def preview_remove_white(image_bytes):
img = Image.open(io.BytesIO(image_bytes))
img = _remove_white_background(img, '前端操作')
img = _crop_transparent_border(img, '前端操作')
buf = io.BytesIO()
img.save(buf, format='PNG')
buf.seek(0)
return buf.read()
def preview_binarize(image_bytes):
img = Image.open(io.BytesIO(image_bytes))
img = _binarize(img, '前端操作')
img = _remove_white_background(img, '前端操作')
img = _crop_transparent_border(img, '前端操作')
buf = io.BytesIO()
img.save(buf, format='PNG')
buf.seek(0)
return buf.read()