From 511fbe88bf22269438bf705037ed82df1a3b045f Mon Sep 17 00:00:00 2001 From: Johnny Zhang Date: Fri, 21 Aug 2026 18:13:22 +0800 Subject: [PATCH] =?UTF-8?q?fix(video):=20=E9=A6=96=E5=B8=A7=E6=94=BE?= =?UTF-8?q?=E5=A4=A7=E6=94=B9=E7=94=A8=20NEAREST=EF=BC=8C=E4=B8=8D?= =?UTF-8?q?=E6=8A=8A=20sprite=20=E7=A1=AC=E8=BE=B9=E7=B3=8A=E6=8E=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 放大是把源像素铺成块,插值会造出源图没有的中间色;缩小仍用 LANCZOS。 与交付侧 _fit_to 的同一条规则对齐。 Closes #542 --- .../src/windup_framework/providers/sufy.py | 7 +++- backend/tests/test_sufy_video_download.py | 36 +++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/backend/packages/framework/src/windup_framework/providers/sufy.py b/backend/packages/framework/src/windup_framework/providers/sufy.py index 03f2ca19..70cffd49 100644 --- a/backend/packages/framework/src/windup_framework/providers/sufy.py +++ b/backend/packages/framework/src/windup_framework/providers/sufy.py @@ -68,6 +68,11 @@ def _fit_first_frame(frame: bytes, size: str, *, background: tuple[int, int, int 小于目标画布的输入必须**放大**:128x128 的 sprite 原尺寸贴进 1280x720 只占 13% 高, 等于自愿把主体有效分辨率砍掉七分之六,之后无论 i2v 还是重抠图都补不回来。 + + **放大用 NEAREST,缩小用 LANCZOS。** 放大是把一个源像素铺成一块,插值会在块边界 + 造出源图里没有的中间色:实测一张 256x256 的像素画母版放到 720x720,唯一色从 5982 + 涨到 32479(5.4 倍),硬边糊成渐变,而这张糊图正是喂给 i2v 的输入。缩小反过来, + NEAREST 会丢样出锯齿。交付侧的 ``_fit_to`` 早就是这条规则,这里与它对齐。 """ from PIL import Image @@ -83,7 +88,7 @@ def _fit_first_frame(frame: bytes, size: str, *, background: tuple[int, int, int pad = im.getpixel((0, 0)) # 不透明输入沿用角点色,补边与画面自身背景连成一片 scale = min(w/im.width, h/im.height) tw, th = max(1, round(im.width*scale)), max(1, round(im.height*scale)) - fitted = im.resize((tw, th), Image.LANCZOS) + fitted = im.resize((tw, th), Image.NEAREST if scale > 1 else Image.LANCZOS) canvas = Image.new("RGB", (w, h), pad) canvas.paste(fitted, ((w - tw)//2, (h - th)//2)) buf = io.BytesIO() diff --git a/backend/tests/test_sufy_video_download.py b/backend/tests/test_sufy_video_download.py index 8fc0b7d7..17526f35 100644 --- a/backend/tests/test_sufy_video_download.py +++ b/backend/tests/test_sufy_video_download.py @@ -1044,3 +1044,39 @@ def test_square_first_frame_forms_a_720x720_content_region_in_a_1280x720_canvas( assert 715 <= cols.max()-cols.min()+1 <= 725, f"内容区宽 {cols.max()-cols.min()+1},应≈720" assert 715 <= rows.max()-rows.min()+1 <= 725, f"内容区高 {rows.max()-rows.min()+1},应≈720" assert abs(cols.min() - 280) <= 4, f"内容区左边界 {cols.min()},应≈280(左右各补 280)" + + +def test_upscaling_a_small_sprite_keeps_hard_edges_instead_of_interpolating_them(): + """放大用 NEAREST:插值会把 sprite 的硬边糊成渐变,而这张糊图正是喂给 i2v 的输入。 + + 判据取一条竖直硬边在成品里的**过渡带宽度**(既不接近左色、也不接近右色的像素数)。 + 倍率越大差距越明显,实测同一函数下:64x64 源(11.2x)NEAREST 4px、LANCZOS 20px; + 而 256x256 源(2.8x)只有 4px 对 5px —— 所以这条用小 sprite 测,大图分辨不出来。 + 小 sprite 也正是 #509 点名的场景。 + + 不测色数:JPEG q90 本身就把 7704 色变成 23000 色,两种滤波在那个量上分不开 + (12 张真实母版配对实测 1.0 倍),拿色数当判据会得出"没区别"的错结论。 + """ + import io as _io + + import numpy as _np + from PIL import Image as _Image + + left, right = (30, 60, 200), (240, 200, 40) + src = _np.zeros((64, 64, 4), _np.uint8) + src[:, :, 3] = 255 + src[:, :32] = (*left, 255) + src[:, 32:] = (*right, 255) + buf = _io.BytesIO() + _Image.fromarray(src, "RGBA").save(buf, "PNG") + + im = _submitted_first_frame(buf.getvalue()) + # 只取内容区:1280 宽的画布里,720x720 的内容区居中,两侧各 280px 是补边 + row = _np.asarray(im.convert("RGB"))[360].astype(int)[280:1000] + far_from_left = _np.abs(row - _np.array(left)).sum(1) > 40 + far_from_right = _np.abs(row - _np.array(right)).sum(1) > 40 + width = int((far_from_left & far_from_right).sum()) + assert width <= 8, ( + f"硬边过渡带 {width}px,说明放大用了插值(实测 LANCZOS 20px、BICUBIC 10px、" + f"NEAREST 4px)。sprite 的硬边被糊掉后,i2v 拿到的就是一张糊图。" + )