@@ -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,18 +699,47 @@ 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 (
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 ( " 文字、Logo、水印、按钮、UI 字、网格线、编号、标签、边框、外轮廓框、白色描边、白色贴纸边、圆角框、阴影框、分隔线、裁切参考线、低清晰度、主体跨格、主体贴边、重复同款小图 " ) ,
Some ( PUZZLE_CLEAR_ATLAS_NEGATIVE_PROMPT ) ,
PUZZLE_CLEAR_ATLAS_GENERATION_SIZE ,
1 ,
& [ ] ,
failure_context . as_str ( ) ,
)
. await ? ;
. 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 (
@@ -712,12 +751,52 @@ async fn maybe_prepare_puzzle_clear_assets_inner(
} ) ) ,
)
} ) ? ;
generated_sheets . push ( P uzzleC learGeneratedSheet {
match validate_p uzzle_c lear_sheet_quality ( & image , & sheet_spec ) {
Ok ( ( ) ) = > {
accepted_sheet = Some ( PuzzleClearGeneratedSheet {
spec : sheet_spec ,
prompt : sheet_prompt ,
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 " : PUZZLE_CLEAR_CREATION_PROVIDER ,
" message " : format ! ( " 拼消消素材 {} 多次生成后仍未得到可切图集。 " , sheet_spec . sheet_id ) ,
} ) ) ,
) ) ;
} ;
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! [ 0 u8 ; width . saturating_mul ( height ) ] ;
let mut foreground_pixels = 0 u32 ;
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 = [ 0 u32 ; 4 ] ;
let mut count = 0 u32 ;
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 = 0 usize ;
let mut strongest_ratio = 0.0 f32 ;
let edge_specs = [
( ( - 1 i32 , 0 i32 ) , 0 usize , 0 usize , 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 = 0 usize ;
let mut total = 0 usize ;
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.0 f32 ;
for x in x_start .. x_end {
let mut strong = 0 u32 ;
let mut total = 0 u32 ;
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 = 0 u32 ;
let mut total = 0 u32 ;
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_GE NER ATION_SIZE ,
build_puzzle_clear_atlas_prompt , build_puzzle_clear_board_background _prompt ,
build_puzzle_clear_draf t , planne d_puzzle_clear_pattern_groups ,
puzzle_clear_atlas_sheet_spec s,
PUZZLE_CLEAR_ATLAS_CELL_SIZE , PUZZLE_CLEAR_ATLAS_ NEG ATIVE_PROMPT ,
PUZZLE_CLEAR_BOARD_BACKGROUND_GENERATION_SIZE , build_puzzle_clear_atlas _prompt ,
build_puzzle_clear_board_background_promp t , buil d_puzzle_clear_draft ,
is_retryable_puzzle_clear_sheet_generation_error , planned_puzzle_clear_pattern_group s,
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 .. 6 u32 {
for col in 0 .. 4 u32 {
let base_x = col * PUZZLE_CLEAR_ATLAS_CELL_SIZE ;
let base_y = row * PUZZLE_CLEAR_ATLAS_CELL_SIZE ;
let color = Rgba ( [
70 u8 . saturating_add ( ( row * 23 ) as u8 ) ,
80 u8 . 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 .. 180 u32 {
for x in 0 .. 180 u32 {
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 .. 6 u32 {
for col in 0 .. 4 u32 {
let base_x = col * PUZZLE_CLEAR_ATLAS_CELL_SIZE ;
let base_y = row * PUZZLE_CLEAR_ATLAS_CELL_SIZE ;
let color = Rgba ( [
70 u8 . saturating_add ( ( row * 23 ) as u8 ) ,
80 u8 . 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 .. 6 u32 {
for col in 0 .. 4 u32 {
let base_x = col * PUZZLE_CLEAR_ATLAS_CELL_SIZE ;
let base_y = row * PUZZLE_CLEAR_ATLAS_CELL_SIZE ;
let color = Rgba ( [
70 u8 . saturating_add ( ( row * 23 ) as u8 ) ,
80 u8 . 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 ( " 星港花园 " ) ;