编辑器角色形象改用通用抠图
将编辑器角色形象回填切到通用绿幕去背能力 保留内部镂空清理并补充回归测试 同步角色形象设计文档和项目决策记录
This commit is contained in:
@@ -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![
|
||||
|
||||
Reference in New Issue
Block a user