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
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:
@@ -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 unique‐color & 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 & one‐pixel‐edge 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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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))
|
||||
@@ -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
|
||||
@@ -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}")
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()}")
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user