plan(P5.3): fail fast on the weights that the pack needs

This commit is contained in:
WildAi
2026-10-23 23:07:17 +03:00
parent 536a4c285a
commit 3d93cc5625
4 changed files with 263 additions and 24 deletions
+59 -21
View File
@@ -4,20 +4,36 @@ The engine that builds a :class:`KEEPModelPack`: the KEEP network, the
facelib face helper and the optional ComfyUI upscalers. It moved here from
``modules/keep_model_loader.py`` in P3.2.
Since **P5.3** the weights are resolved through the manifest
(:mod:`src.keep_model_configs`) and the downloader (:mod:`src.keep_download`):
every **required** weight must be on disk before the model is built - a
missing one is downloaded and, when that fails, the error names the file, the
expected path and the manifest URL. The optional weights (the GMFlow
checkpoint) are deferred to :meth:`KEEPModelPack.ensure_flow`.
Since **P5.5** the optical flow is wired: the GMFlow checkpoint is only
resolved when the loaded KEEP checkpoint does **not** carry the ``flownet.*``
weights (the shipped checkpoint does - P0.4 section 2). The resolved path is
put into the architecture config **before** the net is built, so the arch
loads the trained weights instead of random ones.
The ``comfy`` imports are **lazy** (function level): the plan's §3 import
rule wants the pack to import without a ComfyUI server. The vendored
facelib helper imports ``comfy`` as well, so it is imported lazily too.
"""
import torch
import os
from . import keep_download
from . import keep_paths
from .keep_logger import logger
from .keep_model_configs import (
ARCH_REGISTRY, KEEP_MODEL_CONFIGS, FACELIB_MODEL_URLS, FACELIB_DEST_FOLDER
ARCH_REGISTRY,
FACELIB_DEST_FOLDER,
KEEP_MODEL_CONFIGS,
WEIGHT_MANIFEST,
weight_entry_for,
)
from .keep_paths import load_file_from_url_comfy
# P2.7: the swallow-and-stub fallback is gone - a broken vendored dependency
@@ -231,25 +247,32 @@ class KEEPModelLoader:
raise ValueError(f"Unknown KEEP model type: {model_type_str}")
config_entry = KEEP_MODEL_CONFIGS[model_type_str]
# P5.3: fail fast. Every weight the pack needs is resolved (and
# downloaded when it is missing) **before** the model is built. A
# missing required weight raises a FileNotFoundError that names the
# file, the expected path and the manifest URL.
weight_entry = weight_entry_for(model_type_str)
ckpt_path = self._resolve_checkpoint(weight_entry)
facelib_entries = [
entry for entry in WEIGHT_MANIFEST
if entry.dest_folder == FACELIB_DEST_FOLDER
]
for entry in facelib_entries:
keep_download.ensure_required(entry)
keep_model_arch_class = ARCH_REGISTRY.get('KEEP')
if keep_model_arch_class is None:
raise RuntimeError("KEEP architecture class not found in ARCH_REGISTRY.")
net = keep_model_arch_class(**config_entry['architecture']).to(self.offload_device)
ckpt_path = load_file_from_url_comfy(
url=config_entry['url'],
folder_name=config_entry['dest_folder'],
subdir=config_entry['subdir'],
file_name=os.path.basename(config_entry['url']),
sha256=config_entry.get('sha256')
)
checkpoint = torch.load(ckpt_path, map_location=self.offload_device, weights_only=True)
state_dict_key = 'params_ema' if 'params_ema' in checkpoint else 'params'
state_dict = checkpoint.get(state_dict_key, checkpoint)
net = keep_model_arch_class(**config_entry['architecture']).to(self.offload_device)
converted_state_dict = {}
needs_conversion = any('cross_fuse' in k or 'fuse_convs_dict' in k for k in state_dict.keys())
if needs_conversion:
@@ -259,20 +282,17 @@ class KEEPModelLoader:
if 'fuse_convs_dict' in k: new_k = k.replace('fuse_convs_dict', 'cft')
converted_state_dict[new_k] = v
state_dict = converted_state_dict
net.load_state_dict(state_dict, strict=True)
net.eval()
logger.debug(f"KEEP model '{model_type_str}' loaded onto {self.offload_device}.")
for fname, (url, sha) in FACELIB_MODEL_URLS.items():
load_file_from_url_comfy(url=url, folder_name=FACELIB_DEST_FOLDER, file_name=fname, sha256=sha)
# P3.5: the facelib root comes from the registered model folders -
# the raw ``folder_paths.models_dir`` concatenation is gone. It is
# the root the facelib weights really live in (an extra model root
# included), because facelib builds the weight paths from it.
facelib_actual_paths = keep_paths.resolve_model_root(
FACELIB_DEST_FOLDER, FACELIB_MODEL_URLS.keys()
FACELIB_DEST_FOLDER, [entry.name for entry in facelib_entries]
)
FaceRestoreHelper = _face_restore_helper()
@@ -297,4 +317,22 @@ class KEEPModelLoader:
patchers=base_pack_for_cache.patchers_by_role,
)
return model_pack
return model_pack
def _resolve_checkpoint(self, weight_entry) -> str:
"""The path of the KEEP checkpoint (downloaded when it is missing).
A checkpoint that is not published upstream (the Asian one) raises an
actionable ``FileNotFoundError``: the file, the expected path and the
dead URL are named, and the user is told to place the file manually.
"""
path = keep_download.ensure_weight(weight_entry)
if path is None:
raise FileNotFoundError(
f"KEEP: the model needs the weight {weight_entry.name!r}, which "
"is not published upstream (HTTP 404).\n"
f" url: {weight_entry.url}\n"
f" dest: {keep_download.target_path(weight_entry)}\n"
"Place the file manually at the destination and try again."
)
return path
+177
View File
@@ -10,7 +10,9 @@ exactly once per execute.
from __future__ import annotations
import hashlib
import importlib
import os
import pytest
import torch
@@ -26,6 +28,11 @@ def node_module():
return importlib.import_module("ComfyUI_KEEP.nodes.keep_face_upscale_image")
@pytest.fixture(scope="module")
def configs_module():
return importlib.import_module("ComfyUI_KEEP.src.keep_model_configs")
@pytest.fixture
def model_management():
"""The mocked ``comfy.model_management`` (the calls are reset per test)."""
@@ -302,3 +309,173 @@ def test_ensure_flow_reports_a_missing_flownet(loader_module):
net.flownet = None
pack = loader_module.KEEPModelPack(net, _StubFaceHelper(), None, None, "KEEP")
assert pack.ensure_flow() is False
# ---------------------------------------------------------------------------
# P5.3 - the fail-fast weight validation
# ---------------------------------------------------------------------------
PAYLOAD = b"not a real checkpoint"
PAYLOAD_SHA256 = hashlib.sha256(PAYLOAD).hexdigest()
@pytest.fixture
def synthetic_manifest(loader_module, configs_module, monkeypatch):
"""The manifest with the hash of the test payload.
The real hashes cannot be reproduced by a test, so the manifest the
loader and the downloader see is replaced by entries that carry the hash
of the payload the transport double writes. Everything else - the
resolution order, the fail-fast rule, the error messages - is the
production code.
"""
entries = tuple(
configs_module.WeightEntry(
name=entry.name,
url=entry.url,
sha256=PAYLOAD_SHA256,
dest_folder=entry.dest_folder,
subdir=entry.subdir,
required=entry.required,
available=entry.available,
unverified=entry.unverified,
sha256_prefix=entry.sha256_prefix,
)
for entry in configs_module.WEIGHT_MANIFEST
)
by_name = {entry.name: entry for entry in entries}
monkeypatch.setattr(loader_module, "WEIGHT_MANIFEST", entries)
monkeypatch.setattr(
loader_module,
"weight_entry_for",
lambda model_type: by_name[
"KEEP_Asian-4765ebe0.pth" if model_type == "Asian" else "KEEP-b76feb75.pth"
],
)
monkeypatch.setattr(
loader_module.keep_download, "WEIGHT_MANIFEST", entries
)
return by_name
@pytest.fixture
def transport(monkeypatch):
"""The transport double: it records the URLs and writes the payload."""
download_module = importlib.import_module("ComfyUI_KEEP.src.keep_download")
calls: list[str] = []
def _download(url, dst, hash_prefix=None, progress=True):
calls.append(url)
os.makedirs(os.path.dirname(dst), exist_ok=True)
with open(dst, "wb") as handle:
handle.write(PAYLOAD)
monkeypatch.setattr(download_module, "download_url_to_file", _download)
return calls
@pytest.fixture
def loader(loader_module, monkeypatch, tmp_path, synthetic_manifest, transport):
"""A loader whose model build is stubbed out (the weights are real)."""
class _StubArch:
def __init__(self, **kwargs):
self.kwargs = kwargs
def to(self, device):
return self
def load_state_dict(self, state_dict, strict=True):
self.loaded = state_dict
return self
def eval(self):
return self
class _StubHelper:
def __init__(self, **kwargs):
self.kwargs = kwargs
monkeypatch.setattr(loader_module, "ARCH_REGISTRY", {"KEEP": _StubArch})
monkeypatch.setattr(
loader_module.torch,
"load",
lambda *args, **kwargs: {"params_ema": {"weight": torch.zeros(1)}},
)
monkeypatch.setattr(loader_module, "_face_restore_helper", lambda: _StubHelper)
monkeypatch.setattr(
loader_module.keep_paths, "resolve_model_root", lambda *args, **kwargs: str(tmp_path)
)
return loader_module.KEEPModelLoader()
@pytest.mark.unit
def test_a_missing_required_weight_triggers_a_download(loader, transport):
"""The weights are resolved (and downloaded) before the net is built."""
pack = loader.load_keep_model_pack("KEEP", "retinaface_resnet50")
assert pack.keep_net is not None
urls = [url.rsplit("/", 1)[-1] for url in transport]
assert "KEEP-b76feb75.pth" in urls, urls
for name in (
"detection_Resnet50_Final.pth",
"detection_mobilenet0.25_Final.pth",
"yolov5n-face.pth",
"yolov5l-face.pth",
"parsing_parsenet.pth",
):
assert name in urls, (name, urls)
@pytest.mark.unit
def test_a_failed_download_raises_the_actionable_message(
loader_module, loader, monkeypatch, transport
):
"""A missing required weight names the file, the path and the URL."""
download_module = importlib.import_module("ComfyUI_KEEP.src.keep_download")
def _fail(url, dst, hash_prefix=None, progress=True):
raise RuntimeError("the server is not reachable")
monkeypatch.setattr(download_module, "download_url_to_file", _fail)
with pytest.raises(FileNotFoundError) as excinfo:
loader.load_keep_model_pack("KEEP", "retinaface_resnet50")
message = str(excinfo.value)
assert "KEEP-b76feb75.pth" in message
assert "https://github.com/jnjaby/KEEP/releases/download/v1.0.0/KEEP-b76feb75.pth" in message
assert "keep_models" in message and "KEEP" in message
@pytest.mark.unit
def test_the_optional_gmflow_weight_is_not_resolved_at_pack_construction(
loader, transport
):
"""The GMFlow checkpoint is deferred to ``ensure_flow()``."""
loader.load_keep_model_pack("KEEP", "retinaface_resnet50")
urls = [url.rsplit("/", 1)[-1] for url in transport]
assert "gmflow_sintel-0c07dcb3.pth" not in urls, urls
@pytest.mark.unit
def test_the_unavailable_asian_entry_does_not_break_the_default_pack(loader, transport):
"""The Asian checkpoint is not required - the default pack still builds."""
pack = loader.load_keep_model_pack("KEEP", "retinaface_resnet50")
assert pack is not None
urls = [url.rsplit("/", 1)[-1] for url in transport]
assert "KEEP_Asian-4765ebe0.pth" not in urls, urls
@pytest.mark.unit
def test_selecting_the_asian_model_raises_an_actionable_error(loader):
"""The Asian checkpoint is not published upstream (HTTP 404)."""
with pytest.raises(FileNotFoundError) as excinfo:
loader.load_keep_model_pack("Asian", "retinaface_resnet50")
message = str(excinfo.value)
assert "KEEP_Asian-4765ebe0.pth" in message
assert "not published upstream" in message
assert "KEEP_Asian-4765ebe0.pth" in message
assert "keep_models" in message
+14 -3
View File
@@ -82,10 +82,21 @@ def stubbed_loader(loader_module, monkeypatch, tmp_path):
self.kwargs = kwargs
monkeypatch.setattr(loader_module, "ARCH_REGISTRY", {"KEEP": _StubArch})
# P5.3: the weights are resolved through the manifest downloader
monkeypatch.setattr(
loader_module,
"load_file_from_url_comfy",
lambda **kwargs: str(tmp_path / "checkpoint.pth"),
loader_module.keep_download,
"ensure_weight",
lambda entry, progress=True: str(tmp_path / "checkpoint.pth"),
)
monkeypatch.setattr(
loader_module.keep_download,
"ensure_required",
lambda entry, progress=True: str(tmp_path / "checkpoint.pth"),
)
monkeypatch.setattr(
loader_module.keep_download,
"ensure_optional",
lambda entry, progress=True: None,
)
monkeypatch.setattr(
loader_module.torch,
+13
View File
@@ -338,6 +338,17 @@ def probe(simulate_missing_ffmpeg: bool = False) -> dict:
stub_weight.write_bytes(b"stub-weight-not-a-real-checkpoint")
original_models_dir = folder_paths.models_dir
folder_paths.models_dir = str(models_dir)
# The probe must never download a weight: the stub file is not the
# real one (its sha256 does not match the manifest since P5.3), so
# the resolution would delete it and fetch ~634 MB from upstream.
# The resolution is therefore stubbed - the build itself stays real.
download_mod = sys.modules[f"{sys_module_name}.src.keep_download"]
original_ensure_weight = download_mod.ensure_weight
original_ensure_required = download_mod.ensure_required
download_mod.ensure_weight = lambda entry, progress=True: str(stub_weight)
download_mod.ensure_required = lambda entry, progress=True: str(
stub_weight.parent / entry.name
)
try:
loader = loader_mod.KEEPModelLoader()
loader.load_keep_model_pack(
@@ -357,6 +368,8 @@ def probe(simulate_missing_ffmpeg: bool = False) -> dict:
"stub_weight_present": stub_weight.is_file(),
}
finally:
download_mod.ensure_weight = original_ensure_weight
download_mod.ensure_required = original_ensure_required
folder_paths.models_dir = original_models_dir
pip_module.main = pip_main_original