重构分离树与非重叠批次选择

保留真实根节点并记录 root_extractable
按终态反查和 DFS 贪心筛选逻辑叶
拆出父子区域标记图生成模块
This commit is contained in:
2026-09-09 14:48:18 +08:00
parent bc5398894e
commit 26b9ad7fad
6 changed files with 174 additions and 354 deletions
@@ -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>,
} }
@@ -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(())
} }
@@ -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);
}
}