From a807268bf05afc1fb20e10a86f0b23fa8a71eb8d Mon Sep 17 00:00:00 2001 From: Johnny Zhang Date: Fri, 14 Aug 2026 10:37:42 +0800 Subject: [PATCH 1/3] =?UTF-8?q?fix(postprocess):=20=E9=80=90=E5=B8=A7?= =?UTF-8?q?=E8=A1=A5=E5=81=BF=E4=B8=80=E6=AE=B5=E5=8A=A8=E4=BD=9C=E5=86=85?= =?UTF-8?q?=E7=9A=84=E5=8D=95=E8=B0=83=E5=B0=BA=E5=BA=A6=E6=BC=82=E7=A7=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/windup_ai_engine/postprocess/pack.py | 55 +++++++++++- backend/tests/test_pack_align.py | 87 +++++++++++++++++++ 2 files changed, 138 insertions(+), 4 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 caf63a91..1053e716 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 @@ -8,6 +8,7 @@ import logging +import numpy as np from PIL import Image _logger = logging.getLogger(__name__) @@ -60,6 +61,39 @@ def span(counts: np.ndarray, base: float) -> float: return (span(rows, float(np.median(nz_rows))), span(cols, float(cols.max()))) +# 单调漂移的判定门槛(整段首尾相对变化)。低于它的不动 —— 真实身高起伏实测约 4%, +# 把那也当漂移消掉,就成了原设计担心的"蹲下的帧被放大"。 +DRIFT_MIN_RATIO = 0.08 + + +def scale_drift(spans: list[float]) -> tuple[list[float], float]: + """把逐帧本体高里的**单调趋势**分离出来,返回(逐帧补偿系数, 首尾相对变化)。 + + 存在的理由是整段共用一个缩放系数会原样保留 i2v 的推镜:实测线上两段真实产出, + 本体高从 137→165(+20%)与 70→158(+127%),几乎无回落。统一缩放对整段乘同一个数, + 趋势不受影响,于是角色在一个动作内单调变大。 + + 只除趋势、不逐帧归一:后者会把走路自然的身高起伏(约 4%)一起压平,蹲下的帧被放大、 + 伸展的帧被缩小 —— 那正是本模块最初拒绝逐帧归一的原因。对本体高做一次线性拟合, + 补偿拟合值、保留残差,两个目标就不再冲突(实测修后趋势归零,残差 1.5%–6.7%)。 + + 返回的系数以 1.0 为中心(除以均值),所以整段的**平均**尺寸不变,跨动作口径不受影响。 + """ + n = len(spans) + if n < 4: + return [1.0] * n, 0.0 + x = np.arange(n, dtype=float) + a = np.asarray(spans, dtype=float) + k, b = np.polyfit(x, a, 1) + trend = k * x + b + if trend.min() <= 0: # 拟合出非正值:数据不适合线性描述,不动 + return [1.0] * n, 0.0 + ratio = float(trend[-1] / trend[0] - 1.0) + if abs(ratio) < DRIFT_MIN_RATIO: + return [1.0] * n, ratio + return (trend / trend.mean()).tolist(), ratio + + def align_bottom_center( frames: list[Image.Image], cell: int = CELL, @@ -161,6 +195,18 @@ def align_bottom_center( # 跳到 0.961,跨过了任何合理的窗口。一个永不成立的分支比没有分支更坏。 # # 关键是**不静默**:裁掉多少写进日志,让丢像素可见,而不是靠人看图发现。 + # 逐帧补偿单调漂移。整段共用的 scale 只决定平均尺寸,趋势项由这里除掉; + # 补偿系数以 1.0 为中心,故平均尺寸与跨动作口径都不变。 + per_frame = [1.0] * len(frames) + if spans and len(spans) == len(frames): + comp, ratio = scale_drift([sp[0] for sp in spans]) + if any(c != 1.0 for c in comp): + per_frame = [1.0 / c for c in comp] + _logger.info( + "整段尺度单调漂移 %.1f%%(i2v 推镜),已逐帧补偿;补偿区间 %.3f–%.3f", + ratio * 100, min(per_frame), max(per_frame), + ) + if max_full * scale > cw: _logger.info( "保尺寸一致而不压缩:整帧需 %.0fpx、画布 %dpx,两侧各溢出约 %.0fpx", @@ -168,15 +214,16 @@ def align_bottom_center( ) out = [] - for f, box in zip(frames, boxes): + for idx, (f, box) in enumerate(zip(frames, boxes)): if box is None: out.append(Image.new("RGBA", (cw, ch), (0, 0, 0, 0))) continue crop = f.crop(box) - w = max(1, round(crop.width * scale)) - h = max(1, round(crop.height * scale)) + fs = scale * per_frame[idx] + w = max(1, round(crop.width * fs)) + h = max(1, round(crop.height * fs)) crop = crop.resize((w, h), Image.NEAREST) - lift = round((ground - box[3]) * scale) if preserve_lift else 0 + lift = round((ground - box[3]) * fs) if preserve_lift else 0 canvas = Image.new("RGBA", (cw, ch), (0, 0, 0, 0)) canvas.alpha_composite(crop, (cw // 2 - w // 2, int(ch * foot_line) - h - lift)) out.append(canvas) diff --git a/backend/tests/test_pack_align.py b/backend/tests/test_pack_align.py index 6dd71121..2880a441 100644 --- a/backend/tests/test_pack_align.py +++ b/backend/tests/test_pack_align.py @@ -244,3 +244,90 @@ def test_clipping_is_logged_not_silent(caplog): align_bottom_center(src, cell=256, cell_h=256) assert any("溢出" in r.message for r in caplog.records), \ f"裁切没有上报,日志:{[r.message for r in caplog.records]}" + + +# ── 一段动作内的单调漂移(#307)────────────────────────────────────────────── +# +# 线上真实产出实测:walk 的本体高 137→165(+20%)、custom 70→158(+127%),几乎无回落。 +# 整段共用一个缩放系数只决定平均尺寸,趋势原样保留,于是角色在一个动作内单调变大。 + + +# 任务 94(walk,32 帧)的逐帧本体高,直接取自线上产物。 +_REAL_WALK_SPANS = [ + 132, 133, 136, 139, 141, 142, 139, 138, 141, 146, 146, 148, 146, 145, 149, 155, + 155, 153, 151, 154, 157, 161, 160, 161, 159, 160, 162, 170, 169, 167, 168, 168, +] + + +def test_monotonic_drift_is_removed_on_real_data(): + from windup_ai_engine.postprocess.pack import scale_drift + + comp, ratio = scale_drift(_REAL_WALK_SPANS) + assert ratio > 0.15, "这段真实数据本身就有 20% 漂移,判不出来说明门槛错了" + fixed = np.asarray(_REAL_WALK_SPANS, float) / np.asarray(comp) + head, tail = fixed[:8].mean(), fixed[-8:].mean() + assert abs(tail / head - 1) < 0.03, f"补偿后首尾仍差 {(tail/head-1)*100:.1f}%" + + +def test_natural_bob_is_preserved_not_flattened(): + """只除趋势、不逐帧归一 —— 走路自然的身高起伏必须留着。 + + 逐帧归一会把蹲下的帧放大、伸展的帧缩小,那正是本模块最初拒绝它的原因。 + """ + from windup_ai_engine.postprocess.pack import scale_drift + + comp, _ = scale_drift(_REAL_WALK_SPANS) + fixed = np.asarray(_REAL_WALK_SPANS, float) / np.asarray(comp) + spread = fixed.std() / fixed.mean() + assert spread > 0.005, "起伏被压平了,退化成逐帧归一" + assert spread < 0.10, f"残差 {spread*100:.1f}% 过大,趋势没除干净" + + +def test_steady_sequence_is_left_alone(): + """没有漂移就不该动。真实身高起伏约 4%,把那当漂移消掉是过度矫正。""" + from windup_ai_engine.postprocess.pack import scale_drift + + steady = [100, 104, 98, 102, 101, 99, 103, 100] * 4 + comp, ratio = scale_drift(steady) + assert abs(ratio) < 0.08 + assert all(c == 1.0 for c in comp) + + +def test_average_size_is_unchanged_so_cross_action_scale_still_holds(): + """补偿系数以 1.0 为中心:整段平均尺寸不变,#280 的跨动作口径不受影响。""" + from windup_ai_engine.postprocess.pack import scale_drift + + comp, _ = scale_drift(_REAL_WALK_SPANS) + assert abs(float(np.mean(comp)) - 1.0) < 0.01 + + +def test_too_few_frames_are_left_alone(): + """三帧拟合不出可信趋势,拟合了反而制造漂移。""" + from windup_ai_engine.postprocess.pack import scale_drift + + comp, ratio = scale_drift([100, 130, 160]) + assert comp == [1.0, 1.0, 1.0] and ratio == 0.0 + + +def test_align_actually_applies_the_compensation(): + """钉的是"补偿真的接上了",不是"函数算得对"。 + + 只测 ``scale_drift`` 的话,把 ``align_bottom_center`` 里那一行乘法删掉,用例照样全绿 + (变异测试逮到过)—— 那正是本仓最忌讳的"看起来成功的错结果"。 + """ + from windup_ai_engine.postprocess.pack import align_bottom_center, core_span + + def body(h: int) -> Image.Image: + a = np.zeros((256, 256, 4), np.uint8) + w = max(2, h // 3) + a[200 - h:200, 128 - w // 2:128 + w // 2, 3] = 255 + return Image.fromarray(a) + + # 单调放大:60 → 140,与线上观测到的形状一致 + src = [body(int(round(v))) for v in np.linspace(60, 140, 16)] + out = align_bottom_center(src, cell=256) + got = [core_span(f)[0] for f in out] + head, tail = float(np.mean(got[:4])), float(np.mean(got[-4:])) + assert abs(tail / head - 1) < 0.08, ( + f"出帧后仍在单调变大:首 {head:.0f} → 尾 {tail:.0f}({(tail/head-1)*100:+.0f}%)" + ) From f2076db2e75393c86533db8dcc07bbc15415b596 Mon Sep 17 00:00:00 2001 From: Johnny Zhang Date: Fri, 14 Aug 2026 16:54:29 +0800 Subject: [PATCH 2/3] =?UTF-8?q?fix(postprocess):=20=E7=A9=BA=E5=B8=A7?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E8=AE=A9=E6=95=B4=E6=AE=B5=E8=B7=B3=E8=BF=87?= =?UTF-8?q?=E5=B0=BA=E5=BA=A6=E6=BC=82=E7=A7=BB=E8=A1=A5=E5=81=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 守卫用 len(spans) == len(frames) 判齐,而 spans 已滤掉空帧,于是序列里只要有一帧 量不到本体,整段补偿就被跳过,其余帧静默留着 i2v 的推镜漂移。 空帧只是缺一个观测:改为把 None 一并传给 scale_drift,它按真实帧号拟合、空位系数 取 1.0,其余帧照常补偿。 --- .../src/windup_ai_engine/postprocess/pack.py | 39 +++++++----- backend/tests/test_pack_align.py | 60 ++++++++++++++++--- 2 files changed, 76 insertions(+), 23 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 1053e716..ed8035ce 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 @@ -66,7 +66,7 @@ def span(counts: np.ndarray, base: float) -> float: DRIFT_MIN_RATIO = 0.08 -def scale_drift(spans: list[float]) -> tuple[list[float], float]: +def scale_drift(spans: list[float | None]) -> tuple[list[float], float]: """把逐帧本体高里的**单调趋势**分离出来,返回(逐帧补偿系数, 首尾相对变化)。 存在的理由是整段共用一个缩放系数会原样保留 i2v 的推镜:实测线上两段真实产出, @@ -77,13 +77,18 @@ def scale_drift(spans: list[float]) -> tuple[list[float], float]: 伸展的帧被缩小 —— 那正是本模块最初拒绝逐帧归一的原因。对本体高做一次线性拟合, 补偿拟合值、保留残差,两个目标就不再冲突(实测修后趋势归零,残差 1.5%–6.7%)。 + ``None`` = 空帧(量不到本体):不参与拟合、系数取 1.0,其余帧照常补偿。 + 返回的系数以 1.0 为中心(除以均值),所以整段的**平均**尺寸不变,跨动作口径不受影响。 """ n = len(spans) - if n < 4: + # 自变量用**真实帧号**而不是压缩后的序号:主要是系数必须落回对应的帧,否则空洞之后 + # 整体错位一帧;顺带也不让空洞压短趋势的时间轴(32 帧缺 1 实测斜率差 3.7%, + # 落到逐帧系数上 <0.3%)。 + x = np.array([i for i, s in enumerate(spans) if s is not None], dtype=float) + a = np.array([s for s in spans if s is not None], dtype=float) + if len(a) < 4: # 观测不足:三点拟不出可信趋势,拟合反而制造漂移 return [1.0] * n, 0.0 - x = np.arange(n, dtype=float) - a = np.asarray(spans, dtype=float) k, b = np.polyfit(x, a, 1) trend = k * x + b if trend.min() <= 0: # 拟合出非正值:数据不适合线性描述,不动 @@ -91,7 +96,10 @@ def scale_drift(spans: list[float]) -> tuple[list[float], float]: ratio = float(trend[-1] / trend[0] - 1.0) if abs(ratio) < DRIFT_MIN_RATIO: return [1.0] * n, ratio - return (trend / trend.mean()).tolist(), ratio + comp = [1.0] * n + for i, c in zip(x.astype(int), trend / trend.mean(), strict=True): + comp[i] = float(c) + return comp, ratio def align_bottom_center( @@ -153,7 +161,9 @@ def align_bottom_center( if not heights: return [Image.new("RGBA", (cw, ch), (0, 0, 0, 0)) for _ in frames] # 定标一律按**本体**跨度,不按包围盒:后者被延展物撑大,而延展物幅度随动作变。 - spans = [s for s in (core_span(f) for f in frames) if s is not None] + # 逐帧补偿要按帧号索引系数,故先留一份与 frames 等长、空帧为 None 的原始表。 + core_spans = [core_span(f) for f in frames] + spans = [s for s in core_spans if s is not None] # 腾空模式:以最低脚线(数值最大 = 站在地上)为地面基准,保留每帧的抬升量 ground = max(b[3] for b in boxes if b) if preserve_lift else 0 # 定标要把抬升量算进去,否则跳到最高时头顶会顶出画布被切掉 @@ -195,17 +205,18 @@ def align_bottom_center( # 跳到 0.961,跨过了任何合理的窗口。一个永不成立的分支比没有分支更坏。 # # 关键是**不静默**:裁掉多少写进日志,让丢像素可见,而不是靠人看图发现。 + # 逐帧补偿单调漂移。整段共用的 scale 只决定平均尺寸,趋势项由这里除掉; # 补偿系数以 1.0 为中心,故平均尺寸与跨动作口径都不变。 + # 空帧照常传给 scale_drift(它按帧号拟合、空位给 1.0)—— 少一个观测不该让整段不补。 per_frame = [1.0] * len(frames) - if spans and len(spans) == len(frames): - comp, ratio = scale_drift([sp[0] for sp in spans]) - if any(c != 1.0 for c in comp): - per_frame = [1.0 / c for c in comp] - _logger.info( - "整段尺度单调漂移 %.1f%%(i2v 推镜),已逐帧补偿;补偿区间 %.3f–%.3f", - ratio * 100, min(per_frame), max(per_frame), - ) + comp, ratio = scale_drift([s[0] if s is not None else None for s in core_spans]) + if any(c != 1.0 for c in comp): + per_frame = [1.0 / c for c in comp] + _logger.info( + "整段尺度单调漂移 %.1f%%(i2v 推镜),已逐帧补偿;补偿区间 %.3f–%.3f", + ratio * 100, min(per_frame), max(per_frame), + ) if max_full * scale > cw: _logger.info( diff --git a/backend/tests/test_pack_align.py b/backend/tests/test_pack_align.py index 2880a441..fd80a268 100644 --- a/backend/tests/test_pack_align.py +++ b/backend/tests/test_pack_align.py @@ -309,6 +309,56 @@ def test_too_few_frames_are_left_alone(): assert comp == [1.0, 1.0, 1.0] and ratio == 0.0 +def _drifting_bodies(n=16, lo=60, hi=140): + """本体高从 lo 单调涨到 hi 的合成序列,形状与线上观测到的推镜一致。""" + def body(h: int) -> Image.Image: + a = np.zeros((256, 256, 4), np.uint8) + w = max(2, h // 3) + a[200 - h:200, 128 - w // 2:128 + w // 2, 3] = 255 + return Image.fromarray(a) + + return [body(int(round(v))) for v in np.linspace(lo, hi, n)] + + +def test_drift_is_still_compensated_when_a_frame_is_empty(): + """中间夹一帧全透明,其余帧的漂移照样要补掉。 + + 空帧只是**缺一个观测**。整段跳过补偿会让其余帧静默留着漂移 —— 本 PR 要修的问题 + 原样回来,且无声无息。 + """ + from windup_ai_engine.postprocess.pack import align_bottom_center, core_span + + src = _drifting_bodies() + src[8] = Image.new("RGBA", (256, 256), (0, 0, 0, 0)) + + out = align_bottom_center(src, cell=256) + assert core_span(out[8]) is None, "空帧必须原样透明输出" + + got = [core_span(f)[0] for i, f in enumerate(out) if i != 8] + head, tail = float(np.mean(got[:4])), float(np.mean(got[-4:])) + assert abs(tail / head - 1) < 0.08, ( + f"有空帧时补偿被整段跳过,出帧仍在单调变大:首 {head:.0f} → 尾 {tail:.0f}" + f"({(tail/head-1)*100:+.0f}%)" + ) + + +def test_empty_frames_do_not_shift_the_trend_timeline(): + """空帧不参与拟合,系数取 1.0,其余帧的系数与它不在时一致。""" + from windup_ai_engine.postprocess.pack import scale_drift + + full = _REAL_WALK_SPANS + holed = list(full) + holed[8] = None + + ref, _ = scale_drift(full) + comp, ratio = scale_drift(holed) + assert ratio > 0.15, "少一个观测不该让 20% 的漂移判不出来" + assert comp[8] == 1.0, "空帧的系数应为 1.0" + for i, (c, r) in enumerate(zip(comp, ref, strict=True)): + if i != 8: + assert abs(c - r) < 0.01, f"第 {i} 帧系数被空洞带偏: {c:.3f} vs {r:.3f}" + + def test_align_actually_applies_the_compensation(): """钉的是"补偿真的接上了",不是"函数算得对"。 @@ -317,15 +367,7 @@ def test_align_actually_applies_the_compensation(): """ from windup_ai_engine.postprocess.pack import align_bottom_center, core_span - def body(h: int) -> Image.Image: - a = np.zeros((256, 256, 4), np.uint8) - w = max(2, h // 3) - a[200 - h:200, 128 - w // 2:128 + w // 2, 3] = 255 - return Image.fromarray(a) - - # 单调放大:60 → 140,与线上观测到的形状一致 - src = [body(int(round(v))) for v in np.linspace(60, 140, 16)] - out = align_bottom_center(src, cell=256) + out = align_bottom_center(_drifting_bodies(), cell=256) got = [core_span(f)[0] for f in out] head, tail = float(np.mean(got[:4])), float(np.mean(got[-4:])) assert abs(tail / head - 1) < 0.08, ( From 36b29bf7979c0092adc87a93c707f42868b8effc Mon Sep 17 00:00:00 2001 From: Johnny Zhang Date: Tue, 18 Aug 2026 10:51:51 +0800 Subject: [PATCH 3/3] =?UTF-8?q?fix(postprocess):=20=E6=8E=A8=E9=95=9C?= =?UTF-8?q?=E8=A1=A5=E5=81=BF=E5=8F=AA=E5=9C=A8=E9=AB=98=E5=AE=BD=E5=90=8C?= =?UTF-8?q?=E6=AF=94=E6=97=B6=E8=A7=A6=E5=8F=91,=E4=B8=8D=E8=AF=AF?= =?UTF-8?q?=E4=BC=A4=E7=9C=9F=E5=AE=9E=E5=A7=BF=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/windup_ai_engine/postprocess/pack.py | 47 +++++++++++++++- backend/tests/test_pack_align.py | 56 +++++++++++++++++++ 2 files changed, 101 insertions(+), 2 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 ed8035ce..2fc0321a 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 @@ -65,8 +65,44 @@ def span(counts: np.ndarray, base: float) -> float: # 把那也当漂移消掉,就成了原设计担心的"蹲下的帧被放大"。 DRIFT_MIN_RATIO = 0.08 +# 判定"这是推镜、不是姿态变化"的容差:推镜把整个角色等比放大,高与宽的首尾相对变化应当 +# 同号且同量级;真实姿态(深蹲→起跳)只改高、宽度基本不动。取 0.5 = 宽的变化至少要达到 +# 高的一半才认推镜 —— 门槛过严会漏掉带轻微形变的真推镜,过松会把 jump 判成漂移。 +DRIFT_WIDTH_AGREEMENT = 0.5 -def scale_drift(spans: list[float | None]) -> tuple[list[float], float]: + +def _trend_ratio(values: list[float | None]) -> float | None: + """逐帧序列的线性趋势首尾相对变化;观测不足或拟合出非正值返回 ``None``。""" + x = np.array([i for i, v in enumerate(values) if v is not None], dtype=float) + a = np.array([v for v in values if v is not None], dtype=float) + if len(a) < 4: + return None + k, b = np.polyfit(x, a, 1) + trend = k * x + b + if trend.min() <= 0: + return None + return float(trend[-1] / trend[0] - 1.0) + + +def _looks_like_camera_zoom( + spans: list[float | None], widths: list[float | None], height_ratio: float +) -> bool: + """高宽是否一起变 —— 区分推镜与真实姿态变化。 + + 推镜等比放大整个角色,高与宽的趋势同号且同量级;深蹲→起跳只把高拉长,宽基本不动。 + 量不出宽度趋势时返回 True,退回旧行为:宁可补偿一次可疑的,也不因为量不到就整段不补。 + """ + width_ratio = _trend_ratio(widths) + if width_ratio is None: + return True + if width_ratio * height_ratio <= 0: # 反号:一个变大一个变小,不是推镜 + return False + return abs(width_ratio) >= abs(height_ratio) * DRIFT_WIDTH_AGREEMENT + + +def scale_drift( + spans: list[float | None], widths: list[float | None] | None = None +) -> tuple[list[float], float]: """把逐帧本体高里的**单调趋势**分离出来,返回(逐帧补偿系数, 首尾相对变化)。 存在的理由是整段共用一个缩放系数会原样保留 i2v 的推镜:实测线上两段真实产出, @@ -96,6 +132,10 @@ def scale_drift(spans: list[float | None]) -> tuple[list[float], float]: ratio = float(trend[-1] / trend[0] - 1.0) if abs(ratio) < DRIFT_MIN_RATIO: return [1.0] * n, ratio + if widths is not None and not _looks_like_camera_zoom(spans, widths, ratio): + # 高在变而宽没跟着变 = 真实姿态(深蹲→起跳),不是推镜。补偿它会把高度拉平、 + # 同时把宽度按同一系数缩掉,姿态被压扁。 + return [1.0] * n, ratio comp = [1.0] * n for i, c in zip(x.astype(int), trend / trend.mean(), strict=True): comp[i] = float(c) @@ -210,7 +250,10 @@ def align_bottom_center( # 补偿系数以 1.0 为中心,故平均尺寸与跨动作口径都不变。 # 空帧照常传给 scale_drift(它按帧号拟合、空位给 1.0)—— 少一个观测不该让整段不补。 per_frame = [1.0] * len(frames) - comp, ratio = scale_drift([s[0] if s is not None else None for s in core_spans]) + comp, ratio = scale_drift( + [s[0] if s is not None else None for s in core_spans], + [s[1] if s is not None else None for s in core_spans], + ) if any(c != 1.0 for c in comp): per_frame = [1.0 / c for c in comp] _logger.info( diff --git a/backend/tests/test_pack_align.py b/backend/tests/test_pack_align.py index fd80a268..27bd460c 100644 --- a/backend/tests/test_pack_align.py +++ b/backend/tests/test_pack_align.py @@ -373,3 +373,59 @@ def test_align_actually_applies_the_compensation(): assert abs(tail / head - 1) < 0.08, ( f"出帧后仍在单调变大:首 {head:.0f} → 尾 {tail:.0f}({(tail/head-1)*100:+.0f}%)" ) + + +# ── 推镜 vs 真实姿态:只有高宽一起变才算漂移 ──────────────────────────────── + + +def _spans_seq(heights, widths): + """构造 (高, 宽) 序列,喂给 scale_drift 的两个入参。""" + return list(heights), list(widths) + + +def test_camera_zoom_is_compensated(): + """高宽同比放大 = 推镜,照旧补偿。""" + from windup_ai_engine.postprocess.pack import scale_drift + + n = 16 + h = [60 + 40 * i / (n - 1) for i in range(n)] + w = [30 + 20 * i / (n - 1) for i in range(n)] # 与高同比例 + comp, ratio = scale_drift(h, w) + assert ratio > 0.5 + assert any(c != 1.0 for c in comp), "等比放大是推镜,必须补偿" + + +def test_pose_change_is_not_compensated(): + """深蹲→起跳:高从 60 涨到 100 而宽不动,是真实姿态,不能补偿。 + + 补偿它会把高度拉平的同时按同一系数缩宽,角色沿动作被压扁 —— 这正是本判据要挡的。 + """ + from windup_ai_engine.postprocess.pack import scale_drift + + n = 16 + h = [60 + 40 * i / (n - 1) for i in range(n)] + w = [30.0] * n # 宽度不动 + comp, ratio = scale_drift(h, w) + assert ratio > 0.5, "高度趋势确实存在,判据不是靠 ratio 门槛挡掉的" + assert all(c == 1.0 for c in comp), "宽度没跟着变,不该当推镜补偿" + + +def test_opposite_trends_are_not_compensated(): + """高涨宽缩 = 姿态在拉伸,不是推镜。""" + from windup_ai_engine.postprocess.pack import scale_drift + + n = 16 + h = [60 + 40 * i / (n - 1) for i in range(n)] + w = [40 - 10 * i / (n - 1) for i in range(n)] + comp, _ = scale_drift(h, w) + assert all(c == 1.0 for c in comp) + + +def test_missing_widths_falls_back_to_old_behaviour(): + """量不到宽度时退回旧行为,不因为少一个观测就整段不补。""" + from windup_ai_engine.postprocess.pack import scale_drift + + n = 16 + 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)