From 916ecb770857ca2252fe5ddefdfe0f919ad409a6 Mon Sep 17 00:00:00 2001 From: Michele Balistreri Date: Fri, 14 Aug 2026 18:53:42 +0200 Subject: [PATCH 1/6] initial esrgan implementation --- scripts/download_esrgan.py | 39 ++++++ tests/test_upscale.py | 108 ++++++++++++++++ thenoise/__main__.py | 1 + thenoise/api.py | 4 + thenoise/cli.py | 13 ++ thenoise/generate.py | 3 + thenoise/models/anima.py | 2 + thenoise/models/base.py | 209 +++++++++++++++++++++++++++---- thenoise/models/krea2.py | 2 + thenoise/runtime.py | 2 + thenoise/ui/index.html | 48 +++++++- thenoise/upscale/__init__.py | 4 + thenoise/upscale/esrgan.py | 231 +++++++++++++++++++++++++++++++++++ thenoise/utils/png.py | 7 ++ 14 files changed, 649 insertions(+), 24 deletions(-) create mode 100644 scripts/download_esrgan.py create mode 100644 tests/test_upscale.py create mode 100644 thenoise/upscale/esrgan.py diff --git a/scripts/download_esrgan.py b/scripts/download_esrgan.py new file mode 100644 index 0000000..2279456 --- /dev/null +++ b/scripts/download_esrgan.py @@ -0,0 +1,39 @@ +"""Download the Real-ESRGAN x4 model (ComfyUI repackage) for pixel upscaling. + +Source: https://huggingface.co/Comfy-Org/Real-ESRGAN_repackaged + RealESRGAN_x4plus.safetensors + +Used by thenoise's ``fast`` upscale path, and by the ``refined`` path when +``--upscale-factor`` exceeds the latent 2x. Optional: if the model is absent +only the refiner (latent) upscale is available. + +Usage: + python scripts/download_esrgan.py --out ./models/esrgan +""" +from __future__ import annotations + +import argparse +from pathlib import Path + +from huggingface_hub import hf_hub_download + +REPO = "Comfy-Org/Real-ESRGAN_repackaged" +FILE = "RealESRGAN_x4plus.safetensors" + + +def main() -> None: + ap = argparse.ArgumentParser(description="Download the Real-ESRGAN x4 model") + ap.add_argument( + "--out", default="./models/esrgan", help="output directory" + ) + args = ap.parse_args() + + out = Path(args.out) + out.mkdir(parents=True, exist_ok=True) + + path = hf_hub_download(repo_id=REPO, filename=FILE, local_dir=out) + print(f"saved ESRGAN model to {path}") + + +if __name__ == "__main__": + main() diff --git a/tests/test_upscale.py b/tests/test_upscale.py new file mode 100644 index 0000000..97e786f --- /dev/null +++ b/tests/test_upscale.py @@ -0,0 +1,108 @@ +"""Upscale factor/type planning tests (no torch, no weights).""" +from __future__ import annotations + +import pytest + +from thenoise.models.base import DiffusionModel + + +def _make_model(esrgan_path=None, esrgan_scale=None): + """Build a minimal concrete subclass (bypassing __init__ / VAE).""" + + class _M(DiffusionModel): + name = "test" + + @staticmethod + def detect(f): + return False + + def encode_prompt(self, prompt, negative_prompt="", *, guidance_scale): + pass + + def init_latents(self, height, width, seed): + pass + + def schedule(self, steps, height, width): + pass + + def denoise_step(self, latents, t, cond, guidance_scale, i): + pass + + def _upscale_format(self): + return "wan21" + + m = object.__new__(_M) + m.esrgan_path = esrgan_path + m._esrgan_scale_val = esrgan_scale + return m + + +def test_esrgan_scale_mapping_with_4x(): + m = _make_model(esrgan_path="/tmp/x4.safetensors", esrgan_scale=4) + # refined: latent gives 2x, ESRGAN 4x only above the latent 2x. + assert m._esrgan_scale_for(1.0, "refined") == 0 + assert m._esrgan_scale_for(2.0, "refined") == 0 + assert m._esrgan_scale_for(2.5, "refined") == 4 + assert m._esrgan_scale_for(8.0, "refined") == 4 + assert m._esrgan_scale_for(0.5, "refined") == 0 + # fast: no latent multiplier, always ESRGAN for any upscale. + assert m._esrgan_scale_for(1.5, "fast") == 4 + assert m._esrgan_scale_for(4.0, "fast") == 4 + assert m._esrgan_scale_for(0.5, "fast") == 0 + + +def test_esrgan_scale_mapping_with_2x(): + m = _make_model(esrgan_path="/tmp/x2.safetensors", esrgan_scale=2) + assert m._esrgan_scale_for(2.0, "refined") == 0 + assert m._esrgan_scale_for(2.5, "refined") == 2 + assert m._esrgan_scale_for(4.0, "refined") == 2 + assert m._esrgan_scale_for(1.5, "fast") == 2 + assert m._esrgan_scale_for(2.0, "fast") == 2 + + +def test_resolve_valid_refined_without_esrgan(): + m = _make_model(esrgan_path=None) + # f <= latent 2x in refined mode needs no ESRGAN. + assert m._resolve_upscale(1.0, "refined") == (1.0, "refined") + assert m._resolve_upscale(2.0, "refined") == (2.0, "refined") + + +def test_resolve_needs_esrgan_when_absent(): + m = _make_model(esrgan_path=None) + with pytest.raises(ValueError): + m._resolve_upscale(2.5, "refined") + with pytest.raises(ValueError): + m._resolve_upscale(1.5, "fast") + + +def test_resolve_max_ranges_depend_on_scale(): + # 4x model: refined up to 8, fast up to 4. + m4 = _make_model("/tmp/x4.safetensors", esrgan_scale=4) + assert m4._resolve_upscale(8.0, "refined") == (8.0, "refined") + assert m4._resolve_upscale(4.0, "fast") == (4.0, "fast") + with pytest.raises(ValueError): + m4._resolve_upscale(5.0, "fast") + with pytest.raises(ValueError): + m4._resolve_upscale(9.0, "refined") + + # 2x model: refined up to 4, fast up to 2. + m2 = _make_model("/tmp/x2.safetensors", esrgan_scale=2) + assert m2._resolve_upscale(4.0, "refined") == (4.0, "refined") + assert m2._resolve_upscale(2.0, "fast") == (2.0, "fast") + with pytest.raises(ValueError): + m2._resolve_upscale(5.0, "refined") + with pytest.raises(ValueError): + m2._resolve_upscale(3.0, "fast") + + +def test_resolve_invalid_factor(): + m = _make_model("/tmp/x4.safetensors", esrgan_scale=4) + for bad in (0.0, -1.0, 8.5): + with pytest.raises(ValueError): + m._resolve_upscale(bad, "refined") + + +def test_resolve_invalid_type(): + m = _make_model("/tmp/x4.safetensors", esrgan_scale=4) + with pytest.raises(ValueError): + m._resolve_upscale(2.0, "bogus") diff --git a/thenoise/__main__.py b/thenoise/__main__.py index e2eadad..be330ae 100644 --- a/thenoise/__main__.py +++ b/thenoise/__main__.py @@ -15,6 +15,7 @@ def _serve(args) -> None: vae_path=args.vae, text_encoder_path=args.text_encoder, lora_dir=args.lora_dir, + esrgan_path=args.esrgan, ), ) diff --git a/thenoise/api.py b/thenoise/api.py index aca1b90..7cbea39 100644 --- a/thenoise/api.py +++ b/thenoise/api.py @@ -38,6 +38,8 @@ class Text2ImageRequest(BaseModel): guidance_scale: Optional[float] = None seed: Optional[int] = None upscale: bool = False + upscale_factor: float = 1.0 + upscale_type: str = "refined" sampler: Optional[str] = None qwen_vae_enhance: bool = False film_grain: float = 0.0 @@ -83,6 +85,8 @@ def text2image(req: Text2ImageRequest): guidance_scale=req.guidance_scale, seed=req.seed, upscale=req.upscale, + upscale_factor=req.upscale_factor, + upscale_type=req.upscale_type, sampler=req.sampler, qwen_vae_enhance=req.qwen_vae_enhance, film_grain=req.film_grain, diff --git a/thenoise/cli.py b/thenoise/cli.py index c8eed78..34157f3 100644 --- a/thenoise/cli.py +++ b/thenoise/cli.py @@ -23,6 +23,10 @@ def _add_model_paths(p: argparse.ArgumentParser) -> None: p.add_argument("--lora-dir", default="", metavar="PATH", help="directory containing LoRA .safetensors files " "(subdirectories allowed)") + p.add_argument("--esrgan", default="", metavar="PATH", + help="optional Real-ESRGAN model (.safetensors) for pixel " + "upscaling; without it only refiner (latent) upscale is " + "available") def build_parser() -> argparse.ArgumentParser: @@ -57,6 +61,15 @@ def build_parser() -> argparse.ArgumentParser: gen.add_argument("--upscale", action="store_true", help="upscale the latent 2x in latent space (SesquiLSR) and " "run a low-strength refine denoise before decoding") + gen.add_argument("--upscale-factor", type=float, default=1.0, + help="upscale factor, > 0.0 (default: 1.0 = no upscale); " + "max depends on the ESRGAN model scale: 'fast' is " + "limited to the model scale, 'refined' to latent 2x * " + "model scale") + gen.add_argument("--upscale-type", choices=["refined", "fast"], + default="refined", + help="'refined' (default): latent 2x + refiner, plus ESRGAN " + "above factor 2; 'fast': ESRGAN only (no latent 2x)") gen.add_argument("--sampler", choices=["euler", "er_sde"], default=None, help="denoising solver (default: er_sde)") gen.add_argument("--qwen-vae-enhance", action="store_true", diff --git a/thenoise/generate.py b/thenoise/generate.py index 0a42a5e..c50adca 100644 --- a/thenoise/generate.py +++ b/thenoise/generate.py @@ -24,6 +24,7 @@ def run_generate(args) -> None: vae_path=args.vae, text_encoder_path=args.text_encoder, lora_dir=args.lora_dir, + esrgan_path=args.esrgan, ), ) @@ -37,6 +38,8 @@ def run_generate(args) -> None: guidance_scale=args.guidance_scale, seed=seed, upscale=args.upscale, + upscale_factor=args.upscale_factor, + upscale_type=args.upscale_type, sampler=args.sampler, qwen_vae_enhance=args.qwen_vae_enhance, film_grain=args.film_grain, diff --git a/thenoise/models/anima.py b/thenoise/models/anima.py index 8d0f499..49e79be 100644 --- a/thenoise/models/anima.py +++ b/thenoise/models/anima.py @@ -58,6 +58,7 @@ def __init__( device: str = "cuda", dtype: torch.dtype = torch.bfloat16, lora_dir: Optional[str] = None, + esrgan_path: Optional[str] = None, ): super().__init__( dit_path=dit_path, @@ -66,6 +67,7 @@ def __init__( device=device, dtype=dtype, lora_dir=lora_dir, + esrgan_path=esrgan_path, ) logger.info("Loading Anima DiT from %s", dit_path) diff --git a/thenoise/models/base.py b/thenoise/models/base.py index 85ee2c2..299b62e 100644 --- a/thenoise/models/base.py +++ b/thenoise/models/base.py @@ -34,6 +34,23 @@ moved to CPU inside ``_to_pil``. Metadata that must live on the final PNG is a separate concern and is added later, after the PIL conversion. +Upscaling has two modes, driven by ``upscale_factor`` (f in (0.0, 8.0]) and +``upscale_type``: + + * ``refined`` (default): the latent (Sesqui) upscaler runs ``UPSCALE_SCALE``x + in latent space followed by a low-strength refine denoise. For f in (1, 2] + that 2x is the whole upscale; for f in (2, ...] a pixel-domain Real-ESRGAN + step (scale auto-detected from the model, 2/4/8) is added on the decoded + image and the result is downscaled to f. + * ``fast``: only the pixel-domain Real-ESRGAN step runs on the decoded image + (no latent 2x multiplier), so f is capped at the detected ESRGAN scale. + +ESRGAN requires an ``esrgan_path`` (optional CLI ``--esrgan``); without it only +``refined`` factors <= 2 are available. Max factor ranges follow the detected +model scale: ``refined`` up to ``UPSCALE_SCALE * esrgan_scale``, ``fast`` up to +``esrgan_scale``. ESRGAN is fast and is deliberately *not* pipeline-cached — +only the decoded VAE output is cached. + Pipeline caching ---------------- Each stage of the generate pipeline is cached (single-entry, on-device tensors). @@ -43,12 +60,14 @@ at any stage invalidates that stage and all downstream stages. Stage | Cache key depends on | Cached value - ---------------+-----------------------------------------+--------------- + ---------------+-----------------------------------------------+----------- Prompt | prompt, negative_prompt, guidance_scale, lora_specs | Conditioning Sampling | prompt_key + size, steps, seed, sampler, lora_specs | latents Upscale+refine | (driven by decode cache hit below) | — - VAE decode | sampling_key (+ upscale constants) | pixels (fp32) + VAE decode | sampling_key + refined constants | pixels (fp32) + Notch filter | (not cached; runs right after decode) | — Postprocess | (not cached — cheap) | — + ESRGAN/resize | (not cached — fast) | — LoRA switching --------------- @@ -69,10 +88,11 @@ from typing import Dict, List, Optional, Tuple import torch +import torch.nn.functional as F from PIL import Image from safetensors.torch import load_file -from thenoise.upscale import load_upscaler +from thenoise.upscale import load_upscaler, load_esrgan, detect_esrgan_scale from thenoise.samplers import Step, create_sampler from thenoise.samplers.euler import EulerSampler from thenoise.utils.lora import apply_lora_to_model, undo_lora_on_model @@ -164,6 +184,7 @@ def __init__( device: str = "cuda", dtype: torch.dtype = torch.bfloat16, lora_dir: Optional[str] = None, + esrgan_path: Optional[str] = None, ): self.device = device self.dtype = dtype @@ -171,6 +192,7 @@ def __init__( self.vae_path = vae_path self.text_encoder_path = text_encoder_path self.lora_dir = lora_dir + self.esrgan_path = esrgan_path self._lock = threading.Lock() torch._dynamo.config.recompile_limit = 64 @@ -190,6 +212,10 @@ def __init__( self._upscaler = None self._adaptor = None + # Lazy pixel-domain Real-ESRGAN upscaler (only if ``esrgan_path`` set). + self._esrgan = None + self._esrgan_scale_val: Optional[int] = None # detected scale, cached + # ------------------------------------------------------------------ hooks @abstractmethod def encode_prompt( @@ -419,18 +445,20 @@ def _cache_key_sampling( def _cache_key_decode( self, sampling_key: Tuple, - upscale: bool, + refined: bool, ) -> Tuple: """Cache key for the VAE decode stage. Embeds the sampling key so any upstream change cascades. - When upscale is True the upscale-and-refine pipeline produces - different latents, so the upscale class-constants are added. + When ``refined`` is True the latent upscale-and-refine pipeline produces + different latents (at 2x), so the refined constants are added to the key. + The pixel-domain ESRGAN step is deliberately NOT cached (it is fast), so + it does not participate in the key. """ - if not upscale: + if not refined: return ("decode", sampling_key) return ( - "decode_upscale", + "decode_refined", sampling_key, self.UPSCALE_SCALE, self.REFINE_STEPS, @@ -449,6 +477,8 @@ def generate( guidance_scale: Optional[float] = None, seed: Optional[int] = None, upscale: bool = False, + upscale_factor: float = 1.0, + upscale_type: str = "refined", sampler: Optional[str] = None, qwen_vae_enhance: bool = False, film_grain: float = 0.0, @@ -472,11 +502,19 @@ def generate( else guidance_scale ) - target_width, target_height = width, height - if upscale: - target_width *= self.UPSCALE_SCALE - target_height *= self.UPSCALE_SCALE + # Resolve upscale parameters: ``upscale`` is a legacy alias for a 2x + # refined upscale; an explicit factor/type overrides it. + if upscale and upscale_factor == 1.0: + upscale_factor = float(self.UPSCALE_SCALE) + factor, upscale_type = self._resolve_upscale(upscale_factor, upscale_type) width, height = self.resolve_size(width, height) + target_width = width + target_height = height + if factor != 1.0: + target_width = round(width * factor) + target_height = round(height * factor) + refined = upscale_type == "refined" and factor > 1.0 + esrgan_scale = self._esrgan_scale_for(factor, upscale_type) effective_sampler = sampler or self.SAMPLER # seed=-1 is treated as "random" (same as None) @@ -490,9 +528,7 @@ def generate( sampling_key = self._cache_key_sampling( prompt_key, width, height, steps, seed, effective_sampler ) - decode_key = self._cache_key_decode( - sampling_key, upscale, - ) + decode_key = self._cache_key_decode(sampling_key, refined) # --- locked section: cache checks + model access --- with self._lock: @@ -522,18 +558,27 @@ def generate( if self._cache.decode_hit(decode_key): pixels = self._cache.decode_get() else: - # Cache miss — run upscale (if requested) then decode - if upscale: + # Cache miss — run latent upscale+refine (if refined) then decode + if refined: latents = self._upscale_and_refine( latents, cond, steps, height, width, seed, guidance_scale ) pixels = self.decode(latents) # fp32 GPU tensor [C,H,W] self._cache.decode_store(decode_key, pixels) + # Qwen notch filter must run immediately after VAE decode (before any + # pixel-domain upscaling), so the 2px grid pattern is removed at its + # native resolution. + if qwen_vae_enhance: + pixels = nyquist_notch(pixels) + + # Pixel-domain ESRGAN (fast, not cached) + GPU resize to target size. + pixels = self._esrgan_step(pixels, esrgan_scale) + pixels = self._resize_to_target(pixels, target_width, target_height) + # Stage 5: postprocess (cheap — not cached) pixels = self.postprocess( pixels, - qwen_vae_enhance=qwen_vae_enhance, film_grain_strength=film_grain, sharpening=sharpening, ) @@ -553,6 +598,8 @@ def generate( guidance_scale=guidance_scale, seed=seed, upscale=upscale, + upscale_factor=factor, + upscale_type=upscale_type, sampler=effective_sampler, qwen_vae_enhance=qwen_vae_enhance, film_grain=film_grain, @@ -608,6 +655,129 @@ def _load_upscaler(self): ) return self._upscaler, self._adaptor + # ------------------------------------------------------------- upscale plan + def _resolve_upscale( + self, + factor: float, + upscale_type: str, + ) -> tuple[float, str]: + """Validate and return the effective (factor, type). + + ``upscale_factor`` must be in (0.0, 8.0]. ``fast`` mode has no latent + 2x multiplier so it is capped at 4.0. ESRGAN (required for ``fast`` and + for ``refined`` factors above the latent 2x) must have been configured + via ``esrgan_path``. + """ + if upscale_type not in ("refined", "fast"): + raise ValueError( + f"upscale_type must be 'refined' or 'fast', got {upscale_type!r}" + ) + if not 0.0 < factor <= 8.0: + raise ValueError("upscale_factor must be in (0.0, 8.0]") + esrgan = self._esrgan_scale # detected scale (0 = no ESRGAN configured) + if esrgan == 0: + # No ESRGAN: only ``refined`` factors up to the latent 2x work. + if factor > self.UPSCALE_SCALE: + raise ValueError( + f"upscale_factor > {self.UPSCALE_SCALE} requires a " + "Real-ESRGAN model; pass --esrgan PATH (or run " + "scripts/download_esrgan.py)" + ) + if upscale_type == "fast" and factor > 1.0: + raise ValueError( + "upscale_type='fast' requires a Real-ESRGAN model; " + "pass --esrgan PATH (or run scripts/download_esrgan.py)" + ) + else: + # Max factor depends on the detected model scale: refined gets the + # latent 2x multiplier, fast does not. + max_refined = self.UPSCALE_SCALE * esrgan + if upscale_type == "refined" and factor > max_refined: + raise ValueError( + f"upscale_type='refined' with a {esrgan}x ESRGAN model is " + f"limited to factor {max_refined}" + ) + if upscale_type == "fast" and factor > esrgan: + raise ValueError( + f"upscale_type='fast' with a {esrgan}x ESRGAN model is " + f"limited to factor {esrgan}" + ) + return factor, upscale_type + + @property + def _esrgan_scale(self) -> int: + """Detected scale of the configured ESRGAN model (0 if none), cached. + + Reads only the safetensors header on first use; the value is then reused. + Tests may set ``_esrgan_scale_val`` directly to skip file access. + """ + if self._esrgan_scale_val is None: + if not self.esrgan_path: + self._esrgan_scale_val = 0 + else: + self._esrgan_scale_val = detect_esrgan_scale(self.esrgan_path) + return self._esrgan_scale_val + + def _esrgan_scale_for(self, factor: float, upscale_type: str) -> int: + """Pixel-domain ESRGAN scale for a (factor, type), or 0 to skip. + + ``refined`` gets a 2x from the latent path, so ESRGAN is only needed when + the factor exceeds that 2x. ``fast`` has no latent multiplier and always + needs ESRGAN for any upscale. Uses the detected model scale. + """ + esrgan = self._esrgan_scale + if esrgan == 0 or factor <= 1.0: + return 0 + if upscale_type == "fast": + return esrgan + return esrgan if factor > self.UPSCALE_SCALE else 0 + + def _load_esrgan(self): + """Load the pixel-domain ESRGAN model (once, lazily, under the lock).""" + if self._esrgan is None: + if not self.esrgan_path: + raise ValueError( + "no ESRGAN model configured; pass --esrgan PATH " + "(or run scripts/download_esrgan.py)" + ) + self._esrgan, scale = load_esrgan(self.esrgan_path, device=self.device) + self._esrgan_scale_val = scale + return self._esrgan + + def _esrgan_step(self, pixels: torch.Tensor, scale: int) -> torch.Tensor: + """Apply the pixel-domain ESRGAN upscale by ``scale``x (if > 0). + + The Real-ESRGAN model operates on RGB in [0, 1] (see its ``enhance`` + path), while the pipeline's decoded pixels are in [-1, 1]. Convert to + [0, 1] before the model and back to [-1, 1] afterwards so downstream + postprocessing / ``_to_pil`` stay unchanged. + """ + if not scale: + return pixels + model = self._load_esrgan() + x = (pixels.unsqueeze(0) + 1.0) / 2.0 # [-1, 1] -> [0, 1] + with torch.no_grad(): + out = model.forward_tiled(x) + out = out * 2.0 - 1.0 # [0, 1] -> [-1, 1] + return out[0] + + @staticmethod + def _resize_to_target( + pixels: torch.Tensor, target_w: int, target_h: int + ) -> torch.Tensor: + """GPU bilinear resize of ``[C, H, W]`` to the target size (no-op if equal).""" + c, h, w = pixels.shape + if (w, h) == (target_w, target_h): + return pixels + with torch.no_grad(): + return F.interpolate( + pixels.unsqueeze(0), + size=(target_h, target_w), + mode="bilinear", + align_corners=False, + )[0] + + def _upscale_and_refine( self, latents: torch.Tensor, @@ -691,13 +861,10 @@ def postprocess( self, pixels: torch.Tensor, *, - qwen_vae_enhance: bool = False, film_grain_strength: float = 0.0, sharpening: float = 0.0, ) -> torch.Tensor: """Tensor post-processing hook. Runs on the fp32 GPU pixels.""" - if qwen_vae_enhance: - pixels = nyquist_notch(pixels) if sharpening > 0.0: pixels = rcas(pixels, strength=sharpening) if film_grain_strength > 0.0: diff --git a/thenoise/models/krea2.py b/thenoise/models/krea2.py index 9b7443a..c4afdd4 100644 --- a/thenoise/models/krea2.py +++ b/thenoise/models/krea2.py @@ -62,6 +62,7 @@ def __init__( device: str = "cuda", dtype: torch.dtype = torch.bfloat16, lora_dir: Optional[str] = None, + esrgan_path: Optional[str] = None, ): super().__init__( dit_path=dit_path, @@ -70,6 +71,7 @@ def __init__( device=device, dtype=dtype, lora_dir=lora_dir, + esrgan_path=esrgan_path, ) logger.info("Loading Krea 2 DiT from %s", dit_path) diff --git a/thenoise/runtime.py b/thenoise/runtime.py index 679d605..9da6fb4 100644 --- a/thenoise/runtime.py +++ b/thenoise/runtime.py @@ -32,6 +32,7 @@ class ModelPaths: vae_path: str text_encoder_path: str lora_dir: str = "" + esrgan_path: str = "" # optional pixel-domain Real-ESRGAN model class NotLoadedError(RuntimeError): @@ -57,6 +58,7 @@ def load(self, paths: ModelPaths) -> None: device=self._settings.device, ) kwargs["lora_dir"] = paths.lora_dir or None + kwargs["esrgan_path"] = paths.esrgan_path or None self._unload() # swap: only one model resident at a time logger.info("Loading model '%s'", name) diff --git a/thenoise/ui/index.html b/thenoise/ui/index.html index 52e159e..f36fe8f 100644 --- a/thenoise/ui/index.html +++ b/thenoise/ui/index.html @@ -292,7 +292,22 @@

