Workflow1111 / nodes.py
ysharma's picture
ysharma HF Staff
Workflow1111 — Automatic1111-style diffusion studio on gr.Workflow
af888c6 verified
Raw
History Blame Contribute Delete
53.1 kB
"""
Workflow1111 · the bound-function library
=========================================
Every function here becomes an `fn` operator node on the canvas. They are pure
local Python (Pillow + numpy) — no network, no token, no quota — which is what
makes the app's spine reliable: only the `model` and `space` nodes ever leave
the machine.
Two conventions matter, and both are load-bearing (see the module docstring in
`app.py` for why):
1. **Image inputs arrive in many shapes.** Depending on whether the value came
from an upload, a `model` node, another `fn` node, or the browser executor,
it can be a ``{"path"/"url"}`` dict, a ``data:`` URI, an ``http(s)`` URL, a
``/gradio_api/file=`` reference, or a plain path. `_load_image` normalizes
all of them.
2. **Image outputs are ``{"path": <file>, "url": <data: URI>}``** — see `_emit`.
The REST endpoint needs the real file, the canvas and any chained `model`
node need the URI, so the value carries both.
3. **Structured data travels as JSON *text*, never on a ``json`` port.** The
canvas stringifies a `json` port value with JavaScript's ``String(obj)``
rather than ``JSON.stringify``, so the receiving node gets the literal text
``"[object Object]"`` and the data is gone. `detect_objects`,
`classify_image`, `top_labels` and `png_info` therefore emit JSON strings,
and `_as_list` parses them back.
Everything returned must be JSON-serializable — that is the contract for
`bind=` functions.
"""
import base64
import io
import json
import os
import random
import re
import tempfile
import urllib.parse
import urllib.request
from typing import Optional
import numpy as np
# Imported (not string-annotated) so `get_type_hints` can resolve it: gradio
# detects injected parameters by their resolved type, and an unresolvable
# annotation would turn the token into a visible input port.
from gradio.oauth import OAuthToken
from huggingface_hub import get_token as _saved_hf_token
from PIL import (
Image,
ImageDraw,
ImageEnhance,
ImageFilter,
ImageFont,
ImageOps,
PngImagePlugin,
)
Image.MAX_IMAGE_PIXELS = 200_000_000
_RNG = random.SystemRandom()
_MAX_SEED = 2**31 - 1
# ─────────────────────────────────────────────────────────────────────────────
# Coercion helpers — the canvas can hand us strings where we want numbers
# ─────────────────────────────────────────────────────────────────────────────
def _text(value, default=""):
if value is None:
return default
if isinstance(value, (list, tuple)):
value = " ".join(str(v) for v in value)
s = str(value).strip()
return s if s else default
def _num(value, default, lo=None, hi=None, integer=False):
"""Best-effort numeric coercion with clamping. Blank/garbage → default."""
if isinstance(value, bool):
value = int(value)
try:
n = float(str(value).strip())
if n != n or n in (float("inf"), float("-inf")):
raise ValueError
except (TypeError, ValueError, AttributeError):
n = float(default)
if lo is not None:
n = max(lo, n)
if hi is not None:
n = min(hi, n)
return int(round(n)) if integer else float(n)
def _flag(value, default=False):
if value is None or value == "":
return bool(default)
if isinstance(value, bool):
return value
return str(value).strip().lower() in ("1", "true", "yes", "y", "on", "enable", "enabled")
def _choice(value, options, default):
"""Match a dropdown value against `options` leniently (case/space/punct)."""
def norm(s):
return re.sub(r"[^a-z0-9]", "", str(s).lower())
v = norm(value)
if not v:
return default
for o in options:
if norm(o) == v:
return o
for o in options:
if v and (norm(o).startswith(v) or v.startswith(norm(o))):
return o
return default
def _as_list(value):
"""Detection/label payloads arrive as a list, or as a JSON string of one."""
if value is None or value == "":
return []
if isinstance(value, str):
try:
value = json.loads(value)
except (ValueError, TypeError):
return []
if isinstance(value, dict):
for key in ("detections", "labels", "results", "data", "predictions"):
if isinstance(value.get(key), list):
return value[key]
return [value]
return list(value) if isinstance(value, (list, tuple)) else []
# ─────────────────────────────────────────────────────────────────────────────
# Image IO
# ─────────────────────────────────────────────────────────────────────────────
_GRADIO_FILE_PREFIX = "/gradio_api/file="
def _load_image(value, label="image"):
"""Accept every shape an image port can carry and return a PIL Image."""
if value is None or value == "":
raise ValueError(f"No {label} supplied — connect or upload one.")
if isinstance(value, Image.Image):
return value
src = value
if isinstance(value, dict):
src = (
value.get("path")
or value.get("url")
or value.get("name")
or value.get("src")
or value.get("data")
or ""
)
if isinstance(src, (bytes, bytearray)):
return Image.open(io.BytesIO(bytes(src)))
if isinstance(src, (list, tuple)) and src:
# e.g. an ImageSlider-style [before, after] payload — take the last
return _load_image(src[-1], label)
if not isinstance(src, str) or not src.strip():
raise ValueError(f"Could not read {label} (got {type(value).__name__}).")
src = src.strip()
if src.startswith("data:"):
_, _, payload = src.partition(",")
payload = payload.strip()
if not payload:
raise ValueError(f"Empty data URI for {label}.")
pad = "=" * (-len(payload) % 4)
return Image.open(io.BytesIO(base64.b64decode(payload + pad)))
# "/gradio_api/file=C:\..." — or the same wrapped in an absolute URL
if _GRADIO_FILE_PREFIX in src:
src = src.split(_GRADIO_FILE_PREFIX, 1)[1]
src = urllib.parse.unquote(src)
if src.startswith(("http://", "https://")):
req = urllib.request.Request(src, headers={"User-Agent": "workflow1111"})
with urllib.request.urlopen(req, timeout=90) as resp: # noqa: S310
return Image.open(io.BytesIO(resp.read()))
if src.startswith("file://"):
src = urllib.request.url2pathname(urllib.parse.urlparse(src).path)
if os.path.isfile(src):
return Image.open(src)
unquoted = urllib.parse.unquote(src)
if os.path.isfile(unquoted):
return Image.open(unquoted)
raise ValueError(f"Could not resolve {label}: {src[:120]!r}")
def _rgb(img):
"""Flatten to RGB over white so JPEG-bound ops never crash on alpha."""
if img.mode == "RGB":
return img
if img.mode in ("RGBA", "LA", "P"):
img = img.convert("RGBA")
flat = Image.new("RGB", img.size, (255, 255, 255))
flat.paste(img, mask=img.split()[-1])
return flat
return img.convert("RGB")
def _has_alpha(img):
return img.mode in ("RGBA", "LA") or (img.mode == "P" and "transparency" in img.info)
_JPEG_ABOVE_PX = 1_600_000 # ~1600×1000; keeps data URIs from ballooning
def _emit(img, info_text=None, quality=93):
"""PIL Image → the value an image output port should carry.
Returns ``{"path": <temp file>, "url": <data: URI>}``, which is the only
shape that satisfies all four consumers at once:
* **REST API** — `_from_output` takes ``path`` first, and a gradio `Image`
output component needs a real file. Handing it a bare ``data:`` URI makes
gradio treat the URI as a filename and join it to the CWD
(``OSError: [Errno 22] ...\\data:image\\png;base64,...``).
* **Canvas** — the frontend prefers ``url``; a ``data:`` URI renders
immediately with no round trip and no `allowed_paths` juggling.
* **Chaining into a `model` node** — `_img_url` also prefers ``url``, and
`InferenceClient` accepts a ``data:`` URI (a ``/gradio_api/file=`` path
would be meaningless to a remote provider).
* **Chaining into another `fn`** — `_load_image` prefers ``path``.
`space` nodes are the one consumer this does *not* serve (`handle_file`
cannot read a ``data:`` URI), which is why no `space` node is ever fed from
an `fn` node — they take uploaded reference images only.
"""
if img.mode not in ("RGB", "RGBA", "L"):
img = img.convert("RGBA" if _has_alpha(img) else "RGB")
buf = io.BytesIO()
force_png = bool(info_text) or _has_alpha(img)
if force_png or (img.width * img.height) <= _JPEG_ABOVE_PX:
params = {}
if info_text:
meta = PngImagePlugin.PngInfo()
# "parameters" is the key Automatic1111 itself writes, so these
# images round-trip through the PNG Info pipeline (and through
# real A1111 installs).
meta.add_text("parameters", str(info_text))
meta.add_text("Software", "Workflow1111")
params["pnginfo"] = meta
img.save(buf, format="PNG", optimize=True, **params)
mime = "image/png"
else:
_rgb(img).save(buf, format="JPEG", quality=int(quality), optimize=True, subsampling=1)
mime = "image/jpeg"
raw = buf.getvalue()
ext = "png" if mime.endswith("png") else "jpg"
path = os.path.join(tempfile.gettempdir(), f"wf1111_{os.urandom(8).hex()}.{ext}")
with open(path, "wb") as f:
f.write(raw)
return {
"path": path,
"url": f"data:{mime};base64," + base64.b64encode(raw).decode("ascii"),
}
def _emit_uri(img, info_text=None, quality=93):
"""Just the ``data:`` URI — for places that need a bare string, such as the
image reference handed to a chat-completion request."""
return _emit(img, info_text, quality)["url"]
def _font(size):
for name in ("arial.ttf", "segoeui.ttf", "DejaVuSans.ttf", "Helvetica.ttc"):
try:
return ImageFont.truetype(name, size)
except OSError:
continue
try:
return ImageFont.load_default(size=size)
except TypeError: # Pillow < 10
return ImageFont.load_default()
# ─────────────────────────────────────────────────────────────────────────────
# 1 · Prompt engineering — Automatic1111's "Styles"
# ─────────────────────────────────────────────────────────────────────────────
# name → (positive suffix, extra negative terms)
STYLE_PRESETS = {
"None": ("", ""),
"Photorealistic": (
"photorealistic, 35mm photograph, natural lighting, shallow depth of field, "
"highly detailed, sharp focus",
"illustration, painting, drawing, cartoon, anime, 3d render, cgi",
),
"Cinematic": (
"cinematic film still, dramatic rim lighting, anamorphic lens, film grain, "
"moody color grade, wide shot",
"flat lighting, snapshot, low contrast",
),
"Anime": (
"anime key visual, cel shading, vibrant colors, clean linework, "
"studio-quality illustration",
"photorealistic, photograph, 3d render, western comic",
),
"Digital Art": (
"digital painting, concept art, trending on artstation, dramatic lighting, "
"highly detailed brushwork",
"photograph, low effort sketch",
),
"Oil Painting": (
"oil on canvas, visible impasto brush strokes, rich pigment, classical composition, "
"gallery lighting",
"digital, vector, photograph, flat shading",
),
"Watercolour": (
"delicate watercolour painting, soft wet-on-wet washes, visible paper texture, "
"loose expressive edges",
"harsh outlines, digital, 3d render, photograph",
),
"3D Render": (
"octane render, physically based materials, global illumination, soft shadows, "
"subsurface scattering, 8k",
"flat illustration, 2d, sketch, painting",
),
"Pixel Art": (
"16-bit pixel art, limited palette, crisp dithering, isometric sprite",
"smooth gradients, photorealistic, antialiasing, blur",
),
"Line Art": (
"clean black and white line art, bold inked contours, minimal hatching, white background",
"color, shading, gradient, photograph, texture",
),
"Studio Product": (
"professional product photography, seamless white sweep, three-point softbox lighting, "
"crisp reflections, commercial catalogue shot",
"clutter, busy background, harsh shadows, low resolution",
),
"Fantasy Concept": (
"epic fantasy concept art, volumetric god rays, intricate ornamentation, "
"matte painting, sweeping scale",
"modern clothing, urban, mundane, photograph",
),
"Neon Cyberpunk": (
"cyberpunk megacity, neon signage, wet reflective asphalt, volumetric fog, "
"teal and magenta lighting, blade runner mood",
"daylight, rural, pastel, medieval",
),
"Analog Film": (
"portra 400 analog film photograph, halation, subtle grain, faded highlights, "
"warm cast, candid framing",
"digital clarity, oversharpened, hdr, 3d render",
),
}
QUALITY_TAGS = "masterpiece, best quality, highly detailed, intricate detail, sharp focus"
BASE_NEGATIVE = (
"lowres, worst quality, low quality, jpeg artifacts, blurry, out of focus, "
"watermark, signature, text, username, logo, cropped, bad anatomy, "
"extra limbs, extra fingers, missing fingers, deformed hands, mutated, "
"disfigured, poorly drawn face, long neck, duplicate, error"
)
SAFETY_NEGATIVE = "nsfw, nude, gore, blood, violence, disturbing imagery"
def apply_style(prompt, style, extra_tags, quality_boost):
"""Compose the final positive prompt: subject + style preset + your tags."""
prompt = _text(prompt)
if not prompt:
raise ValueError("Prompt is empty — describe what you want to see.")
style_name = _choice(style, list(STYLE_PRESETS), "None")
parts = [prompt, STYLE_PRESETS[style_name][0], _text(extra_tags)]
if _flag(quality_boost, True):
parts.append(QUALITY_TAGS)
seen, out = set(), []
for chunk in parts:
for tag in (t.strip() for t in chunk.split(",")):
key = tag.lower()
if tag and key not in seen:
seen.add(key)
out.append(tag)
return ", ".join(out)
def build_negative(negative, style, use_base, safety_filter):
"""Compose the negative prompt from your text + the style's counter-tags."""
style_name = _choice(style, list(STYLE_PRESETS), "None")
parts = [_text(negative)]
if _flag(use_base, True):
parts.append(BASE_NEGATIVE)
parts.append(STYLE_PRESETS[style_name][1])
if _flag(safety_filter, True):
parts.append(SAFETY_NEGATIVE)
seen, out = set(), []
for chunk in parts:
for tag in (t.strip() for t in chunk.split(",")):
key = tag.lower()
if tag and key not in seen:
seen.add(key)
out.append(tag)
return ", ".join(out)
# Automatic1111's aspect presets, as (width, height)
ASPECTS = {
"Custom": None,
"1:1 Square": (1024, 1024),
"3:2 Landscape": (1216, 832),
"2:3 Portrait": (832, 1216),
"16:9 Widescreen": (1344, 768),
"9:16 Vertical": (768, 1344),
"4:3 Classic": (1152, 896),
"3:4 Tall": (896, 1152),
}
def sampler_settings(steps, cfg_scale, seed, aspect, width, height):
"""Validate and normalize the sampler block.
Returns (steps, cfg_scale, seed, width, height). Guardrails matter here:
FLUX is served by fal-ai, which hard-rejects ``guidance_scale`` below 1.0
with a 422, and dimensions must be multiples of 16. A seed of -1 rolls a
fresh one and reports it, exactly like A1111.
"""
steps = _num(steps, 4, lo=1, hi=50, integer=True)
cfg = round(_num(cfg_scale, 1.0, lo=1.0, hi=20.0), 2)
seed = _num(seed, -1, lo=-1, hi=_MAX_SEED, integer=True)
if seed < 0:
seed = _RNG.randint(0, _MAX_SEED)
preset = ASPECTS.get(_choice(aspect, list(ASPECTS), "Custom"))
if preset:
width, height = preset
width = _num(width, 1024, lo=256, hi=1536, integer=True)
height = _num(height, 1024, lo=256, hi=1536, integer=True)
width -= width % 16
height -= height % 16
return steps, cfg, seed, width, height
def generation_info(prompt, negative, steps, cfg_scale, seed, width, height, model_id):
"""The Automatic1111 'generation parameters' block, verbatim in its format.
`postprocess` embeds this into the PNG's ``parameters`` text chunk, so the
PNG Info pipeline (and a real A1111 install) can read it straight back.
"""
prompt = _text(prompt, "(none)")
negative = _text(negative)
steps = _num(steps, 4, integer=True)
cfg = round(_num(cfg_scale, 1.0), 2)
seed = _num(seed, 0, integer=True)
width = _num(width, 1024, integer=True)
height = _num(height, 1024, integer=True)
model = _text(model_id, "black-forest-labs/FLUX.1-schnell")
lines = [prompt]
if negative:
lines.append(f"Negative prompt: {negative}")
lines.append(
f"Steps: {steps}, Sampler: Euler, CFG scale: {cfg}, Seed: {seed}, "
f"Size: {width}x{height}, Model: {model}, Backend: HF Inference Providers, "
f"Version: Workflow1111 (gr.Workflow)"
)
return "\n".join(lines)
def prompt_matrix(base_prompt, variations, shared_tags):
"""Split ``variations`` into up to four prompt variants — A1111's prompt matrix.
Returns (prompt_1..prompt_4, labels). Each variant is fed to its own
txt2img node so the four render in parallel, then `contact_sheet` tiles
them into the familiar X/Y grid.
"""
base = _text(base_prompt)
if not base:
raise ValueError("Prompt matrix needs a base prompt.")
shared = _text(shared_tags)
raw = [v.strip() for v in re.split(r"[|\n]+", _text(variations)) if v.strip()]
if not raw:
raw = ["", "", "", ""]
raw = (raw + raw * 4)[:4] if len(raw) < 4 else raw[:4]
prompts, labels = [], []
for variant in raw:
parts = [base, variant, shared]
prompts.append(", ".join(p for p in parts if p))
labels.append(variant or "base")
return prompts[0], prompts[1], prompts[2], prompts[3], " | ".join(labels)
def magic_instruction(idea, target_style, verbosity):
"""Wrap a rough idea into an instruction for the prompt-writing LLM."""
idea = _text(idea)
if not idea:
raise ValueError("Give the prompt builder an idea to work from.")
style = _choice(target_style, list(STYLE_PRESETS), "Cinematic")
length = _choice(verbosity, ["Concise", "Detailed", "Elaborate"], "Detailed")
budget = {"Concise": "18", "Detailed": "35", "Elaborate": "60"}[length]
return (
"You are a Stable Diffusion prompt engineer. Rewrite the idea below as a single "
f"image-generation prompt in the '{style}' style.\n"
f"Rules: comma-separated visual tags only, at most {budget} tags, no sentences, "
"no preamble, no explanation, no quotes, no markdown, do not mention the rules. "
"Cover subject, composition, lighting, colour palette, medium and mood.\n"
f"Idea: {idea}"
)
_PREAMBLE = re.compile(
r"^\s*(sure|certainly|here(?:'s| is)|okay|ok|absolutely|of course|prompt)\b[^\n:]*:?\s*",
re.I,
)
_TAG_EDGES = re.compile(r"""^[\s"'`*\-–—]+|[\s"'`*.;:]+$""")
def clean_prompt(raw, max_tags):
"""Strip an LLM's chattiness down to a clean comma-separated prompt."""
text = _text(raw)
if not text:
raise ValueError("The language model returned nothing to clean up.")
text = re.sub(r"```[a-zA-Z]*\n?", "", text).replace("```", "")
text = re.sub(r"^\s*#+\s*.*$", "", text, flags=re.M) # md headings
text = re.sub(r"<think>.*?</think>", "", text, flags=re.S | re.I)
text = re.sub(r"\*\*(.+?)\*\*", r"\1", text)
text = _PREAMBLE.sub("", text.strip())
text = text.strip().strip('"').strip("'")
# Prefer the densest line — LLMs often add a trailing note after the prompt
lines = [ln.strip(" -*\t") for ln in text.splitlines() if ln.strip()]
if lines:
text = max(lines, key=lambda ln: ln.count(","))
cap = _num(max_tags, 40, lo=1, hi=120, integer=True)
seen, tags = set(), []
# Strip quoting/bullet punctuation per tag — a wrapping quote survives the
# line-level strip when the closing quote is not the final character.
for tag in (_TAG_EDGES.sub("", t) for t in text.split(",")):
key = tag.lower()
if tag and key not in seen:
seen.add(key)
tags.append(tag)
if len(tags) >= cap:
break
if not tags:
raise ValueError("Could not extract a usable prompt from the model output.")
return ", ".join(tags)
def txt2img(prompt, negative_prompt, steps, cfg_scale, seed, width, height,
model_id, oauth_token: Optional[OAuthToken] = None):
"""Text-to-image against HF Inference Providers — the A1111 txt2img box.
This is deliberately an `fn` node rather than a `model` node. The canvas
rewrites a `model` node's input ports to match the endpoint's canonical
schema, and `text_to_image`'s schema is just ``prompt`` — so negative
prompt, steps, CFG, seed and size were being silently dropped the moment
the graph was opened in a browser. Calling `InferenceClient` here keeps the
whole control surface, and `fn` ports are left alone.
Returns a ``data:`` URI, which chains into both model and fn nodes.
"""
from huggingface_hub import InferenceClient
prompt = _text(prompt)
if not prompt:
raise ValueError("Prompt is empty — describe what you want to see.")
token = _hf_token(oauth_token)
model = _text(model_id, "black-forest-labs/FLUX.1-schnell")
kwargs = {
"prompt": prompt,
"num_inference_steps": _num(steps, 4, lo=1, hi=50, integer=True),
# fal-ai rejects guidance_scale < 1 with a 422, so never send one.
"guidance_scale": round(_num(cfg_scale, 1.0, lo=1.0, hi=20.0), 2),
"width": _num(width, 1024, lo=256, hi=1536, integer=True),
"height": _num(height, 1024, lo=256, hi=1536, integer=True),
}
negative = _text(negative_prompt)
if negative:
kwargs["negative_prompt"] = negative
seed = _num(seed, -1, lo=-1, hi=_MAX_SEED, integer=True)
if seed >= 0:
kwargs["seed"] = seed
try:
image = InferenceClient(model=model, token=token,
provider="auto").text_to_image(**kwargs)
except Exception as e:
detail = str(e)
if "402" in detail or "quota" in detail.lower() or "credits" in detail.lower():
raise ValueError(
f"Inference credits exhausted for {model}. Wait for the quota to "
"reset, or point this node at a different model."
) from e
if "401" in detail or "403" in detail:
raise ValueError(f"Not authorized for {model} — check your token.") from e
if "not supported" in detail.lower():
raise ValueError(
f"No enabled provider serves {model} for text-to-image. Try "
"black-forest-labs/FLUX.1-schnell or FLUX.1-dev."
) from e
raise ValueError(f"{model} failed: {detail[:300]}") from e
return _emit(image)
def _hf_token(oauth_token):
token = (getattr(oauth_token, "token", None)
or os.environ.get("HF_TOKEN") or _saved_hf_token())
if not token:
raise ValueError(
"No Hugging Face token. Run `hf auth login`, set HF_TOKEN, or sign "
"in with the button at the top of the canvas."
)
return token
def _chat(model_id, token, text, image=None, max_tokens=512):
"""One chat-completion call, streamed.
Streamed rather than buffered because the router's gateway drops a
non-streaming request at ~120s; keeping bytes moving bounds the call by the
model instead of by an idle proxy.
"""
from huggingface_hub import InferenceClient
content = []
if text:
content.append({"type": "text", "text": text})
if image is not None:
content.append({"type": "image_url", "image_url": {"url": image}})
if not content:
raise ValueError("Nothing to send — connect a prompt or an image.")
client = InferenceClient(model=model_id, token=token, provider="auto")
parts, finish = [], None
try:
for chunk in client.chat_completion(
[{"role": "user", "content": content}],
max_tokens=int(max_tokens), stream=True,
):
if not chunk.choices:
continue
choice = chunk.choices[0]
if getattr(choice, "delta", None) and choice.delta.content:
parts.append(choice.delta.content)
if choice.finish_reason:
finish = choice.finish_reason
except Exception as e:
detail = str(e)
if "not supported" in detail.lower():
raise ValueError(
f"No enabled provider serves {model_id}. Try "
"Qwen/Qwen3-4B-Instruct-2507 or google/gemma-3-27b-it."
) from e
if "402" in detail or "quota" in detail.lower():
raise ValueError(f"Inference credits exhausted for {model_id}.") from e
raise ValueError(f"{model_id} failed: {detail[:300]}") from e
out = "".join(parts).strip()
if not out:
raise ValueError(f"{model_id} returned no text (finish_reason={finish}).")
return out
def chat_llm(prompt, model_id, max_tokens, oauth_token: Optional[OAuthToken] = None):
"""Text-only LLM call.
An `fn` node rather than a `model` node for the same reason as `txt2img`:
the canvas normalizes a `chat_completion` node's ports to the schema's
(image, text) pair, and the wired prompt was not reaching the model.
"""
prompt = _text(prompt)
if not prompt:
raise ValueError("Nothing to send to the language model.")
return _chat(_text(model_id, "Qwen/Qwen3-4B-Instruct-2507"),
_hf_token(oauth_token), prompt,
max_tokens=_num(max_tokens, 512, lo=32, hi=4096, integer=True))
def interrogate(image, instruction, model_id, max_tokens,
oauth_token: Optional[OAuthToken] = None):
"""Vision-language call — CLIP-interrogate, essentially.
The image is **required**: without this guard the model cheerfully invents
a description of an image it was never given, which looks like a working
result and is entirely fabricated.
"""
img = _load_image(image, "image to interrogate") # raises if absent
return _chat(_text(model_id, "Qwen/Qwen2.5-VL-72B-Instruct"),
_hf_token(oauth_token),
_text(instruction, "Describe this image."),
image=_emit_uri(img),
max_tokens=_num(max_tokens, 512, lo=32, hi=4096, integer=True))
def _image_file(image, label="image"):
"""Materialize any accepted image value as a temp file path.
A **path**, not bytes: handing `InferenceClient` raw bytes makes the router
reject the call with "No content type provided and no default one
configured", whereas from a path huggingface_hub infers the MIME type.
"""
img = _load_image(image, label)
path = os.path.join(tempfile.gettempdir(), f"wf1111_in_{os.urandom(8).hex()}.jpg")
_rgb(img).save(path, format="JPEG", quality=94, optimize=True)
return path
def detect_objects(image, model_id, min_score, oauth_token: Optional[OAuthToken] = None):
"""Object detection, returning the detections as a **JSON string**.
An `fn` node calling `InferenceClient` rather than a `model` node, because
the canvas destroys `json`-typed port values: it stringifies them with
JavaScript's `String(obj)` instead of `JSON.stringify`, so the downstream
node receives the literal text ``"[object Object]"`` and sees zero
detections. Text ports survive intact, so the detections travel as JSON
text and `_as_list` parses them back.
"""
from huggingface_hub import InferenceClient
model = _text(model_id, "facebook/detr-resnet-50")
floor = _num(min_score, 0.0, lo=0.0, hi=1.0)
client = InferenceClient(model=model, token=_hf_token(oauth_token), provider="auto")
try:
results = client.object_detection(image=_image_file(image, "image to analyse"))
except Exception as e:
raise ValueError(f"{model} failed: {str(e)[:300]}") from e
found = []
for r in results:
box = getattr(r, "box", None) or {}
get = (lambda k: getattr(box, k, None)) if not isinstance(box, dict) else box.get
try:
coords = {k: int(get(k)) for k in ("xmin", "ymin", "xmax", "ymax")}
except (TypeError, ValueError):
continue
score = float(getattr(r, "score", 0.0) or 0.0)
if score < floor:
continue
found.append({"label": str(getattr(r, "label", "object")),
"score": round(score, 4), "box": coords})
found.sort(key=lambda d: d["score"], reverse=True)
return json.dumps(found)
def classify_image(image, model_id, oauth_token: Optional[OAuthToken] = None):
"""Image classification, returning the labels as a **JSON string**
(same reason as `detect_objects`)."""
from huggingface_hub import InferenceClient
model = _text(model_id, "google/vit-base-patch16-224")
client = InferenceClient(model=model, token=_hf_token(oauth_token), provider="auto")
try:
results = client.image_classification(
image=_image_file(image, "image to classify"))
except Exception as e:
raise ValueError(f"{model} failed: {str(e)[:300]}") from e
return json.dumps([{"label": str(getattr(r, "label", "?")),
"score": round(float(getattr(r, "score", 0.0) or 0.0), 5)}
for r in results])
def top_labels(labels, top_k, min_score):
"""Format an image-classification payload. Returns (text, json)."""
items = _as_list(labels)
k = _num(top_k, 5, lo=1, hi=25, integer=True)
floor = _num(min_score, 0.0, lo=0.0, hi=1.0)
rows = []
for item in items:
if not isinstance(item, dict):
continue
score = _num(item.get("score"), 0.0)
if score < floor:
continue
rows.append({"label": _text(item.get("label"), "?"), "score": round(score, 4)})
rows.sort(key=lambda r: r["score"], reverse=True)
rows = rows[:k]
if not rows:
return "No labels above the score threshold.", "[]"
width = max(len(r["label"]) for r in rows)
lines = [
f"{r['label']:<{width}} {r['score'] * 100:5.1f}% {'█' * max(1, int(r['score'] * 24))}"
for r in rows
]
# JSON *text*, not a list: a `json` port would reach the canvas as
# "[object Object]" (see `detect_objects`).
return "\n".join(lines), json.dumps(rows, indent=2)
# ─────────────────────────────────────────────────────────────────────────────
# 2 · Image operators
# ─────────────────────────────────────────────────────────────────────────────
_RESAMPLE = {
"Lanczos": Image.LANCZOS,
"Bicubic": Image.BICUBIC,
"Bilinear": Image.BILINEAR,
"Nearest (pixel art)": Image.NEAREST,
}
def _resize(img, factor, method):
factor = _num(factor, 1.0, lo=1.0, hi=4.0)
if factor <= 1.001:
return img
resample = _RESAMPLE[_choice(method, list(_RESAMPLE), "Lanczos")]
w = min(int(img.width * factor), 4096)
h = min(int(img.height * factor), 4096)
return img.resize((w, h), resample)
def _vignette(img, strength):
strength = _num(strength, 0.0, lo=0.0, hi=1.0)
if strength <= 0.001:
return img
w, h = img.size
yy, xx = np.mgrid[0:h, 0:w]
cx, cy = (w - 1) / 2.0, (h - 1) / 2.0
dist = np.sqrt(((xx - cx) / cx) ** 2 + ((yy - cy) / cy) ** 2) / np.sqrt(2.0)
mask = np.clip(1.0 - strength * np.clip(dist, 0, 1) ** 2.2, 0.0, 1.0)
arr = np.asarray(_rgb(img)).astype(np.float32)
arr *= mask[..., None]
return Image.fromarray(np.clip(arr, 0, 255).astype(np.uint8), "RGB")
def _grain(img, amount, seed=None):
amount = _num(amount, 0.0, lo=0.0, hi=1.0)
if amount <= 0.001:
return img
rng = np.random.default_rng(None if seed is None else int(seed) % (2**32))
arr = np.asarray(_rgb(img)).astype(np.float32)
noise = rng.normal(0.0, amount * 26.0, arr.shape[:2])[..., None]
return Image.fromarray(np.clip(arr + noise, 0, 255).astype(np.uint8), "RGB")
def _watermark(img, text, opacity=0.72):
text = _text(text)
if not text:
return img
base = img.convert("RGBA")
layer = Image.new("RGBA", base.size, (0, 0, 0, 0))
draw = ImageDraw.Draw(layer)
size = max(13, int(min(base.size) * 0.032))
font = _font(size)
pad = max(8, size // 2)
box = draw.textbbox((0, 0), text, font=font)
tw, th = box[2] - box[0], box[3] - box[1]
x, y = base.width - tw - pad, base.height - th - pad - box[1]
draw.rectangle(
[x - pad // 2, y + box[1] - pad // 3, x + tw + pad // 2, y + box[1] + th + pad // 3],
fill=(0, 0, 0, int(90 * opacity)),
)
draw.text((x, y), text, font=font, fill=(255, 255, 255, int(255 * opacity)))
return Image.alpha_composite(base, layer).convert("RGB")
def _border(img, width_pct, color="#0f0f12"):
width_pct = _num(width_pct, 0.0, lo=0.0, hi=0.2)
if width_pct <= 0.0005:
return img
pad = max(1, int(min(img.size) * width_pct))
return ImageOps.expand(_rgb(img), border=pad, fill=color)
def postprocess(
image,
upscale,
upscale_method,
sharpen,
saturation,
contrast,
brightness,
vignette,
grain,
border,
watermark,
embed_info,
):
"""The txt2img finishing chain — A1111's post-processing, one node.
Runs in a deliberate order (resize → tone → sharpen → optical → framing) so
grain and vignette are not resampled and the watermark stays crisp. When
``embed_info`` carries a generation-parameters block it is written into the
PNG's ``parameters`` chunk.
"""
img = _load_image(image)
img = _resize(img, upscale, upscale_method)
sat = _num(saturation, 1.0, lo=0.0, hi=2.5)
con = _num(contrast, 1.0, lo=0.2, hi=2.5)
bri = _num(brightness, 1.0, lo=0.2, hi=2.5)
if abs(sat - 1.0) > 0.01:
img = ImageEnhance.Color(img).enhance(sat)
if abs(con - 1.0) > 0.01:
img = ImageEnhance.Contrast(img).enhance(con)
if abs(bri - 1.0) > 0.01:
img = ImageEnhance.Brightness(img).enhance(bri)
sharp = _num(sharpen, 0.0, lo=0.0, hi=2.0)
if sharp > 0.01:
img = img.filter(
ImageFilter.UnsharpMask(radius=1.8, percent=int(80 * sharp), threshold=3)
)
img = _vignette(img, vignette)
img = _grain(img, grain)
img = _border(img, border)
img = _watermark(img, watermark)
return _emit(img, info_text=_text(embed_info) or None)
def prep_image(image, max_side, mode, strip_alpha):
"""Normalize an image for the next stage — and bridge model → model.
A `model` node's image output cannot be wired straight into another
model's ``image_to_image`` port (gradio hands the provider a
``/gradio_api/file=`` path it cannot resolve). Routing it through this node
re-emits the pixels as a ``data:`` URI, which does chain. It also caps the
long edge, which keeps img2img latency sane.
"""
img = _load_image(image)
mode_name = _choice(mode, ["Fit", "Cover (crop)", "Stretch", "Pad to square"], "Fit")
side = _num(max_side, 1024, lo=256, hi=2048, integer=True)
if _flag(strip_alpha, True):
img = _rgb(img)
if mode_name == "Pad to square":
img = ImageOps.contain(img, (side, side), Image.LANCZOS)
canvas = Image.new(img.mode, (side, side), (255, 255, 255) if img.mode == "RGB" else 0)
canvas.paste(img, ((side - img.width) // 2, (side - img.height) // 2))
img = canvas
elif mode_name == "Cover (crop)":
img = ImageOps.fit(img, (side, side), Image.LANCZOS, centering=(0.5, 0.5))
elif mode_name == "Stretch":
img = img.resize((side, side), Image.LANCZOS)
else: # Fit — preserve aspect, only ever downscale
if max(img.size) > side:
img = ImageOps.contain(img, (side, side), Image.LANCZOS)
return _emit(img)
def extras_upscale(image, factor, method, sharpen, denoise, restore_contrast):
"""A1111's 'Extras' upscaler, done locally. Returns (image, report).
Honest about what it is: high-quality Lanczos resampling with an unsharp
pass — not a GAN. It is instant, deterministic and never hits a quota,
which makes it the right default; the AuraSR Space node next to it on the
canvas is there when you want real hallucinated detail.
"""
img = _load_image(image)
before = img.size
if _flag(denoise, False):
img = img.filter(ImageFilter.MedianFilter(size=3))
img = _resize(img, factor, method)
sharp = _num(sharpen, 0.45, lo=0.0, hi=2.0)
if sharp > 0.01:
img = img.filter(
ImageFilter.UnsharpMask(radius=2.2, percent=int(95 * sharp), threshold=2)
)
if _flag(restore_contrast, True):
img = ImageEnhance.Contrast(img).enhance(1.04)
mp = (img.width * img.height) / 1e6
report = (
f"Upscaled {before[0]}×{before[1]}{img.width}×{img.height} ({mp:.2f} MP)\n"
f"Resampler: {_choice(method, list(_RESAMPLE), 'Lanczos')} "
f"Unsharp: {sharp:.2f} Denoise: {'on' if _flag(denoise, False) else 'off'}"
)
return _emit(img), report
# --- ControlNet-style preprocessors -----------------------------------------
def _sobel(gray):
"""Gradient magnitude + direction via Sobel."""
a = np.asarray(gray, dtype=np.float32)
kx = np.array([[-1, 0, 1], [-2, 0, 2], [-1, 0, 1]], dtype=np.float32)
ky = np.array([[-1, -2, -1], [0, 0, 0], [1, 2, 1]], dtype=np.float32)
p = np.pad(a, 1, mode="edge")
win = np.lib.stride_tricks.sliding_window_view(p, (3, 3))
gx = np.einsum("ijkl,kl->ij", win, kx)
gy = np.einsum("ijkl,kl->ij", win, ky)
return np.hypot(gx, gy), np.arctan2(gy, gx)
def _canny(gray, low, high):
"""Canny: Sobel → non-maximum suppression → hysteresis."""
mag, theta = _sobel(gray)
# Normalize against the 99th percentile, not the maximum: a single specular
# highlight can be 3× the typical strong edge, and dividing by it crushes
# every real contour below the threshold (a photo yielded 0.7% edge pixels
# instead of ~5%). Clipping keeps that one outlier from mattering.
scale = float(np.percentile(mag, 99)) or float(mag.max())
if scale > 0:
mag = np.clip(mag / scale * 255.0, 0, 255)
# non-maximum suppression, directions quantized to 4 bins
angle = (np.degrees(theta) % 180.0)
nms = np.zeros_like(mag)
m = mag
pad = np.pad(m, 1, mode="constant")
neighbours = {
0: (pad[1:-1, 2:], pad[1:-1, :-2]), # 0° → E / W
1: (pad[:-2, 2:], pad[2:, :-2]), # 45° → NE / SW
2: (pad[:-2, 1:-1], pad[2:, 1:-1]), # 90° → N / S
3: (pad[:-2, :-2], pad[2:, 2:]), # 135° → NW / SE
}
bins = np.digitize(angle, [22.5, 67.5, 112.5, 157.5]) % 4
for b, (n1, n2) in neighbours.items():
sel = bins == b
nms[sel] = np.where((m[sel] >= n1[sel]) & (m[sel] >= n2[sel]), m[sel], 0)
strong, weak = nms >= high, (nms >= low) & (nms < high)
# hysteresis: iteratively promote weak pixels touching strong ones
keep = strong.copy()
for _ in range(12):
grown = keep.copy()
for dy in (-1, 0, 1):
for dx in (-1, 0, 1):
if dx or dy:
grown |= np.roll(np.roll(keep, dy, 0), dx, 1)
promoted = grown & weak & ~keep
if not promoted.any():
break
keep |= promoted
return (keep * 255).astype(np.uint8)
CONTROL_MODES = [
"Canny edges",
"Line art",
"Soft sketch",
"Luma depth (approx)",
"Posterize",
"Threshold",
"Grayscale",
]
def controlnet_preprocess(image, mode, low_threshold, high_threshold, invert, blur):
"""Annotator previews, computed locally.
Real depth estimation is unavailable — `InferenceClient` has no
``depth_estimation`` method and the legacy ``api-inference`` host no longer
resolves — so 'Luma depth' is an honest luminance-based approximation, not
a monocular depth model. The edge modes are the genuine article.
"""
img = _load_image(image)
mode_name = _choice(mode, CONTROL_MODES, "Canny edges")
blur_r = _num(blur, 0.0, lo=0.0, hi=6.0)
work = img.filter(ImageFilter.GaussianBlur(blur_r)) if blur_r > 0.05 else img
gray = _rgb(work).convert("L")
low = _num(low_threshold, 60, lo=1, hi=254, integer=True)
high = _num(high_threshold, 160, lo=2, hi=255, integer=True)
if high <= low:
high = min(255, low + 20)
if mode_name == "Canny edges":
out = Image.fromarray(_canny(gray.filter(ImageFilter.GaussianBlur(1.1)), low, high), "L")
elif mode_name == "Line art":
edges = gray.filter(ImageFilter.FIND_EDGES)
edges = ImageOps.autocontrast(edges).filter(ImageFilter.MaxFilter(3))
out = ImageOps.invert(edges)
elif mode_name == "Soft sketch":
inv = ImageOps.invert(gray).filter(ImageFilter.GaussianBlur(max(1.5, blur_r or 3.0)))
a = np.asarray(gray, np.float32)
b = np.asarray(inv, np.float32)
dodge = np.clip(a * 255.0 / np.maximum(255.0 - b, 1.0), 0, 255)
out = Image.fromarray(dodge.astype(np.uint8), "L")
elif mode_name == "Luma depth (approx)":
eq = ImageOps.autocontrast(gray.filter(ImageFilter.GaussianBlur(2.0)))
out = eq.point(lambda v: int(255 * (v / 255.0) ** 0.72))
elif mode_name == "Posterize":
out = ImageOps.posterize(gray, 3)
elif mode_name == "Threshold":
out = gray.point(lambda v: 255 if v >= low else 0, mode="L")
else: # Grayscale
out = ImageOps.autocontrast(gray)
if _flag(invert, False):
out = ImageOps.invert(out)
return _emit(out.convert("RGB"))
# --- detection, masking, grids ----------------------------------------------
_PALETTE = [
(255, 92, 92), (86, 204, 242), (255, 199, 84), (129, 236, 160),
(200, 143, 255), (255, 145, 200), (120, 180, 255), (255, 170, 110),
]
def _boxes(detections, min_score):
floor = _num(min_score, 0.5, lo=0.0, hi=1.0)
out = []
for det in _as_list(detections):
if not isinstance(det, dict):
continue
box = det.get("box") or det.get("bbox") or {}
if not isinstance(box, dict):
continue
try:
x0, y0 = float(box["xmin"]), float(box["ymin"])
x1, y1 = float(box["xmax"]), float(box["ymax"])
except (KeyError, TypeError, ValueError):
continue
score = _num(det.get("score"), 0.0)
if score < floor:
continue
out.append(
{
"label": _text(det.get("label"), "object"),
"score": score,
"box": (min(x0, x1), min(y0, y1), max(x0, x1), max(y0, y1)),
}
)
out.sort(key=lambda d: d["score"], reverse=True)
return out
def draw_detections(image, detections, min_score, show_labels):
"""Overlay DETR boxes. Returns (annotated image, summary text)."""
img = _rgb(_load_image(image)).copy()
found = _boxes(detections, min_score)
draw = ImageDraw.Draw(img, "RGBA")
# Sized generously on purpose: a canvas node renders a 768px image at
# roughly a third of its size, where a hairline box is invisible.
stroke = max(3, int(min(img.size) * 0.008))
font = _font(max(15, int(min(img.size) * 0.034)))
for i, det in enumerate(found):
color = _PALETTE[i % len(_PALETTE)]
x0, y0, x1, y1 = det["box"]
draw.rectangle([x0, y0, x1, y1], outline=color + (255,), width=stroke)
if _flag(show_labels, True):
tag = f"{det['label']} {det['score'] * 100:.0f}%"
tb = draw.textbbox((0, 0), tag, font=font)
tw, th = tb[2] - tb[0], tb[3] - tb[1]
ly = max(0, y0 - th - stroke * 3)
draw.rectangle([x0, ly, x0 + tw + stroke * 3, ly + th + stroke * 2],
fill=color + (230,))
draw.text((x0 + stroke * 1.5, ly + stroke - tb[1]), tag, font=font,
fill=(20, 20, 24, 255))
if found:
counts = {}
for d in found:
counts[d["label"]] = counts.get(d["label"], 0) + 1
summary = f"{len(found)} object(s) detected\n" + "\n".join(
f" • {n}× {label}" for label, n in
sorted(counts.items(), key=lambda kv: -kv[1])
)
else:
summary = "No objects above the score threshold."
return _emit(img), summary
def mask_from_detections(image, detections, label_filter, min_score, feather, invert, preview):
"""Build an inpainting mask from detections — A1111's masked-region workflow.
White = the region to repaint. ``label_filter`` accepts a comma-separated
list ('cat, dog'); blank means every detection.
"""
img = _load_image(image)
w, h = img.size
wanted = {t.strip().lower() for t in _text(label_filter).split(",") if t.strip()}
mask = Image.new("L", (w, h), 0)
draw = ImageDraw.Draw(mask)
hits = 0
for det in _boxes(detections, min_score):
if wanted and det["label"].lower() not in wanted:
continue
x0, y0, x1, y1 = det["box"]
draw.rectangle(
[max(0, x0), max(0, y0), min(w - 1, x1), min(h - 1, y1)], fill=255
)
hits += 1
if not hits:
raise ValueError(
"No detections matched — lower the score threshold or clear the label filter."
)
blur = _num(feather, 6, lo=0, hi=64)
if blur > 0.5:
mask = mask.filter(ImageFilter.GaussianBlur(blur))
if _flag(invert, False):
mask = ImageOps.invert(mask)
if _flag(preview, False):
# red overlay on the source, for eyeballing the region
overlay = Image.new("RGB", (w, h), (255, 60, 60))
return _emit(Image.composite(overlay, _rgb(img), mask.point(lambda v: v // 2)))
return _emit(mask.convert("RGB"))
def contact_sheet(image_1, image_2, image_3, image_4, labels, columns, gap, title):
"""Tile up to four images into A1111's X/Y grid, with captions."""
sources = [image_1, image_2, image_3, image_4]
tiles = []
for i, src in enumerate(sources):
if src in (None, ""):
continue
try:
tiles.append(_rgb(_load_image(src, f"image_{i + 1}")))
except ValueError:
continue
if not tiles:
raise ValueError("Connect at least one image to the contact sheet.")
caption = [c.strip() for c in _text(labels).split("|")]
cols = _num(columns, 2, lo=1, hi=4, integer=True)
cols = min(cols, len(tiles))
rows = (len(tiles) + cols - 1) // cols
pad = _num(gap, 14, lo=0, hi=80, integer=True)
cell = min(640, max(t.width for t in tiles))
tiles = [ImageOps.fit(t, (cell, cell), Image.LANCZOS, centering=(0.5, 0.5)) for t in tiles]
head = _text(title)
label_h = max(26, cell // 16)
head_h = int(label_h * 1.7) if head else 0
sheet_w = cols * cell + pad * (cols + 1)
sheet_h = head_h + rows * (cell + label_h) + pad * (rows + 1)
sheet = Image.new("RGB", (sheet_w, sheet_h), (16, 16, 20))
draw = ImageDraw.Draw(sheet)
if head:
f = _font(int(label_h * 0.95))
draw.text((pad, pad // 2 + 2), head, font=f, fill=(240, 240, 245))
f = _font(int(label_h * 0.68))
for i, tile in enumerate(tiles):
r, c = divmod(i, cols)
x = pad + c * (cell + pad)
y = head_h + pad + r * (cell + label_h + pad)
sheet.paste(tile, (x, y))
text = caption[i] if i < len(caption) and caption[i] else f"#{i + 1}"
if len(text) > 46:
text = text[:43] + "…"
draw.text((x + 3, y + cell + 5), text, font=f, fill=(196, 199, 210))
return _emit(sheet)
def png_info(image):
"""A1111's 'PNG Info' tab: recover generation parameters from a file.
Returns (report, fields-as-JSON-text). The fields are serialized rather
than returned as a dict because a `json` port arrives in the canvas as
"[object Object]" (see `detect_objects`).
"""
img = _load_image(image)
img.load() # force chunk parsing so text metadata is populated
meta = {k: v for k, v in (img.info or {}).items()
if isinstance(v, (str, int, float)) and k not in ("icc_profile",)}
fields = {
"width": img.width,
"height": img.height,
"mode": img.mode,
"format": img.format or "unknown",
"megapixels": round(img.width * img.height / 1e6, 2),
}
raw = str(meta.get("parameters") or meta.get("Comment") or "").strip()
if raw:
lines = raw.splitlines()
fields["prompt"] = lines[0].strip()
for line in lines[1:]:
if line.lower().startswith("negative prompt:"):
fields["negative_prompt"] = line.split(":", 1)[1].strip()
elif ":" in line:
for pair in re.split(r",\s*(?=[A-Z][A-Za-z ]*:)", line):
if ":" in pair:
k, v = pair.split(":", 1)
fields[k.strip().lower().replace(" ", "_")] = v.strip()
try:
exif = img.getexif()
if exif:
from PIL.ExifTags import TAGS
for tag, value in exif.items():
name = TAGS.get(tag, str(tag))
if isinstance(value, (str, int, float)) and name != "MakerNote":
fields.setdefault(f"exif_{name.lower()}", value)
except Exception: # EXIF is best-effort; never fail the node over it
pass
head = f"{fields['format']} {img.width}×{img.height} ({fields['megapixels']} MP, {img.mode})"
if raw:
body = "\n\n── Generation parameters ──\n" + raw
else:
extra = [f"{k}: {v}" for k, v in meta.items()][:12]
body = (
"\n\nNo generation parameters embedded in this file.\n"
+ ("Other metadata:\n" + "\n".join(extra) if extra else
"This image carries no text metadata at all.")
)
return head + body, json.dumps(fields, indent=2, default=str)
# What `app.py` binds onto the canvas. Keys must match the "fn" field of each
# operator node in workflow.json.
BIND = {
"apply_style": apply_style,
"build_negative": build_negative,
"sampler_settings": sampler_settings,
"generation_info": generation_info,
"prompt_matrix": prompt_matrix,
"magic_instruction": magic_instruction,
"clean_prompt": clean_prompt,
"txt2img": txt2img,
"chat_llm": chat_llm,
"interrogate": interrogate,
"detect_objects": detect_objects,
"classify_image": classify_image,
"top_labels": top_labels,
"postprocess": postprocess,
"prep_image": prep_image,
"extras_upscale": extras_upscale,
"controlnet_preprocess": controlnet_preprocess,
"draw_detections": draw_detections,
"mask_from_detections": mask_from_detections,
"contact_sheet": contact_sheet,
"png_info": png_info,
}