chore: import upstream snapshot with attribution
docs / deploy (push) Has been cancelled
docs / changes (push) Has been cancelled
docs / check-and-build (push) Has been cancelled
build container image / cpu (push) Has been cancelled
build container image / cuda (push) Has been cancelled
build container image / rocm (push) Has been cancelled
frontend checks / frontend-checks (push) Has been cancelled
frontend tests / frontend-tests (push) Has been cancelled
lfs checks / lfs-check (push) Has been cancelled
python checks / python-checks (push) Has been cancelled
python tests / py3.12: macos-default (push) Has been cancelled
python tests / py3.11: windows-cpu (push) Has been cancelled
python tests / py3.12: windows-cpu (push) Has been cancelled
python tests / py3.11: linux-cpu (push) Has been cancelled
typegen checks / typegen-checks (push) Has been cancelled
uv lock checks / uv-lock-checks (push) Has been cancelled
openapi checks / openapi-checks (push) Has been cancelled
python tests / py3.11: macos-default (push) Has been cancelled
python tests / py3.12: linux-cpu (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:22:06 +08:00
commit cddb07a176
3370 changed files with 685519 additions and 0 deletions
View File
+433
View File
@@ -0,0 +1,433 @@
from typing import Any, Literal, Union
import cv2
import numpy as np
import torch
from einops import rearrange
from PIL import Image
from invokeai.backend.image_util.util import nms, normalize_image_channel_count
CONTROLNET_RESIZE_VALUES = Literal[
"just_resize",
"crop_resize",
"fill_resize",
"just_resize_simple",
]
CONTROLNET_MODE_VALUES = Literal["balanced", "more_prompt", "more_control", "unbalanced"]
###################################################################
# Copy of scripts/lvminthin.py from Mikubill/sd-webui-controlnet
###################################################################
# High Quality Edge Thinning using Pure Python
# Written by Lvmin Zhangu
# 2023 April
# Stanford University
# If you use this, please Cite "High Quality Edge Thinning using Pure Python", Lvmin Zhang, In Mikubill/sd-webui-controlnet.
lvmin_kernels_raw = [
np.array([[-1, -1, -1], [0, 1, 0], [1, 1, 1]], dtype=np.int32),
np.array([[0, -1, -1], [1, 1, -1], [0, 1, 0]], dtype=np.int32),
]
lvmin_kernels = []
lvmin_kernels += [np.rot90(x, k=0, axes=(0, 1)) for x in lvmin_kernels_raw]
lvmin_kernels += [np.rot90(x, k=1, axes=(0, 1)) for x in lvmin_kernels_raw]
lvmin_kernels += [np.rot90(x, k=2, axes=(0, 1)) for x in lvmin_kernels_raw]
lvmin_kernels += [np.rot90(x, k=3, axes=(0, 1)) for x in lvmin_kernels_raw]
lvmin_prunings_raw = [
np.array([[-1, -1, -1], [-1, 1, -1], [0, 0, -1]], dtype=np.int32),
np.array([[-1, -1, -1], [-1, 1, -1], [-1, 0, 0]], dtype=np.int32),
]
lvmin_prunings = []
lvmin_prunings += [np.rot90(x, k=0, axes=(0, 1)) for x in lvmin_prunings_raw]
lvmin_prunings += [np.rot90(x, k=1, axes=(0, 1)) for x in lvmin_prunings_raw]
lvmin_prunings += [np.rot90(x, k=2, axes=(0, 1)) for x in lvmin_prunings_raw]
lvmin_prunings += [np.rot90(x, k=3, axes=(0, 1)) for x in lvmin_prunings_raw]
def remove_pattern(x, kernel):
objects = cv2.morphologyEx(x, cv2.MORPH_HITMISS, kernel)
objects = np.where(objects > 127)
x[objects] = 0
return x, objects[0].shape[0] > 0
def thin_one_time(x, kernels):
y = x
is_done = True
for k in kernels:
y, has_update = remove_pattern(y, k)
if has_update:
is_done = False
return y, is_done
def lvmin_thin(x, prunings=True):
y = x
for _i in range(32):
y, is_done = thin_one_time(y, lvmin_kernels)
if is_done:
break
if prunings:
y, _ = thin_one_time(y, lvmin_prunings)
return y
################################################################################
# copied from Mikubill/sd-webui-controlnet external_code.py and modified for InvokeAI
################################################################################
# FIXME: not using yet, if used in the future will most likely require modification of preprocessors
def pixel_perfect_resolution(
image: np.ndarray,
target_H: int,
target_W: int,
resize_mode: str,
) -> int:
"""
Calculate the estimated resolution for resizing an image while preserving aspect ratio.
The function first calculates scaling factors for height and width of the image based on the target
height and width. Then, based on the chosen resize mode, it either takes the smaller or the larger
scaling factor to estimate the new resolution.
If the resize mode is OUTER_FIT, the function uses the smaller scaling factor, ensuring the whole image
fits within the target dimensions, potentially leaving some empty space.
If the resize mode is not OUTER_FIT, the function uses the larger scaling factor, ensuring the target
dimensions are fully filled, potentially cropping the image.
After calculating the estimated resolution, the function prints some debugging information.
Args:
image (np.ndarray): A 3D numpy array representing an image. The dimensions represent [height, width, channels].
target_H (int): The target height for the image.
target_W (int): The target width for the image.
resize_mode (ResizeMode): The mode for resizing.
Returns:
int: The estimated resolution after resizing.
"""
raw_H, raw_W, _ = image.shape
k0 = float(target_H) / float(raw_H)
k1 = float(target_W) / float(raw_W)
if resize_mode == "fill_resize":
estimation = min(k0, k1) * float(min(raw_H, raw_W))
else: # "crop_resize" or "just_resize" (or possibly "just_resize_simple"?)
estimation = max(k0, k1) * float(min(raw_H, raw_W))
# print(f"Pixel Perfect Computation:")
# print(f"resize_mode = {resize_mode}")
# print(f"raw_H = {raw_H}")
# print(f"raw_W = {raw_W}")
# print(f"target_H = {target_H}")
# print(f"target_W = {target_W}")
# print(f"estimation = {estimation}")
return int(np.round(estimation))
def clone_contiguous(x: np.ndarray[Any, Any]) -> np.ndarray[Any, Any]:
"""Get a memory-contiguous clone of the given numpy array, as a safety measure and to improve computation efficiency."""
return np.ascontiguousarray(x).copy()
def np_img_to_torch(np_img: np.ndarray[Any, Any], device: torch.device) -> torch.Tensor:
"""Convert a numpy image to a PyTorch tensor. The image is normalized to 0-1, rearranged to BCHW format and sent to
the specified device."""
torch_img = torch.from_numpy(np_img)
normalized = torch_img.float() / 255.0
bchw = rearrange(normalized, "h w c -> 1 c h w")
on_device = bchw.to(device)
return on_device.clone()
def heuristic_resize(np_img: np.ndarray[Any, Any], size: tuple[int, int]) -> np.ndarray[Any, Any]:
"""Resizes an image using a heuristic to choose the best resizing strategy.
- If the image appears to be an edge map, special handling will be applied to ensure the edges are not distorted.
- Single-pixel edge maps use NMS and thinning to keep the edges as single-pixel lines.
- Low-color-count images are resized with nearest-neighbor to preserve color information (for e.g. segmentation maps).
- The alpha channel is handled separately to ensure it is resized correctly.
Args:
np_img (np.ndarray): The input image.
size (tuple[int, int]): The target size for the image.
Returns:
np.ndarray: The resized image.
Adapted from https://github.com/Mikubill/sd-webui-controlnet.
"""
# Return early if the image is already at the requested size
if np_img.shape[0] == size[1] and np_img.shape[1] == size[0]:
return np_img
# If the image has an alpha channel, separate it for special handling later.
inpaint_mask = None
if np_img.ndim == 3 and np_img.shape[2] == 4:
inpaint_mask = np_img[:, :, 3]
np_img = np_img[:, :, 0:3]
new_size_is_smaller = (size[0] * size[1]) < (np_img.shape[0] * np_img.shape[1])
new_size_is_bigger = (size[0] * size[1]) > (np_img.shape[0] * np_img.shape[1])
unique_color_count = np.unique(np_img.reshape(-1, np_img.shape[2]), axis=0).shape[0]
is_one_pixel_edge = False
is_binary = False
if unique_color_count == 2:
# If the image has only two colors, it is likely binary. Check if the image has one-pixel edges.
is_binary = np.min(np_img) < 16 and np.max(np_img) > 240
if is_binary:
eroded = cv2.erode(np_img, np.ones(shape=(3, 3), dtype=np.uint8), iterations=1)
dilated = cv2.dilate(eroded, np.ones(shape=(3, 3), dtype=np.uint8), iterations=1)
one_pixel_edge_count = np.where(dilated < np_img)[0].shape[0]
all_edge_count = np.where(np_img > 127)[0].shape[0]
is_one_pixel_edge = one_pixel_edge_count * 2 > all_edge_count
if 2 < unique_color_count < 200:
# With a low color count, we assume this is a map where exact colors are important. Near-neighbor preserves
# the colors as needed.
interpolation = cv2.INTER_NEAREST
elif new_size_is_smaller:
# This works best for downscaling
interpolation = cv2.INTER_AREA
else:
# Fall back for other cases
interpolation = cv2.INTER_CUBIC # Must be CUBIC because we now use nms. NEVER CHANGE THIS
# This may be further transformed depending on the binary nature of the image.
resized = cv2.resize(np_img, size, interpolation=interpolation)
if inpaint_mask is not None:
# Resize the inpaint mask to match the resized image using the same interpolation method.
inpaint_mask = cv2.resize(inpaint_mask, size, interpolation=interpolation)
# If the image is binary, we will perform some additional processing to ensure the edges are preserved.
if is_binary:
resized = np.mean(resized.astype(np.float32), axis=2).clip(0, 255).astype(np.uint8)
if is_one_pixel_edge:
# Use NMS and thinning to keep the edges as single-pixel lines.
resized = nms(resized)
_, resized = cv2.threshold(resized, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
resized = lvmin_thin(resized, prunings=new_size_is_bigger)
else:
_, resized = cv2.threshold(resized, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
resized = np.stack([resized] * 3, axis=2)
# Restore the alpha channel if it was present.
if inpaint_mask is not None:
inpaint_mask = (inpaint_mask > 127).astype(np.float32) * 255.0
inpaint_mask = inpaint_mask[:, :, None].clip(0, 255).astype(np.uint8)
resized = np.concatenate([resized, inpaint_mask], axis=2)
return resized
# precompute common kernels
_KERNEL3 = cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3))
# directional masks for NMS
_DIRS = [
np.array([[0, 0, 0], [1, 1, 1], [0, 0, 0]], np.uint8),
np.array([[0, 1, 0], [0, 1, 0], [0, 1, 0]], np.uint8),
np.array([[1, 0, 0], [0, 1, 0], [0, 0, 1]], np.uint8),
np.array([[0, 0, 1], [0, 1, 0], [1, 0, 0]], np.uint8),
]
def heuristic_resize_fast(np_img: np.ndarray, size: tuple[int, int]) -> np.ndarray:
h, w = np_img.shape[:2]
# early exit
if (w, h) == size:
return np_img
# separate alpha channel
img = np_img
alpha = None
if img.ndim == 3 and img.shape[2] == 4:
alpha, img = img[:, :, 3], img[:, :, :3]
# build small sample for uniquecolor & binary detection
flat = img.reshape(-1, img.shape[-1])
N = flat.shape[0]
# include four corners to avoid missing extreme values
corners = np.vstack([img[0, 0], img[0, w - 1], img[h - 1, 0], img[h - 1, w - 1]])
cnt = min(N, 100_000)
samp = np.vstack([corners, flat[np.random.choice(N, cnt, replace=False)]])
uc = np.unique(samp, axis=0).shape[0]
vmin, vmax = samp.min(), samp.max()
# detect binary edge map & onepixeledge case
is_binary = uc == 2 and vmin < 16 and vmax > 240
one_pixel_edge = False
if is_binary:
# single gray conversion
gray0 = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
grad = cv2.morphologyEx(gray0, cv2.MORPH_GRADIENT, _KERNEL3)
cnt_edge = cv2.countNonZero(grad)
cnt_all = cv2.countNonZero((gray0 > 127).astype(np.uint8))
one_pixel_edge = (2 * cnt_edge) > cnt_all
# choose interp for color/seg/grayscale
area_new, area_old = size[0] * size[1], w * h
if 2 < uc < 200: # segmentation map
interp = cv2.INTER_NEAREST
elif area_new < area_old:
interp = cv2.INTER_AREA
else:
interp = cv2.INTER_CUBIC
# single resize pass on RGB
resized = cv2.resize(img, size, interpolation=interp)
if is_binary:
# convert to gray & apply NMS via C++ dilate
gray_r = cv2.cvtColor(resized, cv2.COLOR_BGR2GRAY)
nms = np.zeros_like(gray_r)
for K in _DIRS:
d = cv2.dilate(gray_r, K)
mask = d == gray_r
nms[mask] = gray_r[mask]
# threshold + thinning if needed
_, bw = cv2.threshold(nms, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
out_bin = cv2.ximgproc.thinning(bw) if one_pixel_edge else bw
# restore 3 channels
resized = np.stack([out_bin] * 3, axis=2)
# restore alpha with same interp as RGB for consistency
if alpha is not None:
am = cv2.resize(alpha, size, interpolation=interp)
am = (am > 127).astype(np.uint8) * 255
resized = np.dstack((resized, am))
return resized
###########################################################################
# Copied from detectmap_proc method in scripts/detectmap_proc.py in Mikubill/sd-webui-controlnet
# modified for InvokeAI
###########################################################################
def np_img_resize(
np_img: np.ndarray,
resize_mode: CONTROLNET_RESIZE_VALUES,
h: int,
w: int,
device: torch.device = torch.device("cpu"),
) -> tuple[torch.Tensor, np.ndarray[Any, Any]]:
np_img = normalize_image_channel_count(np_img)
if resize_mode == "just_resize": # RESIZE
np_img = heuristic_resize_fast(np_img, (w, h))
np_img = clone_contiguous(np_img)
return np_img_to_torch(np_img, device), np_img
old_h, old_w, _ = np_img.shape
old_w = float(old_w)
old_h = float(old_h)
k0 = float(h) / old_h
k1 = float(w) / old_w
def safeint(x: Union[int, float]) -> int:
return int(np.round(x))
if resize_mode == "fill_resize": # OUTER_FIT
k = min(k0, k1)
borders = np.concatenate([np_img[0, :, :], np_img[-1, :, :], np_img[:, 0, :], np_img[:, -1, :]], axis=0)
high_quality_border_color = np.median(borders, axis=0).astype(np_img.dtype)
if len(high_quality_border_color) == 4:
# Inpaint hijack
high_quality_border_color[3] = 255
high_quality_background = np.tile(high_quality_border_color[None, None], [h, w, 1])
np_img = heuristic_resize_fast(np_img, (safeint(old_w * k), safeint(old_h * k)))
new_h, new_w, _ = np_img.shape
pad_h = max(0, (h - new_h) // 2)
pad_w = max(0, (w - new_w) // 2)
high_quality_background[pad_h : pad_h + new_h, pad_w : pad_w + new_w] = np_img
np_img = high_quality_background
np_img = clone_contiguous(np_img)
return np_img_to_torch(np_img, device), np_img
else: # resize_mode == "crop_resize" (INNER_FIT)
k = max(k0, k1)
np_img = heuristic_resize_fast(np_img, (safeint(old_w * k), safeint(old_h * k)))
new_h, new_w, _ = np_img.shape
pad_h = max(0, (new_h - h) // 2)
pad_w = max(0, (new_w - w) // 2)
np_img = np_img[pad_h : pad_h + h, pad_w : pad_w + w]
np_img = clone_contiguous(np_img)
return np_img_to_torch(np_img, device), np_img
def prepare_control_image(
image: Image.Image,
width: int,
height: int,
num_channels: int = 3,
device: str | torch.device = "cuda",
dtype: torch.dtype = torch.float16,
control_mode: CONTROLNET_MODE_VALUES = "balanced",
resize_mode: CONTROLNET_RESIZE_VALUES = "just_resize_simple",
do_classifier_free_guidance: bool = True,
) -> torch.Tensor:
"""Pre-process images for ControlNets or T2I-Adapters.
Args:
image (Image): The PIL image to pre-process.
width (int): The target width in pixels.
height (int): The target height in pixels.
num_channels (int, optional): The target number of image channels. This is achieved by converting the input
image to RGB, then naively taking the first `num_channels` channels. The primary use case is converting a
RGB image to a single-channel grayscale image. Raises if `num_channels` cannot be achieved. Defaults to 3.
device (str | torch.Device, optional): The target device for the output image. Defaults to "cuda".
dtype (_type_, optional): The dtype for the output image. Defaults to torch.float16.
do_classifier_free_guidance (bool, optional): If True, repeat the output image along the batch dimension.
Defaults to True.
control_mode (str, optional): Defaults to "balanced".
resize_mode (str, optional): Defaults to "just_resize_simple".
Raises:
ValueError: If `resize_mode` is not recognized.
ValueError: If `num_channels` is out of range.
Returns:
torch.Tensor: The pre-processed input tensor.
"""
if resize_mode == "just_resize_simple":
image = image.convert("RGB")
image = image.resize((width, height), resample=Image.LANCZOS)
nimage = np.array(image)
nimage = nimage[None, :]
nimage = np.concatenate([nimage], axis=0)
# normalizing RGB values to [0,1] range (in PIL.Image they are [0-255])
nimage = np.array(nimage).astype(np.float32) / 255.0
nimage = nimage.transpose(0, 3, 1, 2)
timage = torch.from_numpy(nimage)
# use fancy lvmin controlnet resizing
elif resize_mode == "just_resize" or resize_mode == "crop_resize" or resize_mode == "fill_resize":
nimage = np.array(image)
timage, nimage = np_img_resize(
np_img=nimage,
resize_mode=resize_mode,
h=height,
w=width,
device=torch.device(device),
)
else:
raise ValueError(f"Unsupported resize_mode: '{resize_mode}'.")
if timage.shape[1] < num_channels or num_channels <= 0:
raise ValueError(f"Cannot achieve the target of num_channels={num_channels}.")
timage = timage[:, :num_channels, :, :]
timage = timage.to(device=device, dtype=dtype)
cfg_injection = control_mode == "more_control" or control_mode == "unbalanced"
if do_classifier_free_guidance and not cfg_injection:
timage = torch.cat([timage] * 2)
return timage
+153
View File
@@ -0,0 +1,153 @@
from typing import Any, Callable, Optional
from fastapi import FastAPI
from fastapi.openapi.utils import get_openapi
from pydantic.json_schema import models_json_schema
from invokeai.app.invocations.baseinvocation import (
InvocationRegistry,
UIConfigBase,
)
from invokeai.app.invocations.fields import InputFieldJSONSchemaExtra, OutputFieldJSONSchemaExtra
from invokeai.app.invocations.model import ModelIdentifierField
from invokeai.app.services.events.events_common import EventBase
from invokeai.app.services.session_processor.session_processor_common import ProgressImage
from invokeai.backend.model_manager.configs.factory import AnyModelConfigValidator
from invokeai.backend.util.logging import InvokeAILogger
logger = InvokeAILogger.get_logger()
def move_defs_to_top_level(openapi_schema: dict[str, Any], component_schema: dict[str, Any]) -> None:
"""Moves a component schema's $defs to the top level of the openapi schema. Useful when generating a schema
for a single model that needs to be added back to the top level of the schema. Mutates openapi_schema and
component_schema."""
defs = component_schema.pop("$defs", {})
for schema_key, json_schema in defs.items():
if schema_key in openapi_schema["components"]["schemas"]:
continue
openapi_schema["components"]["schemas"][schema_key] = json_schema
def normalize_path_defaults(node: Any) -> None:
"""Recursively normalize `default` strings on schema nodes whose `format` is `path` to use forward slashes.
Pydantic stringifies `Path` defaults using the host OS's separator, so a default declared as
`Path("models/.convert_cache")` serializes to `models\\.convert_cache` on Windows. That OS-dependent drift
pollutes diffs whenever schema is regenerated on Windows. We force POSIX form for path-typed defaults.
"""
if isinstance(node, dict):
if node.get("format") == "path" and isinstance(node.get("default"), str):
node["default"] = node["default"].replace("\\", "/")
for v in node.values():
normalize_path_defaults(v)
elif isinstance(node, list):
for v in node:
normalize_path_defaults(v)
def get_openapi_func(
app: FastAPI, post_transform: Optional[Callable[[dict[str, Any]], dict[str, Any]]] = None
) -> Callable[[], dict[str, Any]]:
"""Gets the OpenAPI schema generator function.
Args:
app (FastAPI): The FastAPI app to generate the schema for.
post_transform (Optional[Callable[[dict[str, Any]], dict[str, Any]]], optional): A function to apply to the
generated schema before returning it. Defaults to None.
Returns:
Callable[[], dict[str, Any]]: The OpenAPI schema generator function. When first called, the generated schema is
cached in `app.openapi_schema`. On subsequent calls, the cached schema is returned. This caching behaviour
matches FastAPI's default schema generation caching.
"""
def openapi() -> dict[str, Any]:
if app.openapi_schema:
return app.openapi_schema
openapi_schema = get_openapi(
title=app.title,
description="An API for invoking AI image operations",
version="1.0.0",
routes=app.routes,
separate_input_output_schemas=False, # https://fastapi.tiangolo.com/how-to/separate-openapi-schemas/
)
# We'll create a map of invocation type to output schema to make some types simpler on the client.
invocation_output_map_properties: dict[str, Any] = {}
invocation_output_map_required: list[str] = []
# We need to manually add all outputs to the schema - pydantic doesn't add them because they aren't used directly.
for output in InvocationRegistry.get_output_classes():
json_schema = output.model_json_schema(mode="serialization", ref_template="#/components/schemas/{model}")
# Remove output_metadata that is only used on back-end from the schema
if "output_meta" in json_schema["properties"]:
json_schema["properties"].pop("output_meta")
move_defs_to_top_level(openapi_schema, json_schema)
openapi_schema["components"]["schemas"][output.__name__] = json_schema
# Technically, invocations are added to the schema by pydantic, but we still need to manually set their output
# property, so we'll just do it all manually.
for invocation in InvocationRegistry.get_invocation_classes():
json_schema = invocation.model_json_schema(
mode="serialization", ref_template="#/components/schemas/{model}"
)
move_defs_to_top_level(openapi_schema, json_schema)
output_title = invocation.get_output_annotation().__name__
outputs_ref = {"$ref": f"#/components/schemas/{output_title}"}
json_schema["output"] = outputs_ref
openapi_schema["components"]["schemas"][invocation.__name__] = json_schema
# Add this invocation and its output to the output map
invocation_type = invocation.get_type()
invocation_output_map_properties[invocation_type] = json_schema["output"]
invocation_output_map_required.append(invocation_type)
# Add the output map to the schema
openapi_schema["components"]["schemas"]["InvocationOutputMap"] = {
"type": "object",
"properties": dict(sorted(invocation_output_map_properties.items())),
"required": sorted(invocation_output_map_required),
}
# Some models don't end up in the schemas as standalone definitions because they aren't used directly in the API.
# We need to add them manually here. WARNING: Pydantic can choke if you call `model.model_json_schema()` to get
# a schema. This has something to do with schema refs - not totally clear. For whatever reason, using
# `models_json_schema` seems to work fine.
additional_models = [
*EventBase.get_events(),
UIConfigBase,
InputFieldJSONSchemaExtra,
OutputFieldJSONSchemaExtra,
ModelIdentifierField,
ProgressImage,
]
additional_schemas = models_json_schema(
[(m, "serialization") for m in additional_models],
ref_template="#/components/schemas/{model}",
)
# additional_schemas[1] is a dict of $defs that we need to add to the top level of the schema
move_defs_to_top_level(openapi_schema, additional_schemas[1])
any_model_config_schema = AnyModelConfigValidator.json_schema(
mode="serialization",
ref_template="#/components/schemas/{model}",
)
move_defs_to_top_level(openapi_schema, any_model_config_schema)
openapi_schema["components"]["schemas"]["AnyModelConfig"] = any_model_config_schema
if post_transform is not None:
openapi_schema = post_transform(openapi_schema)
normalize_path_defaults(openapi_schema)
openapi_schema["components"]["schemas"] = dict(sorted(openapi_schema["components"]["schemas"].items()))
app.openapi_schema = openapi_schema
return app.openapi_schema
return openapi
+68
View File
@@ -0,0 +1,68 @@
from __future__ import annotations
from collections.abc import Iterator
from dynamicprompts.commands import (
Command,
SequenceCommand,
VariantCommand,
WildcardCommand,
WrapCommand,
)
from dynamicprompts.parser.parse import parse
from dynamicprompts.wildcards import WildcardManager
from pyparsing import ParseException
def _iter_wildcard_names(command: Command, in_variant: bool = False) -> Iterator[str]:
"""Recursively yield the statically-known wildcard names that appear as (part of) a variant value.
Only wildcards reachable from a `VariantCommand` value are yielded: those are the references that
hang the combinatorial generator (see `find_missing_wildcards`). The same wildcard used as plain
literal text (e.g. `a __nope__ b`) generates fine, so it is intentionally not reported.
"""
if isinstance(command, WildcardCommand):
# The wildcard name may itself be a dynamic Command (e.g. `__${var}__`). Only plain string
# names can be validated ahead of time, so the dynamic case is intentionally skipped.
if in_variant and isinstance(command.wildcard, str):
yield command.wildcard
elif isinstance(command, SequenceCommand):
for token in command.tokens:
yield from _iter_wildcard_names(token, in_variant)
elif isinstance(command, VariantCommand):
# Everything below a variant value is a variant-nested reference, even across sequences.
for value in command.values:
yield from _iter_wildcard_names(value, in_variant=True)
elif isinstance(command, WrapCommand):
yield from _iter_wildcard_names(command.wrapper, in_variant)
yield from _iter_wildcard_names(command.inner, in_variant)
# LiteralCommand and variable commands reference no wildcards we can resolve statically.
def find_missing_wildcards(prompt: str, wildcard_manager: WildcardManager | None = None) -> list[str]:
"""Return the unique unknown wildcard names in `prompt` that hang the combinatorial generator.
Referencing an unknown wildcard *as a variant value* (e.g. `{__nope__|x}`) makes dynamicprompts'
combinatorial generator loop forever: its not-found fallback (`get_wildcard_not_found_fallback`)
yields the wrapped wildcard infinitely, and the combinatorial variant logic dedupes those
duplicates away without ever advancing. Detecting these names up front lets callers report a clear
error instead of hanging. Only the combinatorial generator is affected, and only for wildcards
nested in a variant — a bare `a __nope__ b` generates fine and is not reported.
Without a configured `wildcard_manager`, an empty one is used so that every referenced wildcard is
treated as missing (wildcards are not resolved against any files here).
"""
if wildcard_manager is None:
wildcard_manager = WildcardManager()
try:
tree = parse(prompt)
except ParseException:
# Malformed prompts are surfaced separately by the generators; nothing to validate here.
return []
missing: list[str] = []
for name in _iter_wildcard_names(tree):
if name not in missing and not wildcard_manager.get_values(name):
missing.append(name)
return missing
+15
View File
@@ -0,0 +1,15 @@
from enum import EnumMeta
class MetaEnum(EnumMeta):
"""Metaclass to support additional features in Enums.
- `in` operator support: `'value' in MyEnum -> bool`
"""
def __contains__(cls, item):
try:
cls(item)
except ValueError:
return False
return True
+35
View File
@@ -0,0 +1,35 @@
import datetime
import typing
import uuid
import numpy as np
def get_timestamp() -> int:
return int(datetime.datetime.now(datetime.timezone.utc).timestamp())
def get_iso_timestamp() -> str:
return datetime.datetime.now(datetime.timezone.utc).isoformat()
def get_datetime_from_iso_timestamp(iso_timestamp: str) -> datetime.datetime:
return datetime.datetime.fromisoformat(iso_timestamp)
SEED_MAX = np.iinfo(np.uint32).max
def get_random_seed() -> int:
rng = np.random.default_rng(seed=None)
return int(rng.integers(0, SEED_MAX))
def uuid_string() -> str:
res = uuid.uuid4()
return str(res)
def is_optional(value: typing.Any) -> bool:
"""Checks if a value is typed as Optional. Note that Optional is sugar for Union[x, None]."""
return typing.get_origin(value) is typing.Union and type(None) in typing.get_args(value)
+23
View File
@@ -0,0 +1,23 @@
from typing import Any
from pydantic import BaseModel
"""
We want to exclude null values from objects that make their way to the client.
Unfortunately there is no built-in way to do this in pydantic, so we need to override the default
dict method to do this.
From https://github.com/tiangolo/fastapi/discussions/8882#discussioncomment-5154541
"""
class BaseModelExcludeNull(BaseModel):
def model_dump(self, *args, **kwargs) -> dict[str, Any]:
"""
Override the default dict method to exclude None values in the response
"""
kwargs.pop("exclude_none", None)
return super().model_dump(*args, exclude_none=True, **kwargs)
pass
+67
View File
@@ -0,0 +1,67 @@
import cProfile
from logging import Logger
from pathlib import Path
from typing import Optional
class Profiler:
"""
Simple wrapper around cProfile.
Usage
```
# Create a profiler
profiler = Profiler(logger, output_dir, "sql_query_perf")
# Start a new profile
profiler.start("my_profile")
# Do stuff
profiler.stop()
```
Visualize a profile as a flamegraph with [snakeviz](https://jiffyclub.github.io/snakeviz/)
```sh
snakeviz my_profile.prof
```
Visualize a profile as directed graph with [graphviz](https://graphviz.org/download/) & [gprof2dot](https://github.com/jrfonseca/gprof2dot)
```sh
gprof2dot -f pstats my_profile.prof | dot -Tpng -o my_profile.png
# SVG or PDF may be nicer - you can search for function names
gprof2dot -f pstats my_profile.prof | dot -Tsvg -o my_profile.svg
gprof2dot -f pstats my_profile.prof | dot -Tpdf -o my_profile.pdf
```
"""
def __init__(self, logger: Logger, output_dir: Path, prefix: Optional[str] = None) -> None:
self._logger = logger.getChild(f"profiler.{prefix}" if prefix else "profiler")
self._output_dir = output_dir
self._output_dir.mkdir(parents=True, exist_ok=True)
self._profiler: Optional[cProfile.Profile] = None
self._prefix = prefix
self.profile_id: Optional[str] = None
def start(self, profile_id: str) -> None:
if self._profiler:
self.stop()
self.profile_id = profile_id
self._profiler = cProfile.Profile()
self._profiler.enable()
self._logger.info(f"Started profiling {self.profile_id}.")
def stop(self) -> Path:
if not self._profiler:
raise RuntimeError("Profiler not initialized. Call start() first.")
self._profiler.disable()
filename = f"{self._prefix}_{self.profile_id}.prof" if self._prefix else f"{self.profile_id}.prof"
path = Path(self._output_dir, filename)
self._profiler.dump_stats(path)
self._logger.info(f"Stopped profiling, profile dumped to {path}.")
self._profiler = None
self.profile_id = None
return path
+74
View File
@@ -0,0 +1,74 @@
import logging
import mimetypes
import socket
from pathlib import Path
import torch
def find_open_port(port: int) -> int:
"""Find a port not in use starting at given port"""
# Taken from https://waylonwalker.com/python-find-available-port/, thanks Waylon!
# https://github.com/WaylonWalker
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.settimeout(1)
if s.connect_ex(("localhost", port)) == 0:
return find_open_port(port=port + 1)
else:
return port
def check_cudnn(logger: logging.Logger) -> None:
"""Check for cuDNN issues that could be causing degraded performance."""
if torch.backends.cudnn.is_available():
try:
# Note: At the time of writing (torch 2.2.1), torch.backends.cudnn.version() only raises an error the first
# time it is called. Subsequent calls will return the version number without complaining about a mismatch.
cudnn_version = torch.backends.cudnn.version()
logger.info(f"cuDNN version: {cudnn_version}")
except RuntimeError as e:
logger.warning(
"Encountered a cuDNN version issue. This may result in degraded performance. This issue is usually "
"caused by an incompatible cuDNN version installed in your python environment, or on the host "
f"system. Full error message:\n{e}"
)
def invokeai_source_dir() -> Path:
# `invokeai.__file__` doesn't always work for editable installs
this_module_path = Path(__file__).resolve()
# https://youtrack.jetbrains.com/issue/PY-38382/Unresolved-reference-spec-but-this-is-standard-builtin
# noinspection PyUnresolvedReferences
depth = len(__spec__.parent.split("."))
return this_module_path.parents[depth - 1]
def enable_dev_reload(custom_nodes_path=None) -> None:
"""Enable hot reloading on python file changes during development."""
from invokeai.backend.util.logging import InvokeAILogger
try:
import jurigged
except ImportError as e:
raise RuntimeError(
'Can\'t start `--dev_reload` because jurigged is not found; `pip install -e ".[dev]"` to include development dependencies.'
) from e
else:
paths = [str(invokeai_source_dir() / "*.py")]
if custom_nodes_path:
paths.append(str(custom_nodes_path / "*.py"))
jurigged.watch(pattern=paths, logger=InvokeAILogger.get_logger(name="jurigged").info)
def apply_monkeypatches() -> None:
"""Apply monkeypatches to fix issues with third-party libraries."""
import invokeai.backend.util.hotfixes # noqa: F401 (monkeypatching on import)
def register_mime_types() -> None:
"""Register additional mime types for windows."""
# Fix for windows mimetypes registry entries being borked.
# see https://github.com/invoke-ai/InvokeAI/discussions/3684#discussioncomment-6391352
mimetypes.add_type("application/javascript", ".js")
mimetypes.add_type("text/css", ".css")
+294
View File
@@ -0,0 +1,294 @@
from math import floor
from typing import Callable, Optional, TypeAlias
import torch
from PIL import Image
from invokeai.app.services.session_processor.session_processor_common import CanceledException
from invokeai.backend.model_manager.taxonomy import BaseModelType
from invokeai.backend.stable_diffusion.diffusers_pipeline import PipelineIntermediateState
# See scripts/generate_vae_linear_approximation.py for generating these factors.
# fast latents preview matrix for sdxl
# generated by @StAlKeR7779
SDXL_LATENT_RGB_FACTORS = [
# R G B
[0.3816, 0.4930, 0.5320],
[-0.3753, 0.1631, 0.1739],
[0.1770, 0.3588, -0.2048],
[-0.4350, -0.2644, -0.4289],
]
SDXL_SMOOTH_MATRIX = [
[0.0358, 0.0964, 0.0358],
[0.0964, 0.4711, 0.0964],
[0.0358, 0.0964, 0.0358],
]
# origingally adapted from code by @erucipe and @keturn here:
# https://discuss.huggingface.co/t/decoding-latents-to-rgb-without-upscaling/23204/7
# these updated numbers for v1.5 are from @torridgristle
SD1_5_LATENT_RGB_FACTORS = [
# R G B
[0.3444, 0.1385, 0.0670], # L1
[0.1247, 0.4027, 0.1494], # L2
[-0.3192, 0.2513, 0.2103], # L3
[-0.1307, -0.1874, -0.7445], # L4
]
SD3_5_LATENT_RGB_FACTORS = [
[-0.05240681, 0.03251581, 0.0749016],
[-0.0580572, 0.00759826, 0.05729818],
[0.16144888, 0.01270368, -0.03768577],
[0.14418615, 0.08460266, 0.15941818],
[0.04894035, 0.0056485, -0.06686988],
[0.05187166, 0.19222395, 0.06261094],
[0.1539433, 0.04818359, 0.07103094],
[-0.08601796, 0.09013458, 0.10893912],
[-0.12398469, -0.06766567, 0.0033688],
[-0.0439737, 0.07825329, 0.02258823],
[0.03101129, 0.06382551, 0.07753657],
[-0.01315361, 0.08554491, -0.08772475],
[0.06464487, 0.05914605, 0.13262741],
[-0.07863674, -0.02261737, -0.12761454],
[-0.09923835, -0.08010759, -0.06264447],
[-0.03392309, -0.0804029, -0.06078822],
]
FLUX_LATENT_RGB_FACTORS = [
[-0.0412, 0.0149, 0.0521],
[0.0056, 0.0291, 0.0768],
[0.0342, -0.0681, -0.0427],
[-0.0258, 0.0092, 0.0463],
[0.0863, 0.0784, 0.0547],
[-0.0017, 0.0402, 0.0158],
[0.0501, 0.1058, 0.1152],
[-0.0209, -0.0218, -0.0329],
[-0.0314, 0.0083, 0.0896],
[0.0851, 0.0665, -0.0472],
[-0.0534, 0.0238, -0.0024],
[0.0452, -0.0026, 0.0048],
[0.0892, 0.0831, 0.0881],
[-0.1117, -0.0304, -0.0789],
[0.0027, -0.0479, -0.0043],
[-0.1146, -0.0827, -0.0598],
]
COGVIEW4_LATENT_RGB_FACTORS = [
[0.00408832, -0.00082485, -0.00214816],
[0.00084172, 0.00132241, 0.00842067],
[-0.00466737, -0.00983181, -0.00699561],
[0.03698397, -0.04797235, 0.03585809],
[0.00234701, -0.00124326, 0.00080869],
[-0.00723903, -0.00388422, -0.00656606],
[-0.00970917, -0.00467356, -0.00971113],
[0.17292486, -0.03452463, -0.1457515],
[0.02330308, 0.02942557, 0.02704329],
[-0.00903131, -0.01499841, -0.01432564],
[0.01250298, 0.0019407, -0.02168986],
[0.01371188, 0.00498283, -0.01302135],
[0.42396525, 0.4280575, 0.42148206],
[0.00983825, 0.00613302, 0.00610316],
[0.00473307, -0.00889551, -0.00915924],
[-0.00955853, -0.00980067, -0.00977842],
]
# Qwen Image uses the same VAE as Wan 2.1 (16-channel).
# Factors from ComfyUI: https://github.com/comfyanonymous/ComfyUI/blob/master/comfy/latent_formats.py
QWEN_IMAGE_LATENT_RGB_FACTORS = [
[-0.1299, -0.1692, 0.2932],
[0.0671, 0.0406, 0.0442],
[0.3568, 0.2548, 0.1747],
[0.0372, 0.2344, 0.1420],
[0.0313, 0.0189, -0.0328],
[0.0296, -0.0956, -0.0665],
[-0.3477, -0.4059, -0.2925],
[0.0166, 0.1902, 0.1975],
[-0.0412, 0.0267, -0.1364],
[-0.1293, 0.0740, 0.1636],
[0.0680, 0.3019, 0.1128],
[0.0032, 0.0581, 0.0639],
[-0.1251, 0.0927, 0.1699],
[0.0060, -0.0633, 0.0005],
[0.3477, 0.2275, 0.2950],
[0.1984, 0.0913, 0.1861],
]
QWEN_IMAGE_LATENT_RGB_BIAS = [-0.1835, -0.0868, -0.3360]
# FLUX.2 uses 32 latent channels.
# Factors from ComfyUI: https://github.com/Comfy-Org/ComfyUI/blob/main/comfy/latent_formats.py
FLUX2_LATENT_RGB_FACTORS = [
# R G B
[0.0058, 0.0113, 0.0073],
[0.0495, 0.0443, 0.0836],
[-0.0099, 0.0096, 0.0644],
[0.2144, 0.3009, 0.3652],
[0.0166, -0.0039, -0.0054],
[0.0157, 0.0103, -0.0160],
[-0.0398, 0.0902, -0.0235],
[-0.0052, 0.0095, 0.0109],
[-0.3527, -0.2712, -0.1666],
[-0.0301, -0.0356, -0.0180],
[-0.0107, 0.0078, 0.0013],
[0.0746, 0.0090, -0.0941],
[0.0156, 0.0169, 0.0070],
[-0.0034, -0.0040, -0.0114],
[0.0032, 0.0181, 0.0080],
[-0.0939, -0.0008, 0.0186],
[0.0018, 0.0043, 0.0104],
[0.0284, 0.0056, -0.0127],
[-0.0024, -0.0022, -0.0030],
[0.1207, -0.0026, 0.0065],
[0.0128, 0.0101, 0.0142],
[0.0137, -0.0072, -0.0007],
[0.0095, 0.0092, -0.0059],
[0.0000, -0.0077, -0.0049],
[-0.0465, -0.0204, -0.0312],
[0.0095, 0.0012, -0.0066],
[0.0290, -0.0034, 0.0025],
[0.0220, 0.0169, -0.0048],
[-0.0332, -0.0457, -0.0468],
[-0.0085, 0.0389, 0.0609],
[-0.0076, 0.0003, -0.0043],
[-0.0111, -0.0460, -0.0614],
]
FLUX2_LATENT_RGB_BIAS = [-0.0329, -0.0718, -0.0851]
# Anima uses Wan 2.1 VAE with 16 latent channels.
# Factors from ComfyUI: https://github.com/Comfy-Org/ComfyUI/blob/main/comfy/latent_formats.py
ANIMA_LATENT_RGB_FACTORS = [
[-0.1299, -0.1692, 0.2932],
[0.0671, 0.0406, 0.0442],
[0.3568, 0.2548, 0.1747],
[0.0372, 0.2344, 0.1420],
[0.0313, 0.0189, -0.0328],
[0.0296, -0.0956, -0.0665],
[-0.3477, -0.4059, -0.2925],
[0.0166, 0.1902, 0.1975],
[-0.0412, 0.0267, -0.1364],
[-0.1293, 0.0740, 0.1636],
[0.0680, 0.3019, 0.1128],
[0.0032, 0.0581, 0.0639],
[-0.1251, 0.0927, 0.1699],
[0.0060, -0.0633, 0.0005],
[0.3477, 0.2275, 0.2950],
[0.1984, 0.0913, 0.1861],
]
ANIMA_LATENT_RGB_BIAS = [-0.1835, -0.0868, -0.3360]
def sample_to_lowres_estimated_image(
samples: torch.Tensor,
latent_rgb_factors: torch.Tensor,
smooth_matrix: Optional[torch.Tensor] = None,
latent_rgb_bias: Optional[torch.Tensor] = None,
):
if samples.dim() == 4:
samples = samples[0]
latent_image = samples.permute(1, 2, 0) @ latent_rgb_factors
if latent_rgb_bias is not None:
latent_image = latent_image + latent_rgb_bias
if smooth_matrix is not None:
latent_image = latent_image.unsqueeze(0).permute(3, 0, 1, 2)
latent_image = torch.nn.functional.conv2d(latent_image, smooth_matrix.reshape((1, 1, 3, 3)), padding=1)
latent_image = latent_image.permute(1, 2, 3, 0).squeeze(0)
latents_ubyte = (
((latent_image + 1) / 2).clamp(0, 1).mul(0xFF).byte() # change scale from -1..1 to 0..1 # to 0..255
).cpu()
return Image.fromarray(latents_ubyte.numpy())
def calc_percentage(intermediate_state: PipelineIntermediateState) -> float:
"""Calculate the percentage of completion of denoising."""
step = intermediate_state.step
total_steps = intermediate_state.total_steps
order = intermediate_state.order
if total_steps == 0:
return 0.0
if order == 2:
# Prevent division by zero when total_steps is 1 or 2
denominator = floor(total_steps / 2)
if denominator == 0:
return 0.0
return floor(step / 2) / denominator
# order == 1
return step / total_steps
SignalProgressFunc: TypeAlias = Callable[[str, float | None, Image.Image | None, tuple[int, int] | None], None]
def diffusion_step_callback(
signal_progress: SignalProgressFunc,
intermediate_state: PipelineIntermediateState,
base_model: BaseModelType,
is_canceled: Callable[[], bool],
) -> None:
if is_canceled():
raise CanceledException
# Some schedulers report not only the noisy latents at the current timestep,
# but also their estimate so far of what the de-noised latents will be. Use
# that estimate if it is available.
if intermediate_state.predicted_original is not None:
sample = intermediate_state.predicted_original
else:
sample = intermediate_state.latents
smooth_matrix: list[list[float]] | None = None
latent_rgb_bias: list[float] | None = None
if base_model in [BaseModelType.StableDiffusion1, BaseModelType.StableDiffusion2]:
latent_rgb_factors = SD1_5_LATENT_RGB_FACTORS
elif base_model in [BaseModelType.StableDiffusionXL, BaseModelType.StableDiffusionXLRefiner]:
latent_rgb_factors = SDXL_LATENT_RGB_FACTORS
smooth_matrix = SDXL_SMOOTH_MATRIX
elif base_model == BaseModelType.StableDiffusion3:
latent_rgb_factors = SD3_5_LATENT_RGB_FACTORS
elif base_model == BaseModelType.CogView4:
latent_rgb_factors = COGVIEW4_LATENT_RGB_FACTORS
elif base_model == BaseModelType.QwenImage:
latent_rgb_factors = QWEN_IMAGE_LATENT_RGB_FACTORS
latent_rgb_bias = QWEN_IMAGE_LATENT_RGB_BIAS
elif base_model == BaseModelType.Flux:
latent_rgb_factors = FLUX_LATENT_RGB_FACTORS
elif base_model == BaseModelType.Flux2:
latent_rgb_factors = FLUX2_LATENT_RGB_FACTORS
latent_rgb_bias = FLUX2_LATENT_RGB_BIAS
elif base_model == BaseModelType.ZImage:
# Z-Image uses FLUX-compatible VAE with 16 latent channels
latent_rgb_factors = FLUX_LATENT_RGB_FACTORS
elif base_model == BaseModelType.Anima:
# Anima uses Wan 2.1 VAE with 16 latent channels
latent_rgb_factors = ANIMA_LATENT_RGB_FACTORS
latent_rgb_bias = ANIMA_LATENT_RGB_BIAS
else:
raise ValueError(f"Unsupported base model: {base_model}")
latent_rgb_factors_torch = torch.tensor(latent_rgb_factors, dtype=sample.dtype, device=sample.device)
smooth_matrix_torch = (
torch.tensor(smooth_matrix, dtype=sample.dtype, device=sample.device) if smooth_matrix else None
)
latent_rgb_bias_torch = (
torch.tensor(latent_rgb_bias, dtype=sample.dtype, device=sample.device) if latent_rgb_bias else None
)
image = sample_to_lowres_estimated_image(
samples=sample,
latent_rgb_factors=latent_rgb_factors_torch,
smooth_matrix=smooth_matrix_torch,
latent_rgb_bias=latent_rgb_bias_torch,
)
width = image.width * 8
height = image.height * 8
percentage = calc_percentage(intermediate_state)
signal_progress("Denoising", percentage, image, (width, height))
+24
View File
@@ -0,0 +1,24 @@
import io
import sys
from typing import Any
class SuppressOutput:
"""Context manager to suppress stdout.
Example:
```
with SuppressOutput():
print("This will not be printed")
```
"""
def __enter__(self):
# Save the original stdout
self._original_stdout = sys.stdout
# Redirect stdout to a dummy StringIO object
sys.stdout = io.StringIO()
def __exit__(self, *args: Any, **kwargs: Any):
# Restore stdout
sys.stdout = self._original_stdout
+26
View File
@@ -0,0 +1,26 @@
from invokeai.app.invocations.model import ModelIdentifierField
from invokeai.backend.model_manager.taxonomy import BaseModelType, SubModelType
def preprocess_t5_encoder_model_identifier(model_identifier: ModelIdentifierField) -> ModelIdentifierField:
"""A helper function to normalize a T5 encoder model identifier so that T5 models associated with FLUX
or SD3 models can be used interchangeably.
"""
if model_identifier.base == BaseModelType.Any:
return model_identifier.model_copy(update={"submodel_type": SubModelType.TextEncoder2})
elif model_identifier.base == BaseModelType.StableDiffusion3:
return model_identifier.model_copy(update={"submodel_type": SubModelType.TextEncoder3})
else:
raise ValueError(f"Unsupported model base: {model_identifier.base}")
def preprocess_t5_tokenizer_model_identifier(model_identifier: ModelIdentifierField) -> ModelIdentifierField:
"""A helper function to normalize a T5 tokenizer model identifier so that T5 models associated with FLUX
or SD3 models can be used interchangeably.
"""
if model_identifier.base == BaseModelType.Any:
return model_identifier.model_copy(update={"submodel_type": SubModelType.Tokenizer2})
elif model_identifier.base == BaseModelType.StableDiffusion3:
return model_identifier.model_copy(update={"submodel_type": SubModelType.Tokenizer3})
else:
raise ValueError(f"Unsupported model base: {model_identifier.base}")
+16
View File
@@ -0,0 +1,16 @@
import os
from PIL import Image
def get_thumbnail_name(image_name: str) -> str:
"""Formats given an image name, returns the appropriate thumbnail image name"""
thumbnail_name = os.path.splitext(image_name)[0] + ".webp"
return thumbnail_name
def make_thumbnail(image: Image.Image, size: int = 256) -> Image.Image:
"""Makes a thumbnail from a PIL Image"""
thumbnail = image.copy()
thumbnail.thumbnail(size=(size, size))
return thumbnail
+47
View File
@@ -0,0 +1,47 @@
import re
from typing import List, Tuple
import invokeai.backend.util.logging as logger
from invokeai.app.services.model_records import UnknownModelException
from invokeai.app.services.shared.invocation_context import InvocationContext
from invokeai.backend.model_manager.taxonomy import BaseModelType, ModelType
from invokeai.backend.textual_inversion import TextualInversionModelRaw
def extract_ti_triggers_from_prompt(prompt: str) -> List[str]:
ti_triggers: List[str] = []
for trigger in re.findall(r"<[a-zA-Z0-9., _-]+>", prompt):
ti_triggers.append(str(trigger))
return ti_triggers
def generate_ti_list(
prompt: str, base: BaseModelType, context: InvocationContext
) -> List[Tuple[str, TextualInversionModelRaw]]:
ti_list: List[Tuple[str, TextualInversionModelRaw]] = []
for trigger in extract_ti_triggers_from_prompt(prompt):
name_or_key = trigger[1:-1]
try:
loaded_model = context.models.load(name_or_key)
model = loaded_model.model
assert isinstance(model, TextualInversionModelRaw)
assert loaded_model.config.base == base
ti_list.append((name_or_key, model))
except UnknownModelException:
try:
loaded_model = context.models.load_by_attrs(
name=name_or_key, base=base, type=ModelType.TextualInversion
)
model = loaded_model.model
assert isinstance(model, TextualInversionModelRaw)
assert loaded_model.config.base == base
ti_list.append((name_or_key, model))
except UnknownModelException:
pass
except ValueError:
logger.warning(f'trigger: "{trigger}" more than one similarly-named textual inversion models')
except AssertionError:
logger.warning(f'trigger: "{trigger}" not a valid textual inversion model for this graph')
except Exception:
logger.warning(f'Failed to load TI model for trigger: "{trigger}"')
return ti_list
+52
View File
@@ -0,0 +1,52 @@
import logging
import os
import sys
def configure_torch_cuda_allocator(pytorch_cuda_alloc_conf: str, logger: logging.Logger):
"""Configure the PyTorch CUDA memory allocator. See
https://pytorch.org/docs/stable/notes/cuda.html#optimizing-memory-usage-with-pytorch-cuda-alloc-conf for supported
configurations.
"""
if "torch" in sys.modules:
raise RuntimeError("configure_torch_cuda_allocator() must be called before importing torch.")
# Log a warning if the PYTORCH_CUDA_ALLOC_CONF environment variable is already set.
prev_cuda_alloc_conf = os.environ.get("PYTORCH_CUDA_ALLOC_CONF", None)
if prev_cuda_alloc_conf is not None:
if prev_cuda_alloc_conf == pytorch_cuda_alloc_conf:
logger.info(
f"PYTORCH_CUDA_ALLOC_CONF is already set to '{pytorch_cuda_alloc_conf}'. Skipping configuration."
)
return
else:
logger.warning(
f"Attempted to configure the PyTorch CUDA memory allocator with '{pytorch_cuda_alloc_conf}', but PYTORCH_CUDA_ALLOC_CONF is already set to "
f"'{prev_cuda_alloc_conf}'. Skipping configuration."
)
return
# Configure the PyTorch CUDA memory allocator.
# NOTE: It is important that this happens before torch is imported.
os.environ["PYTORCH_CUDA_ALLOC_CONF"] = pytorch_cuda_alloc_conf
import torch
# Relevant docs: https://pytorch.org/docs/stable/notes/cuda.html#optimizing-memory-usage-with-pytorch-cuda-alloc-conf
if not torch.cuda.is_available():
raise RuntimeError(
"Attempted to configure the PyTorch CUDA memory allocator, but no CUDA devices are available."
)
# Verify that the torch allocator was properly configured.
allocator_backend = torch.cuda.get_allocator_backend()
expected_backend = "cudaMallocAsync" if "cudaMallocAsync" in pytorch_cuda_alloc_conf else "native"
if allocator_backend != expected_backend:
raise RuntimeError(
f"Failed to configure the PyTorch CUDA memory allocator. Expected backend: '{expected_backend}', but got "
f"'{allocator_backend}'. Verify that 1) the pytorch_cuda_alloc_conf is set correctly, and 2) that torch is "
"not imported before calling configure_torch_cuda_allocator()."
)
logger.info(f"PyTorch CUDA memory allocator: {torch.cuda.get_allocator_backend()}")
+579
View File
@@ -0,0 +1,579 @@
"""User management command entry points for InvokeAI.
These functions are registered as console scripts in pyproject.toml and can be
called from the command line after installing the package:
invoke-useradd -- add a user
invoke-userdel -- delete a user
invoke-userlist -- list users
invoke-usermod -- modify a user
"""
import argparse
import getpass
import json
import os
import sys
_root_help = (
"Path to the InvokeAI root directory. If omitted, the root is resolved in this order: "
"the $INVOKEAI_ROOT environment variable, the active virtual environment's parent directory, "
"or $HOME/invokeai."
)
# ---------------------------------------------------------------------------
# useradd
# ---------------------------------------------------------------------------
def _add_user_interactive() -> bool:
"""Add a user interactively by prompting for details."""
from invokeai.app.services.auth.password_utils import validate_password_strength
from invokeai.app.services.config import get_config
from invokeai.app.services.shared.sqlite.sqlite_database import SqliteDatabase
from invokeai.app.services.users.users_common import UserCreateRequest
from invokeai.app.services.users.users_default import UserService
from invokeai.backend.util.logging import InvokeAILogger
print("=== Add InvokeAI User ===\n")
email = input("Email address: ").strip()
if not email:
print("Error: Email is required")
return False
display_name = input("Display name (optional): ").strip() or None
while True:
password = getpass.getpass("Password: ")
password_confirm = getpass.getpass("Confirm password: ")
if password != password_confirm:
print("Error: Passwords do not match. Please try again.\n")
continue
is_valid, error_msg = validate_password_strength(password)
if not is_valid:
print(f"Error: {error_msg}\n")
continue
break
is_admin_input = input("Make this user an administrator? (y/N): ").strip().lower()
is_admin = is_admin_input in ("y", "yes")
try:
config = get_config()
db = SqliteDatabase(config.db_path, InvokeAILogger.get_logger())
user_service = UserService(db)
user_data = UserCreateRequest(email=email, display_name=display_name, password=password, is_admin=is_admin)
user = user_service.create(user_data)
print("\n✅ User created successfully!")
print(f" User ID: {user.user_id}")
print(f" Email: {user.email}")
print(f" Display Name: {user.display_name or '(not set)'}")
print(f" Admin: {'Yes' if user.is_admin else 'No'}")
print(f" Active: {'Yes' if user.is_active else 'No'}")
return True
except ValueError as e:
print(f"\n❌ Error: {e}")
return False
except Exception as e:
print(f"\n❌ Unexpected error: {e}")
import traceback
traceback.print_exc()
return False
def _add_user_cli(email: str, password: str, display_name: str | None = None, is_admin: bool = False) -> bool:
"""Add a user via CLI arguments."""
from invokeai.app.services.auth.password_utils import validate_password_strength
from invokeai.app.services.config import get_config
from invokeai.app.services.shared.sqlite.sqlite_database import SqliteDatabase
from invokeai.app.services.users.users_common import UserCreateRequest
from invokeai.app.services.users.users_default import UserService
from invokeai.backend.util.logging import InvokeAILogger
is_valid, error_msg = validate_password_strength(password)
if not is_valid:
print(f"❌ Password validation failed: {error_msg}")
return False
try:
config = get_config()
db = SqliteDatabase(config.db_path, InvokeAILogger.get_logger())
user_service = UserService(db)
user_data = UserCreateRequest(email=email, display_name=display_name, password=password, is_admin=is_admin)
user = user_service.create(user_data)
print("✅ User created successfully!")
print(f" User ID: {user.user_id}")
print(f" Email: {user.email}")
print(f" Display Name: {user.display_name or '(not set)'}")
print(f" Admin: {'Yes' if user.is_admin else 'No'}")
print(f" Active: {'Yes' if user.is_active else 'No'}")
return True
except ValueError as e:
print(f"❌ Error: {e}")
return False
except Exception as e:
print(f"❌ Unexpected error: {e}")
import traceback
traceback.print_exc()
return False
def useradd() -> None:
"""Entry point for ``invoke-useradd``."""
parser = argparse.ArgumentParser(
description="Add a user to the InvokeAI database",
epilog="If no arguments are provided, the script will run in interactive mode.",
)
parser.add_argument("--root", "-r", help=_root_help)
parser.add_argument("--email", "-e", help="User email address")
parser.add_argument("--password", "-p", help="User password")
parser.add_argument("--name", "-n", help="User display name (optional)")
parser.add_argument("--admin", "-a", action="store_true", help="Make user an administrator")
args = parser.parse_args()
if args.root:
os.environ["INVOKEAI_ROOT"] = args.root
if args.email or args.password:
if not args.email or not args.password:
print("❌ Error: Both --email and --password are required when using CLI mode")
print(" Run without arguments for interactive mode")
sys.exit(1)
success = _add_user_cli(args.email, args.password, args.name, args.admin)
else:
success = _add_user_interactive()
sys.exit(0 if success else 1)
# ---------------------------------------------------------------------------
# userdel
# ---------------------------------------------------------------------------
def _delete_user_interactive() -> bool:
"""Delete a user interactively by prompting for email."""
from invokeai.app.services.config import get_config
from invokeai.app.services.shared.sqlite.sqlite_database import SqliteDatabase
from invokeai.app.services.users.users_default import UserService
from invokeai.backend.util.logging import InvokeAILogger
print("=== Delete InvokeAI User ===\n")
email = input("Email address of user to delete: ").strip()
if not email:
print("Error: Email is required")
return False
try:
config = get_config()
db = SqliteDatabase(config.db_path, InvokeAILogger.get_logger())
user_service = UserService(db)
user = user_service.get_by_email(email)
if not user:
print(f"\n❌ Error: No user found with email '{email}'")
return False
print("\nUser to delete:")
print(f" User ID: {user.user_id}")
print(f" Email: {user.email}")
print(f" Display Name: {user.display_name or '(not set)'}")
print(f" Admin: {'Yes' if user.is_admin else 'No'}")
print(f" Active: {'Yes' if user.is_active else 'No'}")
confirm = input("\n⚠️ Are you sure you want to delete this user? (yes/no): ").strip().lower()
if confirm not in ("yes", "y"):
print("Deletion cancelled.")
return False
user_service.delete(user.user_id)
print("\n✅ User deleted successfully!")
return True
except ValueError as e:
print(f"\n❌ Error: {e}")
return False
except Exception as e:
print(f"\n❌ Unexpected error: {e}")
import traceback
traceback.print_exc()
return False
def _delete_user_cli(email: str, force: bool = False) -> bool:
"""Delete a user via CLI arguments."""
from invokeai.app.services.config import get_config
from invokeai.app.services.shared.sqlite.sqlite_database import SqliteDatabase
from invokeai.app.services.users.users_default import UserService
from invokeai.backend.util.logging import InvokeAILogger
try:
config = get_config()
db = SqliteDatabase(config.db_path, InvokeAILogger.get_logger())
user_service = UserService(db)
user = user_service.get_by_email(email)
if not user:
print(f"❌ Error: No user found with email '{email}'")
return False
if not force:
print("User to delete:")
print(f" User ID: {user.user_id}")
print(f" Email: {user.email}")
print(f" Display Name: {user.display_name or '(not set)'}")
print(f" Admin: {'Yes' if user.is_admin else 'No'}")
print(f" Active: {'Yes' if user.is_active else 'No'}")
confirm = input("\n⚠️ Are you sure you want to delete this user? (yes/no): ").strip().lower()
if confirm not in ("yes", "y"):
print("Deletion cancelled.")
return False
user_service.delete(user.user_id)
print("✅ User deleted successfully!")
return True
except ValueError as e:
print(f"❌ Error: {e}")
return False
except Exception as e:
print(f"❌ Unexpected error: {e}")
import traceback
traceback.print_exc()
return False
def userdel() -> None:
"""Entry point for ``invoke-userdel``."""
parser = argparse.ArgumentParser(
description="Delete a user from the InvokeAI database",
epilog="If no arguments are provided, the script will run in interactive mode.",
)
parser.add_argument("--root", "-r", help=_root_help)
parser.add_argument("--email", "-e", help="User email address")
parser.add_argument("--force", "-f", action="store_true", help="Delete without confirmation prompt")
args = parser.parse_args()
if args.root:
os.environ["INVOKEAI_ROOT"] = args.root
if args.email:
success = _delete_user_cli(args.email, args.force)
else:
success = _delete_user_interactive()
sys.exit(0 if success else 1)
# ---------------------------------------------------------------------------
# userlist
# ---------------------------------------------------------------------------
def _list_users_table() -> bool:
"""List all users in a formatted table."""
from invokeai.app.services.config import get_config
from invokeai.app.services.shared.sqlite.sqlite_database import SqliteDatabase
from invokeai.app.services.users.users_default import UserService
from invokeai.backend.util.logging import InvokeAILogger
config = get_config()
logger = InvokeAILogger.get_logger(config=config)
db = SqliteDatabase(config.db_path, logger)
user_service = UserService(db)
try:
users = user_service.list_users()
if not users:
print("No users found in database.")
return True
print("\n=== InvokeAI Users ===\n")
print(f"{'User ID':<36} {'Email':<30} {'Display Name':<20} {'Admin':<8} {'Active':<8}")
print("-" * 108)
for user in users:
user_id = user.user_id
email = user.email[:29] if len(user.email) > 29 else user.email
raw_name = user.display_name or ""
name = raw_name[:19] if len(raw_name) > 19 else raw_name
is_admin = "Yes" if user.is_admin else "No"
is_active = "Yes" if user.is_active else "No"
print(f"{user_id:<36} {email:<30} {name:<20} {is_admin:<8} {is_active:<8}")
print(f"\nTotal users: {len(users)}")
return True
except Exception as e:
print(f"Error listing users: {e}")
return False
def _list_users_json() -> bool:
"""List all users in JSON format."""
from invokeai.app.services.config import get_config
from invokeai.app.services.shared.sqlite.sqlite_database import SqliteDatabase
from invokeai.app.services.users.users_default import UserService
from invokeai.backend.util.logging import InvokeAILogger
config = get_config()
logger = InvokeAILogger.get_logger(config=config)
db = SqliteDatabase(config.db_path, logger)
user_service = UserService(db)
try:
users = user_service.list_users()
users_data = [
{
"id": user.user_id,
"email": user.email,
"name": user.display_name,
"is_admin": user.is_admin,
"is_active": user.is_active,
}
for user in users
]
print(json.dumps(users_data, indent=2))
return True
except Exception as e:
print(f'{{"error": "{e}"}}', file=sys.stderr)
return False
def userlist() -> None:
"""Entry point for ``invoke-userlist``."""
parser = argparse.ArgumentParser(
description="List users from the InvokeAI database",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
invoke-userlist
invoke-userlist --json
""",
)
parser.add_argument("--root", "-r", help=_root_help)
parser.add_argument(
"--json",
action="store_true",
help="Output users in JSON format instead of table",
)
args = parser.parse_args()
if args.root:
os.environ["INVOKEAI_ROOT"] = args.root
success = _list_users_json() if args.json else _list_users_table()
sys.exit(0 if success else 1)
# ---------------------------------------------------------------------------
# usermod
# ---------------------------------------------------------------------------
def _modify_user_interactive() -> bool:
"""Modify a user interactively by prompting for details."""
from invokeai.app.services.auth.password_utils import validate_password_strength
from invokeai.app.services.config import get_config
from invokeai.app.services.shared.sqlite.sqlite_database import SqliteDatabase
from invokeai.app.services.users.users_common import UserUpdateRequest
from invokeai.app.services.users.users_default import UserService
from invokeai.backend.util.logging import InvokeAILogger
print("=== Modify InvokeAI User ===\n")
email = input("Email address of user to modify: ").strip()
if not email:
print("Error: Email is required")
return False
try:
config = get_config()
db = SqliteDatabase(config.db_path, InvokeAILogger.get_logger())
user_service = UserService(db)
user = user_service.get_by_email(email)
if not user:
print(f"\n❌ Error: No user found with email '{email}'")
return False
print("\nCurrent user details:")
print(f" User ID: {user.user_id}")
print(f" Email: {user.email}")
print(f" Display Name: {user.display_name or '(not set)'}")
print(f" Admin: {'Yes' if user.is_admin else 'No'}")
print(f" Active: {'Yes' if user.is_active else 'No'}")
print("\n--- What would you like to change? (leave blank to keep current value) ---\n")
new_name = input(f"New display name [{user.display_name or '(not set)'}]: ").strip()
display_name = new_name if new_name else None
change_password = input("Change password? (y/N): ").strip().lower()
password = None
if change_password in ("y", "yes"):
while True:
password = getpass.getpass("New password: ")
if not password:
print("Keeping existing password.")
password = None
break
password_confirm = getpass.getpass("Confirm new password: ")
if password != password_confirm:
print("Error: Passwords do not match. Please try again.\n")
continue
is_valid, error_msg = validate_password_strength(password)
if not is_valid:
print(f"Error: {error_msg}\n")
continue
break
change_admin = input("Change admin status? (y/N): ").strip().lower()
is_admin = None
if change_admin in ("y", "yes"):
is_admin_input = (
input(f"Make administrator? [current: {'Yes' if user.is_admin else 'No'}] (y/N): ").strip().lower()
)
is_admin = is_admin_input in ("y", "yes")
if display_name is None and password is None and is_admin is None:
print("\nNo changes requested. User not modified.")
return True
changes = UserUpdateRequest(display_name=display_name, password=password, is_admin=is_admin)
updated_user = user_service.update(user.user_id, changes)
print("\n✅ User updated successfully!")
print(f" User ID: {updated_user.user_id}")
print(f" Email: {updated_user.email}")
print(f" Display Name: {updated_user.display_name or '(not set)'}")
print(f" Admin: {'Yes' if updated_user.is_admin else 'No'}")
print(f" Active: {'Yes' if updated_user.is_active else 'No'}")
return True
except ValueError as e:
print(f"\n❌ Error: {e}")
return False
except Exception as e:
print(f"\n❌ Unexpected error: {e}")
import traceback
traceback.print_exc()
return False
def _modify_user_cli(
email: str,
display_name: str | None = None,
password: str | None = None,
is_admin: bool | None = None,
) -> bool:
"""Modify a user via CLI arguments."""
from invokeai.app.services.auth.password_utils import validate_password_strength
from invokeai.app.services.config import get_config
from invokeai.app.services.shared.sqlite.sqlite_database import SqliteDatabase
from invokeai.app.services.users.users_common import UserUpdateRequest
from invokeai.app.services.users.users_default import UserService
from invokeai.backend.util.logging import InvokeAILogger
if password is not None:
is_valid, error_msg = validate_password_strength(password)
if not is_valid:
print(f"❌ Password validation failed: {error_msg}")
return False
try:
config = get_config()
db = SqliteDatabase(config.db_path, InvokeAILogger.get_logger())
user_service = UserService(db)
user = user_service.get_by_email(email)
if not user:
print(f"❌ Error: No user found with email '{email}'")
return False
if display_name is None and password is None and is_admin is None:
print("❌ Error: No changes specified. Use --name, --password, --admin, or --no-admin")
return False
changes = UserUpdateRequest(display_name=display_name, password=password, is_admin=is_admin)
updated_user = user_service.update(user.user_id, changes)
print("✅ User updated successfully!")
print(f" User ID: {updated_user.user_id}")
print(f" Email: {updated_user.email}")
print(f" Display Name: {updated_user.display_name or '(not set)'}")
print(f" Admin: {'Yes' if updated_user.is_admin else 'No'}")
print(f" Active: {'Yes' if updated_user.is_active else 'No'}")
return True
except ValueError as e:
print(f"❌ Error: {e}")
return False
except Exception as e:
print(f"❌ Unexpected error: {e}")
import traceback
traceback.print_exc()
return False
def usermod() -> None:
"""Entry point for ``invoke-usermod``."""
parser = argparse.ArgumentParser(
description="Modify a user in the InvokeAI database",
epilog="If no arguments are provided, the script will run in interactive mode.",
)
parser.add_argument("--root", "-r", help=_root_help)
parser.add_argument("--email", "-e", help="User email address")
parser.add_argument("--name", "-n", help="New display name")
parser.add_argument("--password", "-p", help="New password")
admin_group = parser.add_mutually_exclusive_group()
admin_group.add_argument("--admin", "-a", action="store_true", help="Grant administrator privileges")
admin_group.add_argument("--no-admin", dest="no_admin", action="store_true", help="Remove administrator privileges")
args = parser.parse_args()
if args.root:
os.environ["INVOKEAI_ROOT"] = args.root
is_admin = None
if args.admin:
is_admin = True
elif args.no_admin:
is_admin = False
if args.email:
success = _modify_user_cli(args.email, args.name, args.password, is_admin)
else:
success = _modify_user_interactive()
sys.exit(0 if success else 1)