diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 13ecbf4ac..c41408a5d 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -2403,3 +2403,10 @@ - 决策:`/editor/canvas` 的 `生成游戏音效` 入口继续保留,但默认且暂时唯一可用模型为 Vidu `audio1.0`,前端请求固定发送 `prompt`、`model: "audio1.0"`、`duration` 和 `priceMudPoints`,面板只显示 `Vidu` 与 `2-10` 秒时长选项,默认 `5` 秒;不再发送或展示 `sound`、`type`、`tempo`、BPM 或 Suno 文生音效入口。后端 `/api/editor/audios/sound-effects/generations` 只接受空模型或 `audio1.0`,拒绝 Suno / `chirp-*`;提交和轮询改走 Vidu `/ent/v2/text2audio` 与 `/ent/v2/tasks/{taskId}/creations`。背景音乐仍保留 Suno `/suno/submit/music`、`/suno/fetch/{taskId}` 和 wav clip 兜底逻辑。 - 影响范围:`server-rs/crates/platform-audio`、`server-rs/crates/api-server/src/vector_engine_audio_generation/generation.rs`、`server-rs/crates/shared-contracts/src/assets.rs`、`src/services/image-editor/editorProjectClient.ts`、`src/components/image-editor/ImageCanvasGeneration*`。 - 验证方式:`cargo test -p platform-audio --manifest-path server-rs/Cargo.toml --test vector_engine_audio`、`cargo test -p api-server --manifest-path server-rs/Cargo.toml editor_sound_effect`、`cargo test -p shared-contracts --manifest-path server-rs/Cargo.toml editor_audio_requests_and_response_use_canvas_audio_shape`、`npx vitest run src/services/image-editor/editorProjectClient.test.ts src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts src/components/image-editor/ImageCanvasGenerationComposerView.test.tsx src/components/image-editor/ImageCanvasGenerationModel.test.ts --reporter=dot`。 + +## 2026-06-19 角色主图抠图补充内部镂空检测 + +- 背景:编辑器角色形象和 Big Fish 正式图复用 `character_visual_assets::try_apply_background_alpha_to_png` 的角色主图透明背景后处理;原算法主要从画布四边扩散清理绿幕 / 近白背景,对主体包围的内部绿幕或近白镂空不稳定。 +- 决策:角色主图抠图继续保留在 `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`。 diff --git a/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md b/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md index 510626a12..bf7adedd5 100644 --- a/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md +++ b/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md @@ -64,7 +64,7 @@ 角色设定:<用户输入的角色设定> ``` -- 角色图生成完成后,后端必须先对返回图片执行绿幕 / 近白背景去背,并统一输出透明背景 PNG;随后写入 OSS 私有对象,并确认 `asset_object`。接口回包仍返回透明 PNG Data URL 供画板立即显示,同时返回 `objectKey` / `assetObjectId`,前端创建图层和画板资源记录时必须保存这两个字段。 +- 角色图生成完成后,后端必须先对返回图片执行绿幕 / 近白背景去背,连同角色主体包围的内部绿幕 / 近白镂空区域一起转为透明,并统一输出透明背景 PNG;随后写入 OSS 私有对象,并确认 `asset_object`。接口回包仍返回透明 PNG Data URL 供画板立即显示,同时返回 `objectKey` / `assetObjectId`,前端创建图层和画板资源记录时必须保存这两个字段。 ## 生成规范参考图 @@ -111,7 +111,7 @@ - 画布生成类入口已统一接入新建占位图落点避让:优先使用当前屏幕中心对应画板位置,重叠时自动选择最近的不重叠位置,并将视口中心移动到新占位图。 - 角色生成提交统一走 `/api/editor/images/generations`,按 `角色规范 -> 常规参考图` 顺序传 `referenceImageSrcs`,并写入 `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/character_visual_assets.rs b/server-rs/crates/api-server/src/character_visual_assets.rs index 08adcaa24..7b6901560 100644 --- a/server-rs/crates/api-server/src/character_visual_assets.rs +++ b/server-rs/crates/api-server/src/character_visual_assets.rs @@ -1372,6 +1372,7 @@ fn collect_foreground_neighbor_color( } pub(crate) fn remove_background_from_rgba(pixels: &mut [u8], width: usize, height: usize) -> bool { + const INTERNAL_BACKGROUND_HOLE_MIN_PIXELS: usize = 16; const SOFT_EDGE_ALPHA_THRESHOLD: u8 = 224; const FOREGROUND_NEIGHBOR_ALPHA_THRESHOLD: u8 = 96; @@ -1477,6 +1478,18 @@ pub(crate) fn remove_background_from_rgba(pixels: &mut [u8], width: usize, heigh } } + if mark_internal_background_holes( + pixels, + width, + height, + &mut background_mask, + &green_scores, + &white_scores, + INTERNAL_BACKGROUND_HOLE_MIN_PIXELS, + ) { + changed = true; + } + for _ in 0..2 { let mut expanded_mask = background_mask.clone(); for y in 0..height { @@ -1712,6 +1725,138 @@ pub(crate) fn remove_background_from_rgba(pixels: &mut [u8], width: usize, heigh changed } +fn mark_internal_background_holes( + pixels: &[u8], + width: usize, + height: usize, + background_mask: &mut [u8], + green_scores: &[f32], + white_scores: &[f32], + min_pixels: usize, +) -> bool { + let pixel_count = width.saturating_mul(height); + if pixel_count == 0 + || min_pixels == 0 + || pixels.len() < pixel_count.saturating_mul(4) + || background_mask.len() < pixel_count + || green_scores.len() < pixel_count + || white_scores.len() < pixel_count + { + return false; + } + + let mut visited = vec![false; pixel_count]; + let mut queue = Vec::::new(); + let mut component = Vec::::new(); + let mut changed = false; + + for start_index in 0..pixel_count { + if background_mask[start_index] != 0 + || visited[start_index] + || !is_internal_background_hole_candidate( + pixels, + start_index, + green_scores[start_index], + white_scores[start_index], + ) + { + continue; + } + + queue.clear(); + component.clear(); + queue.push(start_index); + visited[start_index] = true; + let mut queue_index = 0usize; + let mut touches_border = false; + + while queue_index < queue.len() { + let pixel_index = queue[queue_index]; + queue_index += 1; + component.push(pixel_index); + + let x = pixel_index % width; + let y = pixel_index / width; + if x == 0 || x == width.saturating_sub(1) || y == 0 || y == height.saturating_sub(1) { + touches_border = true; + } + + let neighbors = [ + if x > 0 { Some(pixel_index - 1) } else { None }, + if x + 1 < width { + Some(pixel_index + 1) + } else { + None + }, + if y > 0 { + Some(pixel_index - width) + } else { + None + }, + if y + 1 < height { + Some(pixel_index + width) + } else { + None + }, + ]; + + for next_index in neighbors.into_iter().flatten() { + if background_mask[next_index] != 0 || visited[next_index] { + continue; + } + if !is_internal_background_hole_candidate( + pixels, + next_index, + green_scores[next_index], + white_scores[next_index], + ) { + continue; + } + visited[next_index] = true; + queue.push(next_index); + } + } + + if touches_border || component.len() < min_pixels { + continue; + } + + for pixel_index in component.drain(..) { + background_mask[pixel_index] = 1; + } + changed = true; + } + + changed +} + +fn is_internal_background_hole_candidate( + pixels: &[u8], + pixel_index: usize, + green_score: f32, + white_score: f32, +) -> bool { + let offset = pixel_index * 4; + let Some(pixel) = pixels.get(offset..offset + 4) else { + return false; + }; + let red = pixel[0]; + let green = pixel[1]; + let blue = pixel[2]; + let alpha = pixel[3]; + if alpha < 40 { + return true; + } + + let hard_green_key = + green_score >= 0.82 && green >= 180 && red <= 72 && blue <= 96 && green > red.max(blue); + let hard_white_key = white_score >= 0.62 + && red.min(green).min(blue) >= 188 + && red.max(green).max(blue) - red.min(green).min(blue) <= 42; + + hard_green_key || hard_white_key +} + fn character_visual_error_response(request_context: &RequestContext, error: AppError) -> Response { error.into_response_with_context(Some(request_context)) } @@ -1792,6 +1937,33 @@ mod tests { assert!(is_image_test_moderation_error(&error)); } + #[test] + fn character_background_alpha_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])); + } + } + + assert!(remove_background_from_rgba( + image.as_mut(), + width as usize, + height as usize + )); + + assert_eq!(image.get_pixel(0, 0).0[3], 0); + assert_eq!(image.get_pixel(7, 7).0[3], 0); + assert_eq!(image.get_pixel(4, 4).0[3], 255); + } + #[test] fn sanitize_storage_segment_keeps_legacy_safe_shape() { assert_eq!(