Files
JasonHoku-ComfyUI-Ultimate-…/florence2_hires.py
T
Jason HokuandClaude Fable 5.1 198138e51c perf(encode): prepare the text encoder once per pre-encode loop, not once per prompt
ComfyUI's CLIP.encode_from_tokens() calls load_model() -> model_management.load_models_gpu()
on every encode. With the dynamic VRAM loader (comfy_aimdo, 0.4x+) that re-runs
ModelPatcherDynamic.load() for a model that is already resident: a walk over every module plus
free_memory bookkeeping, visible as one

    Model MOSSImageTEModel_ prepared for dynamic VRAM loading. 7002MB Staged. ...

log line per prompt (N prompts -> N+1 lines). USCG's pre-encode paid it once per prompt, and
again on the single-encode paths (model switch, distributed, upscale, Florence2 hires).

Measured on the 5060 Ti (ComfyUI 0.42.5), MOSS-Image TE = Qwen3.5-9B w4a8, same process state:
  template path (CLIPTextEncode), per prompt ....... 0.92 s   (re-prepare every encode)
  USCG batch encode with pinned_clip, per prompt ... 0.84 s   (one prepare per pass)
  USCG job, 21 prompts + 21 one-step images ........ 36.8 s -> 33.0 s
  for scale: SDXL 0.15 s, Krea 2 0.29 s, Qwen-Image 0.32 s per prompt on the template path
The rest is the encoder forward itself: ~0.55 s fixed + ~2 ms/token for Qwen3.5-9B (24 of its
32 layers are Gated DeltaNet run through the pure-torch chunked recurrence, fp32 compute), so
a 60-prompt MOSS grid still spends ~50 s encoding before the first image. That part is
identical to the official workflow.

batch_encoding.pinned_clip(clip, texts): load once through load_model() (sized by the longest
text so any memory estimate still runs), shadow clip.load_model with a no-op for the block,
restore on exit or exception, nesting safe. encode_pair_cached(): positive+negative with one
load, nothing when both are cached. The pre-encode summary line now prints total time and
ms/prompt.

Applied to batch_encode_with_cache, the model-switch pre-encode in the main loop, the
distributed group pre-encode and per-job encode, the deferred-upscale re-encode fallback, the
dashboard upscale runner and the Florence2 hires step.

Tests: tests/test_pinned_clip.py (10) against the real batch_encoding module; the conftest
stub gained pinned_clip/encode_pair_cached. 137 passed, same 15 pre-existing torch-stub failures.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-03 22:16:43 -08:00

900 lines
36 KiB
Python

