Auto-install Real-ESRGAN NCNN Vulkan binary on first use
- upscale.py: add InstallStatus dataclass + ensure_ncnn_installed() async function that downloads and extracts the NCNN binary for the current platform (Linux/macOS/Windows), tracks progress (0-100%), and busts the caps cache when done - main.py: trigger ensure_ncnn_installed() as a background task on app startup when no AI upscaler is detected - print_tools.py: /upscale/available triggers install task when no AI upscaler found; new GET /upscale/install-status endpoint for polling - upscale.js: if no AI upscaler on open, poll install-status showing a progress bar notification, then refresh caps and proceed when done https://claude.ai/code/session_01B58MaJCU1R6KwBDJCp8AfN
This commit is contained in:
+7
-1
@@ -4,6 +4,7 @@ from fastapi.staticfiles import StaticFiles
|
|||||||
from fastapi.responses import FileResponse, HTMLResponse
|
from fastapi.responses import FileResponse, HTMLResponse
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
import asyncio
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
@@ -13,8 +14,13 @@ from app.routers import projects, edits, images, patches, generate, tools, ai_to
|
|||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
"""Initialize database on startup"""
|
"""Initialize database on startup; auto-install Real-ESRGAN NCNN in background."""
|
||||||
init_db()
|
init_db()
|
||||||
|
# Kick off NCNN install in background if no AI upscaler detected
|
||||||
|
from app.services.upscale import probe_upscale_capabilities, ensure_ncnn_installed
|
||||||
|
caps = probe_upscale_capabilities()
|
||||||
|
if not caps["realesrgan_pytorch"] and not caps["realesrgan_ncnn"]:
|
||||||
|
asyncio.create_task(ensure_ncnn_installed())
|
||||||
yield
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -303,17 +303,38 @@ def upscale_refresh_caps():
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/upscale/available")
|
@router.get("/upscale/available")
|
||||||
def upscale_available():
|
async def upscale_available():
|
||||||
"""
|
"""
|
||||||
Return capability probe: which upscale methods are available,
|
Return capability probe: which upscale methods are available,
|
||||||
which device will be used, and which method is recommended.
|
which device will be used, and which method is recommended.
|
||||||
|
If no AI upscaler is found, triggers background NCNN auto-install.
|
||||||
Frontend uses this to populate the method selector.
|
Frontend uses this to populate the method selector.
|
||||||
"""
|
"""
|
||||||
from app.services.upscale import probe_upscale_capabilities
|
from app.services.upscale import probe_upscale_capabilities, ensure_ncnn_installed, get_install_status
|
||||||
caps = probe_upscale_capabilities()
|
caps = probe_upscale_capabilities()
|
||||||
|
# Auto-install NCNN if no AI upscaler is available yet
|
||||||
|
if not caps["realesrgan_pytorch"] and not caps["realesrgan_ncnn"]:
|
||||||
|
asyncio.create_task(ensure_ncnn_installed())
|
||||||
|
caps["ncnn_install_status"] = get_install_status()
|
||||||
return caps
|
return caps
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/upscale/install-status")
|
||||||
|
def upscale_install_status():
|
||||||
|
"""Poll for Real-ESRGAN NCNN auto-install progress."""
|
||||||
|
from app.services.upscale import get_install_status, probe_upscale_capabilities, _find_ncnn_binary
|
||||||
|
status = get_install_status()
|
||||||
|
# If install just finished, refresh caps
|
||||||
|
if status["state"] == "done":
|
||||||
|
from app.services.upscale import invalidate_caps_cache
|
||||||
|
invalidate_caps_cache()
|
||||||
|
caps = probe_upscale_capabilities()
|
||||||
|
status["ncnn_available"] = caps["realesrgan_ncnn"]
|
||||||
|
else:
|
||||||
|
status["ncnn_available"] = False
|
||||||
|
return status
|
||||||
|
|
||||||
|
|
||||||
@router.post("/upscale")
|
@router.post("/upscale")
|
||||||
async def upscale(req: UpscaleRequest):
|
async def upscale(req: UpscaleRequest):
|
||||||
"""
|
"""
|
||||||
|
|||||||
+167
-46
@@ -1,5 +1,6 @@
|
|||||||
"""
|
"""
|
||||||
Upscale service — auto-detects best available method and runs it.
|
Upscale service — auto-detects best available method and runs it.
|
||||||
|
Auto-installs Real-ESRGAN NCNN Vulkan binary on first use if no AI upscaler found.
|
||||||
|
|
||||||
Priority (auto mode):
|
Priority (auto mode):
|
||||||
1. Real-ESRGAN PyTorch + CUDA GPU — fastest, best quality
|
1. Real-ESRGAN PyTorch + CUDA GPU — fastest, best quality
|
||||||
@@ -9,20 +10,173 @@ Priority (auto mode):
|
|||||||
5. Lanczos — always available, instant
|
5. Lanczos — always available, instant
|
||||||
|
|
||||||
Capability probe is run once at first call and cached.
|
Capability probe is run once at first call and cached.
|
||||||
|
NCNN binary is auto-downloaded if no AI upscaler is found.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import os
|
import os
|
||||||
|
import platform
|
||||||
import shutil
|
import shutil
|
||||||
|
import stat
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
|
import urllib.request
|
||||||
|
import zipfile
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from enum import Enum
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
|
# ── NCNN auto-install ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
NCNN_DEST_DIR = Path("/app/data/models/realesrgan")
|
||||||
|
NCNN_VERSION = "v0.2.5.0"
|
||||||
|
NCNN_BASE_URL = f"https://github.com/xinntao/Real-ESRGAN/releases/download/{NCNN_VERSION}"
|
||||||
|
|
||||||
|
_PLATFORM_ZIP = {
|
||||||
|
"linux": f"realesrgan-ncnn-vulkan-{NCNN_VERSION}-ubuntu.zip",
|
||||||
|
"darwin": f"realesrgan-ncnn-vulkan-{NCNN_VERSION}-macos.zip",
|
||||||
|
"win32": f"realesrgan-ncnn-vulkan-{NCNN_VERSION}-windows.zip",
|
||||||
|
"windows": f"realesrgan-ncnn-vulkan-{NCNN_VERSION}-windows.zip",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class InstallState(str, Enum):
|
||||||
|
idle = "idle"
|
||||||
|
downloading = "downloading"
|
||||||
|
extracting = "extracting"
|
||||||
|
done = "done"
|
||||||
|
failed = "failed"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class InstallStatus:
|
||||||
|
state: InstallState = InstallState.idle
|
||||||
|
progress: int = 0 # 0-100
|
||||||
|
message: str = ""
|
||||||
|
error: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
_install_status = InstallStatus()
|
||||||
|
_install_lock = asyncio.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def get_install_status() -> dict:
|
||||||
|
s = _install_status
|
||||||
|
return {
|
||||||
|
"state": s.state.value,
|
||||||
|
"progress": s.progress,
|
||||||
|
"message": s.message,
|
||||||
|
"error": s.error,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _ncnn_binary_name() -> str:
|
||||||
|
plat = sys.platform.lower()
|
||||||
|
return "realesrgan-ncnn-vulkan.exe" if "win" in plat else "realesrgan-ncnn-vulkan"
|
||||||
|
|
||||||
|
|
||||||
|
async def ensure_ncnn_installed() -> Optional[Path]:
|
||||||
|
"""
|
||||||
|
Check if NCNN binary is present; if not, download and install it.
|
||||||
|
Returns the binary Path on success, None on failure.
|
||||||
|
Serialised via _install_lock so concurrent callers wait for a single install.
|
||||||
|
"""
|
||||||
|
global _install_status
|
||||||
|
|
||||||
|
binary_path = NCNN_DEST_DIR / _ncnn_binary_name()
|
||||||
|
if binary_path.exists() and os.access(binary_path, os.X_OK):
|
||||||
|
_install_status = InstallStatus(state=InstallState.done, progress=100,
|
||||||
|
message="Already installed.")
|
||||||
|
return binary_path
|
||||||
|
|
||||||
|
async with _install_lock:
|
||||||
|
# Re-check after acquiring lock (another coroutine may have just finished)
|
||||||
|
if binary_path.exists() and os.access(binary_path, os.X_OK):
|
||||||
|
_install_status = InstallStatus(state=InstallState.done, progress=100,
|
||||||
|
message="Already installed.")
|
||||||
|
return binary_path
|
||||||
|
|
||||||
|
if _install_status.state == InstallState.downloading:
|
||||||
|
return None # install already in progress
|
||||||
|
|
||||||
|
plat = sys.platform.lower()
|
||||||
|
zip_name = _PLATFORM_ZIP.get(plat)
|
||||||
|
if not zip_name:
|
||||||
|
_install_status = InstallStatus(
|
||||||
|
state=InstallState.failed,
|
||||||
|
error=f"Unsupported platform: {plat}",
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
url = f"{NCNN_BASE_URL}/{zip_name}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
NCNN_DEST_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
zip_path = NCNN_DEST_DIR / zip_name
|
||||||
|
|
||||||
|
# Download
|
||||||
|
_install_status = InstallStatus(
|
||||||
|
state=InstallState.downloading, progress=0,
|
||||||
|
message=f"Downloading Real-ESRGAN NCNN {NCNN_VERSION}…",
|
||||||
|
)
|
||||||
|
|
||||||
|
def _do_download():
|
||||||
|
def _progress(count, block, total):
|
||||||
|
if total > 0:
|
||||||
|
pct = min(90, int(count * block * 90 / total))
|
||||||
|
_install_status.progress = pct
|
||||||
|
urllib.request.urlretrieve(url, zip_path, _progress)
|
||||||
|
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
await loop.run_in_executor(None, _do_download)
|
||||||
|
|
||||||
|
# Extract
|
||||||
|
_install_status.state = InstallState.extracting
|
||||||
|
_install_status.progress = 92
|
||||||
|
_install_status.message = "Extracting…"
|
||||||
|
|
||||||
|
def _do_extract():
|
||||||
|
with zipfile.ZipFile(zip_path, "r") as zf:
|
||||||
|
zf.extractall(NCNN_DEST_DIR)
|
||||||
|
# Find binary (may be in a subdirectory)
|
||||||
|
found = list(NCNN_DEST_DIR.rglob(_ncnn_binary_name()))
|
||||||
|
if not found:
|
||||||
|
raise FileNotFoundError(f"Binary not found after extract: {_ncnn_binary_name()}")
|
||||||
|
extracted = found[0]
|
||||||
|
if extracted != binary_path:
|
||||||
|
extracted.rename(binary_path)
|
||||||
|
# Make executable
|
||||||
|
if "win" not in sys.platform.lower():
|
||||||
|
binary_path.chmod(
|
||||||
|
binary_path.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH
|
||||||
|
)
|
||||||
|
zip_path.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
await loop.run_in_executor(None, _do_extract)
|
||||||
|
|
||||||
|
_install_status = InstallStatus(
|
||||||
|
state=InstallState.done, progress=100,
|
||||||
|
message=f"Installed: {binary_path}",
|
||||||
|
)
|
||||||
|
# Bust caps cache so probe picks up new binary
|
||||||
|
invalidate_caps_cache()
|
||||||
|
return binary_path
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
_install_status = InstallStatus(
|
||||||
|
state=InstallState.failed,
|
||||||
|
error=str(exc),
|
||||||
|
message="Installation failed.",
|
||||||
|
)
|
||||||
|
print(f"[upscale] NCNN auto-install failed: {exc}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
# ── Capability detection ──────────────────────────────────────────────────────
|
# ── Capability detection ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
_caps: Optional[dict] = None
|
_caps: Optional[dict] = None
|
||||||
@@ -40,12 +194,13 @@ def probe_upscale_capabilities() -> dict:
|
|||||||
caps = {
|
caps = {
|
||||||
"lanczos": True,
|
"lanczos": True,
|
||||||
"realesrgan_pytorch": False,
|
"realesrgan_pytorch": False,
|
||||||
"realesrgan_pytorch_device": None, # "cuda" | "mps" | "cpu"
|
"realesrgan_pytorch_device": None,
|
||||||
"realesrgan_ncnn": False,
|
"realesrgan_ncnn": False,
|
||||||
"realesrgan_ncnn_path": None,
|
"realesrgan_ncnn_path": None,
|
||||||
"recommended": "lanczos",
|
"recommended": "lanczos",
|
||||||
"recommended_label": "Lanczos (no AI upscaler found)",
|
"recommended_label": "Lanczos (no AI upscaler found)",
|
||||||
"methods": ["lanczos"],
|
"methods": ["lanczos"],
|
||||||
|
"ncnn_install_status": get_install_status(),
|
||||||
}
|
}
|
||||||
|
|
||||||
# ── PyTorch path ──────────────────────────────────────────────────────────
|
# ── PyTorch path ──────────────────────────────────────────────────────────
|
||||||
@@ -91,7 +246,7 @@ def probe_upscale_capabilities() -> dict:
|
|||||||
caps["recommended_label"] = "Real-ESRGAN (CPU — may be slow)"
|
caps["recommended_label"] = "Real-ESRGAN (CPU — may be slow)"
|
||||||
else:
|
else:
|
||||||
caps["recommended"] = "lanczos"
|
caps["recommended"] = "lanczos"
|
||||||
caps["recommended_label"] = "Lanczos (install Real-ESRGAN for AI quality)"
|
caps["recommended_label"] = "Lanczos (installing Real-ESRGAN…)"
|
||||||
|
|
||||||
_caps = caps
|
_caps = caps
|
||||||
return caps
|
return caps
|
||||||
@@ -99,26 +254,20 @@ def probe_upscale_capabilities() -> dict:
|
|||||||
|
|
||||||
def _find_ncnn_binary() -> Optional[Path]:
|
def _find_ncnn_binary() -> Optional[Path]:
|
||||||
"""Find realesrgan-ncnn-vulkan binary on the system."""
|
"""Find realesrgan-ncnn-vulkan binary on the system."""
|
||||||
# Check PATH first
|
|
||||||
found = shutil.which("realesrgan-ncnn-vulkan")
|
found = shutil.which("realesrgan-ncnn-vulkan")
|
||||||
if found:
|
if found:
|
||||||
return Path(found)
|
return Path(found)
|
||||||
|
|
||||||
# Check known install locations
|
|
||||||
candidates = [
|
candidates = [
|
||||||
Path("/app/data/models/realesrgan/realesrgan-ncnn-vulkan"),
|
NCNN_DEST_DIR / _ncnn_binary_name(),
|
||||||
Path("/usr/local/bin/realesrgan-ncnn-vulkan"),
|
Path("/usr/local/bin/realesrgan-ncnn-vulkan"),
|
||||||
Path.home() / ".local/bin/realesrgan-ncnn-vulkan",
|
Path.home() / ".local/bin/realesrgan-ncnn-vulkan",
|
||||||
# Windows
|
|
||||||
Path(r"C:/realesrgan-ncnn-vulkan/realesrgan-ncnn-vulkan.exe"),
|
Path(r"C:/realesrgan-ncnn-vulkan/realesrgan-ncnn-vulkan.exe"),
|
||||||
# macOS Homebrew
|
|
||||||
Path("/opt/homebrew/bin/realesrgan-ncnn-vulkan"),
|
Path("/opt/homebrew/bin/realesrgan-ncnn-vulkan"),
|
||||||
Path("/usr/local/bin/realesrgan-ncnn-vulkan"),
|
|
||||||
]
|
]
|
||||||
for p in candidates:
|
for p in candidates:
|
||||||
if p.exists() and os.access(p, os.X_OK):
|
if p.exists() and os.access(p, os.X_OK):
|
||||||
return p
|
return p
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@@ -148,7 +297,6 @@ def upscale_realesrgan_pytorch(image: Image.Image, scale: float) -> tuple[bytes,
|
|||||||
"""
|
"""
|
||||||
Real-ESRGAN via PyTorch.
|
Real-ESRGAN via PyTorch.
|
||||||
Uses CUDA > MPS > CPU automatically based on what's available.
|
Uses CUDA > MPS > CPU automatically based on what's available.
|
||||||
Scale factors: any float — upscales to nearest 2x or 4x model, then resizes to exact target.
|
|
||||||
"""
|
"""
|
||||||
import torch
|
import torch
|
||||||
from basicsr.archs.rrdbnet_arch import RRDBNet
|
from basicsr.archs.rrdbnet_arch import RRDBNet
|
||||||
@@ -157,20 +305,18 @@ def upscale_realesrgan_pytorch(image: Image.Image, scale: float) -> tuple[bytes,
|
|||||||
caps = probe_upscale_capabilities()
|
caps = probe_upscale_capabilities()
|
||||||
device = caps.get("realesrgan_pytorch_device", "cpu")
|
device = caps.get("realesrgan_pytorch_device", "cpu")
|
||||||
|
|
||||||
# Choose model: x2 for scale <= 2.5, x4 otherwise
|
|
||||||
model_scale = 2 if scale <= 2.5 else 4
|
model_scale = 2 if scale <= 2.5 else 4
|
||||||
model = RRDBNet(
|
model = RRDBNet(
|
||||||
num_in_ch=3, num_out_ch=3, num_feat=64,
|
num_in_ch=3, num_out_ch=3, num_feat=64,
|
||||||
num_block=23, num_grow_ch=32, scale=model_scale
|
num_block=23, num_grow_ch=32, scale=model_scale
|
||||||
)
|
)
|
||||||
|
|
||||||
# Model path: check local cache first, then let RealESRGANer auto-download
|
|
||||||
model_dir = Path("/app/data/models/realesrgan")
|
model_dir = Path("/app/data/models/realesrgan")
|
||||||
model_dir.mkdir(parents=True, exist_ok=True)
|
model_dir.mkdir(parents=True, exist_ok=True)
|
||||||
model_name = f"RealESRGAN_x{model_scale}plus.pth"
|
model_name = f"RealESRGAN_x{model_scale}plus.pth"
|
||||||
model_path = model_dir / model_name
|
model_path = model_dir / model_name
|
||||||
if not model_path.exists():
|
if not model_path.exists():
|
||||||
model_path = None # RealESRGANer will download to its default cache
|
model_path = None
|
||||||
|
|
||||||
upsampler = RealESRGANer(
|
upsampler = RealESRGANer(
|
||||||
scale=model_scale,
|
scale=model_scale,
|
||||||
@@ -179,14 +325,14 @@ def upscale_realesrgan_pytorch(image: Image.Image, scale: float) -> tuple[bytes,
|
|||||||
tile=512,
|
tile=512,
|
||||||
tile_pad=10,
|
tile_pad=10,
|
||||||
pre_pad=0,
|
pre_pad=0,
|
||||||
half=(device == "cuda"), # fp16 only on CUDA
|
half=(device == "cuda"),
|
||||||
device=torch.device(device),
|
device=torch.device(device),
|
||||||
)
|
)
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
img_bgr = np.array(image)[:, :, ::-1].copy() # RGB→BGR
|
img_bgr = np.array(image)[:, :, ::-1].copy()
|
||||||
enhanced, _ = upsampler.enhance(img_bgr, outscale=scale)
|
enhanced, _ = upsampler.enhance(img_bgr, outscale=scale)
|
||||||
result = Image.fromarray(enhanced[:, :, ::-1]) # BGR→RGB
|
result = Image.fromarray(enhanced[:, :, ::-1])
|
||||||
|
|
||||||
label = f"realesrgan_pytorch_{device}"
|
label = f"realesrgan_pytorch_{device}"
|
||||||
return _to_png_bytes(result), label
|
return _to_png_bytes(result), label
|
||||||
@@ -194,7 +340,7 @@ def upscale_realesrgan_pytorch(image: Image.Image, scale: float) -> tuple[bytes,
|
|||||||
|
|
||||||
def upscale_realesrgan_ncnn(image: Image.Image, scale: float) -> tuple[bytes, str]:
|
def upscale_realesrgan_ncnn(image: Image.Image, scale: float) -> tuple[bytes, str]:
|
||||||
"""
|
"""
|
||||||
Real-ESRGAN via NCNN Vulkan binary — works on any GPU (Intel/AMD/integrated/Apple).
|
Real-ESRGAN via NCNN Vulkan binary — works on any GPU.
|
||||||
Runs as subprocess with temp file I/O.
|
Runs as subprocess with temp file I/O.
|
||||||
"""
|
"""
|
||||||
caps = probe_upscale_capabilities()
|
caps = probe_upscale_capabilities()
|
||||||
@@ -202,8 +348,6 @@ def upscale_realesrgan_ncnn(image: Image.Image, scale: float) -> tuple[bytes, st
|
|||||||
if not binary:
|
if not binary:
|
||||||
raise RuntimeError("realesrgan-ncnn-vulkan binary not found")
|
raise RuntimeError("realesrgan-ncnn-vulkan binary not found")
|
||||||
|
|
||||||
# NCNN only supports integer scales (2, 3, 4) natively
|
|
||||||
# For non-integer scales: upscale to nearest integer, then resize to exact target
|
|
||||||
model_scale = 4 if scale > 2.5 else 2
|
model_scale = 4 if scale > 2.5 else 2
|
||||||
target_w = round(image.width * scale)
|
target_w = round(image.width * scale)
|
||||||
target_h = round(image.height * scale)
|
target_h = round(image.height * scale)
|
||||||
@@ -214,29 +358,19 @@ def upscale_realesrgan_ncnn(image: Image.Image, scale: float) -> tuple[bytes, st
|
|||||||
|
|
||||||
image.save(in_path, format="PNG")
|
image.save(in_path, format="PNG")
|
||||||
|
|
||||||
# Model name for NCNN (bundled with binary)
|
|
||||||
model_name = f"realesrgan-x{model_scale}plus"
|
model_name = f"realesrgan-x{model_scale}plus"
|
||||||
|
|
||||||
cmd = [
|
cmd = [
|
||||||
binary,
|
binary, "-i", str(in_path), "-o", str(out_path),
|
||||||
"-i", str(in_path),
|
"-s", str(model_scale), "-n", model_name, "-f", "png",
|
||||||
"-o", str(out_path),
|
|
||||||
"-s", str(model_scale),
|
|
||||||
"-n", model_name,
|
|
||||||
"-f", "png",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
result_proc = subprocess.run(
|
result_proc = subprocess.run(cmd, capture_output=True, timeout=300)
|
||||||
cmd, capture_output=True, timeout=300
|
|
||||||
)
|
|
||||||
if result_proc.returncode != 0:
|
if result_proc.returncode != 0:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
f"realesrgan-ncnn-vulkan failed: {result_proc.stderr.decode()}"
|
f"realesrgan-ncnn-vulkan failed: {result_proc.stderr.decode()}"
|
||||||
)
|
)
|
||||||
|
|
||||||
result = Image.open(out_path).convert("RGB")
|
result = Image.open(out_path).convert("RGB")
|
||||||
|
|
||||||
# Resize to exact target if scale was non-integer
|
|
||||||
if result.width != target_w or result.height != target_h:
|
if result.width != target_w or result.height != target_h:
|
||||||
result = result.resize((target_w, target_h), Image.Resampling.LANCZOS)
|
result = result.resize((target_w, target_h), Image.Resampling.LANCZOS)
|
||||||
|
|
||||||
@@ -246,17 +380,7 @@ def upscale_realesrgan_ncnn(image: Image.Image, scale: float) -> tuple[bytes, st
|
|||||||
# ── Public entry point ────────────────────────────────────────────────────────
|
# ── Public entry point ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def upscale_sync(image: Image.Image, scale: float, method: str = "auto") -> tuple[bytes, str]:
|
def upscale_sync(image: Image.Image, scale: float, method: str = "auto") -> tuple[bytes, str]:
|
||||||
"""
|
"""Upscale image synchronously. Returns (png_bytes, method_used_label)."""
|
||||||
Upscale image synchronously. Call via run_in_executor from async context.
|
|
||||||
|
|
||||||
method values:
|
|
||||||
"auto" — pick best available automatically
|
|
||||||
"realesrgan_pytorch" — force PyTorch path
|
|
||||||
"realesrgan_ncnn" — force NCNN binary path
|
|
||||||
"lanczos" — force Lanczos
|
|
||||||
|
|
||||||
Returns (png_bytes, method_used_label).
|
|
||||||
"""
|
|
||||||
caps = probe_upscale_capabilities()
|
caps = probe_upscale_capabilities()
|
||||||
|
|
||||||
if method == "auto":
|
if method == "auto":
|
||||||
@@ -268,7 +392,6 @@ def upscale_sync(image: Image.Image, scale: float, method: str = "auto") -> tupl
|
|||||||
return upscale_realesrgan_pytorch(image, scale)
|
return upscale_realesrgan_pytorch(image, scale)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Real-ESRGAN PyTorch failed, falling back: {e}")
|
print(f"Real-ESRGAN PyTorch failed, falling back: {e}")
|
||||||
# Fall through to next best
|
|
||||||
if caps["realesrgan_ncnn"]:
|
if caps["realesrgan_ncnn"]:
|
||||||
try:
|
try:
|
||||||
return upscale_realesrgan_ncnn(image, scale)
|
return upscale_realesrgan_ncnn(image, scale)
|
||||||
@@ -282,7 +405,6 @@ def upscale_sync(image: Image.Image, scale: float, method: str = "auto") -> tupl
|
|||||||
return upscale_realesrgan_ncnn(image, scale)
|
return upscale_realesrgan_ncnn(image, scale)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Real-ESRGAN NCNN failed, falling back: {e}")
|
print(f"Real-ESRGAN NCNN failed, falling back: {e}")
|
||||||
# Fall through
|
|
||||||
if caps["realesrgan_pytorch"]:
|
if caps["realesrgan_pytorch"]:
|
||||||
try:
|
try:
|
||||||
return upscale_realesrgan_pytorch(image, scale)
|
return upscale_realesrgan_pytorch(image, scale)
|
||||||
@@ -290,7 +412,6 @@ def upscale_sync(image: Image.Image, scale: float, method: str = "auto") -> tupl
|
|||||||
print(f"Real-ESRGAN PyTorch fallback failed: {e}")
|
print(f"Real-ESRGAN PyTorch fallback failed: {e}")
|
||||||
return upscale_lanczos(image, scale)
|
return upscale_lanczos(image, scale)
|
||||||
|
|
||||||
# Default / lanczos
|
|
||||||
return upscale_lanczos(image, scale)
|
return upscale_lanczos(image, scale)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,12 +2,8 @@
|
|||||||
* Upscale — increase image resolution.
|
* Upscale — increase image resolution.
|
||||||
* Fetches available methods from /api/print/upscale/available on first open.
|
* Fetches available methods from /api/print/upscale/available on first open.
|
||||||
* Auto-selects the recommended method; user can override.
|
* Auto-selects the recommended method; user can override.
|
||||||
*
|
* If no AI upscaler is found, polls /api/print/upscale/install-status while
|
||||||
* Methods (in priority order, server picks best):
|
* the backend auto-installs Real-ESRGAN NCNN Vulkan, then refreshes and continues.
|
||||||
* auto — server picks best available
|
|
||||||
* realesrgan_pytorch — Real-ESRGAN via PyTorch (CUDA > MPS > CPU)
|
|
||||||
* realesrgan_ncnn — Real-ESRGAN NCNN Vulkan binary (any GPU)
|
|
||||||
* lanczos — always available, instant
|
|
||||||
*
|
*
|
||||||
* Menu target: image/upscale.upscale
|
* Menu target: image/upscale.upscale
|
||||||
*/
|
*/
|
||||||
@@ -20,7 +16,6 @@ import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.j
|
|||||||
|
|
||||||
var instance = null;
|
var instance = null;
|
||||||
|
|
||||||
// Method display labels
|
|
||||||
const METHOD_LABELS = {
|
const METHOD_LABELS = {
|
||||||
auto: 'Auto (best available)',
|
auto: 'Auto (best available)',
|
||||||
realesrgan_pytorch: 'Real-ESRGAN — PyTorch',
|
realesrgan_pytorch: 'Real-ESRGAN — PyTorch',
|
||||||
@@ -45,15 +40,25 @@ class Image_upscale_class {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If a previous caps fetch showed no AI upscaler, check install progress
|
||||||
var caps = await this._fetchCaps();
|
var caps = await this._fetchCaps();
|
||||||
|
if (!caps.realesrgan_pytorch && !caps.realesrgan_ncnn) {
|
||||||
|
await this._waitForInstall(caps);
|
||||||
|
// Re-fetch caps after install
|
||||||
|
this._caps = null;
|
||||||
|
caps = await this._fetchCaps();
|
||||||
|
}
|
||||||
|
|
||||||
|
this._showDialog(caps);
|
||||||
|
}
|
||||||
|
|
||||||
|
_showDialog(caps) {
|
||||||
var W = config.layer.width_original;
|
var W = config.layer.width_original;
|
||||||
var H = config.layer.height_original;
|
var H = config.layer.height_original;
|
||||||
|
|
||||||
// Build method selector — only show what's available + auto
|
|
||||||
var available = ['auto', ...caps.methods];
|
var available = ['auto', ...caps.methods];
|
||||||
var methodValues = [...new Set(available)]; // dedupe
|
var methodValues = [...new Set(available)];
|
||||||
|
|
||||||
// Label each option, mark recommended
|
|
||||||
var methodLabels = methodValues.map(m => {
|
var methodLabels = methodValues.map(m => {
|
||||||
var label = METHOD_LABELS[m] || m;
|
var label = METHOD_LABELS[m] || m;
|
||||||
if (m === 'auto') {
|
if (m === 'auto') {
|
||||||
@@ -64,7 +69,6 @@ class Image_upscale_class {
|
|||||||
return label;
|
return label;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Annotate with device info
|
|
||||||
var deviceNote = '';
|
var deviceNote = '';
|
||||||
if (caps.realesrgan_pytorch) {
|
if (caps.realesrgan_pytorch) {
|
||||||
var dev = caps.realesrgan_pytorch_device;
|
var dev = caps.realesrgan_pytorch_device;
|
||||||
@@ -77,8 +81,7 @@ class Image_upscale_class {
|
|||||||
deviceNote += 'NCNN Vulkan binary found. ';
|
deviceNote += 'NCNN Vulkan binary found. ';
|
||||||
}
|
}
|
||||||
if (!caps.realesrgan_pytorch && !caps.realesrgan_ncnn) {
|
if (!caps.realesrgan_pytorch && !caps.realesrgan_ncnn) {
|
||||||
deviceNote = 'No AI upscaler detected — Lanczos only. ' +
|
deviceNote = 'No AI upscaler available — Lanczos only.';
|
||||||
'Install Real-ESRGAN for AI quality (see docs).';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var _this = this;
|
var _this = this;
|
||||||
@@ -103,7 +106,7 @@ class Image_upscale_class {
|
|||||||
{
|
{
|
||||||
name: 'method',
|
name: 'method',
|
||||||
title: 'Method:',
|
title: 'Method:',
|
||||||
value: methodLabels[0], // auto
|
value: methodLabels[0],
|
||||||
values: methodLabels,
|
values: methodLabels,
|
||||||
type: 'select',
|
type: 'select',
|
||||||
},
|
},
|
||||||
@@ -114,7 +117,6 @@ class Image_upscale_class {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
on_finish: async function (params) {
|
on_finish: async function (params) {
|
||||||
// Map label back to method key
|
|
||||||
var labelIdx = methodLabels.indexOf(params.method);
|
var labelIdx = methodLabels.indexOf(params.method);
|
||||||
var methodKey = labelIdx >= 0 ? methodValues[labelIdx] : 'auto';
|
var methodKey = labelIdx >= 0 ? methodValues[labelIdx] : 'auto';
|
||||||
var scale = parseFloat(params.scale);
|
var scale = parseFloat(params.scale);
|
||||||
@@ -123,6 +125,52 @@ class Image_upscale_class {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Poll install-status until done/failed, showing a progress bar notification.
|
||||||
|
*/
|
||||||
|
async _waitForInstall(caps) {
|
||||||
|
var installStatus = caps.ncnn_install_status || {};
|
||||||
|
if (installStatus.state === 'done' || installStatus.state === 'failed') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
var msg = alertify.message(
|
||||||
|
`<div>Installing Real-ESRGAN AI upscaler…<br>
|
||||||
|
<progress id="esrgan-install-progress" value="0" max="100"
|
||||||
|
style="width:100%;margin-top:6px;"></progress>
|
||||||
|
<span id="esrgan-install-pct">0%</span></div>`,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
var poll = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
var base = window.API_BASE_URL || '';
|
||||||
|
var r = await fetch(`${base}/api/print/upscale/install-status`);
|
||||||
|
if (!r.ok) return;
|
||||||
|
var s = await r.json();
|
||||||
|
|
||||||
|
var bar = document.getElementById('esrgan-install-progress');
|
||||||
|
var pct = document.getElementById('esrgan-install-pct');
|
||||||
|
if (bar) bar.value = s.progress || 0;
|
||||||
|
if (pct) pct.textContent = `${s.progress || 0}%`;
|
||||||
|
|
||||||
|
if (s.state === 'done') {
|
||||||
|
clearInterval(poll);
|
||||||
|
alertify.dismissAll();
|
||||||
|
alertify.success('Real-ESRGAN NCNN installed ✓');
|
||||||
|
resolve();
|
||||||
|
} else if (s.state === 'failed') {
|
||||||
|
clearInterval(poll);
|
||||||
|
alertify.dismissAll();
|
||||||
|
alertify.warning('AI upscaler install failed — using Lanczos.');
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
} catch { /* network hiccup, keep polling */ }
|
||||||
|
}, 1500);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async _fetchCaps() {
|
async _fetchCaps() {
|
||||||
if (this._caps) return this._caps;
|
if (this._caps) return this._caps;
|
||||||
try {
|
try {
|
||||||
@@ -133,7 +181,6 @@ class Image_upscale_class {
|
|||||||
}
|
}
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
|
|
||||||
// Safe default if fetch failed
|
|
||||||
if (!this._caps) {
|
if (!this._caps) {
|
||||||
this._caps = {
|
this._caps = {
|
||||||
lanczos: true,
|
lanczos: true,
|
||||||
@@ -142,6 +189,7 @@ class Image_upscale_class {
|
|||||||
recommended: 'lanczos',
|
recommended: 'lanczos',
|
||||||
recommended_label: 'Lanczos',
|
recommended_label: 'Lanczos',
|
||||||
methods: ['lanczos'],
|
methods: ['lanczos'],
|
||||||
|
ncnn_install_status: { state: 'idle', progress: 0 },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return this._caps;
|
return this._caps;
|
||||||
@@ -156,7 +204,7 @@ class Image_upscale_class {
|
|||||||
? `Auto (${caps.recommended_label || 'best available'})`
|
? `Auto (${caps.recommended_label || 'best available'})`
|
||||||
: (METHOD_LABELS[method] || method);
|
: (METHOD_LABELS[method] || method);
|
||||||
|
|
||||||
alertify.message(`Upscaling ${scale}× · ${methodLabel}...`, 0);
|
alertify.message(`Upscaling ${scale}× · ${methodLabel}…`, 0);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
var layerCanvas = document.createElement('canvas');
|
var layerCanvas = document.createElement('canvas');
|
||||||
@@ -185,7 +233,6 @@ class Image_upscale_class {
|
|||||||
resultCanvas.height = img.naturalHeight;
|
resultCanvas.height = img.naturalHeight;
|
||||||
resultCanvas.getContext('2d').drawImage(img, 0, 0);
|
resultCanvas.getContext('2d').drawImage(img, 0, 0);
|
||||||
|
|
||||||
// Human-readable method label for undo history
|
|
||||||
var usedLabel = result.method.replace('realesrgan_pytorch_', 'ESRGAN/')
|
var usedLabel = result.method.replace('realesrgan_pytorch_', 'ESRGAN/')
|
||||||
.replace('realesrgan_ncnn', 'ESRGAN/NCNN');
|
.replace('realesrgan_ncnn', 'ESRGAN/NCNN');
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user