Files
CthomasdesignandClaude Opus 4.8 3ec843606c feat: two-reference grounded edits + preprocess robustness fix
Generalize the grounded encode to N reference images (scene + subject):
both go through the vision tower with a per-image <vision> block, spliced
and deepstack-injected at their own token positions, with multi-image
get_rope_index. Validated vs full torch Qwen3-VL — two-image grounded tap
matches to cosine 1.0, mean|Δ| 3.1e-5 (single-image unchanged). generate_edit
now feeds both image + image_b through grounding when grounding_px > 0.

Fix a ZeroDivisionError in preprocess: max_pixels is now clamped so a tiny
grounding_px (e.g. from a stale workflow whose widgets predate the
grounding_px control) can't zero the area ceiling. The example edit
workflow gains the grounding_px widget so its values align with the node.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 20:37:02 -04:00

70 lines
3.2 KiB
Python

"""Torch-free Qwen3-VL image preprocessing → (pixel_values, grid_thw).
Matches transformers' Qwen2-VL/Qwen3-VL image processor (validated in tools/test_vision_tower.py):
smart-resize to multiples of patch*merge, CLIP rescale+normalize, then the patch flatten the vision
tower expects. `grounding_px` caps the longest side before smart-resize (as in comfyui-krea2edit).
"""
from __future__ import annotations
import math
import numpy as np
IMAGE_MEAN = np.array([0.48145466, 0.4578275, 0.40821073], np.float32)
IMAGE_STD = np.array([0.26862954, 0.26130258, 0.27577711], np.float32)
def smart_resize(h, w, factor, min_pixels, max_pixels):
"""Nearest (h,w) with both divisible by `factor`, pixel count in [min,max], ratio preserved."""
h_bar = max(factor, round(h / factor) * factor)
w_bar = max(factor, round(w / factor) * factor)
if h_bar * w_bar > max_pixels:
beta = math.sqrt((h * w) / max_pixels)
h_bar = max(factor, math.floor(h / beta / factor) * factor)
w_bar = max(factor, math.floor(w / beta / factor) * factor)
elif h_bar * w_bar < min_pixels:
beta = math.sqrt(min_pixels / (h * w))
h_bar = math.ceil(h * beta / factor) * factor
w_bar = math.ceil(w * beta / factor) * factor
return h_bar, w_bar
def preprocess_image(image, *, patch_size=16, merge_size=2, temporal_patch_size=2,
grounding_px=768, min_pixels=None, max_pixels=None):
"""PIL image -> (pixel_values [num_patches, C*T*P*P] float32, grid_thw [[1,gh,gw]] int32)."""
from PIL import Image
image = image.convert("RGB")
w0, h0 = image.size
factor = patch_size * merge_size
if min_pixels is None:
min_pixels = factor * factor
if max_pixels is None:
# area ceiling from grounding_px, clamped so a tiny/garbage value can't zero it out
eff = max(int(grounding_px or 1536), factor)
max_pixels = (eff // factor * factor) ** 2
# cap the longest side to grounding_px first (as comfyui-krea2edit does), then snap to the grid
if grounding_px and grounding_px >= factor and max(h0, w0) > grounding_px:
s = grounding_px / max(h0, w0)
h0, w0 = round(h0 * s), round(w0 * s)
rh, rw = smart_resize(h0, w0, factor, min_pixels, max_pixels)
image = image.resize((rw, rh), Image.Resampling.BICUBIC)
arr = np.asarray(image, np.float32) / 255.0 # (H,W,3) in 0..1
arr = (arr - IMAGE_MEAN) / IMAGE_STD # CLIP normalize
arr = np.transpose(arr, (2, 0, 1)) # (C,H,W)
c = arr.shape[0]
gh, gw = rh // patch_size, rw // patch_size
p, m, tp = patch_size, merge_size, temporal_patch_size
# (C, gh/m, m, p, gw/m, m, p) -> (gh/m, gw/m, m, m, C, p, p) [block-major token order]
patches = arr.reshape(c, gh // m, m, p, gw // m, m, p).transpose(1, 4, 2, 5, 0, 3, 6)
# insert the temporal axis after C and duplicate the single frame → (…, C, T, p, p)
patches = np.broadcast_to(patches[:, :, :, :, :, None, :, :],
(gh // m, gw // m, m, m, c, tp, p, p))
pixel_values = patches.reshape(gh * gw, c * tp * p * p).astype(np.float32)
grid_thw = np.array([[1, gh, gw]], np.int32)
return pixel_values, grid_thw