sdragly commited on
Commit
f952998
·
1 Parent(s): 3ad9f44

Segmentation model

Browse files
Files changed (9) hide show
  1. Dockerfile +7 -1
  2. js/color-normalize.js +83 -0
  3. js/dressup.js +329 -14
  4. js/ml-client.js +95 -13
  5. js/recolor.js +134 -0
  6. js/upload.js +14 -4
  7. space/app.py +233 -6
  8. space/requirements.txt +2 -0
  9. style.css +91 -9
Dockerfile CHANGED
@@ -17,12 +17,18 @@ ENV U2NET_HOME=/home/user/.u2net
17
  WORKDIR /app
18
 
19
  # Install Python deps first so this layer caches across code edits.
 
 
20
  COPY --chown=user space/requirements.txt .
 
 
 
21
  RUN pip install --no-cache-dir --user -r requirements.txt
22
 
23
- # Pre-download the model so the first request isn't slow and so the
24
  # weights live in the image layer (no re-download on Space restart).
25
  RUN python -c "from rembg import new_session; new_session('isnet-general-use')"
 
26
 
27
  # Backend — FastAPI app serves both /remove-bg and the static frontend.
28
  COPY --chown=user space/app.py .
 
17
  WORKDIR /app
18
 
19
  # Install Python deps first so this layer caches across code edits.
20
+ # CPU-only torch from the dedicated index shaves ~600MB vs the default
21
+ # CUDA wheel, which matters for HF's free tier image size limit.
22
  COPY --chown=user space/requirements.txt .
23
+ RUN pip install --no-cache-dir --user \
24
+ --index-url https://download.pytorch.org/whl/cpu \
25
+ torch
26
  RUN pip install --no-cache-dir --user -r requirements.txt
27
 
28
+ # Pre-download models so the first request isn't slow and so the
29
  # weights live in the image layer (no re-download on Space restart).
30
  RUN python -c "from rembg import new_session; new_session('isnet-general-use')"
31
+ RUN python -c "from transformers import pipeline; pipeline('mask-generation', model='Zigeng/SlimSAM-uniform-77')"
32
 
33
  # Backend — FastAPI app serves both /remove-bg and the static frontend.
34
  COPY --chown=user space/app.py .
js/color-normalize.js ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Grey-world white balance. Runs on captured photos before background
2
+ // removal so items photographed under wildly different lighting end up
3
+ // looking like they belong in the same drawing.
4
+ //
5
+ // Pre-segmentation is the right spot: the normalized blob is what gets
6
+ // saved to IDB, so every downstream consumer (dress-up canvas, tray
7
+ // thumbs, screenshot) automatically benefits.
8
+ //
9
+ // The algorithm: compute per-channel means (skipping near-black ink and
10
+ // blown-out highlights), then scale each channel so its mean matches
11
+ // the overall grey target. Gains are clamped to [0.7, 1.4] so
12
+ // monochrome subjects don't get wrecked.
13
+
14
+ import { tagOp } from './diag.js';
15
+
16
+ const BLACK_SUM = 30; // skip r+g+b below this
17
+ const WHITE_SUM = 735; // skip r+g+b above this (3 * 245)
18
+ const MIN_GAIN = 0.7;
19
+ const MAX_GAIN = 1.4;
20
+
21
+ export async function normalizeColors(blob) {
22
+ const t0 = performance.now();
23
+ let bitmap;
24
+ try {
25
+ bitmap = await createImageBitmap(blob);
26
+ } catch (err) {
27
+ tagOp('normalize: decode failed');
28
+ return blob;
29
+ }
30
+
31
+ const { width: w, height: h } = bitmap;
32
+ const canvas = new OffscreenCanvas(w, h);
33
+ const ctx = canvas.getContext('2d', { willReadFrequently: true });
34
+ ctx.drawImage(bitmap, 0, 0);
35
+ bitmap.close();
36
+
37
+ const imgData = ctx.getImageData(0, 0, w, h);
38
+ const px = imgData.data;
39
+ const len = px.length;
40
+
41
+ // Pass 1: accumulate channel means over mid-tone pixels.
42
+ let sumR = 0, sumG = 0, sumB = 0, count = 0;
43
+ for (let i = 0; i < len; i += 4) {
44
+ const r = px[i], g = px[i + 1], b = px[i + 2];
45
+ const s = r + g + b;
46
+ if (s < BLACK_SUM || s > WHITE_SUM) continue;
47
+ sumR += r;
48
+ sumG += g;
49
+ sumB += b;
50
+ count++;
51
+ }
52
+
53
+ if (count === 0) {
54
+ // Nothing useful to sample — return original.
55
+ tagOp('normalize: no mid-tone pixels');
56
+ return blob;
57
+ }
58
+
59
+ const meanR = sumR / count;
60
+ const meanG = sumG / count;
61
+ const meanB = sumB / count;
62
+ const target = (meanR + meanG + meanB) / 3;
63
+
64
+ const gR = clamp(target / meanR, MIN_GAIN, MAX_GAIN);
65
+ const gG = clamp(target / meanG, MIN_GAIN, MAX_GAIN);
66
+ const gB = clamp(target / meanB, MIN_GAIN, MAX_GAIN);
67
+
68
+ // Pass 2: apply gains.
69
+ for (let i = 0; i < len; i += 4) {
70
+ px[i] = Math.min(255, px[i] * gR);
71
+ px[i + 1] = Math.min(255, px[i + 1] * gG);
72
+ px[i + 2] = Math.min(255, px[i + 2] * gB);
73
+ }
74
+
75
+ ctx.putImageData(imgData, 0, 0);
76
+ const out = await canvas.convertToBlob({ type: 'image/jpeg', quality: 0.9 });
77
+ tagOp(`normalize: ${Math.round(performance.now() - t0)}ms`);
78
+ return out;
79
+ }
80
+
81
+ function clamp(v, lo, hi) {
82
+ return v < lo ? lo : v > hi ? hi : v;
83
+ }
js/dressup.js CHANGED
@@ -2,15 +2,27 @@ import { loadDrawings, loadItems, saveDrawing, updateDrawingItems, deleteDrawing
2
  import { getCategoryInfo, DOUBLE_TAP_MS, DRAG_DEAD_ZONE_PX } from './models.js';
3
  import { STICKERS } from './stickers.js';
4
  import { migrateOldData } from './store.js';
 
5
  import { tagOp } from './diag.js';
6
 
7
  let drawings = [];
8
  let items = [];
9
  let currentIdx = 0;
10
- let placedItems = []; // { itemId, x, y, scale, rotation, zIndex, url, isSticker, sparkle }
11
  let objectURLs = [];
12
  let itemsURLMap = {}; // id -> objectURL
13
 
 
 
 
 
 
 
 
 
 
 
 
14
  // Single-finger drag state
15
  let drag = null;
16
  let ghost = null;
@@ -95,6 +107,12 @@ export async function initDressUp() {
95
  objectURLs = [];
96
  itemsURLMap = {};
97
 
 
 
 
 
 
 
98
  let totalBytes = 0;
99
  items.forEach(c => {
100
  const url = blobToURL(c.imageBlob);
@@ -118,6 +136,7 @@ export async function initDressUp() {
118
  <div id="princess-canvas" class="princess-canvas">
119
  <div id="placed-layer" class="placed-layer"></div>
120
  <div id="bbox-overlay" class="bbox-overlay"></div>
 
121
  </div>
122
  </div>
123
 
@@ -164,8 +183,8 @@ let saveTimer = null;
164
  function saveNow() {
165
  const d = drawings[currentIdx];
166
  if (!d) return;
167
- const saved = placedItems.map(({ itemId, x, y, scale, rotation, zIndex, isSticker, sparkle }) =>
168
- ({ itemId, clothingId: itemId, x, y, scale, rotation: rotation || 0, zIndex, isSticker: isSticker || false, sparkle: sparkle || false })
169
  );
170
  d.items = saved; // update in-memory too
171
  updateDrawingItems(d.id, saved);
@@ -217,12 +236,25 @@ function renderDrawing() {
217
  url,
218
  isSticker: item.isSticker || false,
219
  sparkle: item.sparkle || false,
 
220
  });
221
  }
222
  }
223
  }
224
  selectedIdx = -1;
225
  renderPlaced();
 
 
 
 
 
 
 
 
 
 
 
 
226
  }
227
 
228
  let currentTab = 'items';
@@ -296,9 +328,14 @@ function updatePlacedStyle(origIdx) {
296
  el.style.width = `${item.scale * 100}%`;
297
  el.style.transform = `translate(-50%,-50%) rotate(${item.rotation || 0}deg)`;
298
  renderBBox();
 
 
 
 
 
299
  }
300
 
301
- // ---- Desktop bounding box ----
302
 
303
  function renderBBox() {
304
  const overlay = document.getElementById('bbox-overlay');
@@ -306,12 +343,12 @@ function renderBBox() {
306
 
307
  if (selectedIdx < 0 || selectedIdx >= placedItems.length) {
308
  overlay.innerHTML = '';
 
309
  return;
310
  }
311
 
312
- const item = placedItems[selectedIdx];
313
  const el = document.querySelector(`.placed-item[data-orig-idx="${selectedIdx}"]`);
314
- if (!el) { overlay.innerHTML = ''; return; }
315
 
316
  // Get bounding rect relative to canvas
317
  const canvas = document.getElementById('princess-canvas');
@@ -323,13 +360,176 @@ function renderBBox() {
323
  const w = elRect.width;
324
  const h = elRect.height;
325
 
 
 
326
  overlay.innerHTML = `
327
  <div class="bbox" style="left:${left}px;top:${top}px;width:${w}px;height:${h}px;">
328
  <div class="bbox-handle bbox-handle-br" data-handle="br"></div>
329
- <button class="bbox-action bbox-sparkle" data-action="sparkle" title="Toggle sparkles">\u2728</button>
330
- <button class="bbox-action bbox-delete" data-action="delete" title="Remove">\u{1F5D1}</button>
331
  </div>
332
  `;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
333
  }
334
 
335
  function handleBBoxAction(action, idx) {
@@ -359,6 +559,26 @@ function animateNewItem() {
359
  });
360
  }
361
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
362
  // ---- Ghost (for tray drag) ----
363
 
364
  function createGhost(url) {
@@ -493,7 +713,7 @@ function bindEvents() {
493
  }
494
  });
495
 
496
- // BBox actions
497
  const bboxOverlay = document.getElementById('bbox-overlay');
498
  bboxOverlay.addEventListener('pointerdown', (e) => {
499
  const handle = e.target.closest('.bbox-handle');
@@ -509,13 +729,60 @@ function bindEvents() {
509
  startScale: placedItems[selectedIdx].scale,
510
  canvasW: rect.width,
511
  };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
512
  return;
513
  }
514
- const actionBtn = e.target.closest('.bbox-action');
515
- if (actionBtn && selectedIdx >= 0) {
 
516
  e.preventDefault();
517
  e.stopPropagation();
518
- handleBBoxAction(actionBtn.dataset.action, selectedIdx);
 
 
 
 
 
 
 
 
519
  }
520
  });
