RajTheBuilder commited on
Commit
01b417a
Β·
verified Β·
1 Parent(s): 956ef8c

Live delivery progress: background removal, embeddings, labels

Browse files
Files changed (5) hide show
  1. app.py +38 -3
  2. returnguard/service.py +14 -3
  3. static/app.css +11 -0
  4. static/app.js +45 -3
  5. templates/index.html +1 -1
app.py CHANGED
@@ -5,7 +5,9 @@
5
  import json
6
  import logging
7
  import os
 
8
  import secrets
 
9
  import time
10
  from collections import defaultdict, deque
11
 
@@ -199,12 +201,45 @@ def create_delivery():
199
  days = request.form.get("delivered_days_ago", "0").strip() or "0"
200
  if not days.isdigit() or int(days) > 365:
201
  raise service.InputError("'Delivered days ago' must be a whole number between 0 and 365.")
202
- order = service.register_delivery(uploaded_images(), customer["customer_id"],
203
- request.form.get("product_name", "").strip()[:120],
204
- request.form.get("category", "other"), int(days))
 
 
 
 
 
 
 
 
 
 
 
 
205
  return jsonify(success=True, order=order, images=media_urls("orders", order["order_id"]))
206
 
207
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
208
  @app.post("/api/returns")
209
  def create_return():
210
  customer = require_customer()
 
5
  import json
6
  import logging
7
  import os
8
+ import re
9
  import secrets
10
+ import threading
11
  import time
12
  from collections import defaultdict, deque
13
 
 
201
  days = request.form.get("delivered_days_ago", "0").strip() or "0"
202
  if not days.isdigit() or int(days) > 365:
203
  raise service.InputError("'Delivered days ago' must be a whole number between 0 and 365.")
204
+ job = _progress_key(customer["customer_id"], request.form.get("job_id", ""))
205
+
206
+ def on_stage(key, detail=""):
207
+ if job:
208
+ with _progress_lock:
209
+ _progress[job] = {"stage": key, "detail": detail, "at": time.time()}
210
+
211
+ try:
212
+ order = service.register_delivery(uploaded_images(), customer["customer_id"],
213
+ request.form.get("product_name", "").strip()[:120],
214
+ request.form.get("category", "other"), int(days), on_stage=on_stage)
215
+ finally:
216
+ if job:
217
+ with _progress_lock:
218
+ _progress.pop(job, None)
219
  return jsonify(success=True, order=order, images=media_urls("orders", order["order_id"]))
220
 
221
 
222
+ # Live delivery progress: the browser sends a random job_id with the upload and polls this while it waits.
223
+ _progress: dict[tuple[str, str], dict] = {}
224
+ _progress_lock = threading.Lock()
225
+
226
+
227
+ def _progress_key(customer_id: str, job_id: str):
228
+ return (customer_id, job_id) if re.fullmatch(r"[A-Za-z0-9-]{8,64}", job_id or "") else None
229
+
230
+
231
+ @app.get("/api/deliveries/progress/<job_id>")
232
+ def delivery_progress(job_id):
233
+ customer = require_customer()
234
+ key = _progress_key(customer["customer_id"], job_id)
235
+ with _progress_lock:
236
+ stale = [k for k, v in _progress.items() if time.time() - v["at"] > 600]
237
+ for k in stale:
238
+ _progress.pop(k, None)
239
+ state = _progress.get(key) if key else None
240
+ return jsonify(success=True, stage=state["stage"] if state else None, detail=state["detail"] if state else "")
241
+
242
+
243
  @app.post("/api/returns")
244
  def create_return():
245
  customer = require_customer()
returnguard/service.py CHANGED
@@ -97,21 +97,32 @@ def _save(images: dict, cutouts: dict, folder):
97
 
98
  # ── delivery ───────────────────────────────────────────────────────────────
99
  def register_delivery(files: dict, customer_id: str, product_name: str, category: str,
100
- delivered_days_ago: int = 0) -> dict:
 
 
101
  if category not in config.RETURN_WINDOW_DAYS:
102
  raise InputError(f"Unknown category '{category}'.")
103
  images = _load_all(files)
104
- cutouts = {a: imaging.remove_background(images[a]) for a in config.ANGLES}
 
 
 
 
105
  quality = {a: imaging.assess_quality(images[a], cutouts[a]) for a in config.ANGLES}
106
  bad = [f"{a.capitalize()}: {q.problem}" for a, q in quality.items() if not q.ok]
107
  if bad:
108
  raise InputError(" ".join(bad))
109
 
110
- features = {a: view_features(cutouts[a]).to_dict() for a in config.ANGLES}
 
 
 
 
111
  delivered_at = (datetime.now(timezone.utc) - timedelta(days=max(0, delivered_days_ago))).isoformat(timespec="seconds")
112
  order_id = storage.create_order(customer_id, product_name, category, delivered_at, features)
113
  _save(images, cutouts, order_dir(order_id))
114
 
 
115
  try:
