调整图片切分批次请求尺寸计算逻辑

基于批次源矩形总面积动态计算 image-edit 请求尺寸,新增面积对齐和最小/最大值限制,扩展相关模块和测试以支持调整后的工作流。
This commit is contained in:
2026-09-11 17:33:26 +08:00
parent b357d36262
commit 8cd6e34af0
6 changed files with 138 additions and 15 deletions
@@ -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 {
@@ -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
@@ -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<NodeId> {
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);
}
}
@@ -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);
}
}
@@ -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::<Vec<_>>();
@@ -125,16 +129,17 @@ pub(crate) async fn separate_ui_impl(
let batch = batch_nodes.iter().collect::<Vec<_>>();
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::<u32>()
);
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)?;
@@ -48,7 +48,7 @@ Rust 在接收并校验工具结果后,才把这两个枚举变体映射为正
- 追加清单区分本轮 Image 输出目标、已完成 Image、仅作父子/遮挡上下文的 Image,以及只需从父图片移除的 Text。清单使用人类可读的编号、name/description、位置和层级,不向 image-edit 暴露 opaque NodeId。
- 提取 prompt 先把 separation tree 投影为小型 YAML 视图,再注入固定规则文本:每个节点只包含展示编号、状态、`x/y/width/height` 矩形、描述、可选返工意见和递归 childrenYAML 不携带 opaque NodeId,树的嵌套关系替代 `depth/role` 字段。
- 由于 raw endpoint 每次只返回一张 PNGprompt 要求 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 中。