From 1eacee8a22b10751616a033139855cedc3c9269c Mon Sep 17 00:00:00 2001 From: johnnyzhang-eng Date: Tue, 18 Aug 2026 16:33:55 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(postprocess):=20=E6=8C=89=E8=84=9A?= =?UTF-8?q?=E7=BA=BF=E4=B8=8E=E6=9C=AC=E4=BD=93=E4=B8=AD=E5=BF=83=E6=8C=91?= =?UTF-8?q?=E5=87=BA=E7=AB=99=E4=BD=8D=E5=81=8F=E7=A6=BB=E5=85=A8=E5=BA=8F?= =?UTF-8?q?=E5=88=97=E7=9A=84=E5=B8=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refs #395 --- .../src/windup_ai_engine/postprocess/pack.py | 103 +++++++++++++++++- backend/tests/test_pack_align.py | 91 ++++++++++++++++ 2 files changed, 193 insertions(+), 1 deletion(-) diff --git a/backend/packages/ai_engine/src/windup_ai_engine/postprocess/pack.py b/backend/packages/ai_engine/src/windup_ai_engine/postprocess/pack.py index 2fc0321a..1818758c 100644 --- a/backend/packages/ai_engine/src/windup_ai_engine/postprocess/pack.py +++ b/backend/packages/ai_engine/src/windup_ai_engine/postprocess/pack.py @@ -13,7 +13,8 @@ _logger = logging.getLogger(__name__) -__all__ = ["CELL", "CORE_THICKNESS", "FILL_H", "FILL_W", "FOOT_LINE", +__all__ = ["CELL", "CORE_THICKNESS", "DRIFT_CX_TOL", "DRIFT_FOOT_TOL", + "FILL_H", "FILL_W", "FOOT_LINE", "drifted_frames", "align_bottom_center", "core_span", "sprite_sheet", "save_gif"] # 交付画布的几何 —— 提成模块常量而不是只当默认参数,是因为**入口预检要按同一套几何 @@ -284,6 +285,106 @@ def align_bottom_center( return out +# 逐帧锚点相对中位数的容差(比例,相对画布高/宽)。超出即认为该帧生成得偏。 +# 用比例而不是像素:交付画布尺寸随项目变,像素阈值在 256 与 512 上不是同一个严格度。 +# +# 脚底那档按归档里 58 段真实序列扫出来:0.023 时报 3 段,其中两段目视干净 —— 同批 +# 3D 渲出的走路序列自然步态起伏最大到 1.5%,而那两段擦边在 2.5% 与 2.8%。0.028 到 +# 0.045 是同一个结论(只剩一段真离群),取这段平台的中点而不是它的下沿。 +DRIFT_FOOT_TOL = 0.035 # 脚底线 +DRIFT_CX_TOL = 0.047 # 本体横向中心 + + +def drifted_frames( + frames: list[Image.Image], + foot_tol: float = DRIFT_FOOT_TOL, + cx_tol: float = DRIFT_CX_TOL, +) -> tuple[int, ...]: + """挑出脚底或横向中心明显偏离全序列中位数的帧下标。 + + **量的是对齐之前的原始帧**:``align_bottom_center`` 会把每帧摆正,对齐后再量恒为 0, + 那时偏差已经被搬运掉、但生成本身歪没歪的信息也一起没了。这里要的正是后者 —— + 一帧的角色站位与其余帧差得远,通常是那一帧生成坏了(姿态崩、主体缺失、多出人物)。 + + 判据用**中位数**不用均值:坏帧本身会把均值拖过去,于是所有帧看起来都"没偏多少"。 + + 横向中心取本体跨度的中心而不是包围盒中心 —— 见 :func:`_core_columns`。 + """ + import numpy as np + + feet: list[tuple[int, float]] = [] + cxs: list[tuple[int, float]] = [] + for i, f in enumerate(frames): + a = np.asarray(f)[:, :, 3] + ys, xs = np.where(a > 128) + if not len(ys): + continue + feet.append((i, float(ys.max()))) + core = _core_columns(a > 128) + if core is None: + continue + cxs.append((i, float(sum(core) / 2))) + if len(feet) < 3: + return () # 观测太少,中位数不成立 + + h = frames[0].size[1] + w = frames[0].size[0] + bad = _outliers(feet, foot_tol * h) + bad |= _outliers(cxs, cx_tol * w) + return tuple(sorted(bad)) + + +def _core_columns(mask) -> tuple[int, int] | None: + """本体占据的首尾列。判据与 :func:`core_span` 的列方向一致(以最厚的列为基准)。 + + 不用整体包围盒:延展物的幅度随动作变,而它对中心的拉扯不是角色站位变了。实测一段 + 斧战士序列,斧头从举过头顶甩到水平前伸,包围盒中心一帧跳 38px(画布宽 256), + 本体中心几乎不动 —— 按包围盒判,20 帧里 11 帧被误报成站位漂移。 + """ + import numpy as np + + cols = mask.sum(axis=0) + if not cols.any(): + return None + keep = np.flatnonzero(cols >= float(cols.max()) * CORE_THICKNESS) + if not len(keep): + return None + return int(keep.min()), int(keep.max()) + + +def _outliers(obs: list[tuple[int, float]], tol: float) -> set[int]: + """按中位数判离群;整段有净位移时先把这条直线除掉。 + + 两种误报要同时躲开,而它们要求相反的处理: + + - **净位移**(角色一路向右走):不除趋势的话后半段整片被判离群。实测一段斧战士序列 + 连续 7 帧横偏稳定在 30px 上下、脚偏全 0 —— 那是走位。 + - **周期摆动**(原地走路、四足步态):除趋势反而制造离群。实测一段狼序列脚底是 + 1342/1370/1352/1377 循环三遍、净位移为 0,而逐帧差分的中位数给出 25px/帧 的 + 假斜率,残差被推到 ±120px,整段 12 帧报出 8 帧。 + + 判据因此看**首尾净变化**而不是逐帧差分:周期序列首尾回到同一点,净变化接近 0, + 不触发除趋势;真的一路位移才触发。 + """ + import numpy as np + + if len(obs) < 4: + med = float(np.median([v for _, v in obs])) if obs else 0.0 + return {i for i, v in obs if abs(v - med) > tol} + + x = np.array([i for i, _ in obs], dtype=float) + y = np.array([v for _, v in obs], dtype=float) + # 首尾各取三帧的中位数比,单帧首尾正好是坏帧时会把整条判据带偏。 + head = float(np.median(y[:3])) + tail = float(np.median(y[-3:])) + # 无条件除趋势,不设"位移够大才除"的门槛:那个门槛会造一个悬崖 —— 实测一段打捞员 + # 走路净位移 24.0px、容差 24.1px,差 0.1 就不除趋势,于是首两帧被误报。周期序列首尾 + # 回到同一点、净位移≈0,减掉一条≈0 的直线是空操作,所以无条件除对它们无害。 + y = y - np.linspace(0.0, tail - head, len(y)) + med = float(np.median(y)) + return {int(i) for i, v in zip(x, y) if abs(v - med) > tol} + + def sprite_sheet(frames: list[Image.Image], bg=(0, 0, 0, 0)) -> Image.Image: """横向拼接为 sprite sheet。""" if not frames: diff --git a/backend/tests/test_pack_align.py b/backend/tests/test_pack_align.py index 27bd460c..cc27c13d 100644 --- a/backend/tests/test_pack_align.py +++ b/backend/tests/test_pack_align.py @@ -429,3 +429,94 @@ def test_missing_widths_falls_back_to_old_behaviour(): h = [60 + 40 * i / (n - 1) for i in range(n)] comp, _ = scale_drift(h) # 不传 widths assert any(c != 1.0 for c in comp) +# ── 坏帧标记:站位明显偏离全序列的帧要被挑出来 ──────────────────────────────── + + +def _figure(cell=256, cx=128, foot=200, w=40, h=90): + """在指定位置画一个不透明矩形当角色。""" + im = Image.new("RGBA", (cell, cell), (0, 0, 0, 0)) + px = im.load() + for x in range(max(0, cx - w // 2), min(cell, cx + w // 2)): + for y in range(max(0, foot - h), min(cell, foot)): + px[x, y] = (200, 180, 160, 255) + return im + + +def test_clean_sequence_flags_nothing(): + from windup_ai_engine.postprocess.pack import drifted_frames + + frames = [_figure(foot=200 + (i % 2), cx=128) for i in range(12)] + assert drifted_frames(frames) == () + + +def test_foot_outlier_is_flagged(): + """某一帧脚底明显高于其余 —— 角色悬空,通常是那帧姿态崩了。""" + from windup_ai_engine.postprocess.pack import drifted_frames + + frames = [_figure(foot=200) for _ in range(12)] + frames[5] = _figure(foot=160) + assert 5 in drifted_frames(frames) + + +def test_horizontal_outlier_is_flagged(): + from windup_ai_engine.postprocess.pack import drifted_frames + + frames = [_figure(cx=128) for _ in range(12)] + frames[3] = _figure(cx=180) + assert 3 in drifted_frames(frames) + + +def test_median_not_mean_so_one_bad_frame_cannot_hide(): + """判据用中位数:坏帧会把均值拖过去,于是所有帧都显得"没偏多少"。""" + from windup_ai_engine.postprocess.pack import drifted_frames + + frames = [_figure(foot=200) for _ in range(12)] + frames[0] = _figure(foot=60) # 极端离群 + assert drifted_frames(frames) == (0,), "只有那一帧该被标记" + + +def test_too_few_observations_flags_nothing(): + """观测不足时中位数不成立,不硬判。""" + from windup_ai_engine.postprocess.pack import drifted_frames + + assert drifted_frames([_figure(), _figure(foot=100)]) == () + + +def test_steady_walk_translation_is_not_flagged(): + """角色连续横移不是坏帧:坏帧孤立地偏离邻居,位移是连续同向的。""" + from windup_ai_engine.postprocess.pack import drifted_frames + + frames = [_figure(cx=100 + i * 4, foot=200) for i in range(16)] + assert drifted_frames(frames) == () + + +def test_outlier_on_top_of_translation_still_flagged(): + """位移之上叠一帧真离群,仍要报出来。""" + from windup_ai_engine.postprocess.pack import drifted_frames + + frames = [_figure(cx=100 + i * 4, foot=200) for i in range(16)] + frames[7] = _figure(cx=100 + 7 * 4, foot=140) + assert 7 in drifted_frames(frames) + + +def _with_extension(cell=256, cx=128, foot=200, reach=90): + """本体不动,只从右手伸出一条细长延展物(斧柄 / 剑 / 披风)。""" + im = _figure(cell=cell, cx=cx, foot=foot) + px = im.load() + for x in range(cx, min(cell, cx + reach)): + for y in range(foot - 60, foot - 52): + px[x, y] = (120, 120, 120, 255) + return im + + +def test_extension_swing_is_not_mistaken_for_a_shifted_pose(): + """延展物甩出去不是站位变了。 + + 横向中心按整体包围盒算时,一柄伸出的斧子能把中心带走一大截:实测一段斧战士序列 + 包围盒中心一帧跳 38px(画布宽 256),20 帧里 11 帧被判成漂移,而本体中心几乎不动。 + """ + from windup_ai_engine.postprocess.pack import drifted_frames + + frames = [_figure(cx=128, foot=200) for _ in range(6)] + frames += [_with_extension(cx=128, foot=200) for _ in range(6)] + assert drifted_frames(frames) == () From c4d1e47a6775236f89e4518bd056cf68b8b6a0d0 Mon Sep 17 00:00:00 2001 From: johnnyzhang-eng Date: Tue, 18 Aug 2026 16:57:53 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix(postprocess):=20=E6=96=9C=E7=8E=87?= =?UTF-8?q?=E6=8C=89=E4=B8=AD=E4=BD=8D=E6=95=B0=E5=B8=A7=E4=BD=8D=E7=AE=97?= =?UTF-8?q?=EF=BC=8C=E6=97=A0=E4=B8=BB=E4=BD=93=E5=B8=A7=E5=B9=B6=E5=85=A5?= =?UTF-8?q?=E7=BB=93=E6=9E=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refs #395 --- .../src/windup_ai_engine/postprocess/pack.py | 17 +++++++-- backend/tests/test_pack_align.py | 35 +++++++++++++++++++ 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/backend/packages/ai_engine/src/windup_ai_engine/postprocess/pack.py b/backend/packages/ai_engine/src/windup_ai_engine/postprocess/pack.py index 1818758c..e0cce3ae 100644 --- a/backend/packages/ai_engine/src/windup_ai_engine/postprocess/pack.py +++ b/backend/packages/ai_engine/src/windup_ai_engine/postprocess/pack.py @@ -314,10 +314,15 @@ def drifted_frames( feet: list[tuple[int, float]] = [] cxs: list[tuple[int, float]] = [] + empty: set[int] = set() for i, f in enumerate(frames): a = np.asarray(f)[:, :, 3] ys, xs = np.where(a > 128) if not len(ys): + # 整帧没有主体就是本函数要找的那种坏帧(抠图抠穿、生成漏了角色),不是 + # "没测到"。它不进中位数、直接进结果,也不受下面那道观测数下限的约束 —— + # 一帧全透明这件事本身不需要参照就能判。 + empty.add(i) continue feet.append((i, float(ys.max()))) core = _core_columns(a > 128) @@ -325,13 +330,13 @@ def drifted_frames( continue cxs.append((i, float(sum(core) / 2))) if len(feet) < 3: - return () # 观测太少,中位数不成立 + return tuple(sorted(empty)) # 观测太少,中位数不成立 h = frames[0].size[1] w = frames[0].size[0] bad = _outliers(feet, foot_tol * h) bad |= _outliers(cxs, cx_tol * w) - return tuple(sorted(bad)) + return tuple(sorted(bad | empty)) def _core_columns(mask) -> tuple[int, int] | None: @@ -377,10 +382,16 @@ def _outliers(obs: list[tuple[int, float]], tol: float) -> set[int]: # 首尾各取三帧的中位数比,单帧首尾正好是坏帧时会把整条判据带偏。 head = float(np.median(y[:3])) tail = float(np.median(y[-3:])) + # 斜率的分母取这两个中位数**各自对应的帧位**,不取序列总长:三帧窗口的中位数落在窗口 + # 中间那一帧上,拿总长当分母会把斜率算小。实测 16 帧每帧横移 13px 的线性序列,按总长 + # 算出 11.27/帧,两端各剩 13px 残差、双双越过 12.03 的容差,匀速位移被误报成坏帧。 + head_i = float(np.median(x[:3])) + tail_i = float(np.median(x[-3:])) # 无条件除趋势,不设"位移够大才除"的门槛:那个门槛会造一个悬崖 —— 实测一段打捞员 # 走路净位移 24.0px、容差 24.1px,差 0.1 就不除趋势,于是首两帧被误报。周期序列首尾 # 回到同一点、净位移≈0,减掉一条≈0 的直线是空操作,所以无条件除对它们无害。 - y = y - np.linspace(0.0, tail - head, len(y)) + if tail_i > head_i: + y = y - (tail - head) / (tail_i - head_i) * x med = float(np.median(y)) return {int(i) for i, v in zip(x, y) if abs(v - med) > tol} diff --git a/backend/tests/test_pack_align.py b/backend/tests/test_pack_align.py index cc27c13d..4f98cb6a 100644 --- a/backend/tests/test_pack_align.py +++ b/backend/tests/test_pack_align.py @@ -520,3 +520,38 @@ def test_extension_swing_is_not_mistaken_for_a_shifted_pose(): frames = [_figure(cx=128, foot=200) for _ in range(6)] frames += [_with_extension(cx=128, foot=200) for _ in range(6)] assert drifted_frames(frames) == () + + +def test_linear_translation_at_the_tolerance_edge_is_not_flagged(): + """斜率的分母取中位数各自的帧位,不取序列总长。 + + 256px 画布、16 帧、每帧横移 13px:按总长算斜率会得到 11.27/帧,两端各剩 13px 残差、 + 双双越过 12.03 的容差,于是匀速位移被误报成首末两帧坏。 + """ + from windup_ai_engine.postprocess.pack import drifted_frames + + frames = [_figure(cx=30 + i * 13, foot=200) for i in range(16)] + assert drifted_frames(frames) == () + + +def test_a_frame_with_no_subject_at_all_is_flagged(): + """全透明帧是抠图抠穿或生成漏了角色,正是本函数要找的那种坏帧。 + + 它不进中位数(没有可量的锚点),所以必须单独并进结果 —— 静默跳过的话,一组正常帧里 + 插一张透明 PNG 会返回空元组,而 ``_lastmile`` 只拒空字节、合法的全透明 PNG 照样通过。 + """ + from windup_ai_engine.postprocess.pack import drifted_frames + from PIL import Image + + frames = [_figure(foot=200) for _ in range(12)] + frames[4] = Image.new("RGBA", (256, 256), (0, 0, 0, 0)) + assert 4 in drifted_frames(frames) + + +def test_no_subject_is_reported_even_when_observations_are_too_few(): + """观测不足时中位数不成立,但"这帧没有主体"不需要参照就能判。""" + from windup_ai_engine.postprocess.pack import drifted_frames + from PIL import Image + + frames = [_figure(), Image.new("RGBA", (256, 256), (0, 0, 0, 0))] + assert drifted_frames(frames) == (1,)