From 8cd6e34af0e160bd73873ae90971126c27e7e463 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 11 Sep 2026 17:33:26 +0800 Subject: [PATCH] =?UTF-8?q?=E8=B0=83=E6=95=B4=E5=9B=BE=E7=89=87=E5=88=87?= =?UTF-8?q?=E5=88=86=E6=89=B9=E6=AC=A1=E8=AF=B7=E6=B1=82=E5=B0=BA=E5=AF=B8?= =?UTF-8?q?=E8=AE=A1=E7=AE=97=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 基于批次源矩形总面积动态计算 image-edit 请求尺寸,新增面积对齐和最小/最大值限制,扩展相关模块和测试以支持调整后的工作流。 --- .../src/ui_editor/commands/separation/mod.rs | 2 +- .../commands/separation/model/mod.rs | 2 + .../commands/separation/workflow/batch.rs | 91 ++++++++++++++++++- .../commands/separation/workflow/cut.rs | 36 ++++++++ .../commands/separation/workflow/mod.rs | 20 ++-- ...案】UI编辑器自动切分素材工作流-2026-09-08.md | 2 +- 6 files changed, 138 insertions(+), 15 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/mod.rs index a0b15a162..067c83349 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/mod.rs @@ -10,7 +10,7 @@ pub use model::*; pub use persistence::*; pub use tree::*; pub use workflow::apply_batch_patch; -pub use workflow::batch::next_image_batch; +pub use workflow::batch::{image_edit_dimension_for_area, next_image_batch}; pub(crate) use workflow::separate_ui_impl; #[cfg(test)] mod tests { diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/model/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/model/mod.rs index 16f549feb..74a2c773b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/model/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/model/mod.rs @@ -12,6 +12,8 @@ pub const SEPARATION_STATE_SCHEMA_VERSION: &str = "ui-editor-separation-state.v2 pub const MAX_REWORK_COUNT: u32 = 3; pub const MAX_REWORK_NOTE_CHARS: usize = 512; pub const IMAGE_EDIT_MAX_DIMENSION_PX: u64 = 2880; +pub const IMAGE_EDIT_MIN_DIMENSION_PX: u64 = 816; +pub const IMAGE_EDIT_DIMENSION_ALIGNMENT_PX: u64 = 16; pub const IMAGE_EDIT_AREA_UTILIZATION_PERCENT: u64 = 80; pub const IMAGE_EDIT_AREA_LIMIT_PX: u64 = IMAGE_EDIT_MAX_DIMENSION_PX * IMAGE_EDIT_MAX_DIMENSION_PX * IMAGE_EDIT_AREA_UTILIZATION_PERCENT diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/workflow/batch.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/workflow/batch.rs index 487fc312c..f4033880c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/workflow/batch.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/workflow/batch.rs @@ -2,6 +2,44 @@ use crate::ui_editor::commands::separation::model::*; use crate::ui_editor::utils::NodeId; use std::collections::HashSet; +#[derive(Debug)] +pub struct ImageBatch<'a> { + pub nodes: Vec<&'a SeparationNode>, + pub area_px: u64, + pub image_edit_dimension_px: u32, +} + +/// Derive the square raw image-edit canvas from the selected source area. +/// The calculation lives beside batch selection so the area budget and +/// request size cannot drift apart. +pub fn image_edit_dimension_for_area(area_px: u64) -> u32 { + let max = u128::from(IMAGE_EDIT_MAX_DIMENSION_PX); + let limit = u128::from(IMAGE_EDIT_AREA_LIMIT_PX); + let area = u128::from(area_px); + let raw = if area >= limit { + IMAGE_EDIT_MAX_DIMENSION_PX + } else if area == 0 { + 0 + } else { + // Find floor(max * sqrt(area / limit)) without floating-point rounding. + let target = max * max * area; + let mut low = 0u64; + let mut high = IMAGE_EDIT_MAX_DIMENSION_PX; + while low < high { + let mid = low + (high - low + 1) / 2; + if u128::from(mid) * u128::from(mid) * limit <= target { + low = mid; + } else { + high = mid - 1; + } + } + low + }; + let alignment = IMAGE_EDIT_DIMENSION_ALIGNMENT_PX; + let aligned = raw / alignment * alignment; + aligned.clamp(IMAGE_EDIT_MIN_DIMENSION_PX, IMAGE_EDIT_MAX_DIMENSION_PX) as u32 +} + fn terminal_ids(state: &SeparationState) -> HashSet { state .bound @@ -40,10 +78,10 @@ fn collect_dfs_batch<'a>( false } -pub fn next_image_batch<'a>( +pub fn next_image_batch_with_size<'a>( state: &SeparationState, tree: &'a SeparationTree, -) -> Vec<&'a SeparationNode> { +) -> ImageBatch<'a> { let terminal = terminal_ids(state); let mut selected = Vec::new(); let mut area = 0; @@ -55,11 +93,54 @@ pub fn next_image_batch<'a>( &mut selected, &mut area, ); + let image_edit_dimension_px = image_edit_dimension_for_area(area); app_log!( - "ui_separation.batch_selected image_id={} image_nodes={} area_px={}", + "ui_separation.batch_selected image_id={} image_nodes={} area_px={} image_edit_dimension_px={}", tree.src_ui_design.as_str(), selected.len(), - area + area, + image_edit_dimension_px ); - selected + ImageBatch { + nodes: selected, + area_px: area, + image_edit_dimension_px, + } +} + +pub fn next_image_batch<'a>( + state: &SeparationState, + tree: &'a SeparationTree, +) -> Vec<&'a SeparationNode> { + next_image_batch_with_size(state, tree).nodes +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn image_edit_dimension_uses_minimum_and_alignment() { + assert_eq!(image_edit_dimension_for_area(0), 816); + assert_eq!(image_edit_dimension_for_area(1), 816); + assert_eq!( + image_edit_dimension_for_area(IMAGE_EDIT_AREA_LIMIT_PX / 16), + 816 + ); + assert_eq!( + image_edit_dimension_for_area(IMAGE_EDIT_AREA_LIMIT_PX), + 2880 + ); + assert_eq!(image_edit_dimension_for_area(u64::MAX), 2880); + } + + #[test] + fn image_edit_dimension_rounds_down_to_sixteen_pixels() { + let max_squared = IMAGE_EDIT_MAX_DIMENSION_PX * IMAGE_EDIT_MAX_DIMENSION_PX; + let area_just_below_1536 = IMAGE_EDIT_AREA_LIMIT_PX * 1536 * 1536 / max_squared; + let area_at_1536 = (IMAGE_EDIT_AREA_LIMIT_PX * 1536 * 1536).div_ceil(max_squared); + + assert_eq!(image_edit_dimension_for_area(area_just_below_1536), 1520); + assert_eq!(image_edit_dimension_for_area(area_at_1536), 1536); + } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/workflow/cut.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/workflow/cut.rs index 8b083c8b5..bd4e9d2c7 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/workflow/cut.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/workflow/cut.rs @@ -91,3 +91,39 @@ fn cut_processed_image_blocking( ); Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use image::{Rgba, RgbaImage}; + + #[test] + fn cuts_using_small_processed_image_dimensions() { + let directory = tempfile::tempdir().expect("创建临时目录失败"); + let source = directory.path().join("processed.png"); + let target = directory.path().join("cut.png"); + let mut image = RgbaImage::from_pixel(816, 816, Rgba([0, 0, 0, 0])); + for y in 120..152 { + for x in 700..800 { + image.put_pixel(x, y, Rgba([255, 255, 255, 255])); + } + } + image.save(&source).expect("写入处理图失败"); + + cut_processed_image_blocking( + &source, + &BindingArea { + global_pos_x_px: 700, + global_pos_y_px: 120, + width_px: 100, + height_px: 32, + }, + &target, + ) + .expect("裁切处理图失败"); + + let cropped = image::open(target).expect("读取 cut 图片失败"); + assert_eq!(cropped.width(), 100); + assert_eq!(cropped.height(), 32); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/workflow/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/workflow/mod.rs index 4bba6a8da..8c492b9b6 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/workflow/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/workflow/mod.rs @@ -6,7 +6,7 @@ mod patch; pub use patch::apply_batch_patch; -use self::batch::next_image_batch; +use self::batch::next_image_batch_with_size; use super::model::*; use super::persistence::{ project_relative_path, read_separation_state, separation_dto, separation_sidecar_dir, @@ -111,7 +111,11 @@ pub(crate) async fn separate_ui_impl( let Some(current_tree) = separation.trees.get(tree_index) else { break; }; - let batch_nodes = next_image_batch(&separation, current_tree) + let batch_selection = next_image_batch_with_size(&separation, current_tree); + let batch_area_px = batch_selection.area_px; + let image_edit_dimension_px = batch_selection.image_edit_dimension_px; + let batch_nodes = batch_selection + .nodes .into_iter() .cloned() .collect::>(); @@ -125,16 +129,17 @@ pub(crate) async fn separate_ui_impl( let batch = batch_nodes.iter().collect::>(); let prompt = gen_extract_prompt(&separation, current_tree, &batch); app_log!( - "ui_separation.batch_start tree_index={} batch_index={} nodes={} prompt_chars={} rework_total={}", - tree_index, batch_index, batch.len(), prompt.chars().count(), + "ui_separation.batch_start tree_index={} batch_index={} nodes={} area_px={} image_edit_dimension_px={} prompt_chars={} rework_total={}", + tree_index, batch_index, batch.len(), batch_area_px, image_edit_dimension_px, + prompt.chars().count(), batch.iter().map(|node| node.rework_count).sum::() ); let processed_url = match extract::raw_extract( &session, &source_url, &prompt, - image.pixel_size.x as u32, - image.pixel_size.y as u32, + image_edit_dimension_px, + image_edit_dimension_px, ) .await { @@ -147,8 +152,7 @@ pub(crate) async fn separate_ui_impl( }; let processed_path = sidecar.join(format!("processed-{}.png", separation.bound.len())); if let Err(error) = - extract::write_processed_image(processed_url.clone(), processed_path.clone()) - .await + extract::write_processed_image(processed_url.clone(), processed_path.clone()).await { app_log!("ui_separation.error stage=processed_image_write tree_index={} batch_index={} error={error}", tree_index, batch_index); write_separation_state(&state_path, &separation)?; diff --git a/docs/technical/【技术方案】UI编辑器自动切分素材工作流-2026-09-08.md b/docs/technical/【技术方案】UI编辑器自动切分素材工作流-2026-09-08.md index e69506c13..ce0b51f80 100644 --- a/docs/technical/【技术方案】UI编辑器自动切分素材工作流-2026-09-08.md +++ b/docs/technical/【技术方案】UI编辑器自动切分素材工作流-2026-09-08.md @@ -48,7 +48,7 @@ Rust 在接收并校验工具结果后,才把这两个枚举变体映射为正 - 追加清单区分本轮 Image 输出目标、已完成 Image、仅作父子/遮挡上下文的 Image,以及只需从父图片移除的 Text。清单使用人类可读的编号、name/description、位置和层级,不向 image-edit 暴露 opaque NodeId。 - 提取 prompt 先把 separation tree 投影为小型 YAML 视图,再注入固定规则文本:每个节点只包含展示编号、状态、`x/y/width/height` 矩形、描述、可选返工意见和递归 children;YAML 不携带 opaque NodeId,树的嵌套关系替代 `depth/role` 字段。 - 由于 raw endpoint 每次只返回一张 PNG,prompt 要求 image-edit 输出透明 atlas:本轮图片层可以移动和缩放,放置在不会互相遮挡的位置;视觉模型返回每层在 processed 图中的实际区域。源节点矩形只用于语义定位,不用于裁切区域推断。 -- 请求尺寸始终使用源 UI design 尺寸;Raw GPT Image 2 API 保证返回相同尺寸,客户端不额外做尺寸拒绝检查。 +- image-edit 请求尺寸由当前 batch 的源矩形面积决定,不再始终使用源 UI design 尺寸。设 batch 面积为 `A`、面积上限为 `L = IMAGE_EDIT_AREA_LIMIT_PX`,先计算 `floor(IMAGE_EDIT_MAX_DIMENSION_PX × sqrt(A / L))`,再按 `IMAGE_EDIT_DIMENSION_ALIGNMENT_PX` 向下对齐,并限制在 `IMAGE_EDIT_MIN_DIMENSION_PX` 至 `IMAGE_EDIT_MAX_DIMENSION_PX`(当前为 `816` 至 `2880`)之间;请求使用正方形 `N × N`。Raw GPT Image 2 API 保证返回与请求相同尺寸,客户端不额外做尺寸拒绝检查。最终 cut 继续依据 processed PNG 的实际尺寸执行,不使用源图尺寸换算。 - 处理图解码/写入和 cut 裁切属于本地 CPU/文件操作,放入独立的 `spawn_blocking` 任务;image-edit 与 visual binding 网络请求仍运行在 async future 中。 - visual binding 请求前新增本地预处理:复用 `MIN_VISIBLE_ALPHA`,将 alpha 小于该阈值的像素替换为不透明洋红标记色 `[255, 0, 255, 255]`,其余像素保留 RGB 并将 alpha 设为 `255`。预处理图只用于 visual binding,原始 processed RGBA 继续用于 cut;不新增质量门禁、alpha 统计判定或重试。 - 视觉 binding 输入源图与预处理后的不透明处理图,只接收当前 batch 的 Image targets,必须为每个 Image target 恰好返回一次 `Ok` 或 `NeedRework`。每个决定继续携带 `to_node: NodeId`;Text 不出现在请求或 schema 中。