TheNoise

- +
+ + +
+ +
+
+ + 1.00 +
+ +
+
@@ -339,6 +354,19 @@

TheNoise

$('film_grain').addEventListener('input', e => $('film_grain_val').textContent = parseFloat(e.target.value).toFixed(2)); $('sharpening').addEventListener('input', e => $('sharpening_val').textContent = parseFloat(e.target.value).toFixed(2)); +$('upscale_factor').addEventListener('input', e => $('upscale_factor_val').textContent = parseFloat(e.target.value).toFixed(2)); +$('upscale_type').addEventListener('change', updateUpscaleMax); + +// Cap the factor slider to the type's max (fast has no latent 2x multiplier). +function updateUpscaleMax() { + const slider = $('upscale_factor'); + const max = $('upscale_type').value === 'fast' ? 4 : 8; + slider.max = max; + if (parseFloat(slider.value) > max) { + slider.value = max; + $('upscale_factor_val').textContent = max.toFixed(2); + } +} function setTimer(state, text) { const t = $('timer'); @@ -388,6 +416,7 @@

TheNoise

model: 'Model', prompt: 'Prompt', negative_prompt: 'Negative prompt', width: 'Width', height: 'Height', steps: 'Steps', guidance_scale: 'CFG scale', seed: 'Seed', upscale: 'Upscale', + upscale_factor: 'Upscale factor', upscale_type: 'Upscale type', sampler: 'Sampler', qwen_vae_enhance: 'Reduce grid pattern', film_grain: 'Film grain', sharpening: 'Sharpening', lora_specs: 'LoRA', }; @@ -414,6 +443,8 @@

TheNoise

