From 5795115c207943528eeb25e6694058294ffca9a2 Mon Sep 17 00:00:00 2001 From: Linghong Date: Fri, 12 Jun 2026 22:08:57 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8B=BC=E6=B6=88=E6=B6=88=E7=94=9F=E5=9B=BE?= =?UTF-8?q?=E7=AE=A1=E7=BA=BF=E5=8D=87=E7=BA=A7=EF=BC=9A6=20sheet=20?= =?UTF-8?q?=E5=8D=95=E5=BD=A2=E7=8A=B6=E6=BB=A1=E7=94=BB=E5=B8=83=20+=20?= =?UTF-8?q?=E6=B4=8B=E7=BA=A2=E5=8E=BB=E8=83=8C=20+=20=E8=87=AA=E9=80=82?= =?UTF-8?q?=E5=BA=94=E5=88=87=E5=9B=BE=20+=20=E6=8F=90=E7=A4=BA=E8=AF=8D?= =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 改为 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 缓冲区 --- server-rs/crates/api-server/src/jump_hop.rs | 2 +- .../crates/api-server/src/puzzle_clear.rs | 449 ++++++++++++------ tools/analyze_puzzle_clear_output.py | 204 ++++++++ tools/test_ve_api.py | 171 +++++++ 4 files changed, 684 insertions(+), 142 deletions(-) create mode 100644 tools/analyze_puzzle_clear_output.py create mode 100644 tools/test_ve_api.py diff --git a/server-rs/crates/api-server/src/jump_hop.rs b/server-rs/crates/api-server/src/jump_hop.rs index 3bc37128..9b1f7064 100644 --- a/server-rs/crates/api-server/src/jump_hop.rs +++ b/server-rs/crates/api-server/src/jump_hop.rs @@ -1918,7 +1918,7 @@ mod tests { assert!(prompt.contains("第3行第2列:bottom")); assert!(prompt.contains("其余 6 个格子")); // 贴图内容 - assert!(prompt.contains("1x1x1 立方体物体的六面展开")); + assert!(prompt.contains("1×1×1 的立方体")); assert!(prompt.contains("主题为\"森林冒险\"")); assert!(prompt.contains("6 个面必须属于同一个物体")); assert!(prompt.contains("组合成一个完整的立方体造型")); diff --git a/server-rs/crates/api-server/src/puzzle_clear.rs b/server-rs/crates/api-server/src/puzzle_clear.rs index 589686e6..e4be25cc 100644 --- a/server-rs/crates/api-server/src/puzzle_clear.rs +++ b/server-rs/crates/api-server/src/puzzle_clear.rs @@ -62,11 +62,12 @@ const PUZZLE_CLEAR_FINAL_ATLAS_COLUMNS: u32 = 10; const PUZZLE_CLEAR_FINAL_ATLAS_ROWS: u32 = 10; const PUZZLE_CLEAR_SHEET_UNUSED_CELL: &str = "."; 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_BOARD_BACKGROUND_GENERATION_SIZE: &str = "1024x1024"; 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_ATLAS_NEGATIVE_PROMPT: &str = "文字、Logo、水印、按钮、UI、网格线、边框、编号、标签、纯色背景、白底、孤立主体、多场景拼贴"; +const PUZZLE_CLEAR_ATLAS_NEGATIVE_PROMPT: &str = "单元格紧贴、单元格粘连、无间距、贴边、跨格、越界、侵占洋红间距、文字、Logo、水印、按钮、UI、网格线、边框、编号、标签、纯色背景、白底、孤立主体、多场景拼贴、洋红阴影、紫色底边、粉色脏边、洋红色描边"; pub async fn create_puzzle_clear_session( State(state): State, @@ -1303,13 +1304,28 @@ async fn maybe_prepare_puzzle_clear_assets_inner( } 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( slice_puzzle_clear_sheet( - &generated_sheet.image, - &generated_sheet.spec, + &cleaned, + &sheet.spec, &groups_by_id, - generated_sheet.task_id.as_str(), + sheet.task_id.as_str(), ) .map_err(|error| { puzzle_clear_error_response(request_context, PUZZLE_CLEAR_CREATION_PROVIDER, error) @@ -1432,114 +1448,119 @@ fn planned_puzzle_clear_pattern_groups() -> Vec { } fn puzzle_clear_atlas_sheet_specs() -> Vec { + let buf = PUZZLE_CLEAR_SHEET_BUFFER_CELL; vec![ - // Sheet 1: 1×2 横向 12 组, 纯横条节奏 + // Sheet 1: 1×2 横向 12 组, 满画布 24cells PuzzleClearAtlasSheetSpec { sheet_id: "sheet-01", layout: [ - ["A01", "A01", "A03", "A03"], - ["A05", "A05", "A07", "A07"], - ["A09", "A09", "A11", "A11"], - ["A13", "A13", "A15", "A15"], - ["A17", "A17", "A19", "A19"], - ["A21", "A21", "A23", "A23"], + ["A01","A01", "A03","A03"], + ["A05","A05", "A07","A07"], + ["A09","A09", "A11","A11"], + ["A13","A13", "A15","A15"], + ["A17","A17", "A19","A19"], + ["A21","A21", "A23","A23"], ], layout_prompt: concat!( - "整张图集排满 12 个横向场景裁片,每个占 2 格。", - "每格 256×256,4 列 6 行排布,场景间自然过渡。", + "画面等分为 6 行 4 列,每格 256×256。", + "共 12 个横向裁片,每行左右各一个,每裁片严格占 2 格(1 行 2 列)。", + "裁片内部画面连续无间断,不可拆成两格独立小图。", + "不同裁片之间用洋红细线分隔。画面四周留洋红边距。", "绝对不要画网格线、边框、编号、文字或水印。", ), }, - // Sheet 2: 1×2 纵向 11 组 + 2 FILL, 纯竖条节奏 + // Sheet 2: 1×2 纵向 12 组 (取11), 满画布 24cells PuzzleClearAtlasSheetSpec { sheet_id: "sheet-02", layout: [ - ["A02", "A04", "A06", "A08"], - ["A02", "A04", "A06", "A08"], - ["A10", "A12", "A14", "A16"], - ["A10", "A12", "A14", "A16"], - ["A18", "A20", "A22", PUZZLE_CLEAR_SHEET_FILLER_CELL], - ["A18", "A20", "A22", PUZZLE_CLEAR_SHEET_FILLER_CELL], + ["A02","A04", "A06","A08"], + ["A02","A04", "A06","A08"], + ["A10","A12", "A14","A16"], + ["A10","A12", "A14","A16"], + ["A18","A20", "A22",buf], + ["A18","A20", "A22",buf], ], layout_prompt: concat!( - "整张图集排满 11 个纵向场景裁片,每个占 1×2 格。", - "每格 256×256,4 列 6 行排布,场景间自然过渡。右下两格为留白。", + "画面等分为 6 行 4 列,每格 256×256,各行高度严格相等。", + "共 12 个纵向裁片,每行排 4 个,两两成组上下排列,每裁片严格占 1 列 2 行。", + "裁片内部画面连续无间断,不可拆成上下两格独立小图。", + "不同裁片之间用洋红细线分隔。画面四周留洋红边距。", "绝对不要画网格线、边框、编号、文字或水印。", ), }, - // Sheet 3: 2×2 正方形 4 组 + 8 FILL, 纯正方节奏 + // Sheet 3: 2×2 正方形 6 组 (取4), 满画布 24cells PuzzleClearAtlasSheetSpec { sheet_id: "sheet-03", layout: [ - ["C01", "C01", "C02", "C02"], - ["C01", "C01", "C02", "C02"], - ["C03", "C03", "C04", "C04"], - ["C03", "C03", "C04", "C04"], - [ - 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, - ], + ["C01","C01", "C02","C02"], + ["C01","C01", "C02","C02"], + ["C03","C03", "C04","C04"], + ["C03","C03", "C04","C04"], + [buf,buf, buf,buf], + [buf,buf, buf,buf], ], layout_prompt: concat!( - "整张图集排满 4 个正方形场景裁片,每个占 2×2 格。", - "每格 256×256,4 列 6 行排布,下半部分为留白。", + "画面等分为 6 行 4 列,每格 256×256,各行高度严格相等。", + "共 6 个正方形裁片,每行左右各一个,每裁片严格占 2 列 2 行。", + "裁片内部画面连续无间断,不可拆成四格独立小图。", + "不同裁片之间用洋红细线分隔。画面四周留洋红边距。", "绝对不要画网格线、边框、编号、文字或水印。", ), }, - // Sheet 4: 2×3 横向 3 组 + 1×3 纵向 2 组, 大块面节奏 + // Sheet 4: 2×3 横向 3 组, 满画布 + 右列缓冲 PuzzleClearAtlasSheetSpec { sheet_id: "sheet-04", layout: [ - ["D01", "D01", "D01", "B02"], - ["D01", "D01", "D01", "B02"], - ["D02", "D02", "D02", "B02"], - ["D02", "D02", "D02", "B04"], - ["D03", "D03", "D03", "B04"], - ["D03", "D03", "D03", "B04"], + ["D01","D01","D01",buf], + ["D01","D01","D01",buf], + ["D02","D02","D02",buf], + ["D02","D02","D02",buf], + ["D03","D03","D03",buf], + ["D03","D03","D03",buf], ], layout_prompt: concat!( - "整张图集排满 5 个大场景裁片:左边 3 个各占 3×2 格,右边 2 个各占 1×3 格。", - "每格 256×256,4 列 6 行排布,场景间自然过渡。", + "画面等分为 6 行 4 列,每格 256×256,各行高度严格相等。", + "共 3 个横向宽裁片,每两行一个,每裁片严格占 3 列 2 行(宽 3 格高 2 格)。", + "裁片内部画面连续无间断,不可拆成独立格子。", + "不同裁片之间用洋红细线分隔。画面四周留洋红边距。", "绝对不要画网格线、边框、编号、文字或水印。", ), }, - // Sheet 5: 1×3 横向 3 组 + 15 FILL, 纯宽条节奏 + // Sheet 5: 1×3 横向 6 组 (取3), 满画布 18cells + 右列缓冲 PuzzleClearAtlasSheetSpec { sheet_id: "sheet-05", layout: [ - ["B01", "B01", "B01", PUZZLE_CLEAR_SHEET_FILLER_CELL], - ["B03", "B03", "B03", PUZZLE_CLEAR_SHEET_FILLER_CELL], - ["B05", "B05", "B05", 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, - PUZZLE_CLEAR_SHEET_FILLER_CELL, - PUZZLE_CLEAR_SHEET_FILLER_CELL, - PUZZLE_CLEAR_SHEET_FILLER_CELL, - ], + ["B01","B01","B01",buf], + ["B03","B03","B03",buf], + ["B05","B05","B05",buf], + [buf,buf,buf,buf], + [buf,buf,buf,buf], + [buf,buf,buf,buf], ], layout_prompt: concat!( - "整张图集排满 3 个横向宽场景裁片,每个占 3 格。", - "每格 256×256,4 列 6 行排布,下半部分为留白。", + "画面等分为 6 行 4 列,每格 256×256,各行高度严格相等。", + "共 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!( concat!( "生成一张拼消消卡牌图集,主题是「{subject}」,竖版 1024x1536。\n", - "照片式构图、绘本式渲染。每格 256×256 是一个完整微场景裁片,", - "同编号连续格共享场景锚点和道具语言,不同编号展现不同视觉概念。", + "照片式构图、绘本式渲染。画面由若干场景裁片组成,", + "每个裁片是完整连续画面,内部无接缝、无分隔线、无网格。", + "不同裁片之间用纯洋红(#FF00FF)细线分隔。", "不要文字、Logo、水印、UI、网格线、边框、编号、纯色背景或孤立主体。\n", "{layout_prompt}" ), @@ -1706,8 +1728,40 @@ fn is_retryable_puzzle_clear_sheet_generation_error(error: &AppError) -> bool { .unwrap_or(false) } +fn prepare_puzzle_clear_magenta_cleanup( + image: &DownloadedOpenAiImage, + sheet_id: &str, +) -> Result { + 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 { - 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( @@ -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 cells_by_group: BTreeMap<&str, Vec<(u32, u32)>> = BTreeMap::new(); 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 max_row = cells.iter().map(|(row, _)| *row).max().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; if cells.len() != expected_cell_count { return Err( @@ -1762,65 +1826,65 @@ fn slice_puzzle_clear_sheet( "provider": PUZZLE_CLEAR_CREATION_PROVIDER, "message": format!( "拼消消素材 {} 的布局 {} 格数不匹配,期望 {} 格,实际 {} 格。", - sheet_spec.sheet_id, - group_id, - expected_cell_count, - cells.len(), + sheet_spec.sheet_id, 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_x in 0..group.width { - let expected_cell = (min_row + part_y, min_col + part_x); - if !cells.contains(&expected_cell) { - return Err(AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) - .with_details(json!({ + 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()); + 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, - "message": format!( - "拼消消素材 {} 的布局 {} 不是完整连续矩形,缺少第 {} 行第 {} 列。", - sheet_spec.sheet_id, - group_id, - expected_cell.0 + 1, - expected_cell.1 + 1, - ), - }))); - } + "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(), + }); } } - 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) } @@ -1870,8 +1934,101 @@ fn compose_puzzle_clear_final_atlas( }) } -fn scale_sheet_coord(value: u32, actual: u32, sheet_cells: u32) -> u32 { - ((u64::from(value) * u64::from(actual)) / u64::from(sheet_cells)) as u32 +fn find_non_transparent_bounds( + 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 { @@ -2289,15 +2446,15 @@ mod tests { let prompt = build_puzzle_clear_atlas_prompt("水果", &sheet); assert!(prompt.contains("主题是「水果」")); 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("12 个横向场景裁片")); + assert!(prompt.contains("12 个横向")); assert!(!prompt.contains("卡牌排版图")); assert!(!prompt.contains("贴纸表")); assert!(PUZZLE_CLEAR_ATLAS_NEGATIVE_PROMPT.contains("纯色背景")); @@ -2341,11 +2498,11 @@ mod tests { .map(|group| group.width * group.height) .sum::(); - assert_eq!(sheets.len(), 5); + assert_eq!(sheets.len(), 6); 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!(filler_sheet_cells, 25); + assert_eq!(filler_sheet_cells, 0); assert_eq!(group_cells, 95); assert_eq!(PUZZLE_CLEAR_ATLAS_CELL_SIZE, 256); assert_eq!(sheet_cells_by_group.len(), groups.len()); @@ -2559,8 +2716,7 @@ mod tests { rt.block_on(async { use super::{ compose_puzzle_clear_final_atlas, slice_puzzle_clear_sheet, - build_puzzle_clear_atlas_prompt, PUZZLE_CLEAR_ATLAS_CELL_SIZE, - PUZZLE_CLEAR_SHEET_COLUMNS, PUZZLE_CLEAR_SHEET_ROWS, + build_puzzle_clear_atlas_prompt, prepare_puzzle_clear_magenta_cleanup, PUZZLE_CLEAR_ATLAS_NEGATIVE_PROMPT, PUZZLE_CLEAR_ATLAS_GENERATION_SIZE, }; use crate::openai_image_generation::DownloadedOpenAiImage; @@ -2584,7 +2740,12 @@ mod tests { let http_client = build_vector_engine_image_http_client(&settings) .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"); let sheets_out = out.join("sheets"); let cards_out = out.join("cards"); @@ -2643,8 +2804,14 @@ mod tests { 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( - &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())); diff --git a/tools/analyze_puzzle_clear_output.py b/tools/analyze_puzzle_clear_output.py new file mode 100644 index 00000000..05e3a468 --- /dev/null +++ b/tools/analyze_puzzle_clear_output.py @@ -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() diff --git a/tools/test_ve_api.py b/tools/test_ve_api.py new file mode 100644 index 00000000..c2a78aca --- /dev/null +++ b/tools/test_ve_api.py @@ -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()