视觉绑定增加透明标记预处理

新增视觉绑定 RGBA 预处理与阈值复用

在请求前生成不透明洋红标记图并记录耗时

更新英文提示词说明标记色语义
This commit is contained in:
2026-09-11 15:09:44 +08:00
parent 7c7d55dcab
commit 8f680ef376
6 changed files with 210 additions and 17 deletions
@@ -7,6 +7,14 @@ use std::time::Instant;
/// intentional workflow decision rather than a scattered numeric literal.
pub(crate) const MAX_BINDING_AREA_EDGE_ADJUSTMENT_PX: u32 = 32;
/// Alpha values below this threshold are treated as transparent for boundary
/// detection. The cropped pixels themselves are preserved unchanged.
pub(crate) const MIN_VISIBLE_ALPHA: u8 = 16;
/// An edge needs this many consecutive visible pixels to count as supported.
/// The requirement is reduced to the edge length for one-pixel-wide elements.
pub(crate) const MIN_CONSECUTIVE_VISIBLE_EDGE_PIXELS: usize = 2;
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) struct NormalizedBindingArea {
pub(crate) area: BindingArea,
@@ -61,7 +69,35 @@ impl Edge {
const ALL: [Self; 4] = [Self::Left, Self::Right, Self::Top, Self::Bottom];
}
fn pixel_is_visible(alpha: u8) -> bool {
alpha >= MIN_VISIBLE_ALPHA
}
fn has_consecutive_visible_pixels<I>(alphas: I, required: usize) -> bool
where
I: IntoIterator<Item = u8>,
{
let required = required.max(1);
let mut consecutive = 0usize;
for alpha in alphas {
if pixel_is_visible(alpha) {
consecutive = consecutive.saturating_add(1);
if consecutive >= required {
return true;
}
} else {
consecutive = 0;
}
}
false
}
fn edge_has_visible_pixel(image: &RgbaImage, rect: Rect, edge: Edge) -> bool {
let edge_length = match edge {
Edge::Left | Edge::Right => rect.bottom - rect.top,
Edge::Top | Edge::Bottom => rect.right - rect.left,
} as usize;
let required = MIN_CONSECUTIVE_VISIBLE_EDGE_PIXELS.max(1).min(edge_length);
match edge {
Edge::Left | Edge::Right => {
let x = if edge == Edge::Left {
@@ -69,7 +105,10 @@ fn edge_has_visible_pixel(image: &RgbaImage, rect: Rect, edge: Edge) -> bool {
} else {
rect.right - 1
};
(rect.top..rect.bottom).any(|y| image.get_pixel(x, y).0[3] > 0)
has_consecutive_visible_pixels(
(rect.top..rect.bottom).map(|y| image.get_pixel(x, y).0[3]),
required,
)
}
Edge::Top | Edge::Bottom => {
let y = if edge == Edge::Top {
@@ -77,13 +116,17 @@ fn edge_has_visible_pixel(image: &RgbaImage, rect: Rect, edge: Edge) -> bool {
} else {
rect.bottom - 1
};
(rect.left..rect.right).any(|x| image.get_pixel(x, y).0[3] > 0)
has_consecutive_visible_pixels(
(rect.left..rect.right).map(|x| image.get_pixel(x, y).0[3]),
required,
)
}
}
}
fn rect_has_visible_pixel(image: &RgbaImage, rect: Rect) -> bool {
(rect.top..rect.bottom).any(|y| (rect.left..rect.right).any(|x| image.get_pixel(x, y).0[3] > 0))
(rect.top..rect.bottom)
.any(|y| (rect.left..rect.right).any(|x| pixel_is_visible(image.get_pixel(x, y).0[3])))
}
fn edge_direction(image: &RgbaImage, rect: Rect, edge: Edge) -> EdgeDirection {
@@ -366,12 +409,25 @@ mod tests {
}
#[test]
fn keeps_nonzero_alpha_antialias_pixels() {
fn ignores_low_alpha_halo_while_preserving_visible_bounds() {
let mut image = RgbaImage::from_pixel(16, 16, Rgba([0, 0, 0, 0]));
image.put_pixel(5, 6, Rgba([255, 255, 255, 1]));
image.put_pixel(7, 8, Rgba([255, 255, 255, 255]));
let result = normalize_binding_area(&image, area(4, 5, 5, 5)).unwrap();
assert_eq!(result.area, area(5, 6, 3, 3));
for y in 6..10 {
for x in 5..9 {
image.put_pixel(x, y, Rgba([255, 255, 255, 255]));
}
}
image.put_pixel(4, 7, Rgba([255, 255, 255, 1]));
image.put_pixel(9, 8, Rgba([255, 255, 255, 8]));
let result = normalize_binding_area(&image, area(4, 5, 6, 6)).unwrap();
assert_eq!(result.area, area(5, 6, 4, 4));
}
#[test]
fn ignores_isolated_visible_edge_pixel() {
let mut image = image_with_rect(16, 16, 4, 4, 6, 8);
image.put_pixel(6, 4, Rgba([255, 255, 255, 255]));
let result = normalize_binding_area(&image, area(4, 4, 2, 4)).unwrap();
assert_eq!(result.area, area(4, 4, 2, 4));
}
#[test]
@@ -0,0 +1,102 @@
use super::area::MIN_VISIBLE_ALPHA;
use base64::Engine as _;
use image::{ImageFormat, Rgba, RgbaImage};
use std::fs;
use std::io::Cursor;
use std::path::{Path, PathBuf};
pub(crate) const VISUAL_BINDING_TRANSPARENT_MARKER_RGBA: [u8; 4] = [255, 0, 255, 255];
pub(crate) async fn preprocess_for_visual_binding(
processed_url: String,
sidecar: PathBuf,
) -> Result<String, String> {
tokio::task::spawn_blocking(move || {
preprocess_for_visual_binding_blocking(&processed_url, &sidecar)
})
.await
.map_err(|error| format!("视觉绑定预处理任务失败:{error}"))?
}
fn preprocess_for_visual_binding_blocking(
processed_url: &str,
sidecar: &Path,
) -> Result<String, String> {
let encoded = processed_url
.split_once(',')
.map(|(_, data)| data)
.ok_or_else(|| "处理图 data URL 无效".to_string())?;
let bytes = base64::engine::general_purpose::STANDARD
.decode(encoded.trim())
.map_err(|error| format!("解析处理图失败:{error}"))?;
let mut image = image::load_from_memory(&bytes)
.map_err(|error| format!("解码处理图失败:{error}"))?
.to_rgba8();
for pixel in image.pixels_mut() {
if pixel.0[3] < MIN_VISIBLE_ALPHA {
*pixel = Rgba(VISUAL_BINDING_TRANSPARENT_MARKER_RGBA);
} else {
pixel.0[3] = 255;
}
}
let mut png = Vec::new();
image::DynamicImage::ImageRgba8(image)
.write_to(&mut Cursor::new(&mut png), ImageFormat::Png)
.map_err(|error| format!("编码视觉绑定预览失败:{error}"))?;
let debug_name = format!("binding-{}.png", uuid::Uuid::new_v4().simple());
fs::write(sidecar.join(&debug_name), &png)
.map_err(|error| format!("写入视觉绑定预览失败:{error}"))?;
Ok(format!(
"data:image/png;base64,{}",
base64::engine::general_purpose::STANDARD.encode(png)
))
}
#[cfg(test)]
mod tests {
use super::*;
use image::Rgba;
use tempfile::tempdir;
fn data_url(image: RgbaImage) -> String {
let mut bytes = Vec::new();
image::DynamicImage::ImageRgba8(image)
.write_to(&mut Cursor::new(&mut bytes), ImageFormat::Png)
.expect("encode fixture");
format!(
"data:image/png;base64,{}",
base64::engine::general_purpose::STANDARD.encode(bytes)
)
}
#[test]
fn preprocesses_alpha_using_existing_visibility_threshold() {
let mut image = RgbaImage::from_pixel(4, 1, Rgba([10, 20, 30, 255]));
image.put_pixel(0, 0, Rgba([1, 2, 3, 0]));
image.put_pixel(1, 0, Rgba([4, 5, 6, MIN_VISIBLE_ALPHA - 1]));
image.put_pixel(2, 0, Rgba([7, 8, 9, MIN_VISIBLE_ALPHA]));
image.put_pixel(3, 0, Rgba([11, 12, 13, 254]));
let directory = tempdir().expect("create sidecar fixture");
let url = preprocess_for_visual_binding_blocking(&data_url(image), directory.path())
.expect("preprocess fixture");
let encoded = url.split_once(',').expect("data URL").1;
let bytes = base64::engine::general_purpose::STANDARD
.decode(encoded)
.expect("decode output");
let output = image::load_from_memory(&bytes)
.expect("decode output png")
.to_rgba8();
assert_eq!(
output.get_pixel(0, 0).0,
VISUAL_BINDING_TRANSPARENT_MARKER_RGBA
);
assert_eq!(
output.get_pixel(1, 0).0,
VISUAL_BINDING_TRANSPARENT_MARKER_RGBA
);
assert_eq!(output.get_pixel(2, 0).0, [7, 8, 9, 255]);
assert_eq!(output.get_pixel(3, 0).0, [11, 12, 13, 255]);
}
}
@@ -1,7 +1,12 @@
use crate::ui_editor::commands::separation::image_preprocess::VISUAL_BINDING_TRANSPARENT_MARKER_RGBA;
use crate::ui_editor::commands::separation::SeparationNode;
pub(crate) fn gen_binding_prompt(nodes: Vec<&SeparationNode>) -> String {
let binding_system_prompt = r#"
let [marker_red, marker_green, marker_blue, marker_alpha] =
VISUAL_BINDING_TRANSPARENT_MARKER_RGBA;
let marker_color = format!("rgba({marker_red}, {marker_green}, {marker_blue}, {marker_alpha})");
let binding_system_prompt = format!(
r#"
You will be given a src UI design image and a processed image, where some ui elements are separated.
You need to recognize and review the separation using the given tool.
field notes:
@@ -13,7 +18,10 @@ pub(crate) fn gen_binding_prompt(nodes: Vec<&SeparationNode>) -> String {
Here were the separation requirements:
Preserve hard edges and the exact visible shape.
The processed image is a transparent atlas containing the requested image layers.
The processed image is an opaque visual-binding preview containing the requested image layers.
The solid color {marker_color} is an intentional transparency marker added by this workflow before this request.
It is not an image-edit defect and is not part of any UI element.
Do not include this marker color in the extracted area.
Do not use the source node rectangle as the extracted area.
And you should also review if the extracted's successfully meet the src image:
* shape
@@ -27,7 +35,7 @@ pub(crate) fn gen_binding_prompt(nodes: Vec<&SeparationNode>) -> String {
these node need handle:
"#
.to_string();
);
let mut result = binding_system_prompt;
result.reserve(512);
for elem in nodes {
@@ -1,4 +1,5 @@
use crate::config::{build_game_creator_llm_client_from_llm_config, load_game_creator_app_config};
use crate::ui_editor::commands::separation::image_preprocess;
use crate::ui_editor::commands::separation::prompt::gen_binding_prompt;
use crate::ui_editor::commands::separation::{
validate_binding_response, BindingResp, SeparationNode,
@@ -10,15 +11,17 @@ use crate::ui_editor::commands::utils::{
use platform_llm::{
LlmFunctionTool, LlmMessage, LlmMessageContentPart, LlmRunRequest, LlmToolChoice,
};
use std::path::PathBuf;
use std::time::Instant;
pub(super) async fn visual_binding(
source_url: String,
processed_url: String,
sidecar: PathBuf,
nodes: &[&SeparationNode],
) -> Result<BindingResp, String> {
let started = Instant::now();
let result = visual_binding_inner(source_url, processed_url, nodes).await;
let result = visual_binding_inner(source_url, processed_url, sidecar, nodes).await;
app_log!(
"ui_separation.visual_binding.timing outcome={} elapsed_ms={} nodes={}",
if result.is_ok() { "ok" } else { "error" },
@@ -31,6 +34,7 @@ pub(super) async fn visual_binding(
async fn visual_binding_inner(
source_url: String,
processed_url: String,
sidecar: PathBuf,
nodes: &[&SeparationNode],
) -> Result<BindingResp, String> {
app_log!(
@@ -39,6 +43,25 @@ async fn visual_binding_inner(
source_url.chars().count(),
processed_url.chars().count()
);
let preprocess_started = Instant::now();
let binding_processed_url =
match image_preprocess::preprocess_for_visual_binding(processed_url, sidecar).await {
Ok(value) => {
app_log!(
"ui_separation.visual_binding.preprocess.timing outcome=ok elapsed_ms={}",
preprocess_started.elapsed().as_millis()
);
value
}
Err(error) => {
app_log!(
"ui_separation.visual_binding.preprocess.timing outcome=error elapsed_ms={}",
preprocess_started.elapsed().as_millis()
);
app_log!("ui_separation.error stage=visual_binding_preprocess error={error}");
return Err(error);
}
};
let llm_config = load_game_creator_app_config()
.map_err(|e| {
app_log!("ui_separation.error stage=visual_binding reason=load_config error={e}");
@@ -67,7 +90,7 @@ async fn visual_binding_inner(
text: "processed image:".to_string(),
},
LlmMessageContentPart::InputImage {
image_url: processed_url.clone(),
image_url: binding_processed_url,
},
LlmMessageContentPart::InputText {
text: "src image:".to_string(),
@@ -44,14 +44,17 @@ Rust 在接收并校验工具结果后,才把这两个枚举变体映射为正
## 图片编辑与视觉绑定
- image-edit 直接使用原始 UI design PNG,不再生成或发送绿色框、紫色填充等额外辅助输入图。提取 prompt 直接描述完整页面分层清单和当前 batch 状态。
- 原因:部分视觉模型不会可靠读取 PNG alpha;image-edit 返回的处理图还可能出现只包含 `1..254`、缺少 `0``255` 的异常 alpha,导致 visual binding 无法稳定区分透明区域与素材内容。该问题只影响视觉模型的观察输入,不改变正式 cut 使用的 RGBA 真相。
- 追加清单区分本轮 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 保证返回相同尺寸,客户端不额外做尺寸拒绝检查。
- 处理图解码/写入和 cut 裁切属于本地 CPU/文件操作,放入独立的 `spawn_blocking` 任务;image-edit 与 visual binding 网络请求仍运行在 async future 中。
- 视觉 binding 输入源图与处理图,只接收当前 batch 的 Image targets,必须为每个 Image target 恰好返回一次 `Ok``NeedRework`。每个决定继续携带 `to_node: NodeId`Text 不出现在请求或 schema 中
- 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 中。
- binding prompt 使用英文明确说明:洋红色是本工作流在请求前注入的透明区域标记,不是 image-edit 缺陷,也不是 UI 素材;模型不得将该颜色计入 extracted area。颜色文本由 `VISUAL_BINDING_TRANSPARENT_MARKER_RGBA` 常量生成,避免提示词与实现漂移。
- `Ok` 返回 `NodeId + BindingArea`Rust 仅校验 NodeId、区域边界和非零尺寸,不检查与原节点框的偏差,也不要求区域不重叠。
- cut 前会对视觉模型返回的 `BindingArea` 做本地像素边界归一化。处理图是透明 PNG,有效像素定义为 `alpha > 0`。四条边以模型 area 为起点,每条边根据首次扫描结果固定方向:边上无有效像素则只向内收缩,边上有有效像素则只向外扩展;四边每轮从同一矩形快照同时逐像素推进,直到达到“内侧有像素、外侧无像素”的分界、图像边界或每条边相对原始 area 的位移上限。每条边最多相对原始 area 移动 `32px`,由模块级常量 `MAX_BINDING_AREA_EDGE_ADJUSTMENT_PX` 定义,与原始 area 尺寸无关。方向固定用于避免稀疏像素造成边界来回振荡;没有理想分界时使用受限范围内的最终 area,不重新请求视觉模型,也不转 problematic。全透明处理图不走特殊错误分支,仍沿同一规则得到最终 area 后裁切。归一化只影响本地 cut,不改写原始 `BindingDecision`、sidecar 或 DTO;日志记录原始 area、最终 area、是否变更,以及仍需移动时是否受到该常量上限、图像边界或非零尺寸约束。性能优化列 TODO。
- cut 前会对视觉模型返回的 `BindingArea` 做本地像素边界归一化。处理图是透明 PNG,有效像素定义为 alpha 不低于模块级常量 `MIN_VISIBLE_ALPHA`(当前为 `16`);边缘扫描还要求至少连续 `MIN_CONSECUTIVE_VISIBLE_EDGE_PIXELS` 个有效像素(当前为 `2`),避免半透明光晕和孤立噪点驱动边界移动。四条边以模型 area 为起点,每条边根据首次扫描结果固定方向:边上无有效像素则只向内收缩,边上有有效像素则只向外扩展;四边每轮从同一矩形快照同时逐像素推进,直到达到“内侧有像素、外侧无像素”的分界、图像边界或每条边相对原始 area 的位移上限。每条边最多相对原始 area 移动 `32px`,由模块级常量 `MAX_BINDING_AREA_EDGE_ADJUSTMENT_PX` 定义,与原始 area 尺寸无关。方向固定用于避免稀疏像素造成边界来回振荡;没有理想分界时使用受限范围内的最终 area,不重新请求视觉模型,也不转 problematic。全透明处理图不走特殊错误分支,仍沿同一规则得到最终 area 后裁切。归一化只影响本地 cut,不改写原始 `BindingDecision`、sidecar 或 DTO;日志记录原始 area、最终 area、是否变更,以及仍需移动时是否受到该常量上限、图像边界或非零尺寸约束。性能优化列 TODO。
- `NeedRework` 携带短问题描述(最多 512 个 Unicode 字符);通过校验后按产生顺序追加到目标 `SeparationNode.note.rework_notes`,下一次该节点进入 image-edit 时全部意见会注入提取 prompt。结构化工具调用失败时使用可复用 repair harness,把错误反馈给模型并额外请求一次;image-edit 不使用该 harness。
- 达到返工上限时仍先保留最后一条视觉模型意见,再把节点追加到 problematic;网络、IO、裁切等基础设施错误不写入节点意见。
- 达到模块级重做常量后,节点移入 problematic;不中断整条工作流,最终统一通知用户。image-edit、图像写入或裁切失败保留当前 state 并返回错误,不自动把整批标记为 problematic。
@@ -67,7 +70,7 @@ Rust 在接收并校验工具结果后,才把这两个枚举变体映射为正
- `SeparationNode.kind` 与 tree children 一起持久化;当前数据结构变更提升 separation state schema 版本,不提供旧 sidecar 迁移或回退。
- sidecar 只在 separation 未完成期间存在;完成后删除 state JSON。
- 当前只持久化已经完成的 batch;正在执行 batch 的恢复语义列 TODO。
- 临时图片可跨重启保留。image-edit 返回的 processed 图和 cut 图片当前都保留用于 debug;理论上 processed 中间图只应在内存中,清理/归档策略列 TODO。
- 临时图片可跨重启保留。image-edit 返回的 processed 图、visual binding 预处理图和 cut 图片当前都保留用于 debug;预处理图位于同一 sidecar,命名为 `binding-<随机 UUID>.png`,不写入 separation state。清理/归档策略列 TODO。
- 并发边界:当前由前端 `isSeparating``runWithStateLocked` 保证同一 UI 编辑会话
同时只有一次 separation。sidecar 是临时恢复状态,不是正式 UI 资产真相,不参与
manifest 或项目 revision,因此当前不额外持有项目写锁;若未来支持多窗口/多进程并发,
@@ -106,7 +109,8 @@ Rust 在接收并校验工具结果后,才把这两个枚举变体映射为正
## 实现组织
- separation prompt 按职责拆分为 `prompt/extract.rs``prompt/binding.rs`,由 `prompt/mod.rs` 统一导出。
- separation workflow 按执行边界拆分为 `workflow/image_edit.rs`Raw image-edit 与处理图写入)、`workflow/binding.rs`(视觉绑定)、`workflow/cut.rs`(像素归一化与裁切)、`workflow/patch.rs`(批次状态 patch);`workflow/mod.rs` 仅负责批次编排与 sidecar 检查点。
- separation workflow 按执行边界拆分为 `workflow/image_edit.rs`Raw image-edit 与处理图写入)、`workflow/binding.rs`(视觉绑定)、`workflow/cut.rs`(像素归一化与裁切)、`workflow/patch.rs`(批次状态 patch);新增 `image_preprocess.rs`visual binding 请求前的 RGBA 标记图转换、PNG 写入和 data URL 生成);`workflow/mod.rs` 仅负责批次编排与 sidecar 检查点。
- `image_preprocess.rs` 的像素转换和 debug 文件写入运行在独立 `spawn_blocking` 任务;`visual_binding` 记录预处理阶段的 `outcome``elapsed_ms`,不记录图片内容或绝对路径。预处理单元测试只验证像素转换和 PNG 可解码,不把 debug 文件是否存在作为测试契约。
- 上述拆分只调整 Rust 模块边界,不改变批次选择、重试、sidecar 持久化、绑定校验或错误恢复语义。
## TODO