补充角色抠图内部镂空检测
角色主图抠图新增内部绿幕和近白镂空连通域清理 补充内部镂空回归测试 同步角色生成设计文档和项目共享决策记录
This commit is contained in:
@@ -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!(
|
||||
|
||||
Reference in New Issue
Block a user