拼消消生图管线升级: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:
@@ -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("组合成一个完整的立方体造型"));
|
||||||
|
|||||||
@@ -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,8 +1448,9 @@ 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: [
|
||||||
@@ -1445,12 +1462,14 @@ fn puzzle_clear_atlas_sheet_specs() -> Vec<PuzzleClearAtlasSheetSpec> {
|
|||||||
["A21","A21", "A23","A23"],
|
["A21","A21", "A23","A23"],
|
||||||
],
|
],
|
||||||
layout_prompt: concat!(
|
layout_prompt: concat!(
|
||||||
"整张图集排满 12 个横向场景裁片,每个占 2 格。",
|
"画面等分为 6 行 4 列,每格 256×256。",
|
||||||
"每格 256×256,4 列 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: [
|
||||||
@@ -1458,16 +1477,18 @@ fn puzzle_clear_atlas_sheet_specs() -> Vec<PuzzleClearAtlasSheetSpec> {
|
|||||||
["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×256,4 列 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: [
|
||||||
@@ -1475,71 +1496,71 @@ fn puzzle_clear_atlas_sheet_specs() -> Vec<PuzzleClearAtlasSheetSpec> {
|
|||||||
["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×256,4 列 6 行排布,下半部分为留白。",
|
"共 6 个正方形裁片,每行左右各一个,每裁片严格占 2 列 2 行。",
|
||||||
|
"裁片内部画面连续无间断,不可拆成四格独立小图。",
|
||||||
|
"不同裁片之间用洋红细线分隔。画面四周留洋红边距。",
|
||||||
"绝对不要画网格线、边框、编号、文字或水印。",
|
"绝对不要画网格线、边框、编号、文字或水印。",
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
// 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×256,4 列 6 行排布,场景间自然过渡。",
|
"共 3 个横向宽裁片,每两行一个,每裁片严格占 3 列 2 行(宽 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×256,4 列 6 行排布,下半部分为留白。",
|
"共 6 个横向宽裁片,每行一个,每裁片严格占 3 列 1 行(宽 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,50 +1826,49 @@ 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(),
|
|
||||||
),
|
),
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
for part_y in 0..group.height {
|
|
||||||
for part_x in 0..group.width {
|
// 中文注释:阶段1 — 自适应网格找到 group 大致区域,四方向向内扫描剔除透明边距
|
||||||
let expected_cell = (min_row + part_y, min_col + part_x);
|
let gx0 = grid.col_boundaries[min_col as usize];
|
||||||
if !cells.contains(&expected_cell) {
|
let gx1 = grid.col_boundaries[max_col as usize + 1];
|
||||||
return Err(AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
|
let gy0 = grid.row_boundaries[min_row as usize];
|
||||||
.with_details(json!({
|
let gy1 = grid.row_boundaries[max_row as usize + 1];
|
||||||
"provider": PUZZLE_CLEAR_CREATION_PROVIDER,
|
let (cx0, cy0, cx1, cy1) =
|
||||||
"message": format!(
|
find_non_transparent_bounds(&source_rgba, gx0, gy0, gx1, gy1);
|
||||||
"拼消消素材 {} 的布局 {} 不是完整连续矩形,缺少第 {} 行第 {} 列。",
|
let content_w = cx1.saturating_sub(cx0).max(1);
|
||||||
sheet_spec.sheet_id,
|
let content_h = cy1.saturating_sub(cy0).max(1);
|
||||||
group_id,
|
|
||||||
expected_cell.0 + 1,
|
// 中文注释:阶段2 — 裁出 group 内容,缩放到精确尺寸,等分为 1×1 格
|
||||||
expected_cell.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(
|
||||||
for (row, col) in cells {
|
&group_cropped,
|
||||||
let part_x = col.saturating_sub(min_col);
|
target_w,
|
||||||
let part_y = row.saturating_sub(min_row);
|
target_h,
|
||||||
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,
|
image::imageops::FilterType::Lanczos3,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
for part_y in 0..group.height {
|
||||||
|
for part_x in 0..group.width {
|
||||||
|
let px = part_x * PUZZLE_CLEAR_ATLAS_CELL_SIZE;
|
||||||
|
let py = part_y * PUZZLE_CLEAR_ATLAS_CELL_SIZE;
|
||||||
|
let cell = image::imageops::crop_imm(
|
||||||
|
&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());
|
let mut cursor = std::io::Cursor::new(Vec::new());
|
||||||
cropped
|
image::DynamicImage::ImageRgba8(cell)
|
||||||
.write_to(&mut cursor, image::ImageFormat::Png)
|
.write_to(&mut cursor, image::ImageFormat::Png)
|
||||||
.map_err(|error| {
|
.map_err(|error| {
|
||||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||||
@@ -1822,6 +1885,7 @@ fn slice_puzzle_clear_sheet(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
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()));
|
||||||
|
|
||||||
|
|||||||
204
tools/analyze_puzzle_clear_output.py
Normal file
204
tools/analyze_puzzle_clear_output.py
Normal 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
171
tools/test_ve_api.py
Normal 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()
|
||||||
Reference in New Issue
Block a user