521
 
@@ -642,6 +909,13 @@ function onPlacedPointerDown(e) {
642
  }
643
 
644
  function onPointerMove(e) {
 
 
 
 
 
 
 
645
  if (activePointers.has(e.pointerId)) {
646
  const ptr = activePointers.get(e.pointerId);
647
  ptr.x = e.clientX;
@@ -710,12 +984,43 @@ function onPointerMove(e) {
710
  placedItems[drag.canvasIdx].x = drag.startItemX + dx / drag.canvasW;
711
  placedItems[drag.canvasIdx].y = drag.startItemY + dy / drag.canvasH;
712
  updatePlacedStyle(drag.canvasIdx);
 
 
 
713
  } else {
714
  moveGhost(e.clientX, e.clientY);
715
  }
716
  }
717
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
718
  function onPointerUp(e) {
 
 
 
 
 
 
 
 
 
 
 
 
719
  activePointers.delete(e.pointerId);
720
 
721
  // Desktop resize handle
@@ -747,19 +1052,27 @@ function onPointerUp(e) {
747
  if (!drag.hasMoved) {
748
  const ox = (Math.random() - 0.5) * 0.15;
749
  const oy = (Math.random() - 0.5) * 0.15;
 
 
750
  placedItems.push({
751
  itemId: drag.id,
752
- x: 0.5 + ox,
753
- y: 0.5 + oy,
754
  scale: drag.scale,
755
  rotation: 0,
756
  zIndex: drag.zIndex,
757
  url: drag.url,
758
  isSticker: drag.isSticker,
759
  sparkle: false,
 
760
  });
761
  renderPlaced();
762
  animateNewItem();
 
 
 
 
 
763
  autoSave();
764
  drag = null;
765
  return;
@@ -785,9 +1098,11 @@ function onPointerUp(e) {
785
  url: drag.url,
786
  isSticker: drag.isSticker,
787
  sparkle: false,
 
788
  });
789
  renderPlaced();
790
  animateNewItem();
 
791
  autoSave();
792
  }
793
 
 
2
  import { getCategoryInfo, DOUBLE_TAP_MS, DRAG_DEAD_ZONE_PX } from './models.js';
3
  import { STICKERS } from './stickers.js';
4
  import { migrateOldData } from './store.js';
5
+ import { detectDominantHue, recolor } from './recolor.js';
6
  import { tagOp } from './diag.js';
7
 
8
  let drawings = [];
9
  let items = [];
10
  let currentIdx = 0;
11
+ let placedItems = []; // { itemId, x, y, scale, rotation, zIndex, url, isSticker, sparkle, hue }
12
  let objectURLs = [];
13
  let itemsURLMap = {}; // id -> objectURL
14
 
15
+ // Recolor caches. Dominant hue is computed once per source item and
16
+ // kept for the session. Recolored blob URLs are keyed by itemId:hue
17
+ // so tapping the same hue twice is instant.
18
+ const dominantHueCache = new Map(); // itemId -> number (0-360)
19
+ const recoloredURLs = new Map(); // `${itemId}:${Math.round(hue)}` -> objectURL
20
+ const stickerBlobCache = new Map(); // stickerId -> Blob (fetched from data URL)
21
+
22
+ // Live hue-drag state. Set while the child has a finger down on the
23
+ // .hue-track in the action bar.
24
+ let hueDrag = null;
25
+
26
  // Single-finger drag state
27
  let drag = null;
28
  let ghost = null;
 
107
  objectURLs = [];
108
  itemsURLMap = {};
109
 
110
+ // Recolor caches are per-session. Revoke any leftover URLs from a
111
+ // previous initDressUp call and start fresh.
112
+ for (const url of recoloredURLs.values()) URL.revokeObjectURL(url);
113
+ recoloredURLs.clear();
114
+ dominantHueCache.clear();
115
+
116
  let totalBytes = 0;
117
  items.forEach(c => {
118
  const url = blobToURL(c.imageBlob);
 
136
  <div id="princess-canvas" class="princess-canvas">
137
  <div id="placed-layer" class="placed-layer"></div>
138
  <div id="bbox-overlay" class="bbox-overlay"></div>
139
+ <div id="action-bar" class="action-bar hidden"></div>
140
  </div>
141
  </div>
142
 
 
183
  function saveNow() {
184
  const d = drawings[currentIdx];
185
  if (!d) return;
186
+ const saved = placedItems.map(({ itemId, x, y, scale, rotation, zIndex, isSticker, sparkle, hue }) =>
187
+ ({ itemId, clothingId: itemId, x, y, scale, rotation: rotation || 0, zIndex, isSticker: isSticker || false, sparkle: sparkle || false, hue: hue == null ? null : hue })
188
  );
189
  d.items = saved; // update in-memory too
190
  updateDrawingItems(d.id, saved);
 
236
  url,
237
  isSticker: item.isSticker || false,
238
  sparkle: item.sparkle || false,
239
+ hue: (typeof item.hue === 'number') ? item.hue : null,
240
  });
241
  }
242
  }
243
  }
244
  selectedIdx = -1;
245
  renderPlaced();
246
+ // Lazily restore recolored variants for persisted hues. The item
247
+ // flashes with the original color for a moment, then swaps to the
248
+ // recolored PNG once recolor() finishes — acceptable for the switch
249
+ // between drawings.
250
+ for (let i = 0; i < placedItems.length; i++) {
251
+ const it = placedItems[i];
252
+ if (it.hue != null) {
253
+ applyRecoloredHue(i, it.hue, false).catch(err => {
254
+ console.warn('restore hue failed', err);
255
+ });
256
+ }
257
+ }
258
  }
259
 
260
  let currentTab = 'items';
 
328
  el.style.width = `${item.scale * 100}%`;
329
  el.style.transform = `translate(-50%,-50%) rotate(${item.rotation || 0}deg)`;
330
  renderBBox();
331
+ // Keep the action bar anchored to the item as it moves. Uses
332
+ // positionActionBar rather than renderActionBar so we don't nuke
333
+ // the hue-track's pointer capture when a separate finger is dragging
334
+ // the item while the strip is still being touched.
335
+ if (origIdx === selectedIdx) positionActionBar();
336
  }
337
 
338
+ // ---- Selection visuals: desktop bbox + mobile-friendly action bar ----
339
 
