UI编辑器分离区域像素边界归一化
新增独立 BindingArea 像素边界归一化模块与回归测试 在 cut 裁切前按透明像素收缩或扩展四条边并记录约束结果 接入分离工作流并保留原始与归一化区域日志
This commit is contained in:
@@ -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<Rect> {
|
||||
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<NormalizedBindingArea, String> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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("<unknown>"),
|
||||
area.width_px,
|
||||
area.height_px
|
||||
normalized_area.width_px,
|
||||
normalized_area.height_px
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user