修复图标素材切片废图
合并靠近主体的断开高光阴影连通域 过滤孤立小碎片避免生成废图素材 补充图片画布图标切片踩坑记录
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user