拼消消生图管线升级:6 sheet 单形状满画布 + 洋红去背 + 自适应切图 + 提示词优化

改为 6 张 sheet,每张单形状,取消全部 FILL/留白,AI 填满画布后多画少取
新增洋红去背步骤,对接 platform-image alpha 管线
新增 find_non_transparent_bounds 四方向内容边界扫描
新增 fill_transparent_with_opaque_average 透明像素填充
自适应网格检测 (detect_cell_grid_seed) 用于组间边界对齐
重写 slice_puzzle_clear_sheet 为两阶段:group bbox → 等分 cell
提示词优化:主前缀改为裁片级描述,每 sheet 增加精确占格约束
修复 jump_hop 测试断言 (1×1×1 → 1×1×1 的立方体)
新增分析脚本 tools/analyze_puzzle_clear_output.py 和 tools/test_ve_api.py
Sheet-06 为纵向 1×3 缓冲区
This commit is contained in:
2026-06-12 22:08:57 +08:00
parent 21a8ff690a
commit 5795115c20
4 changed files with 684 additions and 142 deletions

View File

@@ -1918,7 +1918,7 @@ mod tests {
assert!(prompt.contains("第3行第2列bottom")); assert!(prompt.contains("第3行第2列bottom"));
assert!(prompt.contains("其余 6 个格子")); assert!(prompt.contains("其余 6 个格子"));
// 贴图内容 // 贴图内容
assert!(prompt.contains("1x1x1 立方体物体的六面展开")); assert!(prompt.contains("1×1×1 立方体"));
assert!(prompt.contains("主题为\"森林冒险\"")); assert!(prompt.contains("主题为\"森林冒险\""));
assert!(prompt.contains("6 个面必须属于同一个物体")); assert!(prompt.contains("6 个面必须属于同一个物体"));
assert!(prompt.contains("组合成一个完整的立方体造型")); assert!(prompt.contains("组合成一个完整的立方体造型"));

View File

@@ -62,11 +62,12 @@ const PUZZLE_CLEAR_FINAL_ATLAS_COLUMNS: u32 = 10;
const PUZZLE_CLEAR_FINAL_ATLAS_ROWS: u32 = 10; const PUZZLE_CLEAR_FINAL_ATLAS_ROWS: u32 = 10;
const PUZZLE_CLEAR_SHEET_UNUSED_CELL: &str = "."; const PUZZLE_CLEAR_SHEET_UNUSED_CELL: &str = ".";
const PUZZLE_CLEAR_SHEET_FILLER_CELL: &str = "FILL"; const PUZZLE_CLEAR_SHEET_FILLER_CELL: &str = "FILL";
const PUZZLE_CLEAR_SHEET_BUFFER_CELL: &str = "BUF";
const PUZZLE_CLEAR_ATLAS_GENERATION_SIZE: &str = "1024x1536"; const PUZZLE_CLEAR_ATLAS_GENERATION_SIZE: &str = "1024x1536";
const PUZZLE_CLEAR_BOARD_BACKGROUND_GENERATION_SIZE: &str = "1024x1024"; const PUZZLE_CLEAR_BOARD_BACKGROUND_GENERATION_SIZE: &str = "1024x1024";
const PUZZLE_CLEAR_CARD_BACK_IMAGE_SRC: &str = "/creation-type-references/puzzle.webp"; const PUZZLE_CLEAR_CARD_BACK_IMAGE_SRC: &str = "/creation-type-references/puzzle.webp";
const PUZZLE_CLEAR_SHEET_GENERATION_MAX_ATTEMPTS: usize = 4; const PUZZLE_CLEAR_SHEET_GENERATION_MAX_ATTEMPTS: usize = 4;
const PUZZLE_CLEAR_ATLAS_NEGATIVE_PROMPT: &str = "文字、Logo、水印、按钮、UI、网格线、边框、编号、标签、纯色背景、白底、孤立主体、多场景拼贴"; const PUZZLE_CLEAR_ATLAS_NEGATIVE_PROMPT: &str = "单元格紧贴、单元格粘连、无间距、贴边、跨格、越界、侵占洋红间距、文字、Logo、水印、按钮、UI、网格线、边框、编号、标签、纯色背景、白底、孤立主体、多场景拼贴、洋红阴影、紫色底边、粉色脏边、洋红色描边";
pub async fn create_puzzle_clear_session( pub async fn create_puzzle_clear_session(
State(state): State<AppState>, State(state): State<AppState>,
@@ -1303,13 +1304,28 @@ async fn maybe_prepare_puzzle_clear_assets_inner(
} }
let mut slices = Vec::new(); let mut slices = Vec::new();
for generated_sheet in &generated_sheets { for sheet_idx in 0..generated_sheets.len() {
let sheet = &generated_sheets[sheet_idx];
let cleaned = prepare_puzzle_clear_magenta_cleanup(
&sheet.image,
sheet.spec.sheet_id,
)
.map_err(|error| {
puzzle_clear_error_response(request_context, PUZZLE_CLEAR_CREATION_PROVIDER, error)
})?;
if let Some(debug_run) = image_debug_run.as_ref() {
debug_run.record_sheet_accepted(
&sheet.spec,
sheet.task_id.as_str(),
&cleaned,
);
}
slices.extend( slices.extend(
slice_puzzle_clear_sheet( slice_puzzle_clear_sheet(
&generated_sheet.image, &cleaned,
&generated_sheet.spec, &sheet.spec,
&groups_by_id, &groups_by_id,
generated_sheet.task_id.as_str(), sheet.task_id.as_str(),
) )
.map_err(|error| { .map_err(|error| {
puzzle_clear_error_response(request_context, PUZZLE_CLEAR_CREATION_PROVIDER, error) puzzle_clear_error_response(request_context, PUZZLE_CLEAR_CREATION_PROVIDER, error)
@@ -1432,114 +1448,119 @@ fn planned_puzzle_clear_pattern_groups() -> Vec<PuzzleClearPatternGroup> {
} }
fn puzzle_clear_atlas_sheet_specs() -> Vec<PuzzleClearAtlasSheetSpec> { fn puzzle_clear_atlas_sheet_specs() -> Vec<PuzzleClearAtlasSheetSpec> {
let buf = PUZZLE_CLEAR_SHEET_BUFFER_CELL;
vec![ vec![
// Sheet 1: 1×2 横向 12 组, 纯横条节奏 // Sheet 1: 1×2 横向 12 组, 满画布 24cells
PuzzleClearAtlasSheetSpec { PuzzleClearAtlasSheetSpec {
sheet_id: "sheet-01", sheet_id: "sheet-01",
layout: [ layout: [
["A01", "A01", "A03", "A03"], ["A01","A01", "A03","A03"],
["A05", "A05", "A07", "A07"], ["A05","A05", "A07","A07"],
["A09", "A09", "A11", "A11"], ["A09","A09", "A11","A11"],
["A13", "A13", "A15", "A15"], ["A13","A13", "A15","A15"],
["A17", "A17", "A19", "A19"], ["A17","A17", "A19","A19"],
["A21", "A21", "A23", "A23"], ["A21","A21", "A23","A23"],
], ],
layout_prompt: concat!( layout_prompt: concat!(
"整张图集排满 12 个横向场景裁片,每个占 2 格", "画面等分为 6 行 4 列,每格 256×256",
"每格 256×2564 列 6 行排布,场景间自然过渡", "共 12 个横向裁片,每行左右各一个,每裁片严格占 2 格1 行 2 列)",
"裁片内部画面连续无间断,不可拆成两格独立小图。",
"不同裁片之间用洋红细线分隔。画面四周留洋红边距。",
"绝对不要画网格线、边框、编号、文字或水印。", "绝对不要画网格线、边框、编号、文字或水印。",
), ),
}, },
// Sheet 2: 1×2 纵向 11+ 2 FILL, 纯竖条节奏 // Sheet 2: 1×2 纵向 12(取11), 满画布 24cells
PuzzleClearAtlasSheetSpec { PuzzleClearAtlasSheetSpec {
sheet_id: "sheet-02", sheet_id: "sheet-02",
layout: [ layout: [
["A02", "A04", "A06", "A08"], ["A02","A04", "A06","A08"],
["A02", "A04", "A06", "A08"], ["A02","A04", "A06","A08"],
["A10", "A12", "A14", "A16"], ["A10","A12", "A14","A16"],
["A10", "A12", "A14", "A16"], ["A10","A12", "A14","A16"],
["A18", "A20", "A22", PUZZLE_CLEAR_SHEET_FILLER_CELL], ["A18","A20", "A22",buf],
["A18", "A20", "A22", PUZZLE_CLEAR_SHEET_FILLER_CELL], ["A18","A20", "A22",buf],
], ],
layout_prompt: concat!( layout_prompt: concat!(
"整张图集排满 11 个纵向场景裁片,每个占 1×2 格", "画面等分为 6 行 4 列,每格 256×256各行高度严格相等",
"每格 256×2564 列 6 行排布,场景间自然过渡。右下两格为留白", "共 12 个纵向裁片,每行排 4 个,两两成组上下排列,每裁片严格占 1 列 2 行",
"裁片内部画面连续无间断,不可拆成上下两格独立小图。",
"不同裁片之间用洋红细线分隔。画面四周留洋红边距。",
"绝对不要画网格线、边框、编号、文字或水印。", "绝对不要画网格线、边框、编号、文字或水印。",
), ),
}, },
// Sheet 3: 2×2 正方形 4+ 8 FILL, 纯正方节奏 // Sheet 3: 2×2 正方形 6(取4), 满画布 24cells
PuzzleClearAtlasSheetSpec { PuzzleClearAtlasSheetSpec {
sheet_id: "sheet-03", sheet_id: "sheet-03",
layout: [ layout: [
["C01", "C01", "C02", "C02"], ["C01","C01", "C02","C02"],
["C01", "C01", "C02", "C02"], ["C01","C01", "C02","C02"],
["C03", "C03", "C04", "C04"], ["C03","C03", "C04","C04"],
["C03", "C03", "C04", "C04"], ["C03","C03", "C04","C04"],
[ [buf,buf, buf,buf],
PUZZLE_CLEAR_SHEET_FILLER_CELL, [buf,buf, buf,buf],
PUZZLE_CLEAR_SHEET_FILLER_CELL,
PUZZLE_CLEAR_SHEET_FILLER_CELL,
PUZZLE_CLEAR_SHEET_FILLER_CELL,
],
[
PUZZLE_CLEAR_SHEET_FILLER_CELL,
PUZZLE_CLEAR_SHEET_FILLER_CELL,
PUZZLE_CLEAR_SHEET_FILLER_CELL,
PUZZLE_CLEAR_SHEET_FILLER_CELL,
],
], ],
layout_prompt: concat!( layout_prompt: concat!(
"整张图集排满 4 个正方形场景裁片,每个占 2×2 格", "画面等分为 6 行 4 列,每格 256×256各行高度严格相等",
"每格 256×25646排布,下半部分为留白", "共 6 个正方形裁片,每行左右各一个,每裁片严格占 22 行。",
"裁片内部画面连续无间断,不可拆成四格独立小图。",
"不同裁片之间用洋红细线分隔。画面四周留洋红边距。",
"绝对不要画网格线、边框、编号、文字或水印。", "绝对不要画网格线、边框、编号、文字或水印。",
), ),
}, },
// Sheet 4: 2×3 横向 3 组 + 1×3 纵向 2 组, 大块面节奏 // Sheet 4: 2×3 横向 3 组, 满画布 + 右列缓冲
PuzzleClearAtlasSheetSpec { PuzzleClearAtlasSheetSpec {
sheet_id: "sheet-04", sheet_id: "sheet-04",
layout: [ layout: [
["D01", "D01", "D01", "B02"], ["D01","D01","D01",buf],
["D01", "D01", "D01", "B02"], ["D01","D01","D01",buf],
["D02", "D02", "D02", "B02"], ["D02","D02","D02",buf],
["D02", "D02", "D02", "B04"], ["D02","D02","D02",buf],
["D03", "D03", "D03", "B04"], ["D03","D03","D03",buf],
["D03", "D03", "D03", "B04"], ["D03","D03","D03",buf],
], ],
layout_prompt: concat!( layout_prompt: concat!(
"整张图集排满 5 个大场景裁片:左边 3 个各占 3×2 格,右边 2 个各占 1×3 格", "画面等分为 6 行 4 列,每格 256×256各行高度严格相等",
"每格 256×25646排布,场景间自然过渡", "共 3 个横向宽裁片,每两行一个,每裁片严格占 32(宽 3 格高 2 格)",
"裁片内部画面连续无间断,不可拆成独立格子。",
"不同裁片之间用洋红细线分隔。画面四周留洋红边距。",
"绝对不要画网格线、边框、编号、文字或水印。", "绝对不要画网格线、边框、编号、文字或水印。",
), ),
}, },
// Sheet 5: 1×3 横向 3+ 15 FILL, 纯宽条节奏 // Sheet 5: 1×3 横向 6(取3), 满画布 18cells + 右列缓冲
PuzzleClearAtlasSheetSpec { PuzzleClearAtlasSheetSpec {
sheet_id: "sheet-05", sheet_id: "sheet-05",
layout: [ layout: [
["B01", "B01", "B01", PUZZLE_CLEAR_SHEET_FILLER_CELL], ["B01","B01","B01",buf],
["B03", "B03", "B03", PUZZLE_CLEAR_SHEET_FILLER_CELL], ["B03","B03","B03",buf],
["B05", "B05", "B05", PUZZLE_CLEAR_SHEET_FILLER_CELL], ["B05","B05","B05",buf],
[ [buf,buf,buf,buf],
PUZZLE_CLEAR_SHEET_FILLER_CELL, [buf,buf,buf,buf],
PUZZLE_CLEAR_SHEET_FILLER_CELL, [buf,buf,buf,buf],
PUZZLE_CLEAR_SHEET_FILLER_CELL,
PUZZLE_CLEAR_SHEET_FILLER_CELL,
],
[
PUZZLE_CLEAR_SHEET_FILLER_CELL,
PUZZLE_CLEAR_SHEET_FILLER_CELL,
PUZZLE_CLEAR_SHEET_FILLER_CELL,
PUZZLE_CLEAR_SHEET_FILLER_CELL,
],
[
PUZZLE_CLEAR_SHEET_FILLER_CELL,
PUZZLE_CLEAR_SHEET_FILLER_CELL,
PUZZLE_CLEAR_SHEET_FILLER_CELL,
PUZZLE_CLEAR_SHEET_FILLER_CELL,
],
], ],
layout_prompt: concat!( layout_prompt: concat!(
"整张图集排满 3 个横向宽场景裁片,每个占 3 格", "画面等分为 6 行 4 列,每格 256×256各行高度严格相等",
"每格 256×25646排布,下半部分为留白", "共 6 个横向宽裁片,每行一个,每裁片严格占 31(宽 3 格高 1 格)",
"裁片内部画面连续无间断,不可拆成三格独立小图。",
"不同裁片之间用洋红细线分隔。画面四周留洋红边距。",
"绝对不要画网格线、边框、编号、文字或水印。",
),
},
// Sheet 6: 1×3 纵向 8 组 (取2), 满画布 24cells
PuzzleClearAtlasSheetSpec {
sheet_id: "sheet-06",
layout: [
["B02",buf,buf,buf],
["B02",buf,buf,buf],
["B02",buf,buf,buf],
["B04",buf,buf,buf],
["B04",buf,buf,buf],
["B04",buf,buf,buf],
],
layout_prompt: concat!(
"画面等分为 6 行 4 列,每格 256×256各列宽度严格相等。",
"共 8 个纵向裁片,每列排 2 组上下相邻,每裁片严格占 1 列 3 行(宽 1 格高 3 格)。",
"裁片内部画面连续无间断,不可拆成三格独立小图。",
"不同裁片之间用洋红细线分隔。画面四周留洋红边距。",
"绝对不要画网格线、边框、编号、文字或水印。", "绝对不要画网格线、边框、编号、文字或水印。",
), ),
}, },
@@ -1554,8 +1575,9 @@ fn build_puzzle_clear_atlas_prompt(
format!( format!(
concat!( concat!(
"生成一张拼消消卡牌图集,主题是「{subject}」,竖版 1024x1536。\n", "生成一张拼消消卡牌图集,主题是「{subject}」,竖版 1024x1536。\n",
"照片式构图、绘本式渲染。每格 256×256 是一个完整微场景裁片,", "照片式构图、绘本式渲染。画面由若干场景裁片组成",
"同编号连续格共享场景锚点和道具语言,不同编号展现不同视觉概念", "每个裁片是完整连续画面,内部无接缝、无分隔线、无网格",
"不同裁片之间用纯洋红(#FF00FF)细线分隔。",
"不要文字、Logo、水印、UI、网格线、边框、编号、纯色背景或孤立主体。\n", "不要文字、Logo、水印、UI、网格线、边框、编号、纯色背景或孤立主体。\n",
"{layout_prompt}" "{layout_prompt}"
), ),
@@ -1706,8 +1728,40 @@ fn is_retryable_puzzle_clear_sheet_generation_error(error: &AppError) -> bool {
.unwrap_or(false) .unwrap_or(false)
} }
fn prepare_puzzle_clear_magenta_cleanup(
image: &DownloadedOpenAiImage,
sheet_id: &str,
) -> Result<DownloadedOpenAiImage, AppError> {
let source = image::load_from_memory(image.bytes.as_slice()).map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": PUZZLE_CLEAR_CREATION_PROVIDER,
"message": format!("拼消消素材 {sheet_id} 洋红去背解码失败:{error}"),
}))
})?;
let processed = crate::generated_asset_sheets::apply_generated_asset_sheet_alpha_with_options(
source,
crate::generated_asset_sheets::GeneratedAssetSheetAlphaOptions::jump_hop_magenta_screen(),
);
let mut encoded = std::io::Cursor::new(Vec::new());
processed
.write_to(&mut encoded, image::ImageFormat::Png)
.map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": PUZZLE_CLEAR_CREATION_PROVIDER,
"message": format!("拼消消素材 {sheet_id} 洋红去背编码失败:{error}"),
}))
})?;
Ok(DownloadedOpenAiImage {
bytes: encoded.into_inner(),
mime_type: "image/png".to_string(),
extension: "png".to_string(),
})
}
fn is_puzzle_clear_sheet_discarded_cell(group_id: &str) -> bool { fn is_puzzle_clear_sheet_discarded_cell(group_id: &str) -> bool {
group_id == PUZZLE_CLEAR_SHEET_UNUSED_CELL || group_id == PUZZLE_CLEAR_SHEET_FILLER_CELL group_id == PUZZLE_CLEAR_SHEET_UNUSED_CELL
|| group_id == PUZZLE_CLEAR_SHEET_FILLER_CELL
|| group_id == PUZZLE_CLEAR_SHEET_BUFFER_CELL
} }
fn slice_puzzle_clear_sheet( fn slice_puzzle_clear_sheet(
@@ -1732,6 +1786,14 @@ fn slice_puzzle_clear_sheet(
})), })),
); );
} }
let source_rgba = source.to_rgba8();
let grid = crate::jump_hop_atlas_slicing::detect_cell_grid_seed(
source_rgba.as_raw(),
source_width,
source_height,
PUZZLE_CLEAR_SHEET_ROWS,
PUZZLE_CLEAR_SHEET_COLUMNS,
);
let mut slices = Vec::new(); let mut slices = Vec::new();
let mut cells_by_group: BTreeMap<&str, Vec<(u32, u32)>> = BTreeMap::new(); let mut cells_by_group: BTreeMap<&str, Vec<(u32, u32)>> = BTreeMap::new();
for (row, cells) in sheet_spec.layout.iter().enumerate() { for (row, cells) in sheet_spec.layout.iter().enumerate() {
@@ -1754,7 +1816,9 @@ fn slice_puzzle_clear_sheet(
})) }))
})?; })?;
let min_row = cells.iter().map(|(row, _)| *row).min().unwrap_or(0); let min_row = cells.iter().map(|(row, _)| *row).min().unwrap_or(0);
let max_row = cells.iter().map(|(row, _)| *row).max().unwrap_or(0);
let min_col = cells.iter().map(|(_, col)| *col).min().unwrap_or(0); let min_col = cells.iter().map(|(_, col)| *col).min().unwrap_or(0);
let max_col = cells.iter().map(|(_, col)| *col).max().unwrap_or(0);
let expected_cell_count = (group.width * group.height) as usize; let expected_cell_count = (group.width * group.height) as usize;
if cells.len() != expected_cell_count { if cells.len() != expected_cell_count {
return Err( return Err(
@@ -1762,65 +1826,65 @@ fn slice_puzzle_clear_sheet(
"provider": PUZZLE_CLEAR_CREATION_PROVIDER, "provider": PUZZLE_CLEAR_CREATION_PROVIDER,
"message": format!( "message": format!(
"拼消消素材 {} 的布局 {} 格数不匹配,期望 {} 格,实际 {} 格。", "拼消消素材 {} 的布局 {} 格数不匹配,期望 {} 格,实际 {} 格。",
sheet_spec.sheet_id, sheet_spec.sheet_id, group_id, expected_cell_count, cells.len(),
group_id,
expected_cell_count,
cells.len(),
), ),
})), })),
); );
} }
// 中文注释阶段1 — 自适应网格找到 group 大致区域,四方向向内扫描剔除透明边距
let gx0 = grid.col_boundaries[min_col as usize];
let gx1 = grid.col_boundaries[max_col as usize + 1];
let gy0 = grid.row_boundaries[min_row as usize];
let gy1 = grid.row_boundaries[max_row as usize + 1];
let (cx0, cy0, cx1, cy1) =
find_non_transparent_bounds(&source_rgba, gx0, gy0, gx1, gy1);
let content_w = cx1.saturating_sub(cx0).max(1);
let content_h = cy1.saturating_sub(cy0).max(1);
// 中文注释阶段2 — 裁出 group 内容,缩放到精确尺寸,等分为 1×1 格
let target_w = group.width * PUZZLE_CLEAR_ATLAS_CELL_SIZE;
let target_h = group.height * PUZZLE_CLEAR_ATLAS_CELL_SIZE;
let mut group_cropped = image::imageops::crop_imm(&source_rgba, cx0, cy0, content_w, content_h)
.to_image();
fill_transparent_with_opaque_average(&mut group_cropped);
let group_resized = image::imageops::resize(
&group_cropped,
target_w,
target_h,
image::imageops::FilterType::Lanczos3,
);
for part_y in 0..group.height { for part_y in 0..group.height {
for part_x in 0..group.width { for part_x in 0..group.width {
let expected_cell = (min_row + part_y, min_col + part_x); let px = part_x * PUZZLE_CLEAR_ATLAS_CELL_SIZE;
if !cells.contains(&expected_cell) { let py = part_y * PUZZLE_CLEAR_ATLAS_CELL_SIZE;
return Err(AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) let cell = image::imageops::crop_imm(
.with_details(json!({ &group_resized,
px,
py,
PUZZLE_CLEAR_ATLAS_CELL_SIZE,
PUZZLE_CLEAR_ATLAS_CELL_SIZE,
)
.to_image();
let mut cursor = std::io::Cursor::new(Vec::new());
image::DynamicImage::ImageRgba8(cell)
.write_to(&mut cursor, image::ImageFormat::Png)
.map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": PUZZLE_CLEAR_CREATION_PROVIDER, "provider": PUZZLE_CLEAR_CREATION_PROVIDER,
"message": format!( "message": format!("拼消消素材 {} 切片失败:{error}", sheet_spec.sheet_id),
"拼消消素材 {} 的布局 {} 不是完整连续矩形,缺少第 {} 行第 {} 列。", }))
sheet_spec.sheet_id, })?;
group_id, slices.push(PuzzleClearAtlasCardSlice {
expected_cell.0 + 1, group: group.clone(),
expected_cell.1 + 1, task_id: Some(task_id.to_string()),
), part_x,
}))); part_y,
} bytes: cursor.into_inner(),
});
} }
} }
for (row, col) in cells {
let part_x = col.saturating_sub(min_col);
let part_y = row.saturating_sub(min_row);
let x0 = scale_sheet_coord(col, source_width, PUZZLE_CLEAR_SHEET_COLUMNS);
let y0 = scale_sheet_coord(row, source_height, PUZZLE_CLEAR_SHEET_ROWS);
let x1 = scale_sheet_coord(col + 1, source_width, PUZZLE_CLEAR_SHEET_COLUMNS)
.max(x0 + 1)
.min(source_width);
let y1 = scale_sheet_coord(row + 1, source_height, PUZZLE_CLEAR_SHEET_ROWS)
.max(y0 + 1)
.min(source_height);
let cropped = source.crop_imm(x0, y0, x1 - x0, y1 - y0).resize_exact(
PUZZLE_CLEAR_ATLAS_CELL_SIZE,
PUZZLE_CLEAR_ATLAS_CELL_SIZE,
image::imageops::FilterType::Lanczos3,
);
let mut cursor = std::io::Cursor::new(Vec::new());
cropped
.write_to(&mut cursor, image::ImageFormat::Png)
.map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": PUZZLE_CLEAR_CREATION_PROVIDER,
"message": format!("拼消消素材 {} 切片失败:{error}", sheet_spec.sheet_id),
}))
})?;
slices.push(PuzzleClearAtlasCardSlice {
group: group.clone(),
task_id: Some(task_id.to_string()),
part_x,
part_y,
bytes: cursor.into_inner(),
});
}
} }
Ok(slices) Ok(slices)
} }
@@ -1870,8 +1934,101 @@ fn compose_puzzle_clear_final_atlas(
}) })
} }
fn scale_sheet_coord(value: u32, actual: u32, sheet_cells: u32) -> u32 { fn find_non_transparent_bounds(
((u64::from(value) * u64::from(actual)) / u64::from(sheet_cells)) as u32 source: &image::RgbaImage,
x0: u32,
y0: u32,
x1: u32,
y1: u32,
) -> (u32, u32, u32, u32) {
let w = source.width();
let pixels = source.as_raw();
let stride = w as usize * 4;
let x1 = x1.min(w);
let y1 = y1.min(source.height());
let mut left = x0;
for x in x0..x1 {
let mut has = false;
for y in y0..y1 {
if pixels[y as usize * stride + x as usize * 4 + 3] > 0 {
has = true;
break;
}
}
if has { left = x; break; }
left = x + 1;
}
let mut right = x1;
for x in (x0..x1).rev() {
let mut has = false;
for y in y0..y1 {
if pixels[y as usize * stride + x as usize * 4 + 3] > 0 {
has = true;
break;
}
}
if has { right = x + 1; break; }
right = x;
}
let mut top = y0;
for y in y0..y1 {
let mut has = false;
for x in left..right {
if pixels[y as usize * stride + x as usize * 4 + 3] > 0 {
has = true;
break;
}
}
if has { top = y; break; }
top = y + 1;
}
let mut bottom = y1;
for y in (y0..y1).rev() {
let mut has = false;
for x in left..right {
if pixels[y as usize * stride + x as usize * 4 + 3] > 0 {
has = true;
break;
}
}
if has { bottom = y + 1; break; }
bottom = y;
}
(left, top, right, bottom)
}
fn fill_transparent_with_opaque_average(image: &mut image::RgbaImage) {
let mut total_r = 0u64;
let mut total_g = 0u64;
let mut total_b = 0u64;
let mut opaque_count = 0u64;
for pixel in image.pixels() {
if pixel.0[3] > 0 {
total_r += pixel.0[0] as u64;
total_g += pixel.0[1] as u64;
total_b += pixel.0[2] as u64;
opaque_count += 1;
}
}
if opaque_count == 0 {
return;
}
let avg = image::Rgba([
(total_r / opaque_count) as u8,
(total_g / opaque_count) as u8,
(total_b / opaque_count) as u8,
255,
]);
for pixel in image.pixels_mut() {
if pixel.0[3] == 0 {
*pixel = avg;
}
}
} }
fn normalize_non_empty_str(value: &str) -> Option<String> { fn normalize_non_empty_str(value: &str) -> Option<String> {
@@ -2289,15 +2446,15 @@ mod tests {
let prompt = build_puzzle_clear_atlas_prompt("水果", &sheet); let prompt = build_puzzle_clear_atlas_prompt("水果", &sheet);
assert!(prompt.contains("主题是「水果」")); assert!(prompt.contains("主题是「水果」"));
assert!(prompt.contains("竖版 1024x1536")); assert!(prompt.contains("竖版 1024x1536"));
assert!(prompt.contains("每格 256×256"));
assert!(prompt.contains("照片式构图")); assert!(prompt.contains("照片式构图"));
assert!(prompt.contains("共享场景锚点和道具语言")); assert!(prompt.contains("完整连续画面"));
assert!(prompt.contains("不同编号展现不同视觉概念")); assert!(prompt.contains("内部无接缝"));
assert!(prompt.contains("纯洋红"));
assert!(prompt.contains("不要文字")); assert!(prompt.contains("不要文字"));
assert!(prompt.contains("网格线")); assert!(prompt.contains("网格线"));
assert!(prompt.contains("纯色背景")); assert!(prompt.contains("纯色背景"));
assert!(prompt.contains("孤立主体")); assert!(prompt.contains("孤立主体"));
assert!(prompt.contains("12 个横向场景裁片")); assert!(prompt.contains("12 个横向"));
assert!(!prompt.contains("卡牌排版图")); assert!(!prompt.contains("卡牌排版图"));
assert!(!prompt.contains("贴纸表")); assert!(!prompt.contains("贴纸表"));
assert!(PUZZLE_CLEAR_ATLAS_NEGATIVE_PROMPT.contains("纯色背景")); assert!(PUZZLE_CLEAR_ATLAS_NEGATIVE_PROMPT.contains("纯色背景"));
@@ -2341,11 +2498,11 @@ mod tests {
.map(|group| group.width * group.height) .map(|group| group.width * group.height)
.sum::<u32>(); .sum::<u32>();
assert_eq!(sheets.len(), 5); assert_eq!(sheets.len(), 6);
assert_eq!(groups.len(), 35); assert_eq!(groups.len(), 35);
assert_eq!(occupied_sheet_cells, 120); assert_eq!(occupied_sheet_cells, 144);
assert_eq!(playable_sheet_cells, 95); assert_eq!(playable_sheet_cells, 95);
assert_eq!(filler_sheet_cells, 25); assert_eq!(filler_sheet_cells, 0);
assert_eq!(group_cells, 95); assert_eq!(group_cells, 95);
assert_eq!(PUZZLE_CLEAR_ATLAS_CELL_SIZE, 256); assert_eq!(PUZZLE_CLEAR_ATLAS_CELL_SIZE, 256);
assert_eq!(sheet_cells_by_group.len(), groups.len()); assert_eq!(sheet_cells_by_group.len(), groups.len());
@@ -2559,8 +2716,7 @@ mod tests {
rt.block_on(async { rt.block_on(async {
use super::{ use super::{
compose_puzzle_clear_final_atlas, slice_puzzle_clear_sheet, compose_puzzle_clear_final_atlas, slice_puzzle_clear_sheet,
build_puzzle_clear_atlas_prompt, PUZZLE_CLEAR_ATLAS_CELL_SIZE, build_puzzle_clear_atlas_prompt, prepare_puzzle_clear_magenta_cleanup,
PUZZLE_CLEAR_SHEET_COLUMNS, PUZZLE_CLEAR_SHEET_ROWS,
PUZZLE_CLEAR_ATLAS_NEGATIVE_PROMPT, PUZZLE_CLEAR_ATLAS_GENERATION_SIZE, PUZZLE_CLEAR_ATLAS_NEGATIVE_PROMPT, PUZZLE_CLEAR_ATLAS_GENERATION_SIZE,
}; };
use crate::openai_image_generation::DownloadedOpenAiImage; use crate::openai_image_generation::DownloadedOpenAiImage;
@@ -2584,7 +2740,12 @@ mod tests {
let http_client = build_vector_engine_image_http_client(&settings) let http_client = build_vector_engine_image_http_client(&settings)
.expect("build http client"); .expect("build http client");
let out = std::env::temp_dir().join("puzzle-clear-real-output"); let out = std::env::current_dir()
.expect("current dir")
.join("target")
.join("test-output")
.join("puzzle-clear-real");
let _ = fs::remove_dir_all(&out);
fs::create_dir_all(&out).expect("create output dir"); fs::create_dir_all(&out).expect("create output dir");
let sheets_out = out.join("sheets"); let sheets_out = out.join("sheets");
let cards_out = out.join("cards"); let cards_out = out.join("cards");
@@ -2643,8 +2804,14 @@ mod tests {
fs::write(sheets_out.join(format!("{}.png", sheet_id)), &image.bytes).unwrap(); fs::write(sheets_out.join(format!("{}.png", sheet_id)), &image.bytes).unwrap();
let cleaned = prepare_puzzle_clear_magenta_cleanup(
&downloaded, sheet.sheet_id,
)
.unwrap_or_else(|e| panic!("magenta cleanup {} failed: {:?}", sheet_id, e.body_text()));
fs::write(sheets_out.join(format!("{}-cleaned.png", sheet_id)), &cleaned.bytes).unwrap();
let slices = slice_puzzle_clear_sheet( let slices = slice_puzzle_clear_sheet(
&downloaded, sheet, &groups_by_id, &task_id, &cleaned, sheet, &groups_by_id, &task_id,
) )
.unwrap_or_else(|e| panic!("slice {} failed: {:?}", sheet_id, e.body_text())); .unwrap_or_else(|e| panic!("slice {} failed: {:?}", sheet_id, e.body_text()));

View File

@@ -0,0 +1,204 @@
"""
分析拼消消测试输出,检测卡片透明区域并溯源问题成因。
用法:
python tools/analyze_puzzle_clear_output.py
python tools/analyze_puzzle_clear_output.py --dir path/to/output
python tools/analyze_puzzle_clear_output.py --detail # 详细逐卡输出
"""
import os
import sys
import argparse
from collections import defaultdict
from PIL import Image
def scan_transparent_pixels(img_path):
"""扫描图片,返回 (总像素数, 透明像素数, 边缘透明列比例)"""
img = Image.open(img_path).convert("RGBA")
w, h = img.size
pixels = img.load()
total = w * h
transparent = 0
edge_cols_with_transparent = 0
edge_rows_with_transparent = 0
# 统计透明像素
for y in range(h):
for x in range(w):
if pixels[x, y][3] < 128:
transparent += 1
# 检测四边是否有透明像素(边缘列/行透明占比 > 10%
for x in range(w):
col_transparent = sum(1 for y in range(h) if pixels[x, y][3] < 128)
if col_transparent > h * 0.1:
edge_cols_with_transparent += 1
for y in range(h):
row_transparent = sum(1 for x in range(w) if pixels[x, y][3] < 128)
if row_transparent > w * 0.1:
edge_rows_with_transparent += 1
ratio = transparent / total * 100 if total > 0 else 0
has_edge = edge_cols_with_transparent > 0 or edge_rows_with_transparent > 0
return total, transparent, ratio, has_edge, edge_cols_with_transparent, edge_rows_with_transparent
def analyze_sheet_cleaned(sheet_path):
"""分析去背后的 sheet 图,检查各 group 区域的透明情况"""
img = Image.open(sheet_path).convert("RGBA")
w, h = img.size
pixels = img.load()
# 统计整体透明像素
total = w * h
transparent = sum(1 for y in range(h) for x in range(w) if pixels[x, y][3] < 128)
return total, transparent, transparent / total * 100 if total > 0 else 0
def main():
parser = argparse.ArgumentParser(description="分析拼消消测试输出")
parser.add_argument("--dir", default="", help="输出目录路径")
parser.add_argument("--detail", action="store_true", help="详细逐卡输出")
args = parser.parse_args()
# 自动查找输出目录
if args.dir:
base = args.dir
else:
script_dir = os.path.dirname(os.path.abspath(__file__))
repo_root = os.path.dirname(script_dir)
candidates = [
os.path.join(repo_root, "server-rs", "crates", "api-server", "target", "test-output", "puzzle-clear-real"),
os.path.join(repo_root, "server-rs", "target", "test-output", "puzzle-clear-real"),
]
base = None
for c in candidates:
if os.path.isdir(c):
base = c
break
if not base:
print("未找到测试输出目录。请用 --dir 指定路径。")
sys.exit(1)
sheets_dir = os.path.join(base, "sheets")
cards_dir = os.path.join(base, "cards")
if not os.path.isdir(cards_dir):
print(f"cards 目录不存在: {cards_dir}")
sys.exit(1)
# ==================== 阶段 1: 分析卡片 ====================
print("=" * 70)
print("阶段 1: 卡片透明像素分析")
print("=" * 70)
card_results = [] # (sheet, card_name, total, transparent, ratio, has_edge, edge_cols, edge_rows)
problem_cards = []
for sheet_name in sorted(os.listdir(cards_dir)):
sheet_dir = os.path.join(cards_dir, sheet_name)
if not os.path.isdir(sheet_dir):
continue
for card_name in sorted(os.listdir(sheet_dir)):
if not card_name.endswith(".png"):
continue
card_path = os.path.join(sheet_dir, card_name)
total, trans, ratio, has_edge, ec, er = scan_transparent_pixels(card_path)
card_results.append((sheet_name, card_name, total, trans, ratio, has_edge, ec, er))
if ratio > 5 or has_edge:
problem_cards.append((sheet_name, card_name, total, trans, ratio, has_edge, ec, er))
# 按 sheet 汇总
by_sheet = defaultdict(list)
for r in card_results:
by_sheet[r[0]].append(r)
print(f"\n总卡片数: {len(card_results)}")
print(f"问题卡片数 (透明>5% 或 有边缘透明): {len(problem_cards)}")
print()
for sheet_name in sorted(by_sheet.keys()):
cards = by_sheet[sheet_name]
problem_count = sum(1 for r in cards if r[4] > 5 or r[5])
print(f" {sheet_name}: {len(cards)} cards, {problem_count} problems")
if problem_cards:
print(f"\n--- 问题卡片详情 ---")
problem_cards.sort(key=lambda r: -r[4]) # sort by ratio desc
for sheet, name, total, trans, ratio, has_edge, ec, er in problem_cards:
group_id = name.split("-part-")[0]
edge_info = f", 边缘透明列={ec} 行={er}" if has_edge else ""
print(f" {sheet}/{name} group={group_id} transparent={ratio:.1f}% ({trans}/{total}){edge_info}")
# ==================== 阶段 2: 溯源分析 ====================
print()
print("=" * 70)
print("阶段 2: 溯源 — 对比原始 sheet 与去背后 sheet")
print("=" * 70)
if os.path.isdir(sheets_dir):
for fname in sorted(os.listdir(sheets_dir)):
if not fname.endswith(".png"):
continue
sheet_path = os.path.join(sheets_dir, fname)
total, trans, ratio = analyze_sheet_cleaned(sheet_path)
is_cleaned = "-cleaned" in fname
label = "去背后" if is_cleaned else "原始"
print(f" {fname} ({label}): {trans}/{total} 透明像素 ({ratio:.1f}%)")
# ==================== 阶段 3: 问题溯源推理 ====================
print()
print("=" * 70)
print("阶段 3: 问题成因分析")
print("=" * 70)
if not problem_cards:
print(" 无问题卡片,管线正常。")
return
# 分析问题卡片的 group 分布
problem_groups = defaultdict(list)
for r in problem_cards:
group_id = r[1].split("-part-")[0]
problem_groups[group_id].append(r)
print(f"\n 涉及 {len(problem_groups)} 个 group:")
for group_id in sorted(problem_groups.keys()):
cards = problem_groups[group_id]
avg_ratio = sum(r[4] for r in cards) / len(cards)
edge_count = sum(1 for r in cards if r[5])
print(f" {group_id}: {len(cards)} cells, avg透明={avg_ratio:.1f}%, {edge_count} cells有边缘透明")
# 检查原始 sheet 和去背后 sheet 的差异
if os.path.isdir(sheets_dir):
cleaned_files = [f for f in os.listdir(sheets_dir) if "cleaned" in f]
raw_files = [f for f in os.listdir(sheets_dir) if "cleaned" not in f and f.endswith(".png")]
if cleaned_files and raw_files:
for raw_f in sorted(raw_files):
raw_p = os.path.join(sheets_dir, raw_f)
cleaned_f = raw_f.replace(".png", "-cleaned.png")
cleaned_p = os.path.join(sheets_dir, cleaned_f)
if not os.path.exists(cleaned_p):
continue
_, raw_trans, raw_ratio = analyze_sheet_cleaned(raw_p)
_, cleaned_trans, cleaned_ratio = analyze_sheet_cleaned(cleaned_p)
print(f"\n {raw_f}:")
print(f" 原始透明: {raw_ratio:.1f}%")
print(f" 去后透明: {cleaned_ratio:.1f}%")
delta = cleaned_ratio - raw_ratio
if delta > 1:
print(f" ** 去背增加了 {delta:.1f}% 透明像素 — 可能误删了主体内容")
print()
print(" 可能原因:")
print(" 1. AI 未将内容画满整个 group 区域(内容在组内偏移)")
print(" 2. 洋红去背误将主题内近似洋红的像素也变透明")
print(" 3. find_non_transparent_bounds 扫描范围包括了相邻 group 的透明间隙")
print(" 4. group resize 后未完全覆盖目标尺寸(内容比例与目标比例不匹配)")
if __name__ == "__main__":
main()

171
tools/test_ve_api.py Normal file
View File

@@ -0,0 +1,171 @@
"""
Vector Engine API 连通性与生图耗时测试脚本。
用法:
python tools/test_ve_api.py
python tools/test_ve_api.py --prompt "你的自定义提示词"
python tools/test_ve_api.py --size 1024x1024 --samples 3
前置条件:
环境变量 VECTOR_ENGINE_API_KEY 已设置,
或从 ../.env.secrets.local 自动读取。
"""
import os
import sys
import time
import json
import argparse
import requests
from concurrent.futures import ThreadPoolExecutor, as_completed
API_URL = "https://api.vectorengine.cn/v1/images/generations"
DEFAULT_PROMPT = "生成一张白色背景上的一只飞踢橘猫,绘本风格,不要文字水印"
DEFAULT_SIZE = "1024x1536"
DEFAULT_NEGATIVE = "文字、Logo、水印、按钮、UI、网格线、边框、编号、标签、纯色背景、白底、孤立主体"
def load_env_from_file(filepath):
"""从 .env 文件中加载环境变量(简单实现)"""
if not os.path.exists(filepath):
return
with open(filepath, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line or line.startswith("#"):
continue
if "=" in line:
key, _, value = line.partition("=")
key = key.strip()
value = value.strip().strip('"').strip("'")
if key and value and key not in os.environ:
os.environ[key] = value
def single_request(api_key, base_url, prompt, negative, size, quality, index):
"""单次生图请求,返回 (耗时秒, task_id, 图片字节数)"""
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
}
body = {
"model": "gpt-image-2",
"prompt": prompt,
"n": 1,
"size": size,
}
if negative:
body["negative_prompt"] = negative
if quality:
body["quality"] = quality
start = time.time()
try:
resp = requests.post(
base_url.rstrip("/") + "/v1/images/generations",
headers=headers,
json=body,
timeout=600,
)
elapsed = time.time() - start
if resp.status_code != 200:
print(f" [#{index}] HTTP {resp.status_code}: {resp.text[:300]}")
return elapsed, None, 0
data = resp.json()
task_id = data.get("task_id", "")
images = data.get("data", [])
b64_len = len(images[0].get("b64_json", "")) if images else 0
url = images[0].get("url", "") if images else ""
print(f" [#{index}] {elapsed:.1f}s task_id={task_id} b64={b64_len}chars url={'present' if url else 'none'}")
return elapsed, task_id, b64_len
except requests.Timeout:
elapsed = time.time() - start
print(f" [#{index}] TIMEOUT after {elapsed:.0f}s")
return elapsed, None, 0
except Exception as e:
elapsed = time.time() - start
print(f" [#{index}] ERROR: {e}")
return elapsed, None, 0
def main():
parser = argparse.ArgumentParser(description="Vector Engine API 测试")
parser.add_argument("--prompt", default=DEFAULT_PROMPT, help="生图提示词")
parser.add_argument("--negative", default=DEFAULT_NEGATIVE, help="负面提示词")
parser.add_argument("--size", default=DEFAULT_SIZE, help="图片尺寸 (1024x1024 / 1024x1536 / 1536x1024)")
parser.add_argument("--samples", type=int, default=1, help="请求次数")
parser.add_argument("--parallel", type=int, default=1, help="并行请求数 (默认1=串行)")
parser.add_argument("--quality", default="", help="生图质量 (low/medium/high)")
parser.add_argument("--base-url", default=API_URL, help="API 地址")
args = parser.parse_args()
# 自动加载 secrets
script_dir = os.path.dirname(os.path.abspath(__file__))
repo_root = os.path.dirname(script_dir)
for fname in [".env.secrets.local", ".env.local", ".env"]:
load_env_from_file(os.path.join(repo_root, fname))
api_key = os.environ.get("VECTOR_ENGINE_API_KEY", "")
if not api_key:
print("错误: 未设置 VECTOR_ENGINE_API_KEY")
print("请设置环境变量或将密钥写入 .env.secrets.local")
sys.exit(1)
base_url = os.environ.get("VECTOR_ENGINE_BASE_URL", args.base_url)
print(f"API: {base_url}")
print(f"Size: {args.size}")
print(f"Samples: {args.samples}")
print(f"Parallel: {args.parallel}")
if args.quality:
print(f"Quality: {args.quality}")
print(f"Prompt ({len(args.prompt)} chars):")
print(f" {args.prompt[:120]}...")
print(f"Negative ({len(args.negative)} chars):")
print(f" {args.negative[:120]}...")
print()
parallel = args.parallel
total_start = time.time()
if parallel <= 1:
times = []
for i in range(1, args.samples + 1):
elapsed, task_id, b64_len = single_request(
api_key, base_url, args.prompt, args.negative, args.size, args.quality, i
)
if b64_len > 0:
times.append(elapsed)
else:
times = []
with ThreadPoolExecutor(max_workers=parallel) as pool:
futures = {
pool.submit(
single_request,
api_key, base_url, args.prompt, args.negative, args.size, args.quality, idx
): idx
for idx in range(1, args.samples + 1)
}
for future in as_completed(futures):
elapsed, task_id, b64_len = future.result()
if b64_len > 0:
times.append(elapsed)
total_elapsed = time.time() - total_start
if times:
avg = sum(times) / len(times)
print(f"\n成功: {len(times)}/{args.samples}")
print(f"总耗时: {total_elapsed:.1f}s")
print(f"平均: {avg:.1f}s")
print(f"最快: {min(times):.1f}s")
print(f"最慢: {max(times):.1f}s")
else:
print(f"\n全部失败 ({args.samples} 次)" + f" | 总耗时: {total_elapsed:.1f}s")
if __name__ == "__main__":
main()