diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 9a6048138..1864502e1 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -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 素材区域,按钮映射错乱;截图里常表现为底部按钮区只剩透明棋盘格或素材碎片。 diff --git a/server-rs/crates/platform-image/src/generated_asset_sheets/sheet.rs b/server-rs/crates/platform-image/src/generated_asset_sheets/sheet.rs index 40c53581b..1115dba86 100644 --- a/server-rs/crates/platform-image/src/generated_asset_sheets/sheet.rs +++ b/server-rs/crates/platform-image/src/generated_asset_sheets/sheet.rs @@ -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 { +) -> Vec { let pixel_count = (width as usize).saturating_mul(height as usize); let mut visited = vec![false; pixel_count]; - let mut components = Vec::::new(); + let mut components = Vec::::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, + width: u32, + height: u32, + required_count: usize, + auto_name_all_components: bool, +) -> Vec { + 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, + width: u32, + height: u32, +) -> Vec { + 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, + width: u32, + height: u32, + required_count: usize, + auto_name_all_components: bool, +) -> Vec { + 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::>(); + + 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,