first commit

This commit is contained in:
lihansani
2026-07-20 13:16:17 +08:00
commit 7affeaa253
235 changed files with 42351 additions and 0 deletions

251
lib/image_processor.py Normal file
View File

@@ -0,0 +1,251 @@
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()