补充角色抠图内部镂空检测

角色主图抠图新增内部绿幕和近白镂空连通域清理
补充内部镂空回归测试
同步角色生成设计文档和项目共享决策记录
This commit is contained in:
2026-06-19 15:47:07 +08:00
parent 870d723eaf
commit 5bb1ac7337
3 changed files with 181 additions and 2 deletions
@@ -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`
@@ -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/<taskId>/image.png` 路径下的 OSS 私有对象,返回的 `objectKey` / `assetObjectId` 会随画板资源记录保存。
- 角色生成后端已按固定 prompt 骨架补入 `角色设定`,并在生成成功后自动执行绿幕 / 近白背景去背和内部镂空清理、写入 `generated-character-drafts/editor/character-images/<taskId>/image.png` 路径下的 OSS 私有对象,返回的 `objectKey` / `assetObjectId` 会随画板资源记录保存。
- `Esc` 只退出角色规范画布点选状态,不关闭角色生成面板。
- 已补充回归测试覆盖角色形象生成、点选退出、角色动画入口隔离和快速编辑入口。
- 本次验证命令:
@@ -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::<usize>::new();
let mut component = Vec::<usize>::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!(