fix(jump-hop): clean magenta cutout fringes

This commit is contained in:
2026-06-05 13:23:20 +08:00
parent cb08c9ad20
commit 0edcb1b9f1
5 changed files with 202 additions and 12 deletions

View File

@@ -146,7 +146,7 @@ RPG / 拼图等运行态存档仍以 `/api/profile/save-archives` 的后端列
1. 创作端只保留主题输入,作品标题、简介、标签和地块提示词由系统派生; 1. 创作端只保留主题输入,作品标题、简介、标签和地块提示词由系统派生;
2. v1 不再单独生成角色图片,运行态固定使用抠除白底后的陶泥儿 logo 透明 PNG 作为玩家角色; 2. v1 不再单独生成角色图片,运行态固定使用抠除白底后的陶泥儿 logo 透明 PNG 作为玩家角色;
3. 地块只调用一次 image2输出一张 `5行*5列``1:1`、单一纯洋红 `#FF00FF` key 背景的主题地块图集;跳一跳地块常包含草地、花、雪、白石和云朵,后端透明化必须使用跳一跳专用洋红 key不启用近白底扣除也不清理非边缘连通的 key 色像素避免把绿色或白色主体误扣地块造型提示词要求以主题物体本身外轮廓为准允许苹果近似圆形、香蕉近似长条或长方形、西瓜近似扇形等自然差异只统一单格规格、安全留白、正面30度视角和 2D/2.5D 手绘风格包装所有地块素材必须保持统一正面30度视角相机位于物体正前方略高位置、镜头向下约30度必须看到清晰正面、侧壁、下沿、明显自身厚度和少量上表面主体正面或侧壁可见面积必须接近或大于顶面面积顶面只能作为辅助可见面水果主题需要明确要求橙瓣看到橙皮正面外侧和果肉厚度、椰子看到壳的正面侧壁和切口厚度、浆果不能只是从上往下看的圆形球顶避免生成纯俯视、正上方俯拍、鸟瞰地图块、平铺俯拍、圆形顶视图或扁平图标主题物体本身必须是唯一可落脚体只能用自身切面、边缘厚度、花瓣层或果皮边表现承重禁止在主题物体下方额外垫石台、土墩、木板、圆台、托盘、岛屿底座或通用地板前端和后端默认 `tilePrompt` 都必须使用“正面30度视角主题物体图集物体本身作为跳跃落点”的口径不再提交“平台素材 / 跳台 / 地块 / 地砖”等会把模型拉回通用平台造型的词,后端生成前也会清洗旧草稿遗留的这些词; 3. 地块只调用一次 image2输出一张 `5行*5列``1:1`、单一纯洋红 `#FF00FF` key 背景的主题地块图集;跳一跳地块常包含草地、花、雪、白石和云朵,后端透明化必须使用跳一跳专用洋红 key不启用近白底扣除也不清理非边缘连通的 key 色像素,避免把绿色或白色主体误扣;后处理必须对边缘连通 key 色做容差清理、去彩边 defringe 和底部残影清理,主体图不得自带洋红阴影、紫色底边、粉色脏边、彩色光晕或发光底边,运行态阴影统一由 DOM 绘制;地块造型提示词要求以主题物体本身外轮廓为准允许苹果近似圆形、香蕉近似长条或长方形、西瓜近似扇形等自然差异只统一单格规格、安全留白、正面30度视角和 2D/2.5D 手绘风格包装所有地块素材必须保持统一正面30度视角相机位于物体正前方略高位置、镜头向下约30度必须看到清晰正面、侧壁、下沿、明显自身厚度和少量上表面主体正面或侧壁可见面积必须接近或大于顶面面积顶面只能作为辅助可见面水果主题需要明确要求橙瓣看到橙皮正面外侧和果肉厚度、椰子看到壳的正面侧壁和切口厚度、浆果不能只是从上往下看的圆形球顶避免生成纯俯视、正上方俯拍、鸟瞰地图块、平铺俯拍、圆形顶视图或扁平图标主题物体本身必须是唯一可落脚体只能用自身切面、边缘厚度、花瓣层或果皮边表现承重禁止在主题物体下方额外垫石台、土墩、木板、圆台、托盘、岛屿底座或通用地板前端和后端默认 `tilePrompt` 都必须使用“正面30度视角主题物体图集物体本身作为跳跃落点”的口径不再提交“平台素材 / 跳台 / 地块 / 地砖”等会把模型拉回通用平台造型的词,后端生成前也会清洗旧草稿遗留的这些词;
4. 背景底图同样由 image2 生成,复用现有 `coverComposite` / `coverImageSrc` 作为运行态背景读写字段OSS 槽位固定为 `background/image.png`,不新增 SpacetimeDB 字段;提示词必须严格以用户主题关键词为背景主题,结构以左右两侧氛围为主,中央纵轴 1/2 区域保持少元素、简洁、可读且有纵深感两侧允许更强立体层次和行进感背景只作为底图禁止生成跳板、地块、落脚物、角色、UI、文字、路径箭头或海报排版 4. 背景底图同样由 image2 生成,复用现有 `coverComposite` / `coverImageSrc` 作为运行态背景读写字段OSS 槽位固定为 `background/image.png`,不新增 SpacetimeDB 字段;提示词必须严格以用户主题关键词为背景主题,结构以左右两侧氛围为主,中央纵轴 1/2 区域保持少元素、简洁、可读且有纵深感两侧允许更强立体层次和行进感背景只作为底图禁止生成跳板、地块、落脚物、角色、UI、文字、路径箭头或海报排版
5. 后端按从上到下、从左到右均匀切分为 `tile-01``tile-25` 的透明 PNG每个切片必须使用唯一 slot/path 持久化,不能按重复的 `tileType` 复用槽位; 5. 后端按从上到下、从左到右均匀切分为 `tile-01``tile-25` 的透明 PNG每个切片必须使用唯一 slot/path 持久化,不能按重复的 `tileType` 复用槽位;
6. 结果页只展示陶泥儿 logo 透明角色预览、地块池预览和首屏 3 地块预览;不再提供旧角色图生成槽。 6. 结果页只展示陶泥儿 logo 透明角色预览、地块池预览和首屏 3 地块预览;不再提供旧角色图生成槽。