if (key in meta) add(FIELD_LABELS[key], meta[key], key === 'prompt' || key === 'negative_prompt' ? 'prompt' : ''); } if ('upscale' in meta) add('Upscale', meta.upscale); + if ('upscale_factor' in meta) add('Upscale factor', meta.upscale_factor); + if ('upscale_type' in meta) add('Upscale type', meta.upscale_type); if ('qwen_vae_enhance' in meta) add('Reduce grid pattern', meta.qwen_vae_enhance); for (const key of ['film_grain','sharpening','lora_specs']) { if (key in meta && (key !== 'film_grain' || meta.film_grain) && (key !== 'sharpening' || meta.sharpening)) { @@ -489,7 +520,17 @@

TheNoise

if (meta.guidance_scale != null) $('guidance_scale').value = meta.guidance_scale; if (meta.seed != null) $('seed').value = meta.seed; if (meta.sampler) $('sampler').value = meta.sampler; - if (meta.upscale != null) $('upscale').checked = meta.upscale; + if (meta.upscale_factor != null) { + $('upscale_factor').value = meta.upscale_factor; + $('upscale_factor_val').textContent = meta.upscale_factor.toFixed(2); + } else if (meta.upscale === true) { + // legacy metadata: 'upscale: true' == 2x refined + $('upscale_factor').value = 2; + $('upscale_factor_val').textContent = '2.00'; + $('upscale_type').value = 'refined'; + } + if (meta.upscale_type) $('upscale_type').value = meta.upscale_type; + updateUpscaleMax(); if (meta.qwen_vae_enhance != null) $('qwen_vae_enhance').checked = meta.qwen_vae_enhance; if (meta.film_grain != null) { $('film_grain').value = meta.film_grain; @@ -647,7 +688,8 @@

TheNoise

const body = { prompt: $('prompt').value, negative_prompt: $('negative_prompt').value, - upscale: $('upscale').checked, + upscale_factor: parseFloat($('upscale_factor').value), + upscale_type: $('upscale_type').value, qwen_vae_enhance: $('qwen_vae_enhance').checked, film_grain: parseFloat($('film_grain').value), sharpening: parseFloat($('sharpening').value), diff --git a/thenoise/upscale/__init__.py b/thenoise/upscale/__init__.py index 0a0e893..2d775d0 100644 --- a/thenoise/upscale/__init__.py +++ b/thenoise/upscale/__init__.py @@ -36,6 +36,8 @@ logger = logging.getLogger(__name__) +from .esrgan import load_esrgan, detect_esrgan_scale + _WEIGHT_DIR = Path(__file__).resolve().parent / "weights" # Latent format name -> (adaptor factory, weight filename, raw-VAE channel count). @@ -101,4 +103,6 @@ def load_upscaler( __all__ = [ "load_upscaler", + "load_esrgan", + "detect_esrgan_scale", ] diff --git a/thenoise/upscale/esrgan.py b/thenoise/upscale/esrgan.py new file mode 100644 index 0000000..4473c93 --- /dev/null +++ b/thenoise/upscale/esrgan.py @@ -0,0 +1,231 @@ +"""Real-ESRGAN (RRDBNet x4) pixel super-resolution, vendored for thenoise. + +ESRGAN works on *decoded pixels* (fp32, RGB) after the VAE decode and +complements the latent (Sesqui) upscaler: the latent path supplies a 2x +multiplier in ``refined`` mode, ESRGAN adds another 4x on top. It is fast, so it +is *not* pipeline-cached (only the decoded VAE output is cached). + +Like the reference inference, the model operates on RGB in **[0, 1]** and emits +[0, 1]; the caller converts between the pipeline's [-1, 1] pixel range. + +The architecture is adapted from the original Real-ESRGAN (BSD-3-Clause) and +loads the ComfyUI repackaged weights directly (``Comfy-Org/Real-ESRGAN_repackaged`` / +``RealESRGAN_x4plus.safetensors``). The repackage keeps ComfyUI's key naming +(``body.N.rdbN.convN.*``, ``conv_first``, ``conv_body``, ``conv_up1``, +``conv_up2``, ``conv_hr``, ``conv_last``) over the original RRDBNet structure, +so the state dict loads with no key remapping. The forward is faithful to the +original, including the crucial trunk residual skip ``conv_first(x) + +conv_body(body(conv_first(x)))`` and nearest-neighbour upsampling between the +``conv_up`` stages. + +Weights are kept in fp32: bf16's ~7-bit mantissa degrades super-resolution +detail, and the model is small enough that fp32 is cheap. The upscale scale +(2/4/8) is auto-detected from the state dict's ``conv_upN`` stages via +``detect_esrgan_scale``, so both x2 and x4 weights are supported. + +Original copyright/license notice follows. +""" + +# Copyright (c) 2021 xinntao. Licensed under the BSD-3-Clause License. +# Source: https://github.com/xinntao/Real-ESRGAN +from __future__ import annotations + +import torch +import torch.nn as nn +import torch.nn.functional as F +from torch import Tensor + + +class ResidualDenseBlock(nn.Module): + """``ResidualDenseBlock_4C``: 5-conv dense residual block. + + Grows the channel count toward ``gc`` per layer via feature concat, then + collapses back to ``nf``; 0.2 local residual. + """ + + def __init__(self, nf: int = 64, gc: int = 32): + super().__init__() + self.conv1 = nn.Conv2d(nf, gc, 3, 1, 1) + self.conv2 = nn.Conv2d(nf + gc, gc, 3, 1, 1) + self.conv3 = nn.Conv2d(nf + 2 * gc, gc, 3, 1, 1) + self.conv4 = nn.Conv2d(nf + 3 * gc, gc, 3, 1, 1) + self.conv5 = nn.Conv2d(nf + 4 * gc, nf, 3, 1, 1) + self.lrelu = nn.LeakyReLU(negative_slope=0.2, inplace=True) + + def forward(self, x: Tensor) -> Tensor: + x1 = self.lrelu(self.conv1(x)) + x2 = self.lrelu(self.conv2(torch.cat((x, x1), 1))) + x3 = self.lrelu(self.conv3(torch.cat((x, x1, x2), 1))) + x4 = self.lrelu(self.conv4(torch.cat((x, x1, x2, x3), 1))) + x5 = self.conv5(torch.cat((x, x1, x2, x3, x4), 1)) + return x5 * 0.2 + x + + +class RRDB(nn.Module): + """Residual-in-Residual Dense Block: 3 dense blocks + 0.2 global residual.""" + + def __init__(self, nf: int = 64, gc: int = 32): + super().__init__() + self.rdb1 = ResidualDenseBlock(nf, gc) + self.rdb2 = ResidualDenseBlock(nf, gc) + self.rdb3 = ResidualDenseBlock(nf, gc) + + def forward(self, x: Tensor) -> Tensor: + out = self.rdb1(x) + out = self.rdb2(out) + out = self.rdb3(out) + return out * 0.2 + x + + +def _pixel_unshuffle(x: Tensor, scale: int) -> Tensor: + """Inverse of pixel-shuffle: reduce spatial size, enlarge channels.""" + b, c, hh, hw = x.shape + out_channel = c * (scale**2) + y = x.reshape(b, c, hh // scale, scale, hw // scale, scale) + y = y.permute(0, 1, 3, 5, 2, 4).reshape(b, out_channel, hh // scale, hw // scale) + return y + + +class RRDBNet(nn.Module): + """Real-ESRGAN generator (BasicSR RRDBNet, ComfyUI repackaged weights). + + Faithful to the original: the trunk output is added back to the + ``conv_first`` feature map (``feat + conv_body(body(feat))``) before the + nearest-neighbour upsample stages. Omitting that residual skip ruins the + image (ghost/halo artifacts). Scale is the number of 2x upsample stages + (1 = 2x, 2 = 4x, 3 = 8x). Weights: ``RealESRGAN_x2plus/x4plus.safetensors`` + (num_feat 64, num_block 23, num_grow_ch 32). + """ + + def __init__( + self, + num_in_ch: int = 3, + num_out_ch: int = 3, + num_feat: int = 64, + num_block: int = 23, + num_grow_ch: int = 32, + scale: int = 4, + ): + super().__init__() + if scale not in (2, 4, 8): + raise ValueError(f"unsupported ESRGAN scale: {scale} (use 2, 4 or 8)") + self.scale = scale + # BasicSR uses a 2x pixel-unshuffle on the input for scale-2 models, so + # ``conv_first`` takes 4x the input channels there. + in_ch = num_in_ch * 4 if scale == 2 else num_in_ch + self.conv_first = nn.Conv2d(in_ch, num_feat, 3, 1, 1) + self.body = nn.Sequential( + *[RRDB(num_feat, num_grow_ch) for _ in range(num_block)] + ) + self.conv_body = nn.Conv2d(num_feat, num_feat, 3, 1, 1) + self.conv_up1 = nn.Conv2d(num_feat, num_feat, 3, 1, 1) + if scale >= 4: + self.conv_up2 = nn.Conv2d(num_feat, num_feat, 3, 1, 1) + if scale >= 8: + self.conv_up3 = nn.Conv2d(num_feat, num_feat, 3, 1, 1) + self.conv_hr = nn.Conv2d(num_feat, num_feat, 3, 1, 1) + self.conv_last = nn.Conv2d(num_feat, num_out_ch, 3, 1, 1) + self.lrelu = nn.LeakyReLU(negative_slope=0.2, inplace=True) + + def forward(self, x: Tensor) -> Tensor: + feat = _pixel_unshuffle(x, 2) if self.scale == 2 else x + feat = self.conv_first(feat) + feat = feat + self.conv_body(self.body(feat)) # trunk residual skip + # One 2x nearest + conv stage per upsampling level. + feat = self.lrelu( + self.conv_up1(F.interpolate(feat, scale_factor=2, mode="nearest")) + ) + if self.scale >= 4: + feat = self.lrelu( + self.conv_up2(F.interpolate(feat, scale_factor=2, mode="nearest")) + ) + if self.scale >= 8: + feat = self.lrelu( + self.conv_up3(F.interpolate(feat, scale_factor=2, mode="nearest")) + ) + return self.conv_last(self.lrelu(self.conv_hr(feat))) + + @torch.no_grad() + def forward_tiled( + self, img: Tensor, tile_size: int = 512, tile_pad: int = 48 + ) -> Tensor: + """Tiled forward to bound peak activation memory on large inputs. + + Each tile is padded by ``tile_pad`` for context; only the central + ``tile_size`` region of each tile's output is kept, so tiles stitch + seamlessly (no overlap blending). RRDB has a large receptive field, so a + generous pad (48) is used to keep seams below visual threshold; ``img`` + smaller than ``tile_size`` runs as a single exact tile. + Returns ``[B, C, H*s, W*s]``. + """ + scale = self.scale + b, c, h, w = img.shape + out_h, out_w = h * scale, w * scale + out = torch.zeros((b, c, out_h, out_w), device=img.device, dtype=img.dtype) + + for y in range(0, h, tile_size): + for x in range(0, w, tile_size): + y0 = max(0, y - tile_pad) + y1 = min(h, y + tile_size + tile_pad) + x0 = max(0, x - tile_pad) + x1 = min(w, x + tile_size + tile_pad) + tile = img[:, :, y0:y1, x0:x1] + ot = self(tile) + + # Central authoritative region (input [y, y+ts] x [x, x+ts]). + oy0 = (y - y0) * scale + oy1 = oy0 + (min(h, y + tile_size) - y) * scale + ox0 = (x - x0) * scale + ox1 = ox0 + (min(w, x + tile_size) - x) * scale + out[:, :, y * scale : y * scale + (oy1 - oy0), x * scale : x * scale + (ox1 - ox0)] = ( + ot[:, :, oy0:oy1, ox0:ox1] + ) + return out + + +def detect_esrgan_scale(path: str) -> int: + """Detect an ESRGAN model's upscale scale from its safetensors header keys. + + Reads only the header (no weight tensors are loaded). The scale is the number + of ``conv_upN`` upsample stages: 1 -> 2x, 2 -> 4x, 3 -> 8x. + """ + from safetensors import safe_open + + with safe_open(path, framework="pt") as f: + keys = f.keys() + stages = set() + for k in keys: + if k.startswith("conv_up"): + num = k[len("conv_up"):].split(".")[0] + if num.isdigit(): + stages.add(int(num)) + n = len(stages) + if n == 1: + return 2 + if n == 2: + return 4 + if n == 3: + return 8 + raise ValueError( + f"could not detect ESRGAN scale from {path} " + f"(found {n} conv_up upsample stages; expected 1, 2 or 3)" + ) + + +def load_esrgan(path: str, device: str = "cuda") -> tuple[RRDBNet, int]: + """Load a Real-ESRGAN model from a ComfyUI repackaged safetensors. + + The upscale scale (2/4/8) is auto-detected from the state dict. Returns + ``(model, scale)``. Weights are kept in fp32 for super-resolution quality. + """ + from safetensors.torch import load_file + + state_dict = load_file(path, device="cpu") + scale = detect_esrgan_scale(path) + model = RRDBNet(scale=scale) + model.load_state_dict(state_dict) + model.to(device=device).eval().requires_grad_(False) + return model, scale + + +__all__ = ["RRDBNet", "load_esrgan", "detect_esrgan_scale"] diff --git a/thenoise/utils/png.py b/thenoise/utils/png.py index 5466253..488d80f 100644 --- a/thenoise/utils/png.py +++ b/thenoise/utils/png.py @@ -18,6 +18,8 @@ def build_pnginfo( guidance_scale: float, seed: int, upscale: bool, + upscale_factor: float, + upscale_type: str, sampler: str, qwen_vae_enhance: bool, film_grain: float, @@ -43,6 +45,8 @@ def build_pnginfo( "guidance_scale": guidance_scale, "seed": seed, "upscale": upscale, + "upscale_factor": upscale_factor, + "upscale_type": upscale_type, "sampler": sampler, "qwen_vae_enhance": qwen_vae_enhance, "film_grain": film_grain, @@ -67,6 +71,9 @@ def build_pnginfo( ] if upscale: meta_parts.append("Upscale: true") + if upscale_factor != 1.0: + meta_parts.append(f"Upscale factor: {upscale_factor:g}") + meta_parts.append(f"Upscale type: {upscale_type}") if lora_specs: meta_parts.append(f"LoRA: {'; '.join(lora_specs)}") parts.append(", ".join(meta_parts)) From bf99b690325db2fa99086733c75fc2cd1ff9d417 Mon Sep 17 00:00:00 2001 From: Michele Balistreri Date: Sat, 15 Aug 2026 09:16:11 +0200 Subject: [PATCH 2/6] correctly support 2x Real-ESRGAN --- scripts/convert_esrgan_pth.py | 67 ++++++++++++++++++++++++++++++++++ thenoise/upscale/esrgan.py | 69 +++++++++++++++-------------------- 2 files changed, 97 insertions(+), 39 deletions(-) create mode 100644 scripts/convert_esrgan_pth.py diff --git a/scripts/convert_esrgan_pth.py b/scripts/convert_esrgan_pth.py new file mode 100644 index 0000000..a24a026 --- /dev/null +++ b/scripts/convert_esrgan_pth.py @@ -0,0 +1,67 @@ +"""Convert a Real-ESRGAN ``*.pth`` checkpoint to ``.safetensors`` for thenoise. + +The official ``xinntao/Real-ESRGAN`` releases ship ``*.pth`` torch pickles whose +state dict is wrapped under a ``params_ema`` (or ``params``) key, which +thenoise's safetensors-only loader cannot open. This unwraps one and saves it as +a ``.safetensors`` file that ``thenoise.upscale.esrgan`` accepts directly. + +The official weights already use the ComfyUI key naming (``body.*``, +``conv_first``, ``conv_body``, ``conv_up1``, ``conv_up2``, ``conv_hr``, +``conv_last``) that the loader expects, so no key remapping is needed — only the +wrapper is removed. + +Usage: + python scripts/convert_esrgan_pth.py RealESRGAN_x2plus.pth -o models/esrgan +""" +from __future__ import annotations + +import argparse +from pathlib import Path +from typing import Any + +import torch +from safetensors.torch import save_file + +# Wrapper keys used by the official releases, in preference order. +_WRAPPERS = ("params_ema", "params", "state_dict") + + +def _unwrap(obj: Any) -> dict: + """Return the bare state dict from a torch-loaded checkpoint ``obj``.""" + if not isinstance(obj, dict): + return obj + for key in _WRAPPERS: + if key in obj: + return obj[key] + return obj + + +def convert(pth: Path, out: Path) -> Path: + """Load ``pth`` (torch pickle), unwrap it and save as safetensors at ``out``.""" + state = _unwrap(torch.load(pth, map_location="cpu", weights_only=False)) + save_file(state, str(out)) + return out + + +def main() -> None: + ap = argparse.ArgumentParser( + description="Convert a Real-ESRGAN .pth checkpoint to .safetensors" + ) + ap.add_argument("pth", type=Path, help="source .pth checkpoint") + ap.add_argument( + "-o", + "--out", + type=Path, + default=None, + help="output .safetensors path (default: same name next to the .pth)", + ) + args = ap.parse_args() + + out = args.out or args.pth.with_suffix(".safetensors") + out.parent.mkdir(parents=True, exist_ok=True) + convert(args.pth, out) + print(f"saved ESRGAN safetensors to {out}") + + +if __name__ == "__main__": + main() diff --git a/thenoise/upscale/esrgan.py b/thenoise/upscale/esrgan.py index 4473c93..88f4b6b 100644 --- a/thenoise/upscale/esrgan.py +++ b/thenoise/upscale/esrgan.py @@ -20,8 +20,10 @@ Weights are kept in fp32: bf16's ~7-bit mantissa degrades super-resolution detail, and the model is small enough that fp32 is cheap. The upscale scale -(2/4/8) is auto-detected from the state dict's ``conv_upN`` stages via -``detect_esrgan_scale``, so both x2 and x4 weights are supported. +(2 or 4) is auto-detected from ``conv_first``'s input channels via +``detect_esrgan_scale``: scale-2 models pixel-unshuffle the input by 2 (so +``conv_first`` takes 12 channels) and use two 2x upsample stages for a net 2x; +scale-4 models take the 3 RGB channels directly and use two 2x stages for 4x. Original copyright/license notice follows. """ @@ -92,8 +94,10 @@ class RRDBNet(nn.Module): Faithful to the original: the trunk output is added back to the ``conv_first`` feature map (``feat + conv_body(body(feat))``) before the nearest-neighbour upsample stages. Omitting that residual skip ruins the - image (ghost/halo artifacts). Scale is the number of 2x upsample stages - (1 = 2x, 2 = 4x, 3 = 8x). Weights: ``RealESRGAN_x2plus/x4plus.safetensors`` + image (ghost/halo artifacts). Scale is 2 or 4: scale-2 models pixel-unshuffle + the input by 2 (``conv_first`` takes 12 channels) and use two 2x upsample + stages for a net 2x; scale-4 models take the 3 RGB channels and use two 2x + stages for 4x. Weights: ``RealESRGAN_x2plus/x4plus.safetensors`` (num_feat 64, num_block 23, num_grow_ch 32). """ @@ -107,22 +111,21 @@ def __init__( scale: int = 4, ): super().__init__() - if scale not in (2, 4, 8): - raise ValueError(f"unsupported ESRGAN scale: {scale} (use 2, 4 or 8)") + if scale not in (2, 4): + raise ValueError(f"unsupported ESRGAN scale: {scale} (use 2 or 4)") self.scale = scale - # BasicSR uses a 2x pixel-unshuffle on the input for scale-2 models, so - # ``conv_first`` takes 4x the input channels there. + # Scale-2 models pixel-unshuffle the input by 2 (BasicSR), so + # ``conv_first`` takes 4x the input channels there; scale-4 models take + # the 3 RGB channels directly. in_ch = num_in_ch * 4 if scale == 2 else num_in_ch self.conv_first = nn.Conv2d(in_ch, num_feat, 3, 1, 1) self.body = nn.Sequential( *[RRDB(num_feat, num_grow_ch) for _ in range(num_block)] ) self.conv_body = nn.Conv2d(num_feat, num_feat, 3, 1, 1) + # Both scales use two 2x upsample stages (net 2x / 4x). self.conv_up1 = nn.Conv2d(num_feat, num_feat, 3, 1, 1) - if scale >= 4: - self.conv_up2 = nn.Conv2d(num_feat, num_feat, 3, 1, 1) - if scale >= 8: - self.conv_up3 = nn.Conv2d(num_feat, num_feat, 3, 1, 1) + self.conv_up2 = nn.Conv2d(num_feat, num_feat, 3, 1, 1) self.conv_hr = nn.Conv2d(num_feat, num_feat, 3, 1, 1) self.conv_last = nn.Conv2d(num_feat, num_out_ch, 3, 1, 1) self.lrelu = nn.LeakyReLU(negative_slope=0.2, inplace=True) @@ -135,14 +138,9 @@ def forward(self, x: Tensor) -> Tensor: feat = self.lrelu( self.conv_up1(F.interpolate(feat, scale_factor=2, mode="nearest")) ) - if self.scale >= 4: - feat = self.lrelu( - self.conv_up2(F.interpolate(feat, scale_factor=2, mode="nearest")) - ) - if self.scale >= 8: - feat = self.lrelu( - self.conv_up3(F.interpolate(feat, scale_factor=2, mode="nearest")) - ) + feat = self.lrelu( + self.conv_up2(F.interpolate(feat, scale_factor=2, mode="nearest")) + ) return self.conv_last(self.lrelu(self.conv_hr(feat))) @torch.no_grad() @@ -184,38 +182,31 @@ def forward_tiled( def detect_esrgan_scale(path: str) -> int: - """Detect an ESRGAN model's upscale scale from its safetensors header keys. + """Detect an ESRGAN model's upscale scale (2 or 4) from its safetensors header. - Reads only the header (no weight tensors are loaded). The scale is the number - of ``conv_upN`` upsample stages: 1 -> 2x, 2 -> 4x, 3 -> 8x. + Reads only the header (no weight tensors are loaded). The scale is + determined by ``conv_first``'s input channels: scale-2 models pixel-unshuffle + the input by 2, so ``conv_first`` takes 3*4 = 12 channels; scale-4 models + take the 3 RGB channels directly. """ from safetensors import safe_open with safe_open(path, framework="pt") as f: - keys = f.keys() - stages = set() - for k in keys: - if k.startswith("conv_up"): - num = k[len("conv_up"):].split(".")[0] - if num.isdigit(): - stages.add(int(num)) - n = len(stages) - if n == 1: + in_ch = f.get_slice("conv_first.weight").get_shape()[1] + if in_ch == 12: return 2 - if n == 2: + if in_ch == 3: return 4 - if n == 3: - return 8 raise ValueError( - f"could not detect ESRGAN scale from {path} " - f"(found {n} conv_up upsample stages; expected 1, 2 or 3)" + f"could not detect ESRGAN scale from {path}: conv_first takes " + f"{in_ch} input channels (expected 12 for 2x or 3 for 4x)" ) def load_esrgan(path: str, device: str = "cuda") -> tuple[RRDBNet, int]: - """Load a Real-ESRGAN model from a ComfyUI repackaged safetensors. + """Load a Real-ESRGAN model from a safetensors (ComfyUI repackaged keys). - The upscale scale (2/4/8) is auto-detected from the state dict. Returns + The upscale scale (2 or 4) is auto-detected from the state dict. Returns ``(model, scale)``. Weights are kept in fp32 for super-resolution quality. """ from safetensors.torch import load_file From 519d3cbbb9d90c9c602f4e2bcc32ca8c0015adb7 Mon Sep 17 00:00:00 2001 From: Michele Balistreri Date: Sat, 15 Aug 2026 10:16:37 +0200 Subject: [PATCH 3/6] support additional esrgan-based models --- thenoise/models/base.py | 12 +-- thenoise/upscale/__init__.py | 22 ++--- thenoise/upscale/esrgan.py | 124 +++++++++++++++++++++---- thenoise/upscale/inference_adaptors.py | 2 +- 4 files changed, 122 insertions(+), 38 deletions(-) diff --git a/thenoise/models/base.py b/thenoise/models/base.py index 299b62e..b9fb7a6 100644 --- a/thenoise/models/base.py +++ b/thenoise/models/base.py @@ -14,7 +14,7 @@ * ``finalize_latent(...)`` — model-internal -> canonical latent (once, post-loop). * ``resolve_size(...)`` — per-model size rounding / validation. * ``_upscale_format(...)`` — required: the latent-format name for this - model's VAE (selected by ``load_upscaler``). + model's VAE (selected by ``load_latent_upscaler``). Both models use the same Qwen-Image VAE (z_dim=16, spatial compression 8), so ``init_latents`` produces and ``finalize_latent`` returns the canonical latent @@ -92,7 +92,7 @@ from PIL import Image from safetensors.torch import load_file -from thenoise.upscale import load_upscaler, load_esrgan, detect_esrgan_scale +from thenoise.upscale import load_latent_upscaler, load_esrgan, detect_esrgan_scale from thenoise.samplers import Step, create_sampler from thenoise.samplers.euler import EulerSampler from thenoise.utils.lora import apply_lora_to_model, undo_lora_on_model @@ -641,14 +641,14 @@ def _upscale_format(self) -> str: Concrete subclasses must override this to return the name of their VAE's latent format (e.g. ``"wan21"`` for the shared Qwen-Image VAE). It is - passed to ``load_upscaler``, which selects the adaptor and weight file. + passed to ``load_latent_upscaler``, which selects the adaptor and weight file. """ ... - def _load_upscaler(self): + def _load_latent_upscaler(self): """Load the latent upscaler (once, lazily, under the lock).""" if self._upscaler is None: - self._upscaler, self._adaptor = load_upscaler( + self._upscaler, self._adaptor = load_latent_upscaler( self._upscale_format(), device=self.device, dtype=self.dtype, @@ -795,7 +795,7 @@ def _upscale_and_refine( latent to/from that raw space. The refined result is the canonical latent at the upscaled spatial size, ready for ``decode``. """ - upscaler, adaptor = self._load_upscaler() + upscaler, adaptor = self._load_latent_upscaler() scale = self.UPSCALE_SCALE z = latents.to(device=self.device, dtype=self.dtype) diff --git a/thenoise/upscale/__init__.py b/thenoise/upscale/__init__.py index 2d775d0..60efa5d 100644 --- a/thenoise/upscale/__init__.py +++ b/thenoise/upscale/__init__.py @@ -1,15 +1,15 @@ """SesquiLSR latent upscaler, vendored for thenoise. -Krea2 and Anima use the shared Qwen-Image VAE (Wan21 z-score latent format), and -Z-Image uses the Flux VAE (affine shift/scale latent format). The Wan21 and Flux -upscaler weights are committed (``weights/*.safetensors``, ~6MB bf16 each). -``load_upscaler`` takes a latent-format name and selects the adaptor factory + -weight file from ``_UPSCALER_FORMATS``; formats without committed weights raise. -See ``inference_adaptors.make_*`` for the available formats. +Both models (Krea2, Anima) currently use the shared Qwen-Image VAE, so only the +Wan21 upscaler weights are committed (``weights/upscaler_Wan21.safetensors``, +~6MB bf16). ``load_latent_upscaler`` takes a latent-format name and selects the +adaptor factory + weight file from ``_UPSCALER_FORMATS``; formats without +committed weights raise. See ``inference_adaptors.make_*`` for the available +formats. Usage: - model, adaptor = load_upscaler("flux", device="cuda", dtype=torch.bfloat16) - raw = adaptor.to_vae_latent(latent) # pipeline -> raw VAE latent + model, adaptor = load_latent_upscaler("wan21", device="cuda", dtype=torch.bfloat16) + raw = adaptor.to_vae_latent(latent) # normalized -> raw VAE latent up = model(raw, (2*h, 2*w)) # 2x latent upscale out = adaptor.from_vae_latent(up) # raw -> pipeline latent """ @@ -36,7 +36,7 @@ logger = logging.getLogger(__name__) -from .esrgan import load_esrgan, detect_esrgan_scale +from .esrgan import load_esrgan, detect_esrgan_scale, detect_esrgan_scheme _WEIGHT_DIR = Path(__file__).resolve().parent / "weights" @@ -64,7 +64,7 @@ def upscale_weight_path(filename: str) -> Path: return path -def load_upscaler( +def load_latent_upscaler( format_name: str, device: str | torch.device = "cuda", dtype: torch.dtype = torch.bfloat16, @@ -102,7 +102,7 @@ def load_upscaler( __all__ = [ - "load_upscaler", + "load_latent_upscaler", "load_esrgan", "detect_esrgan_scale", ] diff --git a/thenoise/upscale/esrgan.py b/thenoise/upscale/esrgan.py index 88f4b6b..0e7281a 100644 --- a/thenoise/upscale/esrgan.py +++ b/thenoise/upscale/esrgan.py @@ -18,6 +18,13 @@ conv_body(body(conv_first(x)))`` and nearest-neighbour upsampling between the ``conv_up`` stages. +Models saved in the *original-ESRGAN* ``nn.Sequential`` naming (``model.*``, +e.g. ``remacri_original`` / ``4xlsdir`` / ``nmkdSiaxCX``) are also supported: +their keys are remapped on load. They share the same trained architecture as the +ComfyUI repackage — including the trunk residual skip — differing only in key +naming. Both naming schemes are detected automatically via +``detect_esrgan_scheme``. + Weights are kept in fp32: bf16's ~7-bit mantissa degrades super-resolution detail, and the model is small enough that fp32 is cheap. The upscale scale (2 or 4) is auto-detected from ``conv_first``'s input channels via @@ -32,6 +39,8 @@ # Source: https://github.com/xinntao/Real-ESRGAN from __future__ import annotations +import re + import torch import torch.nn as nn import torch.nn.functional as F @@ -89,15 +98,17 @@ def _pixel_unshuffle(x: Tensor, scale: int) -> Tensor: class RRDBNet(nn.Module): - """Real-ESRGAN generator (BasicSR RRDBNet, ComfyUI repackaged weights). - - Faithful to the original: the trunk output is added back to the - ``conv_first`` feature map (``feat + conv_body(body(feat))``) before the - nearest-neighbour upsample stages. Omitting that residual skip ruins the - image (ghost/halo artifacts). Scale is 2 or 4: scale-2 models pixel-unshuffle - the input by 2 (``conv_first`` takes 12 channels) and use two 2x upsample - stages for a net 2x; scale-4 models take the 3 RGB channels and use two 2x - stages for 4x. Weights: ``RealESRGAN_x2plus/x4plus.safetensors`` + """Real-ESRGAN generator (BasicSR RRDBNet). + + The trunk output is added back to the ``conv_first`` feature map + (``feat + conv_body(body(feat))``) before the nearest-neighbour upsample + stages; omitting that residual skip ruins the image (ghost/halo artifacts + and washed-out contrast). This holds for both the ComfyUI repackaged and the + original-ESRGAN ``nn.Sequential`` weight namings — they differ only in key + naming, not in the trained architecture. Scale is 2 or 4: scale-2 models + pixel-unshuffle the input by 2 (``conv_first`` takes 12 channels) and use + two 2x upsample stages for a net 2x; scale-4 models take the 3 RGB channels + and use two 2x stages for 4x. Weights: ``RealESRGAN_x2plus/x4plus.safetensors`` (num_feat 64, num_block 23, num_grow_ch 32). """ @@ -134,7 +145,7 @@ def forward(self, x: Tensor) -> Tensor: feat = _pixel_unshuffle(x, 2) if self.scale == 2 else x feat = self.conv_first(feat) feat = feat + self.conv_body(self.body(feat)) # trunk residual skip - # One 2x nearest + conv stage per upsampling level. + # Two 2x nearest + conv stages (net 2x / 4x). feat = self.lrelu( self.conv_up1(F.interpolate(feat, scale_factor=2, mode="nearest")) ) @@ -181,37 +192,110 @@ def forward_tiled( return out +def detect_esrgan_scheme(path: str) -> str: + """Detect the weight naming / architecture scheme from the safetensors header. + + Returns ``"comfy"`` for the ComfyUI repackaged Real-ESRGAN naming + (``body.*`` / ``conv_first``) or ``"original"`` for the original-ESRGAN + ``nn.Sequential`` naming (``model.*``). Both share the same trained + architecture (including the trunk residual skip); they differ only in key + naming, so ``"original"`` weights are remapped on load. Reads only the header. + """ + from safetensors import safe_open + + with safe_open(path, framework="pt") as f: + keys = f.keys() + if "conv_first.weight" in keys: + return "comfy" + if "model.0.weight" in keys: + return "original" + raise ValueError( + f"could not detect ESRGAN scheme from {path}: expected ComfyUI " + f"('conv_first.weight') or original-ESRGAN ('model.0.weight') naming" + ) + + def detect_esrgan_scale(path: str) -> int: """Detect an ESRGAN model's upscale scale (2 or 4) from its safetensors header. Reads only the header (no weight tensors are loaded). The scale is - determined by ``conv_first``'s input channels: scale-2 models pixel-unshuffle - the input by 2, so ``conv_first`` takes 3*4 = 12 channels; scale-4 models - take the 3 RGB channels directly. + determined by the first conv's input channels: scale-2 models pixel-unshuffle + the input by 2, so it takes 3*4 = 12 channels; scale-4 models take the 3 RGB + channels directly. Handles both the ComfyUI (``conv_first``) and + original-ESRGAN (``model.0``) naming schemes. """ from safetensors import safe_open with safe_open(path, framework="pt") as f: - in_ch = f.get_slice("conv_first.weight").get_shape()[1] + if "conv_first.weight" in f.keys(): + in_ch = f.get_slice("conv_first.weight").get_shape()[1] + else: + in_ch = f.get_slice("model.0.weight").get_shape()[1] if in_ch == 12: return 2 if in_ch == 3: return 4 raise ValueError( - f"could not detect ESRGAN scale from {path}: conv_first takes " + f"could not detect ESRGAN scale from {path}: first conv takes " f"{in_ch} input channels (expected 12 for 2x or 3 for 4x)" ) -def load_esrgan(path: str, device: str = "cuda") -> tuple[RRDBNet, int]: - """Load a Real-ESRGAN model from a safetensors (ComfyUI repackaged keys). +# Canonical ComfyUI naming for the original-ESRGAN ``nn.Sequential`` skeleton: +# ``model.1`` is a Sequential of the RRDB blocks followed by the trunk conv, so +# ``model.1.sub.0..N-1`` are blocks and ``model.1.sub.N`` is the trunk. +_ORIGINAL_SKELETON = { + "model.0": "conv_first", + "model.1.sub.23": "conv_body", # trunk conv, last element of the body sequence + "model.3": "conv_up1", + "model.6": "conv_up2", + "model.8": "conv_hr", + "model.10": "conv_last", +} +_RDB_RE = re.compile(r"model\.1\.sub\.(\d+)\.(RDB\d)\.(conv\d)\.0\.(weight|bias)") + + +def _remap_original_esrgan(state: dict) -> dict: + """Convert original-ESRGAN ``nn.Sequential`` keys to canonical ComfyUI naming. + + ``model.1`` is a Sequential of the RRDB blocks followed by the trunk conv: + ``model.1.sub.0..22`` are RDB blocks (remapped to ``body.0..22`` with + ``RDB1`` -> ``rdb1`` and the ``convN.0`` Sequential wrapper dropped), and + ``model.1.sub.23`` is the trunk conv (``conv_body``). The up/HR/last convs + live directly on the top-level ``model`` Sequential. + """ + out: dict = {} + for k, v in state.items(): + m = _RDB_RE.match(k) + if m: + block, rdb, conv, suffix = m.groups() + out[f"body.{block}.{rdb.lower()}.{conv}.{suffix}"] = v + continue + for prefix, target in _ORIGINAL_SKELETON.items(): + if k.startswith(prefix + "."): + out[target + k[len(prefix):]] = v + break + else: + raise ValueError(f"unmapped original-ESRGAN key: {k}") + return out + - The upscale scale (2 or 4) is auto-detected from the state dict. Returns - ``(model, scale)``. Weights are kept in fp32 for super-resolution quality. +def load_esrgan(path: str, device: str = "cuda") -> tuple[RRDBNet, int]: + """Load a Real-ESRGAN model from a safetensors file. + + Supports both the ComfyUI repackaged naming (``body.*`` / ``conv_first``) + and the original-ESRGAN ``nn.Sequential`` naming (``model.*``, whose keys + are remapped on load). Both share the same trained architecture, so the + trunk residual skip is always applied. The upscale scale (2 or 4) is + auto-detected from the state dict. Returns ``(model, scale)``. Weights are + kept in fp32 for super-resolution quality. """ from safetensors.torch import load_file state_dict = load_file(path, device="cpu") + scheme = detect_esrgan_scheme(path) + if scheme == "original": + state_dict = _remap_original_esrgan(state_dict) scale = detect_esrgan_scale(path) model = RRDBNet(scale=scale) model.load_state_dict(state_dict) @@ -219,4 +303,4 @@ def load_esrgan(path: str, device: str = "cuda") -> tuple[RRDBNet, int]: return model, scale -__all__ = ["RRDBNet", "load_esrgan", "detect_esrgan_scale"] +__all__ = ["RRDBNet", "load_esrgan", "detect_esrgan_scale", "detect_esrgan_scheme"] diff --git a/thenoise/upscale/inference_adaptors.py b/thenoise/upscale/inference_adaptors.py index d05e19e..eb83ab9 100644 --- a/thenoise/upscale/inference_adaptors.py +++ b/thenoise/upscale/inference_adaptors.py @@ -12,7 +12,7 @@ The ``make_*`` constructors cover the other formats SesquiLSR supports (SDXL, Flux, Flux2, Ideogram4). They are imported here as groundwork for future VAE support: a model whose VAE uses a different latent format can build the matching -adaptor and hand it to ``load_upscaler``. +adaptor and hand it to ``load_latent_upscaler``. Copied and trimmed from https://github.com/LoganBooker/SesquiLSR (MIT). """ From 5dd0b00c0e383d18d36dc9867e5a94a8cdec7126 Mon Sep 17 00:00:00 2001 From: Michele Balistreri Date: Sat, 15 Aug 2026 10:29:11 +0200 Subject: [PATCH 4/6] write actual generation size in metadata --- thenoise/models/base.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/thenoise/models/base.py b/thenoise/models/base.py index b9fb7a6..d5e6e96 100644 --- a/thenoise/models/base.py +++ b/thenoise/models/base.py @@ -592,8 +592,8 @@ def generate( model=self.name, prompt=prompt, negative_prompt=negative_prompt, - width=image.width, - height=image.height, + width=width, + height=height, steps=steps, guidance_scale=guidance_scale, seed=seed, From be799d1fb3a1605244100b192b8325fe4668de77 Mon Sep 17 00:00:00 2001 From: Michele Balistreri Date: Sat, 15 Aug 2026 22:10:59 +0200 Subject: [PATCH 5/6] add esrgan argument --- thenoise/models/zimage.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/thenoise/models/zimage.py b/thenoise/models/zimage.py index 1d97ea4..bf09e55 100644 --- a/thenoise/models/zimage.py +++ b/thenoise/models/zimage.py @@ -68,6 +68,7 @@ def __init__( device: str = "cuda", dtype: torch.dtype = torch.bfloat16, lora_dir: Optional[str] = None, + esrgan_path: Optional[str] = None, ): super().__init__( dit_path=dit_path, @@ -76,6 +77,7 @@ def __init__( device=device, dtype=dtype, lora_dir=lora_dir, + esrgan_path=esrgan_path, ) logger.info("Loading Z-Image DiT from %s", dit_path) From 7c9e0d4293f102c9954f5ddb70f481a879e3e5d8 Mon Sep 17 00:00:00 2001 From: Michele Balistreri Date: Sun, 16 Aug 2026 10:49:19 +0200 Subject: [PATCH 6/6] allow to switch upscaler at runtime --- README.md | 19 +-- scripts/download_esrgan.py | 11 +- tests/test_api.py | 65 ++++++++ tests/test_cli.py | 53 ++++++- tests/test_upscale.py | 160 +++++++++++++------ tests/test_zimage.py | 8 +- thenoise/__main__.py | 2 +- thenoise/api.py | 11 ++ thenoise/cli.py | 34 ++-- thenoise/generate.py | 17 +- thenoise/models/anima.py | 4 +- thenoise/models/base.py | 300 ++++++++++++++++++++++------------- thenoise/models/krea2.py | 4 +- thenoise/models/zimage.py | 4 +- thenoise/runtime.py | 4 +- thenoise/ui/index.html | 39 ++++- thenoise/upscale/__init__.py | 22 +++ thenoise/utils/model_dir.py | 60 +++++++ thenoise/utils/png.py | 4 + 19 files changed, 620 insertions(+), 201 deletions(-) create mode 100644 tests/test_api.py create mode 100644 thenoise/utils/model_dir.py diff --git a/README.md b/README.md index 36a5687..55416e2 100644 --- a/README.md +++ b/README.md @@ -178,6 +178,7 @@ the project venv created by [Setup](#setup) — a bare `python` will not work. | Anima | ~5.4 GB | 2B params; fastest to download and run | | Krea 2 | ~35 GB | Higher quality; much larger text encoder and DiT | | Z-Image-Turbo | ~21 GB | Distilled 8-step S3-DiT; Flux VAE + Qwen3 caption encoder | +| Z-Image | ~21 GB | Non-distilled version of Z-Image-Turob | ### Krea 2 @@ -210,15 +211,7 @@ Available variants include `turbo-v1.0` (fewest steps), `aesthetic-v1.1`, and ``` This fetches the single-file bf16 Turbo DiT (~12 GB), the Flux VAE (`ae.safetensors`), -and the Qwen3-4B text encoder (`qwen_3_4b.safetensors`, ~8 GB) — all from -`Comfy-Org/z_image_turbo` — plus the tokenizer (`tokenizer/`) from -`Tongyi-MAI/Z-Image-Turbo`. It also downloads the SesquiLSR **Flux** latent upscaler -and converts it fp32 → bf16 into the package's `thenoise/upscale/weights/` directory -(used by `--upscale`; the `--vae` for Z-Image is the Flux VAE). - -Z-Image-Turbo is a distilled flow model: **8 denoising steps, CFG off** (the default -`guidance_scale` is 1, ComfyUI's "off" convention). It uses the Flux VAE, so it has -its own `--vae`. +and the Qwen3-4B text encoder (`qwen_3_4b.safetensors`, ~8 GB). ```bash ./thenoise.sh generate \ @@ -314,6 +307,8 @@ LoRA format is `filename:weight` — the `.safetensors` extension is appended au |--------|------|-------------| | `GET` | `/` | Web UI | | `GET` | `/health` | Server status and loaded model | +| `GET` | `/lora` | List available LoRA names | +| `GET` | `/upscalers` | List available pixel upscaler names | | `POST` | `/text2image` | Generate an image | ### `/text2image` request body @@ -330,6 +325,9 @@ All fields except `prompt` are optional. Omitted fields use the loaded model's d | `guidance_scale` | `float` | model default | CFG scale (≤ 1.0 disables CFG) | | `seed` | `int` | random | Random seed (`-1` for random) | | `upscale` | `bool` | `false` | 2× latent-space upscale with refine denoise | +| `upscale_factor` | `float` | `1.0` | Upscale factor (max depends on the pixel upscaler scale) | +| `upscale_type` | `string` | `refined` | `refined` (latent 2x + refiner) or `no-refiner` (pixel upscaler only) | +| `pixel_upscaler` | `string` | `null` | Pixel upscaler name (no `.safetensors` suffix) from `--upscaler-dir` | | `sampler` | `string` | `er_sde` | Denoising solver: `euler` or `er_sde` | | `qwen_vae_enhance` | `bool` | `false` | Nyquist notch post-filter (removes 2px grid artifacts) | | `film_grain` | `float` | `0.0` | Film grain strength, 0.0–10.0 | @@ -371,6 +369,7 @@ If no model is loaded, `/text2image` returns HTTP 503. |------|---------|-------------| | `--host` | `127.0.0.1` | Bind host | | `--port` | `8000` | Bind port | +| `--upscaler-dir` | — | Directory containing pixel upscaler `.safetensors` files (e.g. Real-ESRGAN); selected per-request via `pixel_upscaler` | ### `generate` only @@ -385,6 +384,8 @@ If no model is loaded, `/text2image` returns HTTP 503. | `--seed` | no | random | Random seed | | `--out` | no | `out.png` | Output file path | | `--lora` | no | — | LoRA to apply (repeatable, format: `file:weight`) | +| `--pixel-upscaler` | no | — | Full path to the pixel upscaler model (one-shot; e.g. a Real-ESRGAN `.safetensors`) | +| `--upscale-type` | no | `refined` | `refined` or `no-refiner` | | `--upscale` | no | off | 2× latent upscale with refine denoise | | `--sampler` | no | `er_sde` | Solver: `euler` or `er_sde` | | `--qwen-vae-enhance` | no | off | Nyquist notch post-filter | diff --git a/scripts/download_esrgan.py b/scripts/download_esrgan.py index 2279456..ae31951 100644 --- a/scripts/download_esrgan.py +++ b/scripts/download_esrgan.py @@ -3,9 +3,11 @@ Source: https://huggingface.co/Comfy-Org/Real-ESRGAN_repackaged RealESRGAN_x4plus.safetensors -Used by thenoise's ``fast`` upscale path, and by the ``refined`` path when -``--upscale-factor`` exceeds the latent 2x. Optional: if the model is absent -only the refiner (latent) upscale is available. +Used by thenoise's ``no-refiner`` pixel-upscale path, and by the ``refined`` +path when ``--upscale-factor`` exceeds the latent 2x. Optional: if absent only +the refiner (latent) upscale is available. Drop the downloaded file into a +``--upscaler-dir`` (serve) or pass its full path via ``--pixel-upscaler`` +(generate). Usage: python scripts/download_esrgan.py --out ./models/esrgan @@ -24,7 +26,8 @@ def main() -> None: ap = argparse.ArgumentParser(description="Download the Real-ESRGAN x4 model") ap.add_argument( - "--out", default="./models/esrgan", help="output directory" + "--out", default="./models/esrgan", + help="output directory (usable as --upscaler-dir)", ) args = ap.parse_args() diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..d5c39a5 --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,65 @@ +"""API tests using a fake model (no torch, no weights, no TestClient).""" +from __future__ import annotations + +from thenoise.api import create_app, Text2ImageRequest +from thenoise.runtime import Settings, Runtime + + +def _fake_runtime(): + class FakeModel: + name = "fake" + loras = ["style", "pose"] + upscalers = ["RealESRGAN_x4", "sub/x2"] + + def list_loras(self): + return list(self.loras) + + def list_pixel_upscalers(self): + return list(self.upscalers) + + def generate(self, **kwargs): + self.last_kwargs = kwargs + from PIL import Image + return Image.new("RGB", (8, 8)) + + runtime = Runtime(Settings()) + runtime._model = FakeModel() + runtime._model_name = "fake" + return runtime + + +def _empty_runtime(): + return Runtime(Settings()) + + +def _endpoint(app, path): + for r in app.routes: + if getattr(r, "path", None) == path: + return r.endpoint + raise AssertionError(f"no route {path}") + + +def test_upscalers_lists_names(): + app = create_app(_fake_runtime()) + res = _endpoint(app, "/upscalers")() + assert res["upscalers"] == ["RealESRGAN_x4", "sub/x2"] + + +def test_upscalers_503_when_no_model(): + app = create_app(_empty_runtime()) + res = _endpoint(app, "/upscalers")() + assert res.status_code == 503 + + +def test_text2image_passes_pixel_upscaler(): + runtime = _fake_runtime() + app = create_app(runtime) + req = Text2ImageRequest(prompt="a fox", pixel_upscaler="RealESRGAN_x4") + res = _endpoint(app, "/text2image")(req) + assert res.status_code == 200 + assert runtime.model.last_kwargs["pixel_upscaler"] == "RealESRGAN_x4" + + +def test_request_field_defaults_none(): + req = Text2ImageRequest(prompt="x") + assert req.pixel_upscaler is None diff --git a/tests/test_cli.py b/tests/test_cli.py index 32e4d5b..5795746 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -72,10 +72,10 @@ def _upscale_format(self): return "wan21" assert "sub/style.safetensors" in path # .. escape → ValueError - with pytest.raises(ValueError, match="escapes lora_dir"): + with pytest.raises(ValueError, match="escapes base directory"): model._resolve_lora_path("../etc/passwd") - with pytest.raises(ValueError, match="escapes lora_dir"): + with pytest.raises(ValueError, match="escapes base directory"): model._resolve_lora_path("sub/../../etc/passwd") @@ -148,16 +148,64 @@ def test_cli_serve_parses_model_paths(): "--vae", "vae.safetensors", "--text-encoder", "te.safetensors", "--lora-dir", "/path/to/loras", + "--upscaler-dir", "/path/to/upscalers", "--host", "0.0.0.0", "--port", "9000", "--device", "hip", ]) assert args.command == "serve" assert args.dit == "dit.safetensors" assert args.lora_dir == "/path/to/loras" + assert args.upscaler_dir == "/path/to/upscalers" assert args.host == "0.0.0.0" assert args.port == 9000 assert args.device == "hip" +def test_cli_serve_has_no_pixel_upscaler(): + args = build_parser().parse_args([ + "serve", + "--dit", "d.safetensors", + "--vae", "v.safetensors", + "--text-encoder", "te.safetensors", + ]) + assert args.upscaler_dir == "" + assert not hasattr(args, "pixel_upscaler") + + +def test_cli_generate_parses_pixel_upscaler_and_type(): + args = build_parser().parse_args([ + "generate", + "--dit", "d.safetensors", + "--vae", "v.safetensors", + "--text-encoder", "te.safetensors", + "--prompt", "a fox", + "--pixel-upscaler", "/models/RealESRGAN_x4.safetensors", + "--upscale-type", "no-refiner", + ]) + assert args.pixel_upscaler == "/models/RealESRGAN_x4.safetensors" + assert args.upscale_type == "no-refiner" + assert not hasattr(args, "upscaler_dir") + + +def test_cli_generate_rejects_fast_and_old_flags(): + # 'fast' type and '--esrgan' are removed. + with pytest.raises(SystemExit): + build_parser().parse_args([ + "generate", + "--dit", "d.safetensors", + "--vae", "v.safetensors", + "--text-encoder", "te.safetensors", + "--prompt", "a fox", "--upscale-type", "fast", + ]) + with pytest.raises(SystemExit): + build_parser().parse_args([ + "generate", + "--dit", "d.safetensors", + "--vae", "v.safetensors", + "--text-encoder", "te.safetensors", + "--prompt", "a fox", "--esrgan", "/models/x.safetensors", + ]) + + def test_cli_serve_defaults(): args = build_parser().parse_args([ "serve", @@ -168,6 +216,7 @@ def test_cli_serve_defaults(): assert args.host == "127.0.0.1" assert args.port == 8000 assert args.device == "cuda" + assert args.upscaler_dir == "" def test_cli_serve_requires_paths(): diff --git a/tests/test_upscale.py b/tests/test_upscale.py index 97e786f..7d13978 100644 --- a/tests/test_upscale.py +++ b/tests/test_upscale.py @@ -6,9 +6,13 @@ from thenoise.models.base import DiffusionModel -def _make_model(esrgan_path=None, esrgan_scale=None): - """Build a minimal concrete subclass (bypassing __init__ / VAE).""" +def _make_model(upscaler_scales=None, upscaler_dir="/tmp"): + """Build a minimal concrete subclass (bypassing __init__ / VAE). + ``upscaler_scales`` maps pixel-upscaler name -> detected scale, injected into + ``_pixel_upscaler_scales`` to skip file access (mirrors the old + ``_esrgan_scale_val`` shortcut). + """ class _M(DiffusionModel): name = "test" @@ -32,77 +36,137 @@ def _upscale_format(self): return "wan21" m = object.__new__(_M) - m.esrgan_path = esrgan_path - m._esrgan_scale_val = esrgan_scale + m.device = "cuda" + m.upscaler_dir = upscaler_dir + m._pixel_upscaler_name = None + m._pixel_upscaler_scales = dict(upscaler_scales or {}) return m -def test_esrgan_scale_mapping_with_4x(): - m = _make_model(esrgan_path="/tmp/x4.safetensors", esrgan_scale=4) - # refined: latent gives 2x, ESRGAN 4x only above the latent 2x. - assert m._esrgan_scale_for(1.0, "refined") == 0 - assert m._esrgan_scale_for(2.0, "refined") == 0 - assert m._esrgan_scale_for(2.5, "refined") == 4 - assert m._esrgan_scale_for(8.0, "refined") == 4 - assert m._esrgan_scale_for(0.5, "refined") == 0 - # fast: no latent multiplier, always ESRGAN for any upscale. - assert m._esrgan_scale_for(1.5, "fast") == 4 - assert m._esrgan_scale_for(4.0, "fast") == 4 - assert m._esrgan_scale_for(0.5, "fast") == 0 - - -def test_esrgan_scale_mapping_with_2x(): - m = _make_model(esrgan_path="/tmp/x2.safetensors", esrgan_scale=2) - assert m._esrgan_scale_for(2.0, "refined") == 0 - assert m._esrgan_scale_for(2.5, "refined") == 2 - assert m._esrgan_scale_for(4.0, "refined") == 2 - assert m._esrgan_scale_for(1.5, "fast") == 2 - assert m._esrgan_scale_for(2.0, "fast") == 2 - - -def test_resolve_valid_refined_without_esrgan(): - m = _make_model(esrgan_path=None) - # f <= latent 2x in refined mode needs no ESRGAN. +def test_pixel_upscaler_scale_mapping_with_4x(): + m = _make_model(upscaler_scales={"x4": 4}) + # refined: latent gives 2x, pixel upscaler 4x only above the latent 2x. + assert m._pixel_upscaler_scale_for(1.0, "refined", "x4") == 0 + assert m._pixel_upscaler_scale_for(2.0, "refined", "x4") == 0 + assert m._pixel_upscaler_scale_for(2.5, "refined", "x4") == 4 + assert m._pixel_upscaler_scale_for(8.0, "refined", "x4") == 4 + assert m._pixel_upscaler_scale_for(0.5, "refined", "x4") == 0 + # no-refiner: no latent multiplier, always pixel upscaler for any upscale. + assert m._pixel_upscaler_scale_for(1.5, "no-refiner", "x4") == 4 + assert m._pixel_upscaler_scale_for(4.0, "no-refiner", "x4") == 4 + assert m._pixel_upscaler_scale_for(0.5, "no-refiner", "x4") == 0 + + +def test_pixel_upscaler_scale_mapping_with_2x(): + m = _make_model(upscaler_scales={"x2": 2}) + assert m._pixel_upscaler_scale_for(2.0, "refined", "x2") == 0 + assert m._pixel_upscaler_scale_for(2.5, "refined", "x2") == 2 + assert m._pixel_upscaler_scale_for(4.0, "refined", "x2") == 2 + assert m._pixel_upscaler_scale_for(1.5, "no-refiner", "x2") == 2 + assert m._pixel_upscaler_scale_for(2.0, "no-refiner", "x2") == 2 + + +def test_resolve_valid_refined_without_pixel_upscaler(): + m = _make_model() + # f <= latent 2x in refined mode needs no pixel upscaler. assert m._resolve_upscale(1.0, "refined") == (1.0, "refined") assert m._resolve_upscale(2.0, "refined") == (2.0, "refined") -def test_resolve_needs_esrgan_when_absent(): - m = _make_model(esrgan_path=None) +def test_resolve_needs_pixel_upscaler_when_absent(): + m = _make_model() with pytest.raises(ValueError): m._resolve_upscale(2.5, "refined") with pytest.raises(ValueError): - m._resolve_upscale(1.5, "fast") + m._resolve_upscale(1.5, "no-refiner") def test_resolve_max_ranges_depend_on_scale(): - # 4x model: refined up to 8, fast up to 4. - m4 = _make_model("/tmp/x4.safetensors", esrgan_scale=4) - assert m4._resolve_upscale(8.0, "refined") == (8.0, "refined") - assert m4._resolve_upscale(4.0, "fast") == (4.0, "fast") + # 4x model: refined up to 8, no-refiner up to 4. + m4 = _make_model(upscaler_scales={"x4": 4}) + assert m4._resolve_upscale(8.0, "refined", "x4") == (8.0, "refined") + assert m4._resolve_upscale(4.0, "no-refiner", "x4") == (4.0, "no-refiner") with pytest.raises(ValueError): - m4._resolve_upscale(5.0, "fast") + m4._resolve_upscale(5.0, "no-refiner", "x4") with pytest.raises(ValueError): - m4._resolve_upscale(9.0, "refined") + m4._resolve_upscale(9.0, "refined", "x4") - # 2x model: refined up to 4, fast up to 2. - m2 = _make_model("/tmp/x2.safetensors", esrgan_scale=2) - assert m2._resolve_upscale(4.0, "refined") == (4.0, "refined") - assert m2._resolve_upscale(2.0, "fast") == (2.0, "fast") + # 2x model: refined up to 4, no-refiner up to 2. + m2 = _make_model(upscaler_scales={"x2": 2}) + assert m2._resolve_upscale(4.0, "refined", "x2") == (4.0, "refined") + assert m2._resolve_upscale(2.0, "no-refiner", "x2") == (2.0, "no-refiner") with pytest.raises(ValueError): - m2._resolve_upscale(5.0, "refined") + m2._resolve_upscale(5.0, "refined", "x2") with pytest.raises(ValueError): - m2._resolve_upscale(3.0, "fast") + m2._resolve_upscale(3.0, "no-refiner", "x2") def test_resolve_invalid_factor(): - m = _make_model("/tmp/x4.safetensors", esrgan_scale=4) + m = _make_model(upscaler_scales={"x4": 4}) for bad in (0.0, -1.0, 8.5): with pytest.raises(ValueError): - m._resolve_upscale(bad, "refined") + m._resolve_upscale(bad, "refined", "x4") def test_resolve_invalid_type(): - m = _make_model("/tmp/x4.safetensors", esrgan_scale=4) + m = _make_model(upscaler_scales={"x4": 4}) with pytest.raises(ValueError): m._resolve_upscale(2.0, "bogus") + # the old 'fast' name is gone + with pytest.raises(ValueError): + m._resolve_upscale(2.0, "fast") + + +def test_validate_pixel_upscaler_requires_dir_and_file(): + m = _make_model() + with pytest.raises(ValueError, match="no pixel upscaler configured"): + m.upscaler_dir = "" + m._validate_pixel_upscaler("x4") + + +def test_validate_pixel_upscaler_strips_suffix(tmp_path): + (tmp_path / "RealESRGAN_x4.safetensors").write_text("x") + m = _make_model(upscaler_dir=str(tmp_path)) + assert m._validate_pixel_upscaler("RealESRGAN_x4.safetensors") == "RealESRGAN_x4" + assert m._validate_pixel_upscaler("RealESRGAN_x4") == "RealESRGAN_x4" + with pytest.raises(ValueError, match="not found"): + m._validate_pixel_upscaler("missing") + + +def test_list_pixel_upscalers(tmp_path): + (tmp_path / "a.safetensors").write_text("x") + (tmp_path / "not_a_model.txt").write_text("x") + m = _make_model(upscaler_dir=str(tmp_path)) + assert m.list_pixel_upscalers() == ["a"] + + m.upscaler_dir = "" + assert m.list_pixel_upscalers() == [] + + +def test_switch_pixel_upscaler_keeps_last_used(tmp_path, monkeypatch): + """Only the last-used pixel upscaler stays loaded.""" + (tmp_path / "x2.safetensors").write_text("x") + (tmp_path / "x4.safetensors").write_text("x") + m = _make_model(upscaler_dir=str(tmp_path)) + + calls = [] + fake_model = object() + from thenoise.upscale import load_pixel_upscaler as _real_load + def _fake_load(path, device): + calls.append(path) + scale = 2 if "x2" in path else 4 + return fake_model, scale + monkeypatch.setattr("thenoise.models.base.load_pixel_upscaler", _fake_load) + + m._switch_pixel_upscaler("x2") + assert m._pixel_upscaler_name == "x2" + assert m._pixel_upscaler is fake_model + + m._switch_pixel_upscaler("x2") # same -> no-op + assert len(calls) == 1 + + m._switch_pixel_upscaler("x4") # different -> swap + assert m._pixel_upscaler_name == "x4" + assert m._pixel_upscaler is fake_model + assert len(calls) == 2 + assert m._pixel_upscaler_scales == {"x2": 2, "x4": 4} diff --git a/tests/test_zimage.py b/tests/test_zimage.py index 73c534f..227745d 100644 --- a/tests/test_zimage.py +++ b/tests/test_zimage.py @@ -87,9 +87,9 @@ def test_zimage_upscale_format_is_flux(): def test_flux_upscaler_loads_and_runs(): import torch - from thenoise.upscale import load_upscaler + from thenoise.upscale import load_latent_upscaler - model, adaptor = load_upscaler("flux", device="cpu", dtype=torch.bfloat16) + model, adaptor = load_latent_upscaler("flux", device="cpu", dtype=torch.bfloat16) # Canonical Z-Image (Flux) latent -> raw VAE latent -> 2x upscale -> back. z = torch.randn(1, 16, 8, 8) raw = adaptor.to_vae_latent(z).to(torch.bfloat16) @@ -101,7 +101,7 @@ def test_flux_upscaler_loads_and_runs(): def test_load_upscaler_rejects_unknown_format(): import pytest - from thenoise.upscale import load_upscaler + from thenoise.upscale import load_latent_upscaler with pytest.raises(ValueError): - load_upscaler("not_a_real_format") + load_latent_upscaler("not_a_real_format") diff --git a/thenoise/__main__.py b/thenoise/__main__.py index be330ae..ba623ee 100644 --- a/thenoise/__main__.py +++ b/thenoise/__main__.py @@ -15,7 +15,7 @@ def _serve(args) -> None: vae_path=args.vae, text_encoder_path=args.text_encoder, lora_dir=args.lora_dir, - esrgan_path=args.esrgan, + upscaler_dir=args.upscaler_dir, ), ) diff --git a/thenoise/api.py b/thenoise/api.py index 7cbea39..17be095 100644 --- a/thenoise/api.py +++ b/thenoise/api.py @@ -45,6 +45,7 @@ class Text2ImageRequest(BaseModel): film_grain: float = 0.0 sharpening: float = 0.0 lora_specs: Optional[List[str]] = None # ["filename.safetensors:0.8", ...] + pixel_upscaler: Optional[str] = None # name (no .safetensors) in upscaler_dir out: Literal["png", "json"] = "png" @@ -69,6 +70,15 @@ def loras(): return Response(status_code=503, content="no model is loaded") return {"loras": model.list_loras()} + @app.get("/upscalers") + def upscalers(): + """List available pixel upscaler names (short, no .safetensors suffix).""" + try: + model = runtime.model + except NotLoadedError: + return Response(status_code=503, content="no model is loaded") + return {"upscalers": model.list_pixel_upscalers()} + @app.post("/text2image") def text2image(req: Text2ImageRequest): try: @@ -92,6 +102,7 @@ def text2image(req: Text2ImageRequest): film_grain=req.film_grain, sharpening=req.sharpening, lora_specs=req.lora_specs, + pixel_upscaler=req.pixel_upscaler, ) except Exception as e: # surface generation errors cleanly logger.exception("generation failed") diff --git a/thenoise/cli.py b/thenoise/cli.py index 34157f3..d5eb79a 100644 --- a/thenoise/cli.py +++ b/thenoise/cli.py @@ -23,10 +23,20 @@ def _add_model_paths(p: argparse.ArgumentParser) -> None: p.add_argument("--lora-dir", default="", metavar="PATH", help="directory containing LoRA .safetensors files " "(subdirectories allowed)") - p.add_argument("--esrgan", default="", metavar="PATH", - help="optional Real-ESRGAN model (.safetensors) for pixel " - "upscaling; without it only refiner (latent) upscale is " - "available") + + +def _add_upscaler_args(p: argparse.ArgumentParser) -> None: + """Add the pixel-upscaler flags for a subcommand. + + ``serve`` exposes ``--upscaler-dir`` (a directory, selected per-request via + the ``pixel_upscaler`` API field). ``generate`` instead takes a one-shot + ``--pixel-upscaler`` full path, which is split internally into + ``upscaler_dir`` + ``pixel_upscaler`` before being passed down the chain. + """ + p.add_argument("--upscaler-dir", default="", metavar="PATH", + help="directory containing pixel upscaler .safetensors files " + "(e.g. Real-ESRGAN); selected per-request via the " + "'pixel_upscaler' API field") def build_parser() -> argparse.ArgumentParser: @@ -36,6 +46,7 @@ def build_parser() -> argparse.ArgumentParser: # serve serve = sub.add_parser("serve", help="run the FastAPI HTTP server") _add_model_paths(serve) + _add_upscaler_args(serve) serve.add_argument("--host", default="127.0.0.1", help="bind host (default: 127.0.0.1)") serve.add_argument("--port", type=int, default=8000, @@ -46,6 +57,10 @@ def build_parser() -> argparse.ArgumentParser: # generate gen = sub.add_parser("generate", help="run one generation and save a PNG") _add_model_paths(gen) + gen.add_argument("--pixel-upscaler", default="", metavar="PATH", + help="full path to the pixel upscaler model (.safetensors) " + "to use for this one-shot generation (e.g. a Real-ESRGAN " + "model)") gen.add_argument("--prompt", required=True) gen.add_argument("--negative-prompt", default="") gen.add_argument("--width", type=int) @@ -63,13 +78,14 @@ def build_parser() -> argparse.ArgumentParser: "run a low-strength refine denoise before decoding") gen.add_argument("--upscale-factor", type=float, default=1.0, help="upscale factor, > 0.0 (default: 1.0 = no upscale); " - "max depends on the ESRGAN model scale: 'fast' is " - "limited to the model scale, 'refined' to latent 2x * " + "max depends on the pixel upscaler scale: 'no-refiner' " + "is limited to the model scale, 'refined' to latent 2x * " "model scale") - gen.add_argument("--upscale-type", choices=["refined", "fast"], + gen.add_argument("--upscale-type", choices=["refined", "no-refiner"], default="refined", - help="'refined' (default): latent 2x + refiner, plus ESRGAN " - "above factor 2; 'fast': ESRGAN only (no latent 2x)") + help="'refined' (default): latent 2x + refiner, plus pixel " + "upscaler above factor 2; 'no-refiner': pixel upscaler " + "only (no latent 2x)") gen.add_argument("--sampler", choices=["euler", "er_sde"], default=None, help="denoising solver (default: er_sde)") gen.add_argument("--qwen-vae-enhance", action="store_true", diff --git a/thenoise/generate.py b/thenoise/generate.py index c50adca..1b78899 100644 --- a/thenoise/generate.py +++ b/thenoise/generate.py @@ -7,6 +7,7 @@ from __future__ import annotations import logging +import os import random logging.basicConfig(level=logging.INFO) @@ -18,13 +19,26 @@ def run_generate(args) -> None: settings = Settings(device=args.device) runtime = Runtime(settings) + + # ``--pixel-upscaler`` is a one-shot convenience: a full path to the model. + # Split it into ``upscaler_dir`` + ``pixel_upscaler`` (name, sans suffix) + # before passing it down the chain, so the runtime/model only ever see the + # same directory + name form as the ``serve``/API path. + upscaler_dir = "" + pixel_upscaler = None + if args.pixel_upscaler: + upscaler_dir = os.path.dirname(args.pixel_upscaler) + pixel_upscaler = os.path.basename(args.pixel_upscaler) + if pixel_upscaler.endswith(".safetensors"): + pixel_upscaler = pixel_upscaler[: -len(".safetensors")] + runtime.load( ModelPaths( dit_path=args.dit, vae_path=args.vae, text_encoder_path=args.text_encoder, lora_dir=args.lora_dir, - esrgan_path=args.esrgan, + upscaler_dir=upscaler_dir, ), ) @@ -45,6 +59,7 @@ def run_generate(args) -> None: film_grain=args.film_grain, sharpening=args.sharpening, lora_specs=args.lora or None, + pixel_upscaler=pixel_upscaler, ) image.save(args.out, pnginfo=getattr(image, "_pnginfo", None)) diff --git a/thenoise/models/anima.py b/thenoise/models/anima.py index 49e79be..7f3049f 100644 --- a/thenoise/models/anima.py +++ b/thenoise/models/anima.py @@ -58,7 +58,7 @@ def __init__( device: str = "cuda", dtype: torch.dtype = torch.bfloat16, lora_dir: Optional[str] = None, - esrgan_path: Optional[str] = None, + upscaler_dir: Optional[str] = None, ): super().__init__( dit_path=dit_path, @@ -67,7 +67,7 @@ def __init__( device=device, dtype=dtype, lora_dir=lora_dir, - esrgan_path=esrgan_path, + upscaler_dir=upscaler_dir, ) logger.info("Loading Anima DiT from %s", dit_path) diff --git a/thenoise/models/base.py b/thenoise/models/base.py index d5e6e96..2179d6b 100644 --- a/thenoise/models/base.py +++ b/thenoise/models/base.py @@ -39,17 +39,20 @@ * ``refined`` (default): the latent (Sesqui) upscaler runs ``UPSCALE_SCALE``x in latent space followed by a low-strength refine denoise. For f in (1, 2] - that 2x is the whole upscale; for f in (2, ...] a pixel-domain Real-ESRGAN - step (scale auto-detected from the model, 2/4/8) is added on the decoded + that 2x is the whole upscale; for f in (2, ...] a pixel-domain upscaler + step (scale auto-detected from the model, 2/4) is added on the decoded image and the result is downscaled to f. - * ``fast``: only the pixel-domain Real-ESRGAN step runs on the decoded image - (no latent 2x multiplier), so f is capped at the detected ESRGAN scale. - -ESRGAN requires an ``esrgan_path`` (optional CLI ``--esrgan``); without it only -``refined`` factors <= 2 are available. Max factor ranges follow the detected -model scale: ``refined`` up to ``UPSCALE_SCALE * esrgan_scale``, ``fast`` up to -``esrgan_scale``. ESRGAN is fast and is deliberately *not* pipeline-cached — -only the decoded VAE output is cached. + * ``no-refiner``: only the pixel-domain upscaler step runs on the decoded + image (no latent 2x multiplier), so f is capped at the detected scale. + +Pixel-domain upscalers are selected by name from ``upscaler_dir`` (CLI +``--upscaler-dir``); the request's ``pixel_upscaler`` picks which model in that +directory is used. Today the only pixel-space upscaler is Real-ESRGAN, but the +nomenclature is kept generic so future pixel upscalers pass through the same +options. Only the last-used pixel upscaler is kept loaded (switched on change). +Without a pixel upscaler only ``refined`` factors <= 2 are available. Max factor +ranges follow the detected model scale: ``refined`` up to ``UPSCALE_SCALE * +pixel_scale``, ``no-refiner`` up to ``pixel_scale``. Pipeline caching ---------------- @@ -66,8 +69,8 @@ Upscale+refine | (driven by decode cache hit below) | — VAE decode | sampling_key + refined constants | pixels (fp32) Notch filter | (not cached; runs right after decode) | — + ESRGAN/resize | (not cached) | — Postprocess | (not cached — cheap) | — - ESRGAN/resize | (not cached — fast) | — LoRA switching --------------- @@ -92,7 +95,17 @@ from PIL import Image from safetensors.torch import load_file -from thenoise.upscale import load_latent_upscaler, load_esrgan, detect_esrgan_scale +from thenoise.upscale import ( + load_latent_upscaler, + load_pixel_upscaler, + detect_pixel_upscaler_scale, +) +from thenoise.utils.model_dir import ( + ensure_safetensors, + strip_safetensors, + resolve_in_dir, + list_safetensors, +) from thenoise.samplers import Step, create_sampler from thenoise.samplers.euler import EulerSampler from thenoise.utils.lora import apply_lora_to_model, undo_lora_on_model @@ -184,7 +197,7 @@ def __init__( device: str = "cuda", dtype: torch.dtype = torch.bfloat16, lora_dir: Optional[str] = None, - esrgan_path: Optional[str] = None, + upscaler_dir: Optional[str] = None, ): self.device = device self.dtype = dtype @@ -192,7 +205,7 @@ def __init__( self.vae_path = vae_path self.text_encoder_path = text_encoder_path self.lora_dir = lora_dir - self.esrgan_path = esrgan_path + self.upscaler_dir = upscaler_dir self._lock = threading.Lock() torch._dynamo.config.recompile_limit = 64 @@ -212,9 +225,13 @@ def __init__( self._upscaler = None self._adaptor = None - # Lazy pixel-domain Real-ESRGAN upscaler (only if ``esrgan_path`` set). - self._esrgan = None - self._esrgan_scale_val: Optional[int] = None # detected scale, cached + # Lazy pixel-domain upscaler, kept only while it is the last-used one. + # ``upscaler_dir`` holds the available models; a request selects one by + # name via ``pixel_upscaler``. The currently loaded model is cached so + # repeated requests reuse it and a different name unloads the previous. + self._pixel_upscaler = None + self._pixel_upscaler_name: Optional[str] = None # currently loaded name + self._pixel_upscaler_scales: Dict[str, int] = {} # per-name detected scale # ------------------------------------------------------------------ hooks @abstractmethod @@ -291,7 +308,7 @@ def _parse_lora_spec(self, spec: str) -> Tuple[str, float]: filename = spec weight = 1.0 - filename = filename + ".safetensors" + filename = ensure_safetensors(filename) return filename, weight @@ -299,18 +316,9 @@ def _resolve_lora_path(self, filename: str) -> str: """Resolve a LoRA filename to an absolute path, guarded against traversal. Subdirectories are allowed, but .. components that would escape lora_dir - raise ValueError. + raise ValueError. Shared path logic lives in ``utils.model_dir``. """ - if not self.lora_dir: - raise ValueError("lora_dir is not set") - - base = os.path.abspath(self.lora_dir) - candidate = os.path.abspath(os.path.join(self.lora_dir, filename)) - - if not candidate.startswith(base + os.sep) and candidate != base: - raise ValueError("LoRA path escapes lora_dir") - - return candidate + return resolve_in_dir(self.lora_dir, filename) def _get_lora_sd(self, filename: str) -> Dict[str, torch.Tensor]: """Load a LoRA state dict from disk.""" @@ -375,20 +383,85 @@ def switch_loras( def list_loras(self) -> List[str]: """List available LoRA names relative to lora_dir. - Subdirectories are scanned recursively. Names are relative paths with the - .safetensors suffix stripped (e.g. "12345_something" or "sub/style"), so - they can be used directly as lora_specs (which auto-appends the suffix). + Names are relative paths with the .safetensors suffix stripped (e.g. + "12345_something" or "sub/style"), so they can be used directly as + lora_specs (which auto-appends the suffix). Shared listing logic lives + in ``utils.model_dir``. """ - if not self.lora_dir: - return [] - names = [] - for root, _dirs, files in os.walk(self.lora_dir): - for name in sorted(files): - if not name.endswith(".safetensors"): - continue - rel = os.path.relpath(os.path.join(root, name), self.lora_dir) - names.append(rel[: -len(".safetensors")]) - return sorted(names) + return list_safetensors(self.lora_dir) + + # ---------------------------------------------------------- pixel upscaler + def _parse_pixel_upscaler_name(self, name: str) -> str: + """Return the canonical pixel-upscaler name (strip optional suffix).""" + return strip_safetensors(name) + + def _resolve_pixel_upscaler_path(self, filename: str) -> str: + """Resolve a pixel-upscaler filename within upscaler_dir (guarded).""" + return resolve_in_dir(self.upscaler_dir, filename) + + def list_pixel_upscalers(self) -> List[str]: + """List available pixel-upscaler names relative to upscaler_dir. + + Names are relative paths with the .safetensors suffix stripped, so they + can be used directly as the request's ``pixel_upscaler`` value. Shared + listing logic lives in ``utils.model_dir``. + """ + return list_safetensors(self.upscaler_dir) + + def _validate_pixel_upscaler(self, name: str) -> str: + """Validate a pixel-upscaler name; return its canonical form. + + Raises if no ``upscaler_dir`` is configured or the named model does not + exist in it. + """ + if not self.upscaler_dir: + raise ValueError( + "no pixel upscaler configured; pass --upscaler-dir PATH " + "(or run scripts/download_esrgan.py)" + ) + name = self._parse_pixel_upscaler_name(name) + filepath = self._resolve_pixel_upscaler_path(ensure_safetensors(name)) + if not os.path.isfile(filepath): + raise ValueError( + f"pixel upscaler '{name}' not found in {self.upscaler_dir}" + ) + return name + + def _pixel_upscaler_scale(self, name: str) -> int: + """Detected scale of the requested pixel upscaler (0 if none), cached. + + Reads only the safetensors header on first use per name; the value is + then reused. Tests may pre-populate ``_pixel_upscaler_scales`` to skip + file access. + """ + if not self.upscaler_dir or not name: + return 0 + name = self._parse_pixel_upscaler_name(name) + scale = self._pixel_upscaler_scales.get(name) + if scale is None: + filepath = self._resolve_pixel_upscaler_path(ensure_safetensors(name)) + scale = detect_pixel_upscaler_scale(filepath) + self._pixel_upscaler_scales[name] = scale + return scale + + def _switch_pixel_upscaler(self, name: str) -> None: + """Load the requested pixel upscaler, keeping only the last-used loaded. + + Swaps (unloads) any previously loaded upscaler when the requested name + differs; repeated requests with the same name are no-ops. Must be called + under the inference lock (it loads weights onto the device). + """ + name = self._parse_pixel_upscaler_name(name) + if self._pixel_upscaler_name == name: + return # no-op: same upscaler + logger = __import__("logging").getLogger(__name__) + filepath = self._resolve_pixel_upscaler_path(ensure_safetensors(name)) + logger.info("Loading pixel upscaler: %s", filepath) + self._pixel_upscaler, scale = load_pixel_upscaler( + filepath, device=self.device + ) + self._pixel_upscaler_name = name + self._pixel_upscaler_scales[name] = scale def percent_to_sigma(self, percent: float) -> float: """Map a percent (0..1) to a sigma, used by the sampler's SNR offset. @@ -484,6 +557,7 @@ def generate( film_grain: float = 0.0, sharpening: float = 0.0, lora_specs: Optional[List[str]] = None, + pixel_upscaler: Optional[str] = None, ) -> Image.Image: """Encode -> denoise -> decode -> postprocess. Returns a single PIL image. @@ -503,10 +577,15 @@ def generate( ) # Resolve upscale parameters: ``upscale`` is a legacy alias for a 2x - # refined upscale; an explicit factor/type overrides it. + # refined upscale; an explicit factor/type overrides it. A requested + # pixel upscaler is validated (exists in upscaler_dir) before planning. + if pixel_upscaler: + pixel_upscaler = self._validate_pixel_upscaler(pixel_upscaler) if upscale and upscale_factor == 1.0: upscale_factor = float(self.UPSCALE_SCALE) - factor, upscale_type = self._resolve_upscale(upscale_factor, upscale_type) + factor, upscale_type = self._resolve_upscale( + upscale_factor, upscale_type, pixel_upscaler + ) width, height = self.resolve_size(width, height) target_width = width target_height = height @@ -514,7 +593,9 @@ def generate( target_width = round(width * factor) target_height = round(height * factor) refined = upscale_type == "refined" and factor > 1.0 - esrgan_scale = self._esrgan_scale_for(factor, upscale_type) + pixel_scale = self._pixel_upscaler_scale_for( + factor, upscale_type, pixel_upscaler + ) effective_sampler = sampler or self.SAMPLER # seed=-1 is treated as "random" (same as None) @@ -572,8 +653,10 @@ def generate( if qwen_vae_enhance: pixels = nyquist_notch(pixels) - # Pixel-domain ESRGAN (fast, not cached) + GPU resize to target size. - pixels = self._esrgan_step(pixels, esrgan_scale) + # Pixel-domain upscaler (fast, not cached) + GPU resize to target size. + pixels = self._pixel_upscaler_step( + pixels, pixel_scale, pixel_upscaler + ) pixels = self._resize_to_target(pixels, target_width, target_height) # Stage 5: postprocess (cheap — not cached) @@ -605,6 +688,7 @@ def generate( film_grain=film_grain, sharpening=sharpening, lora_specs=lora_specs, + pixel_upscaler=pixel_upscaler, ) image._pnginfo = pnginfo return image @@ -660,101 +744,93 @@ def _resolve_upscale( self, factor: float, upscale_type: str, + pixel_upscaler: Optional[str] = None, ) -> tuple[float, str]: """Validate and return the effective (factor, type). - ``upscale_factor`` must be in (0.0, 8.0]. ``fast`` mode has no latent - 2x multiplier so it is capped at 4.0. ESRGAN (required for ``fast`` and - for ``refined`` factors above the latent 2x) must have been configured - via ``esrgan_path``. + ``upscale_factor`` must be in (0.0, 8.0]. ``no-refiner`` mode has no + latent 2x multiplier so it is capped at the pixel-upscaler scale. A pixel + upscaler (selected by ``pixel_upscaler`` from ``upscaler_dir``) is + required for ``no-refiner`` and for ``refined`` factors above the latent + 2x. """ - if upscale_type not in ("refined", "fast"): + if upscale_type not in ("refined", "no-refiner"): raise ValueError( - f"upscale_type must be 'refined' or 'fast', got {upscale_type!r}" + f"upscale_type must be 'refined' or 'no-refiner', " + f"got {upscale_type!r}" ) if not 0.0 < factor <= 8.0: raise ValueError("upscale_factor must be in (0.0, 8.0]") - esrgan = self._esrgan_scale # detected scale (0 = no ESRGAN configured) - if esrgan == 0: - # No ESRGAN: only ``refined`` factors up to the latent 2x work. + scale = self._pixel_upscaler_scale(pixel_upscaler) + if scale == 0: + # No pixel upscaler: only ``refined`` factors up to the latent 2x work. if factor > self.UPSCALE_SCALE: raise ValueError( - f"upscale_factor > {self.UPSCALE_SCALE} requires a " - "Real-ESRGAN model; pass --esrgan PATH (or run " + f"upscale_factor > {self.UPSCALE_SCALE} requires a pixel " + "upscaler; pass --pixel-upscaler PATH (or run " "scripts/download_esrgan.py)" ) - if upscale_type == "fast" and factor > 1.0: + if upscale_type == "no-refiner" and factor > 1.0: raise ValueError( - "upscale_type='fast' requires a Real-ESRGAN model; " - "pass --esrgan PATH (or run scripts/download_esrgan.py)" + "upscale_type='no-refiner' requires a pixel upscaler; " + "pass --pixel-upscaler PATH (or run " + "scripts/download_esrgan.py)" ) else: # Max factor depends on the detected model scale: refined gets the - # latent 2x multiplier, fast does not. - max_refined = self.UPSCALE_SCALE * esrgan + # latent 2x multiplier, no-refiner does not. + max_refined = self.UPSCALE_SCALE * scale if upscale_type == "refined" and factor > max_refined: raise ValueError( - f"upscale_type='refined' with a {esrgan}x ESRGAN model is " + f"upscale_type='refined' with a {scale}x pixel upscaler is " f"limited to factor {max_refined}" ) - if upscale_type == "fast" and factor > esrgan: + if upscale_type == "no-refiner" and factor > scale: raise ValueError( - f"upscale_type='fast' with a {esrgan}x ESRGAN model is " - f"limited to factor {esrgan}" + f"upscale_type='no-refiner' with a {scale}x pixel upscaler " + f"is limited to factor {scale}" ) return factor, upscale_type - @property - def _esrgan_scale(self) -> int: - """Detected scale of the configured ESRGAN model (0 if none), cached. - - Reads only the safetensors header on first use; the value is then reused. - Tests may set ``_esrgan_scale_val`` directly to skip file access. - """ - if self._esrgan_scale_val is None: - if not self.esrgan_path: - self._esrgan_scale_val = 0 - else: - self._esrgan_scale_val = detect_esrgan_scale(self.esrgan_path) - return self._esrgan_scale_val - - def _esrgan_scale_for(self, factor: float, upscale_type: str) -> int: - """Pixel-domain ESRGAN scale for a (factor, type), or 0 to skip. - - ``refined`` gets a 2x from the latent path, so ESRGAN is only needed when - the factor exceeds that 2x. ``fast`` has no latent multiplier and always - needs ESRGAN for any upscale. Uses the detected model scale. + def _pixel_upscaler_scale_for( + self, + factor: float, + upscale_type: str, + pixel_upscaler: Optional[str] = None, + ) -> int: + """Pixel-upscaler scale to apply for (factor, type, name), or 0 to skip. + + ``refined`` gets a 2x from the latent path, so the pixel upscaler is only + needed when the factor exceeds that 2x. ``no-refiner`` has no latent + multiplier and always needs it for any upscale. Uses the detected scale + of the requested ``pixel_upscaler``. """ - esrgan = self._esrgan_scale - if esrgan == 0 or factor <= 1.0: + if not pixel_upscaler: return 0 - if upscale_type == "fast": - return esrgan - return esrgan if factor > self.UPSCALE_SCALE else 0 - - def _load_esrgan(self): - """Load the pixel-domain ESRGAN model (once, lazily, under the lock).""" - if self._esrgan is None: - if not self.esrgan_path: - raise ValueError( - "no ESRGAN model configured; pass --esrgan PATH " - "(or run scripts/download_esrgan.py)" - ) - self._esrgan, scale = load_esrgan(self.esrgan_path, device=self.device) - self._esrgan_scale_val = scale - return self._esrgan + scale = self._pixel_upscaler_scale(pixel_upscaler) + if scale == 0 or factor <= 1.0: + return 0 + if upscale_type == "no-refiner": + return scale + return scale if factor > self.UPSCALE_SCALE else 0 - def _esrgan_step(self, pixels: torch.Tensor, scale: int) -> torch.Tensor: - """Apply the pixel-domain ESRGAN upscale by ``scale``x (if > 0). + def _pixel_upscaler_step( + self, + pixels: torch.Tensor, + scale: int, + pixel_upscaler: Optional[str] = None, + ) -> torch.Tensor: + """Apply the pixel-domain upscaler by ``scale``x (if > 0). - The Real-ESRGAN model operates on RGB in [0, 1] (see its ``enhance`` - path), while the pipeline's decoded pixels are in [-1, 1]. Convert to - [0, 1] before the model and back to [-1, 1] afterwards so downstream - postprocessing / ``_to_pil`` stay unchanged. + Loads (or reuses) the requested pixel upscaler, keeping only the last-used + model loaded. The model operates on RGB in [0, 1] while the pipeline's + decoded pixels are in [-1, 1]; convert to [0, 1] before the model and back + afterwards so downstream postprocessing / ``_to_pil`` stay unchanged. """ - if not scale: + if not scale or not pixel_upscaler: return pixels - model = self._load_esrgan() + self._switch_pixel_upscaler(pixel_upscaler) + model = self._pixel_upscaler x = (pixels.unsqueeze(0) + 1.0) / 2.0 # [-1, 1] -> [0, 1] with torch.no_grad(): out = model.forward_tiled(x) diff --git a/thenoise/models/krea2.py b/thenoise/models/krea2.py index c4afdd4..123694e 100644 --- a/thenoise/models/krea2.py +++ b/thenoise/models/krea2.py @@ -62,7 +62,7 @@ def __init__( device: str = "cuda", dtype: torch.dtype = torch.bfloat16, lora_dir: Optional[str] = None, - esrgan_path: Optional[str] = None, + upscaler_dir: Optional[str] = None, ): super().__init__( dit_path=dit_path, @@ -71,7 +71,7 @@ def __init__( device=device, dtype=dtype, lora_dir=lora_dir, - esrgan_path=esrgan_path, + upscaler_dir=upscaler_dir, ) logger.info("Loading Krea 2 DiT from %s", dit_path) diff --git a/thenoise/models/zimage.py b/thenoise/models/zimage.py index bf09e55..b0c0a5f 100644 --- a/thenoise/models/zimage.py +++ b/thenoise/models/zimage.py @@ -68,7 +68,7 @@ def __init__( device: str = "cuda", dtype: torch.dtype = torch.bfloat16, lora_dir: Optional[str] = None, - esrgan_path: Optional[str] = None, + upscaler_dir: Optional[str] = None, ): super().__init__( dit_path=dit_path, @@ -77,7 +77,7 @@ def __init__( device=device, dtype=dtype, lora_dir=lora_dir, - esrgan_path=esrgan_path, + upscaler_dir=upscaler_dir, ) logger.info("Loading Z-Image DiT from %s", dit_path) diff --git a/thenoise/runtime.py b/thenoise/runtime.py index 9da6fb4..20e30de 100644 --- a/thenoise/runtime.py +++ b/thenoise/runtime.py @@ -32,7 +32,7 @@ class ModelPaths: vae_path: str text_encoder_path: str lora_dir: str = "" - esrgan_path: str = "" # optional pixel-domain Real-ESRGAN model + upscaler_dir: str = "" # optional directory of pixel-domain upscaler models class NotLoadedError(RuntimeError): @@ -58,7 +58,7 @@ def load(self, paths: ModelPaths) -> None: device=self._settings.device, ) kwargs["lora_dir"] = paths.lora_dir or None - kwargs["esrgan_path"] = paths.esrgan_path or None + kwargs["upscaler_dir"] = paths.upscaler_dir or None self._unload() # swap: only one model resident at a time logger.info("Loading model '%s'", name) diff --git a/thenoise/ui/index.html b/thenoise/ui/index.html index f36fe8f..c7d147e 100644 --- a/thenoise/ui/index.html +++ b/thenoise/ui/index.html @@ -296,7 +296,14 @@

TheNoise

+ + +
+ +
@@ -357,10 +364,10 @@

TheNoise

$('upscale_factor').addEventListener('input', e => $('upscale_factor_val').textContent = parseFloat(e.target.value).toFixed(2)); $('upscale_type').addEventListener('change', updateUpscaleMax); -// Cap the factor slider to the type's max (fast has no latent 2x multiplier). +// Cap the factor slider to the type's max (no-refiner has no latent 2x multiplier). function updateUpscaleMax() { const slider = $('upscale_factor'); - const max = $('upscale_type').value === 'fast' ? 4 : 8; + const max = $('upscale_type').value === 'no-refiner' ? 4 : 8; slider.max = max; if (parseFloat(slider.value) > max) { slider.value = max; @@ -445,6 +452,7 @@

TheNoise

if ('upscale' in meta) add('Upscale', meta.upscale); if ('upscale_factor' in meta) add('Upscale factor', meta.upscale_factor); if ('upscale_type' in meta) add('Upscale type', meta.upscale_type); + if ('pixel_upscaler' in meta && meta.pixel_upscaler) add('Pixel upscaler', meta.pixel_upscaler); if ('qwen_vae_enhance' in meta) add('Reduce grid pattern', meta.qwen_vae_enhance); for (const key of ['film_grain','sharpening','lora_specs']) { if (key in meta && (key !== 'film_grain' || meta.film_grain) && (key !== 'sharpening' || meta.sharpening)) { @@ -543,6 +551,9 @@

TheNoise

if (Array.isArray(meta.lora_specs)) { $('lora_specs').value = meta.lora_specs.join('\n'); } + if (meta.pixel_upscaler) { + $('pixel_upscaler').value = meta.pixel_upscaler; + } } /* ---------- swap width/height ---------- */ @@ -577,6 +588,26 @@

TheNoise

} catch (e) { loras = []; } } +/* ---------- pixel upscaler select ---------- */ +async function loadUpscalers() { + const sel = $('pixel_upscaler'); + const none = sel.firstElementChild; // the empty 'none' option + sel.innerHTML = ''; + sel.appendChild(none); + try { + const res = await fetch('/upscalers'); + if (res.ok) { + const data = await res.json(); + for (const name of (data.upscalers || []).sort()) { + const opt = document.createElement('option'); + opt.value = name; + opt.textContent = name; + sel.appendChild(opt); + } + } + } catch (e) { /* leave just 'none' */ } +} + const acEl = () => $('lora_ac'); function openAc() { acEl().classList.add('open'); } @@ -667,6 +698,7 @@

TheNoise

}); loadLoras(); +loadUpscalers(); /* ---------- generate ---------- */ $('generate').addEventListener('click', async () => { @@ -690,6 +722,7 @@

TheNoise

negative_prompt: $('negative_prompt').value, upscale_factor: parseFloat($('upscale_factor').value), upscale_type: $('upscale_type').value, + pixel_upscaler: $('pixel_upscaler').value || null, qwen_vae_enhance: $('qwen_vae_enhance').checked, film_grain: parseFloat($('film_grain').value), sharpening: parseFloat($('sharpening').value), diff --git a/thenoise/upscale/__init__.py b/thenoise/upscale/__init__.py index 60efa5d..d9f9de8 100644 --- a/thenoise/upscale/__init__.py +++ b/thenoise/upscale/__init__.py @@ -101,8 +101,30 @@ def load_latent_upscaler( return model, adaptor +def load_pixel_upscaler(path: str, device: str = "cuda") -> tuple: + """Load a pixel-domain upscaler from a safetensors file. + + Generic entry point so the model-facing code never names a specific pixel + upscaler architecture. Today the only pixel-space upscaler is Real-ESRGAN, + so this dispatches to ``load_esrgan``; future pixel upscalers plug in here. + Returns ``(model, scale)``. + """ + return load_esrgan(path, device=device) + + +def detect_pixel_upscaler_scale(path: str) -> int: + """Detect a pixel upscaler's upscale scale (2 or 4) from its header. + + Generic wrapper around the ESRGAN scale detection; see + ``load_pixel_upscaler`` for the rationale. + """ + return detect_esrgan_scale(path) + + __all__ = [ "load_latent_upscaler", "load_esrgan", "detect_esrgan_scale", + "load_pixel_upscaler", + "detect_pixel_upscaler_scale", ] diff --git a/thenoise/utils/model_dir.py b/thenoise/utils/model_dir.py new file mode 100644 index 0000000..b282993 --- /dev/null +++ b/thenoise/utils/model_dir.py @@ -0,0 +1,60 @@ +"""Shared helpers for model files living in a directory. + +Both LoRAs and pixel upscalers are selected by name from a configured base +directory. The name-parsing, path-resolution, and directory-listing logic is +identical, so it lives here and is called by the model with the relevant base +path (``lora_dir`` or ``upscaler_dir``). +""" +from __future__ import annotations + + +def ensure_safetensors(name: str) -> str: + """Return ``name`` with a trailing ``.safetensors`` appended if missing.""" + if not name.endswith(".safetensors"): + name += ".safetensors" + return name + + +def strip_safetensors(name: str) -> str: + """Return ``name`` with a trailing ``.safetensors`` removed if present.""" + if name.endswith(".safetensors"): + name = name[: -len(".safetensors")] + return name + + +def resolve_in_dir(base_dir: str, filename: str) -> str: + """Resolve ``filename`` to an absolute path within ``base_dir``. + + Subdirectories are allowed, but ``..`` components that would escape + ``base_dir`` raise ``ValueError``. + """ + if not base_dir: + raise ValueError("base directory is not set") + import os + + base = os.path.abspath(base_dir) + candidate = os.path.abspath(os.path.join(base_dir, filename)) + if not candidate.startswith(base + os.sep) and candidate != base: + raise ValueError("path escapes base directory") + return candidate + + +def list_safetensors(base_dir: str) -> list[str]: + """Recursively list ``.safetensors`` names relative to ``base_dir``. + + Names are relative paths with the ``.safetensors`` suffix stripped (e.g. + ``"12345_something"`` or ``"sub/style"``). Returns ``[]`` when ``base_dir`` + is empty. + """ + if not base_dir: + return [] + import os + + names = [] + for root, _dirs, files in os.walk(base_dir): + for name in sorted(files): + if not name.endswith(".safetensors"): + continue + rel = os.path.relpath(os.path.join(root, name), base_dir) + names.append(strip_safetensors(rel)) + return sorted(names) diff --git a/thenoise/utils/png.py b/thenoise/utils/png.py index 488d80f..efaae7d 100644 --- a/thenoise/utils/png.py +++ b/thenoise/utils/png.py @@ -25,6 +25,7 @@ def build_pnginfo( film_grain: float, sharpening: float, lora_specs: Optional[List[str]], + pixel_upscaler: Optional[str], ) -> PngInfo: """Build a PngInfo object with generation metadata (JSON + human-readable). @@ -52,6 +53,7 @@ def build_pnginfo( "film_grain": film_grain, "sharpening": sharpening, "lora_specs": lora_specs, + "pixel_upscaler": pixel_upscaler, }) pnginfo.add_text("generation_data", gen_data) @@ -76,6 +78,8 @@ def build_pnginfo( meta_parts.append(f"Upscale type: {upscale_type}") if lora_specs: meta_parts.append(f"LoRA: {'; '.join(lora_specs)}") + if pixel_upscaler: + meta_parts.append(f"Pixel upscaler: {pixel_upscaler}") parts.append(", ".join(meta_parts)) pnginfo.add_text("parameters", "\n".join(parts))