340
  function renderBBox() {
341
  const overlay = document.getElementById('bbox-overlay');
 
343
 
344
  if (selectedIdx < 0 || selectedIdx >= placedItems.length) {
345
  overlay.innerHTML = '';
346
+ renderActionBar();
347
  return;
348
  }
349
 
 
350
  const el = document.querySelector(`.placed-item[data-orig-idx="${selectedIdx}"]`);
351
+ if (!el) { overlay.innerHTML = ''; renderActionBar(); return; }
352
 
353
  // Get bounding rect relative to canvas
354
  const canvas = document.getElementById('princess-canvas');
 
360
  const w = elRect.width;
361
  const h = elRect.height;
362
 
363
+ // Only the dashed box + desktop resize handle live here. Sparkle and
364
+ // delete moved to the action bar so they work on touch devices too.
365
  overlay.innerHTML = `
366
  <div class="bbox" style="left:${left}px;top:${top}px;width:${w}px;height:${h}px;">
367
  <div class="bbox-handle bbox-handle-br" data-handle="br"></div>
 
 
368
  </div>
369
  `;
370
+
371
+ renderActionBar();
372
+ }
373
+
374
+ // Floating button row below the selected item. Visible on both desktop
375
+ // and touch — this is how mobile users toggle sparkle and delete items
376
+ // now that the desktop-only .bbox-action buttons are gone.
377
+ //
378
+ // Split into two halves so drags can reposition without rebuilding
379
+ // innerHTML (which would tear down the hue-track element and its
380
+ // pointer capture state).
381
+ function renderActionBar() {
382
+ const bar = document.getElementById('action-bar');
383
+ if (!bar) return;
384
+
385
+ if (selectedIdx < 0 || selectedIdx >= placedItems.length) {
386
+ bar.classList.add('hidden');
387
+ bar.innerHTML = '';
388
+ bar.dataset.forIdx = '';
389
+ return;
390
+ }
391
+
392
+ const el = document.querySelector(`.placed-item[data-orig-idx="${selectedIdx}"]`);
393
+ if (!el) { bar.classList.add('hidden'); bar.innerHTML = ''; return; }
394
+
395
+ const item = placedItems[selectedIdx];
396
+ bar.innerHTML = `
397
+ <button class="action-btn ${item.sparkle ? 'is-on' : ''}" data-action="sparkle" title="Toggle sparkles">\u2728</button>
398
+ <div class="hue-strip">
399
+ <button class="hue-reset" data-action="reset-hue" title="Original color">\u21BA</button>
400
+ <div class="hue-track"></div>
401
+ </div>
402
+ <button class="action-btn action-delete" data-action="delete" title="Remove">\u{1F5D1}</button>
403
+ `;
404
+ bar.classList.remove('hidden');
405
+ bar.dataset.forIdx = String(selectedIdx);
406
+
407
+ // Kick off dominant-hue computation eagerly so the hue strip is
408
+ // responsive when the child actually drags it. Fire-and-forget.
409
+ ensureDominantHue(item).catch(() => {});
410
+
411
+ positionActionBar();
412
+ }
413
+
414
+ // Move the already-rendered action bar so it tracks the selected item.
415
+ // Safe to call during drag/pinch/resize.
416
+ function positionActionBar() {
417
+ const bar = document.getElementById('action-bar');
418
+ if (!bar || bar.classList.contains('hidden')) return;
419
+ if (selectedIdx < 0 || selectedIdx >= placedItems.length) return;
420
+ const el = document.querySelector(`.placed-item[data-orig-idx="${selectedIdx}"]`);
421
+ if (!el) return;
422
+
423
+ const canvas = document.getElementById('princess-canvas');
424
+ const canvasRect = canvas.getBoundingClientRect();
425
+ const elRect = el.getBoundingClientRect();
426
+
427
+ const cx = elRect.left - canvasRect.left + elRect.width / 2;
428
+ const belowY = elRect.bottom - canvasRect.top + 10;
429
+
430
+ const barW = bar.offsetWidth;
431
+ const barH = bar.offsetHeight;
432
+ let left = cx - barW / 2;
433
+ let top = belowY;
434
+ left = Math.max(4, Math.min(canvasRect.width - barW - 4, left));
435
+ if (top + barH > canvasRect.height - 4) {
436
+ top = elRect.top - canvasRect.top - barH - 10;
437
+ }
438
+ bar.style.left = `${left}px`;
439
+ bar.style.top = `${top}px`;
440
+ }
441
+
442
+ // ---- Recoloring ----
443
+
444
+ async function getSourceBlob(placedItem) {
445
+ if (placedItem.isSticker) {
446
+ let b = stickerBlobCache.get(placedItem.itemId);
447
+ if (b) return b;
448
+ const sticker = STICKERS.find(s => s.id === placedItem.itemId);
449
+ if (!sticker) return null;
450
+ const res = await fetch(sticker.url);
451
+ b = await res.blob();
452
+ stickerBlobCache.set(placedItem.itemId, b);
453
+ return b;
454
+ }
455
+ const it = items.find(i => i.id === placedItem.itemId);
456
+ return it ? it.imageBlob : null;
457
+ }
458
+
459
+ function getOriginalURL(placedItem) {
460
+ if (placedItem.isSticker) {
461
+ const s = STICKERS.find(s => s.id === placedItem.itemId);
462
+ return s ? s.url : null;
463
+ }
464
+ return itemsURLMap[placedItem.itemId] || null;
465
+ }
466
+
467
+ async function ensureDominantHue(placedItem) {
468
+ const key = placedItem.itemId;
469
+ if (dominantHueCache.has(key)) return dominantHueCache.get(key);
470
+ const blob = await getSourceBlob(placedItem);
471
+ if (!blob) return 0;
472
+ const h = await detectDominantHue(blob);
473
+ dominantHueCache.set(key, h);
474
+ return h;
475
+ }
476
+
477
+ // Produce the recolored variant for (itemId, targetHue), cache it,
478
+ // and swap the on-screen element's src to it. Clears any inline
479
+ // filter used for live preview.
480
+ async function applyRecoloredHue(idx, targetHue, save = true) {
481
+ const item = placedItems[idx];
482
+ if (!item) return;
483
+ const roundedHue = Math.round(targetHue);
484
+ const cacheKey = `${item.itemId}:${roundedHue}`;
485
+ let url = recoloredURLs.get(cacheKey);
486
+ if (!url) {
487
+ const source = await getSourceBlob(item);
488
+ if (!source) return;
489
+ const dom = await ensureDominantHue(item);
490
+ const blob = await recolor(source, roundedHue, dom);
491
+ url = URL.createObjectURL(blob);
492
+ recoloredURLs.set(cacheKey, url);
493
+ }
494
+ item.url = url;
495
+ item.hue = roundedHue;
496
+ const el = document.querySelector(`.placed-item[data-orig-idx="${idx}"]`);
497
+ if (el) {
498
+ el.src = url;
499
+ el.style.filter = '';
500
+ }
501
+ if (save) autoSave();
502
+ }
503
+
504
+ // Update the inline CSS filter on the placed item during a hue-strip
505
+ // drag. Math is anchored at the item's dominant hue so that the left
506
+ // edge of the strip = red, right edge = red-again (wrap around).
507
+ function updateHueDragPreview(clientX) {
508
+ if (!hueDrag) return;
509
+ const { idx, rect, dom } = hueDrag;
510
+ const frac = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
511
+ const targetHue = frac * 360;
512
+ let delta = targetHue - dom;
513
+ // Shortest path wrap so rotation doesn't ping-pong.
514
+ delta = ((delta + 540) % 360) - 180;
515
+ hueDrag.targetHue = targetHue;
516
+ const el = document.querySelector(`.placed-item[data-orig-idx="${idx}"]`);
517
+ if (el) el.style.filter = `hue-rotate(${delta}deg)`;
518
+ }
519
+
520
+ function resetHue(idx) {
521
+ const item = placedItems[idx];
522
+ if (!item) return;
523
+ const orig = getOriginalURL(item);
524
+ if (!orig) return;
525
+ item.url = orig;
526
+ item.hue = null;
527
+ const el = document.querySelector(`.placed-item[data-orig-idx="${idx}"]`);
528
+ if (el) {
529
+ el.src = orig;
530
+ el.style.filter = '';
531
+ }
532
+ autoSave();
533
  }
534
 
535
  function handleBBoxAction(action, idx) {
 
559
  });
560
  }
561
 
