Skip to content

Commit 9663caf

Browse files
committed
feat: #宫格转gif 使用 PIL + numpy 精确切分
1 parent a30a93b commit 9663caf

2 files changed

Lines changed: 265 additions & 19 deletions

File tree

apps/PYImageTools.js

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,12 @@ export class ImageTools extends plugin {
158158
// 执行脚本
159159
const { stdout } = await this.runPythonScript(args);
160160

161+
// 宫格转gif 等功能强依赖 numpy,缺失时不再静默降级,直接提示安装
162+
if (stdout.includes('NUMPY_REQUIRED')) {
163+
await e.reply(`❌ 该功能需要安装 numpy 模块才能保证效果(缺失会导致切分错位、帧抖动)\n\n请在终端运行以下命令安装:\npip install numpy`);
164+
return true;
165+
}
166+
161167
// 解析 stdout 获取 Base64 图片数据
162168
let outBase64List = stdout.trim().split(/\r?\n/)
163169
.filter(line => line.startsWith('BASE64:'))
@@ -191,7 +197,9 @@ export class ImageTools extends plugin {
191197
} else if (errStr.includes('ERROR:IMPORT:') || errStr.includes('No module named')) {
192198
let match = errStr.match(/No module named '([^']+)'/);
193199
let mod = match ? match[1] : '未知模块';
194-
await e.reply(`❌ 缺少必要的 Python 模块:${mod}\n\n请在终端运行以下命令安装:\npip install Pillow requests pil-utils`);
200+
await e.reply(`❌ 缺少必要的 Python 模块:${mod}\n\n请在终端运行以下命令安装:\npip install Pillow requests pil-utils numpy`);
201+
} else if (errStr.includes('NUMPY_REQUIRED')) {
202+
await e.reply(`❌ 该功能需要安装 numpy 模块才能保证效果(缺失会导致切分错位、帧抖动)\n\n请在终端运行以下命令安装:\npip install numpy`);
195203
} else {
196204
// 截取前200个字符作为错误提示反馈给用户
197205
let errLog = errStr.substring(0, 200);

utils/PYImagetools_core.py

Lines changed: 256 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,12 @@
66
import urllib.request
77
from io import BytesIO
88

9+
try:
10+
import numpy as np
11+
HAS_NUMPY = True
12+
except ImportError:
13+
HAS_NUMPY = False
14+
915
try:
1016
from PIL import ImageFilter, ImageOps, Image
1117
from PIL.Image import Transpose
@@ -36,6 +42,190 @@ def save_gif_and_print(bytes_io):
3642
b64 = base64.b64encode(bytes_io.getvalue()).decode('utf-8')
3743
print(f"BASE64:{b64}")
3844

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+
39229
def parse_frame_duration(arg):
40230
if not arg:
41231
return 300
@@ -215,29 +405,77 @@ def process_image():
215405

216406
elif cmd == "宫格转gif":
217407
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
221410

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)")
225415

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

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

239472
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+
)
241479
save_gif_and_print(out)
242480

243481
elif cmd == "四宫格":

0 commit comments

Comments
 (0)