This commit is contained in:
2026-06-05 22:10:30 +08:00
parent 2a271876ac
commit c98c3de96d
8 changed files with 600 additions and 71 deletions

View File

@@ -59,6 +59,8 @@ const PUZZLE_CLEAR_SHEET_COLUMNS_USIZE: usize = 4;
const PUZZLE_CLEAR_SHEET_ROWS_USIZE: usize = 6;
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_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";
@@ -68,8 +70,10 @@ 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_SHEET_INTERNAL_SEAM_DIFF_THRESHOLD: i32 = 170;
const PUZZLE_CLEAR_SHEET_INTERNAL_SEAM_RATIO_THRESHOLD: f32 = 0.92;
const PUZZLE_CLEAR_SHEET_INTERNAL_SEAM_SIDE_CONTRAST_THRESHOLD: f32 = 145.0;
const PUZZLE_CLEAR_SHEET_INTERNAL_SEAM_SIDE_TEXTURE_MAX: f32 = 36.0;
const PUZZLE_CLEAR_ATLAS_NEGATIVE_PROMPT: &str = "文字、Logo、水印、按钮、UI 字、网格线、编号、标签、边框、外轮廓框、白色描边、白色贴纸边、圆角框、阴影框、分隔线、裁切参考线、单格内部拼接线、内部竖切、内部横切、照片拼贴、相册拼贴、多场景拼贴、双联图、三联图、画中画、单格双图、单格多图、低清晰度、纯色背景、空白背景、白底商品图、孤立主体、单体素材、素材表、图标、贴纸、同品种重复、同一物体多角度、重复同款小图、主体跨格、主体贴边、拼贴、重影、不同图案互相穿插";
pub async fn create_puzzle_clear_session(
@@ -937,7 +941,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 复合图案。相同编号表示玩法分组:请生成一组主题一致的小照片裁片,每个 256 单元都要独立成图,不要把连续区域画成一张横跨多格的大图或照片拼贴"
"A 表示 1x2 复合图案C 表示 2x2 复合图案D 表示 2x3 或 3x2 复合图案。相同编号表示同一视觉家族:请生成一组主题一致、共享同一场景锚点的小照片裁片;同组格子要像同一套连拍或同一场景的不同局部,彼此能看出是同一个故事或场景家族,但每个 256 单元仍需完整可读,不要做成彼此无关的随机独立小图"
),
},
PuzzleClearAtlasSheetSpec {
@@ -958,7 +962,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 复合图案。相同编号表示玩法分组:请生成一组主题一致的小照片裁片,每个 256 单元都要独立成图,不要把连续区域画成一张横跨多格的大图或照片拼贴"
"A 表示 1x2 复合图案B 表示 1x3 或 3x1 复合图案C 表示 2x2 复合图案D 表示 2x3 或 3x2 复合图案。相同编号表示同一视觉家族:请生成一组主题一致、共享同一场景锚点的小照片裁片;同组格子要像同一套连拍或同一场景的不同局部,彼此能看出是同一个故事或场景家族,但每个 256 单元仍需完整可读,不要做成彼此无关的随机独立小图"
),
},
PuzzleClearAtlasSheetSpec {
@@ -969,7 +973,7 @@ fn puzzle_clear_atlas_sheet_specs() -> Vec<PuzzleClearAtlasSheetSpec> {
["B02", "B04", "A07", "A07"],
["B05", "B05", "B05", "A08"],
["A09", "A09", "A10", "A08"],
["A11", "A11", "A10", "."],
["A11", "A11", "A10", PUZZLE_CLEAR_SHEET_FILLER_CELL],
],
layout_prompt: concat!(
"本张 sheet 的布局如下,编号只给后台理解,绝对不要画在图中:\n\n",
@@ -978,8 +982,8 @@ fn puzzle_clear_atlas_sheet_specs() -> Vec<PuzzleClearAtlasSheetSpec> {
"第3行B02 B04 A07 A07\n",
"第4行B05 B05 B05 A08\n",
"第5行A09 A09 A10 A08\n",
"第6行A11 A11 A10 空白\n\n",
"A 表示 1x2 复合图案B 表示 1x3 或 3x1 复合图案。空白格保持干净浅色背景,不要生成图案。相同编号表示玩法分组:请生成一组主题一致的小照片裁片,每个 256 单元都要独立成图,不要把连续区域画成一张横跨多格的大图或照片拼贴"
"第6行A11 A11 A10 FILL\n\n",
"A 表示 1x2 复合图案B 表示 1x3 或 3x1 复合图案。FILL 是后台会丢弃的补位格,请画成主题一致但不参与玩法的小照片裁片,不要写字或编号。相同编号表示同一视觉家族:请生成一组主题一致、共享同一场景锚点的小照片裁片;同组格子要像同一套连拍或同一场景的不同局部,彼此能看出是同一个故事或场景家族,但每个 256 单元仍需完整可读,不要做成彼此无关的随机独立小图"
),
},
PuzzleClearAtlasSheetSpec {
@@ -1000,7 +1004,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 用色调、道具和背景线索互相呼应每个 256 单元都要独立成图,不要把连续区域画成一张横跨两格的大图或照片拼贴"
"A 表示 1x2 复合图案。相同编号表示同一视觉家族:横向 1x2 和纵向 1x2 要共享同一场景锚点,用色调、道具和背景线索互相呼应每个 256 单元仍需完整可读,但不要做成彼此无关的随机独立小图"
),
},
]
@@ -1015,12 +1019,12 @@ fn build_puzzle_clear_atlas_prompt(
concat!(
"生成一张拼消消素材工作表,主题是「{subject}」,竖版 1024x1536。\n\n",
"这张图供程序后台按 4 列 x 6 行裁切,每个裁切单元为 256x256 的正方形。4x6 网格只用于后台理解,画面中绝对不要画出网格线、切分线、边框、编号或坐标。\n\n",
"这不是单个物体素材表,而是一组照片式构图、绘本式渲染的主题微场景拼图卡。每个编号区域必须有明确背景、环境、道具、光影和构图线索,像从一张丰富照片或插画中裁出的局部。\n\n",
"每个 256x256 单元本身就是一张完整的单场景照片裁片,单元内部只能有一个连续画面;禁止在一个单元内部出现两张照片、两个不同场景、拼接线、分割线、内部竖切、内部横切、左右/上下两块不同背景。场景变化只能发生在 256 单元边界上。\n\n",
"相同编号连续占据的格子表示玩法上的同组关系,不是要求把一张大图横跨多个格子。请把同编号区域画成色调、道具、背景线索互相呼应的一组小照片裁片;每个格子独立查看时都必须完整成图,不能在单格内部再切出第二张图或第二个场景。\n\n",
"同一张 sheet 内,不同编号必须使用不同视觉概念,不要把同一种主体换角度、换大小、换姿势后重复使用。比如主题是水果时,不要重复生成不同角度的葡萄、菠萝、西瓜、橙子;应扩展为果园、集市摊位、野餐布、果汁杯、厨房案板、甜品盘、篮筐、玻璃罐、窗边餐桌、花园背景等不同场景。\n\n",
"这不是单个物体素材表,而是一组照片式构图、绘本式渲染的主题微场景拼图卡。每个编号区域都要属于同一视觉家族,必须有明确背景、环境、道具、光影和构图线索,像从同一组丰富照片或插画中裁出的局部。\n\n",
"每个 256x256 单元本身就是一张完整的单场景照片裁片,单元内部只能有一个连续画面;同组之间要共享同一场景锚点、主色和道具语言。禁止在一个单元内部出现两张照片、两个不同场景、拼接线、分割线、内部竖切、内部横切、左右/上下两块不同背景。场景变化只能发生在 256 单元边界上。\n\n",
"相同编号连续占据的格子表示同一视觉家族,不是随机独立小图。请把同编号区域画成一组可辨认的兄弟卡片,至少共享一个明显场景锚点(同一张桌子、同一窗景、同一庭院、同一篮子或同一器物系统);每个格子可以展示这个家族的不同局部、视角或连贯片段,但仍需完整可读,不能在单格内部再切出第二张图或第二个场景。\n\n",
"同一张 sheet 内,不同编号必须使用不同视觉概念,并且拉开主色、场景和道具,不要把同一种主体换角度、换大小、换姿势后重复使用。比如主题是水果时,不要重复生成不同角度的葡萄、菠萝、西瓜、橙子;应扩展为果园、集市摊位、野餐布、果汁杯、厨房案板、甜品盘、篮筐、玻璃罐、窗边餐桌、花园背景等不同场景。\n\n",
"每个 256x256 单元独立查看时,都应该有可辨识的局部信息:可以包含主体局部、背景纹理、桌面、草地、天空、建筑、布料、器皿、叶片、阴影或装饰元素。不要让小卡只有一个孤立主体加纯色背景。\n\n",
"不同编号区域之间保持干净边界,主体不能越界或挤入相邻编号区域;空白格必须保持干净浅色背景,不要出现任何图案碎片\n\n",
"不同编号区域之间保持干净边界,主体不能越界或挤入相邻编号区域;FILL 补位格可以生成主题一致的小照片裁片,但后台会丢弃它,不要在 FILL 中写字、编号或画规则说明\n\n",
"图案不要做成商品素材表、卡牌、贴纸、图标格子或带框小卡片。不能有外轮廓框、白色描边、圆角框、阴影框、分隔线、参考线或贴纸边。\n\n",
"画风为高清、清爽、适合休闲消除游戏的丰富主题插画颜色鲜明边缘干净不能出现文字、Logo、水印、按钮、UI 或教程元素。\n\n",
"{layout_prompt}"
@@ -1208,12 +1212,15 @@ fn validate_puzzle_clear_sheet_quality(
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 group_id == PUZZLE_CLEAR_SHEET_UNUSED_CELL {
if quality.foreground_ratio > PUZZLE_CLEAR_SHEET_BLANK_MAX_FOREGROUND_RATIO {
findings.push(format!("{cell_label} 空白格有主体"));
}
continue;
}
if group_id == PUZZLE_CLEAR_SHEET_FILLER_CELL {
continue;
}
if quality.foreground_ratio < PUZZLE_CLEAR_SHEET_MIN_FOREGROUND_RATIO {
findings.push(format!("{cell_label} 主体过少"));
@@ -1463,7 +1470,14 @@ fn measure_puzzle_clear_sheet_internal_seam(
total = total.saturating_add(1);
}
if total > 0 {
strongest = strongest.max(strong as f32 / total as f32);
let line_ratio = strong as f32 / total as f32;
if line_ratio > PUZZLE_CLEAR_SHEET_INTERNAL_SEAM_RATIO_THRESHOLD
&& puzzle_clear_sheet_internal_seam_has_flat_split(
source, bounds, x, true, x_start, x_end, y_start, y_end,
)
{
strongest = strongest.max(line_ratio);
}
}
}
@@ -1481,19 +1495,135 @@ fn measure_puzzle_clear_sheet_internal_seam(
total = total.saturating_add(1);
}
if total > 0 {
strongest = strongest.max(strong as f32 / total as f32);
let line_ratio = strong as f32 / total as f32;
if line_ratio > PUZZLE_CLEAR_SHEET_INTERNAL_SEAM_RATIO_THRESHOLD
&& puzzle_clear_sheet_internal_seam_has_flat_split(
source, bounds, y, false, x_start, x_end, y_start, y_end,
)
{
strongest = strongest.max(line_ratio);
}
}
}
strongest
}
fn puzzle_clear_sheet_internal_seam_has_flat_split(
source: &image::DynamicImage,
bounds: PuzzleClearSheetCellBounds,
split: u32,
vertical: bool,
x_start: u32,
x_end: u32,
y_start: u32,
y_end: u32,
) -> bool {
// 中文注释:富场景照片里常有窗框、桌沿、地平线等贯穿强边,只有两侧都近似人工平铺色块时才按拼贴硬失败。
let band = (bounds.width().min(bounds.height()) / 10).clamp(14, 28);
let (first, second) = if vertical {
let left_start = split.saturating_sub(band).max(x_start);
let left_end = split.saturating_sub(2).max(left_start);
let right_start = split.saturating_add(2).min(x_end);
let right_end = split.saturating_add(band).min(x_end).max(right_start);
(
puzzle_clear_rgb_stats_for_region(source, left_start, left_end, y_start, y_end),
puzzle_clear_rgb_stats_for_region(source, right_start, right_end, y_start, y_end),
)
} else {
let top_start = split.saturating_sub(band).max(y_start);
let top_end = split.saturating_sub(2).max(top_start);
let bottom_start = split.saturating_add(2).min(y_end);
let bottom_end = split.saturating_add(band).min(y_end).max(bottom_start);
(
puzzle_clear_rgb_stats_for_region(source, x_start, x_end, top_start, top_end),
puzzle_clear_rgb_stats_for_region(source, x_start, x_end, bottom_start, bottom_end),
)
};
if first.count == 0 || second.count == 0 {
return false;
}
let side_contrast = puzzle_clear_rgb_stats_distance(first, second);
let side_texture = first.texture().max(second.texture());
side_contrast >= PUZZLE_CLEAR_SHEET_INTERNAL_SEAM_SIDE_CONTRAST_THRESHOLD
&& side_texture <= PUZZLE_CLEAR_SHEET_INTERNAL_SEAM_SIDE_TEXTURE_MAX
}
#[derive(Clone, Copy, Debug, Default)]
struct PuzzleClearRgbStats {
count: u64,
sum: [u64; 3],
sum_square: [u64; 3],
}
impl PuzzleClearRgbStats {
fn push(&mut self, pixel: [u8; 4]) {
self.count = self.count.saturating_add(1);
for (index, channel) in pixel.iter().take(3).enumerate() {
let value = *channel as u64;
self.sum[index] = self.sum[index].saturating_add(value);
self.sum_square[index] = self.sum_square[index].saturating_add(value * value);
}
}
fn mean_channel(self, index: usize) -> f32 {
if self.count == 0 {
return 0.0;
}
self.sum[index] as f32 / self.count as f32
}
fn texture(self) -> f32 {
if self.count == 0 {
return f32::MAX;
}
let mut variance_sum = 0.0f32;
for index in 0..3 {
let mean = self.mean_channel(index);
let mean_square = self.sum_square[index] as f32 / self.count as f32;
variance_sum += (mean_square - mean * mean).max(0.0);
}
variance_sum.sqrt()
}
}
fn puzzle_clear_rgb_stats_for_region(
source: &image::DynamicImage,
x0: u32,
x1: u32,
y0: u32,
y1: u32,
) -> PuzzleClearRgbStats {
let mut stats = PuzzleClearRgbStats::default();
if x0 >= x1 || y0 >= y1 {
return stats;
}
for y in (y0..y1).step_by(4) {
for x in (x0..x1).step_by(4) {
stats.push(source.get_pixel(x, y).0);
}
}
stats
}
fn puzzle_clear_rgb_stats_distance(left: PuzzleClearRgbStats, right: PuzzleClearRgbStats) -> f32 {
(0..3)
.map(|index| (left.mean_channel(index) - right.mean_channel(index)).abs())
.sum()
}
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 is_puzzle_clear_sheet_discarded_cell(group_id: &str) -> bool {
group_id == PUZZLE_CLEAR_SHEET_UNUSED_CELL || group_id == PUZZLE_CLEAR_SHEET_FILLER_CELL
}
fn puzzle_clear_sheet_neighbor_is_same_group(
sheet_spec: &PuzzleClearAtlasSheetSpec,
row: u32,
@@ -1502,7 +1632,7 @@ fn puzzle_clear_sheet_neighbor_is_same_group(
col_delta: i32,
) -> bool {
let current = sheet_spec.layout[row as usize][col as usize];
if current == "." {
if is_puzzle_clear_sheet_discarded_cell(current) {
return false;
}
let neighbor_row = row as i32 + row_delta;
@@ -1543,7 +1673,7 @@ fn slice_puzzle_clear_sheet(
let mut cells_by_group: BTreeMap<&str, Vec<(u32, u32)>> = BTreeMap::new();
for (row, cells) in sheet_spec.layout.iter().enumerate() {
for (col, group_id) in cells.iter().enumerate() {
if *group_id == "." {
if is_puzzle_clear_sheet_discarded_cell(group_id) {
continue;
}
cells_by_group
@@ -2072,10 +2202,12 @@ fn build_puzzle_clear_public_work_code(profile_id: &str) -> String {
mod tests {
use super::{
PUZZLE_CLEAR_ATLAS_CELL_SIZE, PUZZLE_CLEAR_ATLAS_NEGATIVE_PROMPT,
PUZZLE_CLEAR_BOARD_BACKGROUND_GENERATION_SIZE, build_puzzle_clear_atlas_prompt,
PUZZLE_CLEAR_BOARD_BACKGROUND_GENERATION_SIZE, PUZZLE_CLEAR_SHEET_FILLER_CELL,
PUZZLE_CLEAR_SHEET_UNUSED_CELL, PuzzleClearAtlasSheetSpec, 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,
is_puzzle_clear_sheet_discarded_cell, 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;
@@ -2105,15 +2237,18 @@ mod tests {
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("FILL 补位格可以生成主题一致的小照片裁片"));
assert!(prompt.contains("后台会丢弃它"));
assert!(prompt.contains("图案不要做成商品素材表、卡牌、贴纸、图标格子或带框小卡片"));
assert!(prompt.contains("外轮廓框"));
assert!(prompt.contains("贴纸边"));
@@ -2139,16 +2274,26 @@ mod tests {
fn puzzle_clear_sheet_plan_matches_reduced_asset_strategy() {
let sheets = puzzle_clear_atlas_sheet_specs();
let groups = planned_puzzle_clear_pattern_groups();
let sheet_cells = sheets
let occupied_sheet_cells = sheets
.iter()
.flat_map(|sheet| sheet.layout.iter().flatten())
.filter(|group_id| **group_id != ".")
.filter(|group_id| **group_id != PUZZLE_CLEAR_SHEET_UNUSED_CELL)
.count();
let playable_sheet_cells = sheets
.iter()
.flat_map(|sheet| sheet.layout.iter().flatten())
.filter(|group_id| !is_puzzle_clear_sheet_discarded_cell(group_id))
.count();
let filler_sheet_cells = sheets
.iter()
.flat_map(|sheet| sheet.layout.iter().flatten())
.filter(|group_id| **group_id == PUZZLE_CLEAR_SHEET_FILLER_CELL)
.count();
let mut sheet_cells_by_group = std::collections::BTreeMap::<&str, u32>::new();
for group_id in sheets
.iter()
.flat_map(|sheet| sheet.layout.iter().flatten())
.filter(|group_id| **group_id != ".")
.filter(|group_id| !is_puzzle_clear_sheet_discarded_cell(group_id))
{
*sheet_cells_by_group.entry(*group_id).or_default() += 1;
}
@@ -2159,7 +2304,9 @@ mod tests {
assert_eq!(sheets.len(), 4);
assert_eq!(groups.len(), 35);
assert_eq!(sheet_cells, 95);
assert_eq!(occupied_sheet_cells, 96);
assert_eq!(playable_sheet_cells, 95);
assert_eq!(filler_sheet_cells, 1);
assert_eq!(group_cells, 95);
assert_eq!(PUZZLE_CLEAR_ATLAS_CELL_SIZE, 256);
assert_eq!(sheet_cells_by_group.len(), groups.len());
@@ -2216,12 +2363,88 @@ mod tests {
.expect("edge contact is advisory because generated sheets often touch borders");
}
fn build_test_puzzle_clear_sheet_image_with_cell_pollution(
row: u32,
col: u32,
) -> DownloadedOpenAiImage {
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);
}
}
}
}
let cell_y0 = row * PUZZLE_CLEAR_ATLAS_CELL_SIZE;
let cell_x0 = col * PUZZLE_CLEAR_ATLAS_CELL_SIZE;
for y in cell_y0 + 40..cell_y0 + PUZZLE_CLEAR_ATLAS_CELL_SIZE - 40 {
for x in cell_x0 + 40..cell_x0 + 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");
DownloadedOpenAiImage {
extension: "png".to_string(),
mime_type: "image/png".to_string(),
bytes: encoded.into_inner(),
}
}
#[test]
fn puzzle_clear_sheet_quality_rejects_blank_cell_pollution() {
fn puzzle_clear_sheet_quality_allows_filler_cell_pollution() {
let sheet = puzzle_clear_atlas_sheet_specs()
.into_iter()
.find(|sheet| sheet.sheet_id == "sheet-03")
.expect("sheet exists");
let image = build_test_puzzle_clear_sheet_image_with_cell_pollution(5, 3);
validate_puzzle_clear_sheet_quality(&image, &sheet)
.expect("filler cell is generated only to stabilize the sheet and is discarded later");
}
#[test]
fn puzzle_clear_sheet_quality_rejects_blank_cell_pollution() {
let sheet = PuzzleClearAtlasSheetSpec {
sheet_id: "blank-test",
layout: [
[PUZZLE_CLEAR_SHEET_UNUSED_CELL, "A01", "A01", "A02"],
["A03", "A03", "A04", "A04"],
["A05", "A05", "A06", "A06"],
["A07", "A07", "A08", "A08"],
["A09", "A09", "A10", "A10"],
["A11", "A11", "A12", "A12"],
],
layout_prompt: "test",
};
let image = build_test_puzzle_clear_sheet_image_with_cell_pollution(0, 0);
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_allows_textured_scene_divider() {
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 {
@@ -2241,10 +2464,27 @@ mod tests {
}
}
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]));
for y in 0..PUZZLE_CLEAR_ATLAS_CELL_SIZE {
for x in 0..PUZZLE_CLEAR_ATLAS_CELL_SIZE {
let noise = ((x * 17 + y * 31) % 120) as u8;
let color = if x < PUZZLE_CLEAR_ATLAS_CELL_SIZE / 2 {
Rgba([
72u8.saturating_add(noise),
88u8.saturating_add(((x * 7 + y * 11) % 96) as u8),
112u8.saturating_add(((x * 5 + y * 13) % 72) as u8),
255,
])
} else {
Rgba([
104u8.saturating_add(((x * 19 + y * 3) % 92) as u8),
76u8.saturating_add(noise),
56u8.saturating_add(((x * 11 + y * 23) % 88) as u8),
255,
])
};
source.put_pixel(x, y, color);
}
source.put_pixel(PUZZLE_CLEAR_ATLAS_CELL_SIZE / 2, y, Rgba([24, 24, 24, 255]));
}
let mut encoded = Cursor::new(Vec::new());
@@ -2257,9 +2497,8 @@ mod tests {
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("空白格有主体"));
validate_puzzle_clear_sheet_quality(&image, &sheet)
.expect("textured photo-like scene divider should not be rejected as collage");
}
#[test]