first commit
This commit is contained in:
251
lib/image_processor.py
Normal file
251
lib/image_processor.py
Normal 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()
|
||||
Reference in New Issue
Block a user