116
  fp, rec = identity.fingerprint(cutouts)
117
  fingerprint = fp.model_dump()
 
97
 
98
  # ── delivery ───────────────────────────────────────────────────────────────
99
  def register_delivery(files: dict, customer_id: str, product_name: str, category: str,
100
+ delivered_days_ago: int = 0, on_stage=None) -> dict:
101
+ """on_stage(key, detail) is called as each step starts, so the UI can show live progress."""
102
+ stage = on_stage or (lambda key, detail="": None)
103
  if category not in config.RETURN_WINDOW_DAYS:
104
  raise InputError(f"Unknown category '{category}'.")
105
  images = _load_all(files)
106
+ cutouts = {}
107
+ for i, a in enumerate(config.ANGLES, 1):
108
+ stage("background", f"{a} view ({i}/{len(config.ANGLES)})")
109
+ cutouts[a] = imaging.remove_background(images[a])
110
+ stage("quality", "blur and framing")
111
  quality = {a: imaging.assess_quality(images[a], cutouts[a]) for a in config.ANGLES}
112
  bad = [f"{a.capitalize()}: {q.problem}" for a, q in quality.items() if not q.ok]
113
  if bad:
114
  raise InputError(" ".join(bad))
115
 
116
+ features = {}
117
+ for i, a in enumerate(config.ANGLES, 1):
118
+ stage("embeddings", f"{a} view ({i}/{len(config.ANGLES)})")
119
+ features[a] = view_features(cutouts[a]).to_dict()
120
+ stage("save", "")
121
  delivered_at = (datetime.now(timezone.utc) - timedelta(days=max(0, delivered_days_ago))).isoformat(timespec="seconds")
122
  order_id = storage.create_order(customer_id, product_name, category, delivered_at, features)
123
  _save(images, cutouts, order_dir(order_id))
124
 
125
+ stage("labels", f"order #{order_id}")
126
  try:
127
  fp, rec = identity.fingerprint(cutouts)
128
  fingerprint = fp.model_dump()
static/app.css CHANGED
@@ -595,3 +595,14 @@ footer a:hover { color: var(--text); }
595
  @media (prefers-reduced-motion: reduce) {
596
  *, *::before, *::after { animation-duration: .001ms !important; transition-duration: .001ms !important; }
597
  }
 
 
 
 
 
 
 
 
 
 
 
 
595
  @media (prefers-reduced-motion: reduce) {
596
  *, *::before, *::after { animation-duration: .001ms !important; transition-duration: .001ms !important; }
597
  }
