Spaces:
Running on CPU Upgrade
Running on CPU Upgrade
Upload 4 files
Browse files- Dockerfile +1 -0
- app.py +257 -14
Dockerfile
CHANGED
|
@@ -8,6 +8,7 @@ RUN apt-get update && apt-get install -y \
|
|
| 8 |
libsm6 \
|
| 9 |
libxext6 \
|
| 10 |
libxrender1 \
|
|
|
|
| 11 |
&& rm -rf /var/lib/apt/lists/*
|
| 12 |
|
| 13 |
COPY requirements.txt .
|
|
|
|
| 8 |
libsm6 \
|
| 9 |
libxext6 \
|
| 10 |
libxrender1 \
|
| 11 |
+
ffmpeg \
|
| 12 |
&& rm -rf /var/lib/apt/lists/*
|
| 13 |
|
| 14 |
COPY requirements.txt .
|
app.py
CHANGED
|
@@ -6,11 +6,14 @@ and the rest of the Animation Taskforce 2026
|
|
| 6 |
"""
|
| 7 |
|
| 8 |
import io
|
|
|
|
| 9 |
import base64
|
|
|
|
|
|
|
| 10 |
import warnings
|
| 11 |
import cv2
|
| 12 |
import numpy as np
|
| 13 |
-
from fastapi import FastAPI, File, UploadFile, HTTPException
|
| 14 |
from fastapi.responses import HTMLResponse, Response
|
| 15 |
from fastapi.middleware.cors import CORSMiddleware
|
| 16 |
from pydantic import BaseModel
|
|
@@ -190,7 +193,204 @@ def full_histogram_matching(source, target, mask=None):
|
|
| 190 |
return np.clip(result, 0, 255).astype(np.uint8)
|
| 191 |
|
| 192 |
|
| 193 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 194 |
target_h, target_w = target_img.shape[:2]
|
| 195 |
target_size = (target_w, target_h)
|
| 196 |
source_resized = cv2.resize(source_img, target_size, interpolation=cv2.INTER_LANCZOS4)
|
|
@@ -215,6 +415,11 @@ def align_image(source_img, target_img):
|
|
| 215 |
aligned = source_resized
|
| 216 |
|
| 217 |
result = full_histogram_matching(aligned, target_img, mask=color_mask)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 218 |
return result
|
| 219 |
|
| 220 |
|
|
@@ -245,13 +450,15 @@ def encode_image_png(img: np.ndarray) -> bytes:
|
|
| 245 |
@app.post("/api/align")
|
| 246 |
async def align_api(
|
| 247 |
source: UploadFile = File(..., description="Source image to align"),
|
| 248 |
-
target: UploadFile = File(..., description="Target reference image")
|
|
|
|
| 249 |
):
|
| 250 |
"""
|
| 251 |
Align source image to target image.
|
| 252 |
Returns the aligned image as PNG.
|
| 253 |
"""
|
| 254 |
try:
|
|
|
|
| 255 |
source_data = await source.read()
|
| 256 |
target_data = await target.read()
|
| 257 |
|
|
@@ -261,7 +468,7 @@ async def align_api(
|
|
| 261 |
if source_img is None or target_img is None:
|
| 262 |
raise HTTPException(status_code=400, detail="Failed to decode images")
|
| 263 |
|
| 264 |
-
aligned = align_image(source_img, target_img)
|
| 265 |
png_bytes = encode_image_png(aligned)
|
| 266 |
|
| 267 |
return Response(content=png_bytes, media_type="image/png")
|
|
@@ -273,13 +480,15 @@ async def align_api(
|
|
| 273 |
@app.post("/api/align/base64")
|
| 274 |
async def align_base64_api(
|
| 275 |
source: UploadFile = File(...),
|
| 276 |
-
target: UploadFile = File(...)
|
|
|
|
| 277 |
):
|
| 278 |
"""
|
| 279 |
Align source image to target image.
|
| 280 |
Returns the aligned image as base64-encoded PNG.
|
| 281 |
"""
|
| 282 |
try:
|
|
|
|
| 283 |
source_data = await source.read()
|
| 284 |
target_data = await target.read()
|
| 285 |
|
|
@@ -289,7 +498,7 @@ async def align_base64_api(
|
|
| 289 |
if source_img is None or target_img is None:
|
| 290 |
raise HTTPException(status_code=400, detail="Failed to decode images")
|
| 291 |
|
| 292 |
-
aligned = align_image(source_img, target_img)
|
| 293 |
png_bytes = encode_image_png(aligned)
|
| 294 |
b64 = base64.b64encode(png_bytes).decode('utf-8')
|
| 295 |
|
|
@@ -356,6 +565,28 @@ HTML_CONTENT = """
|
|
| 356 |
.upload-box h3 { margin-bottom: 0.5rem; }
|
| 357 |
.upload-box.source h3 { color: #8be9fd; }
|
| 358 |
.upload-box.target h3 { color: #ffb86c; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 359 |
.btn {
|
| 360 |
display: block;
|
| 361 |
width: 100%;
|
|
@@ -417,28 +648,38 @@ HTML_CONTENT = """
|
|
| 417 |
<body>
|
| 418 |
<div class="container">
|
| 419 |
<div class="dedication">
|
| 420 |
-
<h2>Dedicated with
|
| 421 |
<div class="names">Alon Y., Daniel B., Denis Z., Tal S.</div>
|
| 422 |
<div class="team">and the rest of the Animation Taskforce 2026</div>
|
| 423 |
</div>
|
| 424 |
|
| 425 |
-
<h1>
|
| 426 |
<p class="subtitle">Geometric alignment with background-aware color matching</p>
|
| 427 |
|
| 428 |
<div class="upload-grid">
|
| 429 |
<div class="upload-box source" onclick="document.getElementById('sourceInput').click()">
|
| 430 |
<input type="file" id="sourceInput" accept="image/*">
|
| 431 |
-
<h3>
|
| 432 |
<p>Click to upload</p>
|
| 433 |
</div>
|
| 434 |
<div class="upload-box target" onclick="document.getElementById('targetInput').click()">
|
| 435 |
<input type="file" id="targetInput" accept="image/*">
|
| 436 |
-
<h3>
|
| 437 |
<p>Click to upload</p>
|
| 438 |
</div>
|
| 439 |
</div>
|
| 440 |
|
| 441 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 442 |
|
| 443 |
<div class="loading" id="loading">
|
| 444 |
<div class="spinner"></div>
|
|
@@ -446,19 +687,20 @@ HTML_CONTENT = """
|
|
| 446 |
</div>
|
| 447 |
|
| 448 |
<div class="result" id="result">
|
| 449 |
-
<h2>
|
| 450 |
<img id="resultImg" src="">
|
| 451 |
<br>
|
| 452 |
<a id="downloadLink" download="aligned.png">Download Aligned Image</a>
|
| 453 |
</div>
|
| 454 |
|
| 455 |
<div class="api-docs">
|
| 456 |
-
<h2>
|
| 457 |
<p>POST to <code>/api/align</code> with multipart form data:</p>
|
| 458 |
<pre><code>// JavaScript (fetch)
|
| 459 |
const formData = new FormData();
|
| 460 |
formData.append('source', sourceFile);
|
| 461 |
formData.append('target', targetFile);
|
|
|
|
| 462 |
|
| 463 |
const response = await fetch('/api/align', {
|
| 464 |
method: 'POST',
|
|
@@ -467,7 +709,7 @@ const response = await fetch('/api/align', {
|
|
| 467 |
const blob = await response.blob();
|
| 468 |
const url = URL.createObjectURL(blob);
|
| 469 |
|
| 470 |
-
// Or use /api/align/base64
|
| 471 |
const response = await fetch('/api/align/base64', {
|
| 472 |
method: 'POST',
|
| 473 |
body: formData
|
|
@@ -518,6 +760,7 @@ console.log(data.image); // data:image/png;base64,...</code></pre>
|
|
| 518 |
const formData = new FormData();
|
| 519 |
formData.append('source', sourceFile);
|
| 520 |
formData.append('target', targetFile);
|
|
|
|
| 521 |
|
| 522 |
const response = await fetch('/api/align', {
|
| 523 |
method: 'POST',
|
|
|
|
| 6 |
"""
|
| 7 |
|
| 8 |
import io
|
| 9 |
+
import os
|
| 10 |
import base64
|
| 11 |
+
import subprocess
|
| 12 |
+
import tempfile
|
| 13 |
import warnings
|
| 14 |
import cv2
|
| 15 |
import numpy as np
|
| 16 |
+
from fastapi import FastAPI, File, UploadFile, Form, HTTPException
|
| 17 |
from fastapi.responses import HTMLResponse, Response
|
| 18 |
from fastapi.middleware.cors import CORSMiddleware
|
| 19 |
from pydantic import BaseModel
|
|
|
|
| 193 |
return np.clip(result, 0, 255).astype(np.uint8)
|
| 194 |
|
| 195 |
|
| 196 |
+
# ============== Post-Processing ==============
|
| 197 |
+
|
| 198 |
+
# Level configs: (blur_sigma_mult, blur_sigma_min, motion_min, crf_boost)
|
| 199 |
+
PP_LEVELS = {
|
| 200 |
+
0: None, # disabled
|
| 201 |
+
1: {'sigma_mult': 1.0, 'sigma_min': 0.0, 'motion_min': 1, 'crf_boost': 0},
|
| 202 |
+
2: {'sigma_mult': 1.5, 'sigma_min': 0.5, 'motion_min': 3, 'crf_boost': 5},
|
| 203 |
+
3: {'sigma_mult': 2.0, 'sigma_min': 0.8, 'motion_min': 5, 'crf_boost': 10},
|
| 204 |
+
}
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
def detect_foreground_mask(aligned, target, threshold=25, min_area=500):
|
| 208 |
+
diff = cv2.absdiff(aligned, target)
|
| 209 |
+
diff_gray = cv2.cvtColor(diff, cv2.COLOR_BGR2GRAY)
|
| 210 |
+
_, binary = cv2.threshold(diff_gray, threshold, 255, cv2.THRESH_BINARY)
|
| 211 |
+
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (15, 15))
|
| 212 |
+
binary = cv2.morphologyEx(binary, cv2.MORPH_CLOSE, kernel, iterations=2)
|
| 213 |
+
binary = cv2.morphologyEx(binary, cv2.MORPH_OPEN, kernel, iterations=1)
|
| 214 |
+
num_labels, labels, stats, _ = cv2.connectedComponentsWithStats(binary, connectivity=8)
|
| 215 |
+
cleaned = np.zeros_like(binary)
|
| 216 |
+
for i in range(1, num_labels):
|
| 217 |
+
if stats[i, cv2.CC_STAT_AREA] >= min_area:
|
| 218 |
+
cleaned[labels == i] = 255
|
| 219 |
+
return cv2.GaussianBlur(cleaned.astype(np.float32) / 255.0, (31, 31), 0)
|
| 220 |
+
|
| 221 |
+
|
| 222 |
+
def estimate_blur_level(image):
|
| 223 |
+
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
|
| 224 |
+
laplacian_var = cv2.Laplacian(gray, cv2.CV_64F).var()
|
| 225 |
+
if laplacian_var > 500:
|
| 226 |
+
return 0.0
|
| 227 |
+
elif laplacian_var < 10:
|
| 228 |
+
return 3.0
|
| 229 |
+
return max(0.0, 2.5 - np.log10(laplacian_var) * 0.9)
|
| 230 |
+
|
| 231 |
+
|
| 232 |
+
def estimate_motion_blur(image):
|
| 233 |
+
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY).astype(np.float64)
|
| 234 |
+
f = np.fft.fft2(gray)
|
| 235 |
+
fshift = np.fft.fftshift(f)
|
| 236 |
+
magnitude = np.log1p(np.abs(fshift))
|
| 237 |
+
h, w = magnitude.shape
|
| 238 |
+
cy, cx = h // 2, w // 2
|
| 239 |
+
best_angle = 0.0
|
| 240 |
+
min_energy = float('inf')
|
| 241 |
+
max_energy = float('-inf')
|
| 242 |
+
radius = min(h, w) // 4
|
| 243 |
+
for angle_deg in range(0, 180, 5):
|
| 244 |
+
angle_rad = np.deg2rad(angle_deg)
|
| 245 |
+
dx, dy = np.cos(angle_rad), np.sin(angle_rad)
|
| 246 |
+
energy, count = 0.0, 0
|
| 247 |
+
for r in range(5, radius):
|
| 248 |
+
x, y = int(cx + r * dx), int(cy + r * dy)
|
| 249 |
+
if 0 <= x < w and 0 <= y < h:
|
| 250 |
+
energy += magnitude[y, x]
|
| 251 |
+
count += 1
|
| 252 |
+
x, y = int(cx - r * dx), int(cy - r * dy)
|
| 253 |
+
if 0 <= x < w and 0 <= y < h:
|
| 254 |
+
energy += magnitude[y, x]
|
| 255 |
+
count += 1
|
| 256 |
+
if count > 0:
|
| 257 |
+
avg_energy = energy / count
|
| 258 |
+
if avg_energy < min_energy:
|
| 259 |
+
min_energy = avg_energy
|
| 260 |
+
best_angle = angle_deg
|
| 261 |
+
if avg_energy > max_energy:
|
| 262 |
+
max_energy = avg_energy
|
| 263 |
+
blur_angle = (best_angle + 90) % 180
|
| 264 |
+
anisotropy = (max_energy - min_energy) / (max_energy + 1e-6)
|
| 265 |
+
kernel_size = 1 if anisotropy < 0.05 else max(1, int(anisotropy * 25))
|
| 266 |
+
return kernel_size, blur_angle
|
| 267 |
+
|
| 268 |
+
|
| 269 |
+
def estimate_crf(image):
|
| 270 |
+
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY).astype(np.float64)
|
| 271 |
+
h, w = gray.shape
|
| 272 |
+
laplacian = cv2.Laplacian(gray, cv2.CV_64F)
|
| 273 |
+
hf_energy = np.mean(np.abs(laplacian))
|
| 274 |
+
block_diffs = []
|
| 275 |
+
for x in range(4, w - 1, 4):
|
| 276 |
+
block_diffs.append(np.mean(np.abs(gray[:, x] - gray[:, x - 1])))
|
| 277 |
+
for y in range(4, h - 1, 4):
|
| 278 |
+
block_diffs.append(np.mean(np.abs(gray[y, :] - gray[y - 1, :])))
|
| 279 |
+
interior_diffs = []
|
| 280 |
+
for x in range(3, w - 1, 4):
|
| 281 |
+
if x % 4 != 0:
|
| 282 |
+
interior_diffs.append(np.mean(np.abs(gray[:, x] - gray[:, x - 1])))
|
| 283 |
+
avg_block = np.median(block_diffs) if block_diffs else 0
|
| 284 |
+
avg_interior = np.median(interior_diffs) if interior_diffs else 1
|
| 285 |
+
blockiness = avg_block / (avg_interior + 1e-6)
|
| 286 |
+
if hf_energy > 30:
|
| 287 |
+
crf_from_hf = 15
|
| 288 |
+
elif hf_energy > 15:
|
| 289 |
+
crf_from_hf = 23
|
| 290 |
+
elif hf_energy > 8:
|
| 291 |
+
crf_from_hf = 30
|
| 292 |
+
else:
|
| 293 |
+
crf_from_hf = 38
|
| 294 |
+
crf_from_blockiness = 18 + int((blockiness - 1.0) * 20)
|
| 295 |
+
crf = int(0.6 * crf_from_hf + 0.4 * crf_from_blockiness)
|
| 296 |
+
return max(0, min(51, crf))
|
| 297 |
+
|
| 298 |
+
|
| 299 |
+
def apply_h264_compression(image, crf=23):
|
| 300 |
+
h, w = image.shape[:2]
|
| 301 |
+
with tempfile.TemporaryDirectory() as tmpdir:
|
| 302 |
+
in_path = os.path.join(tmpdir, 'in.png')
|
| 303 |
+
out_path = os.path.join(tmpdir, 'out.mp4')
|
| 304 |
+
dec_path = os.path.join(tmpdir, 'dec.png')
|
| 305 |
+
cv2.imwrite(in_path, image)
|
| 306 |
+
subprocess.run([
|
| 307 |
+
'ffmpeg', '-y', '-hide_banner', '-loglevel', 'error',
|
| 308 |
+
'-i', in_path,
|
| 309 |
+
'-c:v', 'libx264', '-crf', str(crf),
|
| 310 |
+
'-pix_fmt', 'yuv420p', '-frames:v', '1',
|
| 311 |
+
out_path
|
| 312 |
+
], check=True, capture_output=True)
|
| 313 |
+
subprocess.run([
|
| 314 |
+
'ffmpeg', '-y', '-hide_banner', '-loglevel', 'error',
|
| 315 |
+
'-i', out_path, '-frames:v', '1', dec_path
|
| 316 |
+
], check=True, capture_output=True)
|
| 317 |
+
result = cv2.imread(dec_path)
|
| 318 |
+
if result.shape[:2] != (h, w):
|
| 319 |
+
result = cv2.resize(result, (w, h), interpolation=cv2.INTER_LANCZOS4)
|
| 320 |
+
return result
|
| 321 |
+
|
| 322 |
+
|
| 323 |
+
def apply_motion_blur(image, kernel_size=11, angle=0.0):
|
| 324 |
+
if kernel_size <= 1:
|
| 325 |
+
return image.copy()
|
| 326 |
+
kernel = np.zeros((kernel_size, kernel_size), dtype=np.float32)
|
| 327 |
+
center = kernel_size // 2
|
| 328 |
+
angle_rad = np.deg2rad(angle)
|
| 329 |
+
dx, dy = np.cos(angle_rad), np.sin(angle_rad)
|
| 330 |
+
for i in range(kernel_size):
|
| 331 |
+
t = i - center
|
| 332 |
+
x, y = int(round(center + t * dx)), int(round(center + t * dy))
|
| 333 |
+
if 0 <= x < kernel_size and 0 <= y < kernel_size:
|
| 334 |
+
kernel[y, x] = 1.0
|
| 335 |
+
kernel /= kernel.sum() + 1e-8
|
| 336 |
+
return cv2.filter2D(image, -1, kernel)
|
| 337 |
+
|
| 338 |
+
|
| 339 |
+
def apply_gaussian_blur(image, sigma):
|
| 340 |
+
if sigma <= 0:
|
| 341 |
+
return image.copy()
|
| 342 |
+
ksize = int(np.ceil(sigma * 6)) | 1
|
| 343 |
+
return cv2.GaussianBlur(image, (ksize, ksize), sigma)
|
| 344 |
+
|
| 345 |
+
|
| 346 |
+
def postprocess_foreground(aligned, target, level=2):
|
| 347 |
+
if level <= 0 or level not in PP_LEVELS:
|
| 348 |
+
return aligned
|
| 349 |
+
|
| 350 |
+
cfg = PP_LEVELS[level]
|
| 351 |
+
|
| 352 |
+
# Estimate target degradation
|
| 353 |
+
blur_sigma = estimate_blur_level(target)
|
| 354 |
+
motion_kernel, motion_angle = estimate_motion_blur(target)
|
| 355 |
+
crf = estimate_crf(target)
|
| 356 |
+
|
| 357 |
+
# Detect foreground
|
| 358 |
+
fg_mask = detect_foreground_mask(aligned, target)
|
| 359 |
+
if np.mean(fg_mask) < 0.001:
|
| 360 |
+
return aligned
|
| 361 |
+
|
| 362 |
+
degraded = aligned.copy()
|
| 363 |
+
|
| 364 |
+
# 1. Gaussian blur
|
| 365 |
+
applied_sigma = max(blur_sigma * cfg['sigma_mult'], cfg['sigma_min'])
|
| 366 |
+
if applied_sigma > 0:
|
| 367 |
+
degraded = apply_gaussian_blur(degraded, applied_sigma)
|
| 368 |
+
|
| 369 |
+
# 2. Motion blur
|
| 370 |
+
applied_motion = max(motion_kernel, cfg['motion_min'])
|
| 371 |
+
if applied_motion > 1:
|
| 372 |
+
degraded = apply_motion_blur(degraded, applied_motion, motion_angle)
|
| 373 |
+
|
| 374 |
+
# 3. H.264 CRF compression
|
| 375 |
+
applied_crf = min(crf + cfg['crf_boost'], 51)
|
| 376 |
+
try:
|
| 377 |
+
degraded = apply_h264_compression(degraded, applied_crf)
|
| 378 |
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
| 379 |
+
# Fallback to JPEG
|
| 380 |
+
jpeg_q = max(5, 95 - applied_crf * 2)
|
| 381 |
+
_, encoded = cv2.imencode('.jpg', degraded, [int(cv2.IMWRITE_JPEG_QUALITY), jpeg_q])
|
| 382 |
+
degraded = cv2.imdecode(encoded, cv2.IMREAD_COLOR)
|
| 383 |
+
|
| 384 |
+
# Blend into foreground only
|
| 385 |
+
mask_3ch = fg_mask[:, :, np.newaxis]
|
| 386 |
+
result = (degraded.astype(np.float32) * mask_3ch +
|
| 387 |
+
aligned.astype(np.float32) * (1.0 - mask_3ch))
|
| 388 |
+
return np.clip(result, 0, 255).astype(np.uint8)
|
| 389 |
+
|
| 390 |
+
|
| 391 |
+
# ============== Alignment Pipeline ==============
|
| 392 |
+
|
| 393 |
+
def align_image(source_img, target_img, pp_level=2):
|
| 394 |
target_h, target_w = target_img.shape[:2]
|
| 395 |
target_size = (target_w, target_h)
|
| 396 |
source_resized = cv2.resize(source_img, target_size, interpolation=cv2.INTER_LANCZOS4)
|
|
|
|
| 415 |
aligned = source_resized
|
| 416 |
|
| 417 |
result = full_histogram_matching(aligned, target_img, mask=color_mask)
|
| 418 |
+
|
| 419 |
+
# Post-processing
|
| 420 |
+
if pp_level > 0:
|
| 421 |
+
result = postprocess_foreground(result, target_img, level=pp_level)
|
| 422 |
+
|
| 423 |
return result
|
| 424 |
|
| 425 |
|
|
|
|
| 450 |
@app.post("/api/align")
|
| 451 |
async def align_api(
|
| 452 |
source: UploadFile = File(..., description="Source image to align"),
|
| 453 |
+
target: UploadFile = File(..., description="Target reference image"),
|
| 454 |
+
pp: int = Form(2, description="Post-processing level 0-3 (0=none, default=2)")
|
| 455 |
):
|
| 456 |
"""
|
| 457 |
Align source image to target image.
|
| 458 |
Returns the aligned image as PNG.
|
| 459 |
"""
|
| 460 |
try:
|
| 461 |
+
pp_level = max(0, min(3, pp))
|
| 462 |
source_data = await source.read()
|
| 463 |
target_data = await target.read()
|
| 464 |
|
|
|
|
| 468 |
if source_img is None or target_img is None:
|
| 469 |
raise HTTPException(status_code=400, detail="Failed to decode images")
|
| 470 |
|
| 471 |
+
aligned = align_image(source_img, target_img, pp_level=pp_level)
|
| 472 |
png_bytes = encode_image_png(aligned)
|
| 473 |
|
| 474 |
return Response(content=png_bytes, media_type="image/png")
|
|
|
|
| 480 |
@app.post("/api/align/base64")
|
| 481 |
async def align_base64_api(
|
| 482 |
source: UploadFile = File(...),
|
| 483 |
+
target: UploadFile = File(...),
|
| 484 |
+
pp: int = Form(2, description="Post-processing level 0-3 (0=none, default=2)")
|
| 485 |
):
|
| 486 |
"""
|
| 487 |
Align source image to target image.
|
| 488 |
Returns the aligned image as base64-encoded PNG.
|
| 489 |
"""
|
| 490 |
try:
|
| 491 |
+
pp_level = max(0, min(3, pp))
|
| 492 |
source_data = await source.read()
|
| 493 |
target_data = await target.read()
|
| 494 |
|
|
|
|
| 498 |
if source_img is None or target_img is None:
|
| 499 |
raise HTTPException(status_code=400, detail="Failed to decode images")
|
| 500 |
|
| 501 |
+
aligned = align_image(source_img, target_img, pp_level=pp_level)
|
| 502 |
png_bytes = encode_image_png(aligned)
|
| 503 |
b64 = base64.b64encode(png_bytes).decode('utf-8')
|
| 504 |
|
|
|
|
| 565 |
.upload-box h3 { margin-bottom: 0.5rem; }
|
| 566 |
.upload-box.source h3 { color: #8be9fd; }
|
| 567 |
.upload-box.target h3 { color: #ffb86c; }
|
| 568 |
+
.options-row {
|
| 569 |
+
display: flex;
|
| 570 |
+
align-items: center;
|
| 571 |
+
justify-content: center;
|
| 572 |
+
gap: 1.5rem;
|
| 573 |
+
margin-bottom: 2rem;
|
| 574 |
+
flex-wrap: wrap;
|
| 575 |
+
}
|
| 576 |
+
.options-row label {
|
| 577 |
+
font-size: 0.95rem;
|
| 578 |
+
color: #aaa;
|
| 579 |
+
}
|
| 580 |
+
.pp-select {
|
| 581 |
+
background: rgba(255,255,255,0.08);
|
| 582 |
+
color: #e8e8e8;
|
| 583 |
+
border: 1px solid rgba(255,255,255,0.2);
|
| 584 |
+
border-radius: 6px;
|
| 585 |
+
padding: 0.5rem 1rem;
|
| 586 |
+
font-size: 0.95rem;
|
| 587 |
+
cursor: pointer;
|
| 588 |
+
}
|
| 589 |
+
.pp-select option { background: #1a1a2e; color: #e8e8e8; }
|
| 590 |
.btn {
|
| 591 |
display: block;
|
| 592 |
width: 100%;
|
|
|
|
| 648 |
<body>
|
| 649 |
<div class="container">
|
| 650 |
<div class="dedication">
|
| 651 |
+
<h2>Dedicated with ♥ love and devotion to</h2>
|
| 652 |
<div class="names">Alon Y., Daniel B., Denis Z., Tal S.</div>
|
| 653 |
<div class="team">and the rest of the Animation Taskforce 2026</div>
|
| 654 |
</div>
|
| 655 |
|
| 656 |
+
<h1>🎯 Image Aligner</h1>
|
| 657 |
<p class="subtitle">Geometric alignment with background-aware color matching</p>
|
| 658 |
|
| 659 |
<div class="upload-grid">
|
| 660 |
<div class="upload-box source" onclick="document.getElementById('sourceInput').click()">
|
| 661 |
<input type="file" id="sourceInput" accept="image/*">
|
| 662 |
+
<h3>📷 Source Image</h3>
|
| 663 |
<p>Click to upload</p>
|
| 664 |
</div>
|
| 665 |
<div class="upload-box target" onclick="document.getElementById('targetInput').click()">
|
| 666 |
<input type="file" id="targetInput" accept="image/*">
|
| 667 |
+
<h3>🎯 Target Reference</h3>
|
| 668 |
<p>Click to upload</p>
|
| 669 |
</div>
|
| 670 |
</div>
|
| 671 |
|
| 672 |
+
<div class="options-row">
|
| 673 |
+
<label for="ppLevel">Post-processing:</label>
|
| 674 |
+
<select id="ppLevel" class="pp-select">
|
| 675 |
+
<option value="0">0 - None</option>
|
| 676 |
+
<option value="1">1 - Weak</option>
|
| 677 |
+
<option value="2" selected>2 - Medium (default)</option>
|
| 678 |
+
<option value="3">3 - Strong</option>
|
| 679 |
+
</select>
|
| 680 |
+
</div>
|
| 681 |
+
|
| 682 |
+
<button class="btn" id="alignBtn" disabled onclick="alignImages()">✨ Align Images</button>
|
| 683 |
|
| 684 |
<div class="loading" id="loading">
|
| 685 |
<div class="spinner"></div>
|
|
|
|
| 687 |
</div>
|
| 688 |
|
| 689 |
<div class="result" id="result">
|
| 690 |
+
<h2>✨ Aligned Result</h2>
|
| 691 |
<img id="resultImg" src="">
|
| 692 |
<br>
|
| 693 |
<a id="downloadLink" download="aligned.png">Download Aligned Image</a>
|
| 694 |
</div>
|
| 695 |
|
| 696 |
<div class="api-docs">
|
| 697 |
+
<h2>📡 API Usage</h2>
|
| 698 |
<p>POST to <code>/api/align</code> with multipart form data:</p>
|
| 699 |
<pre><code>// JavaScript (fetch)
|
| 700 |
const formData = new FormData();
|
| 701 |
formData.append('source', sourceFile);
|
| 702 |
formData.append('target', targetFile);
|
| 703 |
+
formData.append('pp', '2'); // 0=none, 1=weak, 2=medium, 3=strong
|
| 704 |
|
| 705 |
const response = await fetch('/api/align', {
|
| 706 |
method: 'POST',
|
|
|
|
| 709 |
const blob = await response.blob();
|
| 710 |
const url = URL.createObjectURL(blob);
|
| 711 |
|
| 712 |
+
// Or use /api/align/base64 for base64 response:
|
| 713 |
const response = await fetch('/api/align/base64', {
|
| 714 |
method: 'POST',
|
| 715 |
body: formData
|
|
|
|
| 760 |
const formData = new FormData();
|
| 761 |
formData.append('source', sourceFile);
|
| 762 |
formData.append('target', targetFile);
|
| 763 |
+
formData.append('pp', document.getElementById('ppLevel').value);
|
| 764 |
|
| 765 |
const response = await fetch('/api/align', {
|
| 766 |
method: 'POST',
|