Spaces:
Running on Zero
Running on Zero
Live delivery progress: background removal, embeddings, labels
Browse files- app.py +38 -3
- returnguard/service.py +14 -3
- static/app.css +11 -0
- static/app.js +45 -3
- 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 |
-
|
| 203 |
-
|
| 204 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 = {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 = {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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', '
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 167 |
try {
|
| 168 |
-
const data = await api('/api/deliveries', { method: 'POST', body
|
|
|
|
|
|
|
| 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 ββββββββββββββββββββββββββ -->
|