3 Commits

Author SHA1 Message Date
6bdf84dc0d optimized prompt 2026-06-10 20:34:10 +08:00
09ef80cd23 PRD补充自适应blob+gradient算法说明,保留AI prompt侧4x3 UV布局描述 2026-06-10 19:56:57 +08:00
95df62fc82 跳一跳UV面切分改用blob+gradient自适应算法
重构alpha.rs洋红去背预设参数
新增jump_hop_atlas_slicing.rs独立切图模块
修复jump_hop.rs调用链接入新切图算法
2026-06-10 19:49:39 +08:00
5 changed files with 1838 additions and 131 deletions

View File

@@ -2,7 +2,7 @@
## 1. 目标
`jump-hop` 重定义为竖屏俯视角平台跳跃游戏。创作者只输入主题,系统生成一张该主题的 `1024x1536` 立方体主题物体 UV 展开图集,按 `3列*6行` 容纳 18 个方块,每个方块再按固定 `4列*3行` UV 网切成 top/front/right/back/left/bottom 六张面贴图;运行态使用 Three.js 复用标准 `1x1x1` 等比极小倒角立方体几何体,把六面贴图贴到立方体地板上组成无限平台流,同时使用陶泥儿 logo 透明 PNG 作为玩家角色。
`jump-hop` 重定义为竖屏俯视角平台跳跃游戏。创作者只输入主题,系统生成一张该主题的 `1024x1536` 立方体主题物体 UV 展开图集,按 `3列*6行` 容纳 18 个方块,每个方块内部再用自适应 blob+gradient 算法提取 top/front/right/back/left/bottom 六张面贴图;运行态使用 Three.js 复用标准 `1x1x1` 等比极小倒角立方体几何体,把六面贴图贴到立方体地板上组成无限平台流,同时使用陶泥儿 logo 透明 PNG 作为玩家角色。
首版目标:
@@ -35,10 +35,10 @@
- 单图资产槽位无独立角色图槽位v1 固定使用陶泥儿 logo 透明 PNG 角色
- 系列素材槽位:
- `batchId = jump-hop-tile-atlas`
- `sheetSpec = 1024x1536 / 3列*6行大单元 / 每格4列*3行UV网 / PNG / 纯洋红 #FF00FF 安全缝与外圈背景 / 后端切图为面贴图 PNG`
- `sheetSpec = 1024x1536 / 3列*6行大单元 / 每格内自适应blob+gradient提取六面 / PNG / 纯洋红 #FF00FF 安全缝与外圈背景 / 后端切图为面贴图 PNG`
- `slotSpecs = tile-01 ... tile-18`,每个 tile 再包含 `top/front/right/back/left/bottom` 六个面 slot所有 slot 必须对应唯一 OSS path / `assetObjectId`
- 切图规则:先按原图宽高均分为 3 列 6 行,从上到下、从左到右得到 18 个大单元;每个大单元内部固定 4 列 3 行 UV 网,`top` 在第 1 行第 2 列,`left/front/right/back` 在第 2 行第 1-4 列,`bottom` 在第 3 行第 2 列;每个面输出 `256x256` 不透明 PNG
- 透明化规则:生成时要求纯洋红 key 安全缝和 UV 空位后端不做透明化抠图,只把裁切后残留的洋红 key 色转为不透明材质底色,保留绿色、白色、雪地、云朵、草地、花朵、果肉粉色和浅黄色等主题纹理
- 切图规则:先通过 density 种子点精修自适应检测 3 列 6 行大单元边界(`SeedRefinement`);每个大单元内部先用 BFS 连通域提取主 blob、清除非主 blob 噪点,再对行 density 和列 height profile 做 gradient 分析检测边界y₀/y₁/y₂/y₃、x₀/x₁/x₂/x₃按此边界划分为 3×3 block 并保留 5 个有效 block将含 Right+Back 的 block 从中点拆分为两块,对每个 block 取最大不透明矩形后缩放为 `256x256` 不透明 PNG
- 透明化规则:生成时要求纯洋红 key 安全缝和 UV 空位后端先对图集做洋红去背BFS 漫水 + 镂空洞检测),再对每个大单元内提取主 blob 后进行自适应面切分;切分后在 block 内取最大不透明矩形,消除透明边缘
- 失败回写:生成失败时 session 保持 failed可从生成页重试
- 局部重生成:结果页允许重生成地板贴图图集,仍只调用一次 image2前端展示生成图时以 `assetObjectId` 作为刷新键,避免同一路径重写后的旧签名或旧缓存
- API 命名空间:`/api/creation/jump-hop/*``/api/runtime/jump-hop/*`
@@ -60,11 +60,11 @@
## 5. 地板贴图图集
image2 只生成一张 `1024x1536` 竖版图片,画面为 `3列*6行` 均匀分布的立方体主题物体 UV 展开包装;实际提示词必须先约束“画面只包含 18 个用于跳一跳地板的立方体主题物体 UV 展开包装图”,并明确这是供 Three.js 标准 1x1x1 等比极小倒角立方体使用的 cube object UV unwrap atlas。每个大单元格代表一个完整方块化主题物体并在固定 `4列*3行` UV 网中提供六张面贴图不是单纯材质贴片、单张图重复六面、地块成品图、跳板、物体剪影、游戏界面、棋盘、背包、装备栏或图标集页面。
image2 只生成一张 `1024x1536` 竖版图片,画面为 `3列*6行` 均匀分布的立方体主题物体 UV 展开包装;实际提示词必须先约束“画面只包含 18 个用于跳一跳地板的立方体主题物体 UV 展开包装图”,并明确这是供 Three.js 标准 1x1x1 等比极小倒角立方体使用的 cube object UV unwrap atlas。每个大单元格代表一个完整方块化主题物体并在固定 `4列*3行` UV 网中提供六张面贴图AI prompt 侧不变);后端通过自适应 blob+gradient 算法检测面的实际位置并切图,不再依赖固定像素坐标均分。不是单纯材质贴片、单张图重复六面、地块成品图、跳板、物体剪影、游戏界面、棋盘、背包、装备栏或图标集页面。
图集要求:
1. 每个大单元内部固定使用 `4列*3行` UV 网,只有六个位置有贴图:第 1 行第 2 列是 `top`;第 2 行第 1-4 列依次是 `left / front / right / back`;第 3 行第 2 列是 `bottom`;其它位置保持纯洋红 `#FF00FF`
1. 每个大单元内部固定使用 `4列*3行` UV 网,只有六个位置有贴图:第 1 行第 2 列是 `top`;第 2 行第 1-4 列依次是 `left / front / right / back`;第 3 行第 2 列是 `bottom`;其它位置保持纯洋红 `#FF00FF`。以上为 AI 生图的 layout 要求prompt 侧不变)。后端切图改为自适应 blob+gradient 算法检测面的实际像素区域,不再依赖固定像素坐标均分。
2. 每个面都是 full-bleed 不透明正方形贴图,四角、边缘和中心都要有可识别内容;六个面共同组成同一个完整方块化主题物体,不能把同一张纹理重复六次,也不能六面各画互不相关的小图标;
3. 贴图不生成已经渲染好的透视 3D 块体成品,不包含摄像机角度、已烘焙侧壁、已烘焙厚度、自身投影、接触阴影或烘焙高光;真实倒角、侧壁、透视和阴影由运行态 Three.js 生成;
4. 18 个方块来自同一主题、同一哑光手绘包装体系,但应表达不同方块化主题物体或明显不同的包装识别特征;水果主题要混排方块苹果、方块香蕉、方块橙子、方块西瓜、方块草莓、方块葡萄、方块奇异果、方块菠萝、方块柠檬、方块桃子、方块梨、方块蓝莓、方块芒果、方块椰子、方块火龙果、方块樱桃、方块哈密瓜、方块石榴,不要 18 个方块都只是同一种果皮、果肉或叶脉纹理;
@@ -187,7 +187,7 @@ successfulJumpCount desc -> durationMs asc -> updatedAt asc
1. 创作页只显示主题输入;
2. 生成链路只调用一次地板贴图图集 image2不再调用角色生图
3. 地板贴图图集为 `1024x1536 / 3列*6行 / 每格4列*3行UV网`,后端切出 18 组、共 108 张面贴图 PNG
3. 地板贴图图集为 `1024x1536 / 3列*6行`,后端通过自适应 blob+gradient 算法切出 18 组、共 108 张面贴图 PNG
4. 结果页不依赖旧角色图片槽;
5. 运行态为竖屏俯视角,首屏保持 3 个地块可见;
6. 长按蓄力值影响落点距离,`dragVectorX/dragVectorY` 影响正式落点方向;