562
+ // Fires the .sparkle-burst CSS animation at a canvas-local point.
563
+ // The CSS positions the parent with translate(-50%,-50%), so setting
564
+ // left/top to the desired coordinates centers the burst there.
565
+ function fireSparkleBurst(canvasX, canvasY) {
566
+ const canvas = document.getElementById('princess-canvas');
567
+ if (!canvas) return;
568
+ const burst = document.createElement('div');
569
+ burst.className = 'sparkle-burst';
570
+ burst.style.left = `${canvasX}px`;
571
+ burst.style.top = `${canvasY}px`;
572
+ for (let i = 0; i < 8; i++) {
573
+ const star = document.createElement('span');
574
+ star.className = 'sparkle-star';
575
+ star.textContent = '\u2728';
576
+ burst.appendChild(star);
577
+ }
578
+ canvas.appendChild(burst);
579
+ setTimeout(() => burst.remove(), 700);
580
+ }
581
+
582
  // ---- Ghost (for tray drag) ----
583
 
584
  function createGhost(url) {
 
713
  }
714
  });
715
 
716
+ // BBox resize handle (desktop only)
717
  const bboxOverlay = document.getElementById('bbox-overlay');
718
  bboxOverlay.addEventListener('pointerdown', (e) => {
719
  const handle = e.target.closest('.bbox-handle');
 
729
  startScale: placedItems[selectedIdx].scale,
730
  canvasW: rect.width,
731
  };
732
+ }
733
+ });
734
+
735
+ // Action bar: sparkle/delete buttons, hue-track drag, reset button.
736
+ const actionBar = document.getElementById('action-bar');
737
+ actionBar.addEventListener('pointerdown', (e) => {
738
+ // Hue track: swap src to the original and start live preview.
739
+ const track = e.target.closest('.hue-track');
740
+ if (track && selectedIdx >= 0) {
741
+ e.preventDefault();
742
+ e.stopPropagation();
743
+ const idx = selectedIdx;
744
+ const item = placedItems[idx];
745
+ const el = document.querySelector(`.placed-item[data-orig-idx="${idx}"]`);
746
+ const orig = getOriginalURL(item);
747
+ if (el && orig) el.src = orig;
748
+ // setPointerCapture must run synchronously in the handler or the
749
+ // browser won't honor it. Dominant hue usually comes straight from
750
+ // the cache (renderActionBar warms it on selection). If not, fall
751
+ // back to 0 and swap the real value in when it resolves below.
752
+ track.setPointerCapture(e.pointerId);
753
+ const rect = track.getBoundingClientRect();
754
+ const cached = dominantHueCache.get(item.itemId);
755
+ hueDrag = { idx, track, dom: cached ?? 0, rect, pointerId: e.pointerId };
756
+ updateHueDragPreview(e.clientX);
757
+ if (cached == null) {
758
+ ensureDominantHue(item).then((dom) => {
759
+ if (hueDrag && hueDrag.pointerId === e.pointerId) {
760
+ hueDrag.dom = dom;
761
+ // Refresh preview with the correct anchor.
762
+ updateHueDragPreview(
763
+ // Use the last known target if we have it, else middle.
764
+ hueDrag.rect.left + hueDrag.rect.width *
765
+ ((hueDrag.targetHue ?? 180) / 360),
766
+ );
767
+ }
768
+ }).catch(() => {});
769
+ }
770
  return;
771
  }
772
+ // Reset button
773
+ const reset = e.target.closest('.hue-reset');
774
+ if (reset && selectedIdx >= 0) {
775
  e.preventDefault();
776
  e.stopPropagation();
777
+ resetHue(selectedIdx);
778
+ return;
779
+ }
780
+ // Sparkle / delete
781
+ const btn = e.target.closest('.action-btn');
782
+ if (btn && selectedIdx >= 0) {
783
+ e.preventDefault();
784
+ e.stopPropagation();
785
+ handleBBoxAction(btn.dataset.action, selectedIdx);
786
  }
787
  });
788
 
 
909
  }
910
 
911
  function onPointerMove(e) {
912
+ // Hue-strip drag takes priority and stays self-contained.
913
+ if (hueDrag && e.pointerId === hueDrag.pointerId) {
914
+ e.preventDefault();
915
+ updateHueDragPreview(e.clientX);
916
+ return;
917
+ }
918
+
919
  if (activePointers.has(e.pointerId)) {
920
  const ptr = activePointers.get(e.pointerId);
921
  ptr.x = e.clientX;
 
984
  placedItems[drag.canvasIdx].x = drag.startItemX + dx / drag.canvasW;
985
  placedItems[drag.canvasIdx].y = drag.startItemY + dy / drag.canvasH;
986
  updatePlacedStyle(drag.canvasIdx);
987
+ // Throttled particle trail. Skip during pinches — two-finger
988
+ // activity already has plenty going on visually.
989
+ if (!pinch) emitDragSpark(e.clientX, e.clientY);
990
  } else {
991
  moveGhost(e.clientX, e.clientY);
992
  }
993
  }
994
 
995
+ function emitDragSpark(clientX, clientY) {
996
+ const now = Date.now();
997
+ if (drag.lastEmit && now - drag.lastEmit < 80) return;
998
+ drag.lastEmit = now;
999
+ const canvas = document.getElementById('princess-canvas');
1000
+ if (!canvas) return;
1001
+ const rect = canvas.getBoundingClientRect();
1002
+ const spark = document.createElement('span');
1003
+ spark.className = 'drag-spark';
1004
+ spark.textContent = '\u2728';
1005
+ spark.style.left = `${clientX - rect.left}px`;
1006
+ spark.style.top = `${clientY - rect.top}px`;
1007
+ canvas.appendChild(spark);
1008
+ setTimeout(() => spark.remove(), 500);
1009
+ }
1010
+
1011
  function onPointerUp(e) {
1012
+ // Commit hue drag first — it owns its pointerId exclusively.
1013
+ if (hueDrag && e.pointerId === hueDrag.pointerId) {
1014
+ const { idx, targetHue } = hueDrag;
1015
+ hueDrag = null;
1016
+ if (typeof targetHue === 'number') {
1017
+ applyRecoloredHue(idx, targetHue, true).catch(err => {
1018
+ console.warn('recolor commit failed', err);
1019
+ });
1020
+ }
1021
+ return;
1022
+ }
1023
+
1024
  activePointers.delete(e.pointerId);
1025
 
1026
  // Desktop resize handle
 
1052
  if (!drag.hasMoved) {
1053
  const ox = (Math.random() - 0.5) * 0.15;
1054
  const oy = (Math.random() - 0.5) * 0.15;
1055
+ const px = 0.5 + ox;
1056
+ const py = 0.5 + oy;
1057
  placedItems.push({
1058
  itemId: drag.id,
1059
+ x: px,
1060
+ y: py,
1061
  scale: drag.scale,
1062
  rotation: 0,
1063
  zIndex: drag.zIndex,
1064
  url: drag.url,
1065
  isSticker: drag.isSticker,
1066
  sparkle: false,
1067
+ hue: null,
1068
  });
1069
  renderPlaced();
1070
  animateNewItem();
1071
+ {
1072
+ const canvas = document.getElementById('princess-canvas');
1073
+ const rect = canvas.getBoundingClientRect();
1074
+ fireSparkleBurst(px * rect.width, py * rect.height);
1075
+ }
1076
  autoSave();
1077
  drag = null;
1078
  return;
 
1098
  url: drag.url,
1099
  isSticker: drag.isSticker,
1100
  sparkle: false,
1101
+ hue: null,
1102
  });
1103
  renderPlaced();
1104
  animateNewItem();
1105
+ fireSparkleBurst(e.clientX - rect.left, e.clientY - rect.top);
1106
  autoSave();
1107
  }
1108
 
js/ml-client.js CHANGED
@@ -6,6 +6,17 @@
6
 
7
  import { tagOp } from './diag.js';
8
 
 
 
 
 
 
 
 
 
 
 
 
9
  // 90s covers the worst-case cold-start (~15-30s container boot) plus
10
  // a slow image upload on a weak cellular connection. If we ever need
11
  // more, something has gone wrong upstream and we should surface the
