Video plate capture: calibration, edge enhance, auto-detect well borders

This commit is contained in:
2026-06-03 17:56:23 +02:00
parent 4b42c03756
commit 9bb8fc1bce
58 changed files with 1699 additions and 274 deletions
@@ -5,7 +5,7 @@
.well-btn {
display: grid;
grid-template-columns: repeat(6, 1fr);
grid-template-columns: repeat(var(--well-columns, 6), 1fr);
justify-items: center;
align-items: center;
}
@@ -5,7 +5,7 @@
.well-btn {
display: grid;
grid-template-columns: repeat(6, 1fr);
grid-template-columns: repeat(var(--well-columns, 6), 1fr);
justify-items: center;
align-items: center;
}
@@ -0,0 +1,72 @@
.video-drop-zone {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 80px;
margin-top: 8px;
padding: 12px 20px;
border: 2px dashed #aaa;
border-radius: 6px;
background: #f9f9f9;
cursor: pointer;
transition: border-color 0.2s, background 0.2s;
gap: 8px;
}
.video-drop-zone:hover,
.video-drop-zone.drag-over {
border-color: #417690;
background: #e8f3fb;
}
.video-drop-zone.has-file {
border-style: solid;
border-color: #28a745;
background: #f0fff4;
}
.video-drop-hint {
font-size: 0.9em;
color: #555;
pointer-events: none;
text-align: center;
}
.video-drop-zone.has-file .video-drop-hint {
color: #28a745;
font-weight: 600;
}
/* ---- Progress bar ---- */
.video-progress-wrap {
display: none;
width: 100%;
max-width: 360px;
}
.video-progress-bar-track {
width: 100%;
height: 10px;
background: #ddd;
border-radius: 5px;
overflow: hidden;
margin-bottom: 2px;
}
.video-progress-bar {
height: 10px;
width: 0%;
background: #417690;
border-radius: 5px;
transition: width 0.3s ease;
}
.video-progress-label {
display: block;
text-align: right;
font-size: 0.8em;
color: #417690;
font-weight: 600;
}
@@ -32,14 +32,16 @@ class ScannerManager {
this.step = options.step;
this.well = options.well;
this.debug = options.debug;
this.calib_debug = options.calib_debug;
this.calib_center= options.calib_center;
this.calib_debug = options.calib_debug;
this.draw_debug = options.draw_debug;
this.calib_center= options.calib_center;
this.previous = options.previous;
this.next = options.next;
this.set_well = options.set_well;
this.well_btn = options.well_btn;
this.median = options.median;
this.crop = options.crop;
this.median = options.median;
this.edge_enhance = options.edge_enhance;
this.crop = options.crop;
this.crop_radius = options.crop_radius;
this.calib_auto = options.calib_auto;
@@ -53,7 +55,7 @@ class ScannerManager {
} catch(e) {}
}
init_controls() {
init_controls() {
this.up.addEventListener('mousedown', (e) => { this._send({ type: 'calibrate', topic: "up" }); });
this.down.addEventListener('mousedown', (e) => { this._send({ type: 'calibrate', topic: "down" }); });
this.left.addEventListener('mousedown', (e) => { this._send({ type: 'calibrate', topic: "left" }); });
@@ -62,33 +64,35 @@ class ScannerManager {
this.goto_0.addEventListener('click', (e) => { this.clear_buttons(); this._send({ type: 'calibrate', topic: "goto_0" }); });
this.goto_xy.addEventListener('click', (e) => { this.clear_buttons(); this._send({ type: 'calibrate', topic: "goto_xy" }); });
this.xy_base.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "xy_base" }); });
this.calib_debug.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "calib_debug" }); });
this.previous.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "previous" }); });
this.next.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "next" }); });
this.set_well.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "set_well" }); });
this.median.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "median" }); });
this.crop.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "crop" }); });
this.median.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "median" }); });
this.edge_enhance.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "edge_enhance" }); });
this.crop.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "crop" }); });
this.crop_radius.addEventListener('change',(e) => { this._send({ type: 'calibrate', topic: "crop_radius", value: this.crop_radius.value }); });
this.well.addEventListener("change", (e) => { this._send({ type: 'calibrate', topic: "position", value: e.target.value }); });
this.step.addEventListener("change", (e) => { this._send({ type: 'calibrate', topic: "step", value: e.target.value }); });
this.feed.addEventListener("change", (e) => { this._send({ type: 'calibrate', topic: "feed", value: e.target.value }); });
this.duration.addEventListener("change", (e) => { this._send({ type: 'calibrate', topic: "duration", value: e.target.value }); });
this.test.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "test" }); });
this.calib_center.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "center" }); });
this.calib_auto.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "auto" }); });
this.halt.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "halt" }); });
this.calib_debug.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "calib_debug" }); });
this.draw_debug.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "draw_debug" }); });
try {
this.previous.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "previous" }); });
this.next.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "next" }); });
this.calib_center.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "center" }); });
this.calib_auto.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "auto" }); });
this.min_area_px.addEventListener('change', (e) => { this._send({ type: 'calibrate', topic: "min_area_px", value: e.target.value }); });
this.max_area_ratio.addEventListener('change', (e) => { this._send({ type: 'calibrate', topic: "max_area_ratio", value: e.target.value }); });
this.max_planarians.addEventListener('change', (e) => { this._send({ type: 'calibrate', topic: "max_planarians", value: e.target.value}); });
this.merge_kernel_size.addEventListener('change', (e) => { this._send({ type: 'calibrate', topic: "merge_kernel_size", value: e.target.value}); });
this.min_contour_dist_px.addEventListener('change', (e) => { this._send({ type: 'calibrate', topic: "min_contour_dist_px", value: e.target.value }); });
this.draw.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "draw", value: e.target.value }); });
} catch(e) {}
} catch(e) {console.log(e);}
}
registerSocket(socket) {
@@ -104,8 +108,12 @@ class ScannerManager {
if (payload.state) {
if (payload.state == 'debug') {
const span = this.calib_debug.querySelector("span.debug"); span.style.color = payload.value ? '#f0f': '#fff';
} else if (payload.state == 'draw_debug') {
const span = this.draw_debug.querySelector("span.draw_debug"); span.style.color = payload.value ? '#0ff' : '#888';
} else if (payload.state == 'median') {
const span = this.median.querySelector("span.median"); span.style.color = payload.value ? '#f0f' : '#fff';
} else if (payload.state == 'edge_enhance') {
const span = this.edge_enhance.querySelector("span.edge_enhance"); span.style.color = payload.value ? '#0ff' : '#fff';
} else if (payload.state == 'crop') {
const span = this.crop.querySelector("span.crop"); span.style.color = payload.value ? '#f0f' : '#fff';
}
@@ -113,7 +121,10 @@ class ScannerManager {
}
if (payload.ts) { this.ts.textContent = timestampToLocalISOString(payload.ts); }
if (payload.buttons) { this.well_btn.innerHTML = payload.buttons; }
if (payload.buttons) {
this.well_btn.innerHTML = payload.buttons;
document.documentElement.style.setProperty('--well-columns', payload.columns);
}
if (payload.current >= 0) {
document.querySelectorAll('button.w3-button.well').forEach(btn => {
if (btn.value==payload.current) { btn.classList.add('w3-green'); return; }
@@ -62,6 +62,7 @@ class ScannerManager {
if (payload.buttons) {
this.well_btn.innerHTML = payload.buttons;
const span = this.crop.querySelector("span.crop"); span.style.color = '#f0f';
document.documentElement.style.setProperty('--well-columns', payload.columns);
}
if (payload.current >= 0) {
document.querySelectorAll('button.w3-btn.well').forEach(btn => {
@@ -72,7 +73,7 @@ class ScannerManager {
} catch(e) { console.log(e); }
}
init() { this.axes = 0; this.cropping = 1; this._send({ type: 'scanner', topic: "init", }); }
init() { this.axes = 0; this.cropping = 1; this._send({ type: 'scanner', topic: "init", sid: this.session.value }); }
scan() { this._send({ type: 'scanner', topic: "scan", session: this.session.value ? this.session.value: "0" }); }
simulate() { this._send({ type: 'scanner', topic: "simulate", session: this.session.value ? this.session.value: "0" }); }
halt() { this._send({ type: 'calibrate', topic: "halt" }); }
@@ -0,0 +1,141 @@
(function () {
"use strict";
function initDropZone(input) {
var zone = document.createElement("div");
zone.className = "video-drop-zone";
var hint = document.createElement("span");
hint.className = "video-drop-hint";
hint.textContent = "⬇ Glisser une vidéo ici ou cliquer pour parcourir";
zone.appendChild(hint);
// Progress bar (hidden by default)
var progressWrap = document.createElement("div");
progressWrap.className = "video-progress-wrap";
var progressTrack = document.createElement("div");
progressTrack.className = "video-progress-bar-track";
var progressBar = document.createElement("div");
progressBar.className = "video-progress-bar";
progressTrack.appendChild(progressBar);
progressWrap.appendChild(progressTrack);
var progressLabel = document.createElement("span");
progressLabel.className = "video-progress-label";
progressWrap.appendChild(progressLabel);
zone.appendChild(progressWrap);
input.parentNode.insertBefore(zone, input.nextSibling);
function setFilename(name) {
hint.textContent = "✓ " + name;
zone.classList.add("has-file");
}
function showProgress(pct) {
progressWrap.style.display = "block";
progressBar.style.width = pct + "%";
progressLabel.textContent = Math.round(pct) + " %";
}
function hideProgress() {
progressWrap.style.display = "none";
}
// Drag events
zone.addEventListener("dragenter", function (e) { e.preventDefault(); zone.classList.add("drag-over"); });
zone.addEventListener("dragover", function (e) { e.preventDefault(); zone.classList.add("drag-over"); });
zone.addEventListener("dragleave", function () { zone.classList.remove("drag-over"); });
zone.addEventListener("dragend", function () { zone.classList.remove("drag-over"); });
zone.addEventListener("drop", function (e) {
e.preventDefault();
zone.classList.remove("drag-over");
var files = e.dataTransfer.files;
if (!files.length) return;
var file = files[0];
try {
var dt = new DataTransfer();
dt.items.add(file);
input.files = dt.files;
input.dispatchEvent(new Event("change", { bubbles: true }));
} catch (_) {}
setFilename(file.name);
});
// Click on zone → open file picker
zone.addEventListener("click", function () { input.click(); });
// Sync when user picks via dialog
input.addEventListener("change", function () {
if (input.files && input.files.length > 0) {
setFilename(input.files[0].name);
}
});
// Pre-fill if a file is already set (edit form)
if (input.value) {
var parts = input.value.split(/[\\/]/);
setFilename(parts[parts.length - 1]);
}
return { showProgress: showProgress, hideProgress: hideProgress, input: input };
}
function interceptFormSubmit(form, dropZones) {
form.addEventListener("submit", function (e) {
// Only intercept if at least one file input has a file selected
var hasFile = dropZones.some(function (dz) {
return dz.input.files && dz.input.files.length > 0;
});
if (!hasFile) return; // normal submit, no big file
e.preventDefault();
var data = new FormData(form);
var xhr = new XMLHttpRequest();
// Show progress on the first drop zone that has a file
var active = dropZones.find(function (dz) {
return dz.input.files && dz.input.files.length > 0;
});
if (active) active.showProgress(0);
xhr.upload.addEventListener("progress", function (ev) {
if (ev.lengthComputable && active) {
active.showProgress((ev.loaded / ev.total) * 100);
}
});
xhr.addEventListener("load", function () {
if (active) active.hideProgress();
// Django admin redirects after save — follow the final URL
var finalUrl = xhr.responseURL || form.action;
// The response itself is the resulting admin page; navigate to it
window.location.href = finalUrl;
});
xhr.addEventListener("error", function () {
if (active) {
active.hideProgress();
active.showProgress(0); // reset bar
}
alert("Erreur lors de l'envoi du fichier.");
});
xhr.open(form.method || "POST", form.action || window.location.href, true);
xhr.send(data);
});
}
document.addEventListener("DOMContentLoaded", function () {
var inputs = Array.from(document.querySelectorAll('input[type="file"]'));
var dropZones = inputs.map(initDropZone);
// Hook into the closest form that contains these inputs
var form = inputs.length && inputs[0].closest("form");
if (form && dropZones.length) {
interceptFormSubmit(form, dropZones);
}
});
}());