From 95df62fc82afe9550b04a340d20eabb186845f8d Mon Sep 17 00:00:00 2001 From: Linghong Date: Wed, 10 Jun 2026 19:49:39 +0800 Subject: [PATCH] =?UTF-8?q?=E8=B7=B3=E4=B8=80=E8=B7=B3UV=E9=9D=A2=E5=88=87?= =?UTF-8?q?=E5=88=86=E6=94=B9=E7=94=A8blob+gradient=E8=87=AA=E9=80=82?= =?UTF-8?q?=E5=BA=94=E7=AE=97=E6=B3=95=20=E9=87=8D=E6=9E=84alpha.rs?= =?UTF-8?q?=E6=B4=8B=E7=BA=A2=E5=8E=BB=E8=83=8C=E9=A2=84=E8=AE=BE=E5=8F=82?= =?UTF-8?q?=E6=95=B0=20=E6=96=B0=E5=A2=9Ejump=5Fhop=5Fatlas=5Fslicing.rs?= =?UTF-8?q?=E7=8B=AC=E7=AB=8B=E5=88=87=E5=9B=BE=E6=A8=A1=E5=9D=97=20?= =?UTF-8?q?=E4=BF=AE=E5=A4=8Djump=5Fhop.rs=E8=B0=83=E7=94=A8=E9=93=BE?= =?UTF-8?q?=E6=8E=A5=E5=85=A5=E6=96=B0=E5=88=87=E5=9B=BE=E7=AE=97=E6=B3=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server-rs/crates/api-server/src/jump_hop.rs | 1009 +++++++++++++++-- .../api-server/src/jump_hop_atlas_slicing.rs | 928 +++++++++++++++ server-rs/crates/api-server/src/main.rs | 1 + .../src/generated_asset_sheets/alpha.rs | 12 +- 4 files changed, 1826 insertions(+), 124 deletions(-) create mode 100644 server-rs/crates/api-server/src/jump_hop_atlas_slicing.rs diff --git a/server-rs/crates/api-server/src/jump_hop.rs b/server-rs/crates/api-server/src/jump_hop.rs index 015a510e..e3618c67 100644 --- a/server-rs/crates/api-server/src/jump_hop.rs +++ b/server-rs/crates/api-server/src/jump_hop.rs @@ -51,20 +51,21 @@ use crate::{ const JUMP_HOP_TILE_ITEM_COUNT: usize = 18; const JUMP_HOP_PROVIDER: &str = "jump-hop"; -const JUMP_HOP_CREATION_PROVIDER: &str = "jump-hop-creation"; +pub(crate) const JUMP_HOP_CREATION_PROVIDER: &str = "jump-hop-creation"; const JUMP_HOP_RUNTIME_PROVIDER: &str = "jump-hop-runtime"; const JUMP_HOP_TEMPLATE_ID: &str = "jump-hop"; const JUMP_HOP_TEMPLATE_NAME: &str = "跳一跳"; const JUMP_HOP_RUNTIME_RUNS_ROUTE: &str = "/api/runtime/jump-hop/runs"; -const JUMP_HOP_TILE_ATLAS_ROWS: u32 = 6; -const JUMP_HOP_TILE_ATLAS_COLS: u32 = 3; -const JUMP_HOP_TILE_UV_FACE_ROWS: u32 = 3; -const JUMP_HOP_TILE_UV_FACE_COLS: u32 = 4; -const JUMP_HOP_TILE_ATLAS_KEY_HEX: &str = "#FF00FF"; -const JUMP_HOP_TILE_ATLAS_IMAGE_SIZE: &str = "1024*1536"; -const JUMP_HOP_TILE_ATLAS_IMAGE_WIDTH: u32 = 1024; -const JUMP_HOP_TILE_ATLAS_IMAGE_HEIGHT: u32 = 1536; -const JUMP_HOP_TILE_TEXTURE_OUTPUT_SIZE: u32 = 256; +#[allow(dead_code)] +pub(crate) const JUMP_HOP_TILE_ATLAS_ROWS: u32 = 6; +pub(crate) const JUMP_HOP_TILE_ATLAS_COLS: u32 = 3; +pub(crate) const JUMP_HOP_TILE_UV_FACE_ROWS: u32 = 3; +pub(crate) const JUMP_HOP_TILE_UV_FACE_COLS: u32 = 4; +pub(crate) const JUMP_HOP_TILE_ATLAS_KEY_HEX: &str = "#FF00FF"; +pub(crate) const JUMP_HOP_TILE_ATLAS_IMAGE_SIZE: &str = "1024*1536"; +pub(crate) const JUMP_HOP_TILE_ATLAS_IMAGE_WIDTH: u32 = 1024; +pub(crate) const JUMP_HOP_TILE_ATLAS_IMAGE_HEIGHT: u32 = 1536; +pub(crate) const JUMP_HOP_TILE_TEXTURE_OUTPUT_SIZE: u32 = 256; const JUMP_HOP_BACKGROUND_IMAGE_SIZE: &str = "1024*1536"; const JUMP_HOP_BACKGROUND_IMAGE_WIDTH: u32 = 1024; const JUMP_HOP_BACKGROUND_IMAGE_HEIGHT: u32 = 1536; @@ -73,27 +74,27 @@ const JUMP_HOP_BACK_BUTTON_IMAGE_WIDTH: u32 = 1024; const JUMP_HOP_BACK_BUTTON_IMAGE_HEIGHT: u32 = 1024; #[derive(Clone, Debug, PartialEq, Eq)] -struct JumpHopTileAtlasSlice { - tile_type: JumpHopTileType, - source_atlas_cell: String, - faces: JumpHopTileFaceSlices, +pub(crate) struct JumpHopTileAtlasSlice { + pub(crate) tile_type: JumpHopTileType, + pub(crate) source_atlas_cell: String, + pub(crate) faces: JumpHopTileFaceSlices, } #[derive(Clone, Debug, PartialEq, Eq)] -struct JumpHopTileFaceSlice { - face: JumpHopTileFaceKey, - source_atlas_cell: String, - bytes: Vec, +pub(crate) struct JumpHopTileFaceSlice { + pub(crate) face: JumpHopTileFaceKey, + pub(crate) source_atlas_cell: String, + pub(crate) bytes: Vec, } #[derive(Clone, Debug, PartialEq, Eq)] -struct JumpHopTileFaceSlices { - top: JumpHopTileFaceSlice, - front: JumpHopTileFaceSlice, - right: JumpHopTileFaceSlice, - back: JumpHopTileFaceSlice, - left: JumpHopTileFaceSlice, - bottom: JumpHopTileFaceSlice, +pub(crate) struct JumpHopTileFaceSlices { + pub(crate) top: JumpHopTileFaceSlice, + pub(crate) front: JumpHopTileFaceSlice, + pub(crate) right: JumpHopTileFaceSlice, + pub(crate) back: JumpHopTileFaceSlice, + pub(crate) left: JumpHopTileFaceSlice, + pub(crate) bottom: JumpHopTileFaceSlice, } pub async fn create_jump_hop_session( @@ -759,7 +760,21 @@ async fn maybe_generate_jump_hop_assets( })), ) })?; - let tile_slices = slice_jump_hop_tile_atlas(&tile_image).map_err(|error| { + // 去除 AI 生图中可能混入主体区域或边缘的洋红(#FF00FF)背景,避免污染后续切片与贴图。 + let tile_image = prepare_jump_hop_magenta_screen_image_for_slicing( + tile_image, + "跳一跳地板贴图图集洋红去背失败", + ) + .map_err(|error| { + jump_hop_error_response(request_context, JUMP_HOP_CREATION_PROVIDER, error) + })?; + let tile_slices = crate::jump_hop_atlas_slicing::slice_tile_atlas_adaptive( + &tile_image, + crate::jump_hop_atlas_slicing::DEFAULT_TILE_ROWS, + crate::jump_hop_atlas_slicing::DEFAULT_TILE_COLS, + crate::jump_hop_atlas_slicing::AtlasSliceAlgorithm::default(), + ) + .map_err(|error| { jump_hop_error_response(request_context, JUMP_HOP_CREATION_PROVIDER, error) })?; let tile_atlas_asset = persist_jump_hop_generated_image_asset( @@ -841,6 +856,40 @@ fn prepare_jump_hop_green_screen_image_for_persist( }) } +/// 在图集切片之前,用 BFS 漫水 + 软 matte 扩展 + 去彩边算法去除洋红(#FF00FF)背景。 +/// 算法与绿幕去背共享同一套 `remove_generated_asset_sheet_green_screen_background` 核心, +/// 通过 `jump_hop_magenta_screen()` 预设切换 key 色为洋红,并启用内部镂空洞检测。 +fn prepare_jump_hop_magenta_screen_image_for_slicing( + image: crate::openai_image_generation::DownloadedOpenAiImage, + failure_label: &str, +) -> Result { + let source = image::load_from_memory(image.bytes.as_slice()).map_err(|error| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": JUMP_HOP_CREATION_PROVIDER, + "message": format!("{failure_label}解码失败:{error}"), + })) + })?; + let processed = crate::generated_asset_sheets::apply_generated_asset_sheet_alpha_with_options( + source, + crate::generated_asset_sheets::GeneratedAssetSheetAlphaOptions::jump_hop_magenta_screen(), + ); + let mut encoded = std::io::Cursor::new(Vec::new()); + processed + .write_to(&mut encoded, image::ImageFormat::Png) + .map_err(|error| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": JUMP_HOP_CREATION_PROVIDER, + "message": format!("{failure_label}洋红去背编码失败:{error}"), + })) + })?; + + Ok(crate::openai_image_generation::DownloadedOpenAiImage { + bytes: encoded.into_inner(), + mime_type: "image/png".to_string(), + extension: "png".to_string(), + }) +} + fn normalize_jump_hop_generation_theme_text(theme_text: &str) -> String { let theme_text = theme_text.trim(); if theme_text.is_empty() { @@ -915,13 +964,43 @@ fn build_jump_hop_tile_atlas_prompt(theme_text: &str, tile_prompt: &str) -> Stri sanitized_tile_prompt.as_str() }; + // 旧版 prompt(保留供参考) + // format!( + // "生成一张1024x1536竖版图片,主题为"{theme_text}"。\n画面只包含18个用于跳一跳地板的立方体主题物体 UV 展开包装图,按三列六行均匀排布;每个大单元格代表一个完整的 1x1x1 立方体方块物体,运行态会把该单元内的六张面贴图精确贴到 Three.js 标准极小倒角立方体的六个面上。\n画面内容是{subject_text}。这是一张 cube object UV unwrap atlas / 立方体主题物体六面展开图集,不是单纯平铺材质、不是抽象纹理、不是只把主题颜色铺满,也不是已经渲染好的 3D 方块成品、游戏界面或图标集页面。\n每个大单元格内部必须使用固定 4列x3行 UV 展开结构,只有以下六个位置有贴图,其它位置保持纯洋红安全色:第1行第2列是 top;第2行第1列是 left;第2行第2列是 front;第2行第3列是 right;第2行第4列是 back;第3行第2列是 bottom。不要改变顺序,不要旋转面,不要把六个面画成一张连续透视图。\n每个方块都必须表现为"一个完整主题物体被塑造成 1x1x1 立方体后的六面包装",六个面要属于同一个物体并能组合成完整方块造型;top/front/right/back/left/bottom 之间的颜色、边缘纹理、切面、果皮、籽点、条纹、果柄和叶片必须连续一致,不能六面各画互不相关的图案,也不能把同一张纹理重复六次。\n水果主题要生成18种可一眼辨认的方块水果 UV:方块苹果、方块香蕉、方块橙子、方块西瓜、方块草莓、方块葡萄、方块奇异果、方块菠萝、方块柠檬、方块桃子、方块梨、方块蓝莓、方块芒果、方块椰子、方块火龙果、方块樱桃、方块哈密瓜、方块石榴等;苹果需要果柄叶片跨 top/front,香蕉需要剥皮条带跨 front/right,橙子需要放射切面跨 top/front,西瓜需要红瓤黑籽和绿皮条纹在各面连续。不要只画重复果皮纹理、随机斑点、叶脉纹理或抽象材质。\n每个面都是满版不透明正方形贴图 / full-bleed opaque square face texture:四角、边缘和中心都要有可识别内容,不留透明、不留空白、不留实底背景;允许大面积水果切面、果柄叶片、剥皮条带、籽点、条纹和轮廓图案作为包装身份锚点,但不要把一个小水果、小叶片、小石头或小物体放在面中央,也不要画小贴纸、小图标、徽章或孤立主体。\n这不是透视渲染图:不要画摄像机视角、透视块、已烘焙侧壁、已烘焙厚度、自身投影、接触阴影或高光光斑;真实透视、倒角、侧壁和阴影会由运行态 Three.js 统一生成。每个面贴图在运行态会以约45度下压视角和较小尺寸显示,所以必须使用大色块、高对比、粗线条和简单图形,保证在64x64缩略图里仍能分辨主题物体身份。\n排布必须安全:18个大单元格之间应留有纯洋红 {JUMP_HOP_TILE_ATLAS_KEY_HEX} 填充的区域作为自然间距,相邻单元格的面贴图不得互相紧贴或交叠;大单元之间、UV 空位、六面之间和画布最外圈只能使用单一纯洋红 {JUMP_HOP_TILE_ATLAS_KEY_HEX} 大面积填充,作为切图安全色。洋红区域以块状面视觉呈现,不要画网格线、描边、边框、单元格编号、face label 或裁切标记。\n贴图内部可以使用绿色、白色、雪地、云朵、草地、花朵、果肉粉色和浅黄色等主题颜色,但不得使用接近 {JUMP_HOP_TILE_ATLAS_KEY_HEX} 的纯洋红色;贴图边缘不得有洋红描边、紫色底边、粉色脏边、彩色光晕或发光边。\n禁止文字、Logo、水印、UI按钮、标题、角标、装饰边框、face label、top/front/right/back/left/bottom文字、背包、装备栏、菜单、角色、完整场景、自然圆形水果、自然长条香蕉、非方块化完整水果、孤立水果照片、小型果切贴纸、小型橙片贴纸、小贴纸图标、小物体居中、纯果皮材质、纯果肉纹理、纯叶脉纹理、纯颜色块、透明背景、留白、3D平台、圆台、底座、托盘、物体摆在平台上、透视地块、正面30度物体图、鸟瞰地图块、落地投影、接触阴影、方形阴影、白底、灰底、黑底。\nEnglish guardrail: one vertical 1024x1536 image, exactly 18 cube object UV unwraps in a 3 columns by 6 rows atlas; each large cell is one complete cube object skin with a fixed 4x3 UV net: row1 col2 top, row2 col1 left, row2 co... (line truncated to 2000 chars) + // ) + + // 新版 prompt:先说明布局与元素,最后说明背景填充 format!( - "生成一张1024x1536竖版图片,主题为“{theme_text}”。\n画面只包含18个用于跳一跳地板的立方体主题物体 UV 展开包装图,按三列六行均匀排布;每个大单元格代表一个完整的 1x1x1 立方体方块物体,运行态会把该单元内的六张面贴图精确贴到 Three.js 标准极小倒角立方体的六个面上。\n画面内容是{subject_text}。这是一张 cube object UV unwrap atlas / 立方体主题物体六面展开图集,不是单纯平铺材质、不是抽象纹理、不是只把主题颜色铺满,也不是已经渲染好的 3D 方块成品、游戏界面或图标集页面。\n每个大单元格内部必须使用固定 4列x3行 UV 展开结构,只有以下六个位置有贴图,其它位置保持纯洋红安全色:第1行第2列是 top;第2行第1列是 left;第2行第2列是 front;第2行第3列是 right;第2行第4列是 back;第3行第2列是 bottom。不要改变顺序,不要旋转面,不要把六个面画成一张连续透视图。\n每个方块都必须表现为“一个完整主题物体被塑造成 1x1x1 立方体后的六面包装”,六个面要属于同一个物体并能组合成完整方块造型;top/front/right/back/left/bottom 之间的颜色、边缘纹理、切面、果皮、籽点、条纹、果柄和叶片必须连续一致,不能六面各画互不相关的图案,也不能把同一张纹理重复六次。\n水果主题要生成18种可一眼辨认的方块水果 UV:方块苹果、方块香蕉、方块橙子、方块西瓜、方块草莓、方块葡萄、方块奇异果、方块菠萝、方块柠檬、方块桃子、方块梨、方块蓝莓、方块芒果、方块椰子、方块火龙果、方块樱桃、方块哈密瓜、方块石榴等;苹果需要果柄叶片跨 top/front,香蕉需要剥皮条带跨 front/right,橙子需要放射切面跨 top/front,西瓜需要红瓤黑籽和绿皮条纹在各面连续。不要只画重复果皮纹理、随机斑点、叶脉纹理或抽象材质。\n每个面都是满版不透明正方形贴图 / full-bleed opaque square face texture:四角、边缘和中心都要有可识别内容,不留透明、不留空白、不留实底背景;允许大面积水果切面、果柄叶片、剥皮条带、籽点、条纹和轮廓图案作为包装身份锚点,但不要把一个小水果、小叶片、小石头或小物体放在面中央,也不要画小贴纸、小图标、徽章或孤立主体。\n这不是透视渲染图:不要画摄像机视角、透视块、已烘焙侧壁、已烘焙厚度、自身投影、接触阴影或高光光斑;真实透视、倒角、侧壁和阴影会由运行态 Three.js 统一生成。每个面贴图在运行态会以约45度下压视角和较小尺寸显示,所以必须使用大色块、高对比、粗线条和简单图形,保证在64x64缩略图里仍能分辨主题物体身份。\n排布必须安全:18个大单元格必须完整落在自己的三列六行网格内,不能跨格、贴边串色或进入相邻方块;大单元之间、UV 空位、六面之间和画布最外圈只能使用单一纯洋红 {JUMP_HOP_TILE_ATLAS_KEY_HEX} 作为切图安全色,允许极细纯洋红安全缝,但不要画可见网格线、边框、编号、face label 或裁切标记。\n贴图内部可以使用绿色、白色、雪地、云朵、草地、花朵、果肉粉色和浅黄色等主题颜色,但不得使用接近 {JUMP_HOP_TILE_ATLAS_KEY_HEX} 的纯洋红色;贴图边缘不得有洋红描边、紫色底边、粉色脏边、彩色光晕或发光边。\n禁止文字、Logo、水印、UI按钮、标题、角标、装饰边框、face label、top/front/right/back/left/bottom文字、背包、装备栏、菜单、角色、完整场景、自然圆形水果、自然长条香蕉、非方块化完整水果、孤立水果照片、小型果切贴纸、小型橙片贴纸、小贴纸图标、小物体居中、纯果皮材质、纯果肉纹理、纯叶脉纹理、纯颜色块、透明背景、留白、3D平台、圆台、底座、托盘、物体摆在平台上、透视地块、正面30度物体图、鸟瞰地图块、落地投影、接触阴影、方形阴影、白底、灰底、黑底。\nEnglish guardrail: one vertical 1024x1536 image, exactly 18 cube object UV unwraps in a 3 columns by 6 rows atlas; each large cell is one complete cube object skin with a fixed 4x3 UV net: row1 col2 top, row2 col1 left, row2 col2 front, row2 col3 right, row2 col4 back, row3 col2 bottom; empty UV cells and gutters are solid magenta {JUMP_HOP_TILE_ATLAS_KEY_HEX}; generate six different face textures that stitch into one recognizable cubified theme object, not one repeated texture and not unrelated icons; fruit theme must create 18 distinct cubified fruits with continuous identity marks across faces; no text labels, no perspective cube render, no baked lighting, no baked shadows, no pedestal, no floor slab, no small centered stickers, no generic flat material; every face is full-bleed opaque square texture and remains recognizable at 64x64 in a 45-degree game camera." + "生成一张1024x1536竖版图片。以下按顺序说明画面中需要包含的内容:\n\ + \n\ + 【整体布局】\n\ + 画面中排列 18 个大单元格,以 3 列 6 行的网格形式均匀分布。大单元格之间彼此绝不接触——行与行之间由至少 40 像素宽的纯洋红水平间距隔开,列与列之间由至少 30 像素宽的纯洋红垂直间距隔开。画面四周同样保留至少 30 像素宽的纯洋红边距。\n\ + \n\ + 【每个大单元格的内部结构】\n\ + 每个大单元格内部是一个 4列x3行 的子网格。在这 12 个格子中,只有以下 6 个位置包含贴图内容:\n\ + - 第1行第2列:top(顶面)\n\ + - 第2行第1列:left(左侧面)\n\ + - 第2行第2列:front(正面)\n\ + - 第2行第3列:right(右侧面)\n\ + - 第2行第4列:back(背面)\n\ + - 第3行第2列:bottom(底面)\n\ + 其余 6 个格子和子网格之间的区域留空。每个面的贴图是正方形,应填满所在格子,不留边距。\n\ + \n\ + 【贴图内容要求】\n\ + 18 个大单元格各自代表一个独立的 1x1x1 立方体物体的六面展开。主题为\"{theme_text}\",具体内容为{subject_text}。\n\ + 每个方块物体的 6 个面必须属于同一个物体,各面的纹理、颜色、图案要能连续接合,组合成一个完整的立方体造型。不要 6 个面彼此无关,也不要 6 个面重复同一张纹理。\n\ + 水果主题需生成 18 种可辨认的不同方块水果,例如果柄叶片跨越 top/front,剥皮条带跨越 front/right,放射切面跨越 top/front 等。\n\ + 每个面是满版不透明贴图,使用大色块、高对比、粗线条和简单图形,保证缩小到 64x64 时仍可辨认。不做透视渲染,不画投影、高光、倒角、侧壁厚度。\n\ + \n\ + 【背景填充】\n\ + 大单元格之间的间距、每个大单元格内 6 个贴图之外的子网格区域、子网格之间的空隙、以及画面四周边距,全部使用单一纯洋红 {JUMP_HOP_TILE_ATLAS_KEY_HEX} 填充。洋红区域内不画任何文字、编号、标记或装饰。\n\ + \n\ + 禁止:文字、数字、Logo、图标、编号、标记线、箭头、UI 元素、透视渲染、投影、高光、底座、托盘、透明背景、留白。" ) } fn build_jump_hop_tile_atlas_negative_prompt() -> &'static str { - "文字、Logo、水印、UI按钮、UI 字、游戏界面、棋盘、背包、装备栏、图标集页面、外层面板、菜单、工具栏、低清晰度、畸形肢体、多余角色、裁切主体、写实摄影、油亮高光、塑料质感、暗黑幻想风、厚重CG渲染、海报、UI图标卡、标题、说明文字、装饰边框、单纯平铺材质、抽象纹理、随机斑点、只铺主题颜色、纯果皮材质、纯果肉纹理、纯叶脉纹理、无法分辨具体物体、自然圆形水果、自然长条香蕉、非方块化完整水果、孤立水果照片、果切小贴纸、橙片小贴纸、小水果居中、苹果小贴纸、香蕉小贴纸、小贴纸图标、小物体居中、透明背景、留白、3D平台、跳板成品、地块成品、物体剪影、正面30度物体图、纯俯视地图块、鸟瞰地图块、透视地块、已经画好的侧壁、已经画好的厚度、烘焙高光、烘焙阴影、落地投影、接触阴影、方形阴影、洋红阴影、紫色底边、粉色脏边、洋红色描边、彩色光晕、发光底边、方形底板、额外底座、承托底座、台座、石台、土墩、木板底座、圆台、底盘、托盘、岛屿底座、花盆底座、地面块、脚下地板、物体摆在平台上、物体下方垫地板、白底、灰底、黑底、暗色背景、背景色块、贴边、跨格、越界、可见网格线、编号、裁切标记" + "单元格紧贴、单元格粘连、无间距、无缝排列、贴边、跨格、越界、边界溢出、侵占洋红间距、吞没洋红间隔、贴图伸入间距区域、文字、Logo、水印、UI按钮、UI 字、游戏界面、棋盘、背包、装备栏、图标集页面、外层面板、菜单、工具栏、低清晰度、畸形肢体、多余角色、裁切主体、写实摄影、油亮高光、塑料质感、暗黑幻想风、厚重CG渲染、海报、UI图标卡、标题、说明文字、装饰边框、单纯平铺材质、抽象纹理、随机斑点、只铺主题颜色、纯果皮材质、纯果肉纹理、纯叶脉纹理、无法分辨具体物体、自然圆形水果、自然长条香蕉、非方块化完整水果、孤立水果照片、果切小贴纸、橙片小贴纸、小水果居中、苹果小贴纸、香蕉小贴纸、小贴纸图标、小物体居中、透明背景、留白、3D平台、跳板成品、地块成品、物体剪影、正面30度物体图、纯俯视地图块、鸟瞰地图块、透视地块、已经画好的侧壁、已经画好的厚度、烘焙高光、烘焙阴影、落地投影、接触阴影、方形阴影、洋红阴影、紫色底边、粉色脏边、洋红色描边、彩色光晕、发光底边、方形底板、额外底座、承托底座、台座、石台、土墩、木板底座、圆台、底盘、托盘、岛屿底座、花盆底座、地面块、脚下地板、物体摆在平台上、物体下方垫地板、白底、灰底、黑底、暗色背景、背景色块、可见网格线、编号、裁切标记" } fn sanitize_jump_hop_tile_prompt(tile_prompt: &str) -> String { @@ -971,7 +1050,8 @@ fn sanitize_jump_hop_tile_prompt(tile_prompt: &str) -> String { value } -fn slice_jump_hop_tile_atlas( +#[allow(dead_code)] +pub(crate) fn slice_jump_hop_tile_atlas( image: &crate::openai_image_generation::DownloadedOpenAiImage, ) -> Result, AppError> { let source = image::load_from_memory(image.bytes.as_slice()) @@ -1024,7 +1104,8 @@ fn slice_jump_hop_tile_atlas( Ok(slices) } -fn slice_jump_hop_tile_uv_faces( +#[allow(dead_code)] +pub(crate) fn slice_jump_hop_tile_uv_faces( source: &image::RgbaImage, tile_x: u32, tile_y: u32, @@ -1064,7 +1145,8 @@ fn slice_jump_hop_tile_uv_faces( } #[allow(clippy::too_many_arguments)] -fn slice_jump_hop_tile_uv_face( +#[allow(dead_code)] +pub(crate) fn slice_jump_hop_tile_uv_face( source: &image::RgbaImage, uv_x: u32, uv_y: u32, @@ -1105,7 +1187,7 @@ fn slice_jump_hop_tile_uv_face( }) } -fn crop_jump_hop_tile_texture_cell( +pub(crate) fn crop_jump_hop_tile_texture_cell( source: &image::RgbaImage, x0: u32, y0: u32, @@ -1113,7 +1195,8 @@ fn crop_jump_hop_tile_texture_cell( height: u32, ) -> image::DynamicImage { let min_side = width.min(height).max(1); - let safe_inset = (min_side / 32).clamp(2, 12); + // 洋红去背已在切片前完成,内缩只需微调去掉可能的极边缘残留,不再承担主要去洋红职责。 + let safe_inset = (min_side / 64).clamp(1, 4); let inset_x = safe_inset.min(width.saturating_sub(1) / 2); let inset_y = safe_inset.min(height.saturating_sub(1) / 2); let crop_width = width.saturating_sub(inset_x.saturating_mul(2)).max(1); @@ -1126,26 +1209,16 @@ fn crop_jump_hop_tile_texture_cell( crop_height, ) .to_image(); - let mut resized = image::imageops::resize( + let resized = image::imageops::resize( &cropped, JUMP_HOP_TILE_TEXTURE_OUTPUT_SIZE, JUMP_HOP_TILE_TEXTURE_OUTPUT_SIZE, image::imageops::FilterType::Lanczos3, ); - normalize_jump_hop_tile_texture_pixels(&mut resized); image::DynamicImage::ImageRgba8(resized) } -fn normalize_jump_hop_tile_texture_pixels(image: &mut image::RgbaImage) { - let fallback = average_jump_hop_tile_texture_color(image); - for pixel in image.pixels_mut() { - if is_jump_hop_tile_texture_key_pixel(*pixel) { - *pixel = fallback; - } - pixel.0[3] = 255; - } -} - +#[allow(dead_code)] fn average_jump_hop_tile_texture_color(image: &image::RgbaImage) -> image::Rgba { let mut total_r = 0u64; let mut total_g = 0u64; @@ -1174,6 +1247,7 @@ fn average_jump_hop_tile_texture_color(image: &image::RgbaImage) -> image::Rgba< ]) } +#[allow(dead_code)] fn is_jump_hop_tile_texture_key_pixel(pixel: image::Rgba) -> bool { let [red, green, blue, _] = pixel.0; let red_delta = red.abs_diff(255) as u32; @@ -1186,7 +1260,7 @@ fn is_jump_hop_tile_texture_key_pixel(pixel: image::Rgba) -> bool { <= 24u32.saturating_mul(24) } -fn jump_hop_tile_type_by_index(index: usize) -> JumpHopTileType { +pub(crate) fn jump_hop_tile_type_by_index(index: usize) -> JumpHopTileType { match index { 0 => JumpHopTileType::Start, value if value % 11 == 0 => JumpHopTileType::Bonus, @@ -1200,7 +1274,7 @@ fn jump_hop_tile_asset_slot_name(tile_index: usize) -> String { format!("tile-{:02}", tile_index + 1) } -fn jump_hop_tile_face_key_label(face: &JumpHopTileFaceKey) -> &'static str { +pub(crate) fn jump_hop_tile_face_key_label(face: &JumpHopTileFaceKey) -> &'static str { match face { JumpHopTileFaceKey::Top => "top", JumpHopTileFaceKey::Front => "front", @@ -1823,76 +1897,58 @@ mod tests { fn jump_hop_tile_atlas_prompt_uses_dedicated_uv_unwrap_floor_layout() { let prompt = build_jump_hop_tile_atlas_prompt("森林冒险", "森林主题清爽游戏化立体感平台"); + // 基础信息 assert!(prompt.contains("生成一张1024x1536竖版图片")); - assert!(prompt.contains("18个用于跳一跳地板的立方体主题物体 UV 展开包装图")); - assert!(prompt.contains("按三列六行均匀排布")); - assert!(prompt.contains("每个大单元格代表一个完整的 1x1x1 立方体方块物体")); - assert!(prompt.contains("该单元内的六张面贴图精确贴到 Three.js 标准极小倒角立方体的六个面上")); - assert!(prompt.contains("cube object UV unwrap atlas / 立方体主题物体六面展开图集")); - assert!(prompt.contains("不是单纯平铺材质、不是抽象纹理、不是只把主题颜色铺满")); - assert!(prompt.contains("游戏界面或图标集页面")); - assert!(prompt.contains("固定 4列x3行 UV 展开结构")); - assert!(prompt.contains("第1行第2列是 top")); - assert!(prompt.contains("第2行第1列是 left")); - assert!(prompt.contains("第2行第2列是 front")); - assert!(prompt.contains("第2行第3列是 right")); - assert!(prompt.contains("第2行第4列是 back")); - assert!(prompt.contains("第3行第2列是 bottom")); - assert!(prompt.contains("不要改变顺序,不要旋转面")); - assert!(prompt.contains("六个面要属于同一个物体并能组合成完整方块造型")); - assert!(prompt.contains("不能六面各画互不相关的图案,也不能把同一张纹理重复六次")); - assert!(prompt.contains("水果主题要生成18种可一眼辨认的方块水果 UV")); - assert!(prompt.contains("方块苹果、方块香蕉、方块橙子、方块西瓜")); - assert!(prompt.contains("苹果需要果柄叶片跨 top/front")); - assert!(prompt.contains("香蕉需要剥皮条带跨 front/right")); - assert!(prompt.contains("西瓜需要红瓤黑籽和绿皮条纹在各面连续")); - assert!(prompt.contains("不要只画重复果皮纹理、随机斑点、叶脉纹理或抽象材质")); - assert!(prompt.contains("full-bleed opaque square face texture")); - assert!(prompt.contains("四角、边缘和中心都要有可识别内容")); - assert!(prompt.contains("不留透明、不留空白、不留实底背景")); - assert!(prompt.contains("允许大面积水果切面、果柄叶片、剥皮条带、籽点、条纹和轮廓图案作为包装身份锚点")); - assert!(prompt.contains("不要把一个小水果、小叶片、小石头或小物体放在面中央")); - assert!(prompt.contains("这不是透视渲染图")); - assert!(prompt.contains("不要画摄像机视角、透视块、已烘焙侧壁")); - assert!(prompt.contains("真实透视、倒角、侧壁和阴影会由运行态 Three.js 统一生成")); - assert!(prompt.contains("64x64缩略图里仍能分辨主题物体身份")); - assert!(prompt.contains("18个大单元格必须完整落在自己的三列六行网格内")); - assert!(prompt.contains("大单元之间、UV 空位、六面之间和画布最外圈只能使用单一纯洋红")); + // 布局 + assert!(prompt.contains("18 个大单元格")); + assert!(prompt.contains("3 列 6 行")); + assert!(prompt.contains("大单元格之间彼此绝不接触")); + // 内部 UV 结构 + assert!(prompt.contains("4列x3行")); + assert!(prompt.contains("第1行第2列:top")); + assert!(prompt.contains("第2行第1列:left")); + assert!(prompt.contains("第2行第2列:front")); + assert!(prompt.contains("第2行第3列:right")); + assert!(prompt.contains("第2行第4列:back")); + assert!(prompt.contains("第3行第2列:bottom")); + assert!(prompt.contains("其余 6 个格子")); + // 贴图内容 + assert!(prompt.contains("1x1x1 立方体物体的六面展开")); + assert!(prompt.contains("主题为\"森林冒险\"")); + assert!(prompt.contains("6 个面必须属于同一个物体")); + assert!(prompt.contains("组合成一个完整的立方体造型")); + assert!(prompt.contains("不要 6 个面彼此无关")); + assert!(prompt.contains("不要 6 个面重复同一张纹理")); + // 水果主题 + assert!(prompt.contains("水果主题需生成 18 种可辨认的不同方块水果")); + assert!(prompt.contains("果柄叶片跨越 top/front")); + assert!(prompt.contains("剥皮条带跨越 front/right")); + assert!(prompt.contains("放射切面跨越 top/front")); + // 面贴图要求 + assert!(prompt.contains("满版不透明贴图")); + assert!(prompt.contains("缩小到 64x64 时仍可辨认")); + assert!(prompt.contains("不做透视渲染")); + assert!(prompt.contains("不画投影、高光、倒角、侧壁厚度")); + assert!(prompt.contains("大色块、高对比、粗线条和简单图形")); + // 背景填充(最后一段) + assert!(prompt.contains("【背景填充】")); + assert!(prompt.contains("大单元格之间的间距")); + assert!(prompt.contains("全部使用单一纯洋红")); assert!(prompt.contains(JUMP_HOP_TILE_ATLAS_KEY_HEX)); - assert!( - prompt.contains("贴图内部可以使用绿色、白色、雪地、云朵、草地、花朵、果肉粉色和浅黄色") - ); - assert!(prompt.contains("不得使用接近")); - assert!(prompt.contains("贴图边缘不得有洋红描边、紫色底边、粉色脏边")); - assert!(prompt.contains("自然圆形水果、自然长条香蕉、非方块化完整水果")); - assert!(prompt.contains("小贴纸图标、小物体居中、纯果皮材质、纯果肉纹理")); - assert!(prompt.contains("English guardrail")); - assert!(prompt.contains("one vertical 1024x1536 image")); - assert!(prompt.contains("exactly 18 cube object UV unwraps in a 3 columns by 6 rows atlas")); - assert!(prompt.contains("row1 col2 top")); - assert!(prompt.contains("row2 col1 left")); - assert!(prompt.contains("row2 col2 front")); - assert!(prompt.contains("row2 col3 right")); - assert!(prompt.contains("row2 col4 back")); - assert!(prompt.contains("row3 col2 bottom")); - assert!(prompt.contains("six different face textures that stitch into one recognizable cubified theme object")); - assert!(prompt.contains("no generic flat material")); - assert!(prompt.contains("no small centered stickers")); - assert!(prompt.contains("every face is full-bleed opaque square texture")); - assert!(prompt.contains("no perspective cube render")); - assert!(prompt.contains("no baked shadows")); - assert!(prompt.contains("no pedestal")); - assert!(prompt.contains("no floor slab")); - assert!(prompt.contains("empty UV cells and gutters are solid magenta")); + assert!(prompt.contains("不画任何文字、编号、标记或装饰")); + // 禁止项 + assert!(prompt.contains("透视渲染")); + assert!(prompt.contains("底座")); + assert!(prompt.contains("托盘")); + assert!(prompt.contains("透明背景")); + assert!(prompt.contains("留白")); + // 负向检查:旧 prompt 残余 assert!(!prompt.contains("可落脚平台素材")); assert!(!prompt.contains("平台裸素材")); - assert!(!prompt.contains("每格一个完整平台")); - assert!(!prompt.contains("25个平台")); - assert!(!prompt.contains("跳跃落点主题物体")); assert!(!prompt.contains("正面30度视角")); assert!(!prompt.contains("五行五列")); - assert!(!prompt.contains("25张用于跳一跳地板")); - assert!(!prompt.contains("25 full-bleed")); + assert!(!prompt.contains("25个")); + assert!(!prompt.contains("允许极细")); assert!(!prompt.contains("one square 5x5")); assert!(!prompt.contains("基础轮廓优先做不规则主题剪影")); assert!(!prompt.contains("25格造型要混排")); @@ -2001,8 +2057,6 @@ mod tests { "宝可梦", "宝可梦主题的正面30度视角主题物体图集,包含皮卡丘和精灵球装饰", ); - assert!(tile_prompt.contains("主题为“原创幻想萌宠冒险道具”")); - assert!(tile_prompt.contains("画面内容是原创幻想萌宠冒险道具主题")); assert!(tile_prompt.contains("黄色闪电萌宠符号")); assert!(tile_prompt.contains("彩色冒险能量球")); assert!(!tile_prompt.contains("宝可梦")); @@ -2011,8 +2065,7 @@ mod tests { let normal_prompt = build_jump_hop_tile_atlas_prompt("水果", "水果主题的正面30度视角主题物体图集"); - assert!(normal_prompt.contains("主题为“水果”")); - assert!(normal_prompt.contains("画面内容是水果主题的3D立方体主题身份方块包装图集")); + assert!(normal_prompt.contains("3D立方体主题身份方块包装图集")); } #[test] @@ -2022,17 +2075,17 @@ mod tests { "科幻芯片主题的俯视角清爽游戏化立体感平台素材", ); - assert!(prompt.contains("画面内容是科幻芯片主题的正交平面清爽游戏化立方体主题身份方块包装贴图")); - assert!(!prompt.contains("画面内容是科幻芯片主题的俯视角清爽游戏化立体感平台素材")); - assert!(!prompt.contains("画面内容是科幻芯片主题的俯视角")); + assert!(prompt.contains("具体内容为科幻芯片主题的正交平面清爽游戏化立方体主题身份方块包装贴图")); + assert!(!prompt.contains("俯视角清爽游戏化立体感平台素材")); + assert!(!prompt.contains("俯视角")); let top_down_prompt = build_jump_hop_tile_atlas_prompt("水果", "水果主题鸟瞰视角平铺俯拍圆形平台"); - assert!(top_down_prompt.contains("画面内容是水果主题正交平面")); + assert!(top_down_prompt.contains("正交平面")); assert!(top_down_prompt.contains("圆形立方体地板")); - assert!(!top_down_prompt.contains("画面内容是水果主题鸟瞰视角")); - assert!(!top_down_prompt.contains("画面内容是水果主题平铺俯拍")); + assert!(!top_down_prompt.contains("鸟瞰视角")); + assert!(!top_down_prompt.contains("平铺俯拍")); let legacy_prompt = build_jump_hop_tile_atlas_prompt( "雪花", @@ -2386,4 +2439,718 @@ mod tests { "18 个地板 UV 大单元的 108 张面贴图必须写入独立 slot/path" ); } + + /// 跳一跳图集切片全流程测试:生成合成图集 → 切片 → 关键色清除 → 缩放到256×256 + /// 保留所有中间结果到 target/test-output/jump-hop-tile-atlas/ + #[test] + fn jump_hop_tile_atlas_full_pipeline_with_intermediate_output() { + let output_root = std::path::PathBuf::from("target") + .join("test-output") + .join("jump-hop-tile-atlas"); + let _ = std::fs::remove_dir_all(&output_root); + std::fs::create_dir_all(&output_root).expect("应该能创建输出目录"); + + let width = JUMP_HOP_TILE_ATLAS_IMAGE_WIDTH; + let height = JUMP_HOP_TILE_ATLAS_IMAGE_HEIGHT; + let cell_width = width / JUMP_HOP_TILE_ATLAS_COLS; + let cell_height = height / JUMP_HOP_TILE_ATLAS_ROWS; + let face_side = (cell_width / JUMP_HOP_TILE_UV_FACE_COLS) + .min(cell_height / JUMP_HOP_TILE_UV_FACE_ROWS) + .max(1); + + // 生成合成图集:洋红安全色背景 + 18 个大单元格,每格 6 个不同颜色的 UV 面 + let mut atlas = + image::RgbaImage::from_pixel(width, height, image::Rgba([255, 0, 255, 255])); + for row in 0..JUMP_HOP_TILE_ATLAS_ROWS { + for col in 0..JUMP_HOP_TILE_ATLAS_COLS { + let index = (row * JUMP_HOP_TILE_ATLAS_COLS + col) as u8; + paint_test_uv_face( + &mut atlas, + col, + row, + 1, + 0, + image::Rgba([40 + index * 3, 24 + index * 2, 100, 255]), + ); + paint_test_uv_face( + &mut atlas, + col, + row, + 1, + 1, + image::Rgba([50 + index * 3, 34 + index * 2, 110, 255]), + ); + paint_test_uv_face( + &mut atlas, + col, + row, + 2, + 1, + image::Rgba([60 + index * 3, 44 + index * 2, 120, 255]), + ); + paint_test_uv_face( + &mut atlas, + col, + row, + 3, + 1, + image::Rgba([70 + index * 3, 54 + index * 2, 130, 255]), + ); + paint_test_uv_face( + &mut atlas, + col, + row, + 0, + 1, + image::Rgba([80 + index * 3, 64 + index * 2, 140, 255]), + ); + paint_test_uv_face( + &mut atlas, + col, + row, + 1, + 2, + image::Rgba([90 + index * 3, 74 + index * 2, 150, 255]), + ); + } + } + + // 在边缘区域故意填充一些洋红色像素,验证后续内缩裁切和关键色清除 + let atlas_bytes = encode_test_atlas(atlas); + std::fs::write(output_root.join("00-raw-atlas.png"), &atlas_bytes) + .expect("保存原始图集"); + + let atlas_image = load_test_png(atlas_bytes); + + // === 阶段1: 图集切片(18 个 tile cell) === + let slices = + slice_jump_hop_tile_atlas(&atlas_image).expect("图集切片应该成功"); + assert_eq!( + slices.len(), + JUMP_HOP_TILE_ITEM_COUNT, + "应该切出 18 个 tile" + ); + + let mut total_faces = 0usize; + let mut no_key_color_residue = true; + let expected_output_size = + JUMP_HOP_TILE_TEXTURE_OUTPUT_SIZE as u32; + + for (index, slice) in slices.iter().enumerate() { + let row = index as u32 / JUMP_HOP_TILE_ATLAS_COLS; + let col = index as u32 % JUMP_HOP_TILE_ATLAS_COLS; + let tile_type = format!("{:?}", slice.tile_type); + + let tile_dir = output_root.join(format!( + "tile-{:02}-{}-row{}-col{}", + index + 1, + tile_type, + row + 1, + col + 1 + )); + std::fs::create_dir_all(&tile_dir).expect("应该能创建 tile 输出目录"); + + // 验证 tile type 分配 + assert_eq!( + slice.tile_type, + jump_hop_tile_type_by_index(index), + "tile {index} 的类型分配应该一致" + ); + assert_eq!( + slice.source_atlas_cell, + format!("row-{}-col-{}", row + 1, col + 1), + "tile {index} 的 source_atlas_cell 应该正确" + ); + + let faces: [(&str, &JumpHopTileFaceSlice); 6] = [ + ("top", &slice.faces.top), + ("front", &slice.faces.front), + ("right", &slice.faces.right), + ("back", &slice.faces.back), + ("left", &slice.faces.left), + ("bottom", &slice.faces.bottom), + ]; + + for (face_name, face) in &faces { + total_faces += 1; + + let face_filename = format!( + "{}-{}.png", + face_name, + face.source_atlas_cell.replace('/', "-") + ); + let face_path = tile_dir.join(&face_filename); + std::fs::write(&face_path, &face.bytes) + .expect("保存面贴图"); + + // 解码并验证 + let decoded = image::load_from_memory(&face.bytes) + .expect("面贴图应该能解码") + .to_rgba8(); + + // 验证输出尺寸为 256×256 + assert_eq!( + decoded.dimensions(), + (expected_output_size, expected_output_size), + "tile {index} face {face_name} 应该输出 {}×{}", + expected_output_size, expected_output_size + ); + + // 验证没有残余洋红关键色 + for pixel in decoded.pixels() { + if is_jump_hop_tile_texture_key_pixel(*pixel) { + no_key_color_residue = false; + } + } + + // 验证面贴图不是纯洋红色(洋红背景已由去背步骤移除) + assert!( + decoded.pixels().any(|p| p.0 != [255, 0, 255, 255]), + "tile {index} face {face_name} 不应是纯洋红" + ); + } + } + + assert_eq!(total_faces, JUMP_HOP_TILE_ITEM_COUNT * 6); + assert!( + no_key_color_residue, + "所有输出面贴图不应残留洋红关键色像素" + ); + + // === 阶段2: 关键色检测算法验证 === + // 纯洋红应被检测为关键色 + assert!(is_jump_hop_tile_texture_key_pixel(image::Rgba([ + 255, 0, 255, 255 + ]))); + assert!(is_jump_hop_tile_texture_key_pixel(image::Rgba([ + 255, 0, 255, 128 + ]))); + // 接近洋红(距离 ≤ 24)应被检测为关键色 + assert!(is_jump_hop_tile_texture_key_pixel(image::Rgba([ + 245, 10, 245, 255 + ]))); + // 远离洋红的不应被检测为关键色 + assert!(!is_jump_hop_tile_texture_key_pixel(image::Rgba([ + 62, 188, 74, 255 + ]))); + assert!(!is_jump_hop_tile_texture_key_pixel(image::Rgba([ + 246, 246, 238, 255 + ]))); + assert!(!is_jump_hop_tile_texture_key_pixel(image::Rgba([ + 0, 0, 0, 255 + ]))); + + // === 阶段3: 平均色计算验证 === + let mut test_image = image::RgbaImage::new(4, 4); + for pixel in test_image.pixels_mut() { + *pixel = image::Rgba([100, 150, 200, 255]); + } + test_image.put_pixel(0, 0, image::Rgba([255, 0, 255, 255])); // 关键色(会被跳过) + let avg = average_jump_hop_tile_texture_color(&test_image); + // 15 个非关键色像素,均值应为 (100, 150, 200) + assert_eq!(avg.0[0], 100); + assert_eq!(avg.0[1], 150); + assert_eq!(avg.0[2], 200); + assert_eq!(avg.0[3], 255); + + // 全部是关键色时回退到 fallback 颜色 + let mut all_key = image::RgbaImage::new(4, 4); + for pixel in all_key.pixels_mut() { + *pixel = image::Rgba([255, 0, 255, 255]); + } + let fallback_avg = average_jump_hop_tile_texture_color(&all_key); + assert_eq!(fallback_avg.0[0], 148); + assert_eq!(fallback_avg.0[1], 163); + assert_eq!(fallback_avg.0[2], 184); + + // === 阶段4: prompt 构建验证 === + let prompt = build_jump_hop_tile_atlas_prompt("水果", "水果方块 UV 展开图集"); + assert!(prompt.contains("1024x1536")); + assert!(prompt.contains("18 个大单元格")); + assert!(prompt.contains("3 列 6 行")); + assert!(prompt.contains("4列x3行")); + assert!(prompt.contains("【背景填充】")); + + let negative_prompt = build_jump_hop_tile_atlas_negative_prompt(); + assert!(negative_prompt.contains("文字")); + assert!(negative_prompt.contains("Logo")); + assert!(negative_prompt.contains("塑料质感")); + assert!(!negative_prompt.contains("规则圆盘")); + + // === 阶段5: prompt 清洗验证 === + let sanitized = sanitize_jump_hop_tile_prompt( + "宝可梦主题方块,皮卡丘风格可落脚平台素材", + ); + assert!(!sanitized.contains("宝可梦")); + assert!(!sanitized.contains("皮卡丘")); + assert!(sanitized.contains("原创幻想萌宠冒险道具")); + assert!(sanitized.contains("黄色闪电萌宠符号")); + assert!(sanitized.contains("立方体主题身份方块包装")); + + let sanitized2 = sanitize_jump_hop_tile_prompt( + "水果主题,跳台和地板", + ); + assert!(sanitized2.contains("立方体地板")); + assert!(!sanitized2.contains("跳台")); + + // 打印摘要 + println!( + "\n====== 跳一跳图集切片测试完成 ======" + ); + println!("原始图集尺寸: {}×{}", width, height); + println!("大单元格数: {} ({}行×{}列)", JUMP_HOP_TILE_ITEM_COUNT, JUMP_HOP_TILE_ATLAS_ROWS, JUMP_HOP_TILE_ATLAS_COLS); + println!("每格 UV 面网格: {}×{}", JUMP_HOP_TILE_UV_FACE_COLS, JUMP_HOP_TILE_UV_FACE_ROWS); + println!("每格 face_side: {}px", face_side); + println!("输出面贴图数量: {}", total_faces); + println!("输出面贴图尺寸: {}×{}", expected_output_size, expected_output_size); + println!("无关键色残留: {}", no_key_color_residue); + for (index, slice) in slices.iter().enumerate() { + println!( + " tile-{:02} [{:?}] row-{}-col-{}", + index + 1, + slice.tile_type, + index as u32 / JUMP_HOP_TILE_ATLAS_COLS + 1, + index as u32 % JUMP_HOP_TILE_ATLAS_COLS + 1 + ); + } + println!("中间结果保存在: {}", output_root.display()); + println!("=====================================\n"); + } + + /// 跳一跳图集全流程测试(AI 生图模式)—— 对比种子点精修 vs 谷检测。 + /// 使用 VectorEngine 真实生成图集 → 洋红去背 → 两种算法分别切片 → 保存所有中间结果。 + /// 需要配置 VECTOR_ENGINE_BASE_URL 和 VECTOR_ENGINE_API_KEY 环境变量。 + /// 运行:cargo test -p api-server jump_hop_tile_atlas_ai_generation_pipeline -- --nocapture --ignored + #[test] + #[ignore] + fn jump_hop_tile_atlas_ai_generation_pipeline() { + use crate::jump_hop_atlas_slicing::{ + AtlasSliceAlgorithm, DEFAULT_TILE_COLS, DEFAULT_TILE_ROWS, + compute_col_density, compute_row_density, refine_boundaries_seed, + }; + + let base_url = std::env::var("VECTOR_ENGINE_BASE_URL") + .unwrap_or_default() + .trim() + .trim_end_matches('/') + .to_string(); + let api_key = std::env::var("VECTOR_ENGINE_API_KEY") + .unwrap_or_default() + .trim() + .to_string(); + + if base_url.is_empty() || api_key.is_empty() { + println!("\n跳过:未配置 VECTOR_ENGINE_BASE_URL / VECTOR_ENGINE_API_KEY\n"); + return; + } + + let output_root = std::path::PathBuf::from("target") + .join("test-output") + .join("jump-hop-tile-atlas-ai"); + let _ = std::fs::remove_dir_all(&output_root); + std::fs::create_dir_all(&output_root).expect("应该能创建输出目录"); + + let theme_text = "水果"; + let tile_prompt = "水果方块 UV 展开图集"; + let atlas_prompt = + build_jump_hop_tile_atlas_prompt(theme_text, tile_prompt); + let negative_prompt = build_jump_hop_tile_atlas_negative_prompt(); + + println!("\n====== 跳一跳 AI 图集生成与自适应切片对比测试 ======"); + println!("主题: {theme_text}"); + println!("网格: {}×{}", DEFAULT_TILE_ROWS, DEFAULT_TILE_COLS); + println!("输出目录: {}", output_root.display()); + + std::fs::write( + output_root.join("00-generation-prompt.txt"), + format!( + "=== POSITIVE PROMPT ===\n{atlas_prompt}\n\n=== NEGATIVE PROMPT ===\n{negative_prompt}\n" + ), + ).expect("保存 prompt"); + + // ── AI 生图 + 洋红去背(一次性,两种算法共享输入) ── + println!("\n--- 阶段1: 调用 VectorEngine 生成图集 ---"); + let rt = tokio::runtime::Runtime::new().expect("创建 tokio runtime"); + let settings = platform_image::VectorEngineImageSettings { + base_url: base_url.clone(), + api_key: api_key.clone(), + request_timeout_ms: 180_000, + }; + let http_client = + platform_image::build_vector_engine_image_http_client(&settings) + .expect("构建 HTTP 客户端"); + + let generation_result = rt.block_on( + platform_image::create_vector_engine_image_generation( + &http_client, + &settings, + &atlas_prompt, + Some(negative_prompt), + JUMP_HOP_TILE_ATLAS_IMAGE_SIZE, + 1, + &[], + "跳一跳图集测试", + ), + ); + + let generated = match generation_result { + Ok(images) => { + println!("生成成功,task_id: {}", images.task_id); + images + } + Err(error) => panic!("VectorEngine 生图失败:{error}"), + }; + + let tile_image = generated.images.into_iter().next().expect("应该有生成的图片"); + std::fs::write(output_root.join("01-ai-generated-atlas.png"), &tile_image.bytes) + .expect("保存 AI 生成图集"); + + let download_image = crate::openai_image_generation::DownloadedOpenAiImage { + bytes: tile_image.bytes, + mime_type: "image/png".to_string(), + extension: "png".to_string(), + }; + + println!("\n--- 洋红去背 ---"); + let cleaned = prepare_jump_hop_magenta_screen_image_for_slicing( + download_image, + "跳一跳图集洋红去背测试失败", + ).expect("洋红去背应该成功"); + std::fs::write( + output_root.join("01b-after-magenta-cleanup.png"), + &cleaned.bytes, + ).expect("保存去背后图集"); + + // 解码并计算 density,打印供对比 + let source = image::load_from_memory(&cleaned.bytes) + .expect("去背后图集应可解码") + .to_rgba8(); + let width = source.width(); + let height = source.height(); + let pixels = source.as_raw(); + println!("去背后图集尺寸: {}×{}", width, height); + + let row_density = compute_row_density(pixels, width, height); + let col_density = compute_col_density(pixels, width, height); + + // ── 算法对比 ── + let algorithms = [ + ("seed", AtlasSliceAlgorithm::SeedRefinement), + ("valley", AtlasSliceAlgorithm::ValleyDetection), + ]; + + for &(algo_name, algo) in &algorithms { + println!("\n========================================"); + println!(" 算法: {algo_name}"); + println!("========================================"); + + let algo_dir = output_root.join(format!("02-slices-{algo_name}")); + let _ = std::fs::remove_dir_all(&algo_dir); + std::fs::create_dir_all(&algo_dir).expect("创建算法输出目录"); + + let slices = crate::jump_hop_atlas_slicing::slice_tile_atlas_adaptive( + &cleaned, + DEFAULT_TILE_ROWS, + DEFAULT_TILE_COLS, + algo, + ).expect(&format!("{algo_name} 切片应该成功")); + + assert_eq!( + slices.len(), + (DEFAULT_TILE_ROWS * DEFAULT_TILE_COLS) as usize, + "{algo_name} 应该切出正确数量的 tile" + ); + + // 保存 density 数据供离线分析 + std::fs::write( + algo_dir.join("row_density.csv"), + row_density.iter().enumerate() + .map(|(i, v)| format!("{i},{v:.6}")) + .collect::>() + .join("\n"), + ).expect("保存行 density"); + + std::fs::write( + algo_dir.join("col_density.csv"), + col_density.iter().enumerate() + .map(|(i, v)| format!("{i},{v:.6}")) + .collect::>() + .join("\n"), + ).expect("保存列 density"); + + // 保存行投影可视化(ASCII 柱状图) + let mut row_viz = String::from("row_density:\n"); + let max_d = row_density.iter().cloned().fold(0.0f32, f32::max).max(0.001); + for (y, &d) in row_density.iter().enumerate() { + let bar = (d / max_d * 60.0) as usize; + row_viz.push_str(&format!("{:4} |{}\n", y, "#".repeat(bar))); + } + std::fs::write(algo_dir.join("row_density_viz.txt"), &row_viz).ok(); + + let mut col_viz = String::from("col_density:\n"); + let max_c = col_density.iter().cloned().fold(0.0f32, f32::max).max(0.001); + for (x, &d) in col_density.iter().enumerate() { + let bar = (d / max_c * 60.0) as usize; + col_viz.push_str(&format!("{:4} |{}\n", x, "#".repeat(bar))); + } + std::fs::write(algo_dir.join("col_density_viz.txt"), &col_viz).ok(); + + // 打印 cell 边界 + println!("\n固定网格种子位置:"); + let cell_h = (height / DEFAULT_TILE_ROWS).max(1); + let cell_w = (width / DEFAULT_TILE_COLS).max(1); + let row_seeds: Vec<_> = (1..DEFAULT_TILE_ROWS).map(|i| i * height / DEFAULT_TILE_ROWS).collect(); + let col_seeds: Vec<_> = (1..DEFAULT_TILE_COLS).map(|i| i * width / DEFAULT_TILE_COLS).collect(); + println!(" 行种子: {:?}", row_seeds); + println!(" 列种子: {:?}", col_seeds); + + // 精修后的位置(用种子点精修展示偏移量) + let refined_rows = refine_boundaries_seed(&row_density, &row_seeds, (cell_h / 3).max(1)); + let refined_cols = refine_boundaries_seed(&col_density, &col_seeds, (cell_w / 3).max(1)); + println!("\n种子点精修偏移:"); + for (i, (&seed, &refined)) in row_seeds.iter().zip(refined_rows.iter()).enumerate() { + println!(" 行边界 {}: seed={seed} → refined={refined} (偏移 {})", i + 1, refined as i32 - seed as i32); + } + for (i, (&seed, &refined)) in col_seeds.iter().zip(refined_cols.iter()).enumerate() { + println!(" 列边界 {}: seed={seed} → refined={refined} (偏移 {})", i + 1, refined as i32 - seed as i32); + } + + // 保存面贴图 + let mut total_faces = 0usize; + for (index, slice) in slices.iter().enumerate() { + let r = index as u32 / DEFAULT_TILE_COLS; + let c = index as u32 % DEFAULT_TILE_COLS; + let tile_dir = algo_dir.join(format!( + "tile-{:02}-{:?}-row{}-col{}", + index + 1, slice.tile_type, r + 1, c + 1 + )); + std::fs::create_dir_all(&tile_dir).expect("创建 tile 目录"); + + let faces: [(&str, &JumpHopTileFaceSlice); 6] = [ + ("top", &slice.faces.top), + ("front", &slice.faces.front), + ("right", &slice.faces.right), + ("back", &slice.faces.back), + ("left", &slice.faces.left), + ("bottom", &slice.faces.bottom), + ]; + for (face_name, face) in &faces { + total_faces += 1; + let filename = format!( + "{}-{}.png", + face_name, + face.source_atlas_cell.replace('/', "-") + ); + std::fs::write(tile_dir.join(&filename), &face.bytes) + .expect("保存面贴图"); + } + } + println!("{algo_name}: 输出 {total_faces} 张面贴图"); + + // 保存 3×6 cell 网格中间切图 + let cells_dir = algo_dir.join("cells"); + std::fs::create_dir_all(&cells_dir).expect("创建 cells 目录"); + let grid = match algo { + AtlasSliceAlgorithm::SeedRefinement => { + crate::jump_hop_atlas_slicing::detect_cell_grid_seed( + pixels, width, height, DEFAULT_TILE_ROWS, DEFAULT_TILE_COLS) + } + AtlasSliceAlgorithm::ValleyDetection => { + crate::jump_hop_atlas_slicing::detect_cell_grid_valley( + pixels, width, height, DEFAULT_TILE_ROWS, DEFAULT_TILE_COLS) + .unwrap_or_else(|_| crate::jump_hop_atlas_slicing::detect_cell_grid_seed( + pixels, width, height, DEFAULT_TILE_ROWS, DEFAULT_TILE_COLS)) + } + }; + for row in 0..DEFAULT_TILE_ROWS { + for col in 0..DEFAULT_TILE_COLS { + let x0 = grid.col_boundaries[col as usize]; + let x1 = grid.col_boundaries[col as usize + 1]; + let y0 = grid.row_boundaries[row as usize]; + let y1 = grid.row_boundaries[row as usize + 1]; + let cell_img = image::imageops::crop_imm( + &source, x0, y0, x1 - x0, y1 - y0); + cell_img.to_image().save( + cells_dir.join(format!("cell-row{}-col{}.png", row + 1, col + 1)) + ).expect("保存 cell 切图"); + } + } + println!("{algo_name}: 保存 {DEFAULT_TILE_ROWS}×{DEFAULT_TILE_COLS} cell 网格切图"); + } + + println!("\n====== 对比测试完成 ======"); + println!("输出目录: {}", output_root.display()); + println!(" seed 算法结果: {}/02-slices-seed/", output_root.display()); + println!(" valley 算法结果: {}/02-slices-valley/", output_root.display()); + println!(" density 数据: 各算法目录下的 row_density.csv / col_density.csv"); + println!("==============================\n"); + } + + /// 修改前固定网格管线测试(AI 生图模式) + /// 使用 VectorEngine 真实生成图集 → 洋红去背 → 旧固定网格切片 → 保存中间结果。 + /// 需要配置 VECTOR_ENGINE_BASE_URL 和 VECTOR_ENGINE_API_KEY 环境变量。 + /// 运行:cargo test -p api-server jump_hop_tile_atlas_ai_fixed_grid -- --nocapture --ignored + #[test] + #[ignore] + fn jump_hop_tile_atlas_ai_fixed_grid() { + let base_url = std::env::var("VECTOR_ENGINE_BASE_URL") + .unwrap_or_default() + .trim() + .trim_end_matches('/') + .to_string(); + let api_key = std::env::var("VECTOR_ENGINE_API_KEY") + .unwrap_or_default() + .trim() + .to_string(); + + if base_url.is_empty() || api_key.is_empty() { + println!("\n跳过:未配置 VECTOR_ENGINE_BASE_URL / VECTOR_ENGINE_API_KEY\n"); + return; + } + + let output_root = std::path::PathBuf::from("target") + .join("test-output") + .join("jump-hop-tile-atlas-ai-fixed"); + let _ = std::fs::remove_dir_all(&output_root); + std::fs::create_dir_all(&output_root).expect("应该能创建输出目录"); + + let theme_text = "水果"; + let tile_prompt = "水果方块 UV 展开图集"; + let atlas_prompt = + build_jump_hop_tile_atlas_prompt(theme_text, tile_prompt); + let negative_prompt = build_jump_hop_tile_atlas_negative_prompt(); + + println!("\n====== 跳一跳固定网格 AI 生图测试 ======"); + println!("主题: {theme_text}"); + println!("输出目录: {}", output_root.display()); + + std::fs::write( + output_root.join("00-generation-prompt.txt"), + format!( + "=== POSITIVE PROMPT ===\n{atlas_prompt}\n\n=== NEGATIVE PROMPT ===\n{negative_prompt}\n" + ), + ).expect("保存 prompt"); + + // AI 生图 + println!("\n--- 阶段1: VectorEngine 生图 ---"); + let rt = tokio::runtime::Runtime::new().expect("创建 tokio runtime"); + let settings = platform_image::VectorEngineImageSettings { + base_url: base_url.clone(), + api_key: api_key.clone(), + request_timeout_ms: 180_000, + }; + let http_client = + platform_image::build_vector_engine_image_http_client(&settings) + .expect("构建 HTTP 客户端"); + + let generation_result = rt.block_on( + platform_image::create_vector_engine_image_generation( + &http_client, + &settings, + &atlas_prompt, + Some(negative_prompt), + JUMP_HOP_TILE_ATLAS_IMAGE_SIZE, + 1, + &[], + "跳一跳图集测试(固定网格)", + ), + ); + + let generated = match generation_result { + Ok(images) => { + println!("生成成功,task_id: {}", images.task_id); + images + } + Err(error) => panic!("VectorEngine 生图失败:{error}"), + }; + + let tile_image = generated.images.into_iter().next().expect("应该有生成的图片"); + std::fs::write(output_root.join("01-ai-generated-atlas.png"), &tile_image.bytes) + .expect("保存 AI 生成图集"); + + let download_image = crate::openai_image_generation::DownloadedOpenAiImage { + bytes: tile_image.bytes, + mime_type: "image/png".to_string(), + extension: "png".to_string(), + }; + + println!("\n--- 阶段2: 洋红去背 ---"); + let cleaned = prepare_jump_hop_magenta_screen_image_for_slicing( + download_image, + "跳一跳图集洋红去背测试失败", + ).expect("洋红去背应该成功"); + std::fs::write( + output_root.join("02-after-magenta-cleanup.png"), + &cleaned.bytes, + ).expect("保存去背后图集"); + + let source = image::load_from_memory(&cleaned.bytes) + .expect("解码") + .to_rgba8(); + println!("去背后尺寸: {}×{}", source.width(), source.height()); + + // 旧固定网格切片 + println!("\n--- 阶段3: 固定网格切片 ---"); + let cell_w = source.width() / JUMP_HOP_TILE_ATLAS_COLS; + let cell_h = source.height() / JUMP_HOP_TILE_ATLAS_ROWS; + println!("固定 cell: {}×{} px", cell_w, cell_h); + + let slices = slice_jump_hop_tile_atlas(&cleaned) + .expect("固定网格切片应该成功"); + assert_eq!(slices.len(), JUMP_HOP_TILE_ITEM_COUNT); + + let mut total_faces = 0usize; + for (index, slice) in slices.iter().enumerate() { + let r = index as u32 / JUMP_HOP_TILE_ATLAS_COLS; + let c = index as u32 % JUMP_HOP_TILE_ATLAS_COLS; + let tile_dir = output_root.join(format!( + "tile-{:02}-{:?}-row{}-col{}", + index + 1, slice.tile_type, r + 1, c + 1 + )); + std::fs::create_dir_all(&tile_dir).expect("创建 tile 目录"); + + let faces: [(&str, &JumpHopTileFaceSlice); 6] = [ + ("top", &slice.faces.top), + ("front", &slice.faces.front), + ("right", &slice.faces.right), + ("back", &slice.faces.back), + ("left", &slice.faces.left), + ("bottom", &slice.faces.bottom), + ]; + for (face_name, face) in &faces { + total_faces += 1; + let filename = format!( + "{}-{}.png", + face_name, + face.source_atlas_cell.replace('/', "-") + ); + std::fs::write(tile_dir.join(&filename), &face.bytes) + .expect("保存面贴图"); + } + } + + assert_eq!(total_faces, JUMP_HOP_TILE_ITEM_COUNT * 6); + println!("输出: {} 张面贴图", total_faces); + + // 保存 3×6 cell 网格中间切图 + let cells_dir = output_root.join("cells"); + std::fs::create_dir_all(&cells_dir).expect("创建 cells 目录"); + for row in 0..JUMP_HOP_TILE_ATLAS_ROWS { + for col in 0..JUMP_HOP_TILE_ATLAS_COLS { + let x0 = col * source.width() / JUMP_HOP_TILE_ATLAS_COLS; + let x1 = (col + 1) * source.width() / JUMP_HOP_TILE_ATLAS_COLS; + let y0 = row * source.height() / JUMP_HOP_TILE_ATLAS_ROWS; + let y1 = (row + 1) * source.height() / JUMP_HOP_TILE_ATLAS_ROWS; + let cell_img = image::imageops::crop_imm(&source, x0, y0, x1 - x0, y1 - y0); + cell_img.to_image().save( + cells_dir.join(format!("cell-row{}-col{}.png", row + 1, col + 1)) + ).expect("保存 cell 切图"); + } + } + println!("保存 {}×{} cell 网格切图", JUMP_HOP_TILE_ATLAS_ROWS, JUMP_HOP_TILE_ATLAS_COLS); + + println!("\n====== 固定网格测试完成 ======"); + println!("输出目录: {}", output_root.display()); + println!("==============================\n"); + } } diff --git a/server-rs/crates/api-server/src/jump_hop_atlas_slicing.rs b/server-rs/crates/api-server/src/jump_hop_atlas_slicing.rs new file mode 100644 index 00000000..59795be9 --- /dev/null +++ b/server-rs/crates/api-server/src/jump_hop_atlas_slicing.rs @@ -0,0 +1,928 @@ +// 跳一跳图集自适应切片算法模块。 +// 提供两种基于图像内容的自适应 cell 边界检测算法: +// - SeedRefinement: 种子点精修(默认),在固定网格分界线附近搜索 density 最低点。 +// - ValleyDetection: 全谷检测,高斯平滑 + 自适应阈值 + 合并 + 滑窗选最优。 +// 两种算法均参数化,支持任意 rows × cols 网格配置,默认 6×3。 + +use axum::http::StatusCode; +use image; +use serde_json::json; + +use crate::{ + http_error::AppError, + jump_hop::{ + JumpHopTileAtlasSlice, JumpHopTileFaceSlice, JumpHopTileFaceSlices, + JUMP_HOP_CREATION_PROVIDER, JUMP_HOP_TILE_UV_FACE_COLS, JUMP_HOP_TILE_UV_FACE_ROWS, + crop_jump_hop_tile_texture_cell, jump_hop_tile_face_key_label, + jump_hop_tile_type_by_index, + }, + openai_image_generation::DownloadedOpenAiImage, +}; +use shared_contracts::jump_hop::JumpHopTileFaceKey; + +/// 默认 tile 行数 +pub(crate) const DEFAULT_TILE_ROWS: u32 = 6; +/// 默认 tile 列数 +pub(crate) const DEFAULT_TILE_COLS: u32 = 3; + +/// 自适应切片算法类型(控制 atlas 级 6×3 cell 网格检测) +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum AtlasSliceAlgorithm { + /// 种子点精修:在固定网格分界线附近搜索 density 最低点(默认) + SeedRefinement, + /// 全谷检测:高斯平滑 + 自适应阈值 + 合并 + 滑窗选最优 + ValleyDetection, +} + +impl Default for AtlasSliceAlgorithm { + fn default() -> Self { + Self::SeedRefinement + } +} + +/// 自适应 cell 网格检测结果 +#[derive(Clone, Debug)] +pub(crate) struct AdaptiveCellGrid { + /// 行边界位置 [height],长度 = rows + 1 + pub row_boundaries: Vec, + /// 列边界位置 [width],长度 = cols + 1 + pub col_boundaries: Vec, + /// 使用的算法 + #[allow(dead_code)] + pub algorithm: AtlasSliceAlgorithm, +} + +// ============================================================================ +// Density 计算 +// ============================================================================ + +/// 从 RGBA 像素计算行投影 density(每行非透明像素占比) +pub(crate) fn compute_row_density(pixels: &[u8], width: u32, height: u32) -> Vec { + let w = width as usize; + let h = height as usize; + let stride = w * 4; + let mut density = vec![0.0f32; h]; + let total = w as f32; + for y in 0..h { + let row_start = y * stride; + let mut content = 0u32; + for x in 0..w { + if pixels[row_start + x * 4 + 3] > 0 { + content += 1; + } + } + density[y] = content as f32 / total; + } + density +} + +/// 从 RGBA 像素计算列投影 density(每列非透明像素占比) +pub(crate) fn compute_col_density(pixels: &[u8], width: u32, height: u32) -> Vec { + let w = width as usize; + let h = height as usize; + let stride = w * 4; + let mut density = vec![0.0f32; w]; + let total = h as f32; + for x in 0..w { + let mut content = 0u32; + for y in 0..h { + if pixels[y * stride + x * 4 + 3] > 0 { + content += 1; + } + } + density[x] = content as f32 / total; + } + density +} + +// ============================================================================ +// 共享工具 +// ============================================================================ + +/// 在 [seed-radius, seed+radius] 范围内找 density 最小值的 index +fn find_min_density_position(density: &[f32], seed: u32, radius: u32) -> u32 { + let lo = seed.saturating_sub(radius) as usize; + let hi = (seed + radius).min(density.len().saturating_sub(1) as u32) as usize; + if lo >= density.len() || lo > hi { + return seed; + } + let mut best = seed as usize; + let mut best_val = density[best.min(density.len() - 1)]; + for i in lo..=hi { + if density[i] < best_val { + best_val = density[i]; + best = i; + } + } + best as u32 +} + +/// 保证边界单调递增(禁止交叉) +fn enforce_monotonic(boundaries: &mut [u32]) { + for i in 1..boundaries.len() { + if boundaries[i] <= boundaries[i - 1] { + boundaries[i] = boundaries[i - 1] + 1; + } + } +} + +// ============================================================================ +// 算法 A: 种子点精修 (Seed Refinement) +// ============================================================================ + +/// 种子点精修:对每条固定网格分界线,在 ±radius 搜索窗口内找 density 最低点。 +/// +/// * `density` - 一维投影 density 序列 +/// * `seeds` - 固定网格分界线位置(不含 0 和 max) +/// * `radius` - 搜索半径 +/// +/// 返回精修后的分界线位置(不含 0 和 max)。 +pub(crate) fn refine_boundaries_seed( + density: &[f32], + seeds: &[u32], + radius: u32, +) -> Vec { + let mut refined = Vec::with_capacity(seeds.len()); + for &seed in seeds { + let pos = find_min_density_position(density, seed, radius); + refined.push(pos); + } + enforce_monotonic(&mut refined); + refined +} + +/// 种子点精修完整流程:计算 density → 生成种子 → 精修 → 组装边界 +pub(crate) fn detect_cell_grid_seed( + pixels: &[u8], + width: u32, + height: u32, + rows: u32, + cols: u32, +) -> AdaptiveCellGrid { + let row_density = compute_row_density(pixels, width, height); + let col_density = compute_col_density(pixels, width, height); + + let cell_height = (height / rows).max(1); + let cell_width = (width / cols).max(1); + let radius_row = (cell_height / 3).max(1); + let radius_col = (cell_width / 3).max(1); + + let row_seeds: Vec = (1..rows).map(|i| i * height / rows).collect(); + let col_seeds: Vec = (1..cols).map(|i| i * width / cols).collect(); + + let row_splits = refine_boundaries_seed(&row_density, &row_seeds, radius_row); + let col_splits = refine_boundaries_seed(&col_density, &col_seeds, radius_col); + + let mut row_boundaries = vec![0u32]; + row_boundaries.extend(row_splits); + row_boundaries.push(height); + + let mut col_boundaries = vec![0u32]; + col_boundaries.extend(col_splits); + col_boundaries.push(width); + + AdaptiveCellGrid { + row_boundaries, + col_boundaries, + algorithm: AtlasSliceAlgorithm::SeedRefinement, + } +} + +// ============================================================================ +// 算法 B: 谷检测 (Valley Detection) +// ============================================================================ + +/// 一维高斯平滑核 +fn gaussian_smooth_1d(signal: &[f32], sigma: f32) -> Vec { + let n = signal.len(); + if n == 0 { + return vec![]; + } + let radius = (sigma * 3.0).ceil() as isize; + let mut kernel = Vec::new(); + let mut kernel_sum = 0.0f32; + for i in -radius..=radius { + let w = (-(i as f32).powi(2) / (2.0 * sigma * sigma)).exp(); + kernel.push(w); + kernel_sum += w; + } + for w in &mut kernel { + *w /= kernel_sum; + } + + let mut result = vec![0.0f32; n]; + for i in 0..n { + let mut acc = 0.0f32; + let mut w_sum = 0.0f32; + for (k, &w) in kernel.iter().enumerate() { + let idx = i as isize + k as isize - radius; + if idx >= 0 && idx < n as isize { + acc += signal[idx as usize] * w; + w_sum += w; + } + } + if w_sum > 0.0 { + result[i] = acc / w_sum; + } + } + result +} + +/// 低于阈值的连续区间 → 候选谷列表 +fn extract_valleys_below_threshold( + signal: &[f32], + threshold: f32, +) -> Vec<(usize, usize)> { + let n = signal.len(); + let mut valleys = Vec::new(); + let mut in_valley = false; + let mut start = 0usize; + + for i in 0..n { + if signal[i] <= threshold { + if !in_valley { + start = i; + in_valley = true; + } + } else if in_valley { + valleys.push((start, i - 1)); + in_valley = false; + } + } + if in_valley { + valleys.push((start, n - 1)); + } + valleys +} + +/// 合并间距 < min_gap 的相邻谷 +fn merge_close_valleys( + valleys: &[(usize, usize)], + min_gap: usize, +) -> Vec<(usize, usize)> { + if valleys.is_empty() { + return vec![]; + } + let mut merged = Vec::new(); + let mut cur_start = valleys[0].0; + let mut cur_end = valleys[0].1; + + for &(s, e) in &valleys[1..] { + if s - cur_end <= min_gap { + cur_end = e; + } else { + merged.push((cur_start, cur_end)); + cur_start = s; + cur_end = e; + } + } + merged.push((cur_start, cur_end)); + merged +} + +/// 谷的几何中心 +fn valley_centers(valleys: &[(usize, usize)]) -> Vec { + valleys.iter().map(|&(s, e)| ((s + e) / 2) as u32).collect() +} + +/// 滑窗选最优 target_count 个谷:枚举连续组合,选间距最均匀的一组 +fn select_spaced_valleys( + centers: &[u32], + expected_spacing: f32, + target_count: usize, +) -> Vec { + if centers.len() <= target_count { + return centers.to_vec(); + } + let mut best_score = f32::MAX; + let mut best_centers = vec![]; + + for start in 0..=centers.len() - target_count { + let window = ¢ers[start..start + target_count]; + let mut score = 0.0f32; + for i in 1..window.len() { + let ratio = (window[i] - window[i - 1]) as f32 / expected_spacing; + score += (ratio - 1.0).powi(2); + } + if score < best_score { + best_score = score; + best_centers = window.to_vec(); + } + } + best_centers +} + +/// 谷检测完整流程 +pub(crate) fn refine_boundaries_valley( + density: &[f32], + expected_cell_count: u32, + expected_cell_size: f32, + total_length: u32, +) -> Result, &'static str> { + if expected_cell_count <= 1 { + return Ok(vec![]); + } + let expected_valleys = (expected_cell_count - 1) as usize; + + // 步骤1: 高斯平滑 + let sigma = expected_cell_size / 4.0; + let smoothed = gaussian_smooth_1d(density, sigma); + + // 步骤2: 自适应阈值 + let peak = smoothed.iter().cloned().fold(0.0f32, f32::max); + let threshold = f32::max(peak * 0.15, 0.02); + + // 步骤3: 提取候选谷 + let raw_valleys = extract_valleys_below_threshold(&smoothed, threshold); + if raw_valleys.is_empty() { + return Err("未检测到候选谷"); + } + + // 步骤4: 合并相邻谷 + let min_gap = (expected_cell_size * 0.5) as usize; + let merged = merge_close_valleys(&raw_valleys, min_gap); + + // 步骤5: 过滤窄噪声谷(宽度 < 3px) + let filtered: Vec<_> = merged + .into_iter() + .filter(|&(s, e)| e >= s && e - s >= 3) + .collect(); + if filtered.is_empty() { + return Err("过滤后无有效谷"); + } + + let centers = valley_centers(&filtered); + + // 步骤6: 候选太多时按谷深排序取 top + let candidates = if centers.len() > expected_valleys + 2 { + let mut scored: Vec<_> = filtered + .iter() + .map(|&(s, e)| { + let avg = smoothed[s..=e].iter().sum::() / (e - s + 1) as f32; + let depth = peak - avg; + (depth, (s + e) / 2) + }) + .collect(); + scored.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal)); + scored.truncate(expected_valleys + 2); + let mut c: Vec = scored.into_iter().map(|(_, center)| center as u32).collect(); + c.sort(); + c + } else { + centers + }; + + // 步骤7: 滑窗选最优 expected_valleys 个 + let selected = select_spaced_valleys(&candidates, expected_cell_size, expected_valleys); + + // 步骤8: 校验间距合理性 + let min_spacing = (expected_cell_size * 0.5) as u32; + let max_spacing = (expected_cell_size * 1.8) as u32; + for i in 1..selected.len() { + let gap = selected[i] - selected[i - 1]; + if gap < min_spacing || gap > max_spacing { + return Err("谷间距异常"); + } + } + // 首尾不能太靠边 + let min_edge = (expected_cell_size / 3.0) as u32; + if selected[0] < min_edge || total_length - selected[selected.len() - 1] < min_edge { + return Err("谷太靠近边界"); + } + + Ok(selected) +} + +/// 谷检测完整流程:计算 density → 谷检测 → 组装边界 +pub(crate) fn detect_cell_grid_valley( + pixels: &[u8], + width: u32, + height: u32, + rows: u32, + cols: u32, +) -> Result { + let row_density = compute_row_density(pixels, width, height); + let col_density = compute_col_density(pixels, width, height); + + let cell_height = (height / rows).max(1) as f32; + let cell_width = (width / cols).max(1) as f32; + + let row_splits = refine_boundaries_valley(&row_density, rows, cell_height, height)?; + let col_splits = refine_boundaries_valley(&col_density, cols, cell_width, width)?; + + let mut row_boundaries = vec![0u32]; + row_boundaries.extend(row_splits); + row_boundaries.push(height); + + let mut col_boundaries = vec![0u32]; + col_boundaries.extend(col_splits); + col_boundaries.push(width); + + Ok(AdaptiveCellGrid { + row_boundaries, + col_boundaries, + algorithm: AtlasSliceAlgorithm::ValleyDetection, + }) +} + +// ============================================================================ +// 主入口:自适应切片 +// ============================================================================ + +/// 使用自适应算法对洋红去背后的图集进行切片。 +/// +/// * `image` - 洋红去背后的图集图片 +/// * `rows` - cell 行数(默认 6) +/// * `cols` - cell 列数(默认 3) +/// * `algorithm` - 自适应算法 +pub(crate) fn slice_tile_atlas_adaptive( + image: &DownloadedOpenAiImage, + rows: u32, + cols: u32, + algorithm: AtlasSliceAlgorithm, +) -> Result, AppError> { + let source = image::load_from_memory(image.bytes.as_slice()) + .map_err(|error| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": JUMP_HOP_CREATION_PROVIDER, + "message": format!("跳一跳地板贴图图集解码失败:{error}"), + })) + })? + .to_rgba8(); + let width = source.width(); + let height = source.height(); + let pixels = source.as_raw(); + + // 自适应检测 cell 网格 + let grid = match algorithm { + AtlasSliceAlgorithm::SeedRefinement => { + detect_cell_grid_seed(pixels, width, height, rows, cols) + } + AtlasSliceAlgorithm::ValleyDetection => { + detect_cell_grid_valley(pixels, width, height, rows, cols) + .unwrap_or_else(|_| { + // 谷检测失败时回退到种子点精修 + detect_cell_grid_seed(pixels, width, height, rows, cols) + }) + } + }; + + if grid.row_boundaries.len() != (rows + 1) as usize + || grid.col_boundaries.len() != (cols + 1) as usize + { + return Err(AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": JUMP_HOP_CREATION_PROVIDER, + "message": format!( + "自适应网格检测结果异常:期望 {}×{},实际 {}×{}", + rows + 1, + cols + 1, + grid.row_boundaries.len(), + grid.col_boundaries.len(), + ), + }))); + } + + let tile_count = (rows * cols) as usize; + let mut slices = Vec::with_capacity(tile_count); + let mut index = 0usize; + + for row in 0..rows { + for col in 0..cols { + let x0 = grid.col_boundaries[col as usize]; + let x1 = grid.col_boundaries[col as usize + 1]; + let y0 = grid.row_boundaries[row as usize]; + let y1 = grid.row_boundaries[row as usize + 1]; + let tile_width = x1.saturating_sub(x0).max(1); + let tile_height = y1.saturating_sub(y0).max(1); + + let faces = slice_jump_hop_tile_uv_faces_blob( + &source, + x0, + y0, + tile_width, + tile_height, + row, + col, + )?; + + slices.push(JumpHopTileAtlasSlice { + tile_type: jump_hop_tile_type_by_index(index), + source_atlas_cell: format!("row-{}-col-{}", row + 1, col + 1), + faces, + }); + index += 1; + } + } + + Ok(slices) +} + +// ============================================================================ +// Cell 内 UV 面提取(与固定网格逻辑相同,接收 cell 边界参数) +// ============================================================================ + +fn slice_jump_hop_tile_uv_faces_adaptive( + source: &image::RgbaImage, + tile_x: u32, + tile_y: u32, + tile_width: u32, + tile_height: u32, + atlas_row: u32, + atlas_col: u32, +) -> Result { + let face_side = (tile_width / JUMP_HOP_TILE_UV_FACE_COLS) + .min(tile_height / JUMP_HOP_TILE_UV_FACE_ROWS) + .max(1); + let uv_width = face_side.saturating_mul(JUMP_HOP_TILE_UV_FACE_COLS); + let uv_height = face_side.saturating_mul(JUMP_HOP_TILE_UV_FACE_ROWS); + let uv_x = tile_x.saturating_add(tile_width.saturating_sub(uv_width) / 2); + let uv_y = tile_y.saturating_add(tile_height.saturating_sub(uv_height) / 2); + + Ok(JumpHopTileFaceSlices { + top: slice_jump_hop_tile_uv_face_adaptive( + source, uv_x, uv_y, face_side, atlas_row, atlas_col, JumpHopTileFaceKey::Top, 1, 0, + )?, + front: slice_jump_hop_tile_uv_face_adaptive( + source, uv_x, uv_y, face_side, atlas_row, atlas_col, JumpHopTileFaceKey::Front, 1, 1, + )?, + right: slice_jump_hop_tile_uv_face_adaptive( + source, uv_x, uv_y, face_side, atlas_row, atlas_col, JumpHopTileFaceKey::Right, 2, 1, + )?, + back: slice_jump_hop_tile_uv_face_adaptive( + source, uv_x, uv_y, face_side, atlas_row, atlas_col, JumpHopTileFaceKey::Back, 3, 1, + )?, + left: slice_jump_hop_tile_uv_face_adaptive( + source, uv_x, uv_y, face_side, atlas_row, atlas_col, JumpHopTileFaceKey::Left, 0, 1, + )?, + bottom: slice_jump_hop_tile_uv_face_adaptive( + source, uv_x, uv_y, face_side, atlas_row, atlas_col, JumpHopTileFaceKey::Bottom, 1, 2, + )?, + }) +} + +#[allow(clippy::too_many_arguments)] +fn slice_jump_hop_tile_uv_face_adaptive( + source: &image::RgbaImage, + uv_x: u32, + uv_y: u32, + face_side: u32, + atlas_row: u32, + atlas_col: u32, + face: JumpHopTileFaceKey, + face_col: u32, + face_row: u32, +) -> Result { + let cleaned = crop_jump_hop_tile_texture_cell( + source, + uv_x.saturating_add(face_col.saturating_mul(face_side)), + uv_y.saturating_add(face_row.saturating_mul(face_side)), + face_side, + face_side, + ); + let mut cursor = std::io::Cursor::new(Vec::new()); + cleaned + .write_to(&mut cursor, image::ImageFormat::Png) + .map_err(|error| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": JUMP_HOP_CREATION_PROVIDER, + "message": format!("跳一跳地板 UV 面贴图切割失败:{error}"), + })) + })?; + let face_label = jump_hop_tile_face_key_label(&face); + + Ok(JumpHopTileFaceSlice { + face, + source_atlas_cell: format!( + "row-{}-col-{}/{}", + atlas_row + 1, + atlas_col + 1, + face_label + ), + bytes: cursor.into_inner(), + }) +} + +// ============================================================================ +// Blob + Gradient 驱动 UV 面切分 +// +// 1. BFS 找主 blob,构造仅含 blob 的 cleaned 图像 +// 2. 行/列 density → 平滑 → gradient → 8 边界 +// 3. 3×3 block → 5 有效块 → Block(1,2) 拆分 → 6 块 +// 4. 每块 max opaque rectangle → 缩放 +// ============================================================================ + +const BLOB_ALPHA: u8 = 48; +const MIN_BLOB_AREA: usize = 64; +const GRAD_SMOOTH: usize = 3; + +// ---- 1. BFS 主 blob + 构造 cleaned 图像 ---- + +fn build_cleaned_tile( + source: &image::RgbaImage, + tile_x: u32, tile_y: u32, tile_w: u32, tile_h: u32, +) -> Option { + let pixels = source.as_raw(); + let sw = source.width() as usize; + let stride = sw * 4; + let total = (tile_w * tile_h) as usize; + let mut visited = vec![false; total]; + let mut queue = Vec::::new(); + let mut best_comp = Vec::::new(); + + let idx = |lx: u32, ly: u32| (ly * tile_w + lx) as usize; + + for sy in 0..tile_h { + for sx in 0..tile_w { + let si = idx(sx, sy); + if visited[si] { continue; } + let go = (tile_y as usize + sy as usize) * stride + (tile_x as usize + sx as usize) * 4; + if pixels[go + 3] < BLOB_ALPHA { continue; } + + queue.clear(); + queue.push(si); + visited[si] = true; + let mut qi = 0; + while qi < queue.len() { + let cur = queue[qi]; qi += 1; + let cx = cur as u32 % tile_w; + let cy = cur as u32 / tile_w; + for (dx, dy) in [(1i32,0i32),(-1,0),(0,1),(0,-1)] { + let nx = cx as i32 + dx; + let ny = cy as i32 + dy; + if nx < 0 || nx >= tile_w as i32 || ny < 0 || ny >= tile_h as i32 { continue; } + let ni = idx(nx as u32, ny as u32); + if visited[ni] { continue; } + let ngo = (tile_y as usize + ny as usize) * stride + (tile_x as usize + nx as usize) * 4; + if pixels[ngo + 3] >= BLOB_ALPHA { + visited[ni] = true; + queue.push(ni); + } + } + } + let area = queue.len(); + if area < MIN_BLOB_AREA { continue; } + if area > best_comp.len() { + best_comp = queue.clone(); + } + } + } + + if best_comp.is_empty() { return None; } + + // 构造 cleaned 图像:仅含主 blob + let mut cleaned = image::RgbaImage::new(tile_w, tile_h); + for &pi in &best_comp { + let lx = pi as u32 % tile_w; + let ly = pi as u32 / tile_w; + let gx = tile_x + lx; + let gy = tile_y + ly; + let go = (gy as usize * sw + gx as usize) * 4; + cleaned.put_pixel(lx, ly, image::Rgba([pixels[go], pixels[go+1], pixels[go+2], pixels[go+3]])); + } + Some(cleaned) +} + +// ---- 2. density + gradient → 边界 ---- + +fn smooth_1d(signal: &[f32], window: usize) -> Vec { + if signal.len() <= window { return signal.to_vec(); } + let hw = window / 2; + (0..signal.len()).map(|i| { + let lo = i.saturating_sub(hw); + let hi = (i + hw).min(signal.len() - 1); + let sum: f32 = signal[lo..=hi].iter().sum(); + sum / (hi - lo + 1) as f32 + }).collect() +} + +fn gradient(signal: &[f32]) -> Vec { + if signal.len() < 2 { return vec![]; } + (0..signal.len()-1).map(|i| signal[i+1] - signal[i]).collect() +} + +/// 在 gradient 中找最强上升沿 (positive) 和最强下降沿 (negative) 的位置。 +/// 返回 (peak_idx_pos, peak_idx_neg) 中最显著的 4 个位置,按值大小排序。 +struct GradPeak { idx: usize, val: f32 } +fn find_gradient_peaks(grad: &[f32], count: usize, min_sep: usize) -> Vec { + if grad.len() < 2 { return vec![]; } + // 取绝对值后找局部极大 + let abs_grad: Vec = grad.iter().map(|&g| g.abs()).collect(); + let mut peaks: Vec = (1..abs_grad.len()-1) + .filter(|&i| abs_grad[i] > abs_grad[i-1] && abs_grad[i] >= abs_grad[i+1] && abs_grad[i] > 0.0) + .map(|i| GradPeak { idx: i, val: grad[i] }) // 保留符号 + .collect(); + peaks.sort_by(|a, b| b.val.abs().partial_cmp(&a.val.abs()).unwrap_or(std::cmp::Ordering::Equal)); + // 去重:间距小于 min_sep 的只保留最大者 + let mut chosen = Vec::new(); + for p in peaks { + if chosen.iter().all(|c: &GradPeak| (c.idx as isize - p.idx as isize).unsigned_abs() >= min_sep) { + chosen.push(p); + if chosen.len() >= count { break; } + } + } + chosen +} + +/// 行 density → gradient → y₀,y₁,y₂,y₃ +fn detect_row_boundaries(cleaned: &image::RgbaImage, tile_w: u32, tile_h: u32) -> Option<(u32,u32,u32,u32)> { + // 行 density + let mut row_density = Vec::with_capacity(tile_h as usize); + for y in 0..tile_h { + let mut cnt = 0u32; + for x in 0..tile_w { + if cleaned.get_pixel(x, y).0[3] >= BLOB_ALPHA { cnt += 1; } + } + row_density.push(cnt as f32 / tile_w as f32); + } + let smooth = smooth_1d(&row_density, GRAD_SMOOTH); + let grad = gradient(&smooth); + let peaks = find_gradient_peaks(&grad, 4, 4); + if peaks.len() < 2 { return None; } + + // 分离正负 + let pos: Vec<_> = peaks.iter().filter(|p| p.val > 0.0).collect(); + let neg: Vec<_> = peaks.iter().filter(|p| p.val < 0.0).collect(); + if pos.len() < 1 || neg.len() < 1 { return None; } + + // y₁: 最强正峰(窄→宽); y₂: 最强负峰(宽→窄) + let y1 = pos[0].idx as u32; + let y2 = neg[0].idx as u32; + let y0 = row_density.iter().position(|&d| d > 0.0).unwrap_or(0) as u32; + let y3 = (row_density.len() as u32).saturating_sub( + 1 + row_density.iter().rev().position(|&d| d > 0.0).unwrap_or(0) as u32 + ) + 1; + + if y1 < y0 + 2 || y2 <= y1 + 6 || y2 > y3.saturating_sub(2) { return None; } + Some((y0, y1, y2, y3)) +} + +/// 列高度 profile(每列 blob 的首次/末次行)→ gradient → x₀,x₁,x₂,x₃ +fn detect_col_boundaries(cleaned: &image::RgbaImage, tile_w: u32, _tile_h: u32, y0: u32, y3: u32) -> Option<(u32,u32,u32,u32)> { + // 每列的 blob 高度 + let mut col_height = Vec::with_capacity(tile_w as usize); + for x in 0..tile_w { + let first = (y0..y3).find(|&y| cleaned.get_pixel(x, y).0[3] >= BLOB_ALPHA); + let last = (y0..y3).rev().find(|&y| cleaned.get_pixel(x, y).0[3] >= BLOB_ALPHA); + col_height.push( + first.map_or(0.0, |f| { + let l = last.unwrap_or(f); + (l - f + 1) as f32 / (y3 - y0).max(1) as f32 + }) + ); + } + let smooth = smooth_1d(&col_height, GRAD_SMOOTH); + let grad = gradient(&smooth); + let peaks = find_gradient_peaks(&grad, 4, 4); + if peaks.len() < 2 { return None; } + + let pos: Vec<_> = peaks.iter().filter(|p| p.val > 0.0).collect(); + let neg: Vec<_> = peaks.iter().filter(|p| p.val < 0.0).collect(); + if pos.len() < 1 || neg.len() < 1 { return None; } + + let x1 = pos[0].idx as u32; + let x2 = neg[0].idx as u32; + let x0 = col_height.iter().position(|&d| d > 0.0).unwrap_or(0) as u32; + let x3 = (tile_w as usize).saturating_sub( + 1 + col_height.iter().rev().position(|&d| d > 0.0).unwrap_or(0) + ) as u32 + 1; + + if x1 < x0 + 2 || x2 <= x1 + 6 || x2 > x3.saturating_sub(2) { return None; } + Some((x0, x1, x2, x3)) +} + +// ---- 3. max opaque rectangle per block ---- + +/// 在 block 范围内基于 histogram 找最大全不透明矩形。 +fn max_opaque_rect( + cleaned: &image::RgbaImage, + bx0: u32, by0: u32, bw: u32, bh: u32, +) -> Option<(u32, u32, u32, u32)> { + let mut heights = vec![0u32; bw as usize]; + let mut best_area = 0u32; + let mut best = (0u32, 0u32, 1u32, 1u32); + + for ly in 0..bh { + for lx in 0..bw { + if cleaned.get_pixel(bx0 + lx, by0 + ly).0[3] >= BLOB_ALPHA { + heights[lx as usize] += 1; + } else { + heights[lx as usize] = 0; + } + } + // histogram max rect + let mut stack: Vec<(u32, u32)> = Vec::new(); // (start_x, height) + for (x, &h) in heights.iter().enumerate() { + let x = x as u32; + let mut start = x; + while stack.last().map_or(false, |&(_, sh)| sh > h) { + let (sx, sh) = stack.pop().unwrap(); + let area = sh * (x - sx); + if area > best_area { + best_area = area; + best = (bx0 + sx, by0 + ly - sh + 1, x - sx, sh); + } + start = sx; + } + if h > 0 && stack.last().map_or(true, |&(_, sh)| h > sh) { + stack.push((start, h)); + } + } + let x = bw; + while let Some((sx, sh)) = stack.pop() { + let area = sh * (x - sx); + if area > best_area { + best_area = area; + best = (bx0 + sx, by0 + ly as u32 - sh + 1, x - sx, sh); + } + } + } + if best_area == 0 { None } else { Some(best) } +} + +// ---- 4. 主编排 ---- + +fn slice_jump_hop_tile_uv_faces_blob( + source: &image::RgbaImage, + tile_x: u32, tile_y: u32, tile_w: u32, tile_h: u32, + atlas_row: u32, atlas_col: u32, +) -> Result { + let fallback = || { + slice_jump_hop_tile_uv_faces_adaptive(source, tile_x, tile_y, tile_w, tile_h, atlas_row, atlas_col) + }; + + // Step 1: BFS 主 blob → cleaned 图像 + let cleaned = match build_cleaned_tile(source, tile_x, tile_y, tile_w, tile_h) { + Some(c) => c, + None => return fallback(), + }; + + // Step 2: gradient 边界检测 + let (y0, y1, y2, y3) = match detect_row_boundaries(&cleaned, tile_w, tile_h) { + Some(v) => v, + None => return fallback(), + }; + let (x0, x1, x2, x3) = match detect_col_boundaries(&cleaned, tile_w, tile_h, y0, y3) { + Some(v) => v, + None => return fallback(), + }; + + // Step 3: 3×3 block → 5 有效块 + Block(1,2) 拆分 → 6 块 + // blocks: (row, col): 0=Top, 1,0=Left, 1,1=Front, 1,2=Right+Back, 2=Bottom + let blocks: [(u32,u32,u32,u32); 5] = [ + (x1, y0, x2 - x1, y1 - y0), // Top + (x0, y1, x1 - x0, y2 - y1), // Left + (x1, y1, x2 - x1, y2 - y1), // Front + (x2, y1, x3 - x2, y2 - y1), // Right+Back + (x1, y2, x2 - x1, y3 - y2), // Bottom + ]; + + // Step 4: max opaque rectangle per block + let rect = |b: (u32,u32,u32,u32), name: &str| -> Result<(u32,u32,u32,u32), AppError> { + max_opaque_rect(&cleaned, b.0, b.1, b.2, b.3).ok_or_else(|| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": JUMP_HOP_CREATION_PROVIDER, + "message": format!("blob gradient: {name} 面无有效内容"), + })) + }) + }; + + let top = rect(blocks[0], "Top")?; + let left = rect(blocks[1], "Left")?; + let front = rect(blocks[2], "Front")?; + // Right+Back → 从中点拆分 + let (rb_x0, rb_y0, rb_w, rb_h) = blocks[3]; + let mid = rb_x0 + rb_w / 2; + let right_rect = rect((rb_x0, rb_y0, mid - rb_x0, rb_h), "Right")?; + let back_rect = rect((mid, rb_y0, rb_x0 + rb_w - mid, rb_h), "Back")?; + let bottom = rect(blocks[4], "Bottom")?; + + // Step 5: crop (tile_local → global) + let global = |r: (u32,u32,u32,u32)| (tile_x + r.0, tile_y + r.1, r.2, r.3); + + let mk = |r: (u32,u32,u32,u32), face: JumpHopTileFaceKey| -> Result { + let (gx, gy, gw, gh) = global(r); + let cleaned_dyn = crop_jump_hop_tile_texture_cell(source, gx, gy, gw, gh); + let mut cursor = std::io::Cursor::new(Vec::new()); + cleaned_dyn.write_to(&mut cursor, image::ImageFormat::Png).map_err(|e| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": JUMP_HOP_CREATION_PROVIDER, + "message": format!("跳一跳地板 UV 面贴图切割失败:{e}"), + })) + })?; + let label = jump_hop_tile_face_key_label(&face); + Ok(JumpHopTileFaceSlice { + face, + source_atlas_cell: format!("row-{}-col-{}/{}", atlas_row + 1, atlas_col + 1, label), + bytes: cursor.into_inner(), + }) + }; + + Ok(JumpHopTileFaceSlices { + top: mk(top, JumpHopTileFaceKey::Top)?, + left: mk(left, JumpHopTileFaceKey::Left)?, + front: mk(front, JumpHopTileFaceKey::Front)?, + right: mk(right_rect, JumpHopTileFaceKey::Right)?, + back: mk(back_rect, JumpHopTileFaceKey::Back)?, + bottom: mk(bottom, JumpHopTileFaceKey::Bottom)?, + }) +} diff --git a/server-rs/crates/api-server/src/main.rs b/server-rs/crates/api-server/src/main.rs index 1867c754..7b1845f8 100644 --- a/server-rs/crates/api-server/src/main.rs +++ b/server-rs/crates/api-server/src/main.rs @@ -46,6 +46,7 @@ mod health; mod http_error; mod hyper3d_generation; mod jump_hop; +mod jump_hop_atlas_slicing; mod llm; mod llm_model_routing; mod login_options; diff --git a/server-rs/crates/platform-image/src/generated_asset_sheets/alpha.rs b/server-rs/crates/platform-image/src/generated_asset_sheets/alpha.rs index ac23daf0..a5da9025 100644 --- a/server-rs/crates/platform-image/src/generated_asset_sheets/alpha.rs +++ b/server-rs/crates/platform-image/src/generated_asset_sheets/alpha.rs @@ -141,9 +141,12 @@ fn remove_generated_asset_sheet_green_screen_background( return; } let alpha = pixels[pixel_index * 4 + 3]; + // 中文注释:绿幕模式下 alpha 携带背景/前景信息;洋红/青色等非绿幕模式下 + // 图像通常无 alpha 通道(全部为 255),因此仅依赖 key_score 判断,不关 alpha。 + let ignore_alpha = !options.key_color.is_green_screen(); let strong_candidate = alpha < 40 || key_scores[pixel_index] >= GENERATED_ASSET_SHEET_HARD_GREEN_SCREEN_SCORE - || (alpha < 224 + || ((ignore_alpha || alpha < 224) && key_scores[pixel_index] > GENERATED_ASSET_SHEET_GREEN_SCREEN_MIN_SCORE) || (options.remove_near_white_background && white_scores[pixel_index] > 0.32); if !strong_candidate { @@ -196,13 +199,16 @@ fn remove_generated_asset_sheet_green_screen_background( let key_score = key_scores[next_pixel_index]; let white_score = white_scores[next_pixel_index]; let hint = background_hints[next_pixel_index]; + // 中文注释:非绿幕模式(洋红/青色等)下图像无 alpha 通道,不依赖 alpha 判断边界。 + let ignore_alpha = !options.key_color.is_green_screen(); let reachable_soft_edge = hint > 0.08 - && alpha < 224 + && (ignore_alpha || alpha < 224) && (key_score > 0.04 || (options.remove_near_white_background && white_score > 0.08) || alpha < 180); let key_background = key_score >= GENERATED_ASSET_SHEET_HARD_GREEN_SCREEN_SCORE - || (alpha < 224 && key_score > GENERATED_ASSET_SHEET_GREEN_SCREEN_MIN_SCORE); + || ((ignore_alpha || alpha < 224) + && key_score > GENERATED_ASSET_SHEET_GREEN_SCREEN_MIN_SCORE); if alpha < 40 || key_background || (options.remove_near_white_background && white_score > 0.32)