- The scan summary marks new models whose safetensors header reads but names no drawing model
kind (a voice model such as F5-TTS) as not_drawing; the scan page asks: don't scan them,
scan them, or see each. The choice lives in <model>.anomalous.json ("scan": "skip" / "scan",
model_identity.scan_choice), saved by POST /anomalous/scan_choice (api/scan_choice.py, this
computer only, at most 2000 models, only readable .safetensors layers).
- "skip": a scan of a whole folder leaves the model out (picked by hand it is still scanned),
the summary counts it apart (excluded) with its own list to change it back, the card and the
details say "不扫描"; the details have the same button.
- The unmatched count warns only for damaged files or lookups still to do; not-found rows say
the fingerprint is kept for repairing workflows.
- model_kind.inspect_file now gives format None when the header cannot be read, as its text said.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
275 lines
12 KiB
Python
275 lines
12 KiB
Python
"""What a model file is, from its header alone: which models folder it belongs in (LoRA,
|
|
checkpoint, VAE…) and its base model. Reads a .safetensors header (JSON) or a .gguf header
|
|
(key/value metadata and tensor names); never loads tensors and never opens formats that can
|
|
carry code (.ckpt/.pt/.pth/.bin). Shared by model import (api/model_import.py) and the
|
|
scanner's offline base model (model_identity.infer_base_model_from_header). No imports from
|
|
ComfyUI, so it is tested on its own.
|
|
|
|
`classify(fmt, header)` -> {"kind": a ComfyUI folder name or "", "base": base model or "",
|
|
"sure": True when the header names both outright, False when it is a reasoned guess}.
|
|
"""
|
|
|
|
import json
|
|
import struct
|
|
|
|
MAX_HEADER = 100 * 1024 * 1024
|
|
GGUF_READ = 16 * 1024 * 1024
|
|
|
|
# Cross-attention width (the text encoder's output) -> base model, for the SD family.
|
|
CONTEXT_WIDTH = {768: "SD 1.5", 1024: "SD 2.1", 2048: "SDXL"}
|
|
# ComfyUI-GGUF's architecture names.
|
|
GGUF_DIFFUSION = {
|
|
"flux": "Flux.1 D", "sd1": "SD 1.5", "sdxl": "SDXL", "sd3": "SD3", "aura": "AuraFlow",
|
|
"hidream": "HiDream", "cosmos": "Cosmos", "ltxv": "LTXV", "hyvid": "Hunyuan Video",
|
|
"wan": "Wan Video", "lumina2": "Lumina", "qwen_image": "Qwen", "chroma": "Chroma",
|
|
}
|
|
GGUF_TEXT = ("t5", "t5encoder", "umt5", "llama", "qwen2", "qwen2vl", "qwen3", "gemma", "clip", "mistral")
|
|
EMBEDDING_KEYS = {"emb_params", "clip_l", "clip_g", "string_to_param", "string_to_token", "name", "step",
|
|
"sd_checkpoint", "sd_checkpoint_name"}
|
|
LORA_MARKS = ("lora_up.", "lora_down.", "lora_a.", "lora_b.", "lora.up.", "lora.down.", "hada_w1_a",
|
|
"lokr_w1", ".lora_linear_layer.", "dora_scale")
|
|
CONTROL_MARKS = ("control_model_", "input_hint_block", "controlnet_cond_embedding", "controlnet_blocks",
|
|
"controlnet_x_embedder", "zero_convs", "controlnet_mid_block")
|
|
DIFFUSION_STARTS = ("double_blocks.", "single_blocks.", "joint_blocks.", "transformer_blocks.",
|
|
"single_transformer_blocks.", "input_blocks.", "blocks.0.self_attn", "net.blocks.", "net.x_embedder.")
|
|
|
|
|
|
def parse_safetensors(data):
|
|
"""The header dict from a file's first bytes (8-byte length + JSON); None if not one."""
|
|
if len(data) < 8:
|
|
return None
|
|
size = struct.unpack("<Q", data[:8])[0]
|
|
if not 2 <= size <= MAX_HEADER or len(data) < 8 + size:
|
|
return None
|
|
try:
|
|
header = json.loads(data[8:8 + size].decode("utf-8"))
|
|
except (UnicodeDecodeError, ValueError):
|
|
return None
|
|
return header if isinstance(header, dict) else None
|
|
|
|
|
|
class _Reader:
|
|
def __init__(self, data):
|
|
self.data, self.at = data, 0
|
|
|
|
def take(self, count):
|
|
if self.at + count > len(self.data):
|
|
raise EOFError
|
|
part = self.data[self.at:self.at + count]
|
|
self.at += count
|
|
return part
|
|
|
|
def number(self, fmt):
|
|
return struct.unpack("<" + fmt, self.take(struct.calcsize(fmt)))[0]
|
|
|
|
def text(self):
|
|
return self.take(self.number("Q")).decode("utf-8", "replace")
|
|
|
|
|
|
_GGUF_SCALARS = {0: "B", 1: "b", 2: "H", 3: "h", 4: "I", 5: "i", 6: "f", 7: "?", 10: "Q", 11: "q", 12: "d"}
|
|
|
|
|
|
def _gguf_value(reader, kind):
|
|
if kind in _GGUF_SCALARS:
|
|
return reader.number(_GGUF_SCALARS[kind])
|
|
if kind == 8:
|
|
return reader.text()
|
|
if kind == 9: # an array: skipped (tokenizer word lists are long)
|
|
item, count = reader.number("I"), reader.number("Q")
|
|
for _ in range(count):
|
|
_gguf_value(reader, item)
|
|
return None
|
|
raise ValueError("unknown gguf value type")
|
|
|
|
|
|
def parse_gguf(data):
|
|
"""{"meta": general.* values, "keys": tensor names} from a .gguf's first bytes; None if
|
|
not one. What did not fit in `data` is left out."""
|
|
if data[:4] != b"GGUF":
|
|
return None
|
|
reader = _Reader(data)
|
|
reader.take(4)
|
|
meta, keys = {}, []
|
|
try:
|
|
if reader.number("I") < 2:
|
|
return None
|
|
tensors, values = reader.number("Q"), reader.number("Q")
|
|
for _ in range(values):
|
|
key = reader.text()
|
|
value = _gguf_value(reader, reader.number("I"))
|
|
if key.startswith("general.") and isinstance(value, (str, int, float)):
|
|
meta[key] = value
|
|
for _ in range(tensors):
|
|
keys.append(reader.text())
|
|
dims = reader.number("I")
|
|
reader.take(8 * dims + 4 + 8)
|
|
except (EOFError, ValueError, struct.error):
|
|
pass
|
|
return {"meta": meta, "keys": keys}
|
|
|
|
|
|
def read_head(path):
|
|
"""(fmt, header) of a model file: ("safetensors", dict), ("gguf", dict), or (None, None)
|
|
for other formats and unreadable files."""
|
|
lower = path.lower()
|
|
try:
|
|
with open(path, "rb") as source:
|
|
if lower.endswith((".safetensors", ".sft")):
|
|
start = source.read(8)
|
|
if len(start) < 8:
|
|
return None, None
|
|
size = struct.unpack("<Q", start)[0]
|
|
if size > MAX_HEADER:
|
|
return None, None
|
|
return "safetensors", parse_safetensors(start + source.read(size))
|
|
if lower.endswith(".gguf"):
|
|
return "gguf", parse_gguf(source.read(GGUF_READ))
|
|
except OSError:
|
|
pass
|
|
return None, None
|
|
|
|
|
|
def _context_width(header):
|
|
"""The cross-attention input width a SD-family file shows (768 / 1024 / 2048), else 0."""
|
|
for key, value in header.items():
|
|
low = key.lower().replace(".", "_")
|
|
if "attn2_to_k" not in low or not isinstance(value, dict):
|
|
continue
|
|
shape = value.get("shape") or []
|
|
if len(shape) == 2 and ("lora_down" in low or "lora_a" in low or low.endswith("to_k_weight")):
|
|
return shape[1]
|
|
return 0
|
|
|
|
|
|
def _family(meta):
|
|
"""An SDXL file's family from the name of the model it was trained on (kohya's metadata)."""
|
|
trained_on = " ".join(str(meta.get(key, "")) for key in ("ss_sd_model_name", "ss_base_model", "modelspec.title")).lower()
|
|
for mark, name in (("pony", "Pony"), ("illustrious", "Illustrious"), ("noob", "NoobAI")):
|
|
if mark in trained_on:
|
|
return name
|
|
return ""
|
|
|
|
|
|
def _base_from_meta(meta):
|
|
arch = str(meta.get("modelspec.architecture", "")).lower()
|
|
trained = str(meta.get("ss_base_model_version", "")).lower()
|
|
pairs = (("flux", "Flux.1 D"), ("chroma", "Chroma"), ("stable-diffusion-xl", "SDXL"), ("sdxl", "SDXL"),
|
|
("stable-diffusion-v1", "SD 1.5"), ("sd_v1", "SD 1.5"), ("stable-diffusion-v2", "SD 2.1"),
|
|
("sd_v2", "SD 2.1"), ("stable-diffusion-3", "SD3"), ("sd3", "SD3"), ("wan", "Wan Video"),
|
|
("qwen", "Qwen"), ("hunyuan", "Hunyuan Video"))
|
|
for mark, name in pairs:
|
|
if (arch and mark in arch) or (trained and trained.startswith(mark)):
|
|
return name
|
|
return ""
|
|
|
|
|
|
def _base_from_keys(header, text):
|
|
"""(base, sure) from tensor names; ("", False) when they do not tell."""
|
|
if "individual_token_refiner" in text:
|
|
return "Hunyuan Video", True
|
|
# Cosmos-Predict2 (with or without "net."); Anima adds an adapter for its Qwen3 text
|
|
# encoder. Before Wan: their blocks' attention names look alike.
|
|
if "llm_adapter" in text:
|
|
return "Anima", True
|
|
if "adaln_modulation_self_attn" in text:
|
|
return "Cosmos", True
|
|
if "distilled_guidance_layer" in text:
|
|
return "Chroma", True
|
|
if "img_mod_1" in text or "txt_norm" in text or "img_mlp_net" in text:
|
|
return "Qwen", True
|
|
if "double_blocks_0_img_attn" in text or "single_blocks_0_linear1" in text:
|
|
return "Flux.1 D", True
|
|
if "single_transformer_blocks_0_attn" in text or ("single_transformer_blocks" in text and "x_embedder" in text):
|
|
return "Flux.1 D", False
|
|
if "joint_blocks_0_x_block" in text:
|
|
return "SD3", True
|
|
if "blocks_0_self_attn_q" in text and "blocks_0_cross_attn_k" in text:
|
|
return "Wan Video", True
|
|
if any(mark in text for mark in ("conditioner_embedders_1", "label_emb_0_0", "lora_te1_", "lora_te2_",
|
|
"lora_unet_input_blocks", "lora_unet_output_blocks")):
|
|
return "SDXL", True
|
|
width = _context_width(header)
|
|
if width in CONTEXT_WIDTH:
|
|
return CONTEXT_WIDTH[width], True
|
|
if "cond_stage_model_transformer_text_model" in text:
|
|
return "SD 1.5", True
|
|
if "input_blocks_1_1_transformer_blocks" in text or "lora_unet_down_blocks" in text:
|
|
return "SD 1.5", False
|
|
return "", False
|
|
|
|
|
|
def _embedding_base(header):
|
|
if "clip_g" in header:
|
|
return "SDXL"
|
|
shape = (header.get("emb_params") or {}).get("shape") or []
|
|
return CONTEXT_WIDTH.get(shape[-1], "") if shape else ""
|
|
|
|
|
|
def _kind(keys, raw, text):
|
|
"""The models folder from tensor names: (kind, sure)."""
|
|
names = set(keys)
|
|
if names and names <= EMBEDDING_KEYS and names & {"emb_params", "clip_l", "clip_g", "string_to_param"}:
|
|
return "embeddings", True
|
|
if any(mark in text for mark in CONTROL_MARKS):
|
|
return "controlnet", True
|
|
diffusion = any(key.startswith(("model.diffusion_model.", "diffusion_model.") + DIFFUSION_STARTS) for key in keys)
|
|
# A whole model first: some merged checkpoints still carry a LoRA's keys.
|
|
if diffusion and any(key.startswith(("first_stage_model.", "vae.")) for key in keys):
|
|
return "checkpoints", True
|
|
if any(mark in raw for mark in LORA_MARKS):
|
|
return "loras", True
|
|
if diffusion:
|
|
return "diffusion_models", True
|
|
if any(key.startswith(("image_proj.", "ip_adapter.")) for key in keys):
|
|
return "ipadapter", True
|
|
if any(mark in text for mark in ("encoder_down_0_block", "decoder_up_0_block", "encoder_down_blocks_0",
|
|
"encoder_downsamples", "first_stage_model_encoder")):
|
|
return "vae", True
|
|
if "vision_model_encoder_layers" in text and "text_model" not in text:
|
|
return "clip_vision", True
|
|
if "text_model_encoder_layers" in text or "encoder_block_0_layer_0_selfattention" in text \
|
|
or ("model_layers_0_self_attn" in text and "embed_tokens" in text):
|
|
return "text_encoders", True
|
|
if "conv_first_weight" in text or "body_0_rdb1" in text or ("model_0_weight" in text and "model_1_sub" in text):
|
|
return "upscale_models", False
|
|
return "", False
|
|
|
|
|
|
def classify(fmt, header):
|
|
"""What a parsed header says: {"kind", "base", "sure"} (see the module text)."""
|
|
if not header:
|
|
return {"kind": "", "base": "", "sure": False}
|
|
if fmt == "gguf":
|
|
meta, keys = header.get("meta") or {}, header.get("keys") or []
|
|
arch = str(meta.get("general.architecture", "")).lower()
|
|
if arch in GGUF_DIFFUSION:
|
|
return {"kind": "diffusion_models", "base": GGUF_DIFFUSION[arch], "sure": True}
|
|
if arch.startswith(GGUF_TEXT):
|
|
return {"kind": "text_encoders", "base": "", "sure": True}
|
|
header = {key: {} for key in keys}
|
|
meta = header.get("__metadata__") if isinstance(header.get("__metadata__"), dict) else {}
|
|
keys = [key for key in header if key != "__metadata__"]
|
|
raw = "\n".join(keys).lower()
|
|
text = raw.replace(".", "_")
|
|
kind, sure = _kind(keys, raw, text)
|
|
if kind in ("vae", "text_encoders", "clip_vision", "upscale_models", "ipadapter"):
|
|
base, base_sure = "", True # shared by several base models: the type's own folder
|
|
elif kind == "embeddings":
|
|
base, base_sure = _embedding_base(header), True
|
|
else:
|
|
base = _base_from_meta(meta)
|
|
base_sure = bool(base)
|
|
if not base:
|
|
base, base_sure = _base_from_keys(header, text)
|
|
if base == "SDXL":
|
|
base = _family(meta) or base
|
|
return {"kind": kind, "base": base, "sure": bool(kind) and sure and (base_sure or not base)}
|
|
|
|
|
|
def inspect_file(path):
|
|
"""classify() of a file on disk, plus its "format" (None when its header cannot be read)."""
|
|
fmt, header = read_head(path)
|
|
found = classify(fmt, header)
|
|
found["format"] = fmt if header else None
|
|
return found
|