@@ -47,7 +58,7 @@ export async function removeBackground(blob, onProgress) {
47
  // FormData requires *something* here.
48
  form.append('file', blob, 'input.png');
49
 
50
- const res = await fetch('/remove-bg', {
51
  method: 'POST',
52
  body: form,
53
  signal: abort.signal,
@@ -82,21 +93,92 @@ export async function removeBackground(blob, onProgress) {
82
  }
83
 
84
  /**
85
- * Auto-segment a full drawing into distinct parts.
 
86
  *
87
- * Not implemented server-side yet. Returning an empty array lets the
88
- * Full Drawing flow in upload.js bail out cleanly with "no segments
89
- * found" instead of crashing. If/when we add a SAM endpoint to the
90
- * Space, wire it up here.
91
  */
92
  export async function autoSegment(blob, onProgress) {
93
- tagOp('[auto-segment] stub (not implemented server-side)');
94
- if (onProgress) {
95
- onProgress({
96
- status: 'done',
97
- message: 'Full-drawing auto-split is not available yet',
98
- progress: 100,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
99
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
100
  }
101
- return [];
 
 
 
 
 
 
 
 
 
 
102
  }
 
6
 
7
  import { tagOp } from './diag.js';
8
 
9
+ // In production the frontend is served by the same FastAPI process as
10
+ // the API (HF Space), so a relative URL is same-origin. In local dev
11
+ // the frontend is served by a plain static server with no backend —
12
+ // fall back to the HF Space absolute URL so dev against prod Just Works.
13
+ const SPACE_ORIGIN = 'https://sdragly-background-removal.hf.space';
14
+ const API_BASE =
15
+ typeof location !== 'undefined' &&
16
+ (location.hostname === 'localhost' || location.hostname === '127.0.0.1')
17
+ ? SPACE_ORIGIN
18
+ : '';
19
+
20
  // 90s covers the worst-case cold-start (~15-30s container boot) plus
21
  // a slow image upload on a weak cellular connection. If we ever need
22
  // more, something has gone wrong upstream and we should surface the
 
58
  // FormData requires *something* here.
59
  form.append('file', blob, 'input.png');
60
 
61
+ const res = await fetch(`${API_BASE}/remove-bg`, {
62
  method: 'POST',
63
  body: form,
64
  signal: abort.signal,
 
93
  }
94
 
95
  /**
96
+ * Auto-segment a full drawing into distinct parts via the HF Space
97
+ * SlimSAM endpoint.
98
  *
99
+ * @param {Blob} blob background-removed PNG
100
+ * @param {(p: {status?: string, message: string, progress?: number}) => void} [onProgress]
101
+ * @returns {Promise<Array>} segments in the shape segment-review.js expects
 
102
  */
103
  export async function autoSegment(blob, onProgress) {
104
+ const progress = (message, pct) => {
105
+ if (onProgress) onProgress({ status: 'processing', message, progress: pct });
106
+ };
107
+
108
+ tagOp('[auto-segment] uploading');
109
+ progress('Looking for parts...', 5);
110
+
111
+ // Auto-segment is heavier than bg removal (~5s warm, more on cold
112
+ // start). Keep the same ceiling as remove-bg for now — if SAM ever
113
+ // drifts past this we should investigate rather than paper over.
114
+ const coldTimer = setTimeout(() => {
115
+ tagOp('[auto-segment] waking server');
116
+ progress('Waking up server (first request is slow)...', 10);
117
+ }, COLD_START_HINT_MS);
118
+
119
+ const abort = new AbortController();
120
+ const timeoutTimer = setTimeout(() => abort.abort(), REQUEST_TIMEOUT_MS);
121
+
122
+ try {
123
+ const form = new FormData();
124
+ form.append('file', blob, 'input.png');
125
+
126
+ const res = await fetch(`${API_BASE}/auto-segment`, {
127
+ method: 'POST',
128
+ body: form,
129
+ signal: abort.signal,
130
  });
131
+
132
+ clearTimeout(coldTimer);
133
+
134
+ if (!res.ok) {
135
+ const text = await res.text().catch(() => '');
136
+ throw new Error(
137
+ `Server returned ${res.status}${text ? `: ${text.slice(0, 200)}` : ''}`,
138
+ );
139
+ }
140
+
141
+ progress('Decoding parts...', 85);
142
+ const json = await res.json();
143
+ const rawSegments = Array.isArray(json.segments) ? json.segments : [];
144
+ tagOp(`[auto-segment] ${rawSegments.length} segments`);
145
+
146
+ // The server hands back base64 for both the mask bytes and the
147
+ // cropped PNG. Decode once here so segment-review.js can work with
148
+ // plain Uint8Array + Blob like the old in-browser path did.
149
+ const segments = rawSegments.map((s) => ({
150
+ id: s.id,
151
+ score: s.score,
152
+ area: s.area,
153
+ bbox: s.bbox,
154
+ maskW: s.maskW,
155
+ maskH: s.maskH,
156
+ mask: base64ToUint8Array(s.mask),
157
+ croppedBlob: base64ToBlob(s.croppedPng, 'image/png'),
158
+ }));
159
+
160
+ if (onProgress) onProgress({ status: 'done', message: 'Parts found', progress: 100 });
161
+ return segments;
162
+ } catch (err) {
163
+ clearTimeout(coldTimer);
164
+ if (err.name === 'AbortError') {
165
+ throw new Error(
166
+ `Server request timed out after ${REQUEST_TIMEOUT_MS / 1000}s`,
167
+ );
168
+ }
169
+ throw err;
170
+ } finally {
171
+ clearTimeout(timeoutTimer);
172
  }
173
+ }
174
+
175
+ function base64ToUint8Array(b64) {
176
+ const bin = atob(b64);
177
+ const out = new Uint8Array(bin.length);
178
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
179
+ return out;
180
+ }
181
+
182
+ function base64ToBlob(b64, type) {
183
+ return new Blob([base64ToUint8Array(b64)], { type });
184
  }
js/recolor.js ADDED
@@ -0,0 +1,134 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Per-placement palette-swap recoloring.
2
+ //
3
+ // The child picks a target hue from the bottom-sheet rainbow strip and
4
+ // the dominant color of the item shifts to that hue while keeping
5
+ // saturation and lightness. Blacks stay black, whites stay white — the
6
+ // shift is a hue delta anchored at the item's own dominant color, not
7
+ // a blanket hue-rotate of the whole image.
8
+ //
9
+ // Two entry points:
10
+ // detectDominantHue(blob) — one-shot per source item, cached by the
11
+ // caller. Returns the hue angle (0-360) of the strongest color bin.
12
+ // recolor(blob, targetHue) — returns a new PNG blob with every
13
+ // colored pixel rotated so the dominant becomes the target.
14
+
15
+ import { tagOp } from './diag.js';
16
+
17
+ const HUE_BINS = 36; // 10 degrees per bucket
18
+ const MIN_SAT = 0.15; // skip near-grey pixels
19
+ const MIN_LIGHT = 0.10; // skip near-black
20
+ const MAX_LIGHT = 0.90; // skip near-white
21
+ const MIN_ALPHA = 128; // skip near-transparent
22
+
23
+ async function decodeToImageData(blob) {
24
+ const bitmap = await createImageBitmap(blob);
25
+ const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
26
+ const ctx = canvas.getContext('2d', { willReadFrequently: true });
27
+ ctx.drawImage(bitmap, 0, 0);
28
+ bitmap.close();
29
+ return { ctx, imgData: ctx.getImageData(0, 0, bitmap.width, bitmap.height), canvas };
30
+ }
31
+
32
+ export async function detectDominantHue(blob) {
33
+ const t0 = performance.now();
34
+ const { imgData } = await decodeToImageData(blob);
35
+ const px = imgData.data;
36
+ const bins = new Float32Array(HUE_BINS);
37
+
38
+ for (let i = 0; i < px.length; i += 4) {
39
+ const a = px[i + 3];
40
+ if (a < MIN_ALPHA) continue;
41
+ const [h, s, l] = rgbToHsl(px[i], px[i + 1], px[i + 2]);
42
+ if (s < MIN_SAT || l < MIN_LIGHT || l > MAX_LIGHT) continue;
43
+ const bin = Math.floor((h / 360) * HUE_BINS) % HUE_BINS;
44
+ // Weight by saturation and alpha — confident, opaque pixels matter more.
45
+ bins[bin] += s * (a / 255);
46
+ }
47
+
48
+ let best = 0, bestWeight = -1;
49
+ for (let i = 0; i < HUE_BINS; i++) {
50
+ if (bins[i] > bestWeight) {
51
+ bestWeight = bins[i];
52
+ best = i;
53
+ }
54
+ }
55
+ // Default to 0 (red) for fully monochrome images — the recolor pass
56
+ // will still work because the delta is just (target - 0).
57
+ if (bestWeight <= 0) {
58
+ tagOp('recolor: no dominant hue');
59
+ return 0;
60
+ }
61
+ const h = (best + 0.5) * (360 / HUE_BINS);
62
+ tagOp(`recolor: dominant ${Math.round(h)}deg in ${Math.round(performance.now() - t0)}ms`);
63
+ return h;
64
+ }
65
+
66
+ export async function recolor(blob, targetHue, dominantHue) {
67
+ const t0 = performance.now();
68
+ const { ctx, imgData, canvas } = await decodeToImageData(blob);
69
+ const px = imgData.data;
70
+
71
+ // Wrap delta to [-180, 180] for the shortest path around the wheel.
72
+ let delta = targetHue - dominantHue;
73
+ delta = ((delta + 540) % 360) - 180;
74
+
75
+ for (let i = 0; i < px.length; i += 4) {
76
+ const a = px[i + 3];
77
+ if (a < MIN_ALPHA) continue;
78
+ const [h, s, l] = rgbToHsl(px[i], px[i + 1], px[i + 2]);
79
+ // Don't shift greys — there's nothing to rotate.
80
+ if (s < MIN_SAT) continue;
81
+ const newH = (h + delta + 360) % 360;
82
+ const [r, g, b] = hslToRgb(newH, s, l);
83
+ px[i] = r;
84
+ px[i + 1] = g;
85
+ px[i + 2] = b;
86
+ }
87
+
88
+ ctx.putImageData(imgData, 0, 0);
89
+ const out = await canvas.convertToBlob({ type: 'image/png' });
90
+ tagOp(`recolor: ${Math.round(performance.now() - t0)}ms`);
91
+ return out;
92
+ }
93
+
94
+ // ---- HSL helpers (operate in 0-360, 0-1, 0-1) ----
95
+
96
+ function rgbToHsl(r, g, b) {
97
+ r /= 255; g /= 255; b /= 255;
98
+ const max = Math.max(r, g, b), min = Math.min(r, g, b);
99
+ const l = (max + min) / 2;
100
+ if (max === min) return [0, 0, l];
101
+ const d = max - min;
102
+ const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
103
+ let h;
104
+ switch (max) {
105
+ case r: h = (g - b) / d + (g < b ? 6 : 0); break;
106
+ case g: h = (b - r) / d + 2; break;
107
+ default: h = (r - g) / d + 4; break;
108
+ }
109
+ return [h * 60, s, l];
110
+ }
111
+
112
+ function hslToRgb(h, s, l) {
113
+ h /= 360;
114
+ if (s === 0) {
115
+ const v = Math.round(l * 255);
116
+ return [v, v, v];
117
+ }
118
+ const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
119
+ const p = 2 * l - q;
120
+ return [
121
+ Math.round(hueToRgb(p, q, h + 1 / 3) * 255),
122
+ Math.round(hueToRgb(p, q, h) * 255),
123
+ Math.round(hueToRgb(p, q, h - 1 / 3) * 255),
124
+ ];
125
+ }
126
+
127
+ function hueToRgb(p, q, t) {
128
+ if (t < 0) t += 1;
129
+ if (t > 1) t -= 1;
130
+ if (t < 1 / 6) return p + (q - p) * 6 * t;
131
+ if (t < 1 / 2) return q;
132
+ if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
133
+ return p;
134
+ }
js/upload.js CHANGED
@@ -3,6 +3,7 @@ import { saveItem, savePhoto, loadPhotos, updatePhoto, deletePhoto, blobToURL }
3
  import { removeBackground, autoSegment } from './ml-client.js';
4
  import { showSegmentReview } from './segment-review.js';
5
  import { showPolygonSelector } from './polygon-select.js';
 
6
  import { tagOp } from './diag.js';
7
 
8
  let selectedCategory = 'princess';
@@ -203,8 +204,11 @@ function bindEvents(photos) {
203
  tagOp(`capture: downscaling ${Math.round(file.size / 1024)}KB`);
204
  const resized = await downscalePhoto(file);
205
  tagOp(`capture: downscaled -> ${Math.round(resized.size / 1024)}KB`);
206
- await savePhoto(resized);
207
- selectImage(resized);
 
 
 
208
  });
209
 
210
  // Photo gallery — tap to reuse, X to delete
@@ -280,7 +284,6 @@ async function handleMagicTime() {
280
 
281
  const btn = document.getElementById('submit-btn');
282
  const status = document.getElementById('upload-status');
283
- const skipBlob = croppedBlob || imageBlob;
284
 
285
  btn.disabled = true;
286
  document.getElementById('upload-actions').style.display = 'none';
@@ -294,8 +297,15 @@ async function handleMagicTime() {
294
  </div>
295
  `;
296
 
 
 
 
 
 
297
  try {
298
- let segResult = await removeBackground(imageBlob, (info) => {
 
 
299
  const msg = document.getElementById('seg-message');
300
  const fill = document.getElementById('seg-progress-fill');
301
  if (msg) msg.textContent = info.message;
 
3
  import { removeBackground, autoSegment } from './ml-client.js';
4
  import { showSegmentReview } from './segment-review.js';
5
  import { showPolygonSelector } from './polygon-select.js';
6
+ import { normalizeColors } from './color-normalize.js';
7
  import { tagOp } from './diag.js';
8
 
9
  let selectedCategory = 'princess';
 
204
  tagOp(`capture: downscaling ${Math.round(file.size / 1024)}KB`);
205
  const resized = await downscalePhoto(file);
206
  tagOp(`capture: downscaled -> ${Math.round(resized.size / 1024)}KB`);
207
+ // White-balance before saving so the gallery thumb, polygon preview
208
+ // and eventual background-removal input all see the same image.
209
+ const normalized = await normalizeColors(resized);
210
+ await savePhoto(normalized);
211
+ selectImage(normalized);
212
  });
213
 
214
  // Photo gallery — tap to reuse, X to delete
 
284
 
285
  const btn = document.getElementById('submit-btn');
286
  const status = document.getElementById('upload-status');
 
287
 
288
  btn.disabled = true;
289
  document.getElementById('upload-actions').style.display = 'none';
 
297
  </div>
298
  `;
299
 
300
+ // Re-normalize as a safety net for legacy gallery photos that were
301
+ // captured before normalizeColors existed. Post-feature captures are
302
+ // already normalized, so this is a fast idempotent pass.
303
+ let skipBlob = croppedBlob || imageBlob;
304
+
305
  try {
306
+ const normalized = await normalizeColors(imageBlob);
307
+ if (!croppedBlob) skipBlob = normalized;
308
+ let segResult = await removeBackground(normalized, (info) => {
309
  const msg = document.getElementById('seg-message');
310
  const fill = document.getElementById('seg-progress-fill');
311
  if (msg) msg.textContent = info.message;
space/app.py CHANGED
@@ -5,11 +5,11 @@ frontend from a single FastAPI process. Everything is deployed together
5
  to a Hugging Face Docker Space — one commit, one cold start, no CORS.
6
 
7
  Design notes:
8
- - The rembg session is created once at import time. HF Spaces keeps
9
- the container warm between requests, so model weights stay resident
10
- in process memory. First request after a cold start is slow because
11
- the container itself needs to boot (~15s), not because of model
12
- loading (that's already done).
13
  - CORS stays wide open for now even though frontend and backend share
14
  an origin. It's harmless and lets you hit the API from a second
15
  client (curl, another deploy) without surprise.
@@ -22,16 +22,22 @@ Design notes:
22
  `remove-bg` in the static dir.
23
  """
24
 
 
 
25
  import logging
26
  from pathlib import Path
27
 
 
28
  from fastapi import FastAPI, File, HTTPException, UploadFile
29
  from fastapi.middleware.cors import CORSMiddleware
30
  from fastapi.responses import JSONResponse, Response
31
  from fastapi.staticfiles import StaticFiles
 
32
  from rembg import new_session, remove
 
33
 
34
  MODEL_NAME = "isnet-general-use"
 
35
 
36
  logging.basicConfig(level=logging.INFO)
37
  log = logging.getLogger("princess")
@@ -41,13 +47,36 @@ log = logging.getLogger("princess")
41
  # from accidental full-res uploads burning CPU on the free tier.
42
  MAX_UPLOAD_BYTES = 20 * 1024 * 1024
43
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
44
  # Static assets live next to app.py inside the container. The Dockerfile
45
  # copies index.html, style.css, and js/ into /app alongside this file.
46
  STATIC_ROOT = Path(__file__).parent
47
 
48
  log.info("Loading rembg model: %s", MODEL_NAME)
49
  _bg_session = new_session(MODEL_NAME)
50
- log.info("Model ready")
 
 
 
 
51
 
52
  app = FastAPI(title="Princess", version="1.0.0")
53
 
@@ -96,6 +125,204 @@ async def remove_bg(file: UploadFile = File(...)) -> Response:
96
  )
97
 
98
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
99
  # Mount last so API routes above take precedence. html=True makes "/"
100
  # serve index.html automatically.
101
  app.mount("/", StaticFiles(directory=STATIC_ROOT, html=True), name="static")
 
5
  to a Hugging Face Docker Space — one commit, one cold start, no CORS.
6
 
7
  Design notes:
8
+ - Both the rembg session and the SAM pipeline are created once at
9
+ import time. HF Spaces keeps the container warm between requests,
10
+ so model weights stay resident in process memory. First request
11
+ after a cold start is slow because the container itself needs to
12
+ boot (~45s with torch+SAM), not because of model loading.
13
  - CORS stays wide open for now even though frontend and backend share
14
  an origin. It's harmless and lets you hit the API from a second
15
  client (curl, another deploy) without surprise.
 
22
  `remove-bg` in the static dir.
23
  """
24
 
25
+ import base64
26
+ import io
27
  import logging
28
  from pathlib import Path
29
 
30
+ import numpy as np
31
  from fastapi import FastAPI, File, HTTPException, UploadFile
32
  from fastapi.middleware.cors import CORSMiddleware
33
  from fastapi.responses import JSONResponse, Response
34
  from fastapi.staticfiles import StaticFiles
35
+ from PIL import Image
36
  from rembg import new_session, remove
37
+ from transformers import pipeline
38
 
39
  MODEL_NAME = "isnet-general-use"
40
+ SAM_MODEL = "Zigeng/SlimSAM-uniform-77"
41
 
42
  logging.basicConfig(level=logging.INFO)
43
  log = logging.getLogger("princess")
 
47
  # from accidental full-res uploads burning CPU on the free tier.
48
  MAX_UPLOAD_BYTES = 20 * 1024 * 1024
49
 
50
+ # SAM is quadratically sensitive to input resolution. 512 on the long
51
+ # edge gives good-quality masks on kid drawings while keeping a full
52
+ # pass under ~5s on the free CPU tier.
53
+ SAM_INPUT_DIM = 512
54
+
55
+ # Masks are returned to the client at this resolution. Segment-review
56
+ # uses them for tap-hit-testing and polygon adjust, neither of which
57
+ # needs pixel-perfect alignment — 128x128 keeps the JSON payload small
58
+ # (~16KB per mask pre-base64) without visibly hurting the overlay.
59
+ MASK_OUT_DIM = 128
60
+
61
+ # Drop tiny masks (noise) and very large masks (full image / bg).
62
+ SAM_MIN_AREA_FRAC = 0.005
63
+ SAM_MAX_AREA_FRAC = 0.85
64
+
65
+ # Non-max suppression IoU threshold — masks overlapping more than this
66
+ # are treated as duplicates and the lower-scoring one gets dropped.
67
+ SAM_NMS_IOU = 0.7
68
+
69
  # Static assets live next to app.py inside the container. The Dockerfile
70
  # copies index.html, style.css, and js/ into /app alongside this file.
71
  STATIC_ROOT = Path(__file__).parent
72
 
73
  log.info("Loading rembg model: %s", MODEL_NAME)
74
  _bg_session = new_session(MODEL_NAME)
75
+ log.info("rembg ready")
76
+
77
+ log.info("Loading SAM pipeline: %s", SAM_MODEL)
78
+ _sam_pipeline = pipeline("mask-generation", model=SAM_MODEL, device=-1)
79
+ log.info("SAM ready")
80
 
81
  app = FastAPI(title="Princess", version="1.0.0")
82
 
 
125
  )
126
 
127
 
128
+ def _resize_long_edge(img: Image.Image, target: int) -> Image.Image:
129
+ w, h = img.size
130
+ long_edge = max(w, h)
131
+ if long_edge <= target:
132
+ return img
133
+ ratio = target / long_edge
134
+ return img.resize((round(w * ratio), round(h * ratio)), Image.LANCZOS)
135
+
136
+
137
+ def _downsample_mask(mask: np.ndarray, out_w: int, out_h: int) -> np.ndarray:
138
+ """Nearest-neighbor downsample a bool mask to (out_h, out_w).
139
+
140
+ We don't need anti-aliasing — segment-review just hit-tests pixels
141
+ and draws them as rects.
142
+ """
143
+ h, w = mask.shape
144
+ ys = (np.linspace(0, h - 1, out_h)).astype(np.int32)
145
+ xs = (np.linspace(0, w - 1, out_w)).astype(np.int32)
146
+ return mask[ys[:, None], xs[None, :]]
147
+
148
+
149
+ def _mask_iou(a: np.ndarray, b: np.ndarray) -> float:
150
+ inter = np.logical_and(a, b).sum()
151
+ union = np.logical_or(a, b).sum()
152
+ return float(inter) / float(union) if union else 0.0
153
+
154
+
155
+ def _nms(masks, scores, iou_thresh: float):
156
+ """Greedy NMS. `masks` is a list of (small) bool arrays, aligned
157
+ with `scores`. Returns the list of kept indices in score order."""
158
+ order = np.argsort(scores)[::-1]
159
+ kept: list[int] = []
160
+ for idx in order:
161
+ dominated = False
162
+ for k in kept:
163
+ if _mask_iou(masks[idx], masks[k]) > iou_thresh:
164
+ dominated = True
165
+ break
166
+ if not dominated:
167
+ kept.append(int(idx))
168
+ return kept
169
+
170
+
171
+ def _crop_with_mask(
172
+ rgba: Image.Image,
173
+ mask_full: np.ndarray,
174
+ ) -> tuple[bytes, dict, int]:
175
+ """Crop the image to the mask's bbox and apply the mask as alpha.
176
+
177
+ Returns (png_bytes, normalized_bbox, pixel_area). Uses the full-res
178
+ RGBA image so the output looks sharp — the low-res mask is upsampled
179
+ via nearest-neighbor to match.
180
+ """
181
+ H, W = mask_full.shape
182
+ ys, xs = np.where(mask_full)
183
+ if ys.size == 0:
184
+ raise ValueError("empty mask")
185
+
186
+ pad = 4
187
+ y0 = max(0, int(ys.min()) - pad)
188
+ y1 = min(H, int(ys.max()) + 1 + pad)
189
+ x0 = max(0, int(xs.min()) - pad)
190
+ x1 = min(W, int(xs.max()) + 1 + pad)
191
+
192
+ cropped = rgba.crop((x0, y0, x1, y1)).convert("RGBA")
193
+ crop_mask = mask_full[y0:y1, x0:x1]
194
+
195
+ arr = np.array(cropped, dtype=np.uint8)
196
+ arr[..., 3] = (arr[..., 3].astype(np.uint16) * crop_mask.astype(np.uint16)).astype(np.uint8)
197
+ out_img = Image.fromarray(arr, mode="RGBA")
198
+
199
+ buf = io.BytesIO()
200
+ out_img.save(buf, format="PNG", optimize=False)
201
+ return (
202
+ buf.getvalue(),
203
+ {
204
+ "x": x0 / W,
205
+ "y": y0 / H,
206
+ "w": (x1 - x0) / W,
207
+ "h": (y1 - y0) / H,
208
+ },
209
+ int(ys.size),
210
+ )
211
+
212
+
213
+ @app.post("/auto-segment")
214
+ async def auto_segment(file: UploadFile = File(...)) -> JSONResponse:
215
+ data = await file.read()
216
+ if not data:
217
+ raise HTTPException(status_code=400, detail="empty upload")
218
+ if len(data) > MAX_UPLOAD_BYTES:
219
+ raise HTTPException(
220
+ status_code=413,
221
+ detail=f"upload too large ({len(data)} bytes, max {MAX_UPLOAD_BYTES})",
222
+ )
223
+
224
+ try:
225
+ src = Image.open(io.BytesIO(data)).convert("RGBA")
226
+ except Exception as err: # noqa: BLE001
227
+ raise HTTPException(status_code=400, detail=f"bad image: {err}")
228
+
229
+ full_w, full_h = src.size
230
+
231
+ # SAM wants a 3-channel image; we pass the downscaled RGB view.
232
+ work = _resize_long_edge(src, SAM_INPUT_DIM)
233
+ work_rgb = work.convert("RGB")
234
+
235
+ # points_per_side=8 → 64 grid points. Default 32 gives 1024 which
236
+ # is ~16× slower and overkill for a drawing with maybe 5-10 parts.
237
+ try:
238
+ sam_out = _sam_pipeline(
239
+ work_rgb,
240
+ points_per_side=8,
241
+ pred_iou_thresh=0.85,
242
+ stability_score_thresh=0.85,
243
+ )
244
+ except Exception as err: # noqa: BLE001
245
+ log.exception("SAM failed")
246
+ raise HTTPException(status_code=500, detail=f"sam failed: {err}")
247
+
248
+ raw_masks = sam_out.get("masks", [])
249
+ raw_scores = sam_out.get("scores", [])
250
+ if not raw_masks:
251
+ return JSONResponse({"segments": []})
252
+
253
+ # The pipeline returns masks at the *input* (downscaled) resolution.
254
+ # Upsample them to full-res once so crops are sharp, and keep a
255
+ # small copy for NMS + client-side hit-testing.
256
+ work_w, work_h = work_rgb.size
257
+ min_area_px = int(full_w * full_h * SAM_MIN_AREA_FRAC)
258
+ max_area_px = int(full_w * full_h * SAM_MAX_AREA_FRAC)
259
+
260
+ candidates = []
261
+ for mask, score in zip(raw_masks, raw_scores):
262
+ mask_arr = np.asarray(mask, dtype=bool)
263
+ if mask_arr.shape != (work_h, work_w):
264
+ # Some pipelines return (H, W) at the *original* size; handle both.
265
+ if mask_arr.shape == (full_h, full_w):
266
+ full_mask = mask_arr
267
+ else:
268
+ continue
269
+ else:
270
+ # Nearest-neighbor upsample to full res.
271
+ ys = (np.linspace(0, work_h - 1, full_h)).astype(np.int32)
272
+ xs = (np.linspace(0, work_w - 1, full_w)).astype(np.int32)
273
+ full_mask = mask_arr[ys[:, None], xs[None, :]]
274
+
275
+ area = int(full_mask.sum())
276
+ if area < min_area_px or area > max_area_px:
277
+ continue
278
+
279
+ small = _downsample_mask(full_mask, MASK_OUT_DIM, MASK_OUT_DIM)
280
+ candidates.append(
281
+ {
282
+ "full_mask": full_mask,
283
+ "small_mask": small,
284
+ "score": float(score),
285
+ "area": area,
286
+ }
287
+ )
288
+
289
+ if not candidates:
290
+ return JSONResponse({"segments": []})
291
+
292
+ small_masks = [c["small_mask"] for c in candidates]
293
+ scores_arr = np.array([c["score"] for c in candidates], dtype=np.float32)
294
+ kept_idx = _nms(small_masks, scores_arr, SAM_NMS_IOU)
295
+
296
+ segments = []
297
+ for seg_i, idx in enumerate(kept_idx):
298
+ c = candidates[idx]
299
+ try:
300
+ png_bytes, bbox, _ = _crop_with_mask(src, c["full_mask"])
301
+ except Exception as err: # noqa: BLE001
302
+ log.warning("crop failed for seg %d: %s", seg_i, err)
303
+ continue
304
+
305
+ segments.append(
306
+ {
307
+ "id": f"seg-{seg_i}",
308
+ "score": c["score"],
309
+ "area": c["area"],
310
+ "bbox": bbox,
311
+ "maskW": MASK_OUT_DIM,
312
+ "maskH": MASK_OUT_DIM,
313
+ # Pack bool mask as 1 byte per pixel, base64 for JSON transport.
314
+ "mask": base64.b64encode(
315
+ c["small_mask"].astype(np.uint8).tobytes()
316
+ ).decode("ascii"),
317
+ "croppedPng": base64.b64encode(png_bytes).decode("ascii"),
318
+ }
319
+ )
320
+
321
+ # Largest first — matches segment-review's princess-selection heuristic.
322
+ segments.sort(key=lambda s: s["area"], reverse=True)
323
+ return JSONResponse({"segments": segments})
324
+
325
+
326
  # Mount last so API routes above take precedence. html=True makes "/"
327
  # serve index.html automatically.
328
  app.mount("/", StaticFiles(directory=STATIC_ROOT, html=True), name="static")
space/requirements.txt CHANGED
@@ -4,3 +4,5 @@ python-multipart>=0.0.20
4
  rembg>=2.0.60
5
  onnxruntime>=1.19
6
  Pillow>=11.0
 
 
 
4
  rembg>=2.0.60
5
  onnxruntime>=1.19
6
  Pillow>=11.0
7
+ numpy>=1.26
8
+ transformers>=4.45
style.css CHANGED
@@ -151,29 +151,93 @@ h2 { font-size: 1.5rem; font-weight: 700; color: var(--lavender-dk); }
151
  right: -9px;
152
  }
153
 
154
- .bbox-action {
 
 
 
 
 
 
 
 
155
  position: absolute;
156
- top: -32px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
157
  width: 28px;
158
  height: 28px;
159
  border-radius: 50%;
160
  border: 2px solid var(--lavender);
161
  background: var(--panel);
162
- font-size: 0.85rem;
163
  line-height: 1;
164
  cursor: pointer;
165
- pointer-events: auto;
166
  display: flex;
167
  align-items: center;
168
  justify-content: center;
169
  padding: 0;
170
  }
171
 
172
- .bbox-sparkle { right: 32px; }
173
- .bbox-delete { right: 0; }
174
-
175
- @media (hover: none) {
176
- .bbox-overlay { display: none; }
 
 
 
 
 
 
 
 
 
 
177
  }
178
 
179
  /* ---- Sparkle effect ---- */
@@ -748,6 +812,24 @@ h2 { font-size: 1.5rem; font-weight: 700; color: var(--lavender-dk); }
748
  to { scale: 0; opacity: 0; }
749
  }
750
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
751
  /* ---- Sparkle burst ---- */
752
 
753
  .sparkle-burst {
 
151
  right: -9px;
152
  }
153
 
154
+ @media (hover: none) {
155
+ /* No dashed outline or resize handle on touch — the action bar
156
+ below the item handles sparkle/delete, and pinch handles resize. */
157
+ .bbox-overlay { display: none; }
158
+ }
159
+
160
+ /* ---- Selection action bar (sparkle / delete / hue strip) ---- */
161
+
162
+ .action-bar {
163
  position: absolute;
164
+ display: flex;
165
+ align-items: center;
166
+ gap: 8px;
167
+ padding: 6px 10px;
168
+ border-radius: 999px;
169
+ background: var(--panel);
170
+ border: 2px solid var(--lavender);
171
+ box-shadow: 0 4px 12px var(--shadow-lifted);
172
+ pointer-events: auto;
173
+ z-index: 10000;
174
+ touch-action: none;
175
+ }
176
+
177
+ .action-bar.hidden {
178
+ display: none;
179
+ }
180
+
181
+ .action-btn {
182
+ width: 34px;
183
+ height: 34px;
184
+ border-radius: 50%;
185
+ border: 2px solid transparent;
186
+ background: transparent;
187
+ font-size: 1.1rem;
188
+ line-height: 1;
189
+ cursor: pointer;
190
+ display: flex;
191
+ align-items: center;
192
+ justify-content: center;
193
+ padding: 0;
194
+ }
195
+
196
+ .action-btn.is-on {
197
+ border-color: var(--gold);
198
+ background: rgba(255, 215, 0, 0.15);
199
+ }
200
+
201
+ .action-btn:active {
202
+ transform: scale(0.9);
203
+ }
204
+
205
+ .hue-strip {
206
+ display: flex;
207
+ align-items: center;
208
+ gap: 6px;
209
+ }
210
+
211
+ .hue-reset {
212
  width: 28px;
213
  height: 28px;
214
  border-radius: 50%;
215
  border: 2px solid var(--lavender);
216
  background: var(--panel);
217
+ font-size: 0.95rem;
218
  line-height: 1;
219
  cursor: pointer;
 
220
  display: flex;
221
  align-items: center;
222
  justify-content: center;
223
  padding: 0;
224
  }
225
 
226
+ .hue-track {
227
+ width: 150px;
228
+ height: 24px;
229
+ border-radius: 12px;
230
+ border: 2px solid var(--lavender);
231
+ background: linear-gradient(to right,
232
+ hsl(0, 70%, 50%),
233
+ hsl(60, 70%, 50%),
234
+ hsl(120, 70%, 50%),
235
+ hsl(180, 70%, 50%),
236
+ hsl(240, 70%, 50%),
237
+ hsl(300, 70%, 50%),
238
+ hsl(360, 70%, 50%));
239
+ touch-action: none;
240
+ cursor: pointer;
241
  }
242
 
243
  /* ---- Sparkle effect ---- */
 
812
  to { scale: 0; opacity: 0; }
813
  }
814
 
815
+ /* ---- Drag particle trail ---- */
816
+
817
+ .drag-spark {
818
+ position: absolute;
819
+ pointer-events: none;
820
+ font-size: 1rem;
821
+ transform: translate(-50%, -50%);
822
+ opacity: 0.9;
823
+ z-index: 90;
824
+ animation: drag-spark 0.5s ease-out forwards;
825
+ filter: drop-shadow(0 0 4px rgba(255, 215, 0, 0.7));
826
+ }
827
+
828
+ @keyframes drag-spark {
829
+ 0% { opacity: 0.9; transform: translate(-50%, -50%) scale(1); }
830
+ 100% { opacity: 0; transform: translate(-50%, -140%) scale(0.4); }
831
+ }
832
+
833
  /* ---- Sparkle burst ---- */
834
 
835
  .sparkle-burst {