/** * Vision execute - Alpine component for camera_measure / dxf_compare tasks (#46). * * The operator takes (or picks) an image, `acquire(blob)` sends it to the Flask * proxy, and the result - quote table, outcome, failures, overlay - is shown on * the image. `acquire(blob)` is the single entry point on purpose: the agent * camera (#3) will call it with its own blob and reuse everything below. * * Config comes from window.__visionCfg (set by the template): * executeUrl, imageUrlTpl (with a 0 placeholder for the result id), taskId, * taskType, subtasks, labels. * Run context (lot, serial, production run) is read lazily through `ctx()`, * which the template binds to the parent task component. * * #97: with a camera (cameraAvailable, the task's or the station's) the task opens * on a frame already grabbed and analysed (/vision/check, nothing saved: «preview»). * modo «impulsiva»: «Acquisisci» redoes grab+check. modo «in_linea»: continuous * loop grab+check, one request at a time, standing still while paused / page hidden * / leaving / saving. «Registra» saves the LAST frame shown through /vision/execute. * Extra config: cameraAvailable, modo, checkUrl. */ function visionExecute() { // Overlay colour per role (VisionSuite roles: reference, roi, found). const ROLE_COLORS = { reference: '#2563eb', found: '#16a34a', roi: '#f59e0b' }; return { busy: false, grabbing: false, // frame requested from the station camera (#72) error: '', result: null, // last execute response imageSrc: '', // object URL of the acquired image (shown under the overlay) ctx: () => ({}), // replaced by the template: {lot, serial, runId} // #97: preview / loop state preview: false, // result on screen is an analysis only, not saved saved: false, // «Registra» done: the saved result replaces the preview lastBlob: null, // last frame shown, the one «Registra» saves paused: false, // operator paused the in-line loop registering: false, // «Registra» in progress: the loop stands still leaving: false, // page is being left: no more requests looping: false, // an in-line loop is running gen: 0, // bumped by «Registra»: answers of older requests are dropped // #97: start on a frame already acquired and analysed; hook visibility and exit. init() { if (!this.hasCamera) return; document.addEventListener('visibilitychange', () => { if (!document.hidden) this.kick(); }); const leave = () => { this.leaving = true; }; window.addEventListener('pagehide', leave); window.addEventListener('beforeunload', leave); // After x-init has bound ctx(): in-line starts the loop, impulsive grabs once. this.$nextTick(() => { if (this.inLinea) this.loop(); else this.grab(); }); }, get hasCamera() { return !!(window.__visionCfg || {}).cameraAvailable; }, get inLinea() { return this.hasCamera && (window.__visionCfg || {}).modo === 'in_linea'; }, // «Registra» needs a frame on screen that is not saved yet get canRecord() { return !!this.lastBlob && !this.saved && !this.registering; }, shouldRun() { return this.inLinea && !this.paused && !this.saved && !this.registering && !this.leaving && !document.hidden; }, // Page visible again: restart the loop unless the operator paused it. kick() { if (this.shouldRun() && !this.looping) this.loop(); }, // In-line loop: one request at a time, at least 300 ms between two of them // (longer after an error, so a dead camera is not hammered). async loop() { if (this.looping) return; this.looping = true; try { while (this.shouldRun()) { const ok = await this.grab(); await new Promise((r) => setTimeout(r, ok ? 300 : 2000)); } } finally { this.looping = false; } }, // «Pausa» / «Riprendi»; after «Registra» only «Riprendi» restarts the loop. togglePause() { if (this.paused || this.saved) { this.paused = false; this.saved = false; this.loop(); } else { this.paused = true; } }, // Analysis only (/vision/check): the frame becomes the preview, nothing is saved. async analyze(blob) { const cfg = window.__visionCfg || {}; if (!blob || !cfg.checkUrl) return false; const gen = this.gen; this.busy = true; this.error = ''; try { const c = this.ctx() || {}; const fd = new FormData(); fd.append('task_id', cfg.taskId); fd.append('image', blob, blob.name || 'capture.jpg'); if (c.runId) fd.append('production_run_id', c.runId); const csrf = document.querySelector('meta[name=csrf-token]')?.content || ''; const resp = await fetch(cfg.checkUrl, { method: 'POST', headers: { 'X-CSRFToken': csrf }, body: fd, }); const body = await resp.json().catch(() => ({})); if (gen !== this.gen) return false; // «Registra» took over if (!resp.ok || body.error) { this.error = body.detail || (cfg.labels && cfg.labels.requestFailed) || 'Error'; return false; } if (this.imageSrc) URL.revokeObjectURL(this.imageSrc); this.imageSrc = URL.createObjectURL(blob); this.lastBlob = blob; this.result = body; this.preview = true; this.saved = false; this.$nextTick(() => this.drawOverlay()); return true; } catch (e) { if (gen === this.gen) this.error = (cfg.labels && cfg.labels.requestFailed) || 'Error'; return false; } finally { if (gen === this.gen) this.busy = false; } }, // «Registra»: save the LAST frame shown with the usual /vision/execute. async record() { if (!this.canRecord) return; const blob = this.lastBlob; this.registering = true; this.gen++; // drop whatever request is still in flight this.busy = false; this.grabbing = false; try { await this.acquire(blob); if (!this.error && this.result && this.result.vision_result_id) { this.preview = false; this.saved = true; } } finally { this.registering = false; this.kick(); // in-line: stays still while saved, restarts if the save failed } }, // Entry point shared with the future agent camera. async acquire(blob) { if (!blob || this.busy) return; const cfg = window.__visionCfg || {}; this.busy = true; this.error = ''; this.result = null; // Show the picked image at once: the overlay is drawn on top when the result arrives. if (this.imageSrc) URL.revokeObjectURL(this.imageSrc); this.imageSrc = URL.createObjectURL(blob); try { const c = this.ctx() || {}; const fd = new FormData(); fd.append('task_id', cfg.taskId); fd.append('task_type', cfg.taskType || ''); fd.append('image', blob, blob.name || 'capture.jpg'); fd.append('lot_number', c.lot || ''); fd.append('serial_number', c.serial || ''); if (c.runId) fd.append('production_run_id', c.runId); const csrf = document.querySelector('meta[name=csrf-token]')?.content || ''; const resp = await fetch(cfg.executeUrl, { method: 'POST', headers: { 'X-CSRFToken': csrf }, body: fd, }); const body = await resp.json().catch(() => ({})); if (!resp.ok || body.error) { this.error = body.detail || (cfg.labels && cfg.labels.requestFailed) || 'Error'; return; } this.result = body; // Draw once Alpine has rendered the canvas. this.$nextTick(() => this.drawOverlay()); } catch (e) { this.error = (cfg.labels && cfg.labels.requestFailed) || 'Error'; } finally { this.busy = false; } }, // Station camera (#72): the Flask proxy answers a PNG, which goes through // acquire() like a picked file. Errors show as the execute ones do. // #97: sends task_id (the task's camera wins over the station's); with a camera // the frame goes to the preview analysis. Returns true when the frame was analysed. async grab() { const cfg = window.__visionCfg || {}; if (this.busy || this.grabbing || this.registering || !cfg.grabUrl) return false; const gen = this.gen; this.grabbing = true; this.error = ''; let blob = null; try { const csrf = document.querySelector('meta[name=csrf-token]')?.content || ''; const fd = new FormData(); fd.append('task_id', cfg.taskId); const resp = await fetch(cfg.grabUrl, { method: 'POST', headers: { 'X-CSRFToken': csrf }, body: fd, }); if (!resp.ok) { const body = await resp.json().catch(() => ({})); if (gen === this.gen) this.error = body.detail || (cfg.labels && cfg.labels.requestFailed) || 'Error'; return false; } blob = await resp.blob(); } catch (e) { if (gen === this.gen) this.error = (cfg.labels && cfg.labels.requestFailed) || 'Error'; return false; } finally { if (gen === this.gen) this.grabbing = false; } if (!blob || gen !== this.gen) return false; if (this.hasCamera) return await this.analyze(blob); await this.acquire(blob); return !this.error; }, // Input change: take the file and allow picking the same one again. // #97: with a camera the file is a preview too (the loop pauses), «Registra» saves it. onPick(ev) { const f = ev.target.files && ev.target.files[0]; ev.target.value = ''; if (!f) return; if (this.hasCamera) { if (this.inLinea) this.paused = true; this.analyze(f); } else { this.acquire(f); } }, // Image shown: the saved one from the server when available, else the local one. get shownImage() { const cfg = window.__visionCfg || {}; if (this.result && this.result.vision_result_id && cfg.imageUrlTpl) { return cfg.imageUrlTpl.replace('/0/', '/' + this.result.vision_result_id + '/'); } return this.imageSrc; }, // Quote rows: server measurements joined with the subtask names. get rows() { const cfg = window.__visionCfg || {}; const subs = cfg.subtasks || []; return ((this.result && this.result.measurements) || []).map((m) => { const st = subs.find((s) => s.id === m.subtask_id) || {}; return { label: (st.marker_number ? st.marker_number + '. ' : '') + (st.measurement_type || st.name || '#' + m.subtask_id), value: m.value, unit: st.unit || 'mm', status: m.pass_fail, }; }); }, statusClass(s) { // Same palette as the measurement markers of the manual flow. if (s === 'pass') return 'bg-measure-pass/20 text-measure-pass border border-measure-pass/40'; if (s === 'fail') return 'bg-red-100 dark:bg-red-900/30 text-measure-fail border border-measure-fail/40'; if (s === 'warning') return 'bg-amber-100 dark:bg-amber-900/30 text-measure-warning border border-measure-warning/40'; return 'bg-[var(--bg-secondary)] text-[var(--text-muted)] border border-[var(--border-color)]'; }, // Canvas is sized to the natural image, so overlay coordinates (image px) // are used as they are and CSS scales the whole thing to the displayed size. drawOverlay() { const img = this.$refs.img, cv = this.$refs.overlay; if (!img || !cv || !img.naturalWidth) return; cv.width = img.naturalWidth; cv.height = img.naturalHeight; const g = cv.getContext('2d'); g.clearRect(0, 0, cv.width, cv.height); g.lineWidth = Math.max(2, cv.width / 400); g.lineJoin = 'round'; for (const item of ((this.result && this.result.overlay) || [])) { const rows = item.rows || [], cols = item.cols || []; if (!rows.length || rows.length !== cols.length) continue; g.strokeStyle = ROLE_COLORS[item.role] || '#ef4444'; g.beginPath(); g.moveTo(cols[0], rows[0]); for (let i = 1; i < rows.length; i++) g.lineTo(cols[i], rows[i]); g.stroke(); } }, }; }