598
+
599
+ /* ── live progress steps (busy overlay) ─────────────────────────────── */
600
+ .live-steps { list-style: none; margin: 18px auto 0; padding: 0; text-align: left; display: grid; gap: 10px; min-width: 300px; }
601
+ .live-steps li { display: flex; align-items: flex-start; gap: 10px; font-size: 14px; color: var(--text-3); transition: color .2s; }
602
+ .live-steps .ls-icon { width: 18px; height: 18px; flex-shrink: 0; margin-top: 1px; border-radius: 50%; border: 1.5px solid var(--border-strong, var(--border)); }
603
+ .live-steps .ls-detail { color: var(--text-3); font-size: 12.5px; font-weight: 400; }
604
+ .live-steps li.active { color: var(--text); font-weight: 500; }
605
+ .live-steps li.active .ls-icon { border: 2px solid var(--surface-3); border-top-color: var(--accent); animation: spin .8s linear infinite; }
606
+ .live-steps li.done { color: var(--text-2); }
607
+ .live-steps li.done .ls-icon { border-color: var(--ok); background: var(--ok); position: relative; }
608
+ .live-steps li.done .ls-icon::after { content: ""; position: absolute; left: 5px; top: 2px; width: 4px; height: 8px; border: solid #fff; border-width: 0 2px 2px 0; transform: rotate(45deg); }
static/app.js CHANGED
@@ -49,11 +49,37 @@ function toast(msg, type = '') {
49
  clearTimeout(toastTimer);
50
  toastTimer = setTimeout(() => t.classList.remove('show'), 4500);
51
  }
52
- function busy(on, title = '', sub = '') {
53
  $('#busy').classList.toggle('show', on);
 
 
54
  if (on) { $('#busy-title').textContent = title; $('#busy-sub').textContent = sub; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
55
  }
56
 
 
 
 
 
 
 
 
 
 
 
57
  /* ── theme ───────────────────────────────────────────────────────────── */
58
  function initTheme() {
59
  const apply = t => {
@@ -163,9 +189,24 @@ function initDelivery() {
163
  form.addEventListener('submit', async e => {
164
  e.preventDefault();
165
  if (ANGLES.some(a => !$(`input[name="${a}_image"]`, form).files.length)) return toast('Add all three photos (front, back, side).', 'error');
166
- busy(true, 'Registering delivery', 'Removing backgrounds, computing embeddings, and reading labels with the identity agent…');
 
 
 
 
 
 
 
 
 
 
 
 
 
167
  try {
168
- const data = await api('/api/deliveries', { method: 'POST', body: new FormData(form) });
 
 
169
  store.set('rg-last-order', data.order.order_id);
170
  renderDelivery(data.order, data.images);
171
  clearPreviews(form);
@@ -175,6 +216,7 @@ function initDelivery() {
175
  } catch (err) {
176
  toast(err.message, 'error');
177
  } finally {
 
178
  busy(false);
179
  }
180
  });
 
49
  clearTimeout(toastTimer);
50
  toastTimer = setTimeout(() => t.classList.remove('show'), 4500);
51
  }
52
+ function busy(on, title = '', sub = '', steps = null) {
53
  $('#busy').classList.toggle('show', on);
54
+ $('#busy-spinner').classList.toggle('hidden', on && !!steps);
55
+ $('#busy-steps').classList.toggle('hidden', !(on && steps));
56
  if (on) { $('#busy-title').textContent = title; $('#busy-sub').textContent = sub; }
57
+ if (on && steps) {
58
+ $('#busy-steps').innerHTML = steps.map(s => `<li data-step="${s.key}"><span class="ls-icon"></span><span class="ls-text">${esc(s.label)}<small class="ls-detail"></small></span></li>`).join('');
59
+ busyStep(steps[0].key);
60
+ }
61
+ }
62
+ // Mark every step before `key` done and `key` itself active (with an optional detail like "front view (1/3)").
63
+ function busyStep(key, detail = '') {
64
+ const items = $$('#busy-steps li');
65
+ const at = items.findIndex(li => li.dataset.step === key);
66
+ if (at < 0) return;
67
+ items.forEach((li, i) => {
68
+ li.className = i < at ? 'done' : i === at ? 'active' : '';
69
+ $('.ls-detail', li).textContent = i === at && detail ? ` Β· ${detail}` : '';
70
+ });
71
  }
72
 
73
+ const DELIVERY_STEPS = [
74
+ { key: 'upload', label: 'Uploading photos' },
75
+ { key: 'background', label: 'Removing backgrounds' },
76
+ { key: 'quality', label: 'Checking photo quality' },
77
+ { key: 'embeddings', label: 'Computing DINOv2 embeddings and colour' },
78
+ { key: 'save', label: 'Saving the order' },
79
+ { key: 'labels', label: 'Gemini reading labels and serials' },
80
+ ];
81
+ const newJobId = () => (crypto.randomUUID ? crypto.randomUUID() : `job-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`);
82
+
83
  /* ── theme ───────────────────────────────────────────────────────────── */
84
  function initTheme() {
85
  const apply = t => {
 
189
  form.addEventListener('submit', async e => {
190
  e.preventDefault();
191
  if (ANGLES.some(a => !$(`input[name="${a}_image"]`, form).files.length)) return toast('Add all three photos (front, back, side).', 'error');
192
+ busy(true, 'Registering delivery', 'Live progress from the server:', DELIVERY_STEPS);
193
+ const jobId = newJobId();
194
+ const body = new FormData(form);
195
+ body.append('job_id', jobId);
196
+ let polling = true;
197
+ (async function poll() {
198
+ while (polling) {
199
+ try {
200
+ const p = await api(`/api/deliveries/progress/${jobId}`);
201
+ if (polling && p.stage) busyStep(p.stage, p.detail);
202
+ } catch (e) { /* progress is best effort */ }
203
+ await new Promise(r => setTimeout(r, 450));
204
+ }
205
+ })();
206
  try {
207
+ const data = await api('/api/deliveries', { method: 'POST', body });
208
+ polling = false;
209
+ $$('#busy-steps li').forEach(li => { li.className = 'done'; });
210
  store.set('rg-last-order', data.order.order_id);
211
  renderDelivery(data.order, data.images);
212
  clearPreviews(form);
 
216
  } catch (err) {
217
  toast(err.message, 'error');
218
  } finally {
219
+ polling = false;
220
  busy(false);
221
  }
222
  });
templates/index.html CHANGED
@@ -56,7 +56,7 @@
56
  </div>
57
 
58
  <div class="toast" id="toast" role="status" aria-live="polite"></div>
59
- <div class="busy" id="busy"><div class="busy-card"><div class="spinner"></div><div class="h3" id="busy-title">Working…</div><p class="muted" id="busy-sub"></p></div></div>
60
 
61
  <main>
62
  <!-- ══════════════════════════ HOME ══════════════════════════ -->
 
56
  </div>
57
 
58
  <div class="toast" id="toast" role="status" aria-live="polite"></div>
59
+ <div class="busy" id="busy"><div class="busy-card"><div class="spinner" id="busy-spinner"></div><div class="h3" id="busy-title">Working…</div><p class="muted" id="busy-sub"></p><ol class="live-steps hidden" id="busy-steps"></ol></div></div>
60
 
61
  <main>
62
  <!-- ══════════════════════════ HOME ══════════════════════════ -->