acd94a9d33
Refs #116 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
452 lines
19 KiB
JavaScript
452 lines
19 KiB
JavaScript
/**
|
|
* 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
|
|
|
|
// #115: zoom del riquadro immagine (stesso schema del wizard DXF, #113). zoom = px schermo per px immagine;
|
|
// fit = zoom di «Adatta»; w/h = dimensioni naturali; auto = la vista e' ad Adatta (segue il resize del riquadro)
|
|
z: { zoom: 1, fit: 1, w: 0, h: 0, auto: true },
|
|
zPanning: false, // #115: trascinamento in corso (cursore grabbing)
|
|
_zPan: null,
|
|
_zRO: null,
|
|
|
|
// #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 {
|
|
// #116: chiave stabile della quota, nome senza numero di marker e uscita ricetta, per le etichette sull'immagine
|
|
key: m.subtask_id,
|
|
name: st.description || st.measurement_type || '#' + m.subtask_id,
|
|
vision_output: st.vision_output,
|
|
// #114: il nome della quota e' description (prima: measurement_type/name, che per le quote DXF e' vuoto -> '#15')
|
|
label: (st.marker_number ? st.marker_number + '. ' : '') + (st.description || st.measurement_type || '#' + m.subtask_id),
|
|
value: m.value,
|
|
unit: st.unit || 'mm',
|
|
status: m.pass_fail,
|
|
// #117: nominale e tolleranze della quota (come la schermata dei calibri); null se mancano
|
|
nominal: st.nominal, ltl: st.ltl, lwl: st.lwl, uwl: st.uwl, utl: st.utl,
|
|
};
|
|
});
|
|
},
|
|
|
|
// #115: load dell'immagine (anche a ogni frame in linea): dimensioni naturali, fit solo se serve, poi overlay
|
|
onImgLoad() {
|
|
const img = this.$refs.img;
|
|
if (img && img.naturalWidth) {
|
|
const z = this.z;
|
|
const changed = z.w !== img.naturalWidth || z.h !== img.naturalHeight;
|
|
z.w = img.naturalWidth;
|
|
z.h = img.naturalHeight;
|
|
// dimensioni uguali e vista ingrandita: il frame nuovo resta ingrandito (in linea ogni frame ricarica l'immagine)
|
|
if (changed || z.auto) this.zFit();
|
|
this._zObserve();
|
|
}
|
|
this.drawOverlay();
|
|
},
|
|
|
|
// #115: osservatore di resize sul viewport: se la vista e' ad Adatta (o il riquadro era nascosto) rifa il fit
|
|
_zObserve() {
|
|
const vp = this.$refs.vp;
|
|
if (!vp || this._zRO || typeof ResizeObserver === 'undefined') return;
|
|
this._zRO = new ResizeObserver(() => { if (this.z.w > 0 && this.z.auto) this.zFit(); });
|
|
this._zRO.observe(vp);
|
|
},
|
|
|
|
// #115: area utile del viewport; offset meno i bordi (client* escludono le barre di scorrimento che spariranno dopo il fit, come #113)
|
|
_zArea() {
|
|
const vp = this.$refs.vp;
|
|
if (!vp) return { cw: 0, ch: 0 };
|
|
let cw = vp.clientWidth, ch = vp.clientHeight;
|
|
if (vp.offsetWidth && typeof getComputedStyle === 'function') {
|
|
const cs = getComputedStyle(vp);
|
|
cw = vp.offsetWidth - (parseFloat(cs.borderLeftWidth) + parseFloat(cs.borderRightWidth) || 0);
|
|
ch = vp.offsetHeight - (parseFloat(cs.borderTopWidth) + parseFloat(cs.borderBottomWidth) || 0);
|
|
}
|
|
return { cw, ch };
|
|
},
|
|
|
|
// #115: Adatta = tutta l'immagine nel riquadro (come il fit di #96)
|
|
zFit() {
|
|
const vp = this.$refs.vp, z = this.z;
|
|
if (!vp || !z.w || !z.h) return;
|
|
const a = this._zArea();
|
|
if (!(a.cw > 0) || !(a.ch > 0)) return; // riquadro nascosto o vuoto: niente, ci pensa l'osservatore
|
|
const f = Math.min(a.cw / z.w, a.ch / z.h);
|
|
z.fit = f;
|
|
z.zoom = f;
|
|
z.auto = true;
|
|
vp.scrollLeft = 0;
|
|
vp.scrollTop = 0;
|
|
},
|
|
|
|
// #115: zoom di un fattore tenendo fermo il punto (cx, cy) del viewport (default: il centro)
|
|
zoomBy(f, cx, cy) {
|
|
const vp = this.$refs.vp, z = this.z;
|
|
if (!vp || !z.w) return;
|
|
const old = z.zoom;
|
|
const nz = Math.min(Math.max(8, z.fit), Math.max(z.fit * 0.5, old * f));
|
|
if (nz === old) return;
|
|
if (cx == null) cx = vp.clientWidth / 2;
|
|
if (cy == null) cy = vp.clientHeight / 2;
|
|
const left = vp.scrollLeft, top = vp.scrollTop;
|
|
z.zoom = nz;
|
|
z.auto = false;
|
|
const apply = () => {
|
|
vp.scrollLeft = (left + cx) * nz / old - cx;
|
|
vp.scrollTop = (top + cy) * nz / old - cy;
|
|
};
|
|
if (typeof this.$nextTick === 'function') this.$nextTick(apply); else apply();
|
|
},
|
|
|
|
// #115: Ctrl+rotella = zoom sul punto del cursore; senza Ctrl la pagina scorre come sempre
|
|
zWheel(ev) {
|
|
if (!ev.ctrlKey) return;
|
|
ev.preventDefault();
|
|
const vp = this.$refs.vp;
|
|
let cx, cy;
|
|
if (vp && vp.getBoundingClientRect) {
|
|
const r = vp.getBoundingClientRect();
|
|
cx = ev.clientX - r.left;
|
|
cy = ev.clientY - r.top;
|
|
}
|
|
this.zoomBy(ev.deltaY < 0 ? 1.25 : 1 / 1.25, cx, cy);
|
|
},
|
|
|
|
zCanPan() { const z = this.z; return z.w > 0 && z.zoom > z.fit * 1.0001; },
|
|
zCursor() { return this.zCanPan() ? (this.zPanning ? 'grabbing' : 'grab') : 'default'; },
|
|
zPct() { return Math.round(this.z.zoom * 100) + '%'; },
|
|
// #115: dimensione del wrapper: naturale x zoom; prima del load riempie il riquadro
|
|
zSize() {
|
|
const z = this.z;
|
|
return z.w ? 'width:' + (z.w * z.zoom) + 'px;height:' + (z.h * z.zoom) + 'px' : 'width:100%;height:100%';
|
|
},
|
|
|
|
// #115: trascinamento = scorrimento del viewport (solo se ingrandito)
|
|
zPanDown(ev) {
|
|
const vp = this.$refs.vp;
|
|
if (!vp || !this.zCanPan() || (ev.pointerType === 'mouse' && ev.button)) return;
|
|
if (ev.preventDefault) ev.preventDefault();
|
|
this._zPan = { x: ev.clientX, y: ev.clientY, left: vp.scrollLeft, top: vp.scrollTop };
|
|
this.zPanning = true;
|
|
try { vp.setPointerCapture(ev.pointerId); } catch (e) { /* puntatore gia' rilasciato */ }
|
|
},
|
|
zPanMove(ev) {
|
|
const vp = this.$refs.vp, p = this._zPan;
|
|
if (!p || !vp) return;
|
|
vp.scrollLeft = p.left - (ev.clientX - p.x);
|
|
vp.scrollTop = p.top - (ev.clientY - p.y);
|
|
},
|
|
zPanEnd() { this._zPan = null; this.zPanning = false; },
|
|
|
|
// #117: tolleranza a 3 decimali, trattino se manca (come x-text="...?.toFixed(3) || '—'" dei calibri, ma 0 resta 0.000)
|
|
fmtTol(v) { return v == null ? '\u2014' : Number(v).toFixed(3); },
|
|
|
|
// #116: etichette «nome valore unita» agganciate alla geometria dello strumento; posizione in % delle
|
|
// dimensioni naturali dell'immagine, cosi' seguono lo zoom (#115) senza ingrandirsi
|
|
get overlayLabels() {
|
|
if (typeof VisionOverlay === 'undefined' || !this.result) return [];
|
|
const img = this.$refs && this.$refs.img;
|
|
const w = this.z.w || (img && img.naturalWidth) || 0;
|
|
const h = this.z.h || (img && img.naturalHeight) || 0;
|
|
if (!w || !h) return [];
|
|
return VisionOverlay.labels(this.rows, this.result.overlay || []).map((l) => ({
|
|
key: l.key,
|
|
text: l.text,
|
|
cls: this.labelClass(l.status),
|
|
style: 'left:' + (l.x / w * 100) + '%;top:' + (l.y / h * 100) + '%;transform:translate(8px,-50%)',
|
|
}));
|
|
},
|
|
|
|
// #116: colori dell'etichetta per esito
|
|
labelClass(s) {
|
|
if (s === 'pass') return 'bg-green-600 text-white';
|
|
if (s === 'warning') return 'bg-amber-400 text-black';
|
|
if (s === 'fail') return 'bg-red-600 text-white';
|
|
return 'bg-gray-700 text-white';
|
|
},
|
|
|
|
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);
|
|
const lw = Math.max(2, cv.width / 400);
|
|
g.lineWidth = lw;
|
|
g.lineJoin = 'round';
|
|
// #116: tutte le forme (rect2, segment, circle, arc...) le disegna vision_overlay.js; il ciclo sotto resta come ripiego
|
|
if (typeof VisionOverlay !== 'undefined') {
|
|
VisionOverlay.draw(g, (this.result && this.result.overlay) || [], lw);
|
|
return;
|
|
}
|
|
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();
|
|
}
|
|
},
|
|
};
|
|
}
|