File diff suppressed because one or more lines are too long

View File

@@ -385,7 +385,13 @@ fn remove_generated_asset_sheet_green_screen_background(
let mut red = pixels[offset] as f32; let mut red = pixels[offset] as f32;
let mut green = pixels[offset + 1] as f32; let mut green = pixels[offset + 1] as f32;
let mut blue = pixels[offset + 2] as f32; let mut blue = pixels[offset + 2] as f32;
let blend = clamp_generated_asset_sheet_unit(contamination.max(0.22)); let blend = if options.key_color.is_green_screen() {
clamp_generated_asset_sheet_unit(contamination.max(0.22))
} else {
// 中文注释:洋红 / 青色等非绿幕 key 的残留更容易表现成彩边,
// 需要比绿幕更强地向主体邻近色收敛,避免 PNG 边缘继续带 key 色。
clamp_generated_asset_sheet_unit((key_score * 1.35).max(contamination).max(0.28))
};
if let Some((sample_red, sample_green, sample_blue)) = sample { if let Some((sample_red, sample_green, sample_blue)) = sample {
red = lerp_generated_asset_sheet_channel(red, sample_red as f32, blend); red = lerp_generated_asset_sheet_channel(red, sample_red as f32, blend);
@@ -400,6 +406,17 @@ fn remove_generated_asset_sheet_green_screen_background(
green = green.min(sample_green as f32 + 26.0); green = green.min(sample_green as f32 + 26.0);
blue = blue.min(sample_blue as f32 + 26.0); blue = blue.min(sample_blue as f32 + 26.0);
} }
if !options.key_color.is_green_screen() && key_score > 0.04 {
let defringed = suppress_generated_asset_sheet_key_color_fringe(
[red, green, blue],
[sample_red as f32, sample_green as f32, sample_blue as f32],
key_score,
options.key_color,
);
red = defringed[0];
green = defringed[1];
blue = defringed[2];
}
} else { } else {
if options.key_color.is_green_screen() && key_score > 0.04 { if options.key_color.is_green_screen() && key_score > 0.04 {
let toned_green = (green - (green - red.max(blue)) * 0.78) let toned_green = (green - (green - red.max(blue)) * 0.78)
@@ -417,10 +434,26 @@ fn remove_generated_asset_sheet_green_screen_background(
blue = blue.min(toned_value); blue = blue.min(toned_value);
} }
} }
if !options.key_color.is_green_screen() && key_score > 0.04 {
let neutral = (red + green + blue) / 3.0;
let defringed = suppress_generated_asset_sheet_key_color_fringe(
[red, green, blue],
[neutral, neutral, neutral],
key_score,
options.key_color,
);
red = defringed[0];
green = defringed[1];
blue = defringed[2];
}
} }
let mut next_alpha = alpha; let mut next_alpha = alpha;
let edge_fade = (key_score * 0.35).max(white_score * 0.28); let edge_fade = if options.key_color.is_green_screen() {
(key_score * 0.35).max(white_score * 0.28)
} else {
(key_score * 0.48).max(white_score * 0.28)
};
if edge_fade > 0.08 { if edge_fade > 0.08 {
next_alpha = ((alpha as f32) * (1.0 - edge_fade)).round() as u8; next_alpha = ((alpha as f32) * (1.0 - edge_fade)).round() as u8;
if next_alpha < 10 { if next_alpha < 10 {
@@ -448,6 +481,37 @@ fn remove_generated_asset_sheet_green_screen_background(
changed changed
} }
pub(super) fn suppress_generated_asset_sheet_key_color_fringe(
color: [f32; 3],
target: [f32; 3],
key_score: f32,
key_color: GeneratedAssetSheetKeyColor,
) -> [f32; 3] {
let strength = clamp_generated_asset_sheet_unit(key_score * 1.18);
let key_channels = [
key_color.red as f32 / 255.0,
key_color.green as f32 / 255.0,
key_color.blue as f32 / 255.0,
];
let mut next = color;
for index in 0..3 {
if key_channels[index] >= 0.66 {
let cap = target[index] + 18.0 + (1.0 - strength) * 28.0;
next[index] = next[index].min(lerp_generated_asset_sheet_channel(
next[index],
cap,
strength,
));
} else if key_channels[index] <= 0.34 {
next[index] =
lerp_generated_asset_sheet_channel(next[index], target[index], strength * 0.72);
}
}
next
}
fn compute_generated_asset_sheet_key_score( fn compute_generated_asset_sheet_key_score(
pixel: [u8; 4], pixel: [u8; 4],
key_color: GeneratedAssetSheetKeyColor, key_color: GeneratedAssetSheetKeyColor,

View File

@@ -1,13 +1,14 @@
use super::alpha::{ use super::alpha::{
GeneratedAssetSheetAlphaOptions, apply_generated_asset_sheet_green_screen_alpha, GeneratedAssetSheetAlphaOptions, apply_generated_asset_sheet_green_screen_alpha,
suppress_generated_asset_sheet_key_color_fringe,
}; };
use super::color::{ use super::color::{
compute_generated_asset_sheet_key_color_score, clamp_generated_asset_sheet_unit, compute_generated_asset_sheet_key_color_score,
compute_generated_asset_sheet_white_screen_score, is_generated_asset_sheet_foreground_pixel, compute_generated_asset_sheet_white_screen_score, is_generated_asset_sheet_foreground_pixel,
is_generated_asset_sheet_green_contaminated_edge_pixel, is_generated_asset_sheet_green_contaminated_edge_pixel,
is_generated_asset_sheet_soft_edge_pixel, is_generated_asset_sheet_strong_green_contamination, is_generated_asset_sheet_soft_edge_pixel, is_generated_asset_sheet_strong_green_contamination,
is_generated_asset_sheet_view_background_pixel, is_generated_asset_sheet_visible_pixel, is_generated_asset_sheet_view_background_pixel, is_generated_asset_sheet_visible_pixel,
touches_generated_asset_sheet_background_mask, lerp_generated_asset_sheet_channel, touches_generated_asset_sheet_background_mask,
}; };
use super::error::GeneratedAssetSheetError; use super::error::GeneratedAssetSheetError;
use image::{GenericImageView, ImageFormat}; use image::{GenericImageView, ImageFormat};
@@ -588,11 +589,54 @@ fn remove_generated_asset_sheet_view_edge_matte(
pixels[offset + 1], pixels[offset + 1],
pixels[offset + 2], pixels[offset + 2],
)); ));
let next_red = replacement.0.max(pixels[offset]); let (next_red, next_green, next_blue) = if options.key_color.is_green_screen() {
let next_blue = replacement.2.max(pixels[offset + 2]); let next_red = replacement.0.max(pixels[offset]);
let next_green = replacement let next_blue = replacement.2.max(pixels[offset + 2]);
.1 let next_green = replacement
.min(next_red.max(next_blue).saturating_add(12)); .1
.min(next_red.max(next_blue).saturating_add(12));
(next_red, next_green, next_blue)
} else {
let key_score = compute_generated_asset_sheet_key_color_score(
pixel,
[
options.key_color.red,
options.key_color.green,
options.key_color.blue,
],
);
let blend = clamp_generated_asset_sheet_unit((key_score * 1.25).max(0.36));
let red = lerp_generated_asset_sheet_channel(
pixels[offset] as f32,
replacement.0 as f32,
blend,
);
let green = lerp_generated_asset_sheet_channel(
pixels[offset + 1] as f32,
replacement.1 as f32,
blend,
);
let blue = lerp_generated_asset_sheet_channel(
pixels[offset + 2] as f32,
replacement.2 as f32,
blend,
);
let defringed = suppress_generated_asset_sheet_key_color_fringe(
[red, green, blue],
[
replacement.0 as f32,
replacement.1 as f32,
replacement.2 as f32,
],
key_score,
options.key_color,
);
(
defringed[0].round().clamp(0.0, 255.0) as u8,
defringed[1].round().clamp(0.0, 255.0) as u8,
defringed[2].round().clamp(0.0, 255.0) as u8,
)
};
if next_red != pixels[offset] if next_red != pixels[offset]
|| next_green != pixels[offset + 1] || next_green != pixels[offset + 1]
|| next_blue != pixels[offset + 2] || next_blue != pixels[offset + 2]

View File

@@ -206,6 +206,78 @@ fn generated_asset_sheet_magenta_edge_matte_does_not_remove_white_subject() {
); );
} }
#[test]
fn generated_asset_sheet_magenta_alpha_defringes_pink_halo() {
let mut sheet = RgbaImage::from_pixel(24, 24, Rgba([255, 0, 255, 255]));
for y in 7..17 {
for x in 7..17 {
sheet.put_pixel(x, y, Rgba([198, 170, 120, 255]));
}
}
for y in 6..18 {
sheet.put_pixel(6, y, Rgba([226, 26, 218, 220]));
sheet.put_pixel(17, y, Rgba([226, 26, 218, 220]));
}
for x in 6..18 {
sheet.put_pixel(x, 6, Rgba([226, 26, 218, 220]));
sheet.put_pixel(x, 17, Rgba([226, 26, 218, 220]));
}
let cleaned = apply_generated_asset_sheet_alpha_with_options(
DynamicImage::ImageRgba8(sheet),
GeneratedAssetSheetAlphaOptions::jump_hop_magenta_screen(),
)
.to_rgba8();
let edge = cleaned.get_pixel(6, 12).0;
assert_eq!(cleaned.get_pixel(0, 0).0[3], 0);
assert_eq!(cleaned.get_pixel(12, 12).0, [198, 170, 120, 255]);
if edge[3] > 0 {
assert!(
edge[0].saturating_sub(edge[1]) <= 76,
"红色 key 通道残留过强:{edge:?}"
);
assert!(
edge[2].saturating_sub(edge[1]) <= 76,
"蓝色 key 通道残留过强:{edge:?}"
);
}
}
#[test]
fn generated_asset_sheet_magenta_edge_matte_defringes_bottom_shadow() {
let mut sheet = RgbaImage::from_pixel(32, 32, Rgba([0, 0, 0, 0]));
for y in 8..18 {
for x in 10..22 {
sheet.put_pixel(x, y, Rgba([202, 176, 126, 255]));
}
}
for y in 18..22 {
for x in 9..23 {
sheet.put_pixel(x, y, Rgba([224, 30, 220, 186]));
}
}
let cleaned = crop_generated_asset_sheet_view_edge_matte_with_options(
DynamicImage::ImageRgba8(sheet),
GeneratedAssetSheetAlphaOptions::jump_hop_magenta_screen(),
)
.to_rgba8();
assert!(
cleaned
.pixels()
.any(|pixel| pixel.0 == [202, 176, 126, 255])
);
assert!(
!cleaned.pixels().any(|pixel| {
let [red, green, blue, alpha] = pixel.0;
alpha > 0 && red > 200 && blue > 200 && green < 96
}),
"底部洋红残影应被删除或去彩边"
);
}
#[test] #[test]
fn generated_asset_sheet_view_edge_matte_trims_transparent_border() { fn generated_asset_sheet_view_edge_matte_trims_transparent_border() {
let mut sheet = RgbaImage::from_pixel(20, 20, Rgba([0, 0, 0, 0])); let mut sheet = RgbaImage::from_pixel(20, 20, Rgba([0, 0, 0, 0]));