Spaces:
Running
Super-resolve the head only, which makes real SR affordable on CPU
Browse filesRealESRGAN was GPU-only: on CPU a 4x pass over the full frame takes ~20 minutes
here, so the free tier fell back to a plain Lanczos resize and the "4K" download
was pure interpolation.
Now that everything outside the changed region is composited straight from the
full-resolution original, the background is already native and has nothing for a
super-resolution model to reconstruct. Only the head came through the swap's
128x128 bottleneck, so RealESRGAN now runs on that crop alone and Lanczos covers
the rest, feathered at the crop edge.
Measured on the Library target (CPU, 1086x1448 -> 4344x5792): head-only SR takes
370s against ~1200s for the full frame, and high-frequency detail in the face
region rises from 5.9 to 19.9. GPU behaviour is unchanged when no bbox is given.
Not yet safe to deploy synchronously — 370s exceeds the request timeout. The
async job endpoint lands next.
- core/super_res.py +73 -23
- web_app.py +13 -2
|
@@ -129,41 +129,91 @@ def restore_faces(image: np.ndarray) -> np.ndarray:
|
|
| 129 |
|
| 130 |
|
| 131 |
def upscale_image(image: np.ndarray, scale: int = 4,
|
| 132 |
-
realesrgan_weight: float = 0.5
|
|
|
|
| 133 |
"""
|
| 134 |
-
Upscale
|
| 135 |
|
| 136 |
RealESRGAN adds resolution but, being a general-purpose model, it over-sharpens
|
| 137 |
skin into a plastic/waxy texture on faces. So we BLEND it with a plain Lanczos
|
| 138 |
upscale (realesrgan_weight controls the mix: 0 = pure natural Lanczos, 1 = full
|
| 139 |
RealESRGAN) — keeping most of the detail while killing the artificial texture.
|
| 140 |
|
| 141 |
-
|
| 142 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 143 |
"""
|
| 144 |
h, w = image.shape[:2]
|
| 145 |
lanczos = cv2.resize(image, (w * scale, h * scale),
|
| 146 |
interpolation=cv2.INTER_LANCZOS4)
|
| 147 |
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 167 |
|
| 168 |
|
| 169 |
def enhance_resolution(image: np.ndarray, scale: int = 4) -> np.ndarray:
|
|
|
|
| 129 |
|
| 130 |
|
| 131 |
def upscale_image(image: np.ndarray, scale: int = 4,
|
| 132 |
+
realesrgan_weight: float = 0.5,
|
| 133 |
+
focus_bbox=None) -> np.ndarray:
|
| 134 |
"""
|
| 135 |
+
Upscale ~scale x for the high-resolution download.
|
| 136 |
|
| 137 |
RealESRGAN adds resolution but, being a general-purpose model, it over-sharpens
|
| 138 |
skin into a plastic/waxy texture on faces. So we BLEND it with a plain Lanczos
|
| 139 |
upscale (realesrgan_weight controls the mix: 0 = pure natural Lanczos, 1 = full
|
| 140 |
RealESRGAN) — keeping most of the detail while killing the artificial texture.
|
| 141 |
|
| 142 |
+
focus_bbox restricts RealESRGAN to the head region. Everything outside it is
|
| 143 |
+
now composited from the untouched full-resolution target (see
|
| 144 |
+
utils.image_io.composite_onto_original), so only the head — which came
|
| 145 |
+
through the swap's 128x128 bottleneck — has detail worth reconstructing.
|
| 146 |
+
That matters most on CPU: measured on this pipeline a full 1.57MP frame
|
| 147 |
+
takes ~20 minutes, while the head crop alone is seconds, which is what makes
|
| 148 |
+
real super-resolution affordable on the free CPU tier instead of GPU-only.
|
| 149 |
+
Passing no bbox on CPU keeps the old Lanczos-only behaviour.
|
| 150 |
"""
|
| 151 |
h, w = image.shape[:2]
|
| 152 |
lanczos = cv2.resize(image, (w * scale, h * scale),
|
| 153 |
interpolation=cv2.INTER_LANCZOS4)
|
| 154 |
|
| 155 |
+
on_gpu = _device() == "cuda"
|
| 156 |
+
if not on_gpu and focus_bbox is None:
|
| 157 |
+
print(f"[super_res] Lanczos {scale}x -> {w*scale}x{h*scale}")
|
| 158 |
+
return lanczos
|
| 159 |
+
|
| 160 |
+
upsampler = _load_realesrgan()
|
| 161 |
+
if upsampler is None:
|
| 162 |
+
print(f"[super_res] Lanczos {scale}x -> {w*scale}x{h*scale}")
|
| 163 |
+
return lanczos
|
| 164 |
+
|
| 165 |
+
try:
|
| 166 |
+
if focus_bbox is None:
|
| 167 |
+
output, _ = upsampler.enhance(image, outscale=scale)
|
| 168 |
+
if output is None:
|
| 169 |
+
return lanczos
|
| 170 |
+
if output.shape[:2] != lanczos.shape[:2]:
|
| 171 |
+
output = cv2.resize(output, (lanczos.shape[1], lanczos.shape[0]),
|
| 172 |
+
interpolation=cv2.INTER_LANCZOS4)
|
| 173 |
+
blended = cv2.addWeighted(output, realesrgan_weight,
|
| 174 |
+
lanczos, 1.0 - realesrgan_weight, 0)
|
| 175 |
+
print(f"[super_res] RealESRGAN+Lanczos {scale}x -> "
|
| 176 |
+
f"{blended.shape[1]}x{blended.shape[0]} (re={realesrgan_weight})")
|
| 177 |
+
return blended
|
| 178 |
+
|
| 179 |
+
# -- head-only super-resolution ------------------------------------
|
| 180 |
+
x1, y1, x2, y2 = [int(v) for v in focus_bbox]
|
| 181 |
+
bw, bh = x2 - x1, y2 - y1
|
| 182 |
+
# Generous margin so hair and jaw are reconstructed with the face, and
|
| 183 |
+
# the feathered seam lands on background rather than on skin.
|
| 184 |
+
cx1 = max(0, x1 - int(bw * 1.0)); cy1 = max(0, y1 - int(bh * 1.2))
|
| 185 |
+
cx2 = min(w, x2 + int(bw * 1.0)); cy2 = min(h, y2 + int(bh * 1.6))
|
| 186 |
+
crop = image[cy1:cy2, cx1:cx2]
|
| 187 |
+
if crop.size == 0:
|
| 188 |
+
return lanczos
|
| 189 |
+
|
| 190 |
+
out_crop, _ = upsampler.enhance(crop, outscale=scale)
|
| 191 |
+
if out_crop is None:
|
| 192 |
+
return lanczos
|
| 193 |
+
tw, th = (cx2 - cx1) * scale, (cy2 - cy1) * scale
|
| 194 |
+
if (out_crop.shape[1], out_crop.shape[0]) != (tw, th):
|
| 195 |
+
out_crop = cv2.resize(out_crop, (tw, th), interpolation=cv2.INTER_LANCZOS4)
|
| 196 |
+
|
| 197 |
+
region = lanczos[cy1 * scale:cy2 * scale, cx1 * scale:cx2 * scale]
|
| 198 |
+
mixed = cv2.addWeighted(out_crop, realesrgan_weight,
|
| 199 |
+
region, 1.0 - realesrgan_weight, 0)
|
| 200 |
+
|
| 201 |
+
# Feather the crop edge so the enhanced head doesn't end on a hard line.
|
| 202 |
+
m = np.zeros((th, tw), np.float32)
|
| 203 |
+
pad = max(2, int(min(th, tw) * 0.06))
|
| 204 |
+
m[pad:th - pad, pad:tw - pad] = 1.0
|
| 205 |
+
m = cv2.GaussianBlur(m, (0, 0), pad / 2.0)[..., None]
|
| 206 |
+
|
| 207 |
+
out = lanczos.copy()
|
| 208 |
+
out[cy1 * scale:cy2 * scale, cx1 * scale:cx2 * scale] = (
|
| 209 |
+
mixed.astype(np.float32) * m + region.astype(np.float32) * (1.0 - m)
|
| 210 |
+
).astype(np.uint8)
|
| 211 |
+
print(f"[super_res] RealESRGAN on head {crop.shape[1]}x{crop.shape[0]} "
|
| 212 |
+
f"+ Lanczos {scale}x -> {out.shape[1]}x{out.shape[0]}")
|
| 213 |
+
return out
|
| 214 |
+
except Exception as e:
|
| 215 |
+
print(f"[super_res] RealESRGAN enhance failed: {e}")
|
| 216 |
+
return lanczos
|
| 217 |
|
| 218 |
|
| 219 |
def enhance_resolution(image: np.ndarray, scale: int = 4) -> np.ndarray:
|
|
@@ -864,13 +864,24 @@ def api_swap():
|
|
| 864 |
# 9. Paste the changed region back onto the FULL-RESOLUTION target so the
|
| 865 |
# background and body keep the original photo's detail rather than the
|
| 866 |
# downscaled-then-interpolated version. No-op if nothing was resized.
|
|
|
|
| 867 |
try:
|
| 868 |
swapped = composite_onto_original(swapped, target, target_orig)
|
| 869 |
except Exception as e:
|
| 870 |
print(f"[swap] original-resolution composite skipped: {e}")
|
| 871 |
|
| 872 |
-
# -- 4K upscale for download (RealESRGAN
|
| 873 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 874 |
|
| 875 |
# The 4K result is returned inline as a base64 data-URI so the user can
|
| 876 |
# download it client-side (works on the HF Space too). We do NOT store it
|
|
|
|
| 864 |
# 9. Paste the changed region back onto the FULL-RESOLUTION target so the
|
| 865 |
# background and body keep the original photo's detail rather than the
|
| 866 |
# downscaled-then-interpolated version. No-op if nothing was resized.
|
| 867 |
+
_work_h, _work_w = swapped.shape[:2]
|
| 868 |
try:
|
| 869 |
swapped = composite_onto_original(swapped, target, target_orig)
|
| 870 |
except Exception as e:
|
| 871 |
print(f"[swap] original-resolution composite skipped: {e}")
|
| 872 |
|
| 873 |
+
# -- 4K upscale for download (RealESRGAN on the head, Lanczos elsewhere)
|
| 874 |
+
# The head is the only region that came through the swap's 128px
|
| 875 |
+
# bottleneck; the rest is already native resolution from step 9, so
|
| 876 |
+
# there's nothing there for a super-resolution model to reconstruct.
|
| 877 |
+
_sr_bbox = None
|
| 878 |
+
try:
|
| 879 |
+
_fs = swapped.shape[1] / float(_work_w) # working -> composited scale
|
| 880 |
+
x1, y1, x2, y2 = faces_tgt[0]
|
| 881 |
+
_sr_bbox = (x1 * _fs, y1 * _fs, x2 * _fs, y2 * _fs)
|
| 882 |
+
except Exception:
|
| 883 |
+
pass
|
| 884 |
+
hi_res = upscale_image(swapped, scale=4, focus_bbox=_sr_bbox)
|
| 885 |
|
| 886 |
# The 4K result is returned inline as a base64 data-URI so the user can
|
| 887 |
# download it client-side (works on the HF Space too). We do NOT store it
|