fix: 提升拼消消素材生成质量门禁

This commit is contained in:
2026-06-04 22:32:46 +08:00
parent b9de2f2a43
commit 0c7fc0b26f
6 changed files with 724 additions and 49 deletions

View File

@@ -4080,8 +4080,7 @@ mod tests {
.await
.expect("banners body should collect")
.to_bytes();
let payload: Value =
serde_json::from_slice(&body).expect("banners payload should be json");
let payload: Value = serde_json::from_slice(&body).expect("banners payload should be json");
assert_eq!(payload["eventBanners"][0]["title"], "后台表单公告");
assert_eq!(payload["eventBanners"][0]["renderMode"], "html");

View File

@@ -77,17 +77,13 @@ pub fn resolve_creation_entry_route_id(path: &str) -> Option<&'static str> {
{
return Some("puzzle");
}
if normalized.starts_with("/api/runtime/puzzle/gallery/")
&& normalized.ends_with("/remix")
{
if normalized.starts_with("/api/runtime/puzzle/gallery/") && normalized.ends_with("/remix") {
return Some("puzzle");
}
if normalized == "/api/runtime/big-fish/agent/sessions" {
return Some("big-fish");
}
if normalized.starts_with("/api/runtime/big-fish/gallery/")
&& normalized.ends_with("/remix")
{
if normalized.starts_with("/api/runtime/big-fish/gallery/") && normalized.ends_with("/remix") {
return Some("big-fish");
}
if normalized == "/api/runtime/custom-world/agent/sessions"

View File

@@ -42,6 +42,10 @@ impl AppError {
&self.message
}
pub fn details(&self) -> Option<&Value> {
self.details.as_ref()
}
pub fn body_text(&self) -> String {
// 批处理任务不能只读 HTTP 状态文案,否则 DashScope 返回的真实失败原因会被压成“上游服务请求失败”。
self.details

View File

@@ -5,6 +5,7 @@ use axum::{
http::{HeaderName, StatusCode, header},
response::Response,
};
use image::GenericImageView;
use module_assets::{
AssetObjectAccessPolicy, build_asset_entity_binding_input, build_asset_object_upsert_input,
generate_asset_binding_id, generate_asset_object_id,
@@ -61,6 +62,15 @@ const PUZZLE_CLEAR_FINAL_ATLAS_ROWS: u32 = 10;
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_SHEET_FOREGROUND_DIFF_THRESHOLD: i32 = 58;
const PUZZLE_CLEAR_SHEET_MIN_FOREGROUND_RATIO: f32 = 0.018;
const PUZZLE_CLEAR_SHEET_BLANK_MAX_FOREGROUND_RATIO: f32 = 0.045;
const PUZZLE_CLEAR_SHEET_EDGE_RATIO_THRESHOLD: f32 = 0.34;
const PUZZLE_CLEAR_SHEET_STRONG_EDGE_RATIO_THRESHOLD: f32 = 0.66;
const PUZZLE_CLEAR_SHEET_INTERNAL_SEAM_DIFF_THRESHOLD: i32 = 155;
const PUZZLE_CLEAR_SHEET_INTERNAL_SEAM_RATIO_THRESHOLD: f32 = 0.86;
const PUZZLE_CLEAR_ATLAS_NEGATIVE_PROMPT: &str = "文字、Logo、水印、按钮、UI 字、网格线、编号、标签、边框、外轮廓框、白色描边、白色贴纸边、圆角框、阴影框、分隔线、裁切参考线、单格内部拼接线、内部竖切、内部横切、照片拼贴、相册拼贴、多场景拼贴、双联图、三联图、画中画、单格双图、单格多图、低清晰度、纯色背景、空白背景、白底商品图、孤立主体、单体素材、素材表、图标、贴纸、同品种重复、同一物体多角度、重复同款小图、主体跨格、主体贴边、拼贴、重影、不同图案互相穿插";
pub async fn create_puzzle_clear_session(
State(state): State<AppState>,
@@ -689,35 +699,104 @@ async fn maybe_prepare_puzzle_clear_assets_inner(
payload.theme_prompt.as_deref().unwrap_or_default(),
&sheet_spec,
);
let failure_context = format!("拼消消素材 {} 生成失败", sheet_spec.sheet_id);
let generated = create_openai_image_generation(
&http_client,
&settings,
sheet_prompt.as_str(),
Some("文字、Logo、水印、按钮、UI 字、网格线、编号、标签、边框、外轮廓框、白色描边、白色贴纸边、圆角框、阴影框、分隔线、裁切参考线、低清晰度、主体跨格、主体贴边、重复同款小图"),
PUZZLE_CLEAR_ATLAS_GENERATION_SIZE,
1,
&[],
failure_context.as_str(),
)
.await?;
let task_id = generated.task_id;
let image = generated.images.into_iter().next().ok_or_else(|| {
puzzle_clear_error_response(
let mut accepted_sheet = None;
for attempt_index in 0..PUZZLE_CLEAR_SHEET_GENERATION_MAX_ATTEMPTS {
let failure_context = format!(
"拼消消素材 {} 生成失败,第 {}",
sheet_spec.sheet_id,
attempt_index + 1
);
let generated = match create_openai_image_generation(
&http_client,
&settings,
sheet_prompt.as_str(),
Some(PUZZLE_CLEAR_ATLAS_NEGATIVE_PROMPT),
PUZZLE_CLEAR_ATLAS_GENERATION_SIZE,
1,
&[],
failure_context.as_str(),
)
.await
{
Ok(generated) => generated,
Err(error)
if attempt_index + 1 < PUZZLE_CLEAR_SHEET_GENERATION_MAX_ATTEMPTS
&& is_retryable_puzzle_clear_sheet_generation_error(&error) =>
{
tracing::warn!(
provider = PUZZLE_CLEAR_CREATION_PROVIDER,
sheet_id = sheet_spec.sheet_id,
attempt = attempt_index + 1,
generation_error = %error.body_text(),
"拼消消素材 sheet 生成遇到可重试上游错误,准备重试"
);
continue;
}
Err(error) => {
return Err(puzzle_clear_error_response(
request_context,
PUZZLE_CLEAR_CREATION_PROVIDER,
error,
));
}
};
let task_id = generated.task_id;
let image = generated.images.into_iter().next().ok_or_else(|| {
puzzle_clear_error_response(
request_context,
PUZZLE_CLEAR_CREATION_PROVIDER,
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "vector-engine",
"message": format!("拼消消素材 {} 生成成功但未返回图片。", sheet_spec.sheet_id),
})),
)
})?;
match validate_puzzle_clear_sheet_quality(&image, &sheet_spec) {
Ok(()) => {
accepted_sheet = Some(PuzzleClearGeneratedSheet {
spec: sheet_spec,
prompt: sheet_prompt.clone(),
task_id,
image,
});
break;
}
Err(error) if attempt_index + 1 < PUZZLE_CLEAR_SHEET_GENERATION_MAX_ATTEMPTS => {
tracing::warn!(
provider = PUZZLE_CLEAR_CREATION_PROVIDER,
sheet_id = sheet_spec.sheet_id,
attempt = attempt_index + 1,
quality_error = %error.body_text(),
"拼消消素材 sheet 质量校验未通过,准备重试"
);
}
Err(error) => {
tracing::warn!(
provider = PUZZLE_CLEAR_CREATION_PROVIDER,
sheet_id = sheet_spec.sheet_id,
attempt = attempt_index + 1,
quality_error = %error.body_text(),
"拼消消素材 sheet 质量校验最终未通过"
);
return Err(puzzle_clear_error_response(
request_context,
PUZZLE_CLEAR_CREATION_PROVIDER,
error,
));
}
}
}
let Some(accepted_sheet) = accepted_sheet else {
return Err(puzzle_clear_error_response(
request_context,
PUZZLE_CLEAR_CREATION_PROVIDER,
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "vector-engine",
"message": format!("拼消消素材 {} 生成成功但未返回图片", sheet_spec.sheet_id),
"provider": PUZZLE_CLEAR_CREATION_PROVIDER,
"message": format!("拼消消素材 {} 多次生成后仍未得到可切图集", sheet_spec.sheet_id),
})),
)
})?;
generated_sheets.push(PuzzleClearGeneratedSheet {
spec: sheet_spec,
prompt: sheet_prompt,
task_id,
image,
});
));
};
generated_sheets.push(accepted_sheet);
}
let mut slices = Vec::new();
@@ -858,7 +937,7 @@ fn puzzle_clear_atlas_sheet_specs() -> Vec<PuzzleClearAtlasSheetSpec> {
"第4行D02 D02 C01 C01\n",
"第5行D02 D02 C02 C02\n",
"第6行A01 A01 C02 C02\n\n",
"A 表示 1x2 复合图案C 表示 2x2 复合图案D 表示 2x3 或 3x2 复合图案。请按相同编号连续区域成一幅完整连续的小插画"
"A 表示 1x2 复合图案C 表示 2x2 复合图案D 表示 2x3 或 3x2 复合图案。相同编号只表示玩法分组:请生成一组主题一致的小照片裁片,每个 256 单元都要独立成图,不要把连续区域成一张横跨多格的大图或照片拼贴"
),
},
PuzzleClearAtlasSheetSpec {
@@ -879,7 +958,7 @@ fn puzzle_clear_atlas_sheet_specs() -> Vec<PuzzleClearAtlasSheetSpec> {
"第4行C03 C03 C04 C04\n",
"第5行B01 B01 B01 A06\n",
"第6行B03 B03 B03 A06\n\n",
"A 表示 1x2 复合图案B 表示 1x3 或 3x1 复合图案C 表示 2x2 复合图案D 表示 2x3 或 3x2 复合图案。请按相同编号连续区域成一幅完整连续的小插画"
"A 表示 1x2 复合图案B 表示 1x3 或 3x1 复合图案C 表示 2x2 复合图案D 表示 2x3 或 3x2 复合图案。相同编号只表示玩法分组:请生成一组主题一致的小照片裁片,每个 256 单元都要独立成图,不要把连续区域成一张横跨多格的大图或照片拼贴"
),
},
PuzzleClearAtlasSheetSpec {
@@ -900,7 +979,7 @@ fn puzzle_clear_atlas_sheet_specs() -> Vec<PuzzleClearAtlasSheetSpec> {
"第4行B05 B05 B05 A08\n",
"第5行A09 A09 A10 A08\n",
"第6行A11 A11 A10 空白\n\n",
"A 表示 1x2 复合图案B 表示 1x3 或 3x1 复合图案。空白格保持干净浅色背景,不要生成图案。请按相同编号连续区域成一幅完整连续的小插画"
"A 表示 1x2 复合图案B 表示 1x3 或 3x1 复合图案。空白格保持干净浅色背景,不要生成图案。相同编号只表示玩法分组:请生成一组主题一致的小照片裁片,每个 256 单元都要独立成图,不要把连续区域成一张横跨多格的大图或照片拼贴"
),
},
PuzzleClearAtlasSheetSpec {
@@ -921,7 +1000,7 @@ fn puzzle_clear_atlas_sheet_specs() -> Vec<PuzzleClearAtlasSheetSpec> {
"第4行A16 A19 A19 A18\n",
"第5行A20 A21 A21 A22\n",
"第6行A20 A23 A23 A22\n\n",
"A 表示 1x2 复合图案。请按相同编号连续区域生成一幅完整连续的小插画,横向 1x2 和纵向 1x2 都要自然可拼接"
"A 表示 1x2 复合图案。相同编号只表示玩法分组:横向 1x2 和纵向 1x2 用色调、道具和背景线索互相呼应,每个 256 单元都要独立成图,不要把连续区域画成一张横跨两格的大图或照片拼贴"
),
},
]
@@ -936,9 +1015,14 @@ fn build_puzzle_clear_atlas_prompt(
concat!(
"生成一张拼消消素材工作表,主题是「{subject}」,竖版 1024x1536。\n\n",
"这张图供程序后台按 4 列 x 6 行裁切,每个裁切单元为 256x256 的正方形。4x6 网格只用于后台理解,画面中绝对不要画出网格线、切分线、边框、编号或坐标。\n\n",
"相同编号连续占据的格子是一幅复合小插画,必须形成同一个完整主题物件或小场景;不同编号之间是不同图案,不要重复主体。复合图案可以横向或纵向跨格,但跨格处必须自然连续,切成 1x1 后每一格仍然有清晰可识别的局部图案\n\n",
"图案不要做成卡牌、贴纸、图标格子或带框小卡片。每个图案外沿自然融入干净浅色背景,但不能有过多留白,外轮廓框、白色描边、圆角框、阴影框、分线、参考线或贴纸边\n\n",
"画风为高清、清爽、适合休闲消除游戏的小插画主体饱满颜色鲜明边缘干净不能出现文字、Logo、水印、按钮、UI 或教程元素\n\n",
"这不是单个物体素材表,而是一组照片式构图、绘本式渲染的主题微场景拼图卡。每个编号区域必须有明确背景、环境、道具、光影和构图线索,像从一张丰富照片或插画中裁出的局部。\n\n",
"每个 256x256 单元本身就是一张完整的单场景照片裁片,单元内部只能有一个连续画面;禁止在一个单元内部出现两张照片、两个不同场景、拼接线、分线、内部竖切、内部横切、左右/上下两块不同背景。场景变化只能发生在 256 单元边界上\n\n",
"相同编号连续占据的格子只表示玩法上的同组关系,不是要求把一张大图横跨多个格子。请把同编号区域画成色调、道具、背景线索互相呼应的一组小照片裁片;每个格子独立查看时都必须完整成图,不能在单格内部再切出第二张图或第二个场景\n\n",
"同一张 sheet 内,不同编号必须使用不同视觉概念,不要把同一种主体换角度、换大小、换姿势后重复使用。比如主题是水果时,不要重复生成不同角度的葡萄、菠萝、西瓜、橙子;应扩展为果园、集市摊位、野餐布、果汁杯、厨房案板、甜品盘、篮筐、玻璃罐、窗边餐桌、花园背景等不同场景。\n\n",
"每个 256x256 单元独立查看时,都应该有可辨识的局部信息:可以包含主体局部、背景纹理、桌面、草地、天空、建筑、布料、器皿、叶片、阴影或装饰元素。不要让小卡只有一个孤立主体加纯色背景。\n\n",
"不同编号区域之间保持干净边界,主体不能越界或挤入相邻编号区域;空白格必须保持干净浅色背景,不要出现任何图案碎片。\n\n",
"图案不要做成商品素材表、卡牌、贴纸、图标格子或带框小卡片。不能有外轮廓框、白色描边、圆角框、阴影框、分隔线、参考线或贴纸边。\n\n",
"画风为高清、清爽、适合休闲消除游戏的丰富主题插画颜色鲜明边缘干净不能出现文字、Logo、水印、按钮、UI 或教程元素。\n\n",
"{layout_prompt}"
),
subject = subject,
@@ -1063,6 +1147,376 @@ async fn persist_puzzle_clear_data_url_asset(
.await
}
#[derive(Clone, Copy, Debug)]
struct PuzzleClearSheetCellBounds {
x0: u32,
y0: u32,
x1: u32,
y1: u32,
}
impl PuzzleClearSheetCellBounds {
fn width(self) -> u32 {
self.x1.saturating_sub(self.x0).max(1)
}
fn height(self) -> u32 {
self.y1.saturating_sub(self.y0).max(1)
}
fn area(self) -> u32 {
self.width().saturating_mul(self.height()).max(1)
}
}
#[derive(Clone, Copy, Debug)]
struct PuzzleClearSheetCellQuality {
foreground_ratio: f32,
exposed_edge_count: usize,
strongest_edge_ratio: f32,
strongest_internal_seam_ratio: f32,
}
fn validate_puzzle_clear_sheet_quality(
image: &DownloadedOpenAiImage,
sheet_spec: &PuzzleClearAtlasSheetSpec,
) -> Result<(), AppError> {
// 中文注释:生成图进入正式切片前先做像素级门禁,避免把明显错位的 sheet 持久化成卡牌资产。
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!("拼消消素材 {} 解码失败:{error}", sheet_spec.sheet_id),
}))
})?;
let source_width = source.width();
let source_height = source.height();
if source_width < PUZZLE_CLEAR_SHEET_COLUMNS || source_height < PUZZLE_CLEAR_SHEET_ROWS {
return Err(
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": PUZZLE_CLEAR_CREATION_PROVIDER,
"message": format!("拼消消素材 {} 尺寸过小,无法做切片质量校验。", sheet_spec.sheet_id),
})),
);
}
let mut findings = Vec::new();
let mut advisory_findings = Vec::new();
for row in 0..PUZZLE_CLEAR_SHEET_ROWS {
for col in 0..PUZZLE_CLEAR_SHEET_COLUMNS {
let group_id = sheet_spec.layout[row as usize][col as usize];
let bounds = puzzle_clear_sheet_cell_bounds(row, col, source_width, source_height);
let quality =
analyze_puzzle_clear_sheet_cell_quality(&source, sheet_spec, row, col, bounds);
let cell_label = format!("{}行第{}", row + 1, col + 1);
if group_id == "." {
if quality.foreground_ratio > PUZZLE_CLEAR_SHEET_BLANK_MAX_FOREGROUND_RATIO {
findings.push(format!("{cell_label} 空白格有主体"));
}
continue;
}
if quality.foreground_ratio < PUZZLE_CLEAR_SHEET_MIN_FOREGROUND_RATIO {
findings.push(format!("{cell_label} 主体过少"));
}
if quality.strongest_internal_seam_ratio
> PUZZLE_CLEAR_SHEET_INTERNAL_SEAM_RATIO_THRESHOLD
{
findings.push(format!("{cell_label} 单格内部疑似拼接线"));
}
if quality.exposed_edge_count >= 2
&& quality.strongest_edge_ratio > PUZZLE_CLEAR_SHEET_STRONG_EDGE_RATIO_THRESHOLD
{
advisory_findings.push(format!("{cell_label} 主体贴到不同图案边界"));
}
}
}
if !advisory_findings.is_empty() {
tracing::warn!(
provider = PUZZLE_CLEAR_CREATION_PROVIDER,
sheet_id = sheet_spec.sheet_id,
quality_warning = %advisory_findings.join(""),
"拼消消素材 sheet 检测到边界接触,已作为提示继续切片"
);
}
if findings.is_empty() {
return Ok(());
}
Err(
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": PUZZLE_CLEAR_CREATION_PROVIDER,
"reason": "invalid_puzzle_clear_sheet_quality",
"message": format!(
"拼消消素材 {} 不满足切片质量:{}。请重新生成图集。",
sheet_spec.sheet_id,
findings.join(""),
),
"findings": findings,
})),
)
}
fn is_retryable_puzzle_clear_sheet_generation_error(error: &AppError) -> bool {
if !matches!(
error.status_code(),
StatusCode::BAD_GATEWAY | StatusCode::GATEWAY_TIMEOUT | StatusCode::TOO_MANY_REQUESTS
) {
return false;
}
error
.details()
.and_then(|details| details.get("retryable"))
.and_then(Value::as_bool)
.unwrap_or(false)
}
fn analyze_puzzle_clear_sheet_cell_quality(
source: &image::DynamicImage,
sheet_spec: &PuzzleClearAtlasSheetSpec,
row: u32,
col: u32,
bounds: PuzzleClearSheetCellBounds,
) -> PuzzleClearSheetCellQuality {
let background = sample_puzzle_clear_sheet_cell_background(source, bounds);
let width = bounds.width() as usize;
let height = bounds.height() as usize;
let mut mask = vec![0u8; width.saturating_mul(height)];
let mut foreground_pixels = 0u32;
for local_y in 0..height {
let y = bounds.y0 + local_y as u32;
for local_x in 0..width {
let x = bounds.x0 + local_x as u32;
if is_puzzle_clear_sheet_foreground_pixel(source.get_pixel(x, y).0, background) {
mask[local_y * width + local_x] = 1;
foreground_pixels = foreground_pixels.saturating_add(1);
}
}
}
let (exposed_edge_count, strongest_edge_ratio) =
measure_puzzle_clear_sheet_exposed_edges(&mask, width, height, sheet_spec, row, col);
let strongest_internal_seam_ratio = measure_puzzle_clear_sheet_internal_seam(source, bounds);
PuzzleClearSheetCellQuality {
foreground_ratio: foreground_pixels as f32 / bounds.area() as f32,
exposed_edge_count,
strongest_edge_ratio,
strongest_internal_seam_ratio,
}
}
fn puzzle_clear_sheet_cell_bounds(
row: u32,
col: u32,
source_width: u32,
source_height: u32,
) -> PuzzleClearSheetCellBounds {
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);
PuzzleClearSheetCellBounds { x0, y0, x1, y1 }
}
fn is_puzzle_clear_sheet_foreground_pixel(pixel: [u8; 4], background: [u8; 4]) -> bool {
if pixel[3] <= 24 {
return false;
}
let alpha_diff = (pixel[3] as i32 - background[3] as i32).abs();
let color_diff = (pixel[0] as i32 - background[0] as i32).abs()
+ (pixel[1] as i32 - background[1] as i32).abs()
+ (pixel[2] as i32 - background[2] as i32).abs();
alpha_diff >= 48 || color_diff >= PUZZLE_CLEAR_SHEET_FOREGROUND_DIFF_THRESHOLD
}
fn sample_puzzle_clear_sheet_cell_background(
source: &image::DynamicImage,
bounds: PuzzleClearSheetCellBounds,
) -> [u8; 4] {
let sample_size = (bounds.width().min(bounds.height()) / 12).clamp(2, 8);
let points = [
(bounds.x0, bounds.y0),
(bounds.x1.saturating_sub(sample_size), bounds.y0),
(bounds.x0, bounds.y1.saturating_sub(sample_size)),
(
bounds.x1.saturating_sub(sample_size),
bounds.y1.saturating_sub(sample_size),
),
];
let mut samples = Vec::new();
for (start_x, start_y) in points {
let mut totals = [0u32; 4];
let mut count = 0u32;
for y in start_y..start_y.saturating_add(sample_size).min(bounds.y1) {
for x in start_x..start_x.saturating_add(sample_size).min(bounds.x1) {
let pixel = source.get_pixel(x, y).0;
totals[0] = totals[0].saturating_add(pixel[0] as u32);
totals[1] = totals[1].saturating_add(pixel[1] as u32);
totals[2] = totals[2].saturating_add(pixel[2] as u32);
totals[3] = totals[3].saturating_add(pixel[3] as u32);
count = count.saturating_add(1);
}
}
if count > 0 {
samples.push([
(totals[0] / count) as u8,
(totals[1] / count) as u8,
(totals[2] / count) as u8,
(totals[3] / count) as u8,
]);
}
}
samples
.into_iter()
.max_by_key(|sample| {
let max_channel = sample[0].max(sample[1]).max(sample[2]) as u16;
let min_channel = sample[0].min(sample[1]).min(sample[2]) as u16;
let luminance = sample[0] as u16 + sample[1] as u16 + sample[2] as u16;
let saturation = max_channel.saturating_sub(min_channel);
(luminance, u16::MAX.saturating_sub(saturation))
})
.unwrap_or([255, 255, 255, 255])
}
fn measure_puzzle_clear_sheet_exposed_edges(
mask: &[u8],
width: usize,
height: usize,
sheet_spec: &PuzzleClearAtlasSheetSpec,
row: u32,
col: u32,
) -> (usize, f32) {
if width == 0 || height == 0 || mask.len() < width.saturating_mul(height) {
return (0, 0.0);
}
let band = (width.min(height) / 24).clamp(6, 12);
let mut exposed_edges = 0usize;
let mut strongest_ratio = 0.0f32;
let edge_specs = [
((-1i32, 0i32), 0usize, 0usize, width, band),
((1, 0), 0, height.saturating_sub(band), width, band),
((0, -1), 0, 0, band, height),
((0, 1), width.saturating_sub(band), 0, band, height),
];
for ((row_delta, col_delta), start_x, start_y, edge_width, edge_height) in edge_specs {
if puzzle_clear_sheet_neighbor_is_same_group(sheet_spec, row, col, row_delta, col_delta) {
continue;
}
let mut foreground = 0usize;
let mut total = 0usize;
for local_y in start_y..start_y.saturating_add(edge_height).min(height) {
for local_x in start_x..start_x.saturating_add(edge_width).min(width) {
total = total.saturating_add(1);
if mask[local_y * width + local_x] != 0 {
foreground = foreground.saturating_add(1);
}
}
}
if total == 0 {
continue;
}
let ratio = foreground as f32 / total as f32;
strongest_ratio = strongest_ratio.max(ratio);
if ratio > PUZZLE_CLEAR_SHEET_EDGE_RATIO_THRESHOLD {
exposed_edges = exposed_edges.saturating_add(1);
}
}
(exposed_edges, strongest_ratio)
}
fn measure_puzzle_clear_sheet_internal_seam(
source: &image::DynamicImage,
bounds: PuzzleClearSheetCellBounds,
) -> f32 {
let width = bounds.width();
let height = bounds.height();
if width < 48 || height < 48 {
return 0.0;
}
let margin_x = (width / 8).clamp(18, 36);
let margin_y = (height / 8).clamp(18, 36);
let x_start = bounds.x0.saturating_add(margin_x).max(bounds.x0 + 1);
let x_end = bounds.x1.saturating_sub(margin_x).max(x_start + 1);
let y_start = bounds.y0.saturating_add(margin_y).max(bounds.y0 + 1);
let y_end = bounds.y1.saturating_sub(margin_y).max(y_start + 1);
let mut strongest = 0.0f32;
for x in x_start..x_end {
let mut strong = 0u32;
let mut total = 0u32;
for y in y_start..y_end {
let left = source.get_pixel(x.saturating_sub(1), y).0;
let right = source.get_pixel(x, y).0;
if puzzle_clear_rgb_distance(left, right)
>= PUZZLE_CLEAR_SHEET_INTERNAL_SEAM_DIFF_THRESHOLD
{
strong = strong.saturating_add(1);
}
total = total.saturating_add(1);
}
if total > 0 {
strongest = strongest.max(strong as f32 / total as f32);
}
}
for y in y_start..y_end {
let mut strong = 0u32;
let mut total = 0u32;
for x in x_start..x_end {
let top = source.get_pixel(x, y.saturating_sub(1)).0;
let bottom = source.get_pixel(x, y).0;
if puzzle_clear_rgb_distance(top, bottom)
>= PUZZLE_CLEAR_SHEET_INTERNAL_SEAM_DIFF_THRESHOLD
{
strong = strong.saturating_add(1);
}
total = total.saturating_add(1);
}
if total > 0 {
strongest = strongest.max(strong as f32 / total as f32);
}
}
strongest
}
fn puzzle_clear_rgb_distance(left: [u8; 4], right: [u8; 4]) -> i32 {
(left[0] as i32 - right[0] as i32).abs()
+ (left[1] as i32 - right[1] as i32).abs()
+ (left[2] as i32 - right[2] as i32).abs()
}
fn puzzle_clear_sheet_neighbor_is_same_group(
sheet_spec: &PuzzleClearAtlasSheetSpec,
row: u32,
col: u32,
row_delta: i32,
col_delta: i32,
) -> bool {
let current = sheet_spec.layout[row as usize][col as usize];
if current == "." {
return false;
}
let neighbor_row = row as i32 + row_delta;
let neighbor_col = col as i32 + col_delta;
if neighbor_row < 0
|| neighbor_col < 0
|| neighbor_row >= PUZZLE_CLEAR_SHEET_ROWS as i32
|| neighbor_col >= PUZZLE_CLEAR_SHEET_COLUMNS as i32
{
return false;
}
sheet_spec.layout[neighbor_row as usize][neighbor_col as usize] == current
}
fn slice_puzzle_clear_sheet(
image: &DownloadedOpenAiImage,
sheet_spec: &PuzzleClearAtlasSheetSpec,
@@ -1617,12 +2071,19 @@ fn build_puzzle_clear_public_work_code(profile_id: &str) -> String {
#[cfg(test)]
mod tests {
use super::{
PUZZLE_CLEAR_ATLAS_CELL_SIZE, PUZZLE_CLEAR_BOARD_BACKGROUND_GENERATION_SIZE,
build_puzzle_clear_atlas_prompt, build_puzzle_clear_board_background_prompt,
build_puzzle_clear_draft, planned_puzzle_clear_pattern_groups,
puzzle_clear_atlas_sheet_specs,
PUZZLE_CLEAR_ATLAS_CELL_SIZE, PUZZLE_CLEAR_ATLAS_NEGATIVE_PROMPT,
PUZZLE_CLEAR_BOARD_BACKGROUND_GENERATION_SIZE, build_puzzle_clear_atlas_prompt,
build_puzzle_clear_board_background_prompt, build_puzzle_clear_draft,
is_retryable_puzzle_clear_sheet_generation_error, planned_puzzle_clear_pattern_groups,
puzzle_clear_atlas_sheet_specs, validate_puzzle_clear_sheet_quality,
};
use crate::http_error::AppError;
use crate::openai_image_generation::DownloadedOpenAiImage;
use axum::http::StatusCode;
use image::{ImageFormat, Rgba, RgbaImage};
use serde_json::json;
use shared_contracts::puzzle_clear::PuzzleClearWorkspaceCreateRequest;
use std::io::Cursor;
#[test]
fn puzzle_clear_atlas_prompt_uses_sheet_cells_and_subject() {
@@ -1635,12 +2096,38 @@ mod tests {
assert!(prompt.contains("竖版 1024x1536"));
assert!(prompt.contains("4 列 x 6 行裁切"));
assert!(prompt.contains("256x256 的正方形"));
assert!(prompt.contains("切成 1x1 后每一格"));
assert!(prompt.contains("案不要做成卡牌、贴纸、图标格子或带框小卡片"));
assert!(prompt.contains("完整的单场景照片裁片"));
assert!(prompt.contains("照片式构"));
assert!(prompt.contains("主题微场景拼图卡"));
assert!(prompt.contains("明确背景、环境、道具、光影和构图线索"));
assert!(prompt.contains("每个 256x256 单元本身就是一张完整的单场景照片裁片"));
assert!(prompt.contains("禁止在一个单元内部出现两张照片"));
assert!(prompt.contains("内部竖切"));
assert!(prompt.contains("内部横切"));
assert!(prompt.contains("场景变化只能发生在 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!(PUZZLE_CLEAR_ATLAS_NEGATIVE_PROMPT.contains("纯色背景"));
assert!(PUZZLE_CLEAR_ATLAS_NEGATIVE_PROMPT.contains("白底商品图"));
assert!(PUZZLE_CLEAR_ATLAS_NEGATIVE_PROMPT.contains("孤立主体"));
assert!(PUZZLE_CLEAR_ATLAS_NEGATIVE_PROMPT.contains("同品种重复"));
assert!(PUZZLE_CLEAR_ATLAS_NEGATIVE_PROMPT.contains("同一物体多角度"));
assert!(PUZZLE_CLEAR_ATLAS_NEGATIVE_PROMPT.contains("单格内部拼接线"));
assert!(PUZZLE_CLEAR_ATLAS_NEGATIVE_PROMPT.contains("单格双图"));
assert!(PUZZLE_CLEAR_ATLAS_NEGATIVE_PROMPT.contains("照片拼贴"));
assert!(PUZZLE_CLEAR_ATLAS_NEGATIVE_PROMPT.contains("相册拼贴"));
assert!(!prompt.contains("135 幅"));
assert!(!prompt.contains("24 列 x 38 行"));
assert!(!prompt.contains("卡牌小格"));
@@ -1684,6 +2171,177 @@ mod tests {
}
}
#[test]
fn puzzle_clear_sheet_quality_allows_edge_contact_as_advisory_warning() {
let sheet = puzzle_clear_atlas_sheet_specs()
.into_iter()
.find(|sheet| sheet.sheet_id == "sheet-04")
.expect("sheet exists");
let mut source = RgbaImage::from_pixel(1024, 1536, Rgba([250, 249, 242, 255]));
for row in 0..6u32 {
for col in 0..4u32 {
let base_x = col * PUZZLE_CLEAR_ATLAS_CELL_SIZE;
let base_y = row * PUZZLE_CLEAR_ATLAS_CELL_SIZE;
let color = Rgba([
70u8.saturating_add((row * 23) as u8),
80u8.saturating_add((col * 31) as u8),
160,
255,
]);
for y in base_y + 80..base_y + 176 {
for x in base_x + 80..base_x + 176 {
source.put_pixel(x, y, color);
}
}
}
}
for y in 0..180u32 {
for x in 0..180u32 {
source.put_pixel(x, y, Rgba([215, 48, 62, 255]));
}
}
let mut encoded = Cursor::new(Vec::new());
image::DynamicImage::ImageRgba8(source)
.write_to(&mut encoded, ImageFormat::Png)
.expect("test image should encode");
let image = DownloadedOpenAiImage {
extension: "png".to_string(),
mime_type: "image/png".to_string(),
bytes: encoded.into_inner(),
};
validate_puzzle_clear_sheet_quality(&image, &sheet)
.expect("edge contact is advisory because generated sheets often touch borders");
}
#[test]
fn puzzle_clear_sheet_quality_rejects_blank_cell_pollution() {
let sheet = puzzle_clear_atlas_sheet_specs()
.into_iter()
.find(|sheet| sheet.sheet_id == "sheet-03")
.expect("sheet exists");
let mut source = RgbaImage::from_pixel(1024, 1536, Rgba([250, 249, 242, 255]));
for row in 0..6u32 {
for col in 0..4u32 {
let base_x = col * PUZZLE_CLEAR_ATLAS_CELL_SIZE;
let base_y = row * PUZZLE_CLEAR_ATLAS_CELL_SIZE;
let color = Rgba([
70u8.saturating_add((row * 23) as u8),
80u8.saturating_add((col * 31) as u8),
160,
255,
]);
for y in base_y + 80..base_y + 176 {
for x in base_x + 80..base_x + 176 {
source.put_pixel(x, y, color);
}
}
}
}
for y in 5 * PUZZLE_CLEAR_ATLAS_CELL_SIZE + 40..6 * PUZZLE_CLEAR_ATLAS_CELL_SIZE - 40 {
for x in 3 * PUZZLE_CLEAR_ATLAS_CELL_SIZE + 40..4 * PUZZLE_CLEAR_ATLAS_CELL_SIZE - 40 {
source.put_pixel(x, y, Rgba([215, 48, 62, 255]));
}
}
let mut encoded = Cursor::new(Vec::new());
image::DynamicImage::ImageRgba8(source)
.write_to(&mut encoded, ImageFormat::Png)
.expect("test image should encode");
let image = DownloadedOpenAiImage {
extension: "png".to_string(),
mime_type: "image/png".to_string(),
bytes: encoded.into_inner(),
};
let error = validate_puzzle_clear_sheet_quality(&image, &sheet)
.expect_err("blank cell pollution should be rejected");
assert!(error.body_text().contains("空白格有主体"));
}
#[test]
fn puzzle_clear_sheet_quality_rejects_internal_photo_seam() {
let sheet = puzzle_clear_atlas_sheet_specs()
.into_iter()
.find(|sheet| sheet.sheet_id == "sheet-04")
.expect("sheet exists");
let mut source = RgbaImage::from_pixel(1024, 1536, Rgba([250, 249, 242, 255]));
for row in 0..6u32 {
for col in 0..4u32 {
let base_x = col * PUZZLE_CLEAR_ATLAS_CELL_SIZE;
let base_y = row * PUZZLE_CLEAR_ATLAS_CELL_SIZE;
let color = Rgba([
70u8.saturating_add((row * 23) as u8),
80u8.saturating_add((col * 31) as u8),
160,
255,
]);
for y in base_y + 80..base_y + 176 {
for x in base_x + 80..base_x + 176 {
source.put_pixel(x, y, color);
}
}
}
}
for y in 0..PUZZLE_CLEAR_ATLAS_CELL_SIZE {
for x in 0..PUZZLE_CLEAR_ATLAS_CELL_SIZE / 2 {
source.put_pixel(x, y, Rgba([206, 46, 62, 255]));
}
for x in PUZZLE_CLEAR_ATLAS_CELL_SIZE / 2..PUZZLE_CLEAR_ATLAS_CELL_SIZE {
source.put_pixel(x, y, Rgba([38, 112, 218, 255]));
}
}
let mut encoded = Cursor::new(Vec::new());
image::DynamicImage::ImageRgba8(source)
.write_to(&mut encoded, ImageFormat::Png)
.expect("test image should encode");
let image = DownloadedOpenAiImage {
extension: "png".to_string(),
mime_type: "image/png".to_string(),
bytes: encoded.into_inner(),
};
let error = validate_puzzle_clear_sheet_quality(&image, &sheet)
.expect_err("internal photo seam should be rejected");
assert!(error.body_text().contains("单格内部疑似拼接线"));
}
#[test]
fn puzzle_clear_sheet_generation_retries_only_retryable_upstream_errors() {
let retryable_error = AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "vector-engine",
"message": "上游服务请求失败",
"retryable": true,
}));
assert!(is_retryable_puzzle_clear_sheet_generation_error(
&retryable_error
));
let non_retryable_gateway =
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "vector-engine",
"message": "上游服务请求失败",
"retryable": false,
}));
assert!(!is_retryable_puzzle_clear_sheet_generation_error(
&non_retryable_gateway
));
let bad_request = AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "vector-engine",
"message": "请求参数不合法",
"retryable": true,
}));
assert!(!is_retryable_puzzle_clear_sheet_generation_error(
&bad_request
));
}
#[test]
fn puzzle_clear_board_background_prompt_reveals_theme_goal() {
let prompt = build_puzzle_clear_board_background_prompt("星港花园");