Ricetta DXF da camera: pannello editor, stato calibrazione stazioni, proxy camera (#83)
Rotte maker: elenco stazioni con camera, grab per stazione, stato calibrazione dall'acquisitore (C1), inoltro a B3. Pagina stazioni: stato e link «Calibra». Test con api_client e requests finti; traduzioni IT/EN. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,181 @@
|
||||
// «Crea ricetta da DXF» dell'editor task (#83): da un'immagine della camera e un DXF
|
||||
// genera la ricetta dxf_compare (POST /maker/api/vision/tasks/<id>/recipe/dxf-compare).
|
||||
// «Prova» (save=false) mostra overlay e quattro valori, «Salva ricetta» (save=true)
|
||||
// la scrive e avvisa il componente visionRecipe con l'evento 'dxf-recipe-saved'.
|
||||
// Uso: x-data="dxfRecipeWizard(task.id, csrfFn, msgs)". Dipende da Alpine.js e station_calibration.js.
|
||||
function dxfRecipeWizard(taskId, csrf, msgs) {
|
||||
// Colori overlay per ruolo, uguali a vision_execute.js (reference, roi, found)
|
||||
const ROLE_COLORS = { reference: '#2563eb', found: '#16a34a', roi: '#f59e0b' };
|
||||
const VALUE_KEYS = ['dxf.points_in_pct', 'dxf.distance_avg', 'dxf.distance_min', 'dxf.distance_max'];
|
||||
|
||||
return {
|
||||
taskId: taskId,
|
||||
msgs: msgs || {},
|
||||
open: false,
|
||||
stations: [],
|
||||
stationId: '',
|
||||
calState: '', // '' | calibrated | uncalibrated | unreachable
|
||||
calLabel: '',
|
||||
calUrl: '',
|
||||
imageFile: null, // immagine caricata o acquisita
|
||||
imageName: '',
|
||||
imageSrc: '',
|
||||
dxfFile: null,
|
||||
bandMm: 0.1,
|
||||
minPointsPct: 95,
|
||||
dark: true, // spuntato = pezzo scuro su fondo chiaro (controluce)
|
||||
busy: false,
|
||||
grabbing: false,
|
||||
error: '',
|
||||
notice: '',
|
||||
result: null, // corpo B3 {result, saved, recipe_dir, outputs}
|
||||
|
||||
async toggle() {
|
||||
this.open = !this.open;
|
||||
if (this.open && !this.stations.length) await this.loadStations();
|
||||
},
|
||||
|
||||
async loadStations() {
|
||||
try {
|
||||
const resp = await fetch('/maker/api/stations');
|
||||
if (resp.ok) this.stations = await resp.json();
|
||||
} catch (e) { console.error('dxfRecipeWizard stations', e); }
|
||||
// Una sola stazione con camera: preselezionata
|
||||
if (this.stations.length === 1) {
|
||||
this.stationId = String(this.stations[0].id);
|
||||
await this.onStation();
|
||||
}
|
||||
},
|
||||
|
||||
// Cambio stazione: rilegge lo stato di calibrazione dall'acquisitore
|
||||
async onStation() {
|
||||
this.calState = ''; this.calLabel = ''; this.calUrl = '';
|
||||
this.result = null;
|
||||
if (!this.stationId) return;
|
||||
const r = await fetchStationCalibration(this.stationId);
|
||||
this.calState = r.state || 'unreachable';
|
||||
this.calUrl = r.calibration_url || '';
|
||||
if (this.calState === 'calibrated') this.calLabel = formatCalibration(r.calibration || {});
|
||||
},
|
||||
|
||||
get calibrated() { return this.calState === 'calibrated'; },
|
||||
get canRun() {
|
||||
return !!(this.stationId && this.calibrated && this.imageFile && this.dxfFile) && !this.busy;
|
||||
},
|
||||
|
||||
_setImage(file) {
|
||||
if (this.imageSrc) URL.revokeObjectURL(this.imageSrc);
|
||||
this.imageFile = file;
|
||||
this.imageName = file.name || '';
|
||||
this.imageSrc = URL.createObjectURL(file);
|
||||
this.result = null;
|
||||
},
|
||||
|
||||
onImage(ev) {
|
||||
const f = ev.target.files && ev.target.files[0];
|
||||
if (f) this._setImage(f);
|
||||
},
|
||||
onDxf(ev) { this.dxfFile = (ev.target.files && ev.target.files[0]) || null; this.result = null; },
|
||||
|
||||
// Frame dalla camera della stazione scelta
|
||||
async grab() {
|
||||
if (!this.stationId || this.grabbing) return;
|
||||
this.grabbing = true;
|
||||
this.error = '';
|
||||
try {
|
||||
const resp = await fetch(`/maker/api/stations/${this.stationId}/camera/grab`, {
|
||||
method: 'POST', headers: { 'X-CSRFToken': this._csrf() },
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const body = await resp.json().catch(() => ({}));
|
||||
this.error = body.detail || this.msgs.requestFailed || 'Error';
|
||||
} else {
|
||||
const blob = await resp.blob();
|
||||
this._setImage(new File([blob], 'camera.png', { type: blob.type || 'image/png' }));
|
||||
}
|
||||
} catch (e) {
|
||||
this.error = this.msgs.requestFailed || String(e);
|
||||
}
|
||||
this.grabbing = false;
|
||||
},
|
||||
|
||||
_csrf() { return typeof csrf === 'function' ? csrf() : csrf; },
|
||||
|
||||
// Il 422 porta `detail` (stringa o lista) oppure `errors`
|
||||
_errorText(body) {
|
||||
if (Array.isArray(body.errors) && body.errors.length) return body.errors.map(String).join('; ');
|
||||
const d = body.detail;
|
||||
if (Array.isArray(d)) return d.map(x => (typeof x === 'string' ? x : (x.msg || JSON.stringify(x)))).join('; ');
|
||||
if (d && typeof d === 'object') return JSON.stringify(d);
|
||||
return d || this.msgs.requestFailed || 'Error';
|
||||
},
|
||||
|
||||
async run(save) {
|
||||
if (!this.canRun) return;
|
||||
this.busy = true;
|
||||
this.error = '';
|
||||
this.notice = '';
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append('image', this.imageFile, this.imageFile.name || 'capture.png');
|
||||
fd.append('dxf', this.dxfFile, this.dxfFile.name);
|
||||
fd.append('station_id', this.stationId);
|
||||
fd.append('band_mm', String(this.bandMm));
|
||||
fd.append('min_points_pct', String(this.minPointsPct));
|
||||
fd.append('polarity', this.dark ? 'dark' : 'light');
|
||||
fd.append('save', save ? 'true' : 'false');
|
||||
const resp = await fetch(`/maker/api/vision/tasks/${this.taskId}/recipe/dxf-compare`, {
|
||||
method: 'POST', headers: { 'X-CSRFToken': this._csrf() }, body: fd,
|
||||
});
|
||||
const body = await resp.json().catch(() => ({}));
|
||||
if (!resp.ok || body.error) {
|
||||
this.error = this._errorText(body);
|
||||
} else {
|
||||
this.result = body;
|
||||
this.$nextTick(() => this.drawOverlay());
|
||||
if (save && body.saved) {
|
||||
this.notice = this.msgs.saved || 'Saved';
|
||||
// Il componente visionRecipe ricarica cartella, file e uscite delle quote
|
||||
this.$dispatch('dxf-recipe-saved', { taskId: this.taskId });
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
this.error = this.msgs.requestFailed || String(e);
|
||||
}
|
||||
this.busy = false;
|
||||
},
|
||||
|
||||
// Le quattro uscite del confronto, nell'ordine fisso
|
||||
get values() {
|
||||
const out = (this.result && this.result.result && this.result.result.outputs) || {};
|
||||
return VALUE_KEYS.map(k => ({ key: k, value: out[k] }));
|
||||
},
|
||||
|
||||
fmt(v) {
|
||||
return (typeof v === 'number') ? v.toLocaleString(document.documentElement.lang || 'it',
|
||||
{ maximumFractionDigits: 3 }) : '-';
|
||||
},
|
||||
|
||||
// Come vision_execute.js: canvas della dimensione dell'immagine, coordinate in pixel immagine
|
||||
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';
|
||||
const shapes = (this.result && this.result.result && this.result.result.overlay) || [];
|
||||
for (const item of shapes) {
|
||||
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();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
// Stato di calibrazione della camera di una stazione (#83).
|
||||
// La calibrazione vive nell'acquisitore (camera_service): qui si legge solo lo stato
|
||||
// tramite il proxy Flask e si offre il link alla sua pagina «Calibra».
|
||||
// Uso: x-data="stationCalibrationCell(station, msgs)" (msgs: stringhe tradotte). Dipende da Alpine.js.
|
||||
|
||||
// Formatta px/mm e data nella lingua della pagina (es. «20,03 px/mm · 23/10/2026»)
|
||||
function formatCalibration(cal) {
|
||||
const lang = document.documentElement.lang || 'it';
|
||||
let text = new Intl.NumberFormat(lang, { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
.format(Number(cal.px_per_mm)) + ' px/mm';
|
||||
if (cal.calibrated_at) {
|
||||
const d = new Date(cal.calibrated_at);
|
||||
if (!isNaN(d)) text += ' · ' + d.toLocaleDateString(lang);
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
// Legge lo stato dal proxy: ritorna {state, calibration, calibration_url, detail}
|
||||
async function fetchStationCalibration(stationId) {
|
||||
try {
|
||||
const resp = await fetch(`/maker/api/stations/${stationId}/camera/calibration`);
|
||||
const body = await resp.json().catch(() => ({}));
|
||||
if (!resp.ok) return { state: 'unreachable', detail: body.detail || '' };
|
||||
return body;
|
||||
} catch (e) {
|
||||
return { state: 'unreachable', detail: String(e) };
|
||||
}
|
||||
}
|
||||
|
||||
function stationCalibrationCell(station, msgs) {
|
||||
return {
|
||||
hasCamera: !!(station.camera_url && station.camera_source),
|
||||
cameraUrl: station.camera_url || '',
|
||||
state: '', // '' = in lettura | calibrated | uncalibrated | unreachable
|
||||
label: '',
|
||||
calibrationUrl: '',
|
||||
msgs: msgs || {},
|
||||
|
||||
async init() {
|
||||
if (!this.hasCamera) return;
|
||||
const r = await fetchStationCalibration(station.id);
|
||||
this.state = r.state || 'unreachable';
|
||||
// Link «Calibra»: dal proxy, altrimenti costruito dall'indirizzo della camera
|
||||
this.calibrationUrl = r.calibration_url || (this.cameraUrl.replace(/\/+$/, '') + '/calibrazione');
|
||||
if (this.state === 'calibrated') this.label = formatCalibration(r.calibration || {});
|
||||
else if (this.state === 'uncalibrated') this.label = this.msgs.uncalibrated || 'not calibrated';
|
||||
else this.label = this.msgs.unreachable || 'camera service unreachable';
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user