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
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:
@@ -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)
|
||||
Executable
+97
@@ -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()
|
||||
@@ -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.")
|
||||
Executable
+45
@@ -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)
|
||||
@@ -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()
|
||||
Executable
+12
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Executable
+27
@@ -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()
|
||||
@@ -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()
|
||||
Executable
+20
@@ -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()
|
||||
Executable
+464
@@ -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())
|
||||
Executable
+62
@@ -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
|
||||
Reference in New Issue
Block a user