拼消消生图管线升级: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("其余 6 个格子"));
|
||||
// 贴图内容
|
||||
assert!(prompt.contains("1x1x1 立方体物体的六面展开"));
|
||||
assert!(prompt.contains("1×1×1 的立方体"));
|
||||
assert!(prompt.contains("主题为\"森林冒险\""));
|
||||
assert!(prompt.contains("6 个面必须属于同一个物体"));
|
||||
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_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<AppState>,
|
||||
@@ -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<PuzzleClearPatternGroup> {
|
||||
}
|
||||
|
||||
fn puzzle_clear_atlas_sheet_specs() -> Vec<PuzzleClearAtlasSheetSpec> {
|
||||
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<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 {
|
||||
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<String> {
|
||||
@@ -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::<u32>();
|
||||
|
||||
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()));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user