Files
Praveenhalder ca88d8156d Prune notebooks/configs; add Node wrapper
Remove a large set of example Jupyter notebooks and development/CI config files (pre-commit, pylint, Travis, tox, dev requirements, environment, setup and various resources) to slim the repository. Add a Python package entrypoint (__init__.py) and a new deoldify_node.py module to support Node integration. Update README.md and requirements.txt accordingly.
2026-02-25 03:19:12 +05:30

402 lines
14 KiB
Python

"""
DeOldify ComfyUI Custom Node
Integrates DeOldify colorization into ComfyUI pipelines.
Setup:
1. Clone DeOldify into ComfyUI/custom_nodes/deoldify_comfyui_node/deoldify/
2. Place weights in ComfyUI/models/deoldify/models/
ColorizeArtistic_gen.pth
ColorizeStable_gen.pth
— OR — let the node auto-download them from HuggingFace on first use.
3. pip install fastai==1.0.61 pandas opencv-python IPython scipy scikit-image matplotlib bottleneck numexpr
"""
import sys
import shutil
import logging
from pathlib import Path
import torch
import numpy as np
from PIL import Image as PilImage, ImageEnhance
import comfy.model_management
from huggingface_hub import hf_hub_download
import folder_paths # ComfyUI's path registry
# ---------------------------------------------------------------------------
# Paths
# ---------------------------------------------------------------------------
NODE_DIR = Path(__file__).parent.resolve()
DEOLDIFY_DIR = NODE_DIR / "deoldify"
# fastai resolves: root_folder / "models" / weights_name + ".pth"
DEOLDIFY_MODELS_DIR = Path(folder_paths.models_dir) / "deoldify"
DEOLDIFY_MODELS_DIR.mkdir(parents=True, exist_ok=True)
(DEOLDIFY_MODELS_DIR / "models").mkdir(parents=True, exist_ok=True)
# HuggingFace repo that hosts the DeOldify weights
HF_REPO_ID = "spensercai/DeOldify"
if str(NODE_DIR) not in sys.path:
sys.path.insert(0, str(NODE_DIR))
# ---------------------------------------------------------------------------
# PyTorch 2.6 compat — fastai checkpoints embed arbitrary classes
# ---------------------------------------------------------------------------
def _patch_torch_load():
import torch as _t
if getattr(_t.load, "_deoldify_patched", False):
return
_orig = _t.load
def _patched(f, map_location=None, pickle_module=None, **kw):
kw["weights_only"] = False
if pickle_module is not None:
return _orig(f, map_location=map_location, pickle_module=pickle_module, **kw)
return _orig(f, map_location=map_location, **kw)
_patched._deoldify_patched = True
_t.load = _patched
logging.info("[DeOldify] Patched torch.load → weights_only=False")
_patch_torch_load()
# ---------------------------------------------------------------------------
# Dependency check
# ---------------------------------------------------------------------------
def _ok(name):
try: __import__(name); return True
except ImportError: return False
def _check_dependencies():
REQUIRED = [
("fastai==1.0.61", "fastai"), ("pandas", "pandas"),
("opencv-python", "cv2"), ("IPython", "IPython"),
("scipy", "scipy"), ("scikit-image", "skimage"),
("matplotlib", "matplotlib"), ("bottleneck", "bottleneck"),
("numexpr", "numexpr"),
]
missing = [pip for pip, imp in REQUIRED if not _ok(imp)]
if missing:
cmd = " ".join(missing)
raise ImportError(
f"Missing packages: {', '.join(missing)}\n\n"
f"ComfyUI portable (Windows, run from ComfyUI root):\n"
f" .\\python_embeds\\python.exe -m pip install {cmd}\n\n"
f"Standard Python / venv:\n"
f" pip install {cmd}"
)
def _import_deoldify():
_check_dependencies()
try:
from deoldify import device as device_settings
from deoldify.device_id import DeviceId
from deoldify.generators import gen_inference_wide, gen_inference_deep
from deoldify.filters import ColorizerFilter, MasterFilter
return device_settings, DeviceId, gen_inference_wide, gen_inference_deep, ColorizerFilter, MasterFilter
except ImportError as e:
raise ImportError(
f"Could not import DeOldify. Clone it into:\n {DEOLDIFY_DIR}\n"
f" git clone https://github.com/jantic/DeOldify \"{DEOLDIFY_DIR}\"\n"
f"Original error: {e}"
)
# ---------------------------------------------------------------------------
# Auto-download weights from HuggingFace
# ---------------------------------------------------------------------------
# Map model_type → filename expected inside the HF snapshot
_HF_WEIGHT_FILES = {
"stable": "ColorizeStable_gen.pth",
"video": "ColorizeVideo_gen.pth",
}
def _download_weights_from_hf(weights_name: str, dest_path: Path) -> None:
"""
Download a single DeOldify weight file from HuggingFace using hf_hub_download
(downloads only the requested .pth, not the entire repo) then copies it to dest_path.
"""
logging.info(
f"[DeOldify] Weights not found at {dest_path}. "
f"Downloading '{weights_name}' from HuggingFace repo '{HF_REPO_ID}' …"
)
# Notify ComfyUI that we're about to do a potentially slow download
try:
comfy.model_management.load_models_gpu([]) # no-op, but touches the subsystem
except Exception:
pass
print(
f"\n[DeOldify] ⬇ Downloading {weights_name} from HuggingFace: {HF_REPO_ID}/{weights_name}\n"
f" Destination: {dest_path}\n"
f" This may take a few minutes on first run …\n"
)
try:
cached_file = hf_hub_download(
repo_id=HF_REPO_ID,
filename=weights_name,
)
except Exception as e:
raise RuntimeError(
f"[DeOldify] Failed to download '{weights_name}' from HuggingFace repo '{HF_REPO_ID}'.\n"
f" Make sure you have internet access and 'huggingface_hub' installed.\n"
f" Original error: {e}"
)
dest_path.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(cached_file, dest_path)
logging.info(f"[DeOldify] Weights saved → {dest_path}")
print(f"[DeOldify] ✓ Weights ready: {dest_path}\n")
def _ensure_weights(weights_name: str) -> Path:
"""
Return the Path to a ready-to-use .pth file, downloading from HuggingFace
automatically if it is missing.
Resolution order:
1. DEOLDIFY_MODELS_DIR/models/<weights_name>.pth (canonical fastai path)
2. DEOLDIFY_MODELS_DIR/<weights_name>.pth (flat layout — moved to canonical)
3. Auto-download from HuggingFace → canonical path
"""
nested_path = DEOLDIFY_MODELS_DIR / "models" / f"{weights_name}.pth"
flat_path = DEOLDIFY_MODELS_DIR / f"{weights_name}.pth"
if nested_path.exists():
return nested_path
if flat_path.exists():
flat_path.rename(nested_path)
logging.info(f"[DeOldify] Moved {flat_path.name} → models/")
return nested_path
# Neither location has the file — auto-download
_download_weights_from_hf(f"{weights_name}.pth", nested_path)
if not nested_path.exists():
raise FileNotFoundError(
f"[DeOldify] Download appeared to succeed but file is still missing: {nested_path}"
)
return nested_path
# ---------------------------------------------------------------------------
# Infer nf_factor from the checkpoint itself
# ---------------------------------------------------------------------------
def _infer_nf_factor(weights_path: Path) -> int:
"""
Read the saved state_dict and derive nf_factor from the first UnetBlock
pixel-shuffle conv weight shape, without instantiating any model.
DynamicUnetWide sets: nf = 512 * nf_factor
UnetBlockWide.shuf: CustomPixelShuffle_ICNR(up_in_c, nf//2, ...)
conv weight shape: (nf//2 * scale**2, up_in_c, 1, 1)
= (nf//2 * 4, up_in_c, 1, 1)
For the first decoder block (layers.4), up_in_c == ni (encoder bottleneck,
always 2048 for resnet101). So:
weight shape[0] = nf//2 * 4 = 512 * nf_factor * 2
nf_factor = weight_shape[0] / (512 * 2) = weight_shape[0] / 1024
Returns the inferred integer nf_factor (1 or 2) or 2 as a safe default.
"""
try:
ckpt = torch.load(str(weights_path), map_location="cpu")
sd = ckpt.get("model", ckpt) if isinstance(ckpt, dict) else ckpt
key = "layers.4.shuf.conv.0.weight_orig"
if key in sd:
out_channels = sd[key].shape[0]
nf_factor = round(out_channels / 1024)
logging.info(f"[DeOldify] Inferred nf_factor={nf_factor} from checkpoint "
f"(key={key}, shape[0]={out_channels})")
return max(1, nf_factor)
except Exception as e:
logging.warning(f"[DeOldify] Could not infer nf_factor from checkpoint: {e}")
logging.info("[DeOldify] Falling back to nf_factor=2")
return 2
# ---------------------------------------------------------------------------
# Colorizer cache
# ---------------------------------------------------------------------------
_colorizer_cache: dict = {}
def _get_colorizer(model_type: str, render_factor: int, device: str):
cache_key = (model_type, device)
if cache_key in _colorizer_cache:
return _colorizer_cache[cache_key]
device_settings, DeviceId, gen_inference_wide, gen_inference_deep, ColorizerFilter, MasterFilter = _import_deoldify()
if device == "cuda" and torch.cuda.is_available():
device_settings.set(DeviceId.GPU0)
else:
device_settings.set(DeviceId.CPU)
weights_map = {
"stable": "ColorizeStable_gen",
"video": "ColorizeVideo_gen",
}
weights_name = weights_map[model_type]
# Ensure weights exist, downloading from HuggingFace if needed
nested_path = _ensure_weights(weights_name)
# Derive nf_factor by inspecting the checkpoint — avoids all guesswork
nf_factor = _infer_nf_factor(nested_path)
if model_type == "artistic":
learn = gen_inference_wide(
root_folder=DEOLDIFY_MODELS_DIR,
weights_name=weights_name,
nf_factor=nf_factor,
)
logging.info(f"[DeOldify] Loaded {weights_name} — arch=wide, nf_factor={nf_factor}")
else:
# stable: try deep first (official), fall back to wide if shapes mismatch
ckpt = torch.load(str(nested_path), map_location="cpu")
sd = ckpt.get("model", ckpt) if isinstance(ckpt, dict) else ckpt
is_deep = any("layers.4.conv1" in k for k in sd.keys())
if is_deep:
learn = gen_inference_deep(
root_folder=DEOLDIFY_MODELS_DIR,
weights_name=weights_name,
nf_factor=float(nf_factor),
)
logging.info(f"[DeOldify] Loaded {weights_name} — arch=deep, nf_factor={nf_factor}")
else:
learn = gen_inference_wide(
root_folder=DEOLDIFY_MODELS_DIR,
weights_name=weights_name,
nf_factor=nf_factor,
)
logging.info(f"[DeOldify] Loaded {weights_name} — arch=wide, nf_factor={nf_factor}")
colorizer = MasterFilter(
filters=[ColorizerFilter(learn=learn)],
render_factor=render_factor,
)
_colorizer_cache[cache_key] = colorizer
return colorizer
# ---------------------------------------------------------------------------
# Tensor <-> PIL (ComfyUI: BHWC float32 [0,1])
# ---------------------------------------------------------------------------
def _tensor_to_pil(tensor: torch.Tensor) -> PilImage.Image:
np_img = (tensor.cpu().numpy() * 255).clip(0, 255).astype(np.uint8)
return PilImage.fromarray(np_img, mode="RGB")
def _pil_to_tensor(image: PilImage.Image) -> torch.Tensor:
np_img = np.array(image.convert("RGB")).astype(np.float32) / 255.0
return torch.from_numpy(np_img)
def _adjust_saturation(image: PilImage.Image, factor: float) -> PilImage.Image:
"""
Adjust the color saturation of a PIL image.
factor = 0.0 → fully desaturated (grayscale)
factor = 1.0 → original saturation (no change)
factor > 1.0 → boosted saturation
"""
if factor == 1.0:
return image
return ImageEnhance.Color(image).enhance(factor)
# ---------------------------------------------------------------------------
# ComfyUI Nodes
# ---------------------------------------------------------------------------
class DeOldifyColorize:
"""Colorizes a grayscale or faded image using DeOldify."""
CATEGORY = "image/color"
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("image",)
FUNCTION = "colorize"
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"image": ("IMAGE",),
"model_type": (["stable", "video" ], {"default": "stable"}),
"render_factor": ("INT", {"default": 35, "min": 8, "max": 45, "step": 1}),
"post_process": ("BOOLEAN", {"default": True}),
"saturation": ("FLOAT", {
"default": 0.75,
"min": 0.0,
"max": 4.0,
"step": 0.05,
"display": "slider",
}),
"device": (["cuda", "cpu"], {"default": "cuda"}),
}
}
def colorize(self, image, model_type="stable", render_factor=35,
post_process=True, saturation=1.0, device="cuda"):
colorizer = _get_colorizer(model_type, render_factor, device)
frames = []
for i in range(image.shape[0]):
pil_in = _tensor_to_pil(image[i])
pil_out = colorizer.filter(
orig_image=pil_in,
filtered_image=pil_in,
render_factor=render_factor,
post_process=post_process,
)
pil_out = _adjust_saturation(pil_out, saturation)
frames.append(_pil_to_tensor(pil_out))
return (torch.stack(frames, dim=0),)
class DeOldifyClearCache:
"""Clears the in-memory DeOldify model cache to free VRAM."""
CATEGORY = "image/color"
RETURN_TYPES = ()
FUNCTION = "clear"
OUTPUT_NODE = True
@classmethod
def INPUT_TYPES(cls):
return {"required": {}}
def clear(self):
_colorizer_cache.clear()
torch.cuda.empty_cache()
logging.info("[DeOldify] Model cache cleared.")
return {}
# ---------------------------------------------------------------------------
# Registration
# ---------------------------------------------------------------------------
NODE_CLASS_MAPPINGS = {
"DeOldifyColorize": DeOldifyColorize,
"DeOldifyClearCache": DeOldifyClearCache,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"DeOldifyColorize": "DeOldify Colorize",
"DeOldifyClearCache": "DeOldify Clear Cache",
}