格式化自动分离 Rust 模块

统一树选择、标记图和批次流程代码风格
This commit is contained in:
2026-09-09 14:50:19 +08:00
parent 26b9ad7fad
commit d30bc462c5
4 changed files with 285 additions and 85 deletions
@@ -4,49 +4,129 @@ 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> {
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();
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);
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);
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}"))?;
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)))
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);
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 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; };
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); }
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,31 +1,31 @@
mod model;
mod marker;
mod model;
mod persistence;
mod prompt;
mod tree;
mod workflow;
pub(crate) use marker::build_marked_image;
pub use model::*;
pub use persistence::*;
pub use tree::*;
pub use workflow::apply_batch_patch;
pub(crate) use workflow::separate_ui_impl;
pub(crate) use marker::build_marked_image;
#[cfg(test)]
mod tests {
use super::*;
use crate::ui_editor::component::Component;
use crate::ui_editor::layout::node::Node;
use crate::ui_editor::state::{State, UITree};
use crate::ui_editor::utils::{NodeId, UIDesignImageId};
use std::path::Path;
use crate::ui_editor::component::image::{ImageComponent, ImageType};
use crate::ui_editor::component::Component;
use crate::ui_editor::layout::children_display_mode::ChildrenDisplayMode;
use crate::ui_editor::layout::control_layout::ControlLayout;
use crate::ui_editor::layout::node::Node;
use crate::ui_editor::layout::node::{NodeMetadata, NodeSource, StageStatus};
use crate::ui_editor::resource::ui_design_image::UIDesignImage;
use crate::ui_editor::state::{State, UITree};
use crate::ui_editor::utils::{NodeId, UIDesignImageId};
use nalgebra::Vector2;
use std::collections::HashMap;
use std::path::Path;
use typed_floats::tf32::StrictlyPositiveFinite;
fn node(id: &str, components: Vec<Component>, children: Vec<Node>) -> Node {
@@ -89,10 +89,7 @@ mod tests {
)],
);
let result = construct_separation_state(&state(root));
assert_eq!(
result.trees[0].root.children[0].id.as_str(),
"image"
);
assert_eq!(result.trees[0].root.children[0].id.as_str(), "image");
}
#[test]
@@ -7,11 +7,21 @@ use std::collections::HashSet;
fn is_unbound_image(node: &Node) -> bool {
node.components.iter().any(|component| {
matches!(component, Component::Image(ImageComponent { target_graphic: None, .. }))
matches!(
component,
Component::Image(ImageComponent {
target_graphic: None,
..
})
)
})
}
fn node_pixel_rect(node: &Node, parent: &crate::ui_editor::layout::dimension::UIRect, ppu: f32) -> (u32, u32, u32, u32) {
fn node_pixel_rect(
node: &Node,
parent: &crate::ui_editor::layout::dimension::UIRect,
ppu: f32,
) -> (u32, u32, u32, u32) {
let rect = node.layout.transform.resolve(parent);
(
(rect.min.x * ppu).max(0.0).round() as u32,
@@ -32,7 +42,12 @@ fn node_description(node: &Node) -> String {
}
}
fn collect_todo_nodes(node: &Node, parent: &crate::ui_editor::layout::dimension::UIRect, ppu: f32, output: &mut Vec<SeparationNode>) {
fn collect_todo_nodes(
node: &Node,
parent: &crate::ui_editor::layout::dimension::UIRect,
ppu: f32,
output: &mut Vec<SeparationNode>,
) {
let rect = node.layout.transform.resolve(parent);
let mut children = Vec::new();
for child in &node.children {
@@ -41,10 +56,17 @@ fn collect_todo_nodes(node: &Node, parent: &crate::ui_editor::layout::dimension:
if is_unbound_image(node) {
let (x, y, w, h) = node_pixel_rect(node, parent, ppu);
output.push(SeparationNode {
id: node.id.clone(), global_pos_x_px: x, global_pos_y_px: y,
width_px: w, height_px: h,
note: SeparationNote { description: node_description(node), text_note: String::new() },
children, rework_count: 0,
id: node.id.clone(),
global_pos_x_px: x,
global_pos_y_px: y,
width_px: w,
height_px: h,
note: SeparationNote {
description: node_description(node),
text_note: String::new(),
},
children,
rework_count: 0,
});
} else {
output.extend(children);
@@ -52,40 +74,71 @@ fn collect_todo_nodes(node: &Node, parent: &crate::ui_editor::layout::dimension:
}
pub fn construct_separation_state(state: &State) -> SeparationState {
let trees = state.ui_trees.iter().filter_map(|tree| {
let image = state.ui_design_images.get(&tree.src_ui_design)?;
let ppu = image.pixels_per_unit.get();
let size = image.pixel_size / ppu;
let root_rect = crate::ui_editor::layout::dimension::UIRect::new(nalgebra::Point2::origin(), size);
let mut children = Vec::new();
for child in &tree.root.children {
collect_todo_nodes(child, &root_rect, ppu, &mut children);
}
let root_extractable = is_unbound_image(&tree.root);
if !root_extractable && children.is_empty() { return None; }
let (x, y, w, h) = node_pixel_rect(&tree.root, &root_rect, ppu);
Some(SeparationTree {
src_ui_design: tree.src_ui_design.clone(),
root: SeparationNode {
id: tree.root.id.clone(), global_pos_x_px: x, global_pos_y_px: y,
width_px: w, height_px: h,
note: SeparationNote { description: node_description(&tree.root), text_note: String::new() },
children, rework_count: 0,
},
root_extractable,
let trees = state
.ui_trees
.iter()
.filter_map(|tree| {
let image = state.ui_design_images.get(&tree.src_ui_design)?;
let ppu = image.pixels_per_unit.get();
let size = image.pixel_size / ppu;
let root_rect =
crate::ui_editor::layout::dimension::UIRect::new(nalgebra::Point2::origin(), size);
let mut children = Vec::new();
for child in &tree.root.children {
collect_todo_nodes(child, &root_rect, ppu, &mut children);
}
let root_extractable = is_unbound_image(&tree.root);
if !root_extractable && children.is_empty() {
return None;
}
let (x, y, w, h) = node_pixel_rect(&tree.root, &root_rect, ppu);
Some(SeparationTree {
src_ui_design: tree.src_ui_design.clone(),
root: SeparationNode {
id: tree.root.id.clone(),
global_pos_x_px: x,
global_pos_y_px: y,
width_px: w,
height_px: h,
note: SeparationNote {
description: node_description(&tree.root),
text_note: String::new(),
},
children,
rework_count: 0,
},
root_extractable,
})
})
}).collect();
SeparationState { schema_version: SEPARATION_STATE_SCHEMA_VERSION.to_string(), trees, bound: Vec::new(), problematic_nodes: Vec::new() }
.collect();
SeparationState {
schema_version: SEPARATION_STATE_SCHEMA_VERSION.to_string(),
trees,
bound: Vec::new(),
problematic_nodes: Vec::new(),
}
}
fn terminal_ids(state: &SeparationState) -> HashSet<NodeId> {
state.bound.iter().map(|n| n.node_id.clone())
.chain(state.problematic_nodes.iter().map(|n| n.node_id.clone())).collect()
state
.bound
.iter()
.map(|n| n.node_id.clone())
.chain(state.problematic_nodes.iter().map(|n| n.node_id.clone()))
.collect()
}
fn logical_leaves<'a>(node: &'a SeparationNode, extractable: bool, terminal: &HashSet<NodeId>, output: &mut Vec<&'a SeparationNode>) {
fn logical_leaves<'a>(
node: &'a SeparationNode,
extractable: bool,
terminal: &HashSet<NodeId>,
output: &mut Vec<&'a SeparationNode>,
) {
let is_terminal = terminal.contains(&node.id);
let children_terminal = node.children.iter().all(|child| terminal.contains(&child.id));
let children_terminal = node
.children
.iter()
.all(|child| terminal.contains(&child.id));
if extractable && !is_terminal && children_terminal {
output.push(node);
return;
@@ -100,34 +153,74 @@ fn overlaps(a: &SeparationNode, b: &SeparationNode) -> bool {
let ay1 = a.global_pos_y_px as u64 + a.height_px as u64;
let bx1 = b.global_pos_x_px as u64 + b.width_px as u64;
let by1 = b.global_pos_y_px as u64 + b.height_px as u64;
let width = ax1.min(bx1).saturating_sub(a.global_pos_x_px.max(b.global_pos_x_px) as u64);
let height = ay1.min(by1).saturating_sub(a.global_pos_y_px.max(b.global_pos_y_px) as u64);
let width = ax1
.min(bx1)
.saturating_sub(a.global_pos_x_px.max(b.global_pos_x_px) as u64);
let height = ay1
.min(by1)
.saturating_sub(a.global_pos_y_px.max(b.global_pos_y_px) as u64);
width > 0 && height > 0
}
pub fn next_leaf_batch<'a>(state: &SeparationState, tree: &'a SeparationTree) -> Vec<&'a SeparationNode> {
pub fn next_leaf_batch<'a>(
state: &SeparationState,
tree: &'a SeparationTree,
) -> Vec<&'a SeparationNode> {
let terminal = terminal_ids(state);
let mut candidates = Vec::new();
logical_leaves(&tree.root, tree.root_extractable, &terminal, &mut candidates);
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); }
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());
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<_>>();
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 {
let node_id = match decision { BindingDecision::Ok { to_node, .. } | BindingDecision::NeedRework { to_node, .. } => to_node };
if !expected.contains(node_id) { return Err(format!("视觉绑定返回了未知节点:{}", node_id.as_str())); }
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()); }
let node_id = match decision {
BindingDecision::Ok { to_node, .. } | BindingDecision::NeedRework { to_node, .. } => {
to_node
}
};
if !expected.contains(node_id) {
return Err(format!("视觉绑定返回了未知节点:{}", node_id.as_str()));
}
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 seen.len() != expected.len() { return Err("视觉绑定未覆盖当前 batch 的全部节点".to_string()); }
if seen.len() != expected.len() {
return Err("视觉绑定未覆盖当前 batch 的全部节点".to_string());
}
Ok(())
}
@@ -89,6 +89,27 @@ pub fn apply_batch_patch(
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
);
for node in batch {
state.problematic_nodes.push(ProblematicNode {
node_id: node.id.clone(),
problem_description: error.clone(),
rework_count: node.rework_count,
});
}
}
fn increment_rework_count(node: &mut SeparationNode, id: &NodeId, count: u32) {
if node.id == *id {
node.rework_count = count;
@@ -279,7 +300,6 @@ async fn visual_binding(
let llm_config = llm_config.clone();
async move {
let request = LlmRunRequest::new(vec![
LlmMessage::system("你是 UI 图片视觉绑定器。只根据图像判断区域,不做 OCR。"),
LlmMessage::user_multimodal(vec![
LlmMessageContentPart::InputText { text: prompt },
LlmMessageContentPart::InputImage {
@@ -456,8 +476,10 @@ pub(crate) async fn separate_ui_impl(
tree_index,
batch_index
);
mark_batch_problematic(&mut separation, tree_index, &batch, error);
write_separation_state(&state_path, &separation)?;
return Err(error);
batch_index += 1;
continue;
}
};
let processed_url = match raw_image_edit(
@@ -476,8 +498,10 @@ pub(crate) async fn separate_ui_impl(
tree_index,
batch_index
);
mark_batch_problematic(&mut separation, tree_index, &batch, error);
write_separation_state(&state_path, &separation)?;
return Err(error);
batch_index += 1;
continue;
}
};
let processed_path = sidecar.join(format!("processed-{}.png", separation.bound.len()));
@@ -489,8 +513,10 @@ pub(crate) async fn separate_ui_impl(
tree_index,
batch_index
);
mark_batch_problematic(&mut separation, tree_index, &batch, error);
write_separation_state(&state_path, &separation)?;
return Err(error);
batch_index += 1;
continue;
}
let binding = match visual_binding(source_url.clone(), processed_url, &batch).await {
Ok(value) => value,
@@ -500,8 +526,10 @@ pub(crate) async fn separate_ui_impl(
tree_index,
batch_index
);
mark_batch_problematic(&mut separation, tree_index, &batch, error);
write_separation_state(&state_path, &separation)?;
return Err(error);
batch_index += 1;
continue;
}
};
app_log!(
@@ -550,8 +578,10 @@ pub(crate) async fn separate_ui_impl(
tree_index,
batch_index
);
mark_batch_problematic(&mut separation, tree_index, &batch, error);
write_separation_state(&state_path, &separation)?;
return Err(error);
batch_index += 1;
continue;
}
apply_batch_patch(&mut separation, tree_index, &binding.decisions, &cut_paths)?;
write_separation_state(&state_path, &separation)?;