123
This commit is contained in:
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user