修复图标素材切片废图

合并靠近主体的断开高光阴影连通域

过滤孤立小碎片避免生成废图素材

补充图片画布图标切片踩坑记录
This commit is contained in:
2026-06-23 19:30:04 +08:00
parent 0c2c0c2c0f
commit 4b2cac44ff
2 changed files with 327 additions and 7 deletions
@@ -670,6 +670,14 @@
- 验证:`cargo test -p platform-image --test generated_asset_sheets --manifest-path server-rs/Cargo.toml` 通过,且 `cargo check -p api-server --manifest-path server-rs/Cargo.toml` 保持绿灯。
- 关联:`server-rs/crates/platform-image/src/generated_asset_sheets/``server-rs/crates/api-server/src/generated_asset_sheets.rs``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## 图片画布图标素材切片不要把断开的高光阴影当独立图标
- 现象:图片画布生成图标素材或提取 UI 素材后,右侧素材库出现很小的废图;主体图标的阴影、反光、高光或小装饰不完整。
- 原因:图标 spritesheet 切片按 alpha 连通域识别素材,模型常把软阴影、高光、小星星等画成与主体断开的透明块;如果直接逐连通域出图,小碎片会抢占图标顺序,主体也会缺边缘装饰。
- 处理:在 `platform-image``sheet.rs` 里先合并靠近主体的辅助连通域,再过滤孤立小碎片,最后给裁剪框保留安全 padding。不要在前端素材卡或画布层里修已经切坏的 PNG。
- 验证:`cargo test -p platform-image generated_asset_sheets --manifest-path server-rs/Cargo.toml` 覆盖断开的高光合并和孤立小碎片过滤;调用方补跑 `cargo test -p api-server editor_icon --manifest-path server-rs/Cargo.toml`
- 关联:`server-rs/crates/platform-image/src/generated_asset_sheets/sheet.rs``server-rs/crates/api-server/src/editor_project.rs`
## UI spritesheet 不要依赖模型直接生成透明背景
- 现象:拼图或抓大鹅运行态解析 UI spritesheet 时,把整张背景图、棋盘格、叶子或装饰图也当作 UI 素材区域,按钮映射错乱;截图里常表现为底部按钮区只剩透明棋盘格或素材碎片。
@@ -141,6 +141,9 @@ pub struct GeneratedAssetSheetConnectedIcon {
pub height: u32,
}
const GENERATED_ICON_MIN_VISIBLE_PIXELS: u32 = 16;
const GENERATED_ICON_MAX_MERGE_ITERATIONS: usize = 16;
pub fn slice_generated_icon_spritesheet_by_connected_components(
image: &crate::DownloadedImage,
icon_names: &[String],
@@ -202,6 +205,13 @@ fn slice_generated_icon_spritesheet_rgba_by_connected_components(
}
}
let mut components = normalize_generated_icon_components(
components,
width,
height,
icon_names.len(),
auto_name_all_components,
);
components.sort_by_key(|bounds| (bounds.y0, bounds.x0));
let icon_names = if auto_name_all_components {
(1..=components.len())
@@ -220,8 +230,8 @@ fn slice_generated_icon_spritesheet_rgba_by_connected_components(
let mut icons = Vec::with_capacity(icon_names.len());
for (name, bounds) in icon_names.iter().zip(components.into_iter()) {
let pad_x = (bounds.width() / 12).clamp(4, 16);
let pad_y = (bounds.height() / 12).clamp(4, 16);
let pad_x = resolve_generated_icon_crop_padding(bounds.width(), width);
let pad_y = resolve_generated_icon_crop_padding(bounds.height(), height);
let crop = GeneratedAssetSheetCellBounds {
x0: bounds.x0.saturating_sub(pad_x),
y0: bounds.y0.saturating_sub(pad_y),
@@ -261,10 +271,10 @@ fn detect_generated_icon_components_by_alpha(
image: &image::RgbaImage,
width: u32,
height: u32,
) -> Vec<GeneratedAssetSheetCellBounds> {
) -> Vec<GeneratedAssetSheetIconComponent> {
let pixel_count = (width as usize).saturating_mul(height as usize);
let mut visited = vec![false; pixel_count];
let mut components = Vec::<GeneratedAssetSheetCellBounds>::new();
let mut components = Vec::<GeneratedAssetSheetIconComponent>::new();
for y in 0..height {
for x in 0..width {
let pixel_index = (y as usize)
@@ -275,7 +285,7 @@ fn detect_generated_icon_components_by_alpha(
}
let bounds =
flood_fill_generated_icon_component(image, &mut visited, width, height, x, y);
if bounds.area() >= 16 {
if bounds.visible_pixels >= GENERATED_ICON_MIN_VISIBLE_PIXELS {
components.push(bounds);
}
}
@@ -283,6 +293,205 @@ fn detect_generated_icon_components_by_alpha(
components
}
fn normalize_generated_icon_components(
components: Vec<GeneratedAssetSheetIconComponent>,
width: u32,
height: u32,
required_count: usize,
auto_name_all_components: bool,
) -> Vec<GeneratedAssetSheetCellBounds> {
let merged_components = merge_generated_icon_related_components(components, width, height);
let filtered_components = filter_generated_icon_scrap_components(
merged_components,
width,
height,
required_count,
auto_name_all_components,
);
filtered_components
.into_iter()
.map(|component| component.bounds)
.collect()
}
fn merge_generated_icon_related_components(
mut components: Vec<GeneratedAssetSheetIconComponent>,
width: u32,
height: u32,
) -> Vec<GeneratedAssetSheetIconComponent> {
for _ in 0..GENERATED_ICON_MAX_MERGE_ITERATIONS {
let Some((source_index, target_index)) =
resolve_generated_icon_component_merge_pair(&components, width, height)
else {
break;
};
let source = components[source_index];
let target = components[target_index];
components[target_index] = target.merge(source);
components.remove(source_index);
}
components
}
fn resolve_generated_icon_component_merge_pair(
components: &[GeneratedAssetSheetIconComponent],
width: u32,
height: u32,
) -> Option<(usize, usize)> {
let mut best_pair: Option<(usize, usize, u64, u64)> = None;
for source_index in 0..components.len() {
for target_index in 0..components.len() {
if source_index == target_index
|| components[source_index].bounds.area() > components[target_index].bounds.area()
{
continue;
}
if !should_merge_generated_icon_components(
components[source_index],
components[target_index],
width,
height,
) {
continue;
}
let gap = generated_icon_bounds_gap(
components[source_index].bounds,
components[target_index].bounds,
);
let distance = generated_icon_center_distance_squared(
components[source_index].bounds,
components[target_index].bounds,
);
match best_pair {
Some((_, _, best_gap, best_distance))
if gap > best_gap || gap == best_gap && distance >= best_distance => {}
_ => best_pair = Some((source_index, target_index, gap, distance)),
}
}
}
best_pair.map(|(source_index, target_index, _, _)| (source_index, target_index))
}
fn should_merge_generated_icon_components(
source: GeneratedAssetSheetIconComponent,
target: GeneratedAssetSheetIconComponent,
width: u32,
height: u32,
) -> bool {
if !is_generated_icon_auxiliary_component(source, target) {
return false;
}
let gap = generated_icon_bounds_gap(source.bounds, target.bounds);
let max_dimension = target
.bounds
.width()
.max(target.bounds.height())
.max(source.bounds.width())
.max(source.bounds.height());
let sheet_short_side = width.min(height).max(1);
let merge_gap = (max_dimension / 4).max(sheet_short_side / 64).clamp(6, 48) as u64;
gap <= merge_gap
}
fn is_generated_icon_auxiliary_component(
source: GeneratedAssetSheetIconComponent,
target: GeneratedAssetSheetIconComponent,
) -> bool {
source.bounds.area().saturating_mul(4) <= target.bounds.area()
|| source.visible_pixels.saturating_mul(6) <= target.visible_pixels
|| source.bounds.width().saturating_mul(3) <= target.bounds.width()
|| source.bounds.height().saturating_mul(3) <= target.bounds.height()
}
fn generated_icon_bounds_gap(
left: GeneratedAssetSheetCellBounds,
right: GeneratedAssetSheetCellBounds,
) -> u64 {
let gap_x = if left.x1 < right.x0 {
right.x0 - left.x1
} else if right.x1 < left.x0 {
left.x0 - right.x1
} else {
0
};
let gap_y = if left.y1 < right.y0 {
right.y0 - left.y1
} else if right.y1 < left.y0 {
left.y0 - right.y1
} else {
0
};
u64::from(gap_x.max(gap_y))
}
fn generated_icon_center_distance_squared(
left: GeneratedAssetSheetCellBounds,
right: GeneratedAssetSheetCellBounds,
) -> u64 {
let left_center_x = i64::from(left.x0) + i64::from(left.width()) / 2;
let left_center_y = i64::from(left.y0) + i64::from(left.height()) / 2;
let right_center_x = i64::from(right.x0) + i64::from(right.width()) / 2;
let right_center_y = i64::from(right.y0) + i64::from(right.height()) / 2;
let delta_x = left_center_x - right_center_x;
let delta_y = left_center_y - right_center_y;
(delta_x.saturating_mul(delta_x) + delta_y.saturating_mul(delta_y)) as u64
}
fn filter_generated_icon_scrap_components(
components: Vec<GeneratedAssetSheetIconComponent>,
width: u32,
height: u32,
required_count: usize,
auto_name_all_components: bool,
) -> Vec<GeneratedAssetSheetIconComponent> {
if components.len() <= required_count.max(1) {
return components;
}
let max_visible_pixels = components
.iter()
.map(|component| component.visible_pixels)
.max()
.unwrap_or(0)
.max(1);
let max_area = components
.iter()
.map(|component| component.bounds.area())
.max()
.unwrap_or(0)
.max(1);
let sheet_area = width.saturating_mul(height).max(1);
let min_visible_pixels = (max_visible_pixels / 24)
.max(sheet_area / 8192)
.clamp(GENERATED_ICON_MIN_VISIBLE_PIXELS, 192);
let min_bounds_area = (max_area / 64).clamp(24, 512);
let filtered = components
.iter()
.copied()
.filter(|component| {
let has_usable_bounds = component.bounds.width() >= 8 && component.bounds.height() >= 8;
component.visible_pixels >= min_visible_pixels
|| component.bounds.area() >= min_bounds_area && has_usable_bounds
|| component.bounds.width() >= 12 && component.bounds.height() >= 12
})
.collect::<Vec<_>>();
if filtered.is_empty()
|| required_count > 0 && filtered.len() < required_count
|| !auto_name_all_components && filtered.len() < components.len().min(required_count)
{
return components;
}
filtered
}
fn resolve_generated_icon_crop_padding(component_size: u32, image_size: u32) -> u32 {
(component_size / 8)
.max(image_size / 128)
.clamp(8, 32)
.min(image_size.saturating_sub(1))
}
fn build_generated_icon_spritesheet_foreground_image(
image: &image::RgbaImage,
width: u32,
@@ -352,7 +561,7 @@ fn flood_fill_generated_icon_component(
height: u32,
start_x: u32,
start_y: u32,
) -> GeneratedAssetSheetCellBounds {
) -> GeneratedAssetSheetIconComponent {
let mut queue = vec![(start_x, start_y)];
let mut queue_index = 0usize;
let start_index = (start_y as usize)
@@ -365,10 +574,12 @@ fn flood_fill_generated_icon_component(
x1: start_x.saturating_add(1),
y1: start_y.saturating_add(1),
};
let mut visible_pixels = 0u32;
while queue_index < queue.len() {
let (x, y) = queue[queue_index];
queue_index += 1;
visible_pixels = visible_pixels.saturating_add(1);
bounds.x0 = bounds.x0.min(x);
bounds.y0 = bounds.y0.min(y);
bounds.x1 = bounds.x1.max(x.saturating_add(1));
@@ -391,7 +602,10 @@ fn flood_fill_generated_icon_component(
}
}
bounds
GeneratedAssetSheetIconComponent {
bounds,
visible_pixels,
}
}
#[cfg(test)]
@@ -494,6 +708,80 @@ mod tests {
.to_rgba8();
assert_eq!(decoded.get_pixel(0, 0).0[3], 0);
}
#[test]
fn merges_detached_icon_accents_before_assigning_names() {
let mut sheet: image::RgbaImage = ImageBuffer::from_pixel(128, 96, Rgba([0, 255, 0, 255]));
for y in 24..56 {
for x in 20..52 {
sheet.put_pixel(x, y, Rgba([240, 80, 80, 255]));
}
}
for y in 10..16 {
for x in 24..36 {
sheet.put_pixel(x, y, Rgba([255, 210, 210, 255]));
}
}
for y in 24..56 {
for x in 78..110 {
sheet.put_pixel(x, y, Rgba([80, 120, 240, 255]));
}
}
for y in 6..12 {
for x in 116..122 {
sheet.put_pixel(x, y, Rgba([255, 255, 120, 255]));
}
}
let source = crate::DownloadedImage {
bytes: encode_png(sheet),
mime_type: "image/png".to_string(),
extension: "png".to_string(),
};
let icons = slice_generated_icon_spritesheet_by_connected_components(
&source,
&["爱心".to_string(), "星星".to_string()],
)
.expect("detached accents should merge into their nearby icon");
assert_eq!(icons.len(), 2);
assert_eq!(icons[0].name, "爱心");
assert_eq!(icons[1].name, "星星");
assert!(icons[0].width >= 48);
assert!(icons[0].height >= 56);
}
#[test]
fn filters_unattached_tiny_scraps_when_slicing_all_components() {
let mut sheet: image::RgbaImage = ImageBuffer::from_pixel(128, 96, Rgba([0, 255, 0, 255]));
for y in 18..50 {
for x in 18..50 {
sheet.put_pixel(x, y, Rgba([240, 80, 80, 255]));
}
}
for y in 18..50 {
for x in 78..110 {
sheet.put_pixel(x, y, Rgba([80, 120, 240, 255]));
}
}
for y in 78..84 {
for x in 8..14 {
sheet.put_pixel(x, y, Rgba([255, 255, 120, 255]));
}
}
let source = crate::DownloadedImage {
bytes: encode_png(sheet),
mime_type: "image/png".to_string(),
extension: "png".to_string(),
};
let icons = slice_generated_icon_spritesheet_all_by_connected_components(&source)
.expect("tiny scraps should not become standalone icons");
assert_eq!(icons.len(), 2);
assert_eq!(icons[0].name, "素材 1");
assert_eq!(icons[1].name, "素材 2");
}
}
pub fn crop_generated_asset_sheet_view_edge_matte_with_options(
@@ -553,11 +841,35 @@ impl GeneratedAssetSheetCellBounds {
self.width().saturating_mul(self.height())
}
fn union(self, other: Self) -> Self {
Self {
x0: self.x0.min(other.x0),
y0: self.y0.min(other.y0),
x1: self.x1.max(other.x1),
y1: self.y1.max(other.y1),
}
}
fn to_crop_tuple(self) -> (u32, u32, u32, u32) {
(self.x0, self.y0, self.width(), self.height())
}
}
#[derive(Clone, Copy, Debug)]
struct GeneratedAssetSheetIconComponent {
bounds: GeneratedAssetSheetCellBounds,
visible_pixels: u32,
}
impl GeneratedAssetSheetIconComponent {
fn merge(self, other: Self) -> Self {
Self {
bounds: self.bounds.union(other.bounds),
visible_pixels: self.visible_pixels.saturating_add(other.visible_pixels),
}
}
}
fn resolve_generated_asset_sheet_cell_crop(
source: &image::DynamicImage,
grid_size: u32,