add Real-ESRGAN upscaling (2x/3x/4x) with anime model
Adds `vnasset upscale` CLI command, `VnAssetsSession.upscale()` / `.upscales()` session methods, and a standalone `vnassets.upscale` module following the existing remove-bg pattern. Uses Real-ESRGAN RRDBNet with RealESRGAN_x4plus_anime_6B (~17 MB, auto-downloaded). A 256x256 warmup tile at load time eliminates ~80s of first-run CUDA JIT compilation on ROCm. Steady-state: ~1.8s per 1024->2048 upscale on Strix Halo. The upsampler is lazy-loaded on first call and coexists with SDXL/Qwen in the same session. Works around a basicsr/torchvision API incompatibility (rgb_to_grayscale moved in torchvision 0.20+) with a 3-line module shim in upscale.py.
This commit is contained in:
92
README.md
92
README.md
@@ -176,6 +176,46 @@ novaAnimeXL art style. Inference runs on CPU via onnxruntime (~0.25s per
|
||||
1024×1024 image on Strix Halo). The model is ~176 MB, downloaded on first use
|
||||
to `~/.u2net/`.
|
||||
|
||||
### Upscaling (Real-ESRGAN)
|
||||
|
||||
Upscale images 2×, 3×, or 4× using Real-ESRGAN with the anime-optimized
|
||||
`RealESRGAN_x4plus_anime_6B` model:
|
||||
|
||||
```bash
|
||||
# Single file
|
||||
vnasset upscale --input character_base.png --output character_2x.png --scale 2
|
||||
|
||||
# Batch (reuses model across files)
|
||||
vnasset upscale --input base.png --input happy.png --input sad.png --output-dir upscaled/ --scale 2
|
||||
```
|
||||
|
||||
Or via the session API:
|
||||
|
||||
```python
|
||||
with VnAssetsSession() as vna:
|
||||
vna.upscale("base.png", output="base_2x.png", scale=2)
|
||||
|
||||
# Batch
|
||||
vna.upscales(
|
||||
["base.png", "happy.png", "sad.png"],
|
||||
output_dir="upscaled/",
|
||||
scale=2,
|
||||
)
|
||||
```
|
||||
|
||||
| Option | Default | Description |
|
||||
|--------|---------|-------------|
|
||||
| `--input` | (required, repeatable) | Input image path(s) |
|
||||
| `--output` | (auto) | Output path (single mode) |
|
||||
| `--output-dir` | (none) | Output directory (batch mode; `{stem}_x{scale}.png`) |
|
||||
| `--scale` | `2` | Upscale factor: `2`, `3`, or `4` |
|
||||
|
||||
The model is ~17 MB (auto-downloaded from GitHub on first use). The upsampler
|
||||
runs in FP32 on GPU and is lazy-loaded on first call within a session — it
|
||||
coexists with SDXL and Qwen without memory pressure. A 256×256 warmup tile is
|
||||
run at load time to compile CUDA kernels, so first-user upscale doesn't incur
|
||||
a compilation penalty.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
@@ -206,6 +246,19 @@ to `~/.u2net/`.
|
||||
│ │ happy.png │ │
|
||||
│ │ sad.png │ │
|
||||
│ │ angry.png │ │
|
||||
│ └────────┬─────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌──────────────────┐ │
|
||||
│ │ Upscale │ │
|
||||
│ │ (Real-ESRGAN) │ │
|
||||
│ │ ~1.8s each │ │
|
||||
│ └────────┬─────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌──────────────────┐ │
|
||||
│ │ Remove BG │ │
|
||||
│ │ (isnet-anime) │ │
|
||||
│ └──────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
@@ -219,6 +272,10 @@ SDXL and Qwen use **separate VAEs** with different latent spaces. The SDXL
|
||||
checkpoint bundles its own VAE; Qwen uses `Qwen/Qwen-Image` VAE from
|
||||
HuggingFace. Both coexist in the same session without conflict.
|
||||
|
||||
The upsampler (Real-ESRGAN RRDBNet, ~17 MB, FP32) runs on GPU via PyTorch
|
||||
and is lazy-loaded on first use. It uses tiled processing (512×512 tiles)
|
||||
to keep memory modest even at 4× output (4096×4096).
|
||||
|
||||
## Data Flow
|
||||
|
||||
### Generate Phase
|
||||
@@ -242,6 +299,18 @@ input image ──► Qwen VL 7B TE ──► visual tok.┘
|
||||
The text encoder handles both text conditioning and visual token encoding from
|
||||
the input image.
|
||||
|
||||
### Upscale Phase
|
||||
|
||||
```
|
||||
input image (RGB) ──► tile split (512×512) ──┐
|
||||
├──► RRDBNet (FP32, 4×) ──► tiles ──► blend ──► upscaled image
|
||||
tile overlap padding ─────────────────────────┘
|
||||
```
|
||||
|
||||
If the requested output scale is less than the model's native scale (e.g. 2×
|
||||
from a 4× model), the 4× result is Lanczos-downsampled to the target size.
|
||||
Tiled processing keeps GPU memory constant regardless of output resolution.
|
||||
|
||||
### Turbo vs Normal Mode
|
||||
|
||||
| Parameter | Normal | Turbo (Lightning LoRA) |
|
||||
@@ -291,13 +360,32 @@ processor, not SDPA).
|
||||
| Session load | ~28 | SDXL + Qwen transformer + VAE + TE + LoRA fuse |
|
||||
| Generate | ~31 | SDXL 20-step, 1024×1024 |
|
||||
| Edit (turbo) | ~87 / ~54 | Matmul / flash attention |
|
||||
| **Total (2 ops)** | **~146 / ~118** | One session load amortized |
|
||||
| Upscale (2×) | ~1.8 | Real-ESRGAN, 1024→2048 |
|
||||
| **Total (3 ops)** | **~147 / ~119** | One session load amortized |
|
||||
|
||||
With a session, each additional edit saves one model-load round trip (~28s).
|
||||
5 edits save 112s. Flash attention adds a further 1.6× multiplier on inference.
|
||||
|
||||
See [`docs/stats.md`](docs/stats.md) for detailed per-run breakdowns.
|
||||
|
||||
### Upscale (Real-ESRGAN, anime model)
|
||||
|
||||
| Scale | Input | Tiles | Load (s) | Inference (s) |
|
||||
|-------|-------|-------|----------|---------------|
|
||||
| 2× | 1024×1024 | 4 | ~0.2¹ | ~1.8 |
|
||||
| 4× | 1024×1024 | 4 | ~0.2¹ | ~1.8 |
|
||||
|
||||
¹ Warmup tile only; model download is ~17 MB (first use, cached thereafter).
|
||||
|
||||
Upscaling is tiled (512×512 input tiles) to keep GPU memory modest — the
|
||||
RRDBNet forward pass processes each tile independently, and the results are
|
||||
blended at tile boundaries. The 2× and 4× paths have near-identical latency
|
||||
because the model is natively 4×; 2× output is achieved by upscaling to 4×
|
||||
then downsampling.
|
||||
|
||||
A 256×256 warmup tile is run at model load time to compile CUDA kernels.
|
||||
Without it, the first real upscale incurs ~80s of JIT compilation.
|
||||
|
||||
## Technical Notes
|
||||
|
||||
### bfloat16 required on RDNA 3.5
|
||||
@@ -389,6 +477,8 @@ Use `--raw` to bypass weighting and fall back to plain diffusers encoding.
|
||||
| Flash attention (experimental) | ✅ Working |
|
||||
| `vnasset remove-bg` | ✅ Working |
|
||||
| Session background removal | ✅ Working |
|
||||
| `vnasset upscale` | ✅ Working |
|
||||
| Session upscaling | ✅ Working |
|
||||
| `vnasset pipeline` (batch YAML config) | 🚧 Planned |
|
||||
| `vnasset serve` (daemon/HTTP API) | 🚧 Planned |
|
||||
| `torch.compile` on UNet | 🚧 Planned |
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
"""VNAsset — Visual Novel Asset Pipeline CLI."""
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
|
||||
from .background import remove_background as _remove_bg
|
||||
from .background import remove_backgrounds as _remove_bgs
|
||||
from .edit import edit
|
||||
from .generate import generate
|
||||
from .upscale import upscale as _upscale_fn
|
||||
from .upscale import upscales as _upscales_fn
|
||||
|
||||
|
||||
def _parse_seed(ctx, param, value):
|
||||
@@ -85,6 +89,44 @@ def edit_cmd(model, input_path, prompt, steps, cfg, seed, output, lora_path):
|
||||
|
||||
REMOVE_BG_MODELS = ["isnet-anime", "u2net", "u2netp", "u2net_human_seg", "isnet-general-use", "sam"]
|
||||
|
||||
UPSALE_SCALES = [2, 3, 4]
|
||||
|
||||
|
||||
@main.command("upscale")
|
||||
@click.option("--input", "input_paths", multiple=True, required=True,
|
||||
help="Input image path (repeat for batch).")
|
||||
@click.option("--output", "output_path", default=None,
|
||||
help="Output path (single-input mode).")
|
||||
@click.option("--output-dir", default=None,
|
||||
help="Output directory (batch mode; files named {stem}_x{scale}.png).")
|
||||
@click.option("--scale", default=2, type=click.Choice(["2", "3", "4"]),
|
||||
help="Upscale factor (default: 2).")
|
||||
def upscale_cmd(input_paths, output_path, output_dir, scale):
|
||||
"""Upscale images using Real-ESRGAN (anime model).
|
||||
|
||||
Single file mode:
|
||||
|
||||
vnasset upscale --input char.png --output char_2x.png --scale 2
|
||||
|
||||
Batch mode (reuses model across files):
|
||||
|
||||
vnasset upscale --input base.png --input happy.png --output-dir upscaled/ --scale 2
|
||||
"""
|
||||
scale = int(scale)
|
||||
if len(input_paths) == 1 and output_path:
|
||||
_upscale_fn(input_paths[0], output_path, scale=scale)
|
||||
elif output_dir:
|
||||
_upscales_fn(list(input_paths), output_dir, scale=scale)
|
||||
elif len(input_paths) == 1:
|
||||
# Single input, no output specified: auto-name
|
||||
stem = Path(input_paths[0]).stem
|
||||
out = Path(input_paths[0]).parent / f"{stem}_x{scale}.png"
|
||||
_upscale_fn(input_paths[0], str(out), scale=scale)
|
||||
else:
|
||||
raise click.UsageError(
|
||||
"For multiple inputs, use --output-dir. For single input, use --output."
|
||||
)
|
||||
|
||||
|
||||
@main.command("remove-bg")
|
||||
@click.option("--input", "input_paths", multiple=True, required=True,
|
||||
@@ -112,7 +154,6 @@ def remove_bg_cmd(input_paths, output_path, output_dir, model):
|
||||
_remove_bgs(list(input_paths), output_dir, model=model)
|
||||
elif len(input_paths) == 1:
|
||||
# Single input, no output specified: auto-name
|
||||
from pathlib import Path
|
||||
stem = Path(input_paths[0]).stem
|
||||
out = Path(input_paths[0]).parent / f"{stem}_nobg.png"
|
||||
_remove_bg(input_paths[0], str(out), model=model)
|
||||
|
||||
@@ -26,6 +26,9 @@ from .attention import patch_qwen_transformer, patch_unet_attention
|
||||
from .background import remove_background as _remove_bg
|
||||
from .background import remove_backgrounds as _remove_bgs
|
||||
from .prompt import build_compel, encode_prompts
|
||||
from .upscale import _build_upsampler
|
||||
from .upscale import upscale as _upscale_fn
|
||||
from .upscale import upscales as _upscales_fn
|
||||
|
||||
TEXT_ENCODER_ID = "Qwen/Qwen2.5-VL-7B-Instruct"
|
||||
VAE_ID = "Qwen/Qwen-Image"
|
||||
@@ -72,6 +75,7 @@ class VnAssetsSession:
|
||||
self._compel = None
|
||||
self._pipe_qwen: QwenImageEditPlusPipeline | None = None
|
||||
self._rembg_session = None
|
||||
self._upsampler = None
|
||||
|
||||
t0 = time.perf_counter()
|
||||
if sdxl_checkpoint:
|
||||
@@ -391,6 +395,53 @@ class VnAssetsSession:
|
||||
self._rembg_session = new_session(model)
|
||||
_remove_bgs(input_paths, output_dir, model=model)
|
||||
|
||||
# ── Upscaling ───────────────────────────────────────────────────
|
||||
|
||||
def upscale(
|
||||
self,
|
||||
input_path: str,
|
||||
output_path: str,
|
||||
scale: int = 2,
|
||||
) -> None:
|
||||
"""Upscale an image using Real-ESRGAN (anime model).
|
||||
|
||||
The upsampler is lazy-loaded on first call and reused across
|
||||
calls. Uses ``RealESRGAN_x4plus_anime_6B`` (17 MB,
|
||||
auto-downloaded).
|
||||
|
||||
Args:
|
||||
input_path: Path to the input RGB image.
|
||||
output_path: Where to save the upscaled PNG.
|
||||
scale: Output scale factor (2, 3, or 4).
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: If input_path doesn't exist.
|
||||
ValueError: If scale is not 2, 3, or 4.
|
||||
"""
|
||||
if self._upsampler is None:
|
||||
self._upsampler = _build_upsampler(self.device)
|
||||
# Also use this upsampler for subsequent calls
|
||||
_upscale_fn(input_path, output_path, scale=scale, upsampler=self._upsampler)
|
||||
|
||||
def upscales(
|
||||
self,
|
||||
input_paths: list[str],
|
||||
output_dir: str,
|
||||
scale: int = 2,
|
||||
) -> None:
|
||||
"""Upscale multiple images, reusing one model session.
|
||||
|
||||
Output files are named ``{stem}_x{scale}.png``.
|
||||
|
||||
Args:
|
||||
input_paths: List of input image paths.
|
||||
output_dir: Directory for output PNGs.
|
||||
scale: Output scale factor (2, 3, or 4).
|
||||
"""
|
||||
if self._upsampler is None:
|
||||
self._upsampler = _build_upsampler(self.device)
|
||||
_upscales_fn(input_paths, output_dir, scale=scale, upsampler=self._upsampler)
|
||||
|
||||
# ── Lifecycle ───────────────────────────────────────────────────────
|
||||
|
||||
def close(self) -> None:
|
||||
@@ -405,6 +456,9 @@ class VnAssetsSession:
|
||||
if self._rembg_session:
|
||||
del self._rembg_session
|
||||
self._rembg_session = None
|
||||
if self._upsampler:
|
||||
del self._upsampler
|
||||
self._upsampler = None
|
||||
if self.device == "cuda":
|
||||
torch.cuda.empty_cache()
|
||||
|
||||
|
||||
178
vnassets/upscale.py
Normal file
178
vnassets/upscale.py
Normal file
@@ -0,0 +1,178 @@
|
||||
"""Image upscaling using Real-ESRGAN with the anime-optimized RRDBNet model.
|
||||
|
||||
Uses ``RealESRGAN_x4plus_anime_6B`` (17 MB, auto-downloaded on first use).
|
||||
Output scale is configurable: 2x, 3x, or 4x. The model is natively 4x;
|
||||
2x and 3x are achieved by upscaling to 4x then downsampling.
|
||||
|
||||
Follows the same pattern as ``background.py``: standalone functions that
|
||||
can be called directly, with an optional persistent session for batch use.
|
||||
"""
|
||||
import sys
|
||||
import time
|
||||
import types
|
||||
from pathlib import Path
|
||||
from typing import Sequence
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
from PIL import Image
|
||||
|
||||
# ── basicsr / torchvision compatibility shim ───────────────────────
|
||||
# basicsr.data.degradations imports rgb_to_grayscale from the old
|
||||
# torchvision path (functional_tensor), removed in torchvision 0.20+.
|
||||
# The function lives in torchvision.transforms.functional now.
|
||||
# We only need RRDBNet inference — this shim pacifies the import.
|
||||
from torchvision.transforms import functional as _F_tv
|
||||
|
||||
_ft_mod = types.ModuleType("torchvision.transforms.functional_tensor")
|
||||
_ft_mod.rgb_to_grayscale = _F_tv.rgb_to_grayscale
|
||||
sys.modules["torchvision.transforms.functional_tensor"] = _ft_mod
|
||||
|
||||
from basicsr.archs.rrdbnet_arch import RRDBNet
|
||||
from realesrgan.utils import RealESRGANer
|
||||
|
||||
# ── Constants ──────────────────────────────────────────────────────
|
||||
|
||||
ANIME_MODEL_URL = (
|
||||
"https://github.com/xinntao/Real-ESRGAN/releases/download/"
|
||||
"v0.2.2.4/RealESRGAN_x4plus_anime_6B.pth"
|
||||
)
|
||||
ANIME_MODEL_NET_SCALE = 4
|
||||
_WARMUP_SIZE = 256 # px — small tile to compile CUDA kernels at load time
|
||||
|
||||
|
||||
def _build_upsampler(device: str = "cuda") -> RealESRGANer:
|
||||
"""Create a RealESRGANer with the anime model.
|
||||
|
||||
Runs a tiny warmup forward pass to compile CUDA kernels, so the
|
||||
first user-facing upscale doesn't pay an ~80s compilation penalty.
|
||||
"""
|
||||
model = RRDBNet(
|
||||
num_in_ch=3, num_out_ch=3, num_feat=64, num_block=6, num_grow_ch=32, scale=4
|
||||
)
|
||||
upsampler = RealESRGANer(
|
||||
scale=ANIME_MODEL_NET_SCALE,
|
||||
model_path=ANIME_MODEL_URL,
|
||||
model=model,
|
||||
tile=512,
|
||||
tile_pad=10,
|
||||
pre_pad=0,
|
||||
half=False,
|
||||
device=device,
|
||||
)
|
||||
|
||||
# Warmup: a 256×256 dummy tile compiles all CUDA kernels needed for
|
||||
# the RRDBNet forward pass. Without this, the first real upscale of
|
||||
# a 1024×1024 image incurs ~80s of JIT compilation.
|
||||
dummy = np.zeros((_WARMUP_SIZE, _WARMUP_SIZE, 3), dtype=np.uint8)
|
||||
upsampler.enhance(dummy, outscale=ANIME_MODEL_NET_SCALE)
|
||||
if device == "cuda":
|
||||
torch.cuda.synchronize()
|
||||
|
||||
return upsampler
|
||||
|
||||
|
||||
def upscale(
|
||||
input_path: str,
|
||||
output_path: str,
|
||||
scale: int = 2,
|
||||
upsampler: RealESRGANer | None = None,
|
||||
) -> None:
|
||||
"""Upscale a single image. Output is an RGB PNG.
|
||||
|
||||
Args:
|
||||
input_path: Path to the input image.
|
||||
output_path: Where to save the upscaled PNG.
|
||||
scale: Output scale factor (2, 3, or 4).
|
||||
upsampler: A pre-built RealESRGANer. If None, one is created and
|
||||
immediately discarded. For batch use, create one once and
|
||||
pass it to avoid reloading the model.
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: If input_path doesn't exist.
|
||||
ValueError: If scale is not 2, 3, or 4.
|
||||
"""
|
||||
if scale not in (2, 3, 4):
|
||||
raise ValueError(f"scale must be 2, 3, or 4; got {scale}")
|
||||
|
||||
input_file = Path(input_path)
|
||||
if not input_file.exists():
|
||||
raise FileNotFoundError(f"Input image not found: {input_path}")
|
||||
|
||||
output_file = Path(output_path)
|
||||
output_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
_upsampler = upsampler
|
||||
close_upsampler = upsampler is None
|
||||
if _upsampler is None:
|
||||
_upsampler = _build_upsampler()
|
||||
|
||||
img = Image.open(input_file).convert("RGB")
|
||||
img_np = np.array(img)
|
||||
|
||||
t0 = time.perf_counter()
|
||||
result, _ = _upsampler.enhance(img_np, outscale=scale)
|
||||
t_elapsed = round(time.perf_counter() - t0, 3)
|
||||
|
||||
result_img = Image.fromarray(result)
|
||||
result_img.save(output_file)
|
||||
|
||||
w, h = img.size
|
||||
rw, rh = result_img.size
|
||||
print(f"Saved {output_file} ({w}×{h} → {rw}×{rh}, {t_elapsed}s)")
|
||||
|
||||
if close_upsampler:
|
||||
del _upsampler
|
||||
|
||||
|
||||
def upscales(
|
||||
input_paths: Sequence[str],
|
||||
output_dir: str,
|
||||
scale: int = 2,
|
||||
upsampler: RealESRGANer | None = None,
|
||||
) -> None:
|
||||
"""Upscale multiple images, reusing one model session.
|
||||
|
||||
Output files are named like ``{stem}_x{scale}.png`` in ``output_dir``.
|
||||
|
||||
Args:
|
||||
input_paths: List of input image paths.
|
||||
output_dir: Directory for output PNGs.
|
||||
scale: Output scale factor (2, 3, or 4).
|
||||
upsampler: A pre-built RealESRGANer. If None, one is created and
|
||||
discarded after the batch.
|
||||
"""
|
||||
if scale not in (2, 3, 4):
|
||||
raise ValueError(f"scale must be 2, 3, or 4; got {scale}")
|
||||
|
||||
out_dir = Path(output_dir)
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
_upsampler = upsampler
|
||||
close_upsampler = upsampler is None
|
||||
if _upsampler is None:
|
||||
_upsampler = _build_upsampler()
|
||||
|
||||
total = 0.0
|
||||
for input_path in input_paths:
|
||||
stem = Path(input_path).stem
|
||||
output_path = out_dir / f"{stem}_x{scale}.png"
|
||||
|
||||
img = Image.open(input_path).convert("RGB")
|
||||
img_np = np.array(img)
|
||||
|
||||
t0 = time.perf_counter()
|
||||
result, _ = _upsampler.enhance(img_np, outscale=scale)
|
||||
t_elapsed = time.perf_counter() - t0
|
||||
total += t_elapsed
|
||||
|
||||
result_img = Image.fromarray(result)
|
||||
result_img.save(output_path)
|
||||
w, h = img.size
|
||||
rw, rh = result_img.size
|
||||
print(f"Saved {output_path} ({w}×{h} → {rw}×{rh}, {t_elapsed:.3f}s)")
|
||||
|
||||
print(f"Done: {len(input_paths)} images, {total:.2f}s total")
|
||||
|
||||
if close_upsampler:
|
||||
del _upsampler
|
||||
Reference in New Issue
Block a user