chore: import upstream snapshot with attribution
build container image / cpu (push) Waiting to run
build container image / cuda (push) Waiting to run
build container image / rocm (push) Waiting to run
frontend tests / frontend-tests (push) Waiting to run
openapi checks / openapi-checks (push) Waiting to run
python tests / py3.12: macos-default (push) Waiting to run
python tests / py3.11: windows-cpu (push) Waiting to run
python tests / py3.12: windows-cpu (push) Waiting to run
python tests / py3.11: linux-cpu (push) Waiting to run
python tests / py3.12: linux-cpu (push) Waiting to run
typegen checks / typegen-checks (push) Waiting to run
uv lock checks / uv-lock-checks (push) Waiting to run
frontend checks / frontend-checks (push) Waiting to run
lfs checks / lfs-check (push) Waiting to run
python checks / python-checks (push) Waiting to run
python tests / py3.11: macos-default (push) Waiting to run
docs / deploy (push) Has been cancelled
docs / changes (push) Has been cancelled
docs / check-and-build (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
+63
View File
@@ -0,0 +1,63 @@
import argparse
import torch
def display_vram_usage():
"""Displays the total, allocated, and free VRAM on the current CUDA device."""
assert torch.cuda.is_available(), "CUDA is not available"
device = torch.device("cuda")
total_vram = torch.cuda.get_device_properties(device).total_memory
allocated_vram = torch.cuda.memory_allocated(device)
free_vram = total_vram - allocated_vram
print(f"Total VRAM: {total_vram / (1024 * 1024 * 1024):.2f} GB")
print(f"Allocated VRAM: {allocated_vram / (1024 * 1024 * 1024):.2f} GB")
print(f"Free VRAM: {free_vram / (1024 * 1024 * 1024):.2f} GB")
def allocate_vram(target_gb: float, target_free: bool = False):
"""Allocates VRAM on the current CUDA device. After allocation, the script will pause until the user presses Enter
or ends the script, at which point the VRAM will be released.
Args:
target_gb (float): Amount of VRAM to allocate in GB.
target_free (bool, optional): Instead of allocating <target_gb> VRAM, enough VRAM will be allocated so the system has <target_gb> of VRAM free. For example, if <target_gb> is 2 GB, the script will allocate VRAM until the free VRAM is 2 GB.
"""
assert torch.cuda.is_available(), "CUDA is not available"
device = torch.device("cuda")
if target_free:
total_vram = torch.cuda.get_device_properties(device).total_memory
free_vram = total_vram - torch.cuda.memory_allocated(device)
target_free_bytes = target_gb * 1024 * 1024 * 1024
bytes_to_allocate = free_vram - target_free_bytes
if bytes_to_allocate <= 0:
print(f"Already at or below the target free VRAM of {target_gb} GB")
return
else:
bytes_to_allocate = target_gb * 1024 * 1024 * 1024
# FloatTensor (4 bytes per element)
_tensor = torch.empty(int(bytes_to_allocate / 4), dtype=torch.float, device="cuda")
display_vram_usage()
input("Press Enter to release VRAM allocation and exit...")
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Allocate VRAM for testing purposes. Only works on CUDA devices.")
parser.add_argument("target_gb", type=float, help="Amount of VRAM to allocate in GB.")
parser.add_argument(
"--target-free",
action="store_true",
help="Instead of allocating <target_gb> VRAM, enough VRAM will be allocated so the system has <target_gb> of VRAM free. For example, if <target_gb> is 2 GB, the script will allocate VRAM until the free VRAM is 2 GB.",
)
args = parser.parse_args()
allocate_vram(target_gb=args.target_gb, target_free=args.target_free)
+97
View File
@@ -0,0 +1,97 @@
#!/bin/bash
set -e
BCYAN="\033[1;36m"
BYELLOW="\033[1;33m"
BGREEN="\033[1;32m"
BRED="\033[1;31m"
RED="\033[31m"
RESET="\033[0m"
function git_show {
git show -s --format=oneline --abbrev-commit "$1" | cat
}
if [[ ! -z "${VIRTUAL_ENV}" ]]; then
# we can't just call 'deactivate' because this function is not exported
# to the environment of this script from the bash process that runs the script
echo -e "${BRED}A virtual environment is activated. Please deactivate it before proceeding.${RESET}"
exit -1
fi
cd "$(dirname "$0")"
VERSION=$(
cd ..
python3 -c "from invokeai.version import __version__ as version; print(version)"
)
VERSION="v${VERSION}"
if [[ ! -z ${CI} ]]; then
echo
echo -e "${BCYAN}CI environment detected${RESET}"
echo
fi
echo -e "${BGREEN}HEAD${RESET}:"
git_show HEAD
echo
# If the classifiers are invalid, publishing to PyPI will fail but the build will succeed.
# It's a fast check, do it early.
echo "Checking pyproject classifiers..."
python3 ./check_classifiers.py ../pyproject.toml
echo
# ---------------------- FRONTEND ----------------------
pushd ../invokeai/frontend/web >/dev/null
echo "Installing frontend dependencies..."
echo
pnpm i --frozen-lockfile
echo
if [[ ! -z ${CI} ]]; then
echo "Building frontend without checks..."
# In CI, we have already done the frontend checks and can just build
pnpm vite build
else
echo "Running checks and building frontend..."
# This runs all the frontend checks and builds
pnpm build
fi
echo
popd
# ---------------------- BACKEND ----------------------
echo
echo "Building wheel..."
echo
# install the 'build' package in the user site packages, if needed
# could be improved by using a temporary venv, but it's tiny and harmless
if [[ $(python3 -c 'from importlib.util import find_spec; print(find_spec("build") is None)') == "True" ]]; then
pip install --user build
fi
rm -rf ../build
python3 -m build --outdir ../dist/ ../.
echo -e "${BGREEN}Built PyPi distribution: ./dist${RESET}"
# clean up, but only if we are not in a github action
if [[ -z ${CI} ]]; then
echo
echo "Cleaning up intermediate build files..."
rm -rf InvokeAI-Installer tmp ../invokeai/frontend/web/dist/
fi
if [[ ! -z ${CI} ]]; then
echo
echo "Setting GitHub action outputs..."
echo "DIST_PATH=./dist/" >>$GITHUB_OUTPUT
fi
exit 0
@@ -0,0 +1,305 @@
"""Calibrate the Qwen Image VAE working-memory estimate against measured peak CUDA/HIP memory.
Background
----------
``estimate_vae_working_memory_qwen_image`` models peak working memory as a linear function of
spatial area::
working_memory = h * w * element_size * scaling_constant
This script measures the *actual* peak reserved memory the VAE consumes during decode/encode across
a grid of resolutions so the ``scaling_constant`` can be fit from several points instead of one, and
so we can check whether the pure-linear model holds or whether a super-linear (attention) term
appears at high resolution.
The estimate is consumed by the model cache via ``free >= estimate`` to decide whether to evict, so
it MUST be an upper bound: we measure peak *reserved* (not just allocated) memory, the conservative
quantity that includes caching-allocator overhead and kernel scratch/workspace.
Portability
-----------
Backend-agnostic: uses only ``torch.cuda.*``, which works on both NVIDIA/CUDA and AMD/ROCm (HIP)
builds of PyTorch. Run the SAME script on each backend and compare the ``implied_constant`` columns
-- the curve *shape* (linear vs. super-linear) is architectural and should match, but the absolute
constant can differ (cuDNN vs. MIOpen conv workspaces, flash-attention availability, allocator
rounding). Ship ``max`` across backends plus headroom.
Each (operation, resolution) point is measured in a FRESH SUBPROCESS so the caching allocator's
fragmentation history from earlier points cannot contaminate the reserved-delta reading. A point
that OOMs is recorded as ``oom`` rather than aborting the run, so the grid can probe up to the
card's ceiling safely.
Usage
-----
python scripts/calibrate_qwen_vae_working_memory.py [--vae /path/to/vae_dir] [--csv out.csv]
If ``--vae`` is omitted, the script auto-discovers an ``AutoencoderKLQwenImage`` under
``$INVOKEAI_ROOT/models``.
"""
import argparse
import json
import os
import subprocess
import sys
from pathlib import Path
import torch
from diffusers.models.autoencoders.autoencoder_kl_qwenimage import AutoencoderKLQwenImage
LATENT_SCALE_FACTOR = 8
# (height, width) pixel-space resolutions. Squares to test linearity in area, plus non-square
# points (incl. the original 1248x832 calibration point) to confirm area = h*w is the right
# predictor rather than max(h, w) or perimeter. Subprocess isolation + OOM capture means we can
# list aggressive resolutions; ones that don't fit are simply recorded as oom.
DEFAULT_RESOLUTIONS = [
(512, 512),
(768, 768),
(832, 1248), # original single calibration point (as HxW)
(1024, 1024),
(1088, 1920),
(1280, 1280),
(1536, 1024),
(1536, 1536),
(1792, 1792),
(2048, 2048),
]
def discover_vae() -> Path:
"""Find an AutoencoderKLQwenImage VAE directory under $INVOKEAI_ROOT/models."""
root = os.environ.get("INVOKEAI_ROOT")
if not root:
raise SystemExit("INVOKEAI_ROOT not set; pass --vae explicitly.")
models = Path(root) / "models"
for config_path in models.glob("*/vae/config.json"):
try:
cfg = json.loads(config_path.read_text())
except Exception:
continue
if cfg.get("_class_name") == "AutoencoderKLQwenImage":
return config_path.parent
raise SystemExit(f"No AutoencoderKLQwenImage VAE found under {models}; pass --vae explicitly.")
DTYPES = {"float16": torch.float16, "bfloat16": torch.bfloat16, "float32": torch.float32}
def _load_vae(vae_path: str, dtype: torch.dtype) -> AutoencoderKLQwenImage:
"""Load an AutoencoderKLQwenImage from either a diffusers directory or a single .safetensors file.
Directory: standard ``from_pretrained``.
Single file: ``AutoencoderKLQwenImage`` has no single-file converter registered in diffusers,
so we instantiate with the default config and load the state dict directly. Two on-disk layouts
exist: the diffusers layout (``encoder.conv_in`` / ``down_blocks`` / ``mid_block`` keys, e.g. the
weights InvokeAI's VAELoader consumes) and the original Qwen-Image/Wan release layout
(``encoder.conv1`` / ``downsamples`` / ``middle`` / ``time_conv`` keys). We try a direct strict
load first, and on a key mismatch fall back to diffusers' Wan VAE converter -- the Qwen-Image VAE
shares the Wan VAE key structure -- before retrying.
"""
path = Path(vae_path)
if not path.is_file():
return AutoencoderKLQwenImage.from_pretrained(vae_path, local_files_only=True, torch_dtype=dtype)
from safetensors.torch import load_file
sd = load_file(str(path))
for k in list(sd.keys()):
if sd[k].is_floating_point():
sd[k] = sd[k].to(dtype)
vae = AutoencoderKLQwenImage()
try:
# diffusers-layout checkpoint: keys already match the model. State dict was converted to
# `dtype` above and is assigned in place, so params carry the correct dtype.
vae.load_state_dict(sd, strict=True, assign=True)
except RuntimeError:
# Original Qwen-Image/Wan release layout: convert keys to the diffusers layout, then retry.
from diffusers.loaders.single_file_utils import convert_wan_vae_to_diffusers
converted = convert_wan_vae_to_diffusers(sd)
for k in list(converted.keys()):
if converted[k].is_floating_point():
converted[k] = converted[k].to(dtype)
vae.load_state_dict(converted, strict=True, assign=True)
return vae
def _build_input(operation: str, h: int, w: int, z_dim: int, dtype: torch.dtype) -> torch.Tensor:
"""Construct the 5D (B, C, num_frames, H, W) input the invocation feeds the VAE.
decode: latents at latent resolution (H/8, W/8) with z_dim channels.
encode: image at pixel resolution (H, W) with 3 channels.
These mirror QwenImageLatentsToImageInvocation / QwenImageImageToLatentsInvocation exactly.
"""
device = torch.device("cuda")
if operation == "decode":
return torch.randn(1, z_dim, 1, h // LATENT_SCALE_FACTOR, w // LATENT_SCALE_FACTOR, device=device, dtype=dtype)
return torch.randn(1, 3, 1, h, w, device=device, dtype=dtype)
@torch.inference_mode()
def measure_one(vae_path: str, operation: str, h: int, w: int, dtype: torch.dtype) -> dict:
"""Measure peak reserved-memory growth for a single decode/encode. Runs in a child process."""
vae = _load_vae(vae_path, dtype)
vae.to("cuda")
vae.disable_tiling() # Qwen invocations never tile; match that.
param = next(vae.parameters())
dtype = param.dtype
element_size = param.element_size()
z_dim = int(vae.config.z_dim)
x = _build_input(operation, h, w, z_dim, dtype)
torch.cuda.synchronize()
torch.cuda.empty_cache()
torch.cuda.reset_peak_memory_stats()
baseline_reserved = torch.cuda.memory_reserved()
# Measure the COLD first call -- it includes conv-algorithm-search / attention workspace
# allocation, which is exactly what the real (single-shot) invocation pays.
try:
if operation == "decode":
vae.decode(x, return_dict=False)
else:
vae.encode(x).latent_dist.mode()
torch.cuda.synchronize()
except (torch.cuda.OutOfMemoryError, RuntimeError) as e:
if "out of memory" not in str(e).lower():
raise
return {"operation": operation, "h": h, "w": w, "oom": True}
peak_reserved = torch.cuda.max_memory_reserved()
peak_allocated = torch.cuda.max_memory_allocated()
reserved_delta = peak_reserved - baseline_reserved
area = h * w
return {
"operation": operation,
"h": h,
"w": w,
"area": area,
"element_size": element_size,
"dtype": str(dtype),
"reserved_delta": reserved_delta,
"allocated_peak": peak_allocated,
"reserved_baseline": baseline_reserved,
# The constant as the estimator parameterizes it: mem = area * element_size * k
"implied_constant": reserved_delta / (area * element_size),
"oom": False,
}
def run_grid(vae_path: str, resolutions: list[tuple[int, int]], dtype_name: str, csv_path: Path | None) -> None:
rows: list[dict] = []
print(f"VAE: {vae_path}")
print(
f"torch {torch.__version__} | device {torch.cuda.get_device_name(0)} | hip={torch.version.hip} | dtype={dtype_name}\n"
)
print(f"{'op':6} {'HxW':>11} {'area':>10} {'reserved(GiB)':>14} {'alloc(GiB)':>11} {'implied_k':>10}")
print("-" * 70)
for operation in ("decode", "encode"):
for h, w in resolutions:
# Fresh subprocess per point for an uncontaminated reserved-memory reading.
proc = subprocess.run(
[
sys.executable,
__file__,
"--single",
operation,
str(h),
str(w),
"--vae",
vae_path,
"--dtype",
dtype_name,
],
capture_output=True,
text=True,
)
line = proc.stdout.strip().splitlines()[-1] if proc.stdout.strip() else ""
try:
row = json.loads(line)
except Exception:
print(f"{operation:6} {f'{h}x{w}':>11} FAILED: {proc.stderr.strip().splitlines()[-1:]}")
continue
rows.append(row)
if row.get("oom"):
print(f"{operation:6} {f'{h}x{w}':>11} {h * w:>10} {'OOM':>14}")
continue
gib = 1024**3
print(
f"{operation:6} {f'{h}x{w}':>11} {row['area']:>10} "
f"{row['reserved_delta'] / gib:>14.3f} {row['allocated_peak'] / gib:>11.3f} "
f"{row['implied_constant']:>10.1f}"
)
# Summary: the shippable constant is the MAX implied constant over fitting points (upper bound).
print("\n=== summary (max implied constant = candidate scaling_constant, before headroom) ===")
for operation in ("decode", "encode"):
ks = [r["implied_constant"] for r in rows if r["operation"] == operation and not r.get("oom")]
if ks:
print(
f"{operation:6}: n={len(ks)} min_k={min(ks):.1f} max_k={max(ks):.1f} "
f"-> use >= {max(ks):.0f} (+headroom)"
)
if csv_path:
import csv
fieldnames = [
"operation",
"h",
"w",
"area",
"element_size",
"dtype",
"reserved_delta",
"allocated_peak",
"reserved_baseline",
"implied_constant",
"oom",
]
with csv_path.open("w", newline="") as f:
writer = csv.DictWriter(f, fieldnames=fieldnames, extrasaction="ignore")
writer.writeheader()
for r in rows:
writer.writerow(r)
print(f"\nWrote {csv_path}")
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument(
"--vae",
type=str,
default=None,
help="Path to an AutoencoderKLQwenImage diffusers dir OR a single .safetensors checkpoint.",
)
parser.add_argument("--csv", type=str, default=None, help="Optional path to write the raw results as CSV.")
parser.add_argument(
"--dtype",
choices=list(DTYPES),
default="float16",
help="Compute dtype. Default float16 to match InvokeAI's default precision on CUDA/ROCm.",
)
# Internal: measure a single point in this process and print one JSON line.
parser.add_argument("--single", nargs=3, metavar=("OP", "H", "W"), default=None, help=argparse.SUPPRESS)
args = parser.parse_args()
vae_path = args.vae or str(discover_vae())
dtype = DTYPES[args.dtype]
if args.single:
op, h, w = args.single[0], int(args.single[1]), int(args.single[2])
print(json.dumps(measure_one(vae_path, op, h, w, dtype)))
return
run_grid(vae_path, DEFAULT_RESOLUTIONS, args.dtype, Path(args.csv) if args.csv else None)
if __name__ == "__main__":
main()
+48
View File
@@ -0,0 +1,48 @@
import re
import sys
import urllib.request
from pathlib import Path
# This script checks the classifiers in a pyproject.toml file against the official Trove classifier list.
# If the classifiers are invalid, PyPI will reject the package upload.
# Step 1: Get pyproject.toml path from args
if len(sys.argv) != 2:
print(f"Usage: {sys.argv[0]} path/to/pyproject.toml", file=sys.stderr)
sys.exit(1)
pyproject_path = Path(sys.argv[1])
if not pyproject_path.is_file():
print(f"File not found: {pyproject_path}", file=sys.stderr)
sys.exit(1)
# Step 1: Download the official Trove classifier list
url = "https://pypi.org/pypi?%3Aaction=list_classifiers"
with urllib.request.urlopen(url) as response:
trove_classifiers = {line.decode("utf-8").strip() for line in response}
# Step 2: Extract classifiers from pyproject.toml
with open(pyproject_path) as f:
content = f.read()
match = re.search(r"classifiers\s*=\s*\[([^\]]*)\]", content, re.MULTILINE | re.DOTALL)
if not match:
print("No 'classifiers' block found in pyproject.toml", file=sys.stderr)
sys.exit(1)
raw_block = match.group(1)
classifiers = [c.strip(" \"'\n") for c in raw_block.split(",") if c.strip()]
# Step 3: Check for invalid classifiers
invalid = [c for c in classifiers if c not in trove_classifiers]
if invalid:
print("❌ Invalid classifiers:")
for c in invalid:
print(f" - {c}")
print("Valid classifiers:")
for c in sorted(trove_classifiers):
print(f" - {c}")
sys.exit(1)
else:
print("✅ All classifiers are valid.")
+45
View File
@@ -0,0 +1,45 @@
#!/bin/env python
"""Little command-line utility for probing a model on disk."""
import argparse
from pathlib import Path
from typing import get_args
from invokeai.backend.model_hash.model_hash import HASHING_ALGORITHMS
from invokeai.backend.model_manager import InvalidModelConfigException, ModelProbe
from invokeai.backend.model_manager.configs.factory import ModelConfigFactory
algos = ", ".join(set(get_args(HASHING_ALGORITHMS)))
parser = argparse.ArgumentParser(description="Probe model type")
parser.add_argument(
"model_path",
type=Path,
nargs="+",
)
parser.add_argument(
"--hash_algo",
type=str,
default="blake3_single",
help=f"Hashing algorithm to use (default: blake3_single), one of: {algos}",
)
args = parser.parse_args()
def classify_with_fallback(path: Path, hash_algo: HASHING_ALGORITHMS):
try:
return ModelProbe.probe(path, hash_algo=hash_algo)
except InvalidModelConfigException:
return ModelConfigFactory.from_model_on_disk(
mod=path,
hash_algo=hash_algo,
)
for path in args.model_path:
try:
config = classify_with_fallback(path, args.hash_algo)
print(f"{path}:{config.model_dump_json(indent=4)}")
except InvalidModelConfigException as e:
print(e)
+30
View File
@@ -0,0 +1,30 @@
import argparse
import json
from safetensors.torch import load_file
def extract_sd_keys_and_shapes(safetensors_file: str):
sd = load_file(safetensors_file)
keys_to_shapes = {k: v.shape for k, v in sd.items()}
out_file = "keys_and_shapes.json"
with open(out_file, "w") as f:
json.dump(keys_to_shapes, f, indent=4)
print(f"Keys and shapes written to '{out_file}'.")
def main():
parser = argparse.ArgumentParser(
description="Extracts the keys and shapes from the state dict in a safetensors file. Intended for creating "
+ "dummy state dicts for use in unit tests."
)
parser.add_argument("safetensors_file", type=str, help="Path to the safetensors file.")
args = parser.parse_args()
extract_sd_keys_and_shapes(args.safetensors_file)
if __name__ == "__main__":
main()
+12
View File
@@ -0,0 +1,12 @@
#!/usr/bin/env python3
"""
gallery_maintenance.py
Remove orphan images from the gallery directory.
Remove orphan database entries for images that no longer exist in the gallery directory.
Regenerate missing thumbnail images.
"""
from invokeai.backend.util.gallery_maintenance import main
main()
+323
View File
@@ -0,0 +1,323 @@
from __future__ import annotations
import inspect
import json
import os
import re
from pathlib import Path
from typing import Any, Literal, cast, get_args, get_origin, get_type_hints
from pydantic.fields import FieldInfo
from invokeai.app.services.config.config_default import InvokeAIAppConfig
from invokeai.app.services.shared import invocation_context as invocation_context_module
OUTPUT_DIR = Path("docs/src/generated")
EXCLUDED_SETTINGS = {"schema_version", "legacy_models_yaml_path"}
INTERFACE_NAMES = (
"ImagesInterface",
"TensorsInterface",
"ConditioningInterface",
"ModelsInterface",
"LoggerInterface",
"ConfigInterface",
"UtilInterface",
"BoardsInterface",
)
def build_docs_bundle() -> dict[str, Any]:
return {
"invocation_context": build_invocation_context_export(),
"settings": build_settings_export(),
}
def _simplify_signature(sig: str) -> str:
"""Simplify a Python signature string for documentation display.
- Strips 'self' parameter
- Removes fully-qualified module paths (e.g. invokeai.backend.foo.Bar -> Bar)
- Collapses large Annotated[Union[...]] type blocks to AnyModelConfig
- Strips typing. prefixes
- Strips ForwardRef() wrappers
- Removes Discriminator(...) noise
"""
# Remove 'self' parameter
sig = re.sub(r"\(self(?:,\s*)?", "(", sig)
# Strip typing. prefix early so bracket-balancing can find patterns
sig = re.sub(r"\btyping\.", "", sig)
# Collapse any Annotated[Union[Annotated[..._Config, Tag(...)], ...], Discriminator(...)]
# These massive blocks are the AnyModelConfig discriminated union.
# Match from "Annotated[Union[Annotated[" through to the closing "]]" including Discriminator.
# We use a greedy approach: find the pattern start and then balance brackets.
result = sig
while True:
# Find the start of an AnyModelConfig union block
marker = "Annotated[Union[Annotated["
start = result.find(marker)
if start == -1:
break
# Find the balanced end - count brackets from 'Annotated[Union[...'
depth = 0
i = start
while i < len(result):
if result[i] == "[":
depth += 1
elif result[i] == "]":
depth -= 1
if depth == 0:
break
i += 1
# Replace the entire block
result = result[:start] + "AnyModelConfig" + result[i + 1 :]
sig = result
# Strip ForwardRef('...') -> just the name
sig = re.sub(r"ForwardRef\('([^']+)'\)", r"\1", sig)
# Strip fully-qualified module paths: some.module.path.ClassName -> ClassName
sig = re.sub(r"[a-z_][a-z0-9_.]*\.([A-Z][A-Za-z0-9_]*)", r"\1", sig)
# Clean up any remaining pathlib.Path -> Path
sig = sig.replace("pathlib.Path", "Path")
# Clean up PIL.Image.Image -> Image
sig = re.sub(r"PIL\.I[a-zA-Z.]*", "Image", sig)
# Collapse the AnyModel union type (used in loader callables)
sig = sig.replace(
"Union[ModelMixin, RawModel, Module, Dict[str, Tensor], DiffusionPipeline, InferenceSession]",
"AnyModel",
)
# Clean up raw enum reprs like <ImageCGENERAL: 'general'> -> ImageCategory.GENERAL
sig = re.sub(r"<ImageC([A-Z_]+):\s*'[^']*'>", r"ImageCategory.\1", sig)
return sig
def build_invocation_context_export() -> dict[str, Any]:
context_class = invocation_context_module.InvocationContext
interfaces: list[dict[str, Any]] = []
for interface_name in INTERFACE_NAMES:
interface_class = getattr(invocation_context_module, interface_name)
methods: list[dict[str, Any]] = []
for method_name, method in inspect.getmembers(interface_class, predicate=inspect.isfunction):
if method_name.startswith("_"):
continue
description, doc_parameters, returns = _parse_docstring(inspect.getdoc(method) or "")
sig = inspect.signature(method)
simplified_sig = _simplify_signature(str(sig))
# Build a lookup from docstring param descriptions
doc_param_map: dict[str, str] = {p["name"]: p["description"] for p in doc_parameters}
# Extract type and default from the actual signature, merge with docstring descriptions
parameters: list[dict[str, str]] = []
for param_name, param in sig.parameters.items():
if param_name == "self":
continue
# Format the type annotation
if param.annotation is not inspect.Parameter.empty:
param_type = _simplify_signature(inspect.formatannotation(param.annotation))
else:
param_type = ""
# Format the default value
if param.default is not inspect.Parameter.empty:
default_str = _simplify_signature(repr(param.default))
else:
default_str = ""
parameters.append(
{
"name": param_name,
"type": param_type,
"default": default_str,
"description": doc_param_map.get(param_name, ""),
}
)
# Format the return type
if sig.return_annotation is not inspect.Signature.empty:
return_type = _simplify_signature(inspect.formatannotation(sig.return_annotation))
else:
return_type = ""
methods.append(
{
"name": method_name,
"signature": simplified_sig,
"description": description,
"parameters": parameters,
"returns": returns,
"return_type": return_type,
}
)
interfaces.append(
{
"name": interface_name,
"description": inspect.getdoc(interface_class) or "",
"methods": methods,
}
)
return {
"name": context_class.__name__,
"description": inspect.getdoc(context_class) or "",
"interfaces": interfaces,
}
def build_settings_export() -> dict[str, Any]:
type_hints = get_type_hints(InvokeAIAppConfig)
categories = _extract_settings_categories()
settings: list[dict[str, Any]] = []
fields = cast(dict[str, FieldInfo], InvokeAIAppConfig.model_fields) # pyright: ignore[reportUnknownMemberType]
for field_name, field_info in fields.items():
if field_name in EXCLUDED_SETTINGS or field_info.exclude:
continue
field_type = type_hints.get(field_name)
literal_values: list[Any] = []
if get_origin(field_type) is Literal:
literal_values = list(get_args(field_type))
settings.append(
{
"name": field_name,
"description": field_info.description or "",
"type": str(field_info.annotation),
"default": _normalize_value(field_info.default),
"required": field_info.is_required(),
"literal_values": literal_values,
"env_var": f"INVOKEAI_{field_name.upper()}",
"category": categories.get(field_name, "OTHER"),
"validation": _extract_validation(field_info),
}
)
return {"settings": settings}
def write_docs_bundle(bundle: dict[str, Any], output_dir: Path = OUTPUT_DIR) -> None:
output_dir.mkdir(parents=True, exist_ok=True)
files = {
"invocation-context.json": bundle["invocation_context"],
"settings.json": bundle["settings"],
}
for name, payload in files.items():
with open(output_dir / name, "w") as output_file:
json.dump(payload, output_file, indent=2, sort_keys=True)
output_file.write("\n")
def _normalize_value(value: Any) -> str | int | float | bool | list[Any] | dict[str, Any] | None:
if isinstance(value, Path):
return str(value)
if isinstance(value, (str, int, float, bool)) or value is None:
return value
if isinstance(value, list):
items = cast(list[Any], value)
normalized_list: list[Any] = [_normalize_value(item) for item in items]
return normalized_list
if isinstance(value, dict):
entries = cast(dict[Any, Any], value)
normalized_dict: dict[str, Any] = {str(key): _normalize_value(val) for key, val in entries.items()}
return normalized_dict
return str(value)
def _parse_docstring(docstring: str) -> tuple[str, list[dict[str, str]], str]:
if not docstring:
return "", [], ""
lines = docstring.splitlines()
description_lines: list[str] = []
parameter_lines: list[str] = []
return_lines: list[str] = []
section = "description"
for raw_line in lines:
line = raw_line.rstrip()
stripped = line.strip()
if stripped == "Args:":
section = "args"
continue
if stripped == "Returns:":
section = "returns"
continue
if section == "description":
description_lines.append(stripped)
elif section == "args":
parameter_lines.append(stripped)
elif section == "returns":
return_lines.append(stripped)
parameters: list[dict[str, str]] = []
current_name: str | None = None
current_description: list[str] = []
for line in parameter_lines:
if not line:
continue
if ":" in line and not line.startswith("```"):
if current_name is not None:
parameters.append({"name": current_name, "description": " ".join(current_description).strip()})
current_name, remainder = line.split(":", 1)
current_name = current_name.strip()
current_description = [remainder.strip()]
elif current_name is not None:
current_description.append(line)
if current_name is not None:
parameters.append({"name": current_name, "description": " ".join(current_description).strip()})
description = "\n".join(line for line in description_lines if line).strip()
returns = " ".join(line for line in return_lines if line).strip()
return description, parameters, returns
def _extract_settings_categories() -> dict[str, str]:
categories: dict[str, str] = {}
current_category = "OTHER"
config_path = Path(__file__).resolve().parent.parent / "invokeai/app/services/config/config_default.py"
with open(config_path) as config_file:
for raw_line in config_file:
stripped = raw_line.strip()
if stripped.startswith("# ") and stripped[2:].isupper() and "fmt:" not in stripped:
current_category = stripped[2:]
continue
match = re.match(r"([a-zA-Z_][a-zA-Z0-9_]*)\s*:", stripped)
if match:
categories[match.group(1)] = current_category
return categories
def _extract_validation(field_info: FieldInfo) -> dict[str, Any]:
validation: dict[str, Any] = {}
for attribute in ("gt", "ge", "lt", "le", "pattern"):
value = getattr(field_info, attribute, None)
if value is not None:
validation[attribute] = value
return validation
def main() -> None:
os.chdir(Path(__file__).resolve().parent.parent)
write_docs_bundle(build_docs_bundle())
if __name__ == "__main__":
main()
+18
View File
@@ -0,0 +1,18 @@
import json
import os
import sys
def main():
# Change working directory to the repo root
os.chdir(os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
from invokeai.app.api_app import app
from invokeai.app.util.custom_openapi import get_openapi_func
schema = get_openapi_func(app)()
json.dump(schema, sys.stdout, indent=2)
if __name__ == "__main__":
main()
+27
View File
@@ -0,0 +1,27 @@
#!/bin/bash
# Accepts a path to a directory containing .prof files and generates a graphs
# for each of them. The default output format is pdf, but can be changed by
# providing a second argument.
# Usage: ./generate_profile_graphs.sh <path_to_profiles> <type>
# <path_to_profiles> is the path to the directory containing the .prof files
# <type> is the type of graph to generate. Defaults to 'pdf' if not provided.
# Valid types are: 'svg', 'png' and 'pdf'.
# Requires:
# - graphviz: https://graphviz.org/download/
# - gprof2dot: https://github.com/jrfonseca/gprof2dot
if [ -z "$1" ]; then
echo "Missing path to profiles directory"
exit 1
fi
type=${2:-pdf}
for file in $1/*.prof; do
base_name=$(basename "$file" .prof)
gprof2dot -f pstats "$file" | dot -T$type -Glabel="Session ID ${base_name}" -Glabelloc="t" -o "$1/$base_name.$type"
echo "Generated $1/$base_name.$type"
done
@@ -0,0 +1,184 @@
"""A script to generate a linear approximation of the VAE decode operation. The resultant matrix can be used to quickly
visualize intermediate states of the denoising process.
"""
import argparse
from pathlib import Path
import einops
import torch
import torchvision.transforms as T
from diffusers import AutoencoderKL
from PIL import Image
from tqdm import tqdm
def trim_to_multiple_of(*args: int, multiple_of: int = 8) -> tuple[int, ...]:
return tuple((x - x % multiple_of) for x in args)
def image_to_tensor(image: Image.Image, h: int, w: int, normalize: bool = True) -> torch.Tensor:
transformation = T.Compose([T.Resize((h, w), T.InterpolationMode.LANCZOS), T.ToTensor()])
tensor: torch.Tensor = transformation(image) # type: ignore
if normalize:
tensor = tensor * 2.0 - 1.0
return tensor
def vae_preprocess(image: Image.Image, normalize: bool = True, multiple_of: int = 8) -> torch.Tensor:
w, h = trim_to_multiple_of(*image.size, multiple_of=multiple_of)
return image_to_tensor(image, h, w, normalize)
@torch.no_grad()
def vae_encode(vae: AutoencoderKL, image_tensor: torch.Tensor) -> torch.Tensor:
if image_tensor.dim() == 3:
image_tensor = einops.rearrange(image_tensor, "c h w -> 1 c h w")
orig_dtype = vae.dtype
vae.enable_tiling()
image_tensor = image_tensor.to(device=vae.device, dtype=vae.dtype)
image_tensor_dist = vae.encode(image_tensor).latent_dist
latents = image_tensor_dist.sample().to(dtype=vae.dtype) # FIXME: uses torch.randn. make reproducible!
latents = vae.config.scaling_factor * latents
latents = latents.to(dtype=orig_dtype)
return latents.detach()
@torch.no_grad()
def prepare_data(
vae: AutoencoderKL, image_dir: str, device: torch.device
) -> tuple[list[torch.Tensor], list[torch.Tensor]]:
latents: list[torch.Tensor] = []
targets: list[torch.Tensor] = []
image_paths = Path(image_dir).iterdir()
image_paths = list(filter(lambda p: p.suffix.lower() in [".png", ".jpg", ".jpeg"], image_paths))
for image_path in tqdm(image_paths, desc="Preparing images"):
image = Image.open(image_path).convert("RGB")
image_tensor = vae_preprocess(image)
latent = vae_encode(vae, image_tensor)
latent = latent.squeeze(0)
_, h, w = latent.shape
# Resize the image to the latent size.
target = image_to_tensor(image=image, h=h, w=w)
latents.append(latent)
targets.append(target)
return latents, targets
def train(
latents: list[torch.Tensor],
targets: list[torch.Tensor],
device: torch.device,
dtype: torch.dtype,
num_epochs: int = 500,
lr: float = 0.01,
):
# Initialize latent_rgb_factors randomly
latent_channels, _, _ = latents[0].shape
latent_to_image = torch.randn(latent_channels, 3, device=device, dtype=dtype, requires_grad=True)
optimizer = torch.optim.Adam([latent_to_image], lr=lr)
loss_fn = torch.nn.MSELoss()
epoch_pbar = tqdm(range(num_epochs), desc="Training")
for _ in epoch_pbar:
total_loss = 0.0
for latent, target in zip(latents, targets, strict=True):
latent = latent.to(device=device, dtype=dtype)
target = target.to(device=device, dtype=dtype)
# latent and target have shape [C, H, W]. Rearrange to [H, W, C].
latent = latent.permute(1, 2, 0)
target = target.permute(1, 2, 0)
# Forward pass
predicted = latent @ latent_to_image # [H, W, 3]
# Compute loss
loss = loss_fn(predicted, target)
total_loss += loss.item()
# Backward pass
optimizer.zero_grad()
loss.backward()
optimizer.step()
avg_loss = total_loss / len(latents)
epoch_pbar.set_postfix({"loss": f"{avg_loss:.4f}"})
return latent_to_image.detach()
@torch.no_grad()
def validate(vae: AutoencoderKL, latent_to_image: torch.Tensor, test_image_dir: str):
val_dir = Path("vae_approx_out")
val_dir.mkdir(exist_ok=True)
for image_path in Path(test_image_dir).iterdir():
if image_path.suffix.lower() not in [".png", ".jpg", ".jpeg"]:
continue
image = Image.open(image_path).convert("RGB")
image_tensor = vae_preprocess(image)
latent = vae_encode(vae, image_tensor)
latent = latent.squeeze(0).permute(1, 2, 0).to(device="cpu")
predicted_image_tensor = latent @ latent_to_image.to(device="cpu")
predicted_rgb = (((predicted_image_tensor + 1) / 2).clamp(0, 1).mul(0xFF)).to(dtype=torch.uint8)
predicted_img = Image.fromarray(predicted_rgb.numpy())
out_path = val_dir / f"{image_path.stem}.png"
predicted_img.save(out_path)
print(f"Saved validation image to: {out_path}")
def generate_linear_approximation(vae_path: str, train_image_dir: str, test_image_dir: str):
device = torch.device("cuda")
# Load the VAE model.
print(f"Loading VAE model from: {vae_path}")
vae = AutoencoderKL.from_pretrained(vae_path, local_files_only=True)
vae.to(device=device) # type: ignore
print("Loaded VAE model.")
print(f"Loading training images from: {train_image_dir}")
latents, targets = prepare_data(vae, train_image_dir, device=torch.device("cuda"))
print(f"Loaded {len(latents)} images for training.")
latent_to_image = train(latents, targets, device=device, dtype=torch.float32)
print(f"\nTrained latent_to_image matrix:\n{latent_to_image.cpu().numpy()}")
validate(vae, latent_to_image, test_image_dir)
def main():
parser = argparse.ArgumentParser(description="Generate a linear approximation of the VAE decode operation.")
parser.add_argument("--vae", type=str, required=True, help="Path to a diffusers AutoencoderKL model directory.")
parser.add_argument(
"--train_image_dir",
type=str,
required=True,
help="Path to a directory containing images to be used for training.",
)
parser.add_argument(
"--test_image_dir",
type=str,
required=True,
help="Path to a directory containing images to be used for validation.",
)
args = parser.parse_args()
generate_linear_approximation(args.vae, args.train_image_dir, args.test_image_dir)
if __name__ == "__main__":
main()
+122
View File
@@ -0,0 +1,122 @@
import re
from argparse import ArgumentParser, RawTextHelpFormatter
from typing import Any
import requests
from attr import dataclass
from tqdm import tqdm
def get_author(commit: dict[str, Any]) -> str:
"""Gets the author of a commit.
If the author is not present, the committer is used instead and an asterisk appended to the name."""
return commit["author"]["login"] if commit["author"] else f"{commit['commit']['author']['name']}*"
@dataclass
class CommitInfo:
sha: str
url: str
author: str
is_username: bool
message: str
data: dict[str, Any]
def __str__(self) -> str:
return f"{self.sha}: {self.author}{'*' if not self.is_username else ''} - {self.message} ({self.url})"
@classmethod
def from_data(cls, commit: dict[str, Any]) -> "CommitInfo":
return CommitInfo(
sha=commit["sha"],
url=commit["url"],
author=commit["author"]["login"] if commit["author"] else commit["commit"]["author"]["name"],
is_username=bool(commit["author"]),
message=commit["commit"]["message"].split("\n")[0],
data=commit,
)
def fetch_commits_between_tags(
org_name: str, repo_name: str, from_ref: str, to_ref: str, token: str
) -> list[CommitInfo]:
"""Fetches all commits between two tags in a GitHub repository."""
commit_info: list[CommitInfo] = []
headers = {"Authorization": f"token {token}"} if token else None
# Get the total number of pages w/ an initial request - a bit hacky but it works...
response = requests.get(
f"https://api.github.com/repos/{org_name}/{repo_name}/compare/{from_ref}...{to_ref}?page=1&per_page=100",
headers=headers,
)
last_page_match = re.search(r'page=(\d+)&per_page=\d+>; rel="last"', response.headers["Link"])
last_page = int(last_page_match.group(1)) if last_page_match else 1
pbar = tqdm(range(1, last_page + 1), desc="Fetching commits", unit="page", leave=False)
for page in pbar:
compare_url = f"https://api.github.com/repos/{org_name}/{repo_name}/compare/{from_ref}...{to_ref}?page={page}&per_page=100"
response = requests.get(compare_url, headers=headers)
commits = response.json()["commits"]
commit_info.extend([CommitInfo.from_data(c) for c in commits])
return commit_info
def main():
description = """Fetch external contributions between two tags in the InvokeAI GitHub repository. Useful for generating a list of contributors to include in release notes.
When the GitHub username for a commit is not available, the committer name is used instead and an asterisk appended to the name.
Example output (note the second commit has an asterisk appended to the name):
171f2aa20ddfefa23c5edbeb2849c4bd601fe104: rohinish404 - fix(ui): image not getting selected (https://api.github.com/repos/invoke-ai/InvokeAI/commits/171f2aa20ddfefa23c5edbeb2849c4bd601fe104)
0bb0e226dcec8a17e843444ad27c29b4821dad7c: Mark E. Shoulson* - Flip default ordering of workflow library; #5477 (https://api.github.com/repos/invoke-ai/InvokeAI/commits/0bb0e226dcec8a17e843444ad27c29b4821dad7c)
"""
parser = ArgumentParser(description=description, formatter_class=RawTextHelpFormatter)
parser.add_argument("--token", dest="token", type=str, default=None, help="The GitHub token to use")
parser.add_argument("--from", dest="from_ref", type=str, help="The start reference (commit, tag, etc)")
parser.add_argument("--to", dest="to_ref", type=str, help="The end reference (commit, tag, etc)")
args = parser.parse_args()
org_name = "invoke-ai"
repo_name = "InvokeAI"
# List of members of the organization, including usernames and known display names,
# any of which may be used in the commit data. Used to filter out commits.
org_members = [
"blessedcoolant",
"brandonrising",
"chainchompa",
"ebr",
"Eugene Brodsky",
"hipsterusername",
"Kent Keirsey",
"lstein",
"Lincoln Stein",
"maryhipp",
"Mary Hipp Rogers",
"Mary Hipp",
"psychedelicious",
"RyanJDick",
"Ryan Dick",
]
all_commits = fetch_commits_between_tags(
org_name=org_name,
repo_name=repo_name,
from_ref=args.from_ref,
to_ref=args.to_ref,
token=args.token,
)
filtered_commits = filter(lambda x: x.author not in org_members, all_commits)
for commit in filtered_commits:
print(commit)
if __name__ == "__main__":
main()
+20
View File
@@ -0,0 +1,20 @@
#!/usr/bin/env python
# Copyright (c) 2022 Kyle Schouviller (https://github.com/kyle0654)
import logging
import os
from invokeai.app.run_app import run_app
logging.getLogger("xformers").addFilter(lambda record: "A matching Triton is not available" not in record.getMessage())
def main():
# Change working directory to the repo root
os.chdir(os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
run_app()
if __name__ == "__main__":
main()
+464
View File
@@ -0,0 +1,464 @@
#!/usr/bin/env python
"""Script to remove orphaned model files from INVOKEAI_ROOT directory.
Orphaned models are ones that appear in the INVOKEAI_ROOT/models directory,
but which are not referenced in the database `models` table.
"""
import argparse
import datetime
import json
import locale
import os
import shutil
import sqlite3
from pathlib import Path
from typing import Set
import yaml
class ConfigMapper:
"""Configuration loader for InvokeAI paths."""
YAML_FILENAME = "invokeai.yaml"
DATABASE_FILENAME = "invokeai.db"
DEFAULT_DB_DIR = "databases"
DEFAULT_MODELS_DIR = "models"
def __init__(self):
self.database_path = None
self.database_backup_dir = None
self.models_path = None
def load(self, root_path: Path) -> bool:
"""Load configuration from root directory."""
yaml_path = root_path / self.YAML_FILENAME
if not yaml_path.exists():
print(f"Unable to find {self.YAML_FILENAME} at {yaml_path}!")
return False
db_dir, models_dir = self._load_paths_from_yaml_file(yaml_path)
if db_dir is None:
db_dir = self.DEFAULT_DB_DIR
print(f"The {self.YAML_FILENAME} file was found but is missing the db_dir setting! Defaulting to {db_dir}")
if models_dir is None:
models_dir = self.DEFAULT_MODELS_DIR
print(
f"The {self.YAML_FILENAME} file was found but is missing the models_dir setting! Defaulting to {models_dir}"
)
# Set database path
if os.path.isabs(db_dir):
self.database_path = Path(db_dir) / self.DATABASE_FILENAME
else:
self.database_path = root_path / db_dir / self.DATABASE_FILENAME
self.database_backup_dir = self.database_path.parent / "backup"
# Set models path
if os.path.isabs(models_dir):
self.models_path = Path(models_dir)
else:
self.models_path = root_path / models_dir
db_exists = self.database_path.exists()
models_exists = self.models_path.exists()
print(f"Found {self.YAML_FILENAME} file at {yaml_path}:")
print(f" Database : {self.database_path} - {'Exists!' if db_exists else 'Not Found!'}")
print(f" Models : {self.models_path} - {'Exists!' if models_exists else 'Not Found!'}")
if db_exists and models_exists:
return True
else:
print(
"\nOne or more paths specified in invokeai.yaml do not exist. Please inspect/correct the configuration."
)
return False
def _load_paths_from_yaml_file(self, yaml_path: Path):
"""Load paths from YAML configuration file."""
try:
with open(yaml_path, "rt", encoding=locale.getpreferredencoding()) as file:
yamlinfo = yaml.safe_load(file)
db_dir = yamlinfo.get("InvokeAI", {}).get("Paths", {}).get("db_dir", None)
models_dir = yamlinfo.get("InvokeAI", {}).get("Paths", {}).get("models_dir", None)
return db_dir, models_dir
except Exception as e:
print(f"Failed to load paths from yaml file! {yaml_path}! Error: {e}")
return None, None
class DatabaseMapper:
"""Class to abstract database functionality."""
def __init__(self, database_path: Path, database_backup_dir: Path):
self.database_path = database_path
self.database_backup_dir = database_backup_dir
self.connection = None
self.cursor = None
def backup(self, timestamp_string: str):
"""Take a backup of the database."""
if not self.database_backup_dir.exists():
print(f"Database backup directory {self.database_backup_dir} does not exist -> creating...", end="")
self.database_backup_dir.mkdir(parents=True, exist_ok=True)
print("Done!")
database_backup_path = self.database_backup_dir / f"backup-{timestamp_string}-invokeai.db"
print(f"Making DB Backup at {database_backup_path}...", end="")
shutil.copy2(self.database_path, database_backup_path)
print("Done!")
def connect(self):
"""Open connection to the database."""
self.connection = sqlite3.connect(str(self.database_path))
self.cursor = self.connection.cursor()
def get_all_model_directories(self, models_dir: Path) -> Set[Path]:
"""Get the set of all model directories from the database.
A model directory is the top-level directory under models/ that contains
the model files. If the path in the database is just a directory, that's
the model directory. If it's a file path, we extract the first directory
component.
Args:
models_dir: The root models directory path. Relative paths from the database
will be resolved relative to this directory.
Returns:
Set of absolute Path objects for model directories.
"""
sql_get_models = "SELECT config FROM models"
self.cursor.execute(sql_get_models)
rows = self.cursor.fetchall()
model_directories = set()
for row in rows:
try:
config = json.loads(row[0])
if "path" in config and config["path"]:
path_str = config["path"]
# Convert to Path object
path = Path(path_str)
# If the path is relative, resolve it relative to models_dir
# If it's absolute, use it as-is
if not path.is_absolute():
full_path = (models_dir / path).resolve()
else:
full_path = path.resolve()
# Extract the top-level directory under models_dir
# This handles both cases:
# 1. path is "model-id" -> model-id is the directory
# 2. path is "model-id/file.safetensors" -> model-id is the directory
try:
# Get the relative path from models_dir
rel_path = full_path.relative_to(models_dir)
# Get the first component (top-level directory)
if rel_path.parts:
top_level_dir = models_dir / rel_path.parts[0]
model_directories.add(top_level_dir.resolve())
except ValueError:
# Path is not relative to models_dir, use the path itself
# This handles absolute paths outside models_dir
model_directories.add(full_path)
except (json.JSONDecodeError, KeyError, TypeError) as e:
print(f"Warning: Failed to parse model config: {e}")
continue
return model_directories
def disconnect(self):
"""Disconnect from the database."""
if self.cursor is not None:
self.cursor.close()
if self.connection is not None:
self.connection.close()
class ModelFileMapper:
"""Class to handle model file system operations."""
# Common model file extensions
MODEL_EXTENSIONS = {
".safetensors",
".ckpt",
".pt",
".pth",
".bin",
".onnx",
}
# Directories to skip during scan
SKIP_DIRS = {
".download_cache",
".convert_cache",
"__pycache__",
".git",
}
def __init__(self, models_path: Path):
self.models_path = models_path
def get_all_model_directories(self) -> Set[Path]:
"""
Get all directories in the models path that contain model files.
Returns a set of directory paths that contain at least one model file.
"""
model_dirs = set()
for item in self.models_path.rglob("*"):
# Skip directories we don't want to scan
if any(skip_dir in item.parts for skip_dir in self.SKIP_DIRS):
continue
if item.is_file() and item.suffix.lower() in self.MODEL_EXTENSIONS:
# Add the parent directory of the model file
model_dirs.add(item.parent)
return model_dirs
def get_all_model_files(self) -> Set[Path]:
"""Get all model files in the models directory."""
model_files = set()
for item in self.models_path.rglob("*"):
# Skip directories we don't want to scan
if any(skip_dir in item.parts for skip_dir in self.SKIP_DIRS):
continue
if item.is_file() and item.suffix.lower() in self.MODEL_EXTENSIONS:
model_files.add(item.resolve())
return model_files
def remove_file(self, file_path: Path):
"""Remove a single model file."""
try:
file_path.unlink()
print(f" Deleted file: {file_path}")
except Exception as e:
print(f" Error deleting {file_path}: {e}")
def remove_directory_if_empty(self, directory: Path):
"""Remove a directory if it's empty (after removing files)."""
try:
if directory.exists() and not any(directory.iterdir()):
directory.rmdir()
print(f" Deleted empty directory: {directory}")
except Exception as e:
print(f" Error removing directory {directory}: {e}")
class OrphanedModelsApp:
"""Main application class for removing orphaned model files."""
def __init__(self, delete_without_confirm: bool = False):
self.delete_without_confirm = delete_without_confirm
self.orphaned_count = 0
def find_orphaned_files_by_directory(
self, file_mapper: ModelFileMapper, db_mapper: DatabaseMapper, models_path: Path
) -> dict[Path, list[Path]]:
"""Find orphaned files grouped by their parent directory.
A file is orphaned if it's NOT under any model directory registered in the database.
Model directories are extracted from the database paths - if a path is
'model-id/file.safetensors', then 'model-id' is the model directory and ALL files
under it belong to that model.
"""
print("\nScanning models directory for orphaned models...")
# Get all model files on disk
disk_model_files = file_mapper.get_all_model_files()
print(f"Found {len(disk_model_files)} model directories on disk")
# Get all model directories from database
db_model_directories = db_mapper.get_all_model_directories(models_path)
print(f"Found {len(db_model_directories)} model directories in database")
# Find orphaned files (files on disk but not under any registered model directory)
orphaned_files = set()
for disk_file in disk_model_files:
# Check if this file is under any registered model directory
is_under_model_dir = False
for model_dir in db_model_directories:
try:
# Check if disk_file is under model_dir
disk_file.relative_to(model_dir)
is_under_model_dir = True
break
except ValueError:
# Not under this model directory, continue checking
continue
if not is_under_model_dir:
orphaned_files.add(disk_file)
# Group orphaned files by their parent directory
orphaned_dirs = {}
for orphaned_file in orphaned_files:
parent = orphaned_file.parent
if parent not in orphaned_dirs:
orphaned_dirs[parent] = []
orphaned_dirs[parent].append(orphaned_file)
return orphaned_dirs
def ask_to_continue(self) -> bool:
"""Ask user whether they want to continue with the operation."""
while True:
try:
input_choice = input("\nDo you wish to delete these models? (Y or N) [N]: ")
# Default to 'N' if user presses Enter without input
if input_choice.strip() == "":
return False
if str.lower(input_choice) == "y":
return True
if str.lower(input_choice) == "n":
return False
print("Please enter Y or N")
except (KeyboardInterrupt, EOFError):
return False
def remove_orphaned_models(self, config: ConfigMapper, file_mapper: ModelFileMapper, db_mapper: DatabaseMapper):
"""Remove orphaned model directories."""
print("\n" + "=" * 80)
print("= Remove Orphaned Model Files")
print("=" * 80)
print("\nThis operation will find model files in the models directory that are not")
print("referenced in the database and remove them.")
print()
print(f"Database File Path : {config.database_path}")
print(f"Models Directory : {config.models_path}")
print()
print("Notes:")
print("- A database backup will be created before any changes")
print("- Model files not referenced in the database will be permanently deleted")
print("- This operation cannot be undone (except by restoring the deleted files)")
print()
# Connect to database and find orphaned files
db_mapper.connect()
try:
orphaned_dirs = self.find_orphaned_files_by_directory(file_mapper, db_mapper, config.models_path)
if not orphaned_dirs:
print("\nNo orphaned model files found!")
return
print(f"\nFound {len(orphaned_dirs)} directories with orphaned model files:")
print()
for directory, files in sorted(orphaned_dirs.items()):
print(f"Directory: {directory}")
for file in sorted(files):
print(f" - {file.name}")
print()
self.orphaned_count = sum(len(files) for files in orphaned_dirs.values())
print(f"Total orphans: {self.orphaned_count}")
# Ask for confirmation unless --delete flag is used
if not self.delete_without_confirm:
if not self.ask_to_continue():
print("\nOperation cancelled by user.")
self.orphaned_count = 0 # Reset count since no files were removed
return
# Create database backup with timestamp
timestamp_string = datetime.datetime.now(datetime.UTC).strftime("%Y%m%dT%H%M%SZ")
db_mapper.backup(timestamp_string)
# Delete the orphaned files
print("\nDeleting orphaned model files...")
for directory, files in sorted(orphaned_dirs.items()):
for file in sorted(files):
file_mapper.remove_file(file)
# After removing files, clean up the directory if it's now empty
file_mapper.remove_directory_if_empty(directory)
finally:
db_mapper.disconnect()
def main(self, root_path: Path):
"""Main entry point."""
print("\n" + "=" * 80)
print("Orphaned Model Files Cleanup for InvokeAI")
print("=" * 80 + "\n")
config_mapper = ConfigMapper()
if not config_mapper.load(root_path):
print("\nInvalid configuration...exiting.\n")
return 1
file_mapper = ModelFileMapper(config_mapper.models_path)
db_mapper = DatabaseMapper(config_mapper.database_path, config_mapper.database_backup_dir)
try:
self.remove_orphaned_models(config_mapper, file_mapper, db_mapper)
except KeyboardInterrupt:
print("\n\nOperation cancelled by user.")
return 1
except Exception as e:
print(f"\n\nError during operation: {e}")
import traceback
traceback.print_exc()
return 1
print("\n" + "=" * 80)
print("= Operation Complete")
print("=" * 80)
print(f"\nOrphaned model files removed: {self.orphaned_count}")
print()
return 0
def main():
"""Command-line entry point."""
parser = argparse.ArgumentParser(
description="Remove orphaned model files from InvokeAI installation",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
This script finds and removes model files that exist in the models directory
but are not referenced in the InvokeAI database. This can happen if:
- Models were manually deleted from the database
- The database was reset but model files were kept
- Files were manually copied into the models directory
By default, the script will list orphaned files and ask for confirmation
before deleting them.
""",
)
parser.add_argument(
"--root",
type=Path,
default=os.environ.get("INVOKEAI_ROOT", "."),
help="InvokeAI root directory (default: $INVOKEAI_ROOT or current directory)",
)
parser.add_argument(
"--delete",
action="store_true",
help="Delete orphan model files without asking for confirmation",
)
args = parser.parse_args()
# Resolve the root path
root_path = Path(args.root).resolve()
if not root_path.exists():
print(f"Error: Root directory does not exist: {root_path}")
return 1
app = OrphanedModelsApp(delete_without_confirm=args.delete)
return app.main(root_path)
if __name__ == "__main__":
exit(main())
+62
View File
@@ -0,0 +1,62 @@
#!/bin/bash
set -e
BCYAN="\033[1;36m"
BYELLOW="\033[1;33m"
BGREEN="\033[1;32m"
BRED="\033[1;31m"
RED="\033[31m"
RESET="\033[0m"
function does_tag_exist {
git rev-parse --quiet --verify "refs/tags/$1" >/dev/null
}
function git_show_ref {
git show-ref --dereference $1 --abbrev 7
}
function git_show {
git show -s --format='%h %s' $1
}
VERSION=$(
cd ..
python3 -c "from invokeai.version import __version__ as version; print(version)"
)
PATCH=""
VERSION="v${VERSION}${PATCH}"
if does_tag_exist $VERSION; then
echo -e "${BCYAN}${VERSION}${RESET} already exists:"
git_show_ref tags/$VERSION
echo
fi
echo -e "${BGREEN}HEAD${RESET}:"
git_show
echo
echo -e "${BGREEN}git remote -v${RESET}:"
git remote -v
echo
echo -e -n "Create tags ${BCYAN}${VERSION}${RESET} @ ${BGREEN}HEAD${RESET}, ${RED}deleting existing tags on origin remote${RESET}? "
read -e -p 'y/n [n]: ' input
RESPONSE=${input:='n'}
if [ "$RESPONSE" == 'y' ]; then
echo
echo -e "Deleting ${BCYAN}${VERSION}${RESET} tag on origin remote..."
git push origin :refs/tags/$VERSION
echo -e "Tagging ${BGREEN}HEAD${RESET} with ${BCYAN}${VERSION}${RESET} on locally..."
if ! git tag -fa $VERSION; then
echo "Existing/invalid tag"
exit -1
fi
echo -e "Pushing updated tags to origin remote..."
git push origin --tags
fi
exit 0