File diff suppressed because one or more lines are too long

View File

@@ -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<u32>,
/// 列边界位置 [width],长度 = cols + 1
pub col_boundaries: Vec<u32>,
/// 使用的算法
#[allow(dead_code)]
pub algorithm: AtlasSliceAlgorithm,
}
// ============================================================================
// Density 计算
// ============================================================================
/// 从 RGBA 像素计算行投影 density每行非透明像素占比
pub(crate) fn compute_row_density(pixels: &[u8], width: u32, height: u32) -> Vec<f32> {
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<f32> {
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<u32> {
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<u32> = (1..rows).map(|i| i * height / rows).collect();
let col_seeds: Vec<u32> = (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<f32> {
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<u32> {
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<u32> {
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 = &centers[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<Vec<u32>, &'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::<f32>() / (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<u32> = 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<AdaptiveCellGrid, &'static str> {
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<Vec<JumpHopTileAtlasSlice>, 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<JumpHopTileFaceSlices, AppError> {
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<JumpHopTileFaceSlice, AppError> {
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<image::RgbaImage> {
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::<usize>::new();
let mut best_comp = Vec::<usize>::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<f32> {
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<f32> {
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<GradPeak> {
if grad.len() < 2 { return vec![]; }
// 取绝对值后找局部极大
let abs_grad: Vec<f32> = grad.iter().map(|&g| g.abs()).collect();
let mut peaks: Vec<GradPeak> = (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<JumpHopTileFaceSlices, AppError> {
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<JumpHopTileFaceSlice, AppError> {
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)?,
})
}

View File

@@ -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;

View File

@@ -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)