File size: 11,852 Bytes
a3953cb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
###############################################################################
# Dockerfile β€” HF Spaces (Docker SDK, CPU-only, port 7860)
#
# Two-stage build:
#   Stage A  β€” node:22-slim  β€” compile React/Vite frontend (VITE_API_BASE="")
#   Stage B  β€” python:3.11-slim β€” runtime with all ML models baked in
#
# HF Spaces rules enforced:
#   β€’ Non-root user uid=1000  (useradd -m -u 1000 user)
#   β€’ All writable runtime dirs under /tmp  (STORAGE_DIR, JOBS_DB_DIR)
#   β€’ Model cache baked at build time under /home/user (readable by uid 1000)
#   β€’ Port 7860
#
# Layer-size note
# ---------------
# Model downloads and COPY instructions run AFTER "USER user" so files are
# created owned by uid 1000 directly.  The only root-owned step after mkdir is
# facexlib (must write to site-packages).  This eliminates the old
# "RUN chown -R 1000:1000 /home/user" layer that duplicated ~1.5 GB of model
# weights via Docker's copy-on-write mechanism.
###############################################################################


###############################################################################
# Stage A β€” Build the React / Vite frontend
###############################################################################
FROM node:22-slim AS frontend-build

WORKDIR /build/frontend

# Install npm deps (cached until package*.json changes)
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci

# Build with VITE_API_BASE="" so every fetch() in the compiled JS uses a
# relative path (same-origin), not hardcoded http://localhost:8000.
COPY frontend/ .
RUN VITE_API_BASE="" npm run build
# Output lives at /build/frontend/dist


###############################################################################
# Stage B β€” Python 3.11 runtime
###############################################################################
FROM python:3.11-slim

