3856ee335a
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
126 lines
5.2 KiB
JavaScript
126 lines
5.2 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.
|
|
*/
|
|
function visionExecute() {
|
|
// Overlay colour per role (VisionSuite roles: reference, roi, found).
|
|
const ROLE_COLORS = { reference: '#2563eb', found: '#16a34a', roi: '#f59e0b' };
|
|
|
|
return {
|
|
busy: false,
|
|
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}
|
|
|
|
// 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;
|
|
}
|
|
},
|
|
|
|
// Input change: take the file and allow picking the same one again.
|
|
onPick(ev) {
|
|
const f = ev.target.files && ev.target.files[0];
|
|
ev.target.value = '';
|
|
if (f) 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();
|
|
}
|
|
},
|
|
};
|
|
}
|