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()