编辑器角色形象改用通用抠图

将编辑器角色形象回填切到通用绿幕去背能力

保留内部镂空清理并补充回归测试

同步角色形象设计文档和项目决策记录
This commit is contained in:
2026-06-19 17:15:53 +08:00
parent f615fcd770
commit 1b1caf13b8
3 changed files with 73 additions and 32 deletions
@@ -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`
@@ -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/<taskId>/image.png` 路径下的 OSS 私有对象,返回的 `objectKey` / `assetObjectId` 会随画板资源记录保存。
- 角色生成后端已按固定 prompt 骨架补入 `角色设定`,并在生成成功后通过编辑器通用抠图方法执行绿幕 / 近白背景去背和内部镂空清理、写入 `generated-character-drafts/editor/character-images/<taskId>/image.png` 路径下的 OSS 私有对象,返回的 `objectKey` / `assetObjectId` 会随画板资源记录保存。
- `Esc` 只退出角色规范画布点选状态,不关闭角色生成面板。
- 已补充回归测试覆盖角色形象生成、点选退出、角色动画入口隔离和快速编辑入口。
- 本次验证命令:
@@ -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![