diff --git a/backend/packages/ai_engine/src/windup_ai_engine/impl/character_generator.py b/backend/packages/ai_engine/src/windup_ai_engine/impl/character_generator.py index ff453e7d..12fc8ab0 100644 --- a/backend/packages/ai_engine/src/windup_ai_engine/impl/character_generator.py +++ b/backend/packages/ai_engine/src/windup_ai_engine/impl/character_generator.py @@ -28,6 +28,7 @@ SequenceGeometry, ) from windup_ai_engine.postprocess import FOOT_LINE, align_bottom_center, frame_durations +from windup_ai_engine.postprocess.pack import ANCHOR_CENTROID, FILL_H from windup_ai_engine.prompt import PROMPT_VERSION from windup_ai_engine.slicing import ( dead_frame_indices, @@ -40,6 +41,7 @@ ROUTE_MATRIX, DerivationStrategy, is_cyclic, + vertical_anchor, ) # ── 进度刻度:整条生产线只有一个 total ──────────────────────────────────────── @@ -184,8 +186,8 @@ def _finish( "请调小 n_frames 或加长视频。" ) - # 最后一公里:脚线对齐成原地序列帧(直接对齐到调用方要的画布尺寸) - aligned = self._lastmile(frames, progress, canvas) + # 最后一公里:对齐成原地序列帧(直接对齐到调用方要的画布尺寸) + aligned = self._lastmile(frames, action, progress, canvas) # 量交付成色。在**对齐之后**量,量的是用户真正会看到的那组帧:抠图 / 像素化 / # 对齐都会改像素,在中间任何一步量出来的数都描述不了交付物。 @@ -199,7 +201,7 @@ def _finish( return GeneratedAction( frames=[_png(im) for im in aligned], durations=frame_durations(action.action.value, len(aligned)), - geometry=_geometry_of(aligned[0], canvas), + geometry=_geometry_of(aligned[0], canvas, vertical_anchor(action)), quality=quality, prompt_version=PROMPT_VERSION, ) @@ -227,10 +229,11 @@ def _assess(self, frames: list[Image.Image], action: ActionSpec) -> ActionQualit def _lastmile( self, frames: list[bytes], + action: ActionSpec, progress: ProgressPort, canvas: tuple[int, int] | None = None, ) -> list[Image.Image]: - """脚线对齐:把各帧对齐成原地序列帧(消除逐帧画布漂移,Issue #21)。 + """对齐成原地序列帧(消除逐帧画布漂移,Issue #21);锚点随动作有无地面接触。 返回 PIL 而不是 PNG bytes:紧接着的成色测量要按图看帧,再编码回 PNG 只为了 让上一句话好听、下一句话又得解码回来。编码统一在 ``generate`` 出参那一步做。 @@ -243,7 +246,11 @@ def _lastmile( 原尺寸居中贴进 512 画布,于是这里刚对齐好的脚线 0.92 被挪到 0.709(2026-08-11 实测),角色不站在地上、跨动作对齐也失效。在这里一次出到位就没有那一步了。 """ - progress.step("lastmile", _TICK_LASTMILE, _TOTAL, "脚线对齐(原地)") + anchor = vertical_anchor(action) + progress.step( + "lastmile", _TICK_LASTMILE, _TOTAL, + f"{'质心' if anchor == ANCHOR_CENTROID else '脚线'}对齐(原地)", + ) # 空帧不再静默跳过:未实现的路线现在在 strategy / 装配表处就抛错(见 generate), # 走到这里还有空帧说明 provider 或抠图吐了坏数据,同样要炸而不是原样放行。 if not frames: @@ -262,22 +269,28 @@ def _lastmile( # TODO(dev, #21): tail_match 循环闭合(净位移动作先锚点再匹配帧) ref = float(np.median(hs)) if hs else None if canvas is None: - return align_bottom_center(imgs, ref_height=ref) + return align_bottom_center(imgs, ref_height=ref, anchor=anchor) cw, ch = canvas - return align_bottom_center(imgs, cell=cw, cell_h=ch, ref_height=ref) + return align_bottom_center(imgs, cell=cw, cell_h=ch, ref_height=ref, anchor=anchor) -def _geometry_of(frame: Image.Image, canvas: tuple[int, int] | None) -> SequenceGeometry: +def _geometry_of( + frame: Image.Image, canvas: tuple[int, int] | None, anchor: str +) -> SequenceGeometry: """交付帧的落位几何。取自对齐那一步用的同一组常量,不另立一份。 ``canvas`` 为 None 时 ``_lastmile`` 走 ``align_bottom_center`` 的默认 cell, 所以这里也回落到实际交付帧的尺寸,而不是把 CELL 再抄一遍。 + + 锚点要跟着传:质心那档把主体摆在 ``foot_line - fill_h/2``,仍报脚线的话 + 帧是按质心摆的、几何说的是脚线,消费方按它落位就会错开半个身高。 """ w, h = canvas if canvas else frame.size + y = FOOT_LINE - FILL_H / 2 if anchor == ANCHOR_CENTROID else FOOT_LINE return SequenceGeometry( canvas_w=w, canvas_h=h, anchor_x=0.5, # align_bottom_center 横向恒居中 - anchor_y=FOOT_LINE, - foot_y=int(h * FOOT_LINE), + anchor_y=y, + foot_y=int(h * y), ) 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 e0cce3ae..73be104b 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,9 +13,10 @@ _logger = logging.getLogger(__name__) -__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"] +__all__ = ["ANCHOR_CENTROID", "ANCHOR_FOOT", "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"] # 交付画布的几何 —— 提成模块常量而不是只当默认参数,是因为**入口预检要按同一套几何 # 判母版能不能装下**(见 master_check.REJECT_ASPECT)。抄一份数字过去就等于埋下 @@ -29,6 +30,12 @@ # 0.25 之下是延展物(尾巴、翅膀、披风、举起的武器)—— 它们细,本体厚。 CORE_THICKNESS = 0.25 +# 垂直对齐锚点。脚线那档对站在地上的动作成立;飞 / 游 / 攀全程没有地面接触,它们的包围盒 +# 底边是尾羽与爪子、逐帧在变,钉死底边等于让身体跟着延展物上下浮动(#534)。 +ANCHOR_FOOT = "foot" +ANCHOR_CENTROID = "centroid" +_ANCHORS = (ANCHOR_FOOT, ANCHOR_CENTROID) + def core_span(frame: Image.Image, thickness: float = CORE_THICKNESS) -> tuple[float, float] | None: """本体的 (高, 宽),单位=该帧像素。空帧返回 ``None``。 @@ -54,12 +61,9 @@ def core_span(frame: Image.Image, thickness: float = CORE_THICKNESS) -> tuple[fl # · 它又让**大量列**只有 10px 高 → 列方向若以中位数为基准,中位数被压到 10、门槛低到 # 2,翅膀整条算进本体,量出的本体宽 209px(真值 49)。故列用 **max**。 # 判据不对称是数据形态决定的,不是漏了统一。 - def span(counts: np.ndarray, base: float) -> float: - keep = np.flatnonzero(counts >= base * thickness) - return float(keep.max() - keep.min()) - - nz_rows = rows[rows > 0] - return (span(rows, float(np.median(nz_rows))), span(cols, float(cols.max()))) + r0, r1 = _core_rows(m, thickness) + keep = np.flatnonzero(cols >= float(cols.max()) * thickness) + return (float(r1 - r0), float(keep.max() - keep.min())) # 单调漂移的判定门槛(整段首尾相对变化)。低于它的不动 —— 真实身高起伏实测约 4%, @@ -152,6 +156,7 @@ def align_bottom_center( preserve_lift: bool = False, ref_height: float | None = None, cell_h: int | None = None, + anchor: str = ANCHOR_FOOT, ) -> list[Image.Image]: """按脚线对齐到统一画布,消除逐帧画布漂移(Issue #21)。 @@ -160,7 +165,7 @@ def align_bottom_center( 缩小。统一缩放后帧间只剩真实姿态差,尺度稳定。 水平方向按**主体水平中心**对齐(不含挥出的武器会更好,当前用整体包围盒中心兜底); - 垂直方向按**脚线**(包围盒底边)对齐到 ``foot_line``。 + 垂直方向按 ``anchor`` 选锚点,默认脚线(包围盒底边)对齐到 ``foot_line``。 ``ref_height``:**跨动作一致性的关键**,单位=传入帧的像素高。给定时按它定标,否则按本 序列最高帧。按最高帧定标会让"举过头顶"的动作整段被缩小去迁就那一帧 —— 实测攻击时 @@ -170,6 +175,10 @@ def align_bottom_center( ``preserve_lift``:腾空位移**默认不烘进像素**(业界:位移交引擎 root motion)。仅在要把 位移画进序列帧时才开;开启后以序列里最低的脚线为地面基准,保留每帧相对地面的抬升量。 + ``anchor``:``"foot"`` 给有地面接触的动作,``"centroid"`` 给飞 / 游 / 攀 —— 后者的底边 + 是尾羽与爪子、逐帧在变,钉死底边身体反而上下浮动(#534)。质心那档落在 + ``foot_line - fill_h/2``,即参考姿态包围盒的纵向中心,故换锚点不改变构图。 + ``cell``/``cell_h``:交付画布的宽与高,``cell_h=None`` 即方形 ``cell×cell``(默认, 行为与加这个参数之前逐像素相同)。**要能出非方形画布,是为了让引擎一次就出到项目 要的 sprite 尺寸、不必在上层再缩一次。** 上层那次二次缩放不是"糊一点"那么简单: @@ -189,6 +198,14 @@ def align_bottom_center( # 不静默出一张 0×0:PIL 允许建 0 边长的图,后面 alpha_composite 也不报错, # 错产物要到落库/前端才暴露。 raise ValueError(f"交付画布尺寸必须为正,收到 cell={cell} cell_h={cell_h}") + if anchor not in _ANCHORS: + # 拼错一个字母不静默回落到脚线:那正好是本参数要修的那个错,而帧数 / 尺寸 / 成色 + # 全部正常,要靠人看动图才发现。 + raise ValueError(f"anchor 只能是 {_ANCHORS} 之一,收到 {anchor!r}") + if preserve_lift and anchor == ANCHOR_CENTROID: + # 质心对齐把每帧摆到同一条线上,抬升量随之被抹平 —— 两个都开等于 preserve_lift + # 是个空操作。 + raise ValueError("preserve_lift 与 anchor='centroid' 互斥:质心对齐会抹平抬升量") boxes: list[tuple[int, int, int, int] | None] = [] for f in frames: @@ -277,10 +294,16 @@ def align_bottom_center( fs = scale * per_frame[idx] w = max(1, round(crop.width * fs)) h = max(1, round(crop.height * fs)) + if anchor == ANCHOR_CENTROID: + # 锚点在缩放之前量:本体判据看的是原始像素,resize 的取整会把细延展物抹掉或加粗。 + r0, r1 = _core_rows(np.asarray(crop)[:, :, 3] > 128) + top = round(ch * (foot_line - fill_h / 2) - (r0 + r1) / 2 * fs) + else: + lift = round((ground - box[3]) * fs) if preserve_lift else 0 + top = int(ch * foot_line) - h - lift crop = crop.resize((w, h), Image.NEAREST) - 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)) + canvas.alpha_composite(crop, (cw // 2 - w // 2, top)) out.append(canvas) return out @@ -357,6 +380,19 @@ def _core_columns(mask) -> tuple[int, int] | None: return int(keep.min()), int(keep.max()) +def _core_rows(mask, thickness: float = CORE_THICKNESS) -> tuple[int, int]: + """本体占据的首尾行;``mask`` 须有主体像素。 + + 基准取行宽中位数而不是最厚那行,理由见 :func:`core_span` 里"延展物对行、列的污染方向 + 相反"那段 —— 与它共用一把尺子,免得本体的**位置**和**跨度**各按各的判据算。 + """ + import numpy as np + + rows = mask.sum(1) + keep = np.flatnonzero(rows >= float(np.median(rows[rows > 0])) * thickness) + return int(keep.min()), int(keep.max()) + + def _outliers(obs: list[tuple[int, float]], tol: float) -> set[int]: """按中位数判离群;整段有净位移时先把这条直线除掉。 diff --git a/backend/packages/ai_engine/src/windup_ai_engine/strategy/base.py b/backend/packages/ai_engine/src/windup_ai_engine/strategy/base.py index 1865ad26..a96ea9e7 100644 --- a/backend/packages/ai_engine/src/windup_ai_engine/strategy/base.py +++ b/backend/packages/ai_engine/src/windup_ai_engine/strategy/base.py @@ -17,6 +17,7 @@ from windup_common.models import ActionSpec, ActionType, CharacterCard, GenRoute from windup_ai_engine.ports import ProgressPort +from windup_ai_engine.postprocess.pack import ANCHOR_CENTROID, ANCHOR_FOOT # 动作类型 → 生成路线(架构决策,写死为契约) ROUTE_MATRIX: dict[ActionType, GenRoute] = { @@ -59,6 +60,18 @@ def is_cyclic(action: ActionSpec) -> bool: return bool(action.cyclic) return action.action in CYCLIC_ACTIONS + +def vertical_anchor(action: ActionSpec) -> str: + """这次生成按什么对齐垂直方向(见 postprocess.pack.align_bottom_center)。 + + **jump 不算无地面接触** —— 它腾空但要回地,按质心对齐会让落地帧悬在半空。全程无地面 + 的飞 / 游 / 攀只能以 CUSTOM 进来,故由调用方显式声明:按描述文字猜错是静默的(#534)。 + """ + if action.action is ActionType.CUSTOM and action.ground_contact is False: + return ANCHOR_CENTROID + return ANCHOR_FOOT + + # 本矩阵的形状本身有个已知边界,记录在此以免后来者按错误前提扩展: # 它是「动作类型 → 路线」的一对一映射,隐含前提是"路线由动作的物理性质唯一决定"。 # 该前提对逐帧 / 视频两条路线成立(有无连续步态是动作固有属性),但对渲染出帧路线不成立 diff --git a/backend/packages/app/src/windup_app/server/orchestrator/executor.py b/backend/packages/app/src/windup_app/server/orchestrator/executor.py index 380f3dc4..7197e162 100644 --- a/backend/packages/app/src/windup_app/server/orchestrator/executor.py +++ b/backend/packages/app/src/windup_app/server/orchestrator/executor.py @@ -344,7 +344,14 @@ def _produce_action( # 缺 loop 时兜成一次性,依据是失败代价不对称:一次性误当循环会让末帧接回首帧 # 抽搐、产物不可用;反之只是不无缝闭环、仍可用。不从描述文字猜。 cyclic = False if input.loop is None else bool(input.loop) - extra = {"custom_action": input.custom_prompt or "", "cyclic": cyclic} + # 缺 ground_contact 时兜成"有地面接触":绝大多数自述动作有脚踩地,而误判成 + # 全程离地会让角色不站在地上 —— 比飞行动作上下浮动严重。 + grounded = True if input.ground_contact is None else bool(input.ground_contact) + extra = { + "custom_action": input.custom_prompt or "", + "cyclic": cyclic, + "ground_contact": grounded, + } action = ActionSpec( action=engine_action, poses=[""] * input.num_frames, diff --git a/backend/packages/app/src/windup_app/server/orchestrator/model.py b/backend/packages/app/src/windup_app/server/orchestrator/model.py index 8d9495e6..ca8464f2 100644 --- a/backend/packages/app/src/windup_app/server/orchestrator/model.py +++ b/backend/packages/app/src/windup_app/server/orchestrator/model.py @@ -99,6 +99,10 @@ class CharacterActionInput: # 这个动作是否循环播放。``None`` 原样往下传,由编排层兜成一次性:本层替调用方填默认值 # 的话,"没给"和"明确给了 False"从这里起就再也分不开了。 loop: bool | None = None + # 这个动作有没有地面接触。``None`` 原样往下传,由编排层兜成"有" —— 本层替调用方填默认 + # 值的话,"没给"与"明确给了 True"从这里起就分不开了。飞 / 游 / 攀传 False,垂直对齐 + # 改按躯干中心走(#534);jump 不属此列,它腾空但要回地。 + ground_contact: bool | None = None # 视频模型。``None`` = 用部署配置的默认值。取值域为 # ``ModelRegistry.chain(CHARACTER_ACTION)``(部署默认 + fallbacks);不在链上 → 入口 # 报错,不到付费调用才失败。选中的型号表示这次从它开始试,由 Gateway 读 start_from_model。 diff --git a/backend/packages/app/src/windup_app/web/api/generation.py b/backend/packages/app/src/windup_app/web/api/generation.py index 939abd90..27c22b0b 100644 --- a/backend/packages/app/src/windup_app/web/api/generation.py +++ b/backend/packages/app/src/windup_app/web/api/generation.py @@ -200,6 +200,10 @@ class CharacterActionGenerateRequest(BaseModel): # 不对称:一次性动作被当成循环会让末帧接回首帧抽搐、产物不可用,反之只是不无缝闭环、 # 仍可用。而且猜错是静默的,帧数/时长/成色全部正常、没有任何一道会红。 loop: bool | None = None + # 这个动作有没有地面接触。飞 / 游 / 攀全程离地,它们的包围盒底边是尾羽与爪子、逐帧在变, + # 按脚线对齐反而让身体上下浮动(#534)。不给按"有"处理:误判成离地会让角色不站在地上, + # 比浮动严重。jump 不走这个字段 —— 它腾空但要回地。 + ground_contact: bool | None = None # 视频模型。None = 用部署默认。取值域见 ModelRegistry.chain(CHARACTER_ACTION); # 非法值在入口就报错,不到付费调用才失败。选中的型号表示这次从它开始试。 video_model: str | None = None @@ -222,6 +226,16 @@ def require_custom_prompt(self): self.custom_prompt = prompt return self + @model_validator(mode="after") + def ground_contact_belongs_to_custom_only(self): + # 写死的那几个动作都有地面接触,收下这个字段等于让调用方以为自己能改它。 + if self.action_type is not ActionType.CUSTOM and self.ground_contact is not None: + raise ValueError( + f"action_type={self.action_type.value} 不该带 ground_contact:" + "它只对 custom 动作有意义,写死的动作都有地面接触" + ) + return self + @model_validator(mode="after") def drop_blank_reference_image_urls(self): # 执行阶段只取 reference_image_urls[0]。留着空串会让"有母版"的判定成立, @@ -427,6 +441,7 @@ def submit_action_generation( action_type=body.action_type, custom_prompt=body.custom_prompt, loop=body.loop, + ground_contact=body.ground_contact, video_model=body.video_model, reference_video_url=body.reference_video_url, reference_image_urls=body.reference_image_urls, diff --git a/backend/packages/app/src/windup_app/worker/handlers.py b/backend/packages/app/src/windup_app/worker/handlers.py index 53668cd7..ddc96631 100644 --- a/backend/packages/app/src/windup_app/worker/handlers.py +++ b/backend/packages/app/src/windup_app/worker/handlers.py @@ -87,6 +87,7 @@ def _action_input(payload: dict) -> CharacterActionInput: reference_image_urls=list(payload.get("reference_image_urls") or []), num_frames=int(raw_frames) if raw_frames is not None else None, loop=payload.get("loop"), + ground_contact=payload.get("ground_contact"), video_model=payload.get("video_model"), outfit_id=payload.get("outfit_id"), model_3d_url=payload.get("model_3d_url"), diff --git a/backend/packages/common/src/windup_common/models/character.py b/backend/packages/common/src/windup_common/models/character.py index b00ab34c..3c3ea894 100644 --- a/backend/packages/common/src/windup_common/models/character.py +++ b/backend/packages/common/src/windup_common/models/character.py @@ -241,6 +241,12 @@ class ActionSpec(BaseModel): # 这里不给默认值,否则同一个缺省被两处各写一份,改一处另一处静默不动。 archetype: AttackArchetype | None = None + # 这个动作有没有地面接触。``False`` = 飞 / 游 / 攀这类**全程离地**,垂直对齐改按躯干 + # 中心走(见 ai_engine.strategy.base.vertical_anchor);jump 不属此列 —— 它腾空但要回地。 + # 不给按有地面接触处理:代价不对称,把有脚的动作当离地会让角色不站在地上,而反过来 + # 只是身体跟着尾羽上下浮动、产物仍可用。 + ground_contact: bool | None = None + @model_validator(mode="after") def _archetype_belongs_to_attack_only(self) -> ActionSpec: """非攻击动作带 archetype 要炸:它只被攻击提示词消费,传了不会生效。""" @@ -275,6 +281,11 @@ def _custom_needs_its_own_settings(self) -> ActionSpec: f"action={self.action.value} 不该带 cyclic;循环性由 CYCLIC_ACTIONS 写死," "传了不会生效" ) + if self.ground_contact is not None: + raise ValueError( + f"action={self.action.value} 不该带 ground_contact;写死的那几个动作都有" + "地面接触(jump 腾空但要回地),传了不会生效" + ) return self # 这里**没有** ``route`` 字段:路线选择整个在 server —— 走不走三渲二取决于"这个造型 diff --git a/backend/tests/test_generation_api.py b/backend/tests/test_generation_api.py index b360b2e9..c7e2490a 100644 --- a/backend/tests/test_generation_api.py +++ b/backend/tests/test_generation_api.py @@ -467,6 +467,57 @@ def test_stance_omitted_stays_none_not_biped(auth_client, monkeypatch): assert inputs and inputs[0].stance is None +def test_ground_contact_from_request_reaches_the_task_input(auth_client, monkeypatch): + """飞 / 游 / 攀的声明断在请求层的话,对齐那一步永远走脚线分支(#534)。""" + from windup_app.web.api import generation as gen_api + from windup_app.server.orchestrator.model import CharacterActionInput + + dispatched = _capture_action_input(gen_api, monkeypatch) + project = _create_project(auth_client) + character = _create_character(auth_client, project["id"]) + + auth_client.post( + "/generation/action", + json=_action_payload( + project["id"], character["id"], + action_type="custom", custom_prompt="flies forward", ground_contact=False, + ), + ) + + inputs = [a for args in dispatched for a in args if isinstance(a, CharacterActionInput)] + assert inputs, "任务没被收下" + assert inputs[0].ground_contact is False + + +def test_ground_contact_omitted_stays_none_not_true(auth_client, monkeypatch): + """不给就原样传 None —— 在这层填默认值,"没给"与"明确有地面接触"就分不开了。""" + from windup_app.web.api import generation as gen_api + from windup_app.server.orchestrator.model import CharacterActionInput + + dispatched = _capture_action_input(gen_api, monkeypatch) + project = _create_project(auth_client) + character = _create_character(auth_client, project["id"]) + + auth_client.post( + "/generation/action", json=_action_payload(project["id"], character["id"]), + ) + + inputs = [a for args in dispatched for a in args if isinstance(a, CharacterActionInput)] + assert inputs and inputs[0].ground_contact is None + + +def test_ground_contact_on_a_fixed_action_is_rejected_at_the_entrance(auth_client): + """walk 收下这个字段等于让调用方以为自己能改它,而它不会生效。""" + project = _create_project(auth_client) + character = _create_character(auth_client, project["id"]) + body = auth_client.post( + "/generation/action", + json=_action_payload(project["id"], character["id"], ground_contact=False), + ).json() + assert body["code"] == 400, body + assert "ground_contact" in body["message"], body["message"] + + def test_illegal_stance_is_rejected_at_the_entrance(auth_client): project = _create_project(auth_client) character = _create_character(auth_client, project["id"]) diff --git a/backend/tests/test_ground_contact_anchor.py b/backend/tests/test_ground_contact_anchor.py new file mode 100644 index 00000000..08d55fe9 --- /dev/null +++ b/backend/tests/test_ground_contact_anchor.py @@ -0,0 +1,286 @@ +"""无地面接触的动作按躯干中心对齐(#534)。 + +这一片锁的是一种静默错误:飞行序列的包围盒底边是尾羽与爪子、逐帧在变,把底边钉死在 +脚线上,身体反过来跟着延展物上下浮动 —— 而帧数、时长、成色、脚线 std 全部正常, +只有人盯着动图看才发现。 + +覆盖三段:① 几何(换锚点真的止住浮动,且不动定标);② 映射(jump 腾空但要回地, +不算无地面接触);③ 贯通(声明从 HTTP 入参一路走到对齐那一步,中间不许掉队)。 +""" +from __future__ import annotations + +import dataclasses +import io + +import numpy as np +import pytest +from PIL import Image +from pydantic import ValidationError + +from windup_ai_engine.impl import CharacterGenerator +from windup_ai_engine.postprocess.pack import ( + ANCHOR_CENTROID, + ANCHOR_FOOT, + FOOT_LINE, + align_bottom_center, + core_span, +) +from windup_ai_engine.strategy.base import DerivationStrategy, vertical_anchor +from windup_app.server.orchestrator.executor import ActionTaskExecutor, ProjectConstraints +from windup_app.server.orchestrator.model import ActionType as ApiActionType +from windup_app.server.orchestrator.model import CharacterActionInput +from windup_app.worker.handlers import _action_input +from windup_common.models import ( + ActionSpec, + ActionType, + CharacterCard, + GenRoute, + Stylize, +) + +CELL = 256 +REF_HEIGHT = 120.0 # 参考姿态高(生产由 _lastmile 按各帧包围盒中位数给) +_CORE = (200, 80, 80, 255) +_EXT = (180, 140, 90, 255) + + +def _flying(n: int = 12, size: int = 256, bw: int = 48, bh: int = 44, top: int = 90): + """飞行序列:躯干在原帧里一动不动,尾羽与爪子逐帧伸缩。 + + 躯干不动是**故意**的 —— 交付帧里量到的任何纵向浮动都只能来自锚点选择,不掺姿态。 + """ + out = [] + for i in range(n): + a = np.zeros((size, size, 4), np.uint8) + x0 = (size - bw) // 2 + a[top:top + bh, x0:x0 + bw] = _CORE + reach = int(4 + 22 * abs(np.sin(i / n * 2 * np.pi))) + a[top + bh:top + bh + reach, x0 + 6:x0 + 10] = _EXT + a[top + bh:top + bh + reach // 2, x0 + bw - 10:x0 + bw - 6] = _EXT + out.append(Image.fromarray(a, "RGBA")) + return out + + +def _walking(n: int = 12, size: int = 256, bw: int = 44, bh: int = 96): + """有地面接触的序列:脚线钉在同一行,身高按步态自然起伏。""" + out = [] + for i in range(n): + a = np.zeros((size, size, 4), np.uint8) + bob = i % 3 + x0, y1 = (size - bw) // 2, 200 + a[y1 - bh + bob:y1, x0:x0 + bw] = _CORE + a[y1 - bh - 12:y1 - bh, x0 + 8:x0 + 12] = _EXT # 举起的武器 + out.append(Image.fromarray(a, "RGBA")) + return out + + +def _core_center(img: Image.Image) -> float: + """交付帧里躯干的纵向中心。按颜色认躯干,免得又被延展物带走。""" + a = np.asarray(img) + m = (a[:, :, 0] > 150) & (a[:, :, 1] < 120) & (a[:, :, 3] > 128) + ys, _ = np.where(m) + return float(ys.min() + ys.max()) / 2 + + +def _foot_ratio(img: Image.Image) -> float: + a = np.asarray(img)[:, :, 3] + ys, _ = np.where(a > 128) + return (float(ys.max()) + 1) / img.size[1] + + +def _png(img: Image.Image) -> bytes: + buf = io.BytesIO() + img.save(buf, "PNG") + return buf.getvalue() + + +def _custom(ground_contact=None, **kw) -> ActionSpec: + kw.setdefault("n_frames", 8) + kw.setdefault("stylize", Stylize.NONE) + return ActionSpec( + action=ActionType.CUSTOM, + custom_action="flies forward with slow wing beats", + cyclic=True, + ground_contact=ground_contact, + **kw, + ) + + +# ── ① 几何:换锚点止住浮动,定标一动不动 ──────────────────────────────────── + + +def test_core_stops_bobbing_when_anchored_to_the_core(): + """验收①:同一条飞行序列,躯干中心的纵向 std 明显低于按脚线对齐。""" + src = _flying() + foot = [_core_center(f) for f in align_bottom_center(src, cell=CELL, ref_height=REF_HEIGHT)] + core = [ + _core_center(f) + for f in align_bottom_center(src, cell=CELL, ref_height=REF_HEIGHT, anchor=ANCHOR_CENTROID) + ] + foot_std, core_std = float(np.std(foot)), float(np.std(core)) + # 先验素材:脚线那档本来就该被延展物带着浮动,量不到说明这组帧根本没复现问题 + assert foot_std > 5.0, f"素材不成立:按脚线对齐也没浮动(std={foot_std:.2f})" + assert core_std <= 1.0, ( + f"躯干仍在浮动:脚线 std={foot_std:.2f} → 质心 std={core_std:.2f};" + f"逐帧中心 {[round(v, 1) for v in core]}" + ) + + +def test_grounded_frames_are_pixel_identical_to_the_default(): + """验收②:默认锚点一个像素都不许变。""" + src = _walking() + a = align_bottom_center(src, cell=CELL, ref_height=REF_HEIGHT) + b = align_bottom_center(src, cell=CELL, ref_height=REF_HEIGHT, anchor=ANCHOR_FOOT) + for x, y in zip(a, b, strict=True): + assert np.array_equal(np.asarray(x), np.asarray(y)) + + +def test_walking_still_lands_on_the_foot_line(): + """验收②的读数版:走路 / 待机的脚线仍落在 0.92。""" + out = align_bottom_center(_walking(), cell=CELL, ref_height=REF_HEIGHT) + feet = [_foot_ratio(f) for f in out] + assert max(abs(v - FOOT_LINE) for v in feet) <= 0.01, feet + + +def test_switching_the_anchor_does_not_resize_the_character(): + """只换垂直锚点,不动 fill_h / ref_height 那套定标 —— 交付本体高必须一样。""" + src = _flying() + foot = align_bottom_center(src, cell=CELL, ref_height=REF_HEIGHT) + core = align_bottom_center(src, cell=CELL, ref_height=REF_HEIGHT, anchor=ANCHOR_CENTROID) + for a, b in zip(foot, core, strict=True): + assert abs(core_span(a)[0] - core_span(b)[0]) <= 1 + + +def test_unknown_anchor_raises_instead_of_falling_back(): + """拼错一个字母不静默回落到脚线:那正是本参数要修的错,而产出看起来完全正常。""" + with pytest.raises(ValueError, match="anchor"): + align_bottom_center(_flying(), cell=CELL, anchor="centroide") + + +def test_preserve_lift_and_the_core_anchor_are_mutually_exclusive(): + """质心对齐会把每帧摆到同一条线上,抬升量随之被抹平 —— 同开等于空操作。""" + with pytest.raises(ValueError, match="preserve_lift"): + align_bottom_center(_flying(), cell=CELL, preserve_lift=True, anchor=ANCHOR_CENTROID) + + +# ── ② 映射:哪些动作算无地面接触 ─────────────────────────────────────────── + + +@pytest.mark.parametrize("action", [t for t in ActionType if t is not ActionType.CUSTOM]) +def test_fixed_actions_all_keep_the_foot_anchor(action): + assert vertical_anchor(ActionSpec(action=action)) == ANCHOR_FOOT + + +def test_jump_is_airborne_but_still_anchors_to_the_foot_line(): + """jump 腾空但要回地,与飞 / 游 / 攀不是一回事:按质心对齐会让落地帧悬在半空。""" + assert vertical_anchor(ActionSpec(action=ActionType.JUMP)) == ANCHOR_FOOT + + +def test_custom_switches_only_when_the_caller_declares_no_ground_contact(): + assert vertical_anchor(_custom(ground_contact=False)) == ANCHOR_CENTROID + assert vertical_anchor(_custom(ground_contact=True)) == ANCHOR_FOOT + assert vertical_anchor(_custom()) == ANCHOR_FOOT + + +def test_fixed_actions_must_not_carry_ground_contact(): + """给 walk 传这个字段要炸:写死的动作都有地面接触,收下它等于让调用方以为能改。""" + with pytest.raises(ValidationError, match="ground_contact"): + ActionSpec(action=ActionType.WALK, ground_contact=False) + + +# ── ③ 贯通:声明从 HTTP 入参一路走到对齐 ──────────────────────────────────── + + +class _FlyingStrategy(DerivationStrategy): + """顶替真实 i2v:返回同一条飞行序列,让最后一公里真跑。""" + + route = GenRoute.VIDEO_I2V + + def derive(self, card, action, master, progress) -> list[bytes]: + return [_png(f) for f in _flying(action.n_frames)] + + +class _NullProgress: + def step(self, stage: str, i: int, total: int, note: str = "") -> None: + pass + + +def _generate(action: ActionSpec) -> list[Image.Image]: + gen = CharacterGenerator({GenRoute.VIDEO_I2V: _FlyingStrategy()}) + out = gen.generate( + CharacterCard(name="鹰", desc="羽族斥候"), + action, + master=_png(_flying(1)[0]), + progress=_NullProgress(), + ) + return [Image.open(io.BytesIO(f)).convert("RGBA") for f in out.frames] + + +def test_engine_entrypoint_carries_the_declaration_into_the_last_mile(): + """只测 ``vertical_anchor`` 的话,``_lastmile`` 不把它传下去照样全绿。 + + 所以这条从引擎入口发起:同一组帧、同一条链,只有声明不同,量交付帧的躯干中心。 + """ + walk = [_core_center(f) for f in _generate(ActionSpec(action=ActionType.WALK, n_frames=8))] + fly = [_core_center(f) for f in _generate(_custom(ground_contact=False))] + assert float(np.std(walk)) > 5.0, "对照组不浮动的话这条用例证明不了任何事" + assert float(np.std(fly)) <= 1.0, f"声明了无地面接触,躯干仍在浮动: {fly}" + + +class _Stop(Exception): + """捕到 ActionSpec 就停,后面那些上传 / 记账与本条无关。""" + + +class _CaptureGenerator: + def __init__(self) -> None: + self.specs: list[ActionSpec] = [] + + def generate(self, card, action, master, progress, canvas=None): + self.specs.append(action) + raise _Stop + + def generate_rendered(self, card, action, rigged_model, progress, canvas=None): + raise AssertionError("没有 3D 资产却走了三渲二") + + +def _capture_spec(**input_kw) -> ActionSpec: + gen = _CaptureGenerator() + executor = ActionTaskExecutor( + generator=gen, + upload=lambda _png: "https://cdn.example.com/f.png", + fetch_master=lambda _input: b"master-bytes", + fetch_constraints=lambda *_: ProjectConstraints(sprite_w=64, sprite_h=64), + ) + with pytest.raises(_Stop): + executor._produce_action( + CharacterActionInput( + character_id=1, + action_type=ApiActionType.CUSTOM, + custom_prompt="flies forward", + num_frames=4, + **input_kw, + ), + ProjectConstraints(sprite_w=64, sprite_h=64), + ) + return gen.specs[0] + + +def test_orchestrator_puts_the_declaration_on_the_action_spec(): + assert _capture_spec(ground_contact=False).ground_contact is False + assert _capture_spec(ground_contact=True).ground_contact is True + + +def test_missing_declaration_falls_back_to_having_ground_contact(): + """不给按"有地面接触":误判成全程离地会让角色不站在地上,比浮动严重。""" + assert _capture_spec().ground_contact is True + + +def test_worker_rebuilds_the_declaration_from_the_task_payload(): + """入参经 MQ 落库再取回 —— 生产就是这条路,漏在这里等于整条链恒走默认分支。""" + src = CharacterActionInput( + character_id=1, + action_type=ApiActionType.CUSTOM, + custom_prompt="flies forward", + ground_contact=False, + ) + assert _action_input(dataclasses.asdict(src)).ground_contact is False diff --git a/backend/tests/test_render3d_route_and_assets.py b/backend/tests/test_render3d_route_and_assets.py index 00779559..2dc27ea4 100644 --- a/backend/tests/test_render3d_route_and_assets.py +++ b/backend/tests/test_render3d_route_and_assets.py @@ -607,6 +607,32 @@ def test_after_approval_it_proceeds_and_reuses_the_stored_model(tmp_path): assert (m.calls, r.calls) == (1, 1) +def test_geometry_follows_the_anchor_it_was_aligned_with(): + """质心对齐的动作要报质心那条线,不能仍报脚线。 + + 帧按质心摆、几何说的是脚线的话,消费方按它落位会错开半个身高,而帧数、时长、成色 + 全部正常,没有一道会红 —— 与 #534 换锚点是同一件事的两半。 + """ + from windup_ai_engine.postprocess import FOOT_LINE + from windup_ai_engine.postprocess.pack import FILL_H + + canvas = (256, 320) + gen = _real_generator(_FakeRenderer()) + foot = gen.generate_rendered( + _card(), _spec(), b"RIGGED", _NullProgress(), canvas=canvas, + ).geometry + fly = gen.generate_rendered( + _card(), + _spec(action=ActionType.CUSTOM, custom_action="飞", cyclic=True, ground_contact=False), + b"RIGGED", _NullProgress(), canvas=canvas, + ).geometry + + assert foot.anchor_y == FOOT_LINE + assert fly.anchor_y == pytest.approx(FOOT_LINE - FILL_H / 2) + assert fly.foot_y == int(canvas[1] * fly.anchor_y) + assert fly.anchor_y < foot.anchor_y, "质心那条线必须在脚线之上" + + def test_generated_action_reports_the_alignment_geometry_instead_of_a_constant(): """交付几何由引擎报出,不让消费方按常数推。 diff --git a/openapi.json b/openapi.json index 28d5b27e..cc1444ae 100644 --- a/openapi.json +++ b/openapi.json @@ -220,6 +220,17 @@ "$ref": "#/components/schemas/ActionDirection", "default": "east" }, + "ground_contact": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Ground Contact" + }, "loop": { "anyOf": [ {