"""Florence2 Hi-Res Fix — targeted-region inpaint via Florence2 referring-expression segmentation.
Soft dependency: kijai/ComfyUI-Florence2 (checked at job start, not at import).
Required ComfyUI built-ins: GrowMask, FeatherMask (always present).
Crop / uncrop / resize-back are pure Python (no kjnodes dep).
Pipeline (per image, per Florence2 step):
1. Pre-flight (once per job, cached)
2. Load Florence2 model (cached per job)
3. Detect via Florence2Run(task=referring_expression_segmentation)
4. GrowMask
5. Crop by mask (pure Python)
6. Megapixel resize
7. VAE encode -> generate_image (project's known-working sampling path)
8. VAE decode -> resize back to crop dims
9. FeatherMask
10. Paste back (pure Python)
11. Save + manifest entry (handled by upscale_runner)
"""
import builtins
import math
import sys
def safe_print(*args, **kwargs):
"""Windows-safe print mirroring config_builder_node.safe_print."""
try:
builtins.print(*args, **kwargs)
except (OSError, ValueError):
try:
msg = " ".join(str(a) for a in args) + kwargs.get("end", "\n")
sys.__stdout__.write(msg)
sys.__stdout__.flush()
except Exception:
pass
print = safe_print
def _vram_log(label):
"""Diagnostic VRAM probe. Prints current CUDA allocated + reserved memory.
Used to characterize the per-image VRAM curve of Florence2 Hi-Res Fix —
pinpoints whether OOMs come from a cumulative leak across the batch or a
one-shot peak on a runaway image. Safe no-op if torch isn't available or
CUDA isn't present (CPU-only test runs).
"""
try:
import torch as _t
if _t.cuda.is_available():
alloc = _t.cuda.memory_allocated() / 1024**3
reserved = _t.cuda.memory_reserved() / 1024**3
safe_print(f"[Florence2HiResFix VRAM] {label}: alloc={alloc:.2f}GB reserved={reserved:.2f}GB")
except Exception:
pass
def compute_target_dims(src_w, src_h, target_megapixels):
"""Compute target (width, height) for a megapixel-based resize.
Uses mebipixel convention (1 MP = 1024 * 1024 pixels), matching ComfyUI core's
ImageScaleToTotalPixels. Output dims are snapped to multiples of 8, floored at 64,
capped at 4096.
Args:
src_w: source width in pixels (int)
src_h: source height in pixels (int)
target_megapixels: target area in MP (float, e.g. 1.0 = ~1024x1024)
Returns:
Tuple (target_w, target_h) of ints, both divisible by 8.
"""
target_pixels = target_megapixels * 1024 * 1024
src_pixels = src_w * src_h
if src_pixels <= 0:
raise ValueError(f"Source dimensions invalid: {src_w}x{src_h}")
scale = math.sqrt(target_pixels / src_pixels)
new_w = max(64, min(4096, int(round(src_w * scale)) // 8 * 8))
new_h = max(64, min(4096, int(round(src_h * scale)) // 8 * 8))
return new_w, new_h
def parse_mask_select_indices(user_input, detected_count):
"""Parse user's output_mask_select string against detected region count.
Args:
user_input: string from UI; "" = use all, "0" = first, "0,2" = specific indices.
detected_count: how many regions Florence2 actually returned.
Returns:
Tuple (indices: list[int], mode: str) where mode is one of:
- "all": use all detected regions (caller should union)
- "select": use the listed indices
- "no_detection": no valid selection possible (caller should treat as miss)
"""
if detected_count <= 0:
return [], "no_detection"
stripped = (user_input or "").strip()
if not stripped:
return [], "all"
raw_tokens = [t.strip() for t in stripped.split(",") if t.strip()]
if not raw_tokens:
return [], "all"
parsed = []
for tok in raw_tokens:
try:
n = int(tok)
except ValueError:
return [], "no_detection"
if n < 0:
return [], "no_detection"
if n < detected_count:
parsed.append(n)
# OOR indices silently dropped
if not parsed:
return [], "no_detection"
# Stable dedupe preserving order
seen = set()
out = []
for n in parsed:
if n not in seen:
seen.add(n)
out.append(n)
return out, "select"
def _crop_image_by_mask(image, mask, padding, min_crop_resolution, max_crop_resolution):
"""Crop image to the bbox of a mask, with padding and min/max constraints.
Args:
image: torch tensor (B, H, W, C) float32 in [0,1]
mask: torch tensor (B, H, W) float32 in [0,1]
padding: int, pixels to expand bbox each side before clamping
min_crop_resolution: int, minimum bbox dim — expands bbox if smaller
max_crop_resolution: int, maximum bbox dim — shrinks bbox if larger
Returns:
Tuple (cropped_image, cropped_mask, bbox) where bbox = (x0, y0, w, h).
Raises:
ValueError: mask is empty (sum == 0).
"""
import torch
# Caller is responsible for no-detection check; this is defense-in-depth.
if mask.sum() <= 0:
raise ValueError("Mask is empty — cannot compute bbox")
# Find tight bbox via nonzero indices on a 2D view of the first batch.
# Mask is (B, H, W); we use batch 0 (Florence2Run returns single-batch).
m2d = mask[0] # (H, W)
img_h, img_w = m2d.shape
nz = torch.nonzero(m2d > 0.5) # (N, 2) = (y, x)
if nz.numel() == 0:
raise ValueError("Mask is empty after thresholding")
y_min = int(nz[:, 0].min().item())
y_max = int(nz[:, 0].max().item())
x_min = int(nz[:, 1].min().item())
x_max = int(nz[:, 1].max().item())
# Apply padding
x0 = max(0, x_min - padding)
y0 = max(0, y_min - padding)
x1 = min(img_w, x_max + 1 + padding)
y1 = min(img_h, y_max + 1 + padding)
bw = x1 - x0
bh = y1 - y0
# Apply min_crop_resolution by expanding around center, clamping to image bounds.
if bw < min_crop_resolution:
cx = (x0 + x1) // 2
half = min_crop_resolution // 2
x0 = max(0, cx - half)
x1 = min(img_w, x0 + min_crop_resolution)
x0 = max(0, x1 - min_crop_resolution) # second pass in case clamp shifted
bw = x1 - x0
if bh < min_crop_resolution:
cy = (y0 + y1) // 2
half = min_crop_resolution // 2
y0 = max(0, cy - half)
y1 = min(img_h, y0 + min_crop_resolution)
y0 = max(0, y1 - min_crop_resolution)
bh = y1 - y0
# Apply max_crop_resolution by contracting around center.
if bw > max_crop_resolution:
cx = (x0 + x1) // 2
half = max_crop_resolution // 2
x0 = cx - half
x1 = x0 + max_crop_resolution
bw = x1 - x0
if bh > max_crop_resolution:
cy = (y0 + y1) // 2
half = max_crop_resolution // 2
y0 = cy - half
y1 = y0 + max_crop_resolution
bh = y1 - y0
# Final clamp to image (in case max shrinking pushed us off-bounds)
x0 = max(0, x0)
y0 = max(0, y0)
x1 = min(img_w, x1)
y1 = min(img_h, y1)
bw = x1 - x0
bh = y1 - y0
cropped_image = image[:, y0:y1, x0:x1, :].contiguous()
cropped_mask = mask[:, y0:y1, x0:x1].contiguous()
bbox = (x0, y0, bw, bh)
return cropped_image, cropped_mask, bbox
def _paste_into_image(destination, source, mask, bbox):
"""Paste a cropped image back into the destination using mask for alpha blending.
Args:
destination: torch tensor (B, H, W, C) — the full original image to paste into
source: torch tensor (B, h, w, C) — the cropped (and resized) image to paste
mask: torch tensor (B, h, w) — alpha mask for the source (0..1)
bbox: tuple (x0, y0, w, h) — where in destination to paste
Returns:
New torch tensor (B, H, W, C) with the blended result.
"""
x0, y0, bw, bh = bbox
src_h, src_w = source.shape[1], source.shape[2]
# If source dims don't match bbox (caller error), use source dims
if src_h != bh or src_w != bw:
bh = src_h
bw = src_w
result = destination.clone()
# Broadcast (B, h, w) mask to (B, h, w, C)
mask_3c = mask.unsqueeze(-1).expand(-1, -1, -1, source.shape[-1])
dest_slice = destination[:, y0:y0 + bh, x0:x0 + bw, :]
blended = source * mask_3c + dest_slice * (1.0 - mask_3c)
result[:, y0:y0 + bh, x0:x0 + bw, :] = blended
return result
# Required Florence2 node class names (looked up via nodes.NODE_CLASS_MAPPINGS)
REQUIRED_FLORENCE2_NODE_NAMES = [
"Florence2Run",
"DownloadAndLoadFlorence2Model",
]
def get_florence2_node_classes():
"""Look up Florence2 nodes in NODE_CLASS_MAPPINGS.
Returns:
Dict mapping node name to class.
Raises:
RuntimeError: any required node is missing.
"""
import nodes
found = {}
missing = []
for name in REQUIRED_FLORENCE2_NODE_NAMES:
cls = nodes.NODE_CLASS_MAPPINGS.get(name)
if cls is None:
missing.append(name)
else:
found[name] = cls
if missing:
raise RuntimeError(
"Florence2 Hi-Res Fix requires the kijai/ComfyUI-Florence2 custom node.\n"
"Missing node(s): " + ", ".join(missing) + "\n\n"
"Install via ComfyUI Manager:\n"
" search 'Florence-2' -> install -> restart Comfy\n\n"
"Or manually:\n"
" cd ComfyUI/custom_nodes\n"
" git clone https://github.com/kijai/ComfyUI-Florence2\n"
" restart Comfy"
)
return found
def preflight_florence2():
"""Validate the Florence2 dependencies are installed.
Call ONCE per job before any image is processed. Raises with a clear
install hint if either required node is missing.
"""
get_florence2_node_classes()
# Module-level cache for Florence2 models. Keyed by model name string.
# Cleared on Comfy restart; survives across jobs in the same session.
_FLORENCE2_MODEL_CACHE = {}
def load_florence2_model(model_name):
"""Load a Florence2 model, caching by name across jobs.
Args:
model_name: HF Hub id, e.g. "microsoft/Florence-2-base"
Returns:
The loaded model handle (whatever the loader node returns at index 0).
"""
if model_name in _FLORENCE2_MODEL_CACHE:
return _FLORENCE2_MODEL_CACHE[model_name]
try:
from .ltx_video_generation import _call_node, _unwrap
except ImportError:
from ltx_video_generation import _call_node, _unwrap
classes = get_florence2_node_classes()
loader_cls = classes["DownloadAndLoadFlorence2Model"]
print(f"[Florence2HiResFix] Loading {model_name}...")
result = _call_node(
loader_cls,
model=model_name,
precision="fp16",
attention="sdpa",
convert_to_safetensors=False,
)
handle = _unwrap(result, 0)
_FLORENCE2_MODEL_CACHE[model_name] = handle
return handle
# Indirection so tests can monkeypatch without importing model_loader (which
# would pull in torch/comfy and slow the test boot). At runtime these resolve
# to the real loaders inside _get_or_load_checkpoint_lora.
_FH_CKPT_LOADER = None
_FH_LORA_LOADER = None
def _resolve_loaders():
"""Lazy-import the real loaders. Only called from runtime path; tests
monkeypatch _FH_CKPT_LOADER / _FH_LORA_LOADER directly.
Uses load_loras_for_preencoding (NOT load_loras) — its signature is
(base_model, base_clip, lora_string) and returns (model, clip), exactly
matching what we need. The full load_loras() requires target_model_name
and incompatible_loras dict tracking which is only meaningful when
iterating across multiple grid configs."""
global _FH_CKPT_LOADER, _FH_LORA_LOADER
if _FH_CKPT_LOADER is None or _FH_LORA_LOADER is None:
try:
from .model_loader import load_checkpoint, load_loras_for_preencoding
except ImportError:
from model_loader import load_checkpoint, load_loras_for_preencoding
_FH_CKPT_LOADER = _FH_CKPT_LOADER or load_checkpoint
_FH_LORA_LOADER = _FH_LORA_LOADER or load_loras_for_preencoding
return _FH_CKPT_LOADER, _FH_LORA_LOADER
def _get_or_load_checkpoint_lora(item, fallback, model_source, cache, session_model_name=None):
"""Resolve (model, clip, vae) handles for one manifest item, honoring model_source.
VRAM optimization: when the item's model matches the session's already-loaded
model (same `session_model_name`), we reuse the session's base weights and just
apply the item's LoRAs on top via `load_loras_for_preencoding`. This avoids
holding TWO full copies of the same checkpoint in VRAM (one loaded by
upscale_runner, one freshly loaded here). On 8 GB cards this duplication was
enough to OOM during Florence2's beam search.
Args:
item: manifest entry dict — may have 'model', 'lora_expanded' keys
fallback: tuple (model, clip, vae) — the already-loaded session-default handles
model_source: "from_manifest" or "from_builder"
cache: dict — job-local cache keyed by (model_name, lora_string)
session_model_name: name of the model upscale_runner already loaded (for the
same-model short-circuit). Optional; if None, every from_manifest item
does a fresh load.
Returns:
Tuple (model, clip, vae).
"""
if model_source != "from_manifest":
return fallback
model_name = item.get("model", "").strip()
if not model_name:
# Legacy entry without model key — fall back, but only log once per job.
if not cache.get("_warned_missing_model"):
print("[Florence2HiResFix] Manifest item missing 'model' field; using session default")
cache["_warned_missing_model"] = True
return fallback
# Read lora_expanded (post-folder-expansion concrete file paths) with fallback to
# the original 'lora' field for legacy manifests written before lora_expanded was
# tracked. Both formats are accepted by parse_lora_definition.
# Also normalize: strip whitespace, collapse any trailing " + " (which can leak
# from " + ".join(parts) where parts has empty trailing entries from expand_lora_folder).
raw_lora = item.get("lora_expanded") or item.get("lora") or ""
lora_string = str(raw_lora).strip()
# Strip trailing "+", " +", "+ ", " + " — these break parse_lora_definition's
# float parsing on the last entry (float("0.20 +") -> ValueError).
while lora_string.endswith("+") or lora_string.endswith(" "):
lora_string = lora_string.rstrip("+ ").rstrip()
# Same for leading.
while lora_string.startswith("+") or lora_string.startswith(" "):
lora_string = lora_string.lstrip("+ ").lstrip()
if not lora_string:
lora_string = "None"
key = (model_name, lora_string)
if key in cache:
return cache[key]
ckpt_fn, lora_fn = _resolve_loaders()
# Same-model short-circuit: reuse session's base, just apply the item's LoRAs.
# This saves ~2-7 GiB depending on checkpoint size (SD1.5 ~2GB, SDXL ~7GB).
if session_model_name and model_name == session_model_name:
base_model, base_clip, base_vae = fallback
if lora_string and lora_string != "None":
try:
model, clip = lora_fn(base_model, base_clip, lora_string)
except Exception as e:
print(f"[Florence2HiResFix] Failed to apply lora '{lora_string}' on session base: {e}; using base only")
model, clip = base_model, base_clip
else:
model, clip = base_model, base_clip
result = (model, clip, base_vae)
cache[key] = result
return result
# Different model — full load. Validate the file exists first; without this
# guard a missing checkpoint hits comfy/utils.load_torch_file(None,...) and
# crashes with "'NoneType' has no attribute 'lower'". Common scenario: manifest
# was generated on a different machine that had the per-item checkpoint
# installed.
try:
import folder_paths as _fp_check
ckpt_path_check = _fp_check.get_full_path("checkpoints", model_name)
except Exception:
ckpt_path_check = None
if ckpt_path_check is None:
print(
f"[Florence2HiResFix] Item references model '{model_name}' but the "
f"file is not installed in ComfyUI/models/checkpoints/. Falling back to "
f"session default."
)
cache[key] = fallback
return fallback
try:
model, clip, vae = ckpt_fn(
target_model_name=model_name,
ckpt_name=model_name,
use_remote_vae=False,
)
except Exception as e:
print(f"[Florence2HiResFix] Failed to load model '{model_name}': {e}; using session default")
cache[key] = fallback
return fallback
if lora_string and lora_string != "None":
try:
model, clip = lora_fn(model, clip, lora_string)
except Exception as e:
print(f"[Florence2HiResFix] Failed to apply lora '{lora_string}': {e}; using checkpoint only")
result = (model, clip, vae)
cache[key] = result
return result
def run_florence2_step(
*,
source_image,
item,
step_config,
fallback_handles,
ckpt_cache,
conditioning_cache,
positive_prompt,
negative_prompt,
clip_skip,
session_model_name=None,
):
"""Run one Florence2 Hi-Res Fix step on one image.
Args:
source_image: torch (1, H, W, 3) float32 — the PIL-converted source image
item: manifest entry dict for this image
step_config: dict — the upscale-step config with florence2_* fields
fallback_handles: tuple (model, clip, vae) — already-loaded session-default handles
ckpt_cache: dict — job-local cache for per-item ckpt+lora combos
conditioning_cache: dict {"positive": {}, "negative": {}} — job-local conditioning cache
positive_prompt: str — positive conditioning text
negative_prompt: str — negative conditioning text
clip_skip: int — clip_skip value
Returns:
Dict with at minimum:
- "status": "ok" or "no_detection"
- manifest extras: florence2_model, florence2_text_input, florence2_*
- if status == "ok": "image_pil" (PIL), "duration" (float), "bbox", "detection_count"
"""
import time
t_start = time.time()
preflight_florence2()
# Common manifest extras regardless of outcome
manifest_extras = {
"florence2_model": step_config.get("florence2_model", "microsoft/Florence-2-base"),
"florence2_text_input": step_config.get("text_input", "face"),
"florence2_target_megapixels": float(step_config.get("target_megapixels", 1.0)),
"florence2_crop_padding": int(step_config.get("crop_padding", 64)),
"florence2_grow_expand": int(step_config.get("grow_expand", 32)),
"florence2_feather": "{}/{}/{}/{}".format(
step_config.get("feather_left", 128),
step_config.get("feather_top", 128),
step_config.get("feather_right", 128),
step_config.get("feather_bottom", 128),
),
"florence2_output_mask_select": step_config.get("output_mask_select", ""),
"florence2_model_source": step_config.get("model_source", "from_manifest"),
}
# 1. Load Florence2 model (cached)
florence2_model = load_florence2_model(manifest_extras["florence2_model"])
# 2. Detect — call Florence2Run via _call_node
try:
from .ltx_video_generation import _call_node, _unwrap
except ImportError:
from ltx_video_generation import _call_node, _unwrap
classes = get_florence2_node_classes()
f2r_cls = classes["Florence2Run"]
text_input = manifest_extras["florence2_text_input"]
max_new_tokens = int(step_config.get("max_new_tokens", 1024) or 1024)
manifest_extras["florence2_max_new_tokens"] = max_new_tokens
# Optional pre-detect resize: Florence2's vision encoder activations scale with
# input dims. Down-scaling the image before detection cuts the encoder's peak
# VRAM substantially while preserving detection accuracy (faces are detectable
# at 0.5 MP or lower without quality loss because we crop+inpaint at full source
# resolution afterward — the mask just needs to find the region). Set
# florence2_input_mp=0 to disable the resize.
import comfy.utils as _cu
src_h, src_w = int(source_image.shape[1]), int(source_image.shape[2])
src_mp = (src_w * src_h) / (1024 * 1024)
detect_input_mp = float(step_config.get("florence2_input_mp", 0.5) or 0)
detect_image = source_image
detect_resized = False
if detect_input_mp > 0 and detect_input_mp < src_mp:
det_w, det_h = compute_target_dims(src_w, src_h, detect_input_mp)
det_nchw = source_image.movedim(-1, 1)
det_resized_nchw = _cu.common_upscale(det_nchw, det_w, det_h, "lanczos", "disabled")
detect_image = det_resized_nchw.movedim(1, -1)
detect_resized = True
print(
f"[Florence2HiResFix] Detecting '{text_input}' "
f"(src={src_w}x{src_h}={src_mp:.2f}MP -> detect={det_w}x{det_h}={detect_input_mp:.2f}MP, "
f"max_new_tokens={max_new_tokens})"
)
else:
print(
f"[Florence2HiResFix] Detecting '{text_input}' "
f"(src={src_w}x{src_h}={src_mp:.2f}MP, no pre-resize, max_new_tokens={max_new_tokens})"
)
manifest_extras["florence2_input_mp"] = detect_input_mp
manifest_extras["florence2_detect_resized"] = detect_resized
# max_new_tokens=1024 default (kijai stock). 256 caused polygon truncation —
# Florence2 won't EOS cleanly under a tight cap and burns the budget on
# garbage continuations. With detect-side resize taking the heat instead,
# the KV cache at 1024 fits comfortably.
# VRAM hygiene before Florence2 detect: evict SDXL/CLIP/VAE from VRAM so the
# vision encoder + beam-search KV cache get full headroom. The standalone
# kijai Florence2 workflow uses ~65% of 16GB at 4K/1024 tokens — but only
# because nothing else is resident. In USCG's batch flow, SDXL (~7GB) sits
# alongside it, leaving only ~9GB for Florence2 — under its 10GB peak on
# runaway-token images → OOM. ComfyUI auto-reloads SDXL on the next
# generate_image / vae.encode call below (~1-2s overhead per image).
_vram_log("before unload + Florence2 detect")
try:
import comfy.model_management as _mm_pre_detect
_mm_pre_detect.unload_all_models()
_mm_pre_detect.soft_empty_cache()
try:
import torch as _torch_pre_detect
if _torch_pre_detect.cuda.is_available():
_torch_pre_detect.cuda.empty_cache()
except Exception:
pass
except Exception:
pass
_vram_log("after unload, entering Florence2 detect")
# Settings: do_sample=True + num_beams=12 mirror kijai's reference workflow.
# Determinism: torch.manual_seed(seed=1) is pinned inside kijai's encode().
# NOTE: torch.inference_mode() is applied inside _call_node — see its docstring.
det_result = _call_node(
f2r_cls,
image=detect_image,
florence2_model=florence2_model,
text_input=text_input,
task="referring_expression_segmentation",
fill_mask=True,
keep_model_loaded=False,
max_new_tokens=max_new_tokens,
num_beams=12,
do_sample=True,
output_mask_select=step_config.get("output_mask_select", ""),
seed=1,
)
mask = _unwrap(det_result, 1)
_vram_log("after Florence2 detect")
# If we ran Florence2 on a down-scaled image, the returned mask is at the
# smaller dims. Upscale it back to the original source dims so the crop /
# grow / feather pipeline operates at full resolution. Bilinear is fine for
# mask resize — feather + grow downstream will smooth any edge quantization.
if detect_resized and mask is not None and mask.numel() > 0:
m_nchw = mask.unsqueeze(1) # (B, H, W) -> (B, 1, H, W)
m_up_nchw = _cu.common_upscale(m_nchw, src_w, src_h, "bilinear", "disabled")
mask = m_up_nchw.squeeze(1)
# 3. No-detection check
mask_sum = float(mask.sum().item()) if mask is not None else 0.0
if mask is None or mask_sum <= 0:
print(f"[Florence2HiResFix] No detection for '{text_input}', skipping")
manifest_extras["status"] = "no_detection"
manifest_extras["florence2_detection_count"] = 0
manifest_extras["duration"] = round(time.time() - t_start, 2)
return manifest_extras
# 4. Mask-select parse: for referring_expression_segmentation Florence2 returns
# one segment; detection_count is 1 if mask is non-empty. Any OOR request -> no_detection.
detection_count = 1
select_indices, select_mode = parse_mask_select_indices(
step_config.get("output_mask_select", ""), detected_count=detection_count
)
if select_mode == "no_detection":
print(f"[Florence2HiResFix] mask-select '{step_config.get('output_mask_select')}' "
f"out of range for {detection_count} detection(s), skipping")
manifest_extras["status"] = "no_detection"
manifest_extras["florence2_detection_count"] = detection_count
manifest_extras["duration"] = round(time.time() - t_start, 2)
return manifest_extras
# 5. GrowMask (core ComfyUI)
import nodes
grow_cls = nodes.NODE_CLASS_MAPPINGS.get("GrowMask")
if grow_cls is None:
raise RuntimeError("GrowMask node not available (core ComfyUI). Reinstall ComfyUI.")
grow_result = _call_node(
grow_cls,
mask=mask,
expand=int(step_config.get("grow_expand", 32)),
tapered_corners=True,
)
grown_mask = _unwrap(grow_result, 0)
# 6. Crop by mask (pure Python)
try:
# min_crop_resolution / max_crop_resolution defaults are unconstrained
# (0 / 99999) — Florence2's polygon + grow_expand + crop_padding fully
# determine the crop region. The UI no longer exposes these fields; they
# remain in the function signature for backwards compat with existing tests.
cropped_img, cropped_mask, bbox = _crop_image_by_mask(
source_image,
grown_mask,
padding=int(step_config.get("crop_padding", 64)),
min_crop_resolution=int(step_config.get("min_crop_resolution", 0)),
max_crop_resolution=int(step_config.get("max_crop_resolution", 99999)),
)
except ValueError as e:
print(f"[Florence2HiResFix] Crop failed ({e}); skipping")
manifest_extras["status"] = "no_detection"
manifest_extras["florence2_detection_count"] = detection_count
manifest_extras["duration"] = round(time.time() - t_start, 2)
return manifest_extras
crop_w, crop_h = bbox[2], bbox[3]
if crop_w <= 1 or crop_h <= 1:
print(f"[Florence2HiResFix] Degenerate crop ({crop_w}x{crop_h}); skipping")
manifest_extras["status"] = "no_detection"
manifest_extras["florence2_detection_count"] = detection_count
manifest_extras["duration"] = round(time.time() - t_start, 2)
return manifest_extras
# 7. Megapixel resize (NHWC -> NCHW for common_upscale -> NHWC back)
import comfy.utils
requested_mp = float(step_config.get("target_megapixels", 1.0))
target_w, target_h = compute_target_dims(crop_w, crop_h, requested_mp)
actual_mp = (target_w * target_h) / (1024 * 1024)
print(
f"[Florence2HiResFix] src=({source_image.shape[2]}x{source_image.shape[1]}) "
f"crop=({crop_w}x{crop_h}) requested_MP={requested_mp:.3f} "
f"-> internal_pass=({target_w}x{target_h})={actual_mp:.3f}MP"
)
cropped_nchw = cropped_img.movedim(-1, 1)
resized_nchw = comfy.utils.common_upscale(
cropped_nchw, target_w, target_h, "lanczos", "disabled"
)
resized_img = resized_nchw.movedim(1, -1)
# 8. Resolve checkpoint+lora handles for this item
model_handle, clip_handle, vae_handle = _get_or_load_checkpoint_lora(
item, fallback_handles,
model_source=step_config.get("model_source", "from_manifest"),
cache=ckpt_cache,
session_model_name=session_model_name,
)
# 9. Conditioning. When the per-item clip is the SAME identity as the session's
# clip (fallback_handles[1]), we can reuse the upscale_runner's conditioning
# cache. When from_manifest loaded a DIFFERENT model, the per-item clip can
# have a different architecture (e.g. SDXL 2048-dim vs SD1.5 768-dim); reusing
# session-cached conditioning would feed wrong-shape tensors into cross-attn
# and crash with "mat1 and mat2 shapes cannot be multiplied". So we maintain
# a per-clip-identity conditioning cache inside ckpt_cache.
try:
from .batch_encoding import encode_pair_cached
except ImportError:
from batch_encoding import encode_pair_cached
session_clip = fallback_handles[1]
if clip_handle is session_clip:
# Same clip as session — reuse the upscale_runner's shared cache.
pos_cond, neg_cond = encode_pair_cached(
clip_handle, conditioning_cache, positive_prompt, negative_prompt, clip_skip
)
else:
# Different clip (per-manifest model) — use a per-clip-id cache inside
# ckpt_cache so multiple images with the same per-item model still share.
per_clip = ckpt_cache.setdefault("_cond_cache", {})
clip_id = id(clip_handle)
bucket = per_clip.setdefault(clip_id, {"positive": {}, "negative": {}})
pos_cond, neg_cond = encode_pair_cached(
clip_handle, bucket, positive_prompt, negative_prompt, clip_skip
)
# 10. VAE encode -> generate_image (project's known-working sampling path)
try:
from .image_generation import generate_image, decode_latent_with_vae
except ImportError:
from image_generation import generate_image, decode_latent_with_vae
encoded_latent = vae_handle.encode(resized_img[:, :, :, :3])
noise_seed = int(item.get("seed", 0)) + 1 # tiny offset so face pass != base seed exactly
hires_latent_dict, _ = generate_image(
patched_model=model_handle,
seed=noise_seed,
steps=int(step_config.get("hires_steps", 15)),
cfg=float(step_config.get("cfg", 1.5)),
sampler_name=step_config.get("sampler", "euler"),
scheduler=step_config.get("scheduler", "simple"),
positive_conditioning=pos_cond,
negative_conditioning=neg_cond,
latent_input={"samples": encoded_latent},
denoise=float(step_config.get("hires_denoise", 0.45)),
width=target_w,
height=target_h,
)
decoded_pil = decode_latent_with_vae(vae_handle, hires_latent_dict["samples"])
_vram_log("after sample + VAE decode")
# Convert PIL back to tensor for paste-back
import numpy as np
import torch
decoded_arr = np.array(decoded_pil).astype(np.float32) / 255.0
decoded = torch.from_numpy(decoded_arr).unsqueeze(0) # (1, H, W, 3)
# 11. Resize inpainted crop back to original crop size
decoded_nchw = decoded.movedim(-1, 1)
resized_back_nchw = comfy.utils.common_upscale(
decoded_nchw, crop_w, crop_h, "lanczos", "center"
)
resized_back = resized_back_nchw.movedim(1, -1)
# 12. FeatherMask (core ComfyUI)
feather_cls = nodes.NODE_CLASS_MAPPINGS.get("FeatherMask")
if feather_cls is None:
raise RuntimeError("FeatherMask node not available (core ComfyUI). Reinstall ComfyUI.")
feather_result = _call_node(
feather_cls,
mask=cropped_mask,
left=int(step_config.get("feather_left", 128)),
top=int(step_config.get("feather_top", 128)),
right=int(step_config.get("feather_right", 128)),
bottom=int(step_config.get("feather_bottom", 128)),
)
feathered = _unwrap(feather_result, 0)
# 13. Paste back into source
final_tensor = _paste_into_image(source_image, resized_back, feathered, bbox)
# 14. NHWC tensor -> PIL for upscale_runner save path
from PIL import Image as PILImage
arr = (final_tensor[0].clamp(0, 1) * 255.0).cpu().numpy().astype(np.uint8)
final_pil = PILImage.fromarray(arr, mode="RGB")
manifest_extras["status"] = "ok"
manifest_extras["florence2_detection_count"] = detection_count
manifest_extras["florence2_bbox"] = list(bbox)
manifest_extras["image_pil"] = final_pil
manifest_extras["image_width"] = final_pil.size[0]
manifest_extras["image_height"] = final_pil.size[1]
manifest_extras["duration"] = round(time.time() - t_start, 2)
# VRAM hygiene: release intermediate tensors before next item. The KSampler
# latents, VAE-decoded crops, and Florence2 beam-search KV cache buffers can
# fragment GPU memory across iterations. gc.collect() drops Python-side
# references; soft_empty_cache + torch.cuda.empty_cache let ComfyUI / PyTorch
# reclaim the freed memory. Skip in tests (mm import would pull comfy stack).
try:
import gc as _gc
_gc.collect()
import comfy.model_management as _mm
_mm.soft_empty_cache()
try:
import torch as _torch
if _torch.cuda.is_available():
_torch.cuda.empty_cache()
except Exception:
pass
except Exception:
pass
_vram_log("end of image (after cleanup)")
return manifest_extras
def build_florence2_manifest_entry(
step_result, item, *, session_name, pipeline_name, upscale_id,
upscaled_filename, current_index, hires_denoise
):
"""Build the manifest entry dict for a successful Florence2 hi-res-fix.
Returns a dict ready to be `manifest_data["items"].insert(0, entry)`.
"""
base = {
k: v for k, v in item.items()
if k not in ("id", "gen_index", "file", "filename", "upscaled",
"width", "height", "duration",
"upscale_source", "upscale_pipeline", "upscale_mode",
"upscale_ratio", "upscale_denoise", "upscale_model")
}
base.update({
"id": upscale_id,
"gen_index": current_index,
"file": f"/view?filename={upscaled_filename}&type=output&subfolder=benchmarks/{session_name}/images",
"filename": upscaled_filename,
"width": step_result["image_width"],
"height": step_result["image_height"],
"duration": float(step_result.get("duration", 0)),
"upscaled": True,
"upscale_source": "dashboard",
"upscale_pipeline": pipeline_name,
"upscale_mode": "florence2_hires",
"florence2_model": step_result.get("florence2_model"),
"florence2_text_input": step_result.get("florence2_text_input"),
"florence2_target_megapixels": step_result.get("florence2_target_megapixels"),
"florence2_crop_padding": step_result.get("florence2_crop_padding"),
"florence2_grow_expand": step_result.get("florence2_grow_expand"),
"florence2_feather": step_result.get("florence2_feather"),
"florence2_output_mask_select": step_result.get("florence2_output_mask_select"),
"florence2_model_source": step_result.get("florence2_model_source"),
"florence2_detection_count": step_result.get("florence2_detection_count"),
"florence2_bbox": step_result.get("florence2_bbox"),
"hires_denoise": hires_denoise,
})
return base
def build_florence2_no_detection_entry(step_result, item, *, sentinel_id, current_index):
"""Build a sentinel manifest entry for a Florence2 no-detection result.
No new image file is written — the entry points back at the source filename
so the dashboard can render a "no detection" badge alongside the original.
"""
text = step_result.get("florence2_text_input", "")
return {
"id": sentinel_id,
"gen_index": current_index,
"filename": item.get("filename", ""),
"file": item.get("file", ""),
"upscaled": False,
"florence2_no_detection": True,
"florence2_text_input": text,
"florence2_model": step_result.get("florence2_model", ""),
"note": f"Florence2 found no '{text}' in image",
}