diff --git a/backend/packages/ai_engine/src/windup_ai_engine/prompt/__init__.py b/backend/packages/ai_engine/src/windup_ai_engine/prompt/__init__.py index 07ccd79f..5000829b 100644 --- a/backend/packages/ai_engine/src/windup_ai_engine/prompt/__init__.py +++ b/backend/packages/ai_engine/src/windup_ai_engine/prompt/__init__.py @@ -4,7 +4,12 @@ from .custom import MAX_ACTION_CHARS, build_custom_prompt from .jump import JUMP_PHASES, build_jump_prompt from .presets import ACTION_PRESETS, ActionPreset -from .view_sheet import VIEW_SHEET_PROMPT_VERSION, build_view_sheet_prompt +from .view_sheet import ( + VIEW_SHEET_PROMPT_VERSION, + build_view_sheet_prompt, + master_pose_clause, + pixel_art_clause, +) from .walk import build_walk_prompt # 改动本包任何一个 build_*_prompt 的输出(包括 prompts/*.md 模板)都必须连带把这个 @@ -25,4 +30,6 @@ "PROMPT_VERSION", "VIEW_SHEET_PROMPT_VERSION", "build_view_sheet_prompt", + "master_pose_clause", + "pixel_art_clause", ] diff --git a/backend/packages/ai_engine/src/windup_ai_engine/prompt/prompts/view_sheet.md b/backend/packages/ai_engine/src/windup_ai_engine/prompt/prompts/view_sheet.md index ba333e9d..a250c55d 100644 --- a/backend/packages/ai_engine/src/windup_ai_engine/prompt/prompts/view_sheet.md +++ b/backend/packages/ai_engine/src/windup_ai_engine/prompt/prompts/view_sheet.md @@ -4,14 +4,19 @@ 西 / 西北 / 西南生产路径水平翻转后上传,不调模型 —— 这三节仍保留,避免执行器误调时 落到空提示词。 -正文只写正向计数句。构图约束(单主体、全身、灰底)放在 `## framing`,不抄进每个朝向。 +朝向节的三件套(Required view / Body / Visibility)来自 PerfectPixel Studio 的 +`facingDescs`。正文只写正向计数句。构图约束放在 `## framing`,定妆构图放在 +`## master`。 ## identity ```text This is an image-to-image task. The attached image is the confirmed FRONT-VIEW character master. -Preserve that identity exactly: the same face, hairstyle, body proportions, outfit, colors, -accessories, and silhouette. Change only the camera azimuth around the same standing figure. +It supplies identity only: the same face, hairstyle, body proportions, outfit, colors, +accessories, and silhouette. Facing direction lock (overrides any other facing or view +instruction in this prompt): redraw that identical standing figure at the facing lock below. +Hold that viewing angle for the whole figure. Never drift back toward a front view and never +mirror the character. Left and right of the outfit stay on their own sides of the body. Same idle pose, same scale, same costume, same character. ``` @@ -31,6 +36,35 @@ flat even lighting, plain light-gray background. Exactly one character is in the and the whole body stays inside the frame. ``` +## master + +定妆 `/generation/image` 用。站立参考,不是动作条带。灰底仍由执行器那句 +Plain light-gray 负责,不写洋红。 + +```text +Before drawing, identify and keep the subject's hairstyle, hair color, eye color, outfit +layers, accessories, weapon or signature prop, and dominant colors. Relaxed idle standing +pose, feet level, weight balanced, arms relaxed and readable. A single figure, head to feet, +vertically centered, occupying about three quarters of the canvas height with breathing room +on every side. Almost flat 2D game-sprite view. +``` + +## pixel + +像素项目才拼这一节。措辞全是正面约束:静态图通路没有 negative_prompt。 +来源是 PerfectPixel Studio 的 sprite / low-res 合同,适配本管线:身份锁正视母版, +灰底由 framing 负责,逻辑分辨率由项目精灵尺寸负责,不要写 16×16 / 纯白底 / 洋红。 + +```text +TRUE pixel art on a square grid, like a 32-64px game sprite enlarged on the canvas. +Each cell of the grid holds one flat solid color. Use a palette of six to eight solid colors; +when a reference image is attached, match that image's colors. Chunky square pixels aligned +to the grid, a clean dark 1px outline around the silhouette, solid tone clusters, at most one +highlight step and one shadow step. Compact torso, clear head shape, simple arms and legs. +Faces stay a flat color plane with readable eyes and mouth. The subject is centered with +empty margin on all sides. Every important shape stays readable at thumbnail size. +``` + ## elevation.side ```text @@ -54,63 +88,77 @@ orthographic, no vanishing point. ## south ```text -Front view, camera on the south, zero-degree azimuth. The character faces the viewer: -the face, chest, and both shoulders are square to the camera, both eyes visible, -the left and right sides of the outfit matching the master. +Facing direction lock: front view, camera on the south, zero-degree azimuth, camera directly +in front at eye level. Body orientation: the character faces the viewer directly. +Visibility: full face visible, eyes and mouth, both arms and both legs fully visible and +symmetric. ``` ## east ```text -Right-side profile, camera on the east, ninety-degree azimuth. The character faces right: -a true side view, one eye, the right shoulder and right arm toward the camera, -the left side of the body hidden, the same standing pose as the front master. +Facing direction lock: right-side profile view, camera on the east, ninety-degree azimuth, +camera at the character's right side, perpendicular to the body, strictly 2D profile. +Body orientation: the character faces toward the RIGHT edge of the canvas. +Visibility: true side view, right profile of the face only, one eye and one ear; right arm +and right leg prominent, left limbs fully hidden behind the body. ``` ## north ```text -Back view, camera on the north, one-hundred-eighty-degree azimuth. The character faces away: -the back of the head, the back, and both shoulder blades visible, the face hidden, -left and right reversed from the front master, the same standing pose. +Facing direction lock: back view, camera on the north, one-hundred-eighty-degree azimuth, +camera positioned directly behind the character. Body orientation: the character faces away +from the viewer. Visibility: face completely hidden, only the back of the head and hair +visible; back of the outfit, both arms and legs seen from behind. ``` ## west ```text -Left-side profile, camera on the west, two-hundred-seventy-degree azimuth. The character -faces left: a true side view, one eye, the left shoulder toward the camera, -the same standing pose as the front master. +Facing direction lock: left-side profile view, camera on the west, two-hundred-seventy-degree +azimuth, camera at the character's left side, perpendicular to the body, strictly 2D profile. +Body orientation: the character faces toward the LEFT edge of the canvas. +Visibility: true side view, left profile of the face only, one eye and one ear; left arm +and left leg prominent, right limbs fully hidden behind the body. ``` ## south_east ```text -Three-quarter front view to the right, camera on the south-east, forty-five-degree azimuth. -The face and the right side are both visible, the left side receding, the same standing pose -as the front master. +Facing direction lock: three-quarter front-right view, camera on the south-east, +forty-five-degree azimuth, camera at front-right, rotated about forty-five degrees from +straight ahead. Body orientation: the character is turned about forty-five degrees to the +right, mostly facing the viewer. Visibility: three-quarter face with both eyes visible, +right side emphasized; right arm and leg fully visible, left side partially visible. ``` ## north_east ```text -Three-quarter back view to the right, camera on the north-east, one-hundred-thirty-five-degree -azimuth. The back and the right side are both visible, the face mostly hidden, -the same standing pose as the front master. +Facing direction lock: three-quarter back-right view, camera on the north-east, +one-hundred-thirty-five-degree azimuth, camera behind and to the right. +Body orientation: the character is turned away from the viewer, showing the back-right side. +Visibility: face hidden except a hint of the right jaw; back and right shoulder prominent, +right arm and leg visible from behind. ``` ## south_west ```text -Three-quarter front view to the left, camera on the south-west, three-hundred-fifteen-degree -azimuth. The face and the left side are both visible, the right side receding, -the same standing pose as the front master. +Facing direction lock: three-quarter front-left view, camera on the south-west, +three-hundred-fifteen-degree azimuth, camera at front-left. +Body orientation: the character is turned about forty-five degrees to the left, mostly +facing the viewer. Visibility: three-quarter face with both eyes visible, left side +emphasized; left arm and leg fully visible, right side partially visible. ``` ## north_west ```text -Three-quarter back view to the left, camera on the north-west, two-hundred-twenty-five-degree -azimuth. The back and the left side are both visible, the face mostly hidden, -the same standing pose as the front master. +Facing direction lock: three-quarter back-left view, camera on the north-west, +two-hundred-twenty-five-degree azimuth, camera behind and to the left. +Body orientation: the character is turned away from the viewer, showing the back-left side. +Visibility: face hidden except a hint of the left jaw; back and left shoulder prominent, +left arm and leg visible from behind. ``` diff --git a/backend/packages/ai_engine/src/windup_ai_engine/prompt/view_sheet.py b/backend/packages/ai_engine/src/windup_ai_engine/prompt/view_sheet.py index 29eb5c54..f4cac350 100644 --- a/backend/packages/ai_engine/src/windup_ai_engine/prompt/view_sheet.py +++ b/backend/packages/ai_engine/src/windup_ai_engine/prompt/view_sheet.py @@ -11,10 +11,25 @@ from windup_ai_engine.prompt._md import load_section -__all__ = ["VIEW_SHEET_PROMPT_VERSION", "build_view_sheet_prompt"] +__all__ = [ + "VIEW_SHEET_PROMPT_VERSION", + "build_view_sheet_prompt", + "master_pose_clause", + "pixel_art_clause", +] _DOC = "view_sheet.md" -VIEW_SHEET_PROMPT_VERSION = "v1" +VIEW_SHEET_PROMPT_VERSION = "v4" + + +def pixel_art_clause() -> str: + """像素项目的网格 / 限色约束。定妆与 sheet 图生图共用,避免两处各写一版。""" + return load_section(_DOC, "pixel") + + +def master_pose_clause() -> str: + """定妆站立构图。sheet 图生图不拼这一节,身份与机位已在 identity / 朝向节。""" + return load_section(_DOC, "master") def build_view_sheet_prompt( @@ -22,11 +37,15 @@ def build_view_sheet_prompt( *, view: CharacterView | str = CharacterView.TOP_DOWN, extra: str = "", + stylize: str = "none", + feedback: str = "", ) -> str: """拼一条从正视母版转出 ``direction`` 的图生图提示词。 ``direction`` / ``view`` 都过一遍枚举构造:非法值要炸,不能静默落到某一朝向。 ``extra`` 是调用方补的站姿或画风短句,可空;身份与机位以模板为准。 + ``stylize`` 为 ``pixel`` 时追加网格限色约束;项目画风短语仍不进这条提示词。 + ``feedback`` 是上一轮 QC 的英文修正,可空;拼在末尾,不替换身份 / 机位节。 """ azimuth = ActionDirection(direction) @@ -38,7 +57,12 @@ def build_view_sheet_prompt( load_section(_DOC, f"elevation.{perspective.value}"), load_section(_DOC, "framing"), ] + if stylize == "pixel": + parts.append(pixel_art_clause()) extra_text = extra.strip() if extra_text: parts.append(extra_text) + feedback_text = feedback.strip() + if feedback_text: + parts.append(feedback_text) return " ".join(parts) diff --git a/backend/packages/ai_engine/src/windup_ai_engine/slicing/identity.py b/backend/packages/ai_engine/src/windup_ai_engine/slicing/identity.py new file mode 100644 index 00000000..cae5ad31 --- /dev/null +++ b/backend/packages/ai_engine/src/windup_ai_engine/slicing/identity.py @@ -0,0 +1,187 @@ +"""立绘格 vs 正视母版的身份读数与站立 QC。 + +直方图口径跟 PerfectPixel Studio 一样:64-bin RGB 交,门槛 0.40。背面系 +(north / NE / NW)会对背面误报,跳过身份。母版几乎不透明时直方图会被底色污染, +也跳过(他们 ``hasTransparency`` ≥5%)。 + +``inspect_standing_cell`` 是站立版 InspectFrames:空图 / 身份 / east 正面漂为 +Error,贴边只记 Hint。执行器拿 Error 重画,最多 ``STANDING_QC_ATTEMPTS`` 次。 +""" + +from __future__ import annotations + +import dataclasses + +import numpy as np +from PIL import Image + +from windup_common.directions import ActionDirection + +__all__ = [ + "IDENTITY_ERROR_SIM", + "PROFILE_FRONT_DRIFT", + "STANDING_QC_ATTEMPTS", + "StandingInspect", + "has_transparency", + "histogram_intersection", + "identity_similarity", + "inspect_standing_cell", + "is_back_facing", +] + +IDENTITY_ERROR_SIM = 0.40 +STANDING_QC_ATTEMPTS = 3 +PROFILE_FRONT_DRIFT = 0.85 +_INSPECT_EDGE_MARGIN = 2 +_INSPECT_EDGE_MAX = 24 +_BACK_FACING = frozenset( + { + ActionDirection.NORTH, + ActionDirection.NORTH_EAST, + ActionDirection.NORTH_WEST, + } +) +_HIST_BINS = 64 + + +@dataclasses.dataclass(frozen=True) +class StandingInspect: + ok: bool + hints: tuple[str, ...] + identity_sim: float | None + score: int + errors: int + + +def is_back_facing(direction: ActionDirection | str) -> bool: + return ActionDirection(direction) in _BACK_FACING + + +def has_transparency( + image: Image.Image, alpha_thr: int = 128, min_frac: float = 0.05, +) -> bool: + """透明像素占比达到 ``min_frac`` 才算有可抠的底。全涂满的测试图会跳过身份 / 朝向。""" + arr = np.asarray(image.convert("RGBA")) + total = int(arr.shape[0] * arr.shape[1]) + if total == 0: + return False + return float((arr[:, :, 3] <= alpha_thr).sum()) / total >= min_frac + + +def color_histogram(image: Image.Image, alpha_thr: int = 128) -> np.ndarray: + arr = np.asarray(image.convert("RGBA")) + mask = arr[:, :, 3] > alpha_thr + pixels = arr[mask][:, :3] + hist = np.zeros(_HIST_BINS, dtype=np.float64) + if len(pixels) == 0: + return hist + bins = ( + (pixels[:, 0].astype(np.int32) >> 6) << 4 + | (pixels[:, 1].astype(np.int32) >> 6) << 2 + | (pixels[:, 2].astype(np.int32) >> 6) + ) + counts = np.bincount(bins, minlength=_HIST_BINS).astype(np.float64) + total = counts.sum() + if total > 0: + hist = counts / total + return hist + + +def histogram_intersection(a: np.ndarray, b: np.ndarray) -> float: + return float(np.minimum(a, b).sum()) + + +def identity_similarity(frame: Image.Image, master: Image.Image) -> float: + """不透明像素 64-bin RGB 直方图交,0~1。""" + return histogram_intersection(color_histogram(frame), color_histogram(master)) + + +def _subject_wh(image: Image.Image) -> tuple[int, int] | None: + box = image.convert("RGBA").getchannel("A").getbbox() + if box is None: + return None + x0, y0, x1, y1 = box + width, height = x1 - x0, y1 - y0 + if width < 1 or height < 1: + return None + return width, height + + +def inspect_standing_cell( + frame: Image.Image, + master: Image.Image, + direction: ActionDirection | str, +) -> StandingInspect: + """抠图后、吸附 / 对齐前检查一格。只对 Error 判失败;贴边是 Warning。""" + azimuth = ActionDirection(direction) + arr = np.asarray(frame.convert("RGBA")) + height, width = arr.shape[:2] + opaque = arr[:, :, 3] > 128 + content = int(opaque.sum()) + hints: list[str] = [] + errors = 0 + sim: float | None = None + + # PerfectPixel 的 400 是 256² 格上的绝对值;这里用同一条相对口径:画布的 1%。 + min_content = max(width * height // 100, 1) + if content < min_content: + errors += 1 + hints.append( + "Every view must hold one complete, fully drawn full-body character. " + "Leave no view empty or faint." + ) + + if has_transparency(frame): + margin = _INSPECT_EDGE_MARGIN + edge = int( + opaque[:margin, :].sum() + + opaque[-margin:, :].sum() + + opaque[margin:-margin, :margin].sum() + + opaque[margin:-margin, -margin:].sum() + ) + if edge > _INSPECT_EDGE_MAX: + hints.append( + "Keep the whole figure inside the frame with clear padding on all " + "sides; no body part may touch or cross the edge." + ) + + if ( + not is_back_facing(azimuth) + and has_transparency(master) + and has_transparency(frame) + ): + sim = identity_similarity(frame, master) + if sim < IDENTITY_ERROR_SIM: + errors += 1 + hints.append( + "CRITICAL: the previous attempt drew a different-looking character. " + "Copy the attached FRONT-VIEW master's identity exactly — identical " + "hair color, skin tone, outfit colors, proportions and accessories." + ) + + if ( + azimuth is ActionDirection.EAST + and has_transparency(master) + and has_transparency(frame) + ): + south_wh = _subject_wh(master) + cell_wh = _subject_wh(frame) + if south_wh is not None and cell_wh is not None: + south_w, south_h = south_wh + cell_w, cell_h = cell_wh + width_at_south_h = cell_w * (south_h / cell_h) + if width_at_south_h >= PROFILE_FRONT_DRIFT * south_w: + errors += 1 + hints.append( + "Never drift back toward a front view. Required view is a true " + "right-side profile: camera at the character's right, one eye and " + "one ear, left limbs hidden behind the body." + ) + + return StandingInspect( + ok=errors == 0, + hints=tuple(hints), + identity_sim=sim, + score=content - errors * 10, + errors=errors, + ) 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 b3feb3b2..ce2ad7c4 100644 --- a/backend/packages/app/src/windup_app/server/orchestrator/executor.py +++ b/backend/packages/app/src/windup_app/server/orchestrator/executor.py @@ -25,6 +25,7 @@ from sqlalchemy.orm import Session from windup_ai_engine.ports import PromptRejected +from windup_ai_engine.prompt import master_pose_clause, pixel_art_clause from windup_ai_engine.slicing.quality import subject_blobs from windup_common.directions import direction_prompt from windup_common.enums import ArtStyle @@ -1058,6 +1059,9 @@ def _fetch_style(url: str) -> bytes | None: ] if cons.style: parts.append(f"Art style: {cons.style}.") + if cons.stylize == "pixel": + parts.append(pixel_art_clause()) + parts.append(master_pose_clause()) parts.append("Plain light-gray background, no shadow.") # 图生图模式:明确标注参考图用途。只有角色母版、没有项目风格图时同样必须写明 diff --git a/backend/packages/app/src/windup_app/server/orchestrator/view_sheet_executor.py b/backend/packages/app/src/windup_app/server/orchestrator/view_sheet_executor.py index 332cf302..e6ec64f5 100644 --- a/backend/packages/app/src/windup_app/server/orchestrator/view_sheet_executor.py +++ b/backend/packages/app/src/windup_app/server/orchestrator/view_sheet_executor.py @@ -1,6 +1,8 @@ """四向 / 八向立绘 sheet 编排。 -从已确认正视母版(south)图生图出源方向,镜像格水平翻转后上传,再拼一张 3×3 罗盘。 +从已确认正视母版(south)图生图出源方向;抠图后按 PerfectPixel 口径检查身份 / +空图 / east 正面漂,失败把英文 Hint 喂进下一轮,最多 3 次,不行留最好的一张。 +镜像格水平翻转后上传,再拼一张 3×3 罗盘。 不经过 ``ImageTaskExecutor._produce_image`` / ``DirectionSetTaskExecutor``。 web **不得 import 本模块**(与 ``executor`` 同门禁:会牵出 ai_engine)。 @@ -18,7 +20,14 @@ from PIL import Image from sqlalchemy.orm import Session +from windup_ai_engine.postprocess import master_pixel_spec, to_pixel_art from windup_ai_engine.prompt import build_view_sheet_prompt +from windup_ai_engine.slicing.identity import ( + STANDING_QC_ATTEMPTS, + identity_similarity, + inspect_standing_cell, + is_back_facing, +) from windup_ai_engine.slicing.quality import subject_blobs from windup_common.directions import ActionDirection from windup_common.models import CharacterView @@ -95,6 +104,86 @@ def flip_horizontal(png: bytes) -> bytes: return buf.getvalue() +def _subject_box(im: Image.Image) -> tuple[int, int, int, int] | None: + """不透明包围盒,右/下为开区间。全透明返回 None。""" + return im.convert("RGBA").getchannel("A").getbbox() + + +def pack_cell_to_master( + png: bytes, + master: Image.Image, + width: int, + height: int, + *, + nearest: bool, +) -> bytes: + """把一格主体缩放到与南向母版同高,脚底与水平中心对齐后再写入 ``width``×``height``。 + + 对应 PerfectPixel ``ExtractFrames`` 的公共缩放 + 基线,但标尺是已确认的 south + 立绘,不是条带里最高的一帧。量不到包围盒时退回 ``_fit_to``。画布装不下时 + 先按画布收一档缩放,再夹进画布 —— 不改母版 URL,也不在拼 sheet 时二次缩放。 + """ + src = Image.open(io.BytesIO(png)).convert("RGBA") + master_rgba = master.convert("RGBA") + master_box = _subject_box(master_rgba) + src_box = _subject_box(src) + if master_box is None or src_box is None: + return _fit_to(png, width, height, smooth=not nearest) + mx0, my0, mx1, my1 = master_box + sx0, sy0, sx1, sy1 = src_box + sub_w, sub_h = sx1 - sx0, sy1 - sy0 + master_h = my1 - my0 + if sub_w < 1 or sub_h < 1 or master_h < 1: + return _fit_to(png, width, height, smooth=not nearest) + scale = min(master_h / sub_h, width / sub_w, height / sub_h) + new_w = max(1, round(sub_w * scale)) + new_h = max(1, round(sub_h * scale)) + crop = src.crop((sx0, sy0, sx1, sy1)) + if (new_w, new_h) != (sub_w, sub_h): + crop = crop.resize( + (new_w, new_h), + Image.NEAREST if nearest else Image.LANCZOS, + ) + left = round((mx0 + mx1) / 2 - new_w / 2) + top = my1 - new_h + if left < 0: + left = 0 + elif left + new_w > width: + left = width - new_w + if top < 0: + top = 0 + elif top + new_h > height: + top = height - new_h + canvas = Image.new("RGBA", (width, height), (0, 0, 0, 0)) + canvas.alpha_composite(crop, (left, top)) + buf = io.BytesIO() + canvas.save(buf, "PNG") + return buf.getvalue() + + +def restore_pixel_cell(png: bytes, master: Image.Image, width: int, height: int) -> bytes: + """按正视母版的逻辑网格与色板吸附一格,再 contain 进精灵画布。 + + Perfect Pixel 的第二步是检测网格再采样;这里复用动作链路已有的 + ``master_pixel_spec`` / ``to_pixel_art``,不另引 OpenCV。量不出网格时退回 + NEAREST ``_fit_to``,与非像素路径的尺寸落实同一出口。 + """ + try: + logical_h, palette = master_pixel_spec(master) + except Exception: + return _fit_to(png, width, height, smooth=False) + if logical_h <= 8: + return _fit_to(png, width, height, smooth=False) + snapped = to_pixel_art( + Image.open(io.BytesIO(png)).convert("RGBA"), + target_h=logical_h, + palette=palette, + ) + buf = io.BytesIO() + snapped.save(buf, "PNG") + return _fit_to(buf.getvalue(), width, height, smooth=False) + + def compose_compass_sheet( cells: dict[ActionDirection, bytes], width: int, @@ -247,26 +336,56 @@ def _produce_sheets( matte = self._get_matte() upload = self._upload or self._upload_image refs = [master_png] + south_im = Image.open(io.BytesIO(master_png)).convert("RGBA") def _gen_one(job: tuple[int, ActionDirection]) -> bytes: sheet_i, direction = job - reset_call = fresh_gateway_request(start_from_model=spread[sheet_i]) - try: - prompt = build_view_sheet_prompt(direction, view=view, extra=extra) - return image_gen.gen_image(prompt, refs) - finally: - reset_call() - - raws = generation_io.io_map(_gen_one, jobs) + feedback = "" + best_cut: bytes | None = None + best_score = -10**9 + for _attempt in range(STANDING_QC_ATTEMPTS): + reset_call = fresh_gateway_request(start_from_model=spread[sheet_i]) + try: + prompt = build_view_sheet_prompt( + direction, + view=view, + extra=extra, + stylize=cons.stylize, + feedback=feedback, + ) + raw = image_gen.gen_image(prompt, refs) + finally: + reset_call() + cut = matte.cutout(raw) + insp = inspect_standing_cell( + Image.open(io.BytesIO(cut)).convert("RGBA"), + south_im, + direction, + ) + if insp.score > best_score: + best_cut, best_score = cut, insp.score + if insp.ok: + return cut + feedback = ( + "QUALITY CORRECTIONS detected by automated inspection " + "(fix all of these): " + " ".join(insp.hints) + ) + assert best_cut is not None + return best_cut + + cuts = generation_io.io_map(_gen_one, jobs) fitted: list[bytes] = [] cut_images: list[Image.Image] = [] - for raw in raws: - png = _fit_to( - matte.cutout(raw), - input.width, - input.height, - smooth=cons.stylize != "pixel", - ) + for cut in cuts: + if cons.stylize == "pixel": + png = restore_pixel_cell(cut, south_im, input.width, input.height) + png = pack_cell_to_master( + png, south_im, input.width, input.height, nearest=True, + ) + else: + png = pack_cell_to_master( + cut, south_im, input.width, input.height, nearest=False, + ) cut_images.append(Image.open(io.BytesIO(png)).convert("RGBA")) fitted.append(png) @@ -316,11 +435,23 @@ def _gen_one(job: tuple[int, ActionDirection]) -> bytes: ) sheets.append(CharacterViewSheetCandidate(sheet_url=sheet_url, cells=cells)) - south_im = Image.open(io.BytesIO(master_png)).convert("RGBA") + identity_sim = [] + for (_, direction), img in zip(jobs, cut_images, strict=True): + if is_back_facing(direction): + continue + identity_sim.append( + { + "direction": direction.value, + "sim": round(identity_similarity(img, south_im), 4), + } + ) return CharacterViewSheetOutput( type=task_type.value, sheets=sheets, - quality={"subject_blobs": list(subject_blobs([south_im, *cut_images]))}, + quality={ + "subject_blobs": list(subject_blobs([south_im, *cut_images])), + "identity_sim": identity_sim, + }, ) def _get_image(self): diff --git a/backend/tests/test_identity_histogram.py b/backend/tests/test_identity_histogram.py new file mode 100644 index 00000000..57b64789 --- /dev/null +++ b/backend/tests/test_identity_histogram.py @@ -0,0 +1,97 @@ +"""立绘身份直方图与站立 QC:背面跳过,换色低于 PerfectPixel 的 0.40 门槛。""" + +from __future__ import annotations + +from PIL import Image + +from windup_ai_engine.slicing.identity import ( + IDENTITY_ERROR_SIM, + PROFILE_FRONT_DRIFT, + identity_similarity, + inspect_standing_cell, + is_back_facing, +) +from windup_common.directions import ActionDirection + + +def _blob( + color: tuple[int, int, int, int], + *, + x: int, + y: int, + w: int, + h: int, + canvas: tuple[int, int] = (64, 96), +) -> Image.Image: + im = Image.new("RGBA", canvas, (0, 0, 0, 0)) + for yy in range(y, y + h): + for xx in range(x, x + w): + im.putpixel((xx, yy), color) + return im + + +def test_back_facing_matches_perfectpixel_north_family(): + assert is_back_facing(ActionDirection.NORTH) + assert is_back_facing(ActionDirection.NORTH_EAST) + assert is_back_facing("north_west") + assert not is_back_facing(ActionDirection.EAST) + assert not is_back_facing(ActionDirection.SOUTH_EAST) + + +def test_identical_sprites_score_near_one(): + im = Image.new("RGBA", (32, 32), (200, 40, 40, 255)) + assert identity_similarity(im, im) == 1.0 + + +def test_recolored_sprite_falls_below_identity_error_sim(): + red = Image.new("RGBA", (32, 32), (200, 40, 40, 255)) + blue = Image.new("RGBA", (32, 32), (40, 40, 200, 255)) + assert identity_similarity(red, blue) < IDENTITY_ERROR_SIM + + +def test_opaque_master_skips_identity_and_facing_errors(): + south = Image.new("RGBA", (64, 96), (200, 40, 40, 255)) + east = Image.new("RGBA", (64, 96), (40, 40, 200, 255)) + insp = inspect_standing_cell(east, south, ActionDirection.EAST) + assert insp.ok + assert insp.identity_sim is None + assert insp.errors == 0 + + +def test_empty_cell_is_an_error(): + south = _blob((200, 40, 40, 255), x=20, y=20, w=24, h=56) + empty = Image.new("RGBA", (64, 96), (0, 0, 0, 0)) + insp = inspect_standing_cell(empty, south, ActionDirection.EAST) + assert not insp.ok + assert any("empty or faint" in hint for hint in insp.hints) + + +def test_east_as_wide_as_south_is_front_drift(): + south = _blob((200, 40, 40, 255), x=20, y=20, w=24, h=56) + front = _blob((200, 40, 40, 255), x=20, y=20, w=24, h=56) + insp = inspect_standing_cell(front, south, ActionDirection.EAST) + assert not insp.ok + assert any("Never drift back toward a front view" in hint for hint in insp.hints) + slim = _blob((200, 40, 40, 255), x=4, y=48, w=8, h=28) + ok = inspect_standing_cell(slim, south, ActionDirection.EAST) + assert ok.ok + width_norm = 8 * (56 / 28) + assert width_norm < PROFILE_FRONT_DRIFT * 24 + + +def test_north_skips_identity_and_does_not_check_front_drift(): + south = _blob((200, 40, 40, 255), x=20, y=20, w=24, h=56) + north = _blob((40, 40, 200, 255), x=20, y=20, w=24, h=56) + insp = inspect_standing_cell(north, south, ActionDirection.NORTH) + assert insp.ok + assert insp.identity_sim is None + + +def test_recolored_transparent_east_fails_identity(): + south = _blob((200, 40, 40, 255), x=20, y=20, w=24, h=56) + east = _blob((40, 40, 200, 255), x=4, y=48, w=8, h=28) + insp = inspect_standing_cell(east, south, ActionDirection.EAST) + assert not insp.ok + assert insp.identity_sim is not None + assert insp.identity_sim < IDENTITY_ERROR_SIM + assert any("different-looking character" in hint for hint in insp.hints) diff --git a/backend/tests/test_master_cutout.py b/backend/tests/test_master_cutout.py index 3fc19adf..00b2602d 100644 --- a/backend/tests/test_master_cutout.py +++ b/backend/tests/test_master_cutout.py @@ -378,3 +378,28 @@ def gen_image(self, prompt, refs): assert len(urls) == 3 assert quality["subject_blobs"] == [1, 1, 1] assert len(threads) > 1 + + +def test_pixel_project_image_prompt_includes_grid_clause(): + """像素定妆走 Perfect Pixel 那套正面网格约束,不只丢一句 Art style: pixel art。""" + prompts: list[str] = [] + + class _Gen: + def gen_image(self, prompt, refs): + del refs + prompts.append(prompt) + return _master() + + ImageTaskExecutor( + image=_Gen(), + matte=_BackgroundMatte(), + upload=lambda b: "u", + )._produce_image( + CharacterImageInput(prompt="勇者", width=64, height=64, num_images=1), + ProjectConstraints(sprite_w=64, sprite_h=64, stylize="pixel", style="pixel art"), + ) + assert prompts + assert "Chunky square pixels" in prompts[0] + assert "Art style: pixel art" in prompts[0] + assert "three quarters of the canvas height" in prompts[0] + assert "hairstyle" in prompts[0] diff --git a/backend/tests/test_prompt_assets.py b/backend/tests/test_prompt_assets.py index e7a0b3da..18b8fd98 100644 --- a/backend/tests/test_prompt_assets.py +++ b/backend/tests/test_prompt_assets.py @@ -40,6 +40,8 @@ "identity", "pose", "framing", + "master", + "pixel", "elevation.side", "elevation.top-down", "elevation.isometric", diff --git a/backend/tests/test_view_sheet_executor.py b/backend/tests/test_view_sheet_executor.py index a0c8da85..d9028d3f 100644 --- a/backend/tests/test_view_sheet_executor.py +++ b/backend/tests/test_view_sheet_executor.py @@ -4,6 +4,7 @@ import io +import numpy as np import pytest from PIL import Image from sqlalchemy import create_engine, select @@ -21,6 +22,8 @@ ViewSheetTaskExecutor, compose_compass_sheet, flip_horizontal, + pack_cell_to_master, + restore_pixel_cell, ) from windup_app.server.quota.model import CreditAccount from windup_common.directions import ActionDirection @@ -110,6 +113,94 @@ def gen_image(self, prompt, refs): return _Gen() +def _blob( + color: tuple[int, int, int, int], + *, + x: int, + y: int, + w: int, + h: int, + canvas: tuple[int, int] = (_W, _H), +) -> bytes: + im = Image.new("RGBA", canvas, (0, 0, 0, 0)) + for yy in range(y, y + h): + for xx in range(x, x + w): + im.putpixel((xx, yy), color) + buf = io.BytesIO() + im.save(buf, "PNG") + return buf.getvalue() + + +def _bbox(png: bytes) -> tuple[int, int, int, int]: + box = _open(png).getchannel("A").getbbox() + assert box is not None + return box + + +def test_pack_cell_to_master_matches_south_height_and_foot_line(): + master_png = _blob((200, 40, 40, 255), x=20, y=20, w=24, h=56) + small_png = _blob((40, 180, 40, 255), x=8, y=40, w=12, h=28) + packed = pack_cell_to_master( + small_png, _open(master_png), _W, _H, nearest=False, + ) + assert _open(packed).size == (_W, _H) + assert _bbox(packed) == _bbox(master_png) + packed_im = _open(packed) + # 放大后的绿块脚底应落在母版脚底,水平中心对齐。 + assert packed_im.getpixel((31, 75))[1] > 100 + assert packed_im.getpixel((0, 0))[3] == 0 + + +def test_four_view_packs_generated_cells_to_south_bbox(sheet_session_factory): + from windup_app.server.orchestrator.executor import ProjectConstraints + + master_png = _blob((200, 40, 40, 255), x=20, y=20, w=24, h=56) + small_east = _blob((200, 40, 40, 255), x=4, y=48, w=8, h=28) + small_north = _blob((40, 40, 200, 255), x=40, y=8, w=16, h=32) + uploaded: dict[str, bytes] = {} + + class _Gen: + def gen_image(self, prompt, refs): + del refs + if "ninety-degree" in prompt: + return small_east + if "one-hundred-eighty" in prompt: + return small_north + raise AssertionError(prompt[:80]) + + def upload(png: bytes) -> str: + url = f"https://cdn.example.com/{id(png)}.png" + uploaded[url] = png + return url + + out = ViewSheetTaskExecutor( + image=_Gen(), + matte=_Matte(), + upload=upload, + fetch_ref=lambda url: master_png, + )._produce_sheets( + _sheet_input(), + GenerationType.CHARACTER_FOUR_VIEW, + ProjectConstraints(perspective=2), + ) + by_dir = {cell.direction: cell for cell in out.sheets[0].cells} + east_png = uploaded[by_dir[ActionDirection.EAST].image_url] + north_png = uploaded[by_dir[ActionDirection.NORTH].image_url] + west_png = uploaded[by_dir[ActionDirection.WEST].image_url] + master_box = _bbox(master_png) + east_box = _bbox(east_png) + assert (east_box[1], east_box[3]) == (master_box[1], master_box[3]) + assert (east_box[0] + east_box[2]) / 2 == (master_box[0] + master_box[2]) / 2 + assert east_box[2] - east_box[0] < master_box[2] - master_box[0] + north_box = _bbox(north_png) + assert (north_box[1], north_box[3]) == (master_box[1], master_box[3]) + assert (north_box[0] + north_box[2]) / 2 == (master_box[0] + master_box[2]) / 2 + west_box = _bbox(west_png) + assert (west_box[1], west_box[3]) == (master_box[1], master_box[3]) + assert west_box[2] - west_box[0] == east_box[2] - east_box[0] + assert by_dir[ActionDirection.SOUTH].image_url == _MASTER_URL + + def test_flip_horizontal_moves_mark_to_the_opposite_edge(): flipped = _open(flip_horizontal(EAST)) assert flipped.size == (_W, _H) @@ -296,3 +387,177 @@ def test_view_sheet_prompt_follows_master_not_project_style(): assert all("Art style" not in p and "中世纪厚涂" not in p for p in prompts) assert all("thirty to forty-five degrees" in p for p in prompts) assert all("像素风勇者" in p for p in prompts) + assert all("Chunky square pixels" in p for p in prompts) + + +def _opaque_colors(png: bytes) -> set[tuple[int, int, int]]: + arr = np.asarray(_open(png)) + rgb = arr[arr[:, :, 3] > 128][:, :3] + if rgb.size == 0: + return set() + return {tuple(c) for c in np.unique(rgb, axis=0)} + + +def _chunky_sprite() -> bytes: + """8px 块、6 色、透明底。逻辑高 80/8=10,过 restore 的 >8 门槛。""" + im = Image.new("RGBA", (_W, _H), (0, 0, 0, 0)) + palette = ( + (20, 20, 20), + (200, 40, 40), + (40, 120, 40), + (40, 40, 180), + (220, 180, 60), + (240, 220, 180), + ) + block = 8 + for gy in range(10): + for gx in range(6): + color = palette[(gx + gy) % 6] + x0, y0 = 8 + gx * block, 8 + gy * block + for y in range(block): + for x in range(block): + im.putpixel((x0 + x, y0 + y), (*color, 255)) + buf = io.BytesIO() + im.save(buf, "PNG") + return buf.getvalue() + + +def _lanczos(png: bytes, scale: int) -> bytes: + im = _open(png) + buf = io.BytesIO() + im.resize((im.width * scale, im.height * scale), Image.LANCZOS).save(buf, "PNG") + return buf.getvalue() + + +def test_restore_pixel_cell_snaps_antialiased_turnaround_to_master_palette(): + master_png = _chunky_sprite() + master = _open(master_png) + messy_png = _lanczos(master_png, 4) + restored = restore_pixel_cell(messy_png, master, _W, _H) + assert _open(restored).size == (_W, _H) + master_colors = _opaque_colors(master_png) + assert len(_opaque_colors(messy_png)) > len(master_colors) + assert _opaque_colors(restored) <= master_colors + + +def test_pixel_sheet_snaps_generated_cells_to_master_palette(): + from windup_app.server.orchestrator.executor import ProjectConstraints + + master_png = _chunky_sprite() + messy_png = _lanczos(master_png, 4) + uploaded: dict[str, bytes] = {} + + class _Gen: + def gen_image(self, prompt, refs): + del prompt, refs + return messy_png + + def upload(png: bytes) -> str: + url = f"https://cdn.example.com/{id(png)}.png" + uploaded[url] = png + return url + + out = ViewSheetTaskExecutor( + image=_Gen(), + matte=_Matte(), + upload=upload, + fetch_ref=lambda url: master_png, + )._produce_sheets( + _sheet_input(), + GenerationType.CHARACTER_FOUR_VIEW, + ProjectConstraints(stylize="pixel", perspective=2), + ) + east = next(c for c in out.sheets[0].cells if c.direction is ActionDirection.EAST) + assert _open(uploaded[east.image_url]).size == (_W, _H) + assert _opaque_colors(uploaded[east.image_url]) <= _opaque_colors(master_png) + + +def test_sheet_identity_sim_skips_back_facing_and_reads_recolored_east(): + from windup_app.server.orchestrator.executor import ProjectConstraints + from windup_ai_engine.slicing.identity import IDENTITY_ERROR_SIM + + out = ViewSheetTaskExecutor( + image=_gen([]), + matte=_Matte(), + upload=lambda png: f"https://cdn.example.com/{id(png)}.png", + fetch_ref=lambda url: SOUTH, + )._produce_sheets( + _sheet_input(), + GenerationType.CHARACTER_FOUR_VIEW, + ProjectConstraints(perspective=2), + ) + by_dir = {row["direction"]: row["sim"] for row in out.quality["identity_sim"]} + assert "east" in by_dir + assert "north" not in by_dir + assert by_dir["east"] < IDENTITY_ERROR_SIM + + +def test_east_front_drift_retries_once_then_keeps_profile(): + from windup_app.server.orchestrator.executor import ProjectConstraints + + master_png = _blob((200, 40, 40, 255), x=20, y=20, w=24, h=56) + front_east = _blob((200, 40, 40, 255), x=20, y=20, w=24, h=56) + profile_east = _blob((200, 40, 40, 255), x=4, y=48, w=8, h=28) + north_png = _blob((40, 40, 200, 255), x=40, y=8, w=16, h=32) + prompts: list[str] = [] + east_n = 0 + + class _Gen: + def gen_image(self, prompt, refs): + del refs + prompts.append(prompt) + if "one-hundred-eighty" in prompt: + return north_png + nonlocal east_n + east_n += 1 + return front_east if east_n == 1 else profile_east + + out = ViewSheetTaskExecutor( + image=_Gen(), + matte=_Matte(), + upload=lambda png: f"https://cdn.example.com/{id(png)}.png", + fetch_ref=lambda url: master_png, + )._produce_sheets( + _sheet_input(), + GenerationType.CHARACTER_FOUR_VIEW, + ProjectConstraints(perspective=2), + ) + east_prompts = [p for p in prompts if "ninety-degree" in p] + assert east_n == 2 + assert "QUALITY CORRECTIONS" not in east_prompts[0] + assert "QUALITY CORRECTIONS" in east_prompts[1] + assert "Never drift back toward a front view" in east_prompts[1] + assert any(c.direction is ActionDirection.EAST for c in out.sheets[0].cells) + + +def test_qc_keeps_best_after_three_failed_east_attempts(): + from windup_app.server.orchestrator.executor import ProjectConstraints + from windup_ai_engine.slicing.identity import STANDING_QC_ATTEMPTS + + master_png = _blob((200, 40, 40, 255), x=20, y=20, w=24, h=56) + front_east = _blob((200, 40, 40, 255), x=20, y=20, w=24, h=56) + north_png = _blob((40, 40, 200, 255), x=40, y=8, w=16, h=32) + prompts: list[str] = [] + + class _Gen: + def gen_image(self, prompt, refs): + del refs + prompts.append(prompt) + if "one-hundred-eighty" in prompt: + return north_png + return front_east + + out = ViewSheetTaskExecutor( + image=_Gen(), + matte=_Matte(), + upload=lambda png: f"https://cdn.example.com/{id(png)}.png", + fetch_ref=lambda url: master_png, + )._produce_sheets( + _sheet_input(), + GenerationType.CHARACTER_FOUR_VIEW, + ProjectConstraints(perspective=2), + ) + east_prompts = [p for p in prompts if "ninety-degree" in p] + assert len(east_prompts) == STANDING_QC_ATTEMPTS + assert any(c.direction is ActionDirection.EAST for c in out.sheets[0].cells) + diff --git a/backend/tests/test_view_sheet_prompt.py b/backend/tests/test_view_sheet_prompt.py index e72bcb5a..8dff1515 100644 --- a/backend/tests/test_view_sheet_prompt.py +++ b/backend/tests/test_view_sheet_prompt.py @@ -27,6 +27,8 @@ def test_build_locks_identity_then_camera(): assert text.index("FRONT-VIEW character master") < text.index("ninety-degree") assert "true side view" in text assert "Exactly one character" in text + assert "overrides any other facing" in text + assert "Never drift back toward a front view" in text def test_south_is_front_view_not_east_profile(): @@ -54,12 +56,45 @@ def test_extra_clause_appends_without_replacing_identity(): assert "FRONT-VIEW character master" in text +def test_pixel_stylize_appends_grid_clause_without_art_style_phrase(): + text = build_view_sheet_prompt(ActionDirection.EAST, stylize="pixel") + assert "Chunky square pixels" in text + assert "six to eight solid colors" in text + assert "32-64px game sprite" in text + assert "Art style" not in text + bare = build_view_sheet_prompt(ActionDirection.EAST) + assert "Chunky square pixels" not in bare + + +def test_source_directions_lock_pp_visibility(): + east = build_view_sheet_prompt(ActionDirection.EAST) + north = build_view_sheet_prompt(ActionDirection.NORTH) + south = build_view_sheet_prompt(ActionDirection.SOUTH) + assert "left limbs fully hidden behind the body" in east + assert "face completely hidden" in north + assert "faces the viewer directly" in south + assert "It supplies identity only" in east + + def test_blank_extra_does_not_pad_the_prompt(): bare = build_view_sheet_prompt(ActionDirection.NORTH) padded = build_view_sheet_prompt(ActionDirection.NORTH, extra=" ") assert bare == padded +def test_feedback_appends_after_extra_without_replacing_identity(): + text = build_view_sheet_prompt( + ActionDirection.EAST, + extra="idle stance", + feedback="Never drift back toward a front view.", + ) + assert text.endswith("Never drift back toward a front view.") + assert text.index("FRONT-VIEW character master") < text.index("idle stance") + assert text.index("idle stance") < text.index("Never drift back toward a front view.") + bare = build_view_sheet_prompt(ActionDirection.EAST, extra="idle stance") + assert "Never drift back toward a front view." not in bare + + def test_illegal_direction_or_view_raises(): with pytest.raises(ValueError): build_view_sheet_prompt("eastt")