From 5d0f3e6be64e7a41c5f745f9bab50fd762b8fb63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 10 Sep 2026 12:13:04 +0800 Subject: [PATCH] =?UTF-8?q?UI=E7=BC=96=E8=BE=91=E5=99=A8=E5=88=86=E7=A6=BB?= =?UTF-8?q?=E5=8C=BA=E5=9F=9F=E5=83=8F=E7=B4=A0=E8=BE=B9=E7=95=8C=E5=BD=92?= =?UTF-8?q?=E4=B8=80=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增独立 BindingArea 像素边界归一化模块与回归测试 在 cut 裁切前按透明像素收缩或扩展四条边并记录约束结果 接入分离工作流并保留原始与归一化区域日志 --- .../src/ui_editor/commands/separation/area.rs | 401 ++++++++++++++++++ .../src/ui_editor/commands/separation/mod.rs | 2 + .../ui_editor/commands/separation/workflow.rs | 38 +- 3 files changed, 432 insertions(+), 9 deletions(-) create mode 100644 apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/area.rs diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/area.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/area.rs new file mode 100644 index 000000000..08a218b35 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/area.rs @@ -0,0 +1,401 @@ +use super::model::BindingArea; +use image::RgbaImage; + +/// Each edge may move by at most half of the area dimension returned by the +/// visual model. Keep this policy explicit so changing it is an intentional +/// workflow decision rather than a scattered numeric literal. +pub(crate) const MAX_BINDING_AREA_EDGE_ADJUSTMENT_PERCENT: u32 = 50; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct NormalizedBindingArea { + pub(crate) area: BindingArea, + pub(crate) changed: bool, + pub(crate) clamped: bool, + pub(crate) transparent: bool, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum EdgeDirection { + Inward, + Outward, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct Rect { + left: u32, + top: u32, + right: u32, + bottom: u32, +} + +impl Rect { + fn from_area(area: BindingArea) -> Self { + Self { + left: area.global_pos_x_px, + top: area.global_pos_y_px, + right: area.global_pos_x_px + area.width_px, + bottom: area.global_pos_y_px + area.height_px, + } + } + + fn into_area(self) -> BindingArea { + BindingArea { + global_pos_x_px: self.left, + global_pos_y_px: self.top, + width_px: self.right - self.left, + height_px: self.bottom - self.top, + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum Edge { + Left, + Right, + Top, + Bottom, +} + +impl Edge { + const ALL: [Self; 4] = [Self::Left, Self::Right, Self::Top, Self::Bottom]; +} + +fn edge_has_visible_pixel(image: &RgbaImage, rect: Rect, edge: Edge) -> bool { + match edge { + Edge::Left | Edge::Right => { + let x = if edge == Edge::Left { + rect.left + } else { + rect.right - 1 + }; + (rect.top..rect.bottom).any(|y| image.get_pixel(x, y).0[3] > 0) + } + Edge::Top | Edge::Bottom => { + let y = if edge == Edge::Top { + rect.top + } else { + rect.bottom - 1 + }; + (rect.left..rect.right).any(|x| image.get_pixel(x, y).0[3] > 0) + } + } +} + +fn rect_has_visible_pixel(image: &RgbaImage, rect: Rect) -> bool { + (rect.top..rect.bottom).any(|y| (rect.left..rect.right).any(|x| image.get_pixel(x, y).0[3] > 0)) +} + +fn max_edge_adjustment(dimension: u32) -> u32 { + ((u64::from(dimension) * u64::from(MAX_BINDING_AREA_EDGE_ADJUSTMENT_PERCENT)) / 100) + .min(u64::from(u32::MAX)) as u32 +} + +fn edge_direction(image: &RgbaImage, rect: Rect, edge: Edge) -> EdgeDirection { + if edge_has_visible_pixel(image, rect, edge) { + EdgeDirection::Outward + } else { + EdgeDirection::Inward + } +} + +fn move_edge(rect: &mut Rect, edge: Edge, direction: EdgeDirection) { + match (edge, direction) { + (Edge::Left, EdgeDirection::Inward) => rect.left += 1, + (Edge::Left, EdgeDirection::Outward) => rect.left -= 1, + (Edge::Right, EdgeDirection::Inward) => rect.right -= 1, + (Edge::Right, EdgeDirection::Outward) => rect.right += 1, + (Edge::Top, EdgeDirection::Inward) => rect.top += 1, + (Edge::Top, EdgeDirection::Outward) => rect.top -= 1, + (Edge::Bottom, EdgeDirection::Inward) => rect.bottom -= 1, + (Edge::Bottom, EdgeDirection::Outward) => rect.bottom += 1, + } +} + +fn edge_coordinate(rect: Rect, edge: Edge) -> u32 { + match edge { + Edge::Left => rect.left, + Edge::Right => rect.right, + Edge::Top => rect.top, + Edge::Bottom => rect.bottom, + } +} + +fn edge_displacement(original: Rect, current: Rect, edge: Edge) -> u32 { + edge_coordinate(original, edge).abs_diff(edge_coordinate(current, edge)) +} + +fn edge_adjustment_limit(original: Rect, edge: Edge) -> u32 { + max_edge_adjustment(match edge { + Edge::Left | Edge::Right => original.right - original.left, + Edge::Top | Edge::Bottom => original.bottom - original.top, + }) +} + +fn reached_adjustment_limit(original: Rect, current: Rect, edge: Edge) -> bool { + edge_displacement(original, current, edge) >= edge_adjustment_limit(original, edge) +} + +fn can_move_geometrically( + image: &RgbaImage, + current: Rect, + edge: Edge, + direction: EdgeDirection, +) -> bool { + match (edge, direction) { + (Edge::Left, EdgeDirection::Inward) => current.left + 1 < current.right, + (Edge::Left, EdgeDirection::Outward) => current.left > 0, + (Edge::Right, EdgeDirection::Inward) => current.right > current.left + 1, + (Edge::Right, EdgeDirection::Outward) => current.right < image.width(), + (Edge::Top, EdgeDirection::Inward) => current.top + 1 < current.bottom, + (Edge::Top, EdgeDirection::Outward) => current.top > 0, + (Edge::Bottom, EdgeDirection::Inward) => current.bottom > current.top + 1, + (Edge::Bottom, EdgeDirection::Outward) => current.bottom < image.height(), + } +} + +fn next_edge_rect(rect: Rect, edge: Edge, direction: EdgeDirection) -> Option { + let mut next = rect; + match (edge, direction) { + (Edge::Left, EdgeDirection::Inward) if rect.left + 1 < rect.right => next.left += 1, + (Edge::Left, EdgeDirection::Outward) if rect.left > 0 => next.left -= 1, + (Edge::Right, EdgeDirection::Inward) if rect.right > rect.left + 1 => next.right -= 1, + (Edge::Right, EdgeDirection::Outward) => next.right = next.right.checked_add(1)?, + (Edge::Top, EdgeDirection::Inward) if rect.top + 1 < rect.bottom => next.top += 1, + (Edge::Top, EdgeDirection::Outward) if rect.top > 0 => next.top -= 1, + (Edge::Bottom, EdgeDirection::Inward) if rect.bottom > rect.top + 1 => next.bottom -= 1, + (Edge::Bottom, EdgeDirection::Outward) => next.bottom = next.bottom.checked_add(1)?, + _ => return None, + } + Some(next) +} + +fn edge_requires_move(image: &RgbaImage, rect: Rect, edge: Edge, direction: EdgeDirection) -> bool { + match direction { + EdgeDirection::Inward => !edge_has_visible_pixel(image, rect, edge), + EdgeDirection::Outward => { + if !edge_has_visible_pixel(image, rect, edge) { + return false; + } + if !can_move_geometrically(image, rect, edge, direction) { + return true; + } + next_edge_rect(rect, edge, direction) + .is_some_and(|next| edge_has_visible_pixel(image, next, edge)) + } + } +} + +fn apply_edge_step( + image: &RgbaImage, + original: Rect, + current: Rect, + edge: Edge, + direction: EdgeDirection, +) -> (Rect, bool, bool) { + if !edge_requires_move(image, current, edge, direction) { + return (current, false, false); + } + if reached_adjustment_limit(original, current, edge) + || !can_move_geometrically(image, current, edge, direction) + { + return (current, false, true); + } + let mut next = current; + move_edge(&mut next, edge, direction); + (next, true, false) +} + +/// Normalizes a model-provided area using visible pixels on the processed +/// transparent image. Each edge chooses inward/outward direction once from +/// its initial scan and then moves monotonically, so sparse pixels cannot make +/// the boundary oscillate. The four edge steps are calculated from the same +/// rectangle on each round. +pub(crate) fn normalize_binding_area( + image: &RgbaImage, + original_area: BindingArea, +) -> Result { + original_area.validate_in(image.width(), image.height())?; + let original = Rect::from_area(original_area); + let directions = Edge::ALL.map(|edge| edge_direction(image, original, edge)); + let mut current = original; + let mut clamped = false; + let mut active = [true; 4]; + + // TODO: Replace the deliberately simple pixel-by-pixel scan if real UI + // design sizes show this path to be a measurable bottleneck. + while active.iter().any(|value| *value) { + let before = current; + let mut next = current; + let mut moved = [false; 4]; + for (index, edge) in Edge::ALL.into_iter().enumerate() { + if !active[index] { + continue; + } + let (candidate, did_move, reached_limit) = + apply_edge_step(image, original, current, edge, directions[index]); + if reached_limit { + clamped = true; + active[index] = false; + } else if !did_move { + active[index] = false; + } + moved[index] = did_move; + match edge { + Edge::Left => next.left = candidate.left, + Edge::Right => next.right = candidate.right, + Edge::Top => next.top = candidate.top, + Edge::Bottom => next.bottom = candidate.bottom, + } + } + if next.left >= next.right { + clamped = true; + if moved[0] { + active[0] = false; + } + if moved[1] { + active[1] = false; + } + next.left = current.left; + next.right = current.right; + } + if next.top >= next.bottom { + clamped = true; + if moved[2] { + active[2] = false; + } + if moved[3] { + active[3] = false; + } + next.top = current.top; + next.bottom = current.bottom; + } + current = next; + if current == before { + break; + } + } + + let area = current.into_area(); + Ok(NormalizedBindingArea { + changed: area != original_area, + area, + clamped, + transparent: !rect_has_visible_pixel(image, current), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use image::{Rgba, RgbaImage}; + + fn image_with_rect( + width: u32, + height: u32, + left: u32, + top: u32, + right: u32, + bottom: u32, + ) -> RgbaImage { + let mut image = RgbaImage::from_pixel(width, height, Rgba([0, 0, 0, 0])); + for y in top..bottom { + for x in left..right { + image.put_pixel(x, y, Rgba([255, 255, 255, 255])); + } + } + image + } + + fn area(x: u32, y: u32, width: u32, height: u32) -> BindingArea { + BindingArea { + global_pos_x_px: x, + global_pos_y_px: y, + width_px: width, + height_px: height, + } + } + + #[test] + fn shrinks_empty_edges_to_visible_bounds() { + let image = image_with_rect(32, 32, 10, 11, 16, 18); + let result = normalize_binding_area(&image, area(6, 7, 14, 16)).unwrap(); + assert_eq!(result.area, area(10, 11, 6, 7)); + assert!(result.changed); + assert!(!result.clamped); + assert!(!result.transparent); + } + + #[test] + fn expands_visible_edges_to_cover_the_element() { + let image = image_with_rect(32, 32, 10, 11, 16, 18); + let result = normalize_binding_area(&image, area(11, 12, 4, 5)).unwrap(); + assert_eq!(result.area, area(10, 11, 6, 7)); + assert!(result.changed); + assert!(!result.clamped); + assert!(!result.transparent); + } + + #[test] + fn adjusts_each_edge_independently() { + let image = image_with_rect(32, 32, 10, 11, 16, 18); + let result = normalize_binding_area(&image, area(10, 12, 10, 3)).unwrap(); + assert_eq!(result.area, area(10, 11, 6, 5)); + } + + #[test] + fn keeps_nonzero_alpha_antialias_pixels() { + let mut image = RgbaImage::from_pixel(16, 16, Rgba([0, 0, 0, 0])); + image.put_pixel(5, 6, Rgba([255, 255, 255, 1])); + image.put_pixel(7, 8, Rgba([255, 255, 255, 255])); + let result = normalize_binding_area(&image, area(4, 5, 5, 5)).unwrap(); + assert_eq!(result.area, area(5, 6, 3, 3)); + } + + #[test] + fn fully_transparent_image_uses_the_same_path() { + let image = RgbaImage::from_pixel(32, 32, Rgba([0, 0, 0, 0])); + let result = normalize_binding_area(&image, area(10, 10, 10, 10)).unwrap(); + assert_eq!(result.area, area(14, 14, 2, 2)); + assert!(result.changed); + assert!(result.transparent); + } + + #[test] + fn caps_each_edge_at_half_of_the_original_dimension() { + let image = image_with_rect(64, 64, 0, 0, 64, 64); + let result = normalize_binding_area(&image, area(16, 16, 8, 8)).unwrap(); + assert_eq!(result.area, area(12, 12, 16, 16)); + assert!(result.clamped); + } + + #[test] + fn clamps_expansion_to_image_edges() { + let image = image_with_rect(16, 16, 0, 0, 4, 4); + let result = normalize_binding_area(&image, area(1, 1, 2, 2)).unwrap(); + assert_eq!(result.area, area(0, 0, 4, 4)); + assert!(result.clamped); + } + + #[test] + fn exact_split_at_adjustment_limit_is_not_clamped() { + let image = image_with_rect(16, 16, 4, 4, 8, 8); + let result = normalize_binding_area(&image, area(5, 5, 2, 2)).unwrap(); + assert_eq!(result.area, area(4, 4, 4, 4)); + assert!(!result.clamped); + } + + #[test] + fn one_pixel_area_stays_nonzero_when_adjustment_limit_is_zero() { + let image = image_with_rect(8, 8, 2, 2, 5, 5); + let result = normalize_binding_area(&image, area(3, 3, 1, 1)).unwrap(); + assert_eq!(result.area, area(3, 3, 1, 1)); + assert!(result.clamped); + } + + #[test] + fn rejects_zero_sized_or_out_of_bounds_model_areas() { + let image = RgbaImage::from_pixel(16, 16, Rgba([0, 0, 0, 0])); + assert!(normalize_binding_area(&image, area(0, 0, 0, 1)).is_err()); + assert!(normalize_binding_area(&image, area(15, 15, 2, 2)).is_err()); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/mod.rs index 38a2d5dda..c2bcc3ce7 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/mod.rs @@ -1,3 +1,4 @@ +mod area; mod marker; mod model; mod persistence; @@ -5,6 +6,7 @@ mod prompt; mod tree; mod workflow; +pub(crate) use area::{normalize_binding_area, NormalizedBindingArea}; pub(crate) use marker::build_marked_image; pub use model::*; pub use persistence::*; diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/workflow.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/workflow.rs index da3a1288e..16f6117e4 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/workflow.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/workflow.rs @@ -618,14 +618,34 @@ fn cut_processed_image_blocking( area: &BindingArea, target: &Path, ) -> Result<(), String> { - let image = image::open(source).map_err(|e| format!("读取处理图失败:{e}"))?; - area.validate_in(image.width(), image.height())?; - let cropped = image.crop_imm( - area.global_pos_x_px, - area.global_pos_y_px, - area.width_px, - area.height_px, + let image = image::open(source) + .map_err(|e| format!("读取处理图失败:{e}"))? + .to_rgba8(); + let normalized = normalize_binding_area(&image, *area)?; + let original_area = *area; + let normalized_area = normalized.area; + app_log!( + "ui_separation.cut_image.normalized changed={} clamped={} transparent={} original_area=({}, {}, {}, {}) normalized_area=({}, {}, {}, {})", + normalized.changed, + normalized.clamped, + normalized.transparent, + original_area.global_pos_x_px, + original_area.global_pos_y_px, + original_area.width_px, + original_area.height_px, + normalized_area.global_pos_x_px, + normalized_area.global_pos_y_px, + normalized_area.width_px, + normalized_area.height_px ); + let cropped = image::imageops::crop_imm( + &image, + normalized_area.global_pos_x_px, + normalized_area.global_pos_y_px, + normalized_area.width_px, + normalized_area.height_px, + ) + .to_image(); cropped .save_with_format(target, ImageFormat::Png) .map_err(|e| format!("写入 cut 图片失败:{e}"))?; @@ -635,8 +655,8 @@ fn cut_processed_image_blocking( .file_name() .and_then(|name| name.to_str()) .unwrap_or(""), - area.width_px, - area.height_px + normalized_area.width_px, + normalized_area.height_px ); Ok(()) }