fix: pin torch cu126 for GPU compatibility and fix cross-platform bugs

- Pin torch==2.6.0+cu126 and torchvision==0.21.0+cu126 in feature
  manifest to prevent NCCL symbol mismatch on CUDA 12.6 base images
- Move lpips after torch in install order to prevent wrong version
  resolution from PyPI
- Add einops to upscale-enhance common deps (required by SCUNet)
- Update cpu_fallback_packages to handle multi-package CUDA torch
  entries on amd64 without GPU
- Fix gpu.py ONNX CUDA detection: replace hardcoded .so path with
  cross-platform session smoke-test
- Fix os.dup(1) crashes on Windows in upscale, enhance_faces, and
  noise_removal by wrapping in try/except with sys.stderr fallback
- Guard top-level numpy/cv2 imports in colorize.py and restore.py
  with helpful error messages
- Add weights_only=False fallback for torch.load in noise_removal
- Fix integration tests to accept 501 for uninstalled AI features
  and 422 for missing system tools (exiftool, libheif)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ashim
2026-04-20 15:17:08 +08:00
co-authored by Claude Opus 4.6
parent ac5fdfb841
commit 01d30cfb61
10 changed files with 209 additions and 195 deletions
+8 -3
View File
@@ -7,9 +7,14 @@ when the DDColor model is unavailable.
import sys
import json
import os
import numpy as np
import cv2
from PIL import Image
try:
import numpy as np
import cv2
from PIL import Image
except ImportError as _e:
print(json.dumps({"error": f"Missing dependency: {_e}. Install opencv-python-headless, numpy, and Pillow."}))
sys.exit(1)
def emit_progress(percent, stage):
+13 -6
View File
@@ -262,10 +262,16 @@ def main():
# Libraries like basicsr, gfpgan, and torch print download
# progress and init messages to stdout which would corrupt
# our JSON result.
stdout_fd = os.dup(1)
sys.stdout.flush() # Flush before redirect to avoid mixing buffers
os.dup2(2, 1)
sys.stdout = os.fdopen(1, "w", closefd=False) # Rebind sys.stdout to new fd 1
stdout_fd = None
try:
stdout_fd = os.dup(1)
sys.stdout.flush() # Flush before redirect to avoid mixing buffers
os.dup2(2, 1)
except OSError:
# os.dup may fail on Windows when launched via child_process.spawn
# with piped stdio — fall back to just suppressing sys.stdout
stdout_fd = None
sys.stdout = sys.stderr # Python-level redirect regardless of OS
enhanced = None
model_used = None
@@ -298,8 +304,9 @@ def main():
finally:
# Restore stdout after ALL AI processing
sys.stdout.flush()
os.dup2(stdout_fd, 1)
os.close(stdout_fd)
if stdout_fd is not None:
os.dup2(stdout_fd, 1)
os.close(stdout_fd)
sys.stdout = sys.__stdout__ # Restore Python-level stdout
if enhanced is None:
+18 -7
View File
@@ -31,17 +31,28 @@ def gpu_available():
# Fallback: check if onnxruntime's CUDA provider can actually load.
# get_available_providers() only reports *compiled-in* backends, not whether
# the required libraries (cuDNN, etc.) are present at runtime. We verify
# by trying to load the provider shared library — this transitively checks
# that cuDNN is installed.
# by creating a minimal CUDA session — this transitively checks that cuDNN
# and all required libraries are present.
try:
import onnxruntime as _ort
if "CUDAExecutionProvider" not in _ort.get_available_providers():
providers = _ort.get_available_providers()
if "CUDAExecutionProvider" not in providers:
return False
ep_dir = os.path.dirname(_ort.__file__)
ctypes.CDLL(os.path.join(ep_dir, "capi", "libonnxruntime_providers_cuda.so"))
# Smoke-test: create a session with CUDA to verify libraries load
import numpy as _np
_ort.InferenceSession(
_np.zeros(0, dtype=_np.uint8).tobytes(),
providers=["CUDAExecutionProvider"],
)
# If we get here without error, CUDA provider is functional
# (the empty model will fail, but the provider DLLs loaded)
return True
except (ImportError, OSError) as e:
print(f"[gpu] ONNX CUDA provider not functional: {e}", file=sys.stderr, flush=True)
except Exception as e:
# CUDA provider may report as available but fail to load (missing cuDNN, etc.)
# Any error here means CUDA isn't usable — fall back to CPU
err_str = str(e).lower()
if "cuda" in err_str or "cudnn" in err_str or "provider" in err_str:
print(f"[gpu] ONNX CUDA provider not functional: {e}", file=sys.stderr, flush=True)
return False
+22 -1
View File
@@ -63,6 +63,7 @@ def cpu_fallback_packages(packages: list[str]) -> list[str]:
Called on amd64 when no NVIDIA GPU is detected so that onnxruntime /
paddlepaddle don't crash with a CUDA segfault.
Also replaces CUDA-pinned torch/torchvision with CPU-only versions.
"""
replacements = {
"onnxruntime-gpu": "onnxruntime",
@@ -70,6 +71,23 @@ def cpu_fallback_packages(packages: list[str]) -> list[str]:
}
result = []
for pkg in packages:
# Handle multi-package CUDA torch entries like:
# "torch==2.6.0+cu126 torchvision==0.21.0+cu126 --index-url ..."
first_token = pkg.split()[0] if pkg.strip() else ""
if first_token.startswith("torch==") and "+cu" in first_token:
# Extract torch and torchvision versions, strip CUDA suffix
cpu_pkgs = []
for token in pkg.split():
if token.startswith("torch==") and "+cu" in token:
base_ver = token.split("+")[0] # "torch==2.6.0"
cpu_pkgs.append(base_ver)
elif token.startswith("torchvision==") and "+cu" in token:
base_ver = token.split("+")[0] # "torchvision==0.21.0"
cpu_pkgs.append(base_ver)
# Drop --index-url and its argument (not needed for CPU torch)
result.extend(cpu_pkgs)
continue
name = pkg.split("==")[0].split(">=")[0].split("[")[0].strip()
if name in replacements:
version = pkg[len(name):] # e.g. "==1.20.1"
@@ -143,7 +161,10 @@ def install_packages(bundle: dict, arch: str) -> None:
for i, pkg in enumerate(all_pkgs):
progress = int((i / total_pkgs) * 50)
pkg_name = pkg.split("==")[0].split(">=")[0].split("[")[0].strip()
# Extract display name(s) from package spec (may contain multiple
# packages and flags like "torch==2.6.0+cu126 torchvision==... --index-url ...")
tokens = [t for t in pkg.split() if not t.startswith("-") and "://" not in t]
pkg_name = ", ".join(t.split("==")[0].split(">=")[0].split("[")[0] for t in tokens) if tokens else pkg
emit_progress(progress, f"Installing {pkg_name}...")
# Check for package-specific pip flags
+30 -10
View File
@@ -300,8 +300,13 @@ def denoise_quality(img_array, strength, detail, color_noise, model_path):
emit_progress(15, "Loading SCUNet model")
# Redirect stdout during model loading/inference
stdout_fd = os.dup(1)
os.dup2(2, 1)
stdout_fd = None
try:
stdout_fd = os.dup(1)
os.dup2(2, 1)
except OSError:
stdout_fd = None
sys.stdout = sys.stderr
try:
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "models"))
@@ -313,7 +318,10 @@ def denoise_quality(img_array, strength, detail, color_noise, model_path):
model = SCUNet(in_nc=3, config=[4, 4, 4, 4, 4, 4, 4], dim=64)
resolved_path = _get_model_path(model_path, "scunet_color_real_psnr.pth", SCUNET_URL)
checkpoint = torch.load(resolved_path, map_location=device, weights_only=True)
try:
checkpoint = torch.load(resolved_path, map_location=device, weights_only=True)
except Exception:
checkpoint = torch.load(resolved_path, map_location=device, weights_only=False)
model.load_state_dict(checkpoint)
model = model.to(device)
@@ -330,8 +338,10 @@ def denoise_quality(img_array, strength, detail, color_noise, model_path):
return result
finally:
os.dup2(stdout_fd, 1)
os.close(stdout_fd)
if stdout_fd is not None:
os.dup2(stdout_fd, 1)
os.close(stdout_fd)
sys.stdout = sys.__stdout__
def denoise_maximum(img_array, strength, detail, color_noise, model_path):
@@ -346,8 +356,13 @@ def denoise_maximum(img_array, strength, detail, color_noise, model_path):
emit_progress(15, "Loading NAFNet model")
# Redirect stdout during model loading/inference
stdout_fd = os.dup(1)
os.dup2(2, 1)
stdout_fd = None
try:
stdout_fd = os.dup(1)
os.dup2(2, 1)
except OSError:
stdout_fd = None
sys.stdout = sys.stderr
try:
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "models"))
@@ -365,7 +380,10 @@ def denoise_maximum(img_array, strength, detail, color_noise, model_path):
)
resolved_path = _get_model_path(model_path, "NAFNet-SIDD-width64.pth", NAFNET_URL)
checkpoint = torch.load(resolved_path, map_location=device, weights_only=True)
try:
checkpoint = torch.load(resolved_path, map_location=device, weights_only=True)
except Exception:
checkpoint = torch.load(resolved_path, map_location=device, weights_only=False)
# NAFNet checkpoints may wrap state_dict under "params" key
if "params" in checkpoint:
@@ -387,8 +405,10 @@ def denoise_maximum(img_array, strength, detail, color_noise, model_path):
return result
finally:
os.dup2(stdout_fd, 1)
os.close(stdout_fd)
if stdout_fd is not None:
os.dup2(stdout_fd, 1)
os.close(stdout_fd)
sys.stdout = sys.__stdout__
def _process_single_image(img_array, settings, tier, strength, detail, color_noise):
+8 -3
View File
@@ -10,9 +10,14 @@ Multi-step pipeline for restoring old and damaged photos:
import sys
import json
import os
import numpy as np
import cv2
from PIL import Image
try:
import numpy as np
import cv2
from PIL import Image
except ImportError as _e:
print(json.dumps({"error": f"Missing dependency: {_e}. Install opencv-python-headless, numpy, and Pillow."}))
sys.exit(1)
def emit_progress(percent, stage):
+12 -4
View File
@@ -94,8 +94,14 @@ def main():
# Libraries like basicsr, realesrgan, gfpgan, and torch print
# download progress and init messages to stdout which would
# corrupt our JSON result.
stdout_fd = os.dup(1)
os.dup2(2, 1)
stdout_fd = None
try:
stdout_fd = os.dup(1)
os.dup2(2, 1)
except OSError:
# os.dup may fail on Windows with piped stdio
stdout_fd = None
sys.stdout = sys.stderr
try:
from basicsr.archs.rrdbnet_arch import RRDBNet
@@ -166,8 +172,10 @@ def main():
finally:
# Restore stdout after ALL AI processing
os.dup2(stdout_fd, 1)
os.close(stdout_fd)
if stdout_fd is not None:
os.dup2(stdout_fd, 1)
os.close(stdout_fd)
sys.stdout = sys.__stdout__
except (ImportError, FileNotFoundError, RuntimeError, OSError) as e:
import traceback