调整图片切分批次请求尺寸计算逻辑
基于批次源矩形总面积动态计算 image-edit 请求尺寸,新增面积对齐和最小/最大值限制,扩展相关模块和测试以支持调整后的工作流。
This commit is contained in:
@@ -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
|
||||
|
||||
+86
-5
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+36
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+12
-8
@@ -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` 矩形、描述、可选返工意见和递归 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 中。
|
||||
|
||||
Reference in New Issue
Block a user