# ── OS dependencies ───────────────────────────────────────────────────────────
# libgl1 + libglib2.0-0           : opencv-python requirement
# libsm6 libxext6 libxrender1     : X11 stubs required by cv2 on headless Linux
# wget ca-certificates            : download model weights in subsequent RUN steps
RUN apt-get update \
    && apt-get install -y --no-install-recommends \
        libgl1 \
        libglib2.0-0 \
        libsm6 \
        libxext6 \
        libxrender1 \
        wget \
        ca-certificates \
    && rm -rf /var/lib/apt/lists/*

# ── Non-root user (HF Spaces hard requirement) ────────────────────────────────
RUN useradd -m -u 1000 user

# ── Python packages ───────────────────────────────────────────────────────────
# Step 1: CPU-only torch + torchvision from the dedicated wheel index.
#   Versions pinned to match the local dev environment (torch 2.8.0, tv 0.23.0).
#   Installing from pytorch.org/whl/cpu guarantees no CUDA binary is pulled.
# Step 2: rest of requirements.txt.  pip finds torch>=2.2.0 already satisfied
#   by the CPU wheel above and skips it; all other deps resolve normally.
COPY requirements.txt /tmp/requirements.txt
RUN pip install --no-cache-dir \
        "torch==2.8.0" "torchvision==0.23.0" \
        --index-url https://download.pytorch.org/whl/cpu \
    && pip install --no-cache-dir -r /tmp/requirements.txt

# ── facexlib weights (must run as root β€” writes to site-packages) ─────────────
# URLs verified against facexlib 0.3.0 source:
#   recognition/__init__.py : recognition_arcface_ir_se50.pth  β†’ v0.1.0
#   __init__.py             : detection_Resnet50_Final.pth     β†’ v0.1.0
#   __init__.py             : parsing_parsenet.pth             β†’ v0.2.2
#
# FaceRestoreHelper.__init__ triggers the download of detection + parsing models
# automatically via load_file_from_url.  ArcFace is called separately because
# facerestore_engine.py loads it independently (raises FileNotFoundError if absent).
RUN python -c "\
import torch; \
from facexlib.utils.face_restoration_helper import FaceRestoreHelper; \
from facexlib.utils.misc import load_file_from_url; \
import facexlib; \
from pathlib import Path; \
weights_dir = str(Path(facexlib.__file__).parent / 'weights'); \
print('facexlib weights dir:', weights_dir); \
print('Pre-downloading RetinaFace (detection_Resnet50_Final.pth, 104 MB) and ParseNet (parsing_parsenet.pth, 81 MB)...'); \
FaceRestoreHelper(upscale_factor=1, face_size=512, crop_ratio=(1, 1), det_model='retinaface_resnet50', save_ext='png', use_parse=True, device=torch.device('cpu')); \
print('RetinaFace + ParseNet ready.'); \
print('Downloading ArcFace IR-SE50 (recognition_arcface_ir_se50.pth, 167 MB)...'); \
load_file_from_url(url='https://github.com/xinntao/facexlib/releases/download/v0.1.0/recognition_arcface_ir_se50.pth', model_dir='facexlib/weights', progress=True, save_dir=weights_dir); \
print('All facexlib weights downloaded.')"

# ── Pre-create model/cache dirs and chown while they are EMPTY (cheap) ────────
# chown on empty directories has zero copy-on-write cost.
# All subsequent writes (model downloads, COPY) happen as uid 1000 directly.
RUN mkdir -p \
        /home/user/app/ml_models/birefnet \
        /home/user/app/backend/ml_models/codeformer \
        /home/user/.cache/huggingface \
        /home/user/.paddleocr \
    && chown -R 1000:1000 \
        /home/user/app \
        /home/user/.cache \
        /home/user/.paddleocr

# ── Switch to uid 1000 β€” all subsequent RUN steps write as the target user ────
USER user

# ─────────────────────────────────────────────────────────────────────────────
# MODEL DOWNLOADS  (all happen at build time β€” zero network I/O at startup)
# Running as uid 1000 so files are created with correct ownership directly β€”
# no chown layer needed at the end.
# Layer order: stable downloads first so code changes don't invalidate them.
# ─────────────────────────────────────────────────────────────────────────────

# ── RealESRGAN_x4plus.pth  (64 MB) ───────────────────────────────────────────
# Official release: https://github.com/xinntao/Real-ESRGAN/releases/tag/v0.1.0
# Destination matches UPSCALE_MODEL_PATH = BASE_DIR / "ml_models" / "RealESRGAN_x4plus.pth"
RUN wget --tries=3 --no-verbose --show-progress \
        -O /home/user/app/ml_models/RealESRGAN_x4plus.pth \
        "https://github.com/xinntao/Real-ESRGAN/releases/download/v0.1.0/RealESRGAN_x4plus.pth"

# ── codeformer.pth  (360 MB) ─────────────────────────────────────────────────
# Official release: https://github.com/sczhou/CodeFormer/releases/tag/v0.1.0
# Destination matches CODEFORMER_MODEL_PATH = backend/ml_models/codeformer/codeformer.pth
RUN wget --tries=3 --no-verbose --show-progress \
        -O /home/user/app/backend/ml_models/codeformer/codeformer.pth \
        "https://github.com/sczhou/CodeFormer/releases/download/v0.1.0/codeformer.pth"

# ── BiRefNet  (~424 MB, HuggingFace Hub) ─────────────────────────────────────
# Uses snapshot_download (no model instantiation) so it is reliable in a
# headless build environment.  The cache_dir matches BIREFNET_MODEL_CACHE in
# config.py (BASE_DIR / "ml_models" / "birefnet").  trust_remote_code=True is
# NOT needed here β€” snapshot_download just copies files.  At runtime,
# from_pretrained(..., trust_remote_code=True) reads the same cache.
RUN python -c "\
from huggingface_hub import snapshot_download; \
print('Downloading BiRefNet from HuggingFace Hub (~424 MB)...'); \
snapshot_download(repo_id='ZhengPeng7/BiRefNet', cache_dir='/home/user/app/ml_models/birefnet'); \
print('BiRefNet download complete.')"

# ── PaddleOCR models  (~200 MB, text detection/recognition/orientation) ───────
# HOME is set to /home/user so models land in /home/user/.paddleocr, which is
# exactly where the runtime process will look for them.
# Same PaddleOCR constructor args as ocr_service.py: use_textline_orientation,
# lang="en", enable_mkldnn=False (safe on Linux CPU, avoids OneDNN issues).
RUN HOME=/home/user python -c "\
from paddleocr import PaddleOCR; \
print('Pre-warming PaddleOCR (first-run model download ~200 MB)...'); \
PaddleOCR(use_textline_orientation=True, lang='en', enable_mkldnn=False); \
print('PaddleOCR pre-warm complete.')"

# ─────────────────────────────────────────────────────────────────────────────
# SOURCE & COMMITTED WEIGHTS  (change more often than model downloads β†’ last)
# --chown=user:user ensures uid 1000 owns all copied files without a separate
# chown layer (avoids copy-on-write duplication of large files).
# ─────────────────────────────────────────────────────────────────────────────

# ── Backend source (.dockerignore excludes codeformer.pth β€” already downloaded)
COPY --chown=user:user backend/ /home/user/app/backend/

# ── lama.pth  (197 MB) ───────────────────────────────────────────────────────
# No reliable public download URL exists for the big-lama TorchScript export.
# This file MUST be committed to the repository via Git LFS.
# Register with:  git lfs track "ml_models/lama.pth"
# Destination matches MODEL_PATH = BASE_DIR / "ml_models" / "lama.pth"
COPY --chown=user:user ml_models/lama.pth /home/user/app/ml_models/lama.pth

# ── Built React app from Stage A ─────────────────────────────────────────────
# Destination matches the path computed in main.py:
#   Path(__file__).resolve().parent.parent.parent / "frontend" / "dist"
#   = /home/user/app/frontend/dist   (when __file__ is backend/app/main.py)
COPY --chown=user:user --from=frontend-build /build/frontend/dist /home/user/app/frontend/dist/

# ── Runtime environment variables ─────────────────────────────────────────────
# Every variable here has a local-safe default in the Python code; these
# overrides are ONLY active inside the container (start.bat sets none of them).
ENV HOME=/home/user \
    PORT=7860 \
    SERVE_FRONTEND=true \
    PUBLIC_BASE_URL="" \
    ALLOWED_ORIGINS="*" \
    STORAGE_DIR=/tmp/storage \
    JOBS_DB_DIR=/tmp/data \
    HF_HOME=/home/user/.cache/huggingface \
    HF_HUB_DISABLE_PROGRESS_BARS=1

EXPOSE 7860

# Working directory mirrors start.bat: `cd backend && uvicorn app.main:app`
# "app.main:app" resolves to /home/user/app/backend/app/main.py
WORKDIR /home/user/app/backend

# No --reload (dev-only).  --host 0.0.0.0 required for HF Spaces to route traffic.
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860"]