|
6 | 6 | import urllib.request |
7 | 7 | from io import BytesIO |
8 | 8 |
|
| 9 | +try: |
| 10 | + import numpy as np |
| 11 | + HAS_NUMPY = True |
| 12 | +except ImportError: |
| 13 | + HAS_NUMPY = False |
| 14 | + |
9 | 15 | try: |
10 | 16 | from PIL import ImageFilter, ImageOps, Image |
11 | 17 | from PIL.Image import Transpose |
@@ -36,6 +42,190 @@ def save_gif_and_print(bytes_io): |
36 | 42 | b64 = base64.b64encode(bytes_io.getvalue()).decode('utf-8') |
37 | 43 | print(f"BASE64:{b64}") |
38 | 44 |
|
| 45 | +def detect_grid_split_points(image, grid_dim): |
| 46 | + if not HAS_NUMPY: |
| 47 | + raise Exception("NUMPY_REQUIRED: 宫格分隔线检测需要 numpy 模块") |
| 48 | + |
| 49 | + img_array = np.array(image.convert('L')).astype(np.float32) |
| 50 | + h, w = img_array.shape |
| 51 | + |
| 52 | + vert_grad = np.abs(np.diff(img_array, axis=0)) |
| 53 | + horiz_proj = np.sum(vert_grad, axis=1) |
| 54 | + |
| 55 | + horiz_grad = np.abs(np.diff(img_array, axis=1)) |
| 56 | + vert_proj = np.sum(horiz_grad, axis=0) |
| 57 | + |
| 58 | + window = max(3, min(h, w) // 50) |
| 59 | + kernel = np.ones(window, dtype=np.float32) / window |
| 60 | + vert_proj_s = np.convolve(vert_proj, kernel, mode='same') |
| 61 | + horiz_proj_s = np.convolve(horiz_proj, kernel, mode='same') |
| 62 | + |
| 63 | + def find_peaks(proj, n_needed, min_dist): |
| 64 | + candidates = [] |
| 65 | + for i in range(1, len(proj) - 1): |
| 66 | + if proj[i] > proj[i - 1] and proj[i] >= proj[i + 1]: |
| 67 | + candidates.append((i, proj[i])) |
| 68 | + candidates.sort(key=lambda x: -x[1]) |
| 69 | + selected = [] |
| 70 | + for pos, val in candidates: |
| 71 | + if all(abs(pos - s) >= min_dist for s, _ in selected): |
| 72 | + selected.append((pos, val)) |
| 73 | + if len(selected) == n_needed: |
| 74 | + break |
| 75 | + selected.sort(key=lambda x: x[0]) |
| 76 | + return [p for p, _ in selected] |
| 77 | + |
| 78 | + n_lines = grid_dim - 1 |
| 79 | + min_gap = min(h, w) // (grid_dim * 2) |
| 80 | + |
| 81 | + v_lines = find_peaks(vert_proj_s, n_lines, min_gap) |
| 82 | + h_lines = find_peaks(horiz_proj_s, n_lines, min_gap) |
| 83 | + |
| 84 | + if len(v_lines) != n_lines or len(h_lines) != n_lines: |
| 85 | + return None |
| 86 | + |
| 87 | + col_splits = [0] + [p + 1 for p in v_lines] + [w] |
| 88 | + row_splits = [0] + [p + 1 for p in h_lines] + [h] |
| 89 | + |
| 90 | + cw = [col_splits[i + 1] - col_splits[i] for i in range(grid_dim)] |
| 91 | + rh = [row_splits[i + 1] - row_splits[i] for i in range(grid_dim)] |
| 92 | + avg_w = sum(cw) / grid_dim |
| 93 | + avg_h = sum(rh) / grid_dim |
| 94 | + if any(x < 0.55 * avg_w for x in cw) or any(x < 0.55 * avg_h for x in rh): |
| 95 | + return None |
| 96 | + if any(x > 1.8 * avg_w for x in cw) or any(x > 1.8 * avg_h for x in rh): |
| 97 | + return None |
| 98 | + |
| 99 | + return row_splits, col_splits |
| 100 | + |
| 101 | +def _find_content_bbox(frame_img, threshold=30): |
| 102 | + """检测帧内主体内容的 bounding box (x1, y1, x2, y2)。 |
| 103 | + 通过与四角背景色对比来区分前景主体。 |
| 104 | + """ |
| 105 | + if not HAS_NUMPY: |
| 106 | + raise Exception("NUMPY_REQUIRED: 主体内容检测需要 numpy 模块") |
| 107 | + |
| 108 | + img_arr = np.array(frame_img.convert('L'), dtype=np.float32) |
| 109 | + h, w = img_arr.shape |
| 110 | + if h < 4 or w < 4: |
| 111 | + return None |
| 112 | + |
| 113 | + # 取四角各 3x3 区域的中位数作为背景色估计(抗圆角干扰) |
| 114 | + corner_size = max(2, min(h, w) // 20) |
| 115 | + corners = [ |
| 116 | + img_arr[:corner_size, :corner_size], # 左上 |
| 117 | + img_arr[:corner_size, -corner_size:], # 右上 |
| 118 | + img_arr[-corner_size:, :corner_size], # 左下 |
| 119 | + img_arr[-corner_size:, -corner_size:], # 右下 |
| 120 | + ] |
| 121 | + bg_val = float(np.median(np.concatenate([c.ravel() for c in corners]))) |
| 122 | + |
| 123 | + # 差异大于阈值的像素视为前景 |
| 124 | + mask = np.abs(img_arr - bg_val) > threshold |
| 125 | + |
| 126 | + # 如果前景太少(< 5%),说明可能是纯色帧,返回整帧 |
| 127 | + if np.sum(mask) < 0.05 * h * w: |
| 128 | + return (0, 0, w, h) |
| 129 | + |
| 130 | + # 找前景的最小包围框 |
| 131 | + rows_any = np.any(mask, axis=1) |
| 132 | + cols_any = np.any(mask, axis=0) |
| 133 | + row_indices = np.where(rows_any)[0] |
| 134 | + col_indices = np.where(cols_any)[0] |
| 135 | + if len(row_indices) == 0 or len(col_indices) == 0: |
| 136 | + return (0, 0, w, h) |
| 137 | + |
| 138 | + y1, y2 = int(row_indices[0]), int(row_indices[-1]) + 1 |
| 139 | + x1, x2 = int(col_indices[0]), int(col_indices[-1]) + 1 |
| 140 | + return (x1, y1, x2, y2) |
| 141 | + |
| 142 | +def _align_frames_by_content(raw_frames, target_size): |
| 143 | + """基于主体质心将所有帧对齐到统一画布,消除帧间抖动。 |
| 144 | +
|
| 145 | + 算法: |
| 146 | + 1. 检测每帧主体 bbox → 计算相对质心比例 |
| 147 | + 2. 用所有帧质心比例的中位数作为全局锚点(抗离群值) |
| 148 | + 3. 将每帧缩放到 target_size,根据质心偏差调整粘贴位置 |
| 149 | + 4. 返回对齐后的 PIL.Image 帧列表 |
| 150 | + """ |
| 151 | + if not raw_frames: |
| 152 | + return [] |
| 153 | + if not HAS_NUMPY: |
| 154 | + raise Exception("NUMPY_REQUIRED: 帧主体对齐(防抖)需要 numpy 模块") |
| 155 | + |
| 156 | + # —— 收集每帧的主体质心比例 (cx_ratio, cy_ratio) —— |
| 157 | + cx_ratios = [] |
| 158 | + cy_ratios = [] |
| 159 | + bboxes = [] |
| 160 | + for frame in raw_frames: |
| 161 | + bbox = _find_content_bbox(frame) |
| 162 | + bboxes.append(bbox) |
| 163 | + if bbox: |
| 164 | + x1, y1, x2, y2 = bbox |
| 165 | + cx = (x1 + x2) / 2.0 / frame.width |
| 166 | + cy = (y1 + y2) / 2.0 / frame.height |
| 167 | + cx_ratios.append(cx) |
| 168 | + cy_ratios.append(cy) |
| 169 | + |
| 170 | + if not cx_ratios: |
| 171 | + return [f.resize((target_size, target_size), Image.LANCZOS) for f in raw_frames] |
| 172 | + |
| 173 | + # 全局锚点 = 质心比例的中位数 |
| 174 | + anchor_cx = float(np.median(cx_ratios)) |
| 175 | + anchor_cy = float(np.median(cy_ratios)) |
| 176 | + |
| 177 | + aligned = [] |
| 178 | + canvas_center = target_size / 2.0 |
| 179 | + |
| 180 | + for i, frame in enumerate(raw_frames): |
| 181 | + # 先将帧缩放到目标尺寸 |
| 182 | + resized = frame.resize((target_size, target_size), Image.LANCZOS) |
| 183 | + |
| 184 | + bbox = bboxes[i] |
| 185 | + if bbox is None: |
| 186 | + aligned.append(resized) |
| 187 | + continue |
| 188 | + |
| 189 | + x1, y1, x2, y2 = bbox |
| 190 | + # 当前帧的质心在 target 尺寸下的映射位置 |
| 191 | + cx_in_target = ((x1 + x2) / 2.0 / frame.width) * target_size |
| 192 | + cy_in_target = ((y1 + y2) / 2.0 / frame.height) * target_size |
| 193 | + |
| 194 | + # 锚点在 target 尺寸下的位置 |
| 195 | + anchor_x = anchor_cx * target_size |
| 196 | + anchor_y = anchor_cy * target_size |
| 197 | + |
| 198 | + # 偏移 = 锚点位置 - 当前质心位置(将质心移到锚点) |
| 199 | + offset_x = int(round(anchor_x - cx_in_target)) |
| 200 | + offset_y = int(round(anchor_y - cy_in_target)) |
| 201 | + |
| 202 | + # 如果偏移量很小(< 2px),跳过对齐避免不必要的模糊 |
| 203 | + if abs(offset_x) < 2 and abs(offset_y) < 2: |
| 204 | + aligned.append(resized) |
| 205 | + continue |
| 206 | + |
| 207 | + # 限制偏移量,防止主体被移出画布边界 |
| 208 | + max_shift = target_size // 8 |
| 209 | + offset_x = max(-max_shift, min(max_shift, offset_x)) |
| 210 | + offset_y = max(-max_shift, min(max_shift, offset_y)) |
| 211 | + |
| 212 | + # 创建画布并粘贴偏移后的帧 |
| 213 | + # 使用与背景色相近的颜色填充(取四角均值) |
| 214 | + bg_bbox = _find_content_bbox(frame, threshold=10) |
| 215 | + if bg_bbox and frame.mode in ('RGB', 'RGBA'): |
| 216 | + arr = np.array(frame.convert('RGB')) |
| 217 | + cs = max(2, min(frame.height, frame.width) // 20) |
| 218 | + bg_r = int(np.median(arr[:cs, :cs, :].reshape(-1, 3), axis=0).mean()) |
| 219 | + bg_color = (bg_r, bg_r, bg_r) |
| 220 | + else: |
| 221 | + bg_color = (255, 255, 255) |
| 222 | + |
| 223 | + canvas = Image.new('RGB', (target_size, target_size), bg_color) |
| 224 | + canvas.paste(resized, (offset_x, offset_y)) |
| 225 | + aligned.append(canvas) |
| 226 | + |
| 227 | + return aligned |
| 228 | + |
39 | 229 | def parse_frame_duration(arg): |
40 | 230 | if not arg: |
41 | 231 | return 300 |
@@ -215,29 +405,77 @@ def process_image(): |
215 | 405 |
|
216 | 406 | elif cmd == "宫格转gif": |
217 | 407 | grid_size, duration = parse_grid_gif_args(arg) |
218 | | - image = imgs[0].image |
219 | | - if image.width != image.height: |
220 | | - raise Exception("宫格转gif 需要正方形宫格图片") |
| 408 | + image = imgs[0].image.convert('RGB') |
| 409 | + img_w, img_h = image.size |
221 | 410 |
|
222 | | - usable_side = image.width - (image.width % grid_size) |
223 | | - if usable_side < grid_size: |
224 | | - raise Exception("图片尺寸过小,无法切分宫格") |
| 411 | + # 放宽正方形限制:允许宽高比在 0.8~1.25 范围内 |
| 412 | + aspect = img_w / img_h if img_h > 0 else 1 |
| 413 | + if aspect < 0.5 or aspect > 2.0: |
| 414 | + raise Exception("宫格转gif 需要接近正方形的宫格图片(宽高比不超过 2:1)") |
225 | 415 |
|
226 | | - offset = (image.width - usable_side) // 2 |
227 | | - if usable_side != image.width: |
228 | | - image = image.crop((offset, offset, offset + usable_side, offset + usable_side)) |
| 416 | + # —— 第一步:智能检测分隔线位置(强依赖 numpy)—— |
| 417 | + split_result = detect_grid_split_points(image, grid_size) |
229 | 418 |
|
230 | | - frame_size = usable_side // grid_size |
231 | | - frames = [] |
232 | | - for row in range(grid_size): |
233 | | - for col in range(grid_size): |
234 | | - left = col * frame_size |
235 | | - top = row * frame_size |
236 | | - frame = image.crop((left, top, left + frame_size, top + frame_size)).copy() |
237 | | - frames.append(frame) |
| 419 | + raw_frames = [] |
| 420 | + if split_result: |
| 421 | + # 使用检测到的精确分割点切割(避开分隔线) |
| 422 | + row_splits, col_splits = split_result |
| 423 | + for row in range(grid_size): |
| 424 | + for col in range(grid_size): |
| 425 | + left = col_splits[col] |
| 426 | + top = row_splits[row] |
| 427 | + right = col_splits[col + 1] |
| 428 | + bottom = row_splits[row + 1] |
| 429 | + frame = image.crop((left, top, right, bottom)).copy() |
| 430 | + raw_frames.append(frame) |
| 431 | + else: |
| 432 | + # 回退:基于较短边做均等切割 |
| 433 | + base_side = min(img_w, img_h) |
| 434 | + usable_side = base_side - (base_side % grid_size) |
| 435 | + if usable_side < grid_size: |
| 436 | + raise Exception("图片尺寸过小,无法切分宫格") |
| 437 | + |
| 438 | + offset_x = (img_w - usable_side) // 2 |
| 439 | + offset_y = (img_h - usable_side) // 2 |
| 440 | + cropped = image.crop((offset_x, offset_y, |
| 441 | + offset_x + usable_side, offset_y + usable_side)) |
| 442 | + |
| 443 | + frame_size = usable_side // grid_size |
| 444 | + for row in range(grid_size): |
| 445 | + for col in range(grid_size): |
| 446 | + left = col * frame_size |
| 447 | + top = row * frame_size |
| 448 | + frame = cropped.crop((left, top, |
| 449 | + left + frame_size, top + frame_size)).copy() |
| 450 | + raw_frames.append(frame) |
| 451 | + |
| 452 | + if not raw_frames: |
| 453 | + raise Exception("切分宫格失败,未获取到有效帧") |
| 454 | + |
| 455 | + # —— 第二步:统一尺寸 + 主体对齐(防抖核心)—— |
| 456 | + # 目标尺寸取所有帧面积中位数的边长(到达此处 numpy 已确保可用) |
| 457 | + areas = [f.width * f.height for f in raw_frames] |
| 458 | + median_area = float(np.median(areas)) |
| 459 | + target_size = int(round(median_area ** 0.5)) |
| 460 | + target_size = max(target_size, 16) # 安全下限 |
| 461 | + |
| 462 | + frames = _align_frames_by_content(raw_frames, target_size) |
| 463 | + |
| 464 | + # —— 第三步:转换为 P 模式并输出 GIF —— |
| 465 | + gif_frames = [] |
| 466 | + for f in frames: |
| 467 | + if isinstance(f, Image.Image): |
| 468 | + gif_frames.append(f.convert('RGB').quantize(colors=256, method=2)) |
| 469 | + else: |
| 470 | + gif_frames.append(f) |
238 | 471 |
|
239 | 472 | out = BytesIO() |
240 | | - frames[0].save(out, format="GIF", save_all=True, append_images=frames[1:], loop=0, duration=duration) |
| 473 | + gif_frames[0].save( |
| 474 | + out, format="GIF", save_all=True, |
| 475 | + append_images=gif_frames[1:], |
| 476 | + loop=0, duration=duration, |
| 477 | + optimize=False |
| 478 | + ) |
241 | 479 | save_gif_and_print(out) |
242 | 480 |
|
243 | 481 | elif cmd == "四宫格": |
|
0 commit comments