harelcain commited on
Commit
ed39ef6
·
verified ·
1 Parent(s): 77a1faf

Upload 4 files

Browse files
Files changed (2) hide show
  1. Dockerfile +1 -0
  2. 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
- def align_image(source_img, target_img):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 love and devotion to</h2>
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>🎯 Image Aligner</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>📷 Source Image</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>🎯 Target Reference</h3>
437
  <p>Click to upload</p>
438
  </div>
439
  </div>
440
 
441
- <button class="btn" id="alignBtn" disabled onclick="alignImages()">✨ Align Images</button>
 
 
 
 
 
 
 
 
 
 
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> Aligned Result</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>📡 API Usage</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 to get base64 response:
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 &#9829; 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>&#127919; 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>&#128247; 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>&#127919; 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()">&#10024; 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>&#10024; 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>&#128225; 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',