重构分离树与非重叠批次选择
保留真实根节点并记录 root_extractable 按终态反查和 DFS 贪心筛选逻辑叶 拆出父子区域标记图生成模块
This commit is contained in:
@@ -0,0 +1,52 @@
|
|||||||
|
use super::model::SeparationNode;
|
||||||
|
use base64::Engine as _;
|
||||||
|
use image::GenericImage;
|
||||||
|
use std::path::Path;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
pub async fn build_marked_image(source_url: String, nodes: Vec<SeparationNode>, target: PathBuf) -> Result<String, String> {
|
||||||
|
tokio::task::spawn_blocking(move || build_marked_image_blocking(&source_url, &nodes, &target))
|
||||||
|
.await
|
||||||
|
.map_err(|error| format!("构建标记图任务失败:{error}"))?
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_marked_image_blocking(source_url: &str, nodes: &[SeparationNode], target: &Path) -> Result<String, String> {
|
||||||
|
let encoded = source_url.split_once(',').map(|(_, d)| d).ok_or_else(|| "源图 data URL 无效".to_string())?;
|
||||||
|
let bytes = base64::engine::general_purpose::STANDARD.decode(encoded).map_err(|e| format!("解码源图失败:{e}"))?;
|
||||||
|
let mut image = image::load_from_memory(&bytes).map_err(|e| format!("读取源图失败:{e}"))?.to_rgba8();
|
||||||
|
let width = image.width();
|
||||||
|
let height = image.height();
|
||||||
|
// Purple reconstruction comes first so the parent green frame remains visible on top.
|
||||||
|
for node in nodes {
|
||||||
|
for child in &node.children {
|
||||||
|
fill_rect(&mut image, child.global_pos_x_px, child.global_pos_y_px, child.width_px, child.height_px, image::Rgba([180, 0, 180, 120]), width, height);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for node in nodes {
|
||||||
|
draw_frame(&mut image, node.global_pos_x_px, node.global_pos_y_px, node.width_px, node.height_px, width, height);
|
||||||
|
}
|
||||||
|
image::DynamicImage::ImageRgba8(image.clone()).save_with_format(target, image::ImageFormat::Png).map_err(|e| format!("写入标记图失败:{e}"))?;
|
||||||
|
let mut png = Vec::new();
|
||||||
|
image::DynamicImage::ImageRgba8(image).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).map_err(|e| format!("编码标记图失败:{e}"))?;
|
||||||
|
Ok(format!("data:image/png;base64,{}", base64::engine::general_purpose::STANDARD.encode(png)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clipped_rect(x: u32, y: u32, w: u32, h: u32, width: u32, height: u32) -> Option<(u32, u32, u32, u32)> {
|
||||||
|
if width == 0 || height == 0 || w == 0 || h == 0 { return None; }
|
||||||
|
let x0 = x.min(width - 1); let y0 = y.min(height - 1);
|
||||||
|
let x1 = x.saturating_add(w).min(width).saturating_sub(1);
|
||||||
|
let y1 = y.saturating_add(h).min(height).saturating_sub(1);
|
||||||
|
(x0 <= x1 && y0 <= y1).then_some((x0, y0, x1, y1))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fill_rect(image: &mut image::RgbaImage, x: u32, y: u32, w: u32, h: u32, color: image::Rgba<u8>, width: u32, height: u32) {
|
||||||
|
let Some((x0, y0, x1, y1)) = clipped_rect(x, y, w, h, width, height) else { return; };
|
||||||
|
for yy in y0..=y1 { for xx in x0..=x1 { image.put_pixel(xx, yy, color); } }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_frame(image: &mut image::RgbaImage, x: u32, y: u32, w: u32, h: u32, width: u32, height: u32) {
|
||||||
|
let Some((x0, y0, x1, y1)) = clipped_rect(x, y, w, h, width, height) else { return; };
|
||||||
|
let green = image::Rgba([0, 255, 0, 255]);
|
||||||
|
for xx in x0..=x1 { image.put_pixel(xx, y0, green); image.put_pixel(xx, y1, green); }
|
||||||
|
for yy in y0..=y1 { image.put_pixel(x0, yy, green); image.put_pixel(x1, yy, green); }
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
mod model;
|
mod model;
|
||||||
|
mod marker;
|
||||||
mod persistence;
|
mod persistence;
|
||||||
mod prompt;
|
mod prompt;
|
||||||
mod tree;
|
mod tree;
|
||||||
@@ -9,6 +10,7 @@ pub use persistence::*;
|
|||||||
pub use tree::*;
|
pub use tree::*;
|
||||||
pub use workflow::apply_batch_patch;
|
pub use workflow::apply_batch_patch;
|
||||||
pub(crate) use workflow::separate_ui_impl;
|
pub(crate) use workflow::separate_ui_impl;
|
||||||
|
pub(crate) use marker::build_marked_image;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -88,13 +90,13 @@ mod tests {
|
|||||||
);
|
);
|
||||||
let result = construct_separation_state(&state(root));
|
let result = construct_separation_state(&state(root));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
result.unprocessed_trees[0].root.children[0].id.as_str(),
|
result.trees[0].root.children[0].id.as_str(),
|
||||||
"image"
|
"image"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn construction_uses_distinct_root_id_for_root_image() {
|
fn construction_keeps_real_root_for_root_image() {
|
||||||
let image = Component::Image(ImageComponent {
|
let image = Component::Image(ImageComponent {
|
||||||
target_graphic: None,
|
target_graphic: None,
|
||||||
image_type: ImageType::Simple {
|
image_type: ImageType::Simple {
|
||||||
@@ -103,9 +105,9 @@ mod tests {
|
|||||||
});
|
});
|
||||||
let root = node("root-image", vec![image], vec![]);
|
let root = node("root-image", vec![image], vec![]);
|
||||||
let result = construct_separation_state(&state(root));
|
let result = construct_separation_state(&state(root));
|
||||||
let tree = &result.unprocessed_trees[0];
|
let tree = &result.trees[0];
|
||||||
assert_ne!(tree.root.id, tree.root.children[0].id);
|
assert_eq!(tree.root.id.as_str(), "root-image");
|
||||||
assert_eq!(tree.root.children[0].id.as_str(), "root-image");
|
assert!(tree.root_extractable);
|
||||||
}
|
}
|
||||||
#[test]
|
#[test]
|
||||||
fn binding_validation_requires_exact_batch_coverage() {
|
fn binding_validation_requires_exact_batch_coverage() {
|
||||||
@@ -131,7 +133,7 @@ mod tests {
|
|||||||
assert!(dir.to_string_lossy().ends_with("-separation"));
|
assert!(dir.to_string_lossy().ends_with("-separation"));
|
||||||
}
|
}
|
||||||
#[test]
|
#[test]
|
||||||
fn patch_collects_bound_and_removes_leaf() {
|
fn patch_collects_bound_and_keeps_tree_topology() {
|
||||||
let image = Component::Image(ImageComponent {
|
let image = Component::Image(ImageComponent {
|
||||||
target_graphic: None,
|
target_graphic: None,
|
||||||
image_type: ImageType::Simple {
|
image_type: ImageType::Simple {
|
||||||
@@ -156,6 +158,6 @@ mod tests {
|
|||||||
let paths = HashMap::from([(id.clone(), "ui/.sidecar/cut.png".to_string())]);
|
let paths = HashMap::from([(id.clone(), "ui/.sidecar/cut.png".to_string())]);
|
||||||
apply_batch_patch(&mut state, 0, &decisions, &paths).unwrap();
|
apply_batch_patch(&mut state, 0, &decisions, &paths).unwrap();
|
||||||
assert_eq!(state.bound[0].node_id, id);
|
assert_eq!(state.bound[0].node_id, id);
|
||||||
assert!(state.unprocessed_trees[0].root.children.is_empty());
|
assert_eq!(state.trees[0].root.children.len(), 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ impl SeparationNode {
|
|||||||
pub struct SeparationTree {
|
pub struct SeparationTree {
|
||||||
pub src_ui_design: UIDesignImageId,
|
pub src_ui_design: UIDesignImageId,
|
||||||
pub root: SeparationNode,
|
pub root: SeparationNode,
|
||||||
|
pub root_extractable: bool,
|
||||||
}
|
}
|
||||||
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, TS)]
|
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, TS)]
|
||||||
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))]
|
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))]
|
||||||
@@ -67,7 +68,7 @@ pub struct ProblematicNode {
|
|||||||
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))]
|
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))]
|
||||||
pub struct SeparationState {
|
pub struct SeparationState {
|
||||||
pub schema_version: String,
|
pub schema_version: String,
|
||||||
pub unprocessed_trees: Vec<SeparationTree>,
|
pub trees: Vec<SeparationTree>,
|
||||||
pub bound: Vec<BoundNode>,
|
pub bound: Vec<BoundNode>,
|
||||||
pub problematic_nodes: Vec<ProblematicNode>,
|
pub problematic_nodes: Vec<ProblematicNode>,
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-4
@@ -35,7 +35,7 @@ pub fn write_separation_state(path: &Path, state: &SeparationState) -> Result<()
|
|||||||
path.file_name()
|
path.file_name()
|
||||||
.and_then(|name| name.to_str())
|
.and_then(|name| name.to_str())
|
||||||
.unwrap_or("<unknown>"),
|
.unwrap_or("<unknown>"),
|
||||||
state.unprocessed_trees.len(),
|
state.trees.len(),
|
||||||
state.bound.len(),
|
state.bound.len(),
|
||||||
state.problematic_nodes.len()
|
state.problematic_nodes.len()
|
||||||
);
|
);
|
||||||
@@ -68,7 +68,7 @@ pub fn write_separation_state(path: &Path, state: &SeparationState) -> Result<()
|
|||||||
fs::metadata(path)
|
fs::metadata(path)
|
||||||
.map(|metadata| metadata.len())
|
.map(|metadata| metadata.len())
|
||||||
.unwrap_or(0),
|
.unwrap_or(0),
|
||||||
state.unprocessed_trees.len(),
|
state.trees.len(),
|
||||||
state.bound.len(),
|
state.bound.len(),
|
||||||
state.problematic_nodes.len()
|
state.problematic_nodes.len()
|
||||||
);
|
);
|
||||||
@@ -97,7 +97,7 @@ pub fn read_separation_state(path: &Path) -> Result<SeparationState, String> {
|
|||||||
app_log!(
|
app_log!(
|
||||||
"ui_separation.state_read.completed bytes={} trees={} bound={} problematic={}",
|
"ui_separation.state_read.completed bytes={} trees={} bound={} problematic={}",
|
||||||
bytes.len(),
|
bytes.len(),
|
||||||
state.unprocessed_trees.len(),
|
state.trees.len(),
|
||||||
state.bound.len(),
|
state.bound.len(),
|
||||||
state.problematic_nodes.len()
|
state.problematic_nodes.len()
|
||||||
);
|
);
|
||||||
@@ -109,7 +109,7 @@ pub fn separation_dto(state: &SeparationState) -> SeparationDTO {
|
|||||||
"ui_separation.dto bound_nodes={} problematic_nodes={} remaining_trees={}",
|
"ui_separation.dto bound_nodes={} problematic_nodes={} remaining_trees={}",
|
||||||
state.bound.len(),
|
state.bound.len(),
|
||||||
state.problematic_nodes.len(),
|
state.problematic_nodes.len(),
|
||||||
state.unprocessed_trees.len()
|
state.trees.len()
|
||||||
);
|
);
|
||||||
SeparationDTO {
|
SeparationDTO {
|
||||||
bound_nodes: state.bound.clone(),
|
bound_nodes: state.bound.clone(),
|
||||||
|
|||||||
@@ -1,31 +1,24 @@
|
|||||||
use super::model::*;
|
use super::model::*;
|
||||||
use crate::ui_editor::component::{image::ImageComponent, Component};
|
use crate::ui_editor::component::{image::ImageComponent, Component};
|
||||||
use crate::ui_editor::layout::node::Node;
|
use crate::ui_editor::layout::node::Node;
|
||||||
use crate::ui_editor::state::{State, UITree};
|
use crate::ui_editor::state::State;
|
||||||
use crate::ui_editor::utils::NodeId;
|
use crate::ui_editor::utils::NodeId;
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
fn is_unbound_image(node: &Node) -> bool {
|
fn is_unbound_image(node: &Node) -> bool {
|
||||||
node.components.iter().any(|component| {
|
node.components.iter().any(|component| {
|
||||||
matches!(
|
matches!(component, Component::Image(ImageComponent { target_graphic: None, .. }))
|
||||||
component,
|
|
||||||
Component::Image(ImageComponent {
|
|
||||||
target_graphic: None,
|
|
||||||
..
|
|
||||||
})
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn node_pixel_rect(
|
fn node_pixel_rect(node: &Node, parent: &crate::ui_editor::layout::dimension::UIRect, ppu: f32) -> (u32, u32, u32, u32) {
|
||||||
node: &Node,
|
|
||||||
parent: &crate::ui_editor::layout::dimension::UIRect,
|
|
||||||
ppu: f32,
|
|
||||||
) -> (u32, u32, u32, u32) {
|
|
||||||
let rect = node.layout.transform.resolve(parent);
|
let rect = node.layout.transform.resolve(parent);
|
||||||
let x = (rect.min.x * ppu).max(0.0).round() as u32;
|
(
|
||||||
let y = (rect.min.y * ppu).max(0.0).round() as u32;
|
(rect.min.x * ppu).max(0.0).round() as u32,
|
||||||
let w = (rect.size.x * ppu).max(0.0).round() as u32;
|
(rect.min.y * ppu).max(0.0).round() as u32,
|
||||||
let h = (rect.size.y * ppu).max(0.0).round() as u32;
|
(rect.size.x * ppu).max(0.0).round() as u32,
|
||||||
(x, y, w, h)
|
(rect.size.y * ppu).max(0.0).round() as u32,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn node_description(node: &Node) -> String {
|
fn node_description(node: &Node) -> String {
|
||||||
@@ -39,31 +32,19 @@ fn node_description(node: &Node) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn collect_todo_nodes(
|
fn collect_todo_nodes(node: &Node, parent: &crate::ui_editor::layout::dimension::UIRect, ppu: f32, output: &mut Vec<SeparationNode>) {
|
||||||
node: &Node,
|
|
||||||
parent: &crate::ui_editor::layout::dimension::UIRect,
|
|
||||||
ppu: f32,
|
|
||||||
output: &mut Vec<SeparationNode>,
|
|
||||||
) {
|
|
||||||
let mut children = Vec::new();
|
|
||||||
let rect = node.layout.transform.resolve(parent);
|
let rect = node.layout.transform.resolve(parent);
|
||||||
|
let mut children = Vec::new();
|
||||||
for child in &node.children {
|
for child in &node.children {
|
||||||
collect_todo_nodes(child, &rect, ppu, &mut children);
|
collect_todo_nodes(child, &rect, ppu, &mut children);
|
||||||
}
|
}
|
||||||
if is_unbound_image(node) {
|
if is_unbound_image(node) {
|
||||||
let (x, y, w, h) = node_pixel_rect(node, parent, ppu);
|
let (x, y, w, h) = node_pixel_rect(node, parent, ppu);
|
||||||
output.push(SeparationNode {
|
output.push(SeparationNode {
|
||||||
id: node.id.clone(),
|
id: node.id.clone(), global_pos_x_px: x, global_pos_y_px: y,
|
||||||
global_pos_x_px: x,
|
width_px: w, height_px: h,
|
||||||
global_pos_y_px: y,
|
note: SeparationNote { description: node_description(node), text_note: String::new() },
|
||||||
width_px: w,
|
children, rework_count: 0,
|
||||||
height_px: h,
|
|
||||||
note: SeparationNote {
|
|
||||||
description: node_description(node),
|
|
||||||
text_note: String::new(),
|
|
||||||
},
|
|
||||||
children,
|
|
||||||
rework_count: 0,
|
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
output.extend(children);
|
output.extend(children);
|
||||||
@@ -71,153 +52,82 @@ fn collect_todo_nodes(
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn construct_separation_state(state: &State) -> SeparationState {
|
pub fn construct_separation_state(state: &State) -> SeparationState {
|
||||||
app_log!(
|
let trees = state.ui_trees.iter().filter_map(|tree| {
|
||||||
"ui_separation.tree_construct.start ui_trees={} ui_images={}",
|
let image = state.ui_design_images.get(&tree.src_ui_design)?;
|
||||||
state.ui_trees.len(),
|
let ppu = image.pixels_per_unit.get();
|
||||||
state.ui_design_images.len()
|
let size = image.pixel_size / ppu;
|
||||||
);
|
let root_rect = crate::ui_editor::layout::dimension::UIRect::new(nalgebra::Point2::origin(), size);
|
||||||
let unprocessed_trees = state
|
let mut children = Vec::new();
|
||||||
.ui_trees
|
for child in &tree.root.children {
|
||||||
.iter()
|
collect_todo_nodes(child, &root_rect, ppu, &mut children);
|
||||||
.filter_map(|tree| {
|
}
|
||||||
let Some(image) = state.ui_design_images.get(&tree.src_ui_design) else {
|
let root_extractable = is_unbound_image(&tree.root);
|
||||||
app_log!(
|
if !root_extractable && children.is_empty() { return None; }
|
||||||
"ui_separation.error stage=tree_construct reason=missing_ui_image image_id={}",
|
let (x, y, w, h) = node_pixel_rect(&tree.root, &root_rect, ppu);
|
||||||
tree.src_ui_design.as_str()
|
Some(SeparationTree {
|
||||||
);
|
src_ui_design: tree.src_ui_design.clone(),
|
||||||
return None;
|
root: SeparationNode {
|
||||||
};
|
id: tree.root.id.clone(), global_pos_x_px: x, global_pos_y_px: y,
|
||||||
let ppu = image.pixels_per_unit.get();
|
width_px: w, height_px: h,
|
||||||
let size = image.pixel_size / ppu;
|
note: SeparationNote { description: node_description(&tree.root), text_note: String::new() },
|
||||||
let root_rect =
|
children, rework_count: 0,
|
||||||
crate::ui_editor::layout::dimension::UIRect::new(nalgebra::Point2::origin(), size);
|
},
|
||||||
let mut children = Vec::new();
|
root_extractable,
|
||||||
collect_todo_nodes(&tree.root, &root_rect, ppu, &mut children);
|
|
||||||
app_log!(
|
|
||||||
"ui_separation.tree_construct.tree image_id={} todo_nodes={} pixel_width={} pixel_height={}",
|
|
||||||
tree.src_ui_design.as_str(),
|
|
||||||
count_nodes(&children),
|
|
||||||
image.pixel_size.x.round() as u32,
|
|
||||||
image.pixel_size.y.round() as u32
|
|
||||||
);
|
|
||||||
(!children.is_empty()).then(|| SeparationTree {
|
|
||||||
src_ui_design: tree.src_ui_design.clone(),
|
|
||||||
root: SeparationNode {
|
|
||||||
// The synthetic root must never share an ID with a real
|
|
||||||
// UI node. A single-image design may use the original
|
|
||||||
// tree root as an eligible separation leaf.
|
|
||||||
id: NodeId::new(format!("separation-root-{}", uuid::Uuid::new_v4().simple()))
|
|
||||||
.expect("synthetic separation root id is valid"),
|
|
||||||
global_pos_x_px: 0,
|
|
||||||
global_pos_y_px: 0,
|
|
||||||
width_px: image.pixel_size.x.max(0.0).round() as u32,
|
|
||||||
height_px: image.pixel_size.y.max(0.0).round() as u32,
|
|
||||||
note: SeparationNote::default(),
|
|
||||||
children,
|
|
||||||
rework_count: 0,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
}).collect();
|
||||||
let result = SeparationState {
|
SeparationState { schema_version: SEPARATION_STATE_SCHEMA_VERSION.to_string(), trees, bound: Vec::new(), problematic_nodes: Vec::new() }
|
||||||
schema_version: SEPARATION_STATE_SCHEMA_VERSION.to_string(),
|
|
||||||
unprocessed_trees,
|
|
||||||
bound: Vec::new(),
|
|
||||||
problematic_nodes: Vec::new(),
|
|
||||||
};
|
|
||||||
app_log!(
|
|
||||||
"ui_separation.tree_construct.completed trees={}",
|
|
||||||
result.unprocessed_trees.len()
|
|
||||||
);
|
|
||||||
result
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn count_nodes(nodes: &[SeparationNode]) -> usize {
|
fn terminal_ids(state: &SeparationState) -> HashSet<NodeId> {
|
||||||
nodes
|
state.bound.iter().map(|n| n.node_id.clone())
|
||||||
.iter()
|
.chain(state.problematic_nodes.iter().map(|n| n.node_id.clone())).collect()
|
||||||
.map(|node| 1 + count_nodes(&node.children))
|
|
||||||
.sum()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn next_leaf_batch(tree: &SeparationTree) -> Vec<&SeparationNode> {
|
fn logical_leaves<'a>(node: &'a SeparationNode, extractable: bool, terminal: &HashSet<NodeId>, output: &mut Vec<&'a SeparationNode>) {
|
||||||
fn leaves<'a>(node: &'a SeparationNode, output: &mut Vec<&'a SeparationNode>) {
|
let is_terminal = terminal.contains(&node.id);
|
||||||
if node.children.is_empty() {
|
let children_terminal = node.children.iter().all(|child| terminal.contains(&child.id));
|
||||||
output.push(node);
|
if extractable && !is_terminal && children_terminal {
|
||||||
} else {
|
output.push(node);
|
||||||
for child in &node.children {
|
return;
|
||||||
leaves(child, output);
|
}
|
||||||
}
|
for child in &node.children {
|
||||||
}
|
logical_leaves(child, true, terminal, output);
|
||||||
}
|
}
|
||||||
let mut output = Vec::new();
|
|
||||||
leaves(&tree.root, &mut output);
|
|
||||||
app_log!(
|
|
||||||
"ui_separation.batch_selected image_id={} leaf_nodes={}",
|
|
||||||
tree.src_ui_design.as_str(),
|
|
||||||
output.len()
|
|
||||||
);
|
|
||||||
output
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn validate_binding_response(
|
fn overlaps(a: &SeparationNode, b: &SeparationNode) -> bool {
|
||||||
response: &BindingResp,
|
let ax1 = a.global_pos_x_px as u64 + a.width_px as u64;
|
||||||
batch: &[&SeparationNode],
|
let ay1 = a.global_pos_y_px as u64 + a.height_px as u64;
|
||||||
) -> Result<(), String> {
|
let bx1 = b.global_pos_x_px as u64 + b.width_px as u64;
|
||||||
app_log!(
|
let by1 = b.global_pos_y_px as u64 + b.height_px as u64;
|
||||||
"ui_separation.binding_validate.start expected_nodes={} decisions={}",
|
let width = ax1.min(bx1).saturating_sub(a.global_pos_x_px.max(b.global_pos_x_px) as u64);
|
||||||
batch.len(),
|
let height = ay1.min(by1).saturating_sub(a.global_pos_y_px.max(b.global_pos_y_px) as u64);
|
||||||
response.decisions.len()
|
width > 0 && height > 0
|
||||||
);
|
}
|
||||||
let expected = batch
|
|
||||||
.iter()
|
pub fn next_leaf_batch<'a>(state: &SeparationState, tree: &'a SeparationTree) -> Vec<&'a SeparationNode> {
|
||||||
.map(|node| node.id.clone())
|
let terminal = terminal_ids(state);
|
||||||
.collect::<std::collections::HashSet<_>>();
|
let mut candidates = Vec::new();
|
||||||
let mut seen = std::collections::HashSet::new();
|
logical_leaves(&tree.root, tree.root_extractable, &terminal, &mut candidates);
|
||||||
|
let mut selected: Vec<&'a SeparationNode> = Vec::new();
|
||||||
|
for candidate in candidates {
|
||||||
|
if selected.iter().all(|other| !overlaps(candidate, other)) { selected.push(candidate); }
|
||||||
|
}
|
||||||
|
app_log!("ui_separation.batch_selected image_id={} leaf_nodes={}", tree.src_ui_design.as_str(), selected.len());
|
||||||
|
selected
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn validate_binding_response(response: &BindingResp, batch: &[&SeparationNode]) -> Result<(), String> {
|
||||||
|
let expected = batch.iter().map(|node| node.id.clone()).collect::<HashSet<_>>();
|
||||||
|
let mut seen = HashSet::new();
|
||||||
for decision in &response.decisions {
|
for decision in &response.decisions {
|
||||||
let node_id = match decision {
|
let node_id = match decision { BindingDecision::Ok { to_node, .. } | BindingDecision::NeedRework { to_node, .. } => to_node };
|
||||||
BindingDecision::Ok { to_node, .. } | BindingDecision::NeedRework { to_node, .. } => {
|
if !expected.contains(node_id) { return Err(format!("视觉绑定返回了未知节点:{}", node_id.as_str())); }
|
||||||
to_node
|
if !seen.insert(node_id.clone()) { return Err(format!("视觉绑定重复返回节点:{}", node_id.as_str())); }
|
||||||
}
|
if let BindingDecision::NeedRework { problem_description, .. } = decision {
|
||||||
};
|
if problem_description.trim().is_empty() { return Err("NeedRework 必须包含问题描述".to_string()); }
|
||||||
if !expected.contains(node_id) {
|
|
||||||
app_log!(
|
|
||||||
"ui_separation.error stage=binding_validate reason=unknown_node node_id={}",
|
|
||||||
node_id.as_str()
|
|
||||||
);
|
|
||||||
return Err(format!("视觉绑定返回了未知节点:{}", node_id.as_str()));
|
|
||||||
}
|
|
||||||
if !seen.insert(node_id.clone()) {
|
|
||||||
app_log!(
|
|
||||||
"ui_separation.error stage=binding_validate reason=duplicate_node node_id={}",
|
|
||||||
node_id.as_str()
|
|
||||||
);
|
|
||||||
return Err(format!("视觉绑定重复返回节点:{}", node_id.as_str()));
|
|
||||||
}
|
|
||||||
if let BindingDecision::NeedRework {
|
|
||||||
problem_description,
|
|
||||||
..
|
|
||||||
} = decision
|
|
||||||
{
|
|
||||||
if problem_description.trim().is_empty() {
|
|
||||||
app_log!(
|
|
||||||
"ui_separation.error stage=binding_validate reason=empty_problem_description node_id={}",
|
|
||||||
node_id.as_str()
|
|
||||||
);
|
|
||||||
return Err("NeedRework 必须包含问题描述".to_string());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if seen.len() != expected.len() {
|
if seen.len() != expected.len() { return Err("视觉绑定未覆盖当前 batch 的全部节点".to_string()); }
|
||||||
app_log!(
|
|
||||||
"ui_separation.error stage=binding_validate reason=incomplete_coverage expected={} seen={}",
|
|
||||||
expected.len(),
|
|
||||||
seen.len()
|
|
||||||
);
|
|
||||||
return Err("视觉绑定未覆盖当前 batch 的全部节点".to_string());
|
|
||||||
}
|
|
||||||
app_log!(
|
|
||||||
"ui_separation.binding_validate.completed covered_nodes={}",
|
|
||||||
seen.len()
|
|
||||||
);
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
+24
-169
@@ -29,22 +29,28 @@ pub fn apply_batch_patch(
|
|||||||
decisions.len(),
|
decisions.len(),
|
||||||
cut_paths.len()
|
cut_paths.len()
|
||||||
);
|
);
|
||||||
let tree = state
|
let batch_nodes = {
|
||||||
.unprocessed_trees
|
let tree = state
|
||||||
.get_mut(tree_index)
|
.trees
|
||||||
.ok_or_else(|| "separation tree 索引无效".to_string())?;
|
.get(tree_index)
|
||||||
let batch = next_leaf_batch(tree);
|
.ok_or_else(|| "separation tree 索引无效".to_string())?;
|
||||||
|
next_leaf_batch(state, tree)
|
||||||
|
.into_iter()
|
||||||
|
.cloned()
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
};
|
||||||
|
let batch = batch_nodes.iter().collect::<Vec<_>>();
|
||||||
validate_binding_response(
|
validate_binding_response(
|
||||||
&BindingResp {
|
&BindingResp {
|
||||||
decisions: decisions.to_vec(),
|
decisions: decisions.to_vec(),
|
||||||
},
|
},
|
||||||
&batch,
|
&batch,
|
||||||
)?;
|
)?;
|
||||||
let rework_counts = batch
|
let rework_counts = batch_nodes
|
||||||
.iter()
|
.iter()
|
||||||
.map(|node| (node.id.clone(), node.rework_count))
|
.map(|node| (node.id.clone(), node.rework_count))
|
||||||
.collect::<std::collections::HashMap<_, _>>();
|
.collect::<std::collections::HashMap<_, _>>();
|
||||||
let mut ids = std::collections::HashSet::new();
|
let tree = state.trees.get_mut(tree_index).expect("tree index checked");
|
||||||
for decision in decisions {
|
for decision in decisions {
|
||||||
match decision {
|
match decision {
|
||||||
BindingDecision::Ok { to_node, .. } => {
|
BindingDecision::Ok { to_node, .. } => {
|
||||||
@@ -55,7 +61,6 @@ pub fn apply_batch_patch(
|
|||||||
node_id: to_node.clone(),
|
node_id: to_node.clone(),
|
||||||
cut_image_path: path.clone(),
|
cut_image_path: path.clone(),
|
||||||
});
|
});
|
||||||
ids.insert(to_node.clone());
|
|
||||||
}
|
}
|
||||||
BindingDecision::NeedRework {
|
BindingDecision::NeedRework {
|
||||||
to_node,
|
to_node,
|
||||||
@@ -68,18 +73,15 @@ pub fn apply_batch_patch(
|
|||||||
problem_description: problem_description.clone(),
|
problem_description: problem_description.clone(),
|
||||||
rework_count: count,
|
rework_count: count,
|
||||||
});
|
});
|
||||||
ids.insert(to_node.clone());
|
|
||||||
} else {
|
} else {
|
||||||
increment_rework_count(&mut tree.root, to_node, count);
|
increment_rework_count(&mut tree.root, to_node, count);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
remove_ids(&mut tree.root, &ids);
|
|
||||||
app_log!(
|
app_log!(
|
||||||
"ui_separation.batch_patch.completed tree_index={} removed_nodes={} bound={} problematic={} pending_root_children={}",
|
"ui_separation.batch_patch.completed tree_index={} bound={} problematic={} pending_root_children={}",
|
||||||
tree_index,
|
tree_index,
|
||||||
ids.len(),
|
|
||||||
state.bound.len(),
|
state.bound.len(),
|
||||||
state.problematic_nodes.len(),
|
state.problematic_nodes.len(),
|
||||||
tree.root.children.len()
|
tree.root.children.len()
|
||||||
@@ -87,34 +89,6 @@ pub fn apply_batch_patch(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn mark_batch_problematic(
|
|
||||||
state: &mut SeparationState,
|
|
||||||
tree_index: usize,
|
|
||||||
batch: &[&SeparationNode],
|
|
||||||
error: String,
|
|
||||||
) {
|
|
||||||
app_log!(
|
|
||||||
"ui_separation.batch_problematic tree_index={} nodes={} error={}",
|
|
||||||
tree_index,
|
|
||||||
batch.len(),
|
|
||||||
error
|
|
||||||
);
|
|
||||||
let ids = batch
|
|
||||||
.iter()
|
|
||||||
.map(|node| node.id.clone())
|
|
||||||
.collect::<std::collections::HashSet<_>>();
|
|
||||||
for node in batch {
|
|
||||||
state.problematic_nodes.push(ProblematicNode {
|
|
||||||
node_id: node.id.clone(),
|
|
||||||
problem_description: error.clone(),
|
|
||||||
rework_count: node.rework_count,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if let Some(tree) = state.unprocessed_trees.get_mut(tree_index) {
|
|
||||||
remove_ids(&mut tree.root, &ids);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn increment_rework_count(node: &mut SeparationNode, id: &NodeId, count: u32) {
|
fn increment_rework_count(node: &mut SeparationNode, id: &NodeId, count: u32) {
|
||||||
if node.id == *id {
|
if node.id == *id {
|
||||||
node.rework_count = count;
|
node.rework_count = count;
|
||||||
@@ -222,108 +196,6 @@ async fn raw_image_edit(
|
|||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn build_marked_image(
|
|
||||||
source_url: String,
|
|
||||||
nodes: Vec<SeparationNode>,
|
|
||||||
target: PathBuf,
|
|
||||||
) -> Result<String, String> {
|
|
||||||
app_log!(
|
|
||||||
"ui_separation.mark_image.start nodes={} target_file={}",
|
|
||||||
nodes.len(),
|
|
||||||
target
|
|
||||||
.file_name()
|
|
||||||
.and_then(|name| name.to_str())
|
|
||||||
.unwrap_or("<unknown>")
|
|
||||||
);
|
|
||||||
tokio::task::spawn_blocking(move || {
|
|
||||||
let areas = nodes
|
|
||||||
.iter()
|
|
||||||
.map(|node| {
|
|
||||||
(
|
|
||||||
node.global_pos_x_px,
|
|
||||||
node.global_pos_y_px,
|
|
||||||
node.width_px,
|
|
||||||
node.height_px,
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
build_marked_image_blocking(&source_url, &areas, &target)
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.map_err(|error| format!("构建标记图任务失败:{error}"))?
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_marked_image_blocking(
|
|
||||||
source_url: &str,
|
|
||||||
nodes: &[(u32, u32, u32, u32)],
|
|
||||||
target: &Path,
|
|
||||||
) -> Result<String, String> {
|
|
||||||
let encoded = source_url
|
|
||||||
.split_once(',')
|
|
||||||
.map(|(_, d)| d)
|
|
||||||
.ok_or_else(|| "源图 data URL 无效".to_string())?;
|
|
||||||
let bytes = base64::engine::general_purpose::STANDARD
|
|
||||||
.decode(encoded)
|
|
||||||
.map_err(|e| format!("解码源图失败:{e}"))?;
|
|
||||||
let mut image = image::load_from_memory(&bytes)
|
|
||||||
.map_err(|e| format!("读取源图失败:{e}"))?
|
|
||||||
.to_rgba8();
|
|
||||||
let width = image.width();
|
|
||||||
let height = image.height();
|
|
||||||
app_log!(
|
|
||||||
"ui_separation.mark_image.decoded nodes={} width={} height={}",
|
|
||||||
nodes.len(),
|
|
||||||
width,
|
|
||||||
height
|
|
||||||
);
|
|
||||||
for &(global_pos_x_px, global_pos_y_px, node_width_px, node_height_px) in nodes {
|
|
||||||
let x0 = global_pos_x_px.min(width.saturating_sub(1));
|
|
||||||
let y0 = global_pos_y_px.min(height.saturating_sub(1));
|
|
||||||
let x1 = global_pos_x_px
|
|
||||||
.saturating_add(node_width_px)
|
|
||||||
.min(width)
|
|
||||||
.saturating_sub(1);
|
|
||||||
let y1 = global_pos_y_px
|
|
||||||
.saturating_add(node_height_px)
|
|
||||||
.min(height)
|
|
||||||
.saturating_sub(1);
|
|
||||||
if x0 >= x1 || y0 >= y1 {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
for x in x0..=x1 {
|
|
||||||
image.put_pixel(x, y0, image::Rgba([0, 255, 0, 255]));
|
|
||||||
image.put_pixel(x, y1, image::Rgba([0, 255, 0, 255]));
|
|
||||||
}
|
|
||||||
for y in y0..=y1 {
|
|
||||||
image.put_pixel(x0, y, image::Rgba([0, 255, 0, 255]));
|
|
||||||
image.put_pixel(x1, y, image::Rgba([0, 255, 0, 255]));
|
|
||||||
}
|
|
||||||
for y in y0..=y1 {
|
|
||||||
for x in x0..=x1 {
|
|
||||||
if x > x0 && x < x1 && y > y0 && y < y1 {
|
|
||||||
image.put_pixel(x, y, image::Rgba([180, 0, 180, 120]));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
image::DynamicImage::ImageRgba8(image.clone())
|
|
||||||
.save_with_format(target, image::ImageFormat::Png)
|
|
||||||
.map_err(|e| format!("写入标记图失败:{e}"))?;
|
|
||||||
let mut png = Vec::new();
|
|
||||||
image::DynamicImage::ImageRgba8(image)
|
|
||||||
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
|
|
||||||
.map_err(|e| format!("编码标记图失败:{e}"))?;
|
|
||||||
let result = format!(
|
|
||||||
"data:image/png;base64,{}",
|
|
||||||
base64::engine::general_purpose::STANDARD.encode(png)
|
|
||||||
);
|
|
||||||
app_log!(
|
|
||||||
"ui_separation.mark_image.completed data_url_chars={}",
|
|
||||||
result.len()
|
|
||||||
);
|
|
||||||
Ok(result)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn write_processed_image(processed_url: String, target: PathBuf) -> Result<(), String> {
|
async fn write_processed_image(processed_url: String, target: PathBuf) -> Result<(), String> {
|
||||||
app_log!(
|
app_log!(
|
||||||
"ui_separation.processed_image.write.start target_file={} data_url_chars={}",
|
"ui_separation.processed_image.write.start target_file={} data_url_chars={}",
|
||||||
@@ -503,12 +375,12 @@ pub(crate) async fn separate_ui_impl(
|
|||||||
"ui_separation.state_ready asset_id={} restored={} trees={} bound={} problematic={}",
|
"ui_separation.state_ready asset_id={} restored={} trees={} bound={} problematic={}",
|
||||||
asset_id,
|
asset_id,
|
||||||
restored,
|
restored,
|
||||||
separation.unprocessed_trees.len(),
|
separation.trees.len(),
|
||||||
separation.bound.len(),
|
separation.bound.len(),
|
||||||
separation.problematic_nodes.len()
|
separation.problematic_nodes.len()
|
||||||
);
|
);
|
||||||
for tree_index in 0..separation.unprocessed_trees.len() {
|
for tree_index in 0..separation.trees.len() {
|
||||||
let tree = &separation.unprocessed_trees[tree_index];
|
let tree = &separation.trees[tree_index];
|
||||||
let image_id = tree.src_ui_design.clone();
|
let image_id = tree.src_ui_design.clone();
|
||||||
let image = state
|
let image = state
|
||||||
.ui_design_images
|
.ui_design_images
|
||||||
@@ -541,10 +413,10 @@ pub(crate) async fn separate_ui_impl(
|
|||||||
})?;
|
})?;
|
||||||
let mut batch_index = 0usize;
|
let mut batch_index = 0usize;
|
||||||
loop {
|
loop {
|
||||||
let Some(current_tree) = separation.unprocessed_trees.get(tree_index) else {
|
let Some(current_tree) = separation.trees.get(tree_index) else {
|
||||||
break;
|
break;
|
||||||
};
|
};
|
||||||
let batch_nodes = next_leaf_batch(current_tree)
|
let batch_nodes = next_leaf_batch(&separation, current_tree)
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.cloned()
|
.cloned()
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
@@ -584,10 +456,8 @@ pub(crate) async fn separate_ui_impl(
|
|||||||
tree_index,
|
tree_index,
|
||||||
batch_index
|
batch_index
|
||||||
);
|
);
|
||||||
mark_batch_problematic(&mut separation, tree_index, &batch, error);
|
|
||||||
write_separation_state(&state_path, &separation)?;
|
write_separation_state(&state_path, &separation)?;
|
||||||
batch_index += 1;
|
return Err(error);
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let processed_url = match raw_image_edit(
|
let processed_url = match raw_image_edit(
|
||||||
@@ -606,10 +476,8 @@ pub(crate) async fn separate_ui_impl(
|
|||||||
tree_index,
|
tree_index,
|
||||||
batch_index
|
batch_index
|
||||||
);
|
);
|
||||||
mark_batch_problematic(&mut separation, tree_index, &batch, error);
|
|
||||||
write_separation_state(&state_path, &separation)?;
|
write_separation_state(&state_path, &separation)?;
|
||||||
batch_index += 1;
|
return Err(error);
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let processed_path = sidecar.join(format!("processed-{}.png", separation.bound.len()));
|
let processed_path = sidecar.join(format!("processed-{}.png", separation.bound.len()));
|
||||||
@@ -621,10 +489,8 @@ pub(crate) async fn separate_ui_impl(
|
|||||||
tree_index,
|
tree_index,
|
||||||
batch_index
|
batch_index
|
||||||
);
|
);
|
||||||
mark_batch_problematic(&mut separation, tree_index, &batch, error);
|
|
||||||
write_separation_state(&state_path, &separation)?;
|
write_separation_state(&state_path, &separation)?;
|
||||||
batch_index += 1;
|
return Err(error);
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
let binding = match visual_binding(source_url.clone(), processed_url, &batch).await {
|
let binding = match visual_binding(source_url.clone(), processed_url, &batch).await {
|
||||||
Ok(value) => value,
|
Ok(value) => value,
|
||||||
@@ -634,10 +500,8 @@ pub(crate) async fn separate_ui_impl(
|
|||||||
tree_index,
|
tree_index,
|
||||||
batch_index
|
batch_index
|
||||||
);
|
);
|
||||||
mark_batch_problematic(&mut separation, tree_index, &batch, error);
|
|
||||||
write_separation_state(&state_path, &separation)?;
|
write_separation_state(&state_path, &separation)?;
|
||||||
batch_index += 1;
|
return Err(error);
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
app_log!(
|
app_log!(
|
||||||
@@ -686,10 +550,8 @@ pub(crate) async fn separate_ui_impl(
|
|||||||
tree_index,
|
tree_index,
|
||||||
batch_index
|
batch_index
|
||||||
);
|
);
|
||||||
mark_batch_problematic(&mut separation, tree_index, &batch, error);
|
|
||||||
write_separation_state(&state_path, &separation)?;
|
write_separation_state(&state_path, &separation)?;
|
||||||
batch_index += 1;
|
return Err(error);
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
apply_batch_patch(&mut separation, tree_index, &binding.decisions, &cut_paths)?;
|
apply_batch_patch(&mut separation, tree_index, &binding.decisions, &cut_paths)?;
|
||||||
write_separation_state(&state_path, &separation)?;
|
write_separation_state(&state_path, &separation)?;
|
||||||
@@ -778,10 +640,3 @@ fn cut_processed_image_blocking(
|
|||||||
);
|
);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn remove_ids(node: &mut SeparationNode, ids: &std::collections::HashSet<NodeId>) {
|
|
||||||
node.children.retain(|child| !ids.contains(&child.id));
|
|
||||||
for child in &mut node.children {
|
|
||||||
remove_ids(child, ids);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user