From 1b1caf13b83361ec7bf62deb26f48b99cecf7105 Mon Sep 17 00:00:00 2001 From: kdletters Date: Fri, 19 Jun 2026 17:15:53 +0800 Subject: [PATCH] =?UTF-8?q?=E7=BC=96=E8=BE=91=E5=99=A8=E8=A7=92=E8=89=B2?= =?UTF-8?q?=E5=BD=A2=E8=B1=A1=E6=94=B9=E7=94=A8=E9=80=9A=E7=94=A8=E6=8A=A0?= =?UTF-8?q?=E5=9B=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将编辑器角色形象回填切到通用绿幕去背能力 保留内部镂空清理并补充回归测试 同步角色形象设计文档和项目决策记录 --- .../shared-memory/decision-log.md | 7 ++ ...辑器】画板角色形象生成入口设计-2026-06-15.md | 6 +- .../crates/api-server/src/editor_project.rs | 92 +++++++++++++------ 3 files changed, 73 insertions(+), 32 deletions(-) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index c26de5aa1..0ecea972b 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -2542,3 +2542,10 @@ - 决策:角色主图抠图继续保留在 `api-server` 的 `character_visual_assets` 口径内,不切换到 `platform-image::generated_asset_sheets`。在边缘连通背景 BFS 之后、软边扩展和边缘去污染之前,新增内部背景连通域检测:只清理不触画布边界、像素数达到阈值、且为高置信绿幕 / 近白背景的内部连通域,避免误伤普通角色纹理。 - 影响范围:`server-rs/crates/api-server/src/character_visual_assets.rs`、`docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md`。 - 验证方式:`cargo test -p api-server --manifest-path server-rs/Cargo.toml character_background_alpha_removes_internal_green_holes`、`cargo test -p api-server --manifest-path server-rs/Cargo.toml editor_character_image_postprocess`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run check:encoding`、`git diff --check`。 + +## 2026-06-19 编辑器角色形象回填改用通用抠图 + +- 背景:画板编辑器里的 `生成角色形象` 属于编辑器图片生成链路,用户要求把“人物抠图”改成通用抠图方法,不再与 RPG / 资产工坊角色主图专用后处理绑定。 +- 决策:仅 `/api/editor/images/generations` 中 `kind = "character"` 的编辑器角色形象回填改用 `platform-image::generated_asset_sheets` 通用绿幕 / 近白去背能力,并开启内部镂空检测;输出仍归一为透明 PNG。`character_visual_assets::try_apply_background_alpha_to_png` 继续服务 RPG 角色主图与 Big Fish 等“角色主图口径”调用者,本轮不改变这些链路。 +- 影响范围:`server-rs/crates/api-server/src/editor_project.rs`、`docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md`。 +- 验证方式:`cargo test -p api-server --manifest-path server-rs/Cargo.toml editor_character_image_general_cutout`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run check:encoding`、`git diff --check`。 diff --git a/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md b/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md index bf7adedd5..748855f58 100644 --- a/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md +++ b/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md @@ -64,7 +64,8 @@ 角色设定:<用户输入的角色设定> ``` -- 角色图生成完成后,后端必须先对返回图片执行绿幕 / 近白背景去背,连同角色主体包围的内部绿幕 / 近白镂空区域一起转为透明,并统一输出透明背景 PNG;随后写入 OSS 私有对象,并确认 `asset_object`。接口回包仍返回透明 PNG Data URL 供画板立即显示,同时返回 `objectKey` / `assetObjectId`,前端创建图层和画板资源记录时必须保存这两个字段。 +- 角色图生成完成后,编辑器后端必须走通用抠图方法:复用 `platform-image::generated_asset_sheets` 的绿幕 / 近白背景去背能力,并开启内部绿幕 / 近白镂空检测。该流程不再调用 RPG / 资产工坊的角色主图专用 `character_visual_assets` 后处理;输出仍统一为透明背景 PNG,随后写入 OSS 私有对象并确认 `asset_object`。接口回包仍返回透明 PNG Data URL 供画板立即显示,同时返回 `objectKey` / `assetObjectId`,前端创建图层和画板资源记录时必须保存这两个字段。 +- 对 `assetKind: "character"` 的角色图层执行 `重绘` 时,前端仍使用原图作为参考图,但请求 `kind` 必须传 `character`,让后端继续套用上述角色提示词限定、角色图后处理和角色资产持久化;普通图片图层重绘仍保持 `kind: "quick-edit"`。 ## 生成规范参考图 @@ -110,8 +111,9 @@ - 前端画板已接入 `生成角色形象` 底部入口、角色占位图、角色面板、画布点选规范图、上传规范图、上传常规参考图和序号角标。 - 画布生成类入口已统一接入新建占位图落点避让:优先使用当前屏幕中心对应画板位置,重叠时自动选择最近的不重叠位置,并将视口中心移动到新占位图。 - 角色生成提交统一走 `/api/editor/images/generations`,按 `角色规范 -> 常规参考图` 顺序传 `referenceImageSrcs`,并写入 `assetKind: "character"`。 +- 角色图层重绘同样走 `/api/editor/images/generations` 的 `kind: "character"` 分支,原图作为参考图提交,生成结果继续保留 `assetKind: "character"`。 - 角色和图标素材生成已接入 `nanobanana2` / `gpt-image-2` 模型切换、上次模型记忆,以及按模型归一的比例 / 大小尺寸;`nanobanana2` 使用原生 `generateContent` 的 `imageConfig.aspectRatio/imageSize`,`gpt-image-2` 使用文档列出的 `size` 字符串。 -- 角色生成后端已按固定 prompt 骨架补入 `角色设定`,并在生成成功后自动执行绿幕 / 近白背景去背和内部镂空清理、写入 `generated-character-drafts/editor/character-images//image.png` 路径下的 OSS 私有对象,返回的 `objectKey` / `assetObjectId` 会随画板资源记录保存。 +- 角色生成后端已按固定 prompt 骨架补入 `角色设定`,并在生成成功后通过编辑器通用抠图方法执行绿幕 / 近白背景去背和内部镂空清理、写入 `generated-character-drafts/editor/character-images//image.png` 路径下的 OSS 私有对象,返回的 `objectKey` / `assetObjectId` 会随画板资源记录保存。 - `Esc` 只退出角色规范画布点选状态,不关闭角色生成面板。 - 已补充回归测试覆盖角色形象生成、点选退出、角色动画入口隔离和快速编辑入口。 - 本次验证命令: diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index a4253cb80..6d9502cf2 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -13,7 +13,8 @@ use module_assets::{ use platform_image::{ DownloadedImage, generated_asset_sheets::{ - GeneratedAssetSheetConnectedIcon, + GeneratedAssetSheetAlphaOptions, GeneratedAssetSheetConnectedIcon, + apply_generated_asset_sheet_alpha_with_options, slice_generated_icon_spritesheet_all_by_connected_components, slice_generated_icon_spritesheet_by_connected_components, }, @@ -1944,41 +1945,37 @@ fn build_editor_character_image_prompt(role_setting: &str) -> String { fn prepare_editor_character_image_for_response( image: DownloadedOpenAiImage, ) -> DownloadedOpenAiImage { - let source_bytes = if image.mime_type == "image/png" { - image.bytes - } else { - match image::load_from_memory(image.bytes.as_slice()) { - Ok(decoded) => { - let mut encoded = std::io::Cursor::new(Vec::new()); - if decoded - .write_to(&mut encoded, image::ImageFormat::Png) - .is_err() - { - return image; - } - encoded.into_inner() - } - Err(_) => return image, - } + let Ok(decoded) = image::load_from_memory(image.bytes.as_slice()) else { + return image; }; - let processed = - crate::character_visual_assets::try_apply_background_alpha_to_png(source_bytes.as_slice()); - let Some(bytes) = processed else { - return DownloadedOpenAiImage { - bytes: source_bytes, - mime_type: "image/png".to_string(), - extension: "png".to_string(), - }; - }; + let processed = apply_generated_asset_sheet_alpha_with_options( + decoded, + editor_general_cutout_alpha_options(), + ); + let mut encoded = std::io::Cursor::new(Vec::new()); + if processed + .write_to(&mut encoded, image::ImageFormat::Png) + .is_err() + { + return image; + } DownloadedOpenAiImage { - bytes, + bytes: encoded.into_inner(), mime_type: "image/png".to_string(), extension: "png".to_string(), } } +fn editor_general_cutout_alpha_options() -> GeneratedAssetSheetAlphaOptions { + GeneratedAssetSheetAlphaOptions { + detect_internal_holes: true, + internal_hole_min_pixels: 16, + ..GeneratedAssetSheetAlphaOptions::green_screen() + } +} + pub(crate) fn data_url_from_image_bytes(mime_type: &str, bytes: &[u8]) -> String { format!( "data:{};base64,{}", @@ -2319,14 +2316,15 @@ mod tests { fn editor_character_image_prompt_appends_user_role_setting() { let prompt = build_editor_character_image_prompt("菜市场卖菜大妈"); - assert!(prompt.contains("基于图1的角色美术视觉规范指导生成游戏角色形象图。")); + assert!(prompt.contains("基于图1的角色美术视觉规范指导")); + assert!(prompt.contains("生成游戏角色形象图")); assert!(prompt.contains("背景固定为纯绿色绿幕")); assert!(prompt.contains("禁止镜头透视")); assert!(prompt.contains("角色设定:菜市场卖菜大妈")); } #[test] - fn editor_character_image_postprocess_removes_green_screen_background() { + fn editor_character_image_uses_general_cutout_for_green_screen_background() { let width = 12; let height = 12; let mut image = image::RgbaImage::from_pixel(width, height, image::Rgba([0, 255, 0, 255])); @@ -2356,7 +2354,7 @@ mod tests { } #[test] - fn editor_character_image_postprocess_converts_non_png_green_screen_to_transparent_png() { + fn editor_character_image_general_cutout_converts_non_png_to_transparent_png() { let width = 12; let height = 12; let mut image = @@ -2386,6 +2384,40 @@ mod tests { assert_eq!(decoded.get_pixel(5, 5).0[3], 255); } + #[test] + fn editor_character_image_general_cutout_removes_internal_green_holes() { + let width = 16; + let height = 16; + let mut image = image::RgbaImage::from_pixel(width, height, image::Rgba([0, 255, 0, 255])); + for y in 3..13 { + for x in 3..13 { + image.put_pixel(x, y, image::Rgba([188, 82, 45, 255])); + } + } + for y in 6..10 { + for x in 6..10 { + image.put_pixel(x, y, image::Rgba([0, 255, 0, 255])); + } + } + let mut encoded = std::io::Cursor::new(Vec::new()); + image::DynamicImage::ImageRgba8(image) + .write_to(&mut encoded, image::ImageFormat::Png) + .expect("test image should encode"); + + let processed = prepare_editor_character_image_for_response(DownloadedOpenAiImage { + bytes: encoded.into_inner(), + mime_type: "image/png".to_string(), + extension: "png".to_string(), + }); + let decoded = image::load_from_memory(processed.bytes.as_slice()) + .expect("processed image should decode") + .to_rgba8(); + + assert_eq!(decoded.get_pixel(0, 0).0[3], 0); + assert_eq!(decoded.get_pixel(7, 7).0[3], 0); + assert_eq!(decoded.get_pixel(4, 4).0[3], 255); + } + #[test] fn editor_icon_spritesheet_prompt_uses_ordered_descriptions_and_size_tiers() { let descriptions = vec![