feat: unified Docker image with GPU auto-detection (#37)

Merge CPU, CUDA, and lite Docker images into a single unified image.
One tag (latest) works on all platforms: amd64 (NVIDIA CUDA) and arm64 (CPU).
GPU auto-detected at runtime. All ML models and packages baked in.

Key changes:
- Platform-conditional Dockerfile (nvidia/cuda on amd64, node on arm64)
- tini as PID 1 for proper signal handling
- Fix FILES_STORAGE_PATH data loss bug
- Fix RealESRGAN upscaler (was broken, always fell back to Lanczos)
- Fix PaddleOCR language codes and stdout corruption
- Simplified CI/CD (single build, single tag)
- Expanded model pre-download with verification
- Shutdown timeout, improved health endpoint
- Remove unused lama-cleaner
This commit is contained in:
stirling-image
2026-04-10 13:21:06 +08:00
committed by GitHub
parent 7bc979f677
commit b0083e2b08
15 changed files with 374 additions and 310 deletions
+32 -16
View File
@@ -33,23 +33,39 @@ def run_tesseract(input_path, language):
def run_paddleocr(input_path, language):
"""Run PaddleOCR."""
os.environ["PADDLE_PDX_DISABLE_MODEL_SOURCE_CHECK"] = "True"
from paddleocr import PaddleOCR
from gpu import gpu_available
emit_progress(20, "Loading")
ocr = PaddleOCR(lang=language, use_gpu=gpu_available())
emit_progress(30, "Scanning")
result = ocr.ocr(input_path)
emit_progress(70, "Extracting text")
text = "\n".join(
[
line[1][0]
for res in result
if res
for line in res
if line and line[1]
]
)
# Redirect stdout to stderr so PaddleOCR download/init messages
# cannot contaminate our JSON result on stdout.
stdout_fd = os.dup(1)
os.dup2(2, 1)
try:
from paddleocr import PaddleOCR
from gpu import gpu_available
# Map API language codes to PaddleOCR codes
paddle_lang_map = {"en": "en", "de": "latin", "fr": "latin", "es": "latin", "zh": "ch", "ja": "japan", "ko": "korean"}
paddle_lang = paddle_lang_map.get(language, "en")
emit_progress(20, "Loading")
ocr = PaddleOCR(lang=paddle_lang, use_gpu=gpu_available(), show_log=False)
emit_progress(30, "Scanning")
result = ocr.ocr(input_path)
emit_progress(70, "Extracting text")
text = "\n".join(
[
line[1][0]
for res in result
if res
for line in res
if line and line[1]
]
)
finally:
# Restore stdout
os.dup2(stdout_fd, 1)
os.close(stdout_fd)
return text, "paddleocr"
+1 -1
View File
@@ -1,6 +1,5 @@
rembg==2.0.62
realesrgan==0.3.0
lama-cleaner==1.2.5
paddleocr==2.9.1
paddlepaddle-gpu==3.0.0
mediapipe==0.10.21
@@ -8,3 +7,4 @@ onnxruntime-gpu==1.20.1
numpy==1.26.4
Pillow==11.1.0
opencv-python-headless==4.10.0.84
seam-carving==1.1.0
-1
View File
@@ -1,6 +1,5 @@
rembg[cpu]==2.0.62
realesrgan==0.3.0
lama-cleaner==1.2.5
paddleocr==2.9.1
paddlepaddle==3.0.0
mediapipe==0.10.21
+31 -10
View File
@@ -1,6 +1,7 @@
"""Image upscaling with Real-ESRGAN fallback to Lanczos."""
import sys
import json
import os
def emit_progress(percent, stage):
@@ -8,6 +9,12 @@ def emit_progress(percent, stage):
print(json.dumps({"progress": percent, "stage": stage}), file=sys.stderr, flush=True)
REALESRGAN_MODEL_PATH = os.environ.get(
"REALESRGAN_MODEL_PATH",
"/opt/models/realesrgan/RealESRGAN_x4plus.pth",
)
def main():
input_path = sys.argv[1]
output_path = sys.argv[2]
@@ -24,26 +31,40 @@ def main():
# Try Real-ESRGAN first
try:
from basicsr.archs.rrdbnet_arch import RRDBNet
from realesrgan import RealESRGANer
from gpu import gpu_available
import numpy as np
import torch
# Redirect stdout to stderr so basicsr/realesrgan init messages
# cannot contaminate our JSON result on stdout.
stdout_fd = os.dup(1)
os.dup2(2, 1)
try:
from basicsr.archs.rrdbnet_arch import RRDBNet
from realesrgan import RealESRGANer
from gpu import gpu_available
import numpy as np
import torch
finally:
# Restore stdout after imports
os.dup2(stdout_fd, 1)
os.close(stdout_fd)
if not os.path.exists(REALESRGAN_MODEL_PATH):
raise FileNotFoundError(f"RealESRGAN model not found: {REALESRGAN_MODEL_PATH}")
use_gpu = gpu_available()
device = torch.device("cuda" if use_gpu else "cpu")
# RealESRGAN_x4plus is a 4x model internally
model = RRDBNet(
num_in_ch=3,
num_out_ch=3,
num_feat=64,
num_block=23,
num_grow_ch=32,
scale=scale,
scale=4,
)
upsampler = RealESRGANer(
scale=scale,
model_path=None,
scale=4,
model_path=REALESRGAN_MODEL_PATH,
model=model,
half=use_gpu,
device=device,
@@ -57,8 +78,8 @@ def main():
emit_progress(95, "Saving result")
result.save(output_path)
method = "realesrgan"
except (ImportError, Exception):
# Fallback to Lanczos upscaling
except (ImportError, FileNotFoundError, RuntimeError, OSError):
# RealESRGAN unavailable or failed - fall back to Lanczos
emit_progress(50, "Upscaling with Lanczos")
img_upscaled = img.resize(new_size, Image.LANCZOS)
emit_progress(95, "Saving result")