tube aligner

This commit is contained in:
2026-04-21 00:19:37 +02:00
parent 42677121e3
commit 04da5da162
24 changed files with 1644 additions and 452 deletions
+15
View File
@@ -173,6 +173,21 @@ PlanarianScanner/
--- ---
## Procédure de calibration en 4 étapes
1. Activer "Debug détection" → voir le cercle et les zones sur le stream
2. Positionner la CNC manuellement sur un point stable
→ cliquer "Calib — Point A"
→ mpos_A et centre tube A enregistrés
3. Déplacer la CNC manuellement d'une distance connue (ex: 10mm en X)
→ attendre stabilisation (la pause 2s est déjà là)
→ cliquer "Calib — Point B"
4. Résultat affiché :
"Calibration OK — 38.2000 px/mm (0.026178 mm/px) Δ=10.000mm / 382.0px"
→ px_per_mm sauvegardé dans TubeAligner et persisté en base
## Contexte scientifique ## Contexte scientifique
Les **planaires** sont des vers plats dotés de remarquables capacités de Les **planaires** sont des vers plats dotés de remarquables capacités de
+7
View File
@@ -387,3 +387,10 @@ EXPORTS_LOCAL_PATH = config("EXPORTS_LOCAL_PATH")
EXPORT_REMOTE_PATH = config("EXPORT_REMOTE_PATH") EXPORT_REMOTE_PATH = config("EXPORT_REMOTE_PATH")
EXPORT_DESTINATIONS = ["local", "remote"] EXPORT_DESTINATIONS = ["local", "remote"]
TEST_VIDEOFILE = False
TRACKING = False
TRACKER_TUBE_AXIS = "horizontal" #"vertical"
TRACKER_MIN_AREA = 200
+86 -23
View File
@@ -22,7 +22,9 @@ from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from typing import Optional, Callable, TYPE_CHECKING from typing import Optional, Callable, TYPE_CHECKING
from django.conf import settings
from modules.planarian_tracker import PlanarianTracker from modules.planarian_tracker import PlanarianTracker
from modules.tube_aligner import TubeAligner
if TYPE_CHECKING: if TYPE_CHECKING:
from .circular_crop import CircularCrop # Evite l'import circulaire au runtime from .circular_crop import CircularCrop # Evite l'import circulaire au runtime
@@ -47,13 +49,15 @@ class VideoCaptureInterface(abc.ABC):
# Cadence par défaut en images par seconde # Cadence par défaut en images par seconde
DEFAULT_FPS: float = 5.0 DEFAULT_FPS: float = 5.0
def __init__(self, fps: float = DEFAULT_FPS): def __init__(self, fps: float = DEFAULT_FPS, use_tracking: bool = False, px_per_mm: float = 2.15, display=None):
""" """
Initialise l'interface de capture. Initialise l'interface de capture.
:param fps: Cadence cible en images par seconde :param fps: Cadence cible en images par seconde
""" """
self._fps: float = fps self._fps: float = fps
self.use_tracking = use_tracking
self.display = display
self._interval: float = 1.0 / fps # Intervalle en secondes entre chaque capture self._interval: float = 1.0 / fps # Intervalle en secondes entre chaque capture
self._running: bool = False # Indique si la capture est en cours self._running: bool = False # Indique si la capture est en cours
self._thread: Optional[threading.Thread] = None self._thread: Optional[threading.Thread] = None
@@ -61,12 +65,60 @@ class VideoCaptureInterface(abc.ABC):
self._on_frame: Optional[Callable[[bytes, datetime], None]] = None # Callback image self._on_frame: Optional[Callable[[bytes, datetime], None]] = None # Callback image
self._circular_crop: Optional["CircularCrop"] = None # Recadrage circulaire optionnel self._circular_crop: Optional["CircularCrop"] = None # Recadrage circulaire optionnel
self._active_median = False self._active_median = False
self._active_crop = False
self._error_occured = False self._error_occured = False
self._tracker = PlanarianTracker( self._tracker = PlanarianTracker(
tube_axis = "vertical", # à rendre configurable via settings tube_axis = settings.TRACKER_TUBE_AXIS,
min_area_px = 20, min_area_px = settings.TRACKER_MIN_AREA,
) )
self._aligner = TubeAligner(
px_per_mm = px_per_mm, # à calibrer selon la caméra
grbl_threshold_px = 20, # au-delà → correction GRBL
dead_zone_px = 5, # en-dessous → rien à faire
display = display,
)
self._last_detection = None # résultat du dernier alignement
# calibrage ou lecture réelle
#
def align_on_well_arrival(self, frame: bytes, cnc_controller) -> dict:
"""
Appelé UNE FOIS à l'arrivée sur un nouveau puits.
Détecte le tube, décide l'action, exécute la correction.
:param frame: Frame JPEG bytes capturée après déplacement CNC
:param grbl_send_func: Callable(gcode: str) → envoie le G-code au GRBL
:return: dict résultat de la détection
"""
nparr = np.frombuffer(frame, np.uint8)
img = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
detection = self._aligner.detect_tube(img)
# Stockage pour process_frame
self._last_detection = detection
if not detection["detected"]:
logger.warning("align_on_well_arrival: tube non détecté")
return detection
action = detection["action"]
if action == "grbl":
dx_mm = detection["offset_x_mm"]
dy_mm = detection["offset_y_mm"]
msg = f"align_on_well_arrival: correction CNC move_relative(dx={dx_mm:.3f}, dy={dy_mm:.3f})"
#cnc_controller.move_relative(dx=-dx_mm, dy=-dy_mm, feed=150)
self._tracker.reset()
self._last_detection["action"] = "none"
elif action == "crop":
msg = f"align_on_well_arrival: recadrage logiciel ({detection['offset_x_px']:.1f}px, {detection['offset_y_px']:.1f}px)"
logger.info(msg)
self.display(state='detect_tube', msg=msg)
return detection
def on_well_change(self): def on_well_change(self):
@@ -194,12 +246,15 @@ class VideoCaptureInterface(abc.ABC):
""" """
self._circular_crop = crop self._circular_crop = crop
if crop is not None: if crop is not None:
logger.info( self._active_crop = True
"%s : recadrage circulaire activé (R=%d, stratégie=%s)", msg = f"{self.__class__.__name__}: recadrage circulaire activé (R={crop.radius}, stratégie={crop.strategy.name})"
self.__class__.__name__, crop.radius, crop.strategy.name,
)
else: else:
logger.info("%s : recadrage circulaire désactivé", self.__class__.__name__) self._active_crop = False
msg= f"{self.__class__.__name__}: recadrage circulaire désactivé"
logger.info(msg)
self.display(state='circular_crop', msg=msg)
def process_frame(self, jpeg_bytes: bytes) -> bytes: def process_frame(self, jpeg_bytes: bytes) -> bytes:
""" """
@@ -211,26 +266,39 @@ class VideoCaptureInterface(abc.ABC):
:param jpeg_bytes: Image JPEG brute issue du capteur :param jpeg_bytes: Image JPEG brute issue du capteur
:return: Image traitée (JPEG ou PNG selon la stratégie) :return: Image traitée (JPEG ou PNG selon la stratégie)
""" """
metrics = {"detected": False}
if self._circular_crop is not None: if self._circular_crop is not None:
jpeg = self._circular_crop.process(jpeg_bytes) jpeg = self._circular_crop.process(jpeg_bytes)
# --- tracking ---
nparr = np.frombuffer(jpeg, np.uint8) nparr = np.frombuffer(jpeg, np.uint8)
frame = cv2.imdecode(nparr, cv2.IMREAD_COLOR) frame = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
if frame is None:
return jpeg, metrics
# Mode debug
if self._aligner.debug:
self._last_detection = detection = self._aligner.detect_tube(frame)
annotated = detection.get('frame_annotated')
frame = annotated if annotated is not None else frame
'''
else:
detection = self._last_detection or {}
# --- Crop logiciel si nécessaire ---
if (detection.get("action") == "crop" and detection.get("detected") and not self._aligner.debug ):
frame = self._aligner.crop_to_tube(frame, detection)
'''
if self.use_tracking:
ts = datetime.now(timezone.utc).timestamp() ts = datetime.now(timezone.utc).timestamp()
#metrics = self._tracker.process(frame, ts) if frame is not None else {} frame, metrics = self._tracker.process(frame, ts)
if frame is not None:
frame_annotated, metrics = self._tracker.process(frame, ts) ok, buf = cv2.imencode(".jpg", frame, [cv2.IMWRITE_JPEG_QUALITY, 85])
# Ré-encodage JPEG de la frame annotée
ok, buf = cv2.imencode(".jpg", frame_annotated, [cv2.IMWRITE_JPEG_QUALITY, 85])
if ok: if ok:
jpeg = buf.tobytes() jpeg = buf.tobytes()
else:
metrics = {"detected": False}
return jpeg, metrics return jpeg, metrics
return jpeg_bytes, {"detected": False} return jpeg_bytes, metrics
def save_frame(self, jpeg_bytes: bytes, directory: str = ".", prefix: str = "frame") -> Path: def save_frame(self, jpeg_bytes: bytes, directory: str = ".", prefix: str = "frame") -> Path:
""" """
@@ -265,11 +333,6 @@ class VideoCaptureInterface(abc.ABC):
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# tracer médianes # tracer médianes
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def set_median(self, is_median=False):
"""
Active ou désactive les médianes
"""
self._active_median = is_median
def display_median(self, jpeg): def display_median(self, jpeg):
if self._active_median: if self._active_median:
+10 -143
View File
@@ -4,15 +4,6 @@ GCode pour piloter la L2544 Laser Engraving Machine
GRBLController: GRBLController:
Commande uniquement les mouvements (X, Y) Commande uniquement les mouvements (X, Y)
Le mode absolue est retenu Le mode absolue est retenu
GridScanner
Balayage complet de la grille d'éprouvettes en mode serpentin
Usage:
grbl = GRBLController()
scan = GridScanner(grbl, xbase=100, ybase=100, duration=5)
scan.start()
Created on 25 mars 2026 Created on 25 mars 2026
@author: denis@miraceti.net @author: denis@miraceti.net
@@ -170,6 +161,7 @@ class GRBLController:
if "MPos" in status: if "MPos" in status:
mpos = status.split("MPos:")[1].split("|")[0] mpos = status.split("MPos:")[1].split("|")[0]
x, y, *_ = mpos.split(",") x, y, *_ = mpos.split(",")
self._state(state='Mpos', msg=f"pos >>> ({x}, {y})")
return float(x), float(y) return float(x), float(y)
return None, None return None, None
@@ -188,12 +180,19 @@ class GRBLController:
break break
self.wait_for(0.1) self.wait_for(0.1)
def send_command(self, cmd):
self.send(cmd)
self.wait_idle()
def move_to(self, x, y, feed=1000): def move_to(self, x, y, feed=1000):
x, y = self._clamp(x, y) x, y = self._clamp(x, y)
#cmd = f"G0 X{x:.2f} Y{y:.2f} F{feed}" # feed is not updated in G0 mode #cmd = f"G0 X{x:.2f} Y{y:.2f} F{feed}" # feed is not updated in G0 mode
cmd = f"G53 G1 X{x:.2f} Y{y:.2f} F{feed}" cmd = f"G53 G1 X{x:.2f} Y{y:.2f} F{feed}"
self.send(cmd) self.send_command(cmd)
self.wait_idle()
def move_relative(self, dx=0, dy=0, feed=1000):
x, y = self.get_mpos() # Position actuelle
self.move_to(x + dx, y + dy, feed=feed)
def move_relative_(self, dx=0, dy=0, feed=1000): def move_relative_(self, dx=0, dy=0, feed=1000):
self.send("G91") # Mode relatif self.send("G91") # Mode relatif
@@ -202,10 +201,6 @@ class GRBLController:
self.send("G90") # Retour en mode absolu self.send("G90") # Retour en mode absolu
self.wait_idle() self.wait_idle()
def move_relative(self, dx=0, dy=0, feed=1000):
x, y = self.get_mpos() # Position actuelle
self.move_to(x + dx, y + dy)
def go_origin(self, feed=1000): def go_origin(self, feed=1000):
self.move_to(0, 0, feed=feed) self.move_to(0, 0, feed=feed)
self.wait_for(2.0) self.wait_for(2.0)
@@ -230,131 +225,3 @@ class GRBLController:
def close(self): def close(self):
self.ser.close() self.ser.close()
class GridScanner:
def __init__(self, grbl, process=None, **config):
'''
xbase # Position X de départ (col 0) en mm
ybase # Position Y de départ (row 0) en mm
cols # Nombre de colonnes
rows # Nombre de lignes
dx # Pas entre colonnes en mm
dy # Pas entre lignes en mm
duration # Durée de filmage par éprouvette en secondes
feed # Vitesse de déplacement entre éprouvettes (mm/min)
'''
self.grbl = grbl
self.process = process
self.position = config.get('position', 'HG')
self.xbase = config.get('xbase', 50)
self.ybase = config.get('ybase', 50)
self.cols = config.get('cols', 6)
self.rows = config.get('rows', 4)
self.dx = config.get('dx', 20)
self.dy = config.get('dy', 19)
self.feed = config.get('feed', 1000)
self.duration = config.get('duration', 120) # secondes
self.xnext = config.get('xnext', 50)
self.ynext = config.get('ynext', 50)
row_to_char = config.get('row_to_char', 'D,C,B,A')
self.row_to_char = row_to_char.split(',')
self.stop_playing = None
def halt(self):
self.process.tag.record = False
return self.stop_playing.set()
def _capture(self, uuid: str, duration: float, stop_running: Optional[threading.Event]) -> None:
"""
Déclenche la caméra ArduCam et attend la fin de l'acquisition.
"""
print(f"# démarrer l'enregistrement {uuid}")
self.process.cam.on_well_change()
self.process.tag.uuid = uuid
self.process.tag.record = True
start = time.monotonic()
while not stop_running.is_set():
if time.monotonic() - start > duration:
break
self.grbl.wait_for(1.0)
print("# arrêter l'enregistrement")
self.process.tag.record = False
self.process.tag.uuid = None
def start(self, xnext=None, ynext=None, position=None):
"""
Balayage complet de la grille d'éprouvettes en mode serpentin.
Parcours :
- Lignes paires (0, 2) : gauche → droite (col 0 → col 5)
- Lignes impaires (1, 3) : droite → gauche (col 5 → col 0)
Le déplacement entre éprouvettes se fait en mode absolu via move_to().
La caméra filme pendant `` secondes sur chaque position.
Grille : 6 colonnes × 4 lignes = 24 éprouvettes
- x = XBASE + col * PAS_X
- y = YBASE + row * PAS_Y
"""
try:
if xnext is None:
xnext = self.xnext
if ynext is None:
ynext = self.ynext
if position is None:
position = self.position
max_cells = self.cols * self.rows
cell = 0
logger.info("Début du scan serpentin : %d éprouvettes, %d s/éprouvette, durée totale estimée : %d min",
max_cells,
self.duration,
(max_cells * self.duration) // 60,
)
self.stop_playing = threading.Event()
for row in range(self.rows):
if self.stop_playing.is_set():
break
# Ordre des colonnes selon la parité de la ligne (serpentin)
if row % 2 == 0:
# Ligne paire : gauche → droite
cols = range(self.cols)
else:
# Ligne impaire : droite → gauche
cols = range(self.cols - 1, -1, -1)
for col in cols:
if self.stop_playing.is_set():
break
# Calcul de la position absolue en mm
x = self.xbase + col * self.dx
y = self.ybase + row * self.dy
cell += 1
logger.info(
"[%02d/%02d] row=%d col=%d → X=%.1f mm Y=%.1f mm",
cell, max_cells, row, col, x, y,
)
self.grbl.move_to(x, y, feed=self.feed)
uuid = f'{self.process.tag.session}-{position}-{self.row_to_char[row]}{col+1}'
self._capture(uuid, self.duration, self.stop_playing)
# Retour à nexr après le scan
logger.info("Scan terminé — retour à l'origine (X=%.1f Y=%.1f)", xnext, ynext)
self.grbl.move_to(xnext, ynext, feed=self.feed*2)
except Exception as e:
logger.error(f"scan error: {e}")
@@ -45,6 +45,10 @@ class PiCamera2Capture(VideoCaptureInterface):
jpeg_quality: int = 85, jpeg_quality: int = 85,
camera_index: int = 0, camera_index: int = 0,
use_video_config: bool = True, use_video_config: bool = True,
use_tracking: bool = False,
px_per_mm: float = 2.1,
display = None,
): ):
""" """
:param fps: Cadence cible en images par seconde :param fps: Cadence cible en images par seconde
@@ -55,7 +59,7 @@ class PiCamera2Capture(VideoCaptureInterface):
:param use_video_config: True = VideoConfiguration (flux continu, basse latence) :param use_video_config: True = VideoConfiguration (flux continu, basse latence)
False = StillConfiguration (haute résolution, plus lent) False = StillConfiguration (haute résolution, plus lent)
""" """
super().__init__(fps=fps) super().__init__(fps=fps, use_tracking=use_tracking, px_per_mm=px_per_mm, display=display)
self._width: int = width self._width: int = width
self._height: int = height self._height: int = height
self._jpeg_quality: int = jpeg_quality self._jpeg_quality: int = jpeg_quality
+315
View File
@@ -0,0 +1,315 @@
'''
Created on 17 avr. 2026
@author: denis
'''
# modules/tube_aligner.py
import cv2
import logging
import numpy as np
logger = logging.getLogger(__name__)
class TubeAligner:
GRBL_THRESHOLD_PX = 20
DEAD_ZONE_PX = 5
def __init__(
self,
px_per_mm : float = 10.0,
grbl_threshold_px : int = 20,
dead_zone_px : int = 5,
debug : bool = False, # ← activable depuis la vue
display = None,
):
self.TUBE_DIAMETER_MM = 16.0
self.grbl_threshold_px = grbl_threshold_px
self.dead_zone_px = dead_zone_px
self.debug = debug
self.display = display
# ------------------------------------------------------------------ #
# Détection principale
# ------------------------------------------------------------------ #
def detect_tube(self, frame: np.ndarray) -> dict:
h, w = frame.shape[:2]
cx_img = w // 2
cy_img = h // 2
result = {
"detected" : False,
"tube_cx" : None,
"tube_cy" : None,
"tube_radius" : None,
"offset_x_px" : 0,
"offset_y_px" : 0,
"offset_x_mm" : 0.0,
"offset_y_mm" : 0.0,
"action" : "none",
"frame_annotated": None,
}
frame_out = frame.copy()
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
blurred = cv2.GaussianBlur(gray, (15, 15), 3)
# 3 configurations légèrement différentes — vote majoritaire
# Fonctionne sur fond sombre ET fond clair
configs = [
dict(param1=50, param2=30, minRadius=int(min(w,h)*0.26), maxRadius=int(min(w,h)*0.36)),
dict(param1=60, param2=30, minRadius=int(min(w,h)*0.26), maxRadius=int(min(w,h)*0.37)),
dict(param1=50, param2=28, minRadius=int(min(w,h)*0.25), maxRadius=int(min(w,h)*0.365)),
]
all_cx, all_cy, all_r = [], [], []
for cfg in configs:
circles = cv2.HoughCircles(
blurred,
cv2.HOUGH_GRADIENT,
dp=1.2,
minDist=min(w, h) // 2,
**cfg
)
if circles is not None:
c = np.round(circles[0]).astype(int)
best = min(c, key=lambda c: np.sqrt((c[0]-cx_img)**2 + (c[1]-cy_img)**2))
all_cx.append(int(best[0]))
all_cy.append(int(best[1]))
all_r.append(int(best[2]))
if not all_cx:
logger.warning("TubeAligner: aucun cercle détecté (%dx%d)", w, h)
if self.debug:
frame_out = self._draw_debug_no_detection(frame_out, cx_img, cy_img)
result["frame_annotated"] = frame_out
return result
# Moyenne des détections convergentes
tx = int(np.mean(all_cx))
ty = int(np.mean(all_cy))
tr = int(np.mean(all_r))
if tr > 0:
self.px_per_mm = (2 * tr) / 16.0
offset_x_px = tx - cx_img
offset_y_px = ty - cy_img
#offset_x_mm = offset_x_px / self.px_per_mm
#offset_y_mm = offset_y_px /self. px_per_mm
offset_x_mm = offset_y_px /self. px_per_mm # (X CNC = Y image)
offset_y_mm = -offset_x_px / self.px_per_mm # (Y CNC = -X image)
dist_px = float(np.sqrt(offset_x_px**2 + offset_y_px**2))
if dist_px <= self.dead_zone_px:
action = "none"
elif dist_px <= self.grbl_threshold_px:
action = "crop"
else:
action = "grbl"
if self.debug:
frame_out = self._draw_debug(
frame_out, cx_img, cy_img,
tx, ty, tr,
offset_x_px, offset_y_px,
offset_x_mm, offset_y_mm,
dist_px, action,
votes=len(all_cx), # ← affiche le nombre de configs ayant détecté
)
result.update({
"detected" : True,
"tube_cx" : tx,
"tube_cy" : ty,
"tube_radius" : tr,
"offset_x_px" : offset_x_px,
"offset_y_px" : offset_y_px,
"offset_x_mm" : round(offset_x_mm, 3),
"offset_y_mm" : round(offset_y_mm, 3),
"action" : action,
"frame_annotated": frame_out,
})
return result
def crop_to_tube(self, frame: np.ndarray, detection: dict) -> np.ndarray:
"""
Recadrage logiciel : recentre l'image sur le tube détecté.
Utilisé quand action == "crop".
"""
if not detection["detected"]:
return frame
tx = detection["tube_cx"]
ty = detection["tube_cy"]
tr = detection["tube_radius"]
h, w = frame.shape[:2]
# Fenêtre carrée autour du centre du tube
half = tr
x1 = max(tx - half, 0)
y1 = max(ty - half, 0)
x2 = min(tx + half, w)
y2 = min(ty + half, h)
cropped = frame[y1:y2, x1:x2]
# Redimensionne à la taille originale pour ne pas changer le pipeline
return cv2.resize(cropped, (w, h), interpolation=cv2.INTER_LINEAR)
def _detect_center_stable(
self,
capture_func, # callable() → frame bytes
n_samples: int = 5,
delay_s: float = 0.3,
) -> tuple[float, float] | None:
"""
Capture N frames et retourne le centre moyen du tube.
Réduit l'erreur de détection d'un facteur √N.
:param capture_func: callable sans argument → bytes JPEG
:param n_samples: nombre de captures à moyenner
:param delay_s: pause entre chaque capture
:return: (cx_mean, cy_mean) ou None si échec
"""
import time
centers = []
for i in range(n_samples):
if i > 0:
time.sleep(delay_s)
frame_bytes = capture_func()
nparr = np.frombuffer(frame_bytes, np.uint8)
frame = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
if frame is None:
continue
detection = self.detect_tube(frame)
if detection["detected"]:
centers.append((detection["tube_cx"], detection["tube_cy"]))
logger.debug(
"_detect_center_stable [%d/%d] : cx=%d cy=%d",
i+1, n_samples,
detection["tube_cx"], detection["tube_cy"],
)
else:
logger.warning("_detect_center_stable [%d/%d] : tube non détecté", i+1, n_samples)
if len(centers) < 3:
logger.error("_detect_center_stable : seulement %d détections valides", len(centers))
return None
# Filtre les valeurs aberrantes (écart > 2 sigma)
cx_arr = np.array([c[0] for c in centers], dtype=float)
cy_arr = np.array([c[1] for c in centers], dtype=float)
cx_mean, cx_std = np.mean(cx_arr), np.std(cx_arr)
cy_mean, cy_std = np.mean(cy_arr), np.std(cy_arr)
mask = (
(np.abs(cx_arr - cx_mean) <= 2 * cx_std) &
(np.abs(cy_arr - cy_mean) <= 2 * cy_std)
)
filtered = [(cx_arr[i], cy_arr[i]) for i in range(len(centers)) if mask[i]]
if not filtered:
filtered = centers # fallback si tout est filtré
cx_final = float(np.mean([c[0] for c in filtered]))
cy_final = float(np.mean([c[1] for c in filtered]))
logger.info(
"_detect_center_stable : %d/%d valides cx=%.1f±%.1f cy=%.1f±%.1f",
len(filtered), n_samples,
cx_final, cx_std, cy_final, cy_std,
)
return cx_final, cy_final
def calib_reset(self):
pass
# ------------------------------------------------------------------ #
# Dessin debug
# ------------------------------------------------------------------ #
def _draw_debug(
self, frame, cx_img, cy_img,
tx, ty, tr,
offset_x_px, offset_y_px,
offset_x_mm, offset_y_mm,
dist_px, action, votes: int = 3
) -> np.ndarray:
# Couleur selon action
color = {
"none" : (0, 255, 0), # vert — centré
"crop" : (0, 200, 255), # orange — recadrage
"grbl" : (0, 0, 255), # rouge — correction CNC
}.get(action, (200, 200, 200))
# Cercle intérieur du tube
cv2.circle(frame, (tx, ty), tr, color, 2, cv2.LINE_AA)
# Rayon de zone morte (dead zone) en vert clair
cv2.circle(frame, (cx_img, cy_img), self.dead_zone_px,
(0, 255, 100), 1, cv2.LINE_AA)
# Rayon seuil GRBL en rouge pointillé (simulé par cercle fin)
cv2.circle(frame, (cx_img, cy_img), self.grbl_threshold_px,
(0, 80, 255), 1, cv2.LINE_AA)
# Croix centre image
cv2.drawMarker(frame, (cx_img, cy_img),
(255, 255, 255), cv2.MARKER_CROSS, 24, 1, cv2.LINE_AA)
# Centre tube
cv2.circle(frame, (tx, ty), 5, color, -1, cv2.LINE_AA)
# Vecteur offset centre image → centre tube
if dist_px > self.dead_zone_px:
cv2.arrowedLine(frame, (cx_img, cy_img), (tx, ty),
color, 2, cv2.LINE_AA, tipLength=0.2)
# Panneau info — fond semi-transparent
overlay = frame.copy()
cv2.rectangle(overlay, (8, 8), (400, 130), (0, 0, 0), -1)
cv2.addWeighted(overlay, 0.45, frame, 0.55, 0, frame)
lines = [
(f"Tube cx={tx} cy={ty} r={tr}px", (0, 255, 180)),
(f"Offset dx={offset_x_px:+d}px dy={offset_y_px:+d}px", color),
(f"Offset dx={offset_x_mm:+.3f}mm dy={offset_y_mm:+.3f}mm", color),
(f"Dist={dist_px:.1f}px action={action.upper()}", color),
(f"px/mm={self.px_per_mm:.4f} votes={votes}/3", (180, 180, 180)), # ← votes
]
for i, (text, col) in enumerate(lines):
cv2.putText(frame, text, (14, 30 + i * 20),
cv2.FONT_HERSHEY_SIMPLEX, 0.48, col, 1, cv2.LINE_AA)
# Légende zones
cv2.putText(frame, "dead zone", (cx_img + self.dead_zone_px + 3, cy_img - 3),
cv2.FONT_HERSHEY_SIMPLEX, 0.35, (0, 255, 100), 1)
cv2.putText(frame, "GRBL threshold", (cx_img + self.grbl_threshold_px + 3, cy_img + 6),
cv2.FONT_HERSHEY_SIMPLEX, 0.35, (0, 80, 255), 1)
return frame
def _draw_debug_no_detection(self, frame, cx_img, cy_img) -> np.ndarray:
cv2.drawMarker(frame, (cx_img, cy_img),
(255, 255, 255), cv2.MARKER_CROSS, 24, 1, cv2.LINE_AA)
cv2.putText(frame, "Tube non detecte", (14, 30),
cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 0, 255), 2, cv2.LINE_AA)
return frame
@@ -0,0 +1,230 @@
'''
Created on 17 avr. 2026
@author: denis
'''
# modules/tube_aligner.py
import cv2
import logging
import numpy as np
logger = logging.getLogger(__name__)
class TubeAligner:
"""
Détecte le cercle du tube à essai dans une frame (vue par dessous,
éclairage par dessus → cercle clair sur fond sombre).
Calcule le décalage entre le centre du tube et le centre de l'image.
Décide d'une correction GRBL (grand écart) ou d'un recadrage (petit écart).
"""
# Seuil en pixels : au-delà → correction GRBL, en-dessous → recadrage
GRBL_THRESHOLD_PX = 20
# Tolérance : en-dessous → pas de correction nécessaire
DEAD_ZONE_PX = 5
def __init__(
self,
px_per_mm: float = 10.0, # facteur d'échelle calibration (px/mm)
grbl_threshold_px: int = 20,
dead_zone_px: int = 5,
debug: bool = False, # ← activable depuis la vue
):
self.px_per_mm = px_per_mm
self.grbl_threshold_px = grbl_threshold_px
self.dead_zone_px = dead_zone_px
def detect_tube(self, frame: np.ndarray) -> dict:
"""
Détecte le cercle du tube et calcule le décalage par rapport au centre image.
:param frame: Frame BGR (numpy array)
:return: dict avec cercle détecté, décalage px et mm, action recommandée
"""
h, w = frame.shape[:2]
cx_img = w // 2
cy_img = h // 2
result = {
"detected" : False,
"tube_cx" : None,
"tube_cy" : None,
"tube_radius" : None,
"offset_x_px" : None,
"offset_y_px" : None,
"offset_x_mm" : None,
"offset_y_mm" : None,
"action" : "none", # "none" | "crop" | "grbl"
"grbl_gcode" : None,
"frame_annotated": None,
}
# Prétraitement : niveaux de gris + flou
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
#blurred = cv2.GaussianBlur(gray, (9, 9), 2)
blurred = cv2.GaussianBlur(gray, (15, 15), 3)
# param1 : seuil Canny haut, param2 : seuil accumulation (plus bas = plus permissif)
min_radius = int(min(w, h) * 0.26) # ~260px sur 1000px
max_radius = int(min(w, h) * 0.36) # ~360px sur 1000px — bord intérieur du verre
circles = cv2.HoughCircles(
blurred,
cv2.HOUGH_GRADIENT,
dp = 1.2,
minDist = min(w, h) // 2, # un seul tube attendu
param1 = 50,
param2 = 30,
minRadius = min_radius,
maxRadius = max_radius,
)
if circles is None:
logger.warning("TubeAligner: aucun cercle détecté")
result["frame_annotated"] = self._annotate(frame.copy(), cx_img, cy_img, None)
return result
circles = np.round(circles[0, :]).astype(int)
# Prend le cercle le plus proche du centre image
best = min(
circles,
key=lambda c: np.sqrt((c[0] - cx_img)**2 + (c[1] - cy_img)**2)
)
tx, ty, tr = int(best[0]), int(best[1]), int(best[2])
# Décalage : positif = tube à droite/bas du centre image
offset_x_px = tx - cx_img
offset_y_px = ty - cy_img
offset_x_mm = offset_x_px / self.px_per_mm
offset_y_mm = offset_y_px / self.px_per_mm
dist_px = np.sqrt(offset_x_px**2 + offset_y_px**2)
# Décision d'action
if dist_px <= self.dead_zone_px:
action = "none"
grbl_gcode = None
elif dist_px <= self.grbl_threshold_px:
action = "crop"
grbl_gcode = None
else:
action = "grbl"
# G91 = coordonnées relatives, G0 = déplacement rapide
# Inversion du signe : si tube est à droite (+X image),
# la CNC doit reculer (-X GRBL) pour recentrer
#cmd = f"G53 G1 X{x:.2f} Y{y:.2f} F{feed}"
grbl_gcode = (
f"G91\n"
f"G1 X{-offset_x_mm:.3f} Y{-offset_y_mm:.3f}\n"
f"G90"
)
logger.info(
"TubeAligner: décalage %.1fpx (%.2fmm, %.2fmm) → GRBL: %s",
dist_px, offset_x_mm, offset_y_mm, grbl_gcode.replace('\n', ' | ')
)
result.update({
"detected" : True,
"tube_cx" : tx,
"tube_cy" : ty,
"tube_radius" : tr,
"offset_x_px" : offset_x_px,
"offset_y_px" : offset_y_px,
"offset_x_mm" : round(offset_x_mm, 3),
"offset_y_mm" : round(offset_y_mm, 3),
"action" : action,
"grbl_gcode" : None,
"frame_annotated": self._annotate(
frame.copy(), cx_img, cy_img, (tx, ty, tr), offset_x_px, offset_y_px
),
})
return result
def crop_to_tube(self, frame: np.ndarray, detection: dict) -> np.ndarray:
"""
Recadrage logiciel : recentre l'image sur le tube détecté.
Utilisé quand action == "crop".
"""
if not detection["detected"]:
return frame
tx = detection["tube_cx"]
ty = detection["tube_cy"]
tr = detection["tube_radius"]
h, w = frame.shape[:2]
# Fenêtre carrée autour du centre du tube
half = tr
x1 = max(tx - half, 0)
y1 = max(ty - half, 0)
x2 = min(tx + half, w)
y2 = min(ty + half, h)
cropped = frame[y1:y2, x1:x2]
# Redimensionne à la taille originale pour ne pas changer le pipeline
return cv2.resize(cropped, (w, h), interpolation=cv2.INTER_LINEAR)
def _annotate(
self,
frame: np.ndarray,
cx_img: int,
cy_img: int,
circle: tuple | None,
offset_x: int = 0,
offset_y: int = 0,
) -> np.ndarray:
"""
Dessine le cercle détecté, le centre image et le vecteur de décalage.
"""
# Croix centre image
cv2.drawMarker(
frame, (cx_img, cy_img),
(0, 255, 0), cv2.MARKER_CROSS, 20, 1, cv2.LINE_AA
)
if circle is None:
cv2.putText(frame, "Tube non detecte", (10, 30),
cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 0, 255), 1, cv2.LINE_AA)
return frame
tx, ty, tr = circle
# Cercle du tube en cyan
cv2.circle(frame, (tx, ty), tr, (255, 255, 0), 2, cv2.LINE_AA)
# Centre du tube en rouge
cv2.circle(frame, (tx, ty), 4, (0, 0, 255), -1, cv2.LINE_AA)
# Vecteur décalage (centre image → centre tube)
if abs(offset_x) > 2 or abs(offset_y) > 2:
cv2.arrowedLine(
frame,
(cx_img, cy_img),
(tx, ty),
(0, 100, 255), 2, cv2.LINE_AA, tipLength=0.2
)
# Texte décalage
dist = np.sqrt(offset_x**2 + offset_y**2)
cv2.putText(
frame,
f"dx={offset_x:+d}px dy={offset_y:+d}px dist={dist:.1f}px",
(10, 28),
cv2.FONT_HERSHEY_SIMPLEX, 0.45, (255, 255, 255), 1, cv2.LINE_AA,
)
cv2.putText(
frame,
f"r={tr}px",
(10, 48),
cv2.FONT_HERSHEY_SIMPLEX, 0.45, (200, 200, 200), 1, cv2.LINE_AA,
)
return frame
@@ -0,0 +1,283 @@
'''
Created on 17 avr. 2026
@author: denis
'''
# modules/tube_aligner.py
import cv2
import logging
import numpy as np
logger = logging.getLogger(__name__)
class TubeAligner:
GRBL_THRESHOLD_PX = 20
DEAD_ZONE_PX = 5
def __init__(
self,
px_per_mm : float = 10.0,
grbl_threshold_px : int = 20,
dead_zone_px : int = 5,
debug : bool = False, # ← activable depuis la vue
):
self.px_per_mm = px_per_mm
self.grbl_threshold_px = grbl_threshold_px
self.dead_zone_px = dead_zone_px
self.debug = debug
# Etat calibration
self._calib_step = 0 # 0=idle 1=point A enregistré
self._calib_pos_A_px = None # centre tube point A en px
self._calib_mpos_A = None # position CNC point A en mm
# ------------------------------------------------------------------ #
# Détection principale
# ------------------------------------------------------------------ #
def detect_tube(self, frame: np.ndarray) -> dict:
h, w = frame.shape[:2]
cx_img = w // 2
cy_img = h // 2
result = {
"detected" : False,
"tube_cx" : None,
"tube_cy" : None,
"tube_radius" : None,
"offset_x_px" : 0,
"offset_y_px" : 0,
"offset_x_mm" : 0.0,
"offset_y_mm" : 0.0,
"action" : "none",
"frame_annotated": None,
}
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
blurred = cv2.GaussianBlur(gray, (15, 15), 3)
circles = cv2.HoughCircles(
blurred,
cv2.HOUGH_GRADIENT,
dp = 1.2,
minDist = min(w, h) // 2,
param1 = 50,
param2 = 30,
minRadius = int(min(w, h) * 0.26),
maxRadius = int(min(w, h) * 0.36),
)
frame_out = frame.copy()
if circles is None:
logger.warning("TubeAligner: aucun cercle détecté")
if self.debug:
frame_out = self._draw_debug_no_detection(frame_out, cx_img, cy_img)
result["frame_annotated"] = frame_out
return result
circles = np.round(circles[0, :]).astype(int)
best = min(circles, key=lambda c: np.sqrt((c[0]-cx_img)**2 + (c[1]-cy_img)**2))
tx, ty, tr = int(best[0]), int(best[1]), int(best[2])
offset_x_px = tx - cx_img
offset_y_px = ty - cy_img
offset_x_mm = offset_x_px / self.px_per_mm
offset_y_mm = offset_y_px / self.px_per_mm
dist_px = np.sqrt(offset_x_px**2 + offset_y_px**2)
if dist_px <= self.dead_zone_px:
action = "none"
elif dist_px <= self.grbl_threshold_px:
action = "crop"
else:
action = "grbl"
if self.debug:
frame_out = self._draw_debug(
frame_out, cx_img, cy_img,
tx, ty, tr,
offset_x_px, offset_y_px,
offset_x_mm, offset_y_mm,
dist_px, action,
)
result.update({
"detected" : True,
"tube_cx" : tx,
"tube_cy" : ty,
"tube_radius" : tr,
"offset_x_px" : offset_x_px,
"offset_y_px" : offset_y_px,
"offset_x_mm" : round(offset_x_mm, 3),
"offset_y_mm" : round(offset_y_mm, 3),
"action" : action,
"frame_annotated": frame_out,
})
return result
# ------------------------------------------------------------------ #
# Calibration px/mm — 2 points
# ------------------------------------------------------------------ #
def calib_record_point_A(self, detection: dict, mpos: tuple) -> bool:
"""
Enregistre le point A (position CNC + centre tube en px).
Appeler quand la CNC est immobile sur le point A.
:param detection: résultat de detect_tube()
:param mpos: (x_mm, y_mm) retourné par cnc.get_mpos()
:return: True si enregistré
"""
if not detection["detected"]:
logger.warning("calib_record_point_A: tube non détecté")
return False
self._calib_pos_A_px = (detection["tube_cx"], detection["tube_cy"])
self._calib_mpos_A = mpos
self._calib_step = 1
logger.info("Calibration point A : px=%s mpos=%s", self._calib_pos_A_px, mpos)
return True
def calib_record_point_B(self, detection: dict, mpos: tuple) -> dict | None:
"""
Enregistre le point B et calcule px_per_mm.
Appeler après déplacement CNC manuel d'une distance connue.
:param detection: résultat de detect_tube()
:param mpos: (x_mm, y_mm) retourné par cnc.get_mpos()
:return: dict résultat calibration ou None si échec
"""
if self._calib_step != 1:
logger.warning("calib_record_point_B: point A non enregistré")
return None
if not detection["detected"]:
logger.warning("calib_record_point_B: tube non détecté")
return None
pos_B_px = (detection["tube_cx"], detection["tube_cy"])
mpos_B = mpos
# Déplacement en px
dpx = np.sqrt(
(pos_B_px[0] - self._calib_pos_A_px[0])**2 +
(pos_B_px[1] - self._calib_pos_A_px[1])**2
)
# Déplacement en mm (distance euclidienne CNC)
dmm = np.sqrt(
(mpos_B[0] - self._calib_mpos_A[0])**2 +
(mpos_B[1] - self._calib_mpos_A[1])**2
)
if dmm < 0.1 or dpx < 2:
logger.warning("Déplacement trop faible : dpx=%.1f dmm=%.3f", dpx, dmm)
return None
px_per_mm_new = dpx / dmm
self.px_per_mm = px_per_mm_new
self._calib_step = 0
result = {
"px_per_mm" : round(px_per_mm_new, 4),
"mm_per_px" : round(dmm / dpx, 6),
"delta_px" : round(dpx, 2),
"delta_mm" : round(dmm, 3),
"point_A_px" : self._calib_pos_A_px,
"point_B_px" : pos_B_px,
"mpos_A" : self._calib_mpos_A,
"mpos_B" : mpos_B,
}
logger.info("Calibration OK : %.4f px/mm (%.6f mm/px)", px_per_mm_new, dmm/dpx)
return result
def calib_reset(self):
self._calib_step = 0
self._calib_pos_A_px = None
self._calib_mpos_A = None
# ------------------------------------------------------------------ #
# Dessin debug
# ------------------------------------------------------------------ #
def _draw_debug(
self, frame, cx_img, cy_img,
tx, ty, tr,
offset_x_px, offset_y_px,
offset_x_mm, offset_y_mm,
dist_px, action,
) -> np.ndarray:
# Couleur selon action
color = {
"none" : (0, 255, 0), # vert — centré
"crop" : (0, 200, 255), # orange — recadrage
"grbl" : (0, 0, 255), # rouge — correction CNC
}.get(action, (200, 200, 200))
# Cercle intérieur du tube
cv2.circle(frame, (tx, ty), tr, color, 2, cv2.LINE_AA)
# Rayon de zone morte (dead zone) en vert clair
cv2.circle(frame, (cx_img, cy_img), self.dead_zone_px,
(0, 255, 100), 1, cv2.LINE_AA)
# Rayon seuil GRBL en rouge pointillé (simulé par cercle fin)
cv2.circle(frame, (cx_img, cy_img), self.grbl_threshold_px,
(0, 80, 255), 1, cv2.LINE_AA)
# Croix centre image
cv2.drawMarker(frame, (cx_img, cy_img),
(255, 255, 255), cv2.MARKER_CROSS, 24, 1, cv2.LINE_AA)
# Centre tube
cv2.circle(frame, (tx, ty), 5, color, -1, cv2.LINE_AA)
# Vecteur offset centre image → centre tube
if dist_px > self.dead_zone_px:
cv2.arrowedLine(frame, (cx_img, cy_img), (tx, ty),
color, 2, cv2.LINE_AA, tipLength=0.2)
# Panneau info — fond semi-transparent
overlay = frame.copy()
cv2.rectangle(overlay, (8, 8), (400, 130), (0, 0, 0), -1)
cv2.addWeighted(overlay, 0.45, frame, 0.55, 0, frame)
lines = [
(f"Tube cx={tx} cy={ty} r={tr}px", (0, 255, 180)),
(f"Offset dx={offset_x_px:+d}px dy={offset_y_px:+d}px", color),
(f"Offset dx={offset_x_mm:+.3f}mm dy={offset_y_mm:+.3f}mm", color),
(f"Dist={dist_px:.1f}px action={action.upper()}", color),
(f"px/mm={self.px_per_mm:.4f}", (180, 180, 180)),
]
for i, (text, col) in enumerate(lines):
cv2.putText(frame, text, (14, 30 + i * 20),
cv2.FONT_HERSHEY_SIMPLEX, 0.48, col, 1, cv2.LINE_AA)
# Légende zones
cv2.putText(frame, "dead zone", (cx_img + self.dead_zone_px + 3, cy_img - 3),
cv2.FONT_HERSHEY_SIMPLEX, 0.35, (0, 255, 100), 1)
cv2.putText(frame, "GRBL threshold", (cx_img + self.grbl_threshold_px + 3, cy_img - 3),
cv2.FONT_HERSHEY_SIMPLEX, 0.35, (0, 80, 255), 1)
# Indicateur calibration en cours
if self._calib_step == 1:
cv2.putText(frame, "CALIB — En attente point B",
(14, frame.shape[0] - 14),
cv2.FONT_HERSHEY_SIMPLEX, 0.55, (0, 200, 255), 2, cv2.LINE_AA)
return frame
def _draw_debug_no_detection(self, frame, cx_img, cy_img) -> np.ndarray:
cv2.drawMarker(frame, (cx_img, cy_img),
(255, 255, 255), cv2.MARKER_CROSS, 24, 1, cv2.LINE_AA)
cv2.putText(frame, "Tube non detecte", (14, 30),
cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 0, 255), 2, cv2.LINE_AA)
return frame
@@ -38,7 +38,10 @@ class VideoFileCapture(VideoCaptureInterface):
jpeg_quality: int = 85, jpeg_quality: int = 85,
width: Optional[int] = None, width: Optional[int] = None,
height: Optional[int] = None, height: Optional[int] = None,
video_lists = [] video_lists = [],
use_tracking: bool = False,
px_per_mm: float = 2.1,
display = None,
): ):
""" """
:param video_file: fichier video :param video_file: fichier video
@@ -47,12 +50,13 @@ class VideoFileCapture(VideoCaptureInterface):
:param width: Largeur souhaitée (None = valeur par défaut du pilote) :param width: Largeur souhaitée (None = valeur par défaut du pilote)
:param height: Hauteur souhaitée (None = valeur par défaut du pilote) :param height: Hauteur souhaitée (None = valeur par défaut du pilote)
""" """
super().__init__(fps=fps) super().__init__(fps=fps, use_tracking=use_tracking, px_per_mm=px_per_mm, display=display)
self._video_file: str = video_file self._video_file: str = video_file
self._jpeg_quality: int = jpeg_quality self._jpeg_quality: int = jpeg_quality
self._width: Optional[int] = width self._width: Optional[int] = width
self._height: Optional[int] = height self._height: Optional[int] = height
self._video_lists = video_lists self._video_lists = video_lists
self.ptf = 0 self.ptf = 0
self._cap = None # Instance cv2.VideoCapture self._cap = None # Instance cv2.VideoCapture
+4 -1
View File
@@ -38,6 +38,9 @@ class WebcamCapture(VideoCaptureInterface):
jpeg_quality: int = 85, jpeg_quality: int = 85,
width: Optional[int] = None, width: Optional[int] = None,
height: Optional[int] = None, height: Optional[int] = None,
use_tracking: bool = False,
px_per_mm: float = 2.1,
display = None,
): ):
""" """
:param device_index: Index du périphérique V4L2 (0 = première webcam) :param device_index: Index du périphérique V4L2 (0 = première webcam)
@@ -46,7 +49,7 @@ class WebcamCapture(VideoCaptureInterface):
:param width: Largeur souhaitée (None = valeur par défaut du pilote) :param width: Largeur souhaitée (None = valeur par défaut du pilote)
:param height: Hauteur souhaitée (None = valeur par défaut du pilote) :param height: Hauteur souhaitée (None = valeur par défaut du pilote)
""" """
super().__init__(fps=fps) super().__init__(fps=fps, use_tracking=use_tracking, px_per_mm=px_per_mm, display=display)
self._device_index: int = device_index self._device_index: int = device_index
self._jpeg_quality: int = jpeg_quality self._jpeg_quality: int = jpeg_quality
self._width: Optional[int] = width self._width: Optional[int] = width
+9 -3
View File
@@ -7,11 +7,16 @@ class WellAdmin(admin.ModelAdmin):
list_display = ('name', 'author',) list_display = ('name', 'author',)
class ConfigurationAdmin(admin.ModelAdmin): class ConfigurationAdmin(admin.ModelAdmin):
list_display = ('name', 'author', 'use_rpicam', 'video_width_capture', 'video_height_capture', 'video_frame_rate', 'active',) list_display = ('name', 'author', 'use_rpicam', 'video_width_capture', 'video_height_capture', 'video_frame_rate', 'px_per_mm', 'active',)
class MultiWellAdmin(admin.ModelAdmin): class MultiWellAdmin(admin.ModelAdmin):
list_filter = ('author',) list_filter = ('author', )
list_display = ('label', 'position', 'author', 'order', 'xbase', 'ybase', 'duration', 'feed', 'active',) list_display = ('label', 'position', 'author', 'order', 'xbase', 'ybase', 'duration', 'feed', 'default', 'well_position', 'active',)
class WellPositionAdmin(admin.ModelAdmin):
list_filter = ('author', 'multiwell')
list_display = ('multiwell__position', 'well__name', 'order', 'x', 'y', 'author',)
class ObservationMultiWellDetailInline(admin.TabularInline): class ObservationMultiWellDetailInline(admin.TabularInline):
model = models.ObservationMultiWellDetail model = models.ObservationMultiWellDetail
@@ -60,5 +65,6 @@ class SessionAdmin(admin.ModelAdmin):
admin.site.register(models.Configuration, ConfigurationAdmin) admin.site.register(models.Configuration, ConfigurationAdmin)
admin.site.register(models.Well, WellAdmin) admin.site.register(models.Well, WellAdmin)
admin.site.register(models.MultiWell, MultiWellAdmin) admin.site.register(models.MultiWell, MultiWellAdmin)
admin.site.register(models.WellPostion, WellPositionAdmin)
admin.site.register(models.Observation, ObservationAdmin) admin.site.register(models.Observation, ObservationAdmin)
admin.site.register(models.Session, SessionAdmin) admin.site.register(models.Session, SessionAdmin)
+62 -2
View File
@@ -44,6 +44,8 @@ class Configuration(models.Model):
# Grbl configuration # Grbl configuration
grbl_xmax = models.FloatField(_("Grbl Xmax"), help_text=_("CNC Grbl Xmax en mm"), blank=False, default=350.0) grbl_xmax = models.FloatField(_("Grbl Xmax"), help_text=_("CNC Grbl Xmax en mm"), blank=False, default=350.0)
grbl_ymax = models.FloatField(_("Grbl Ymax"), help_text=_("CNC Grbl Ymax en mm"), blank=False, default=250.0) grbl_ymax = models.FloatField(_("Grbl Ymax"), help_text=_("CNC Grbl Ymax en mm"), blank=False, default=250.0)
px_per_mm = models.FloatField(_("Pixel / mm"), help_text=_('Rapport pixel / déplacement en pixel/mm'), blank=False, default=2.5)
# camera configuration # camera configuration
use_rpicam = models.BooleanField(_("Utiliser rpicam"), help_text=_("Par défaaut. Sinon USB webcam"), default=True) use_rpicam = models.BooleanField(_("Utiliser rpicam"), help_text=_("Par défaaut. Sinon USB webcam"), default=True)
webcam_device_index = models.PositiveSmallIntegerField(_("Index de la webcam"), help_text=_("Index de la webcam (0, 1, ...) si présente"), default=2) webcam_device_index = models.PositiveSmallIntegerField(_("Index de la webcam"), help_text=_("Index de la webcam (0, 1, ...) si présente"), default=2)
@@ -57,7 +59,7 @@ class Configuration(models.Model):
calibration_default_multiwell = models.CharField(_("Multi-puits de calibration par défaut"), help_text=_("Position du multi-puits de calibration par défaut"), max_length=8, choices=MULTIWELL_POSITION, default='HG') calibration_default_multiwell = models.CharField(_("Multi-puits de calibration par défaut"), help_text=_("Position du multi-puits de calibration par défaut"), max_length=8, choices=MULTIWELL_POSITION, default='HG')
calibration_default_feed = models.PositiveIntegerField(_("Vitesse de calibration"), help_text=_("Vitesse de déplacement pour la calibration en mm/mn"), default=1000) calibration_default_feed = models.PositiveIntegerField(_("Vitesse de calibration"), help_text=_("Vitesse de déplacement pour la calibration en mm/mn"), default=1000)
calibration_default_step = models.FloatField(_("Pas de calibration"), help_text=_("Pas de déplacement pour la calibration en mm"), default=1.0) calibration_default_step = models.FloatField(_("Pas de calibration"), help_text=_("Pas de déplacement pour la calibration en mm"), default=1.0)
calibration_default_duration = models.FloatField(_("Duruée calibration"), help_text=_("Durée de pose entre chaque puits en s"), default=3.0)
active = models.BooleanField(_("Actif"), default=False) active = models.BooleanField(_("Actif"), default=False)
class Meta: class Meta:
@@ -80,15 +82,19 @@ class Well(models.Model):
def __str__(self): def __str__(self):
return f'{self.name}' return f'{self.name}'
class MultiWell(models.Model): class MultiWell(models.Model):
label = models.CharField(_("Label"), help_text=_("Label du multi-puit"), max_length=100, null=True, blank=True) label = models.CharField(_("Label"), help_text=_("Label du multi-puit"), max_length=100, null=True, blank=True)
author = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name="Auteur", null=True, blank=True) author = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name="Auteur", null=True, blank=True)
position = models.CharField(_("Position"), help_text=_('Position du multi-puits sur la table'), unique=True, max_length=8, choices=MULTIWELL_POSITION, null=True, blank=False) position = models.CharField(_("Position"), help_text=_('Position du multi-puits sur la table'), unique=True, max_length=8, choices=MULTIWELL_POSITION, null=True, blank=False)
default = models.BooleanField(_("Par défaut"), help_text=_('Multi-puit par défaut'), default=False)
cols = models.PositiveSmallIntegerField(_("Colonnes"), help_text=_('Nombre de colonnes'), blank=False, default=6) cols = models.PositiveSmallIntegerField(_("Colonnes"), help_text=_('Nombre de colonnes'), blank=False, default=6)
rows = models.PositiveSmallIntegerField(_("Lignes"), help_text=_('Nombre de lignes'), blank=False, default=4) rows = models.PositiveSmallIntegerField(_("Lignes"), help_text=_('Nombre de lignes'), blank=False, default=4)
diameter = models.FloatField(_("Diamètre"), help_text=_('Diamètre des tubes en mm'), blank=False, default=16.0)
row_def = models.CharField(_("Définition"), help_text=_('Définition des lignes'), max_length=16, null=True, blank=False, default="A,B,C,D") row_def = models.CharField(_("Définition"), help_text=_('Définition des lignes'), max_length=16, null=True, blank=False, default="A,B,C,D")
row_order = models.CharField(_("Ordre"), help_text=_('Ordre de lecture en serpentin'), max_length=16, null=True, blank=False, default="D,C,B,A") row_order = models.CharField(_("Ordre ligne"), help_text=_('Ordre ligne de puit. Lecture en serpentin dans le sens des +- X'), max_length=16, null=True, blank=False, default="D,C,B,A")
order = models.PositiveSmallIntegerField(_("Ordre"), help_text=_('Ordre de lecture du multi-puit'), blank=False, default=0) order = models.PositiveSmallIntegerField(_("Ordre"), help_text=_('Ordre de lecture du multi-puit'), blank=False, default=0)
duration = models.PositiveIntegerField(_("Durée"), help_text=_('Durée du film en secondes'), blank=False, default=120) duration = models.PositiveIntegerField(_("Durée"), help_text=_('Durée du film en secondes'), blank=False, default=120)
@@ -98,10 +104,14 @@ class MultiWell(models.Model):
dx = models.FloatField(_("Pas X"), help_text=_('Pas ou interval sur X en mm'), blank=False, default=19.5) dx = models.FloatField(_("Pas X"), help_text=_('Pas ou interval sur X en mm'), blank=False, default=19.5)
dy = models.FloatField(_("Pas Y"), help_text=_('Pas ou interval sur Y en mm'), blank=False, default=19.5) dy = models.FloatField(_("Pas Y"), help_text=_('Pas ou interval sur Y en mm'), blank=False, default=19.5)
feed = models.PositiveIntegerField(_("Vitesse"), help_text=_('Vitesse déplacement en mm/mn '), blank=False, default=1000) feed = models.PositiveIntegerField(_("Vitesse"), help_text=_('Vitesse déplacement en mm/mn '), blank=False, default=1000)
well_position = models.BooleanField(_("Positions"), help_text=_('Positions des puits générées ?'), default=False)
active = models.BooleanField(_("Active"), default=True) active = models.BooleanField(_("Active"), default=True)
def config(self): def config(self):
return dict( return dict(
position=self.position,
cols=self.cols, cols=self.cols,
rows=self.rows, rows=self.rows,
row_def=self.row_def, row_def=self.row_def,
@@ -139,6 +149,56 @@ class MultiWell(models.Model):
def __str__(self): def __str__(self):
return f'{self.position}: {self.label}' return f'{self.position}: {self.label}'
class WellPostion(models.Model):
author = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name="Auteur", null=True, blank=True)
well = models.ForeignKey(Well, verbose_name=_("Puit"), on_delete=models.SET_NULL, null=True, blank=True)
multiwell = models.ForeignKey(MultiWell, verbose_name=_("Multi-puits"), on_delete=models.SET_NULL, null=True, blank=True)
order = models.PositiveSmallIntegerField(_("Ordre"), help_text=_('Ordre de lecture du puit'), blank=False, default=0)
x = models.FloatField(_("X"), help_text=_('Axe X en mm'), blank=False, default=10.0)
y = models.FloatField(_("Y"), help_text=_('Axe Y en mm'), blank=False, default=10.0)
class Meta:
ordering = ['order']
unique_together = ["multiwell", "well"]
verbose_name = _("Position d'un puit")
verbose_name_plural = _("Position des puits")
def __str__(self):
return f'{self.multiwell.position}: {self.well.name}'
@receiver(post_save, sender=MultiWell)
def create_well_position(sender, instance, created, **kwargs):
if not instance.well_position:
row_order = instance.row_order.split(',')
n = 0
for row in range(instance.rows):
if row % 2 == 0:
cols = range(instance.cols)
else:
cols = range(instance.cols - 1, -1, -1)
for col in cols:
x = instance.xbase + col * instance.dx
y = instance.ybase + row * instance.dy
try:
name = f'{row_order[row]}{col+1}'
well = Well.objects.get(name__exact=name)
WellPostion.objects.update_or_create(
multiwell=instance,
well=well,
author=instance.author,
defaults={'order': n, 'x': round(x, 4), 'y': round(y, 4)}
)
n += 1
except:
pass
instance.well_position=True
instance.save()
class Observation(models.Model): class Observation(models.Model):
title = models.CharField(_("Titre de l'observation"), max_length=100, null=True, blank=False) title = models.CharField(_("Titre de l'observation"), max_length=100, null=True, blank=False)
comment = models.TextField(_("Commentaires"), help_text=_("Descriptions de l'observations"), null=True, blank=True) comment = models.TextField(_("Commentaires"), help_text=_("Descriptions de l'observations"), null=True, blank=True)
+321
View File
@@ -0,0 +1,321 @@
'''
Created on 20 avr. 2026
@author: denis
'''
import logging
import time
from django.utils.translation import gettext_lazy as _
from threading import Thread, Event
from django.utils import timezone
from django.utils.html import mark_safe
from modules import grbl
from . import models
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
class WellIterator:
"""Itérateur personnalisé pour naviguer dans les Wells"""
def __init__(self, wells_queryset):
self.wells = list(wells_queryset) # Convertir en liste
self.current_index = -1
self.total_count = len(self.wells)
def __iter__(self):
"""Permet d'utiliser l'itérateur dans une boucle for"""
return self
def __next__(self):
"""Retourne l'élément suivant"""
self.current_index += 1
if self.current_index >= self.total_count:
raise StopIteration
return self.wells[self.current_index]
def next(self):
"""Méthode next() pour avancer manuellement"""
if self.current_index + 1 < self.total_count:
self.current_index += 1
return self.wells[self.current_index]
raise StopIteration("Fin de la liste atteinte")
def previous(self):
"""Méthode previous() pour revenir en arrière"""
if self.current_index > 0:
self.current_index -= 1
return self.wells[self.current_index]
raise StopIteration("Début de la liste atteint")
def seek(self, index):
"""Méthode seek() pour sauter à un index spécifique"""
if 0 <= index < self.total_count:
self.current_index = index
return self.wells[index]
raise IndexError(f"Index {index} hors limites (0-{self.total_count - 1})")
def get_current(self):
"""Retourne l'élément courant"""
if -1 < self.current_index < self.total_count:
return self.wells[self.current_index]
return None
def reset(self):
"""Réinitialise l'itérateur au début"""
self.current_index = -1
class MultiWellManager:
def __init__(self, process):
self.process = process
self.cnc_controller = process.grbl
self.stop_playing = Event()
self.well_iterator = None
self.scanner = None
self.multiwel = None
self.set_default_values()
self.set_multiwell()
def set_default_values(self, feed=None, step=None, duration=None):
self._feed = feed or self.process.conf.calibration_default_feed
self._step = step or self.process.conf.calibration_default_step
self._duration = duration or self.process.conf.calibration_default_duration
def set_multiwell(self, position=None):
if position is None:
self.multiwell = models.MultiWell.objects.filter(default=True).first()
else:
self.multiwell = models.MultiWell.by_position(position)
wells = models.WellPostion.objects.filter(multiwell_id=self.multiwell.id).order_by('order').all()
self.well_iterator = WellIterator(wells)
self.position = self.multiwell.position
self._xbase = self.multiwell.xbase
self._ybase = self.multiwell.ybase
self._dx = self.multiwell.dx
self._dy = self.multiwell.dy
return self.multiwell.config()
def multiwell_buttons(self):
multiwells = []
multiwells.append('''<div class="w3-border well-btn">''')
for w in self.well_iterator:
multiwells.append(f"""<button class="w3-button well" value="{w.order}" onclick="goto_well(this)">{w.well.name}</button>""")
multiwells.append('''</div>''')
self.well_iterator.reset()
return mark_safe("\n".join(multiwells))
def _grid_scanning_capture(self, uuid, duration):
self.process.data.uuid = uuid
self.process.data.record = True
start = time.monotonic()
while not self.stop_playing.is_set():
if time.monotonic() - start > duration:
break
self.cnc_controller.wait_for(1.0)
logger.info(f"Arrêter l'enregistrement {uuid}")
self.process.data.record = False
self.process.data.uuid = None
def _grid_scanning(self, observation, xnext=0, ynext=0):
multiwell = observation.multiwell
wells = models.WellPostion.objects.filter(multiwell_id=multiwell.id).order_by('order').all()
self.stop_playing = Event()
for w in wells:
if self.stop_playing.is_set():
break
self.cnc_controller.move_to(w.x, w.y, feed=w.multiwell.feed)
uuid = f'{self.process.data.session}-{multiwell.position}-{w.well.name}'
self._grid_scanning_capture(uuid, multiwell.duration)
logger.info(f"Scan terminé — retour à l'origine (X={xnext:.1f} Y={ynext:.1f})")
self.cnc_controller.move_to(xnext, ynext, feed=multiwell.feed*2)
def _start_scanning(self, session, observations):
xynext = []
for obs in observations:
xynext.append((obs.multiwell.xbase, obs.multiwell.ybase))
xynext.append((0, 0))
pos = 1
self.process.data.session = session.id
started = timezone.now()
for obs in observations:
obs.started = timezone.now()
obs.save()
xnext, ynext = xynext[pos]
pos +=1
self._grid_scanning(obs, xnext=xnext, ynext=ynext)
obs.finished = timezone.now()
obs.save()
session.finished = timezone.now()
session.active = False
session.scanning_task.enabled = False
session.save()
logger.info(f"==== Session {session.name} terminée à {session.finished} après {session.finished - started} secondes.")
def halt_scanning(self):
self.process.data.record = False
return self.stop_playing.set()
def scanning(self, sid):
try:
session = models.Session.objects.get(pk=sid)
observations = models.SessionObservation.observation_by_session(sid)
Thread(target=self._start_scanning, args=(session, observations, ), daemon=True).start()
except Exception as e:
print("MultiWellManager::scan error", e)
def previous_well(self):
w = self.well_iterator.previous()
self.cnc_controller.move_to(w.x, w.y, feed=w.multiwell.feed)
return {"state": "previous", "msg": f">>> ({w.x}, {w.y})"}
def next_well(self):
w = self.well_iterator.next()
self.cnc_controller.move_to(w.x, w.y, feed=w.multiwell.feed)
return {"state": "next", "msg": f">>> ({w.x}, {w.y})"}
def goto_well(self, numwell):
w = self.well_iterator.seek(numwell)
self.cnc_controller.move_to(w.x, w.y, feed=w.multiwell.feed)
return {"state": "goto", "msg": f">>> ({w.x}, {w.y})"}
def set_well_position(self):
w = self.well_iterator.get_current()
w.x, w.y = self.cnc_controller.get_mpos()
w.save()
return {"state": "well_position", "msg": f">>> saved ({w.x}, {w.y})"}
def _scanning_test(self, xnext=0, ynext=0):
self.stop_playing = Event()
for w in self.well_iterator:
if self.stop_playing.is_set():
break
self.cnc_controller.move_to(w.x, w.y, feed=w.multiwell.feed)
start = time.monotonic()
while not self.stop_playing.is_set():
if time.monotonic() - start > self.duration:
break
self.cnc_controller.wait_for(1.0)
logger.info(f"Arrêter la simulation")
self.well_iterator.reset()
logger.info(f"Scan terminé — retour à l'origine (X={xnext:.1f} Y={ynext:.1f})")
self.cnc_controller.move_to(xnext, ynext, feed=self.multiwell.feed*2)
def scan_test(self):
Thread(target=self._scanning_test, daemon=True).start()
@property
def position(self):
return self._position
@position.setter
def position(self, value):
self._position = value
@property
def duration(self):
return self._duration
@duration.setter
def duration(self, value):
self._duration = value
@property
def step(self):
return self._step
@step.setter
def step(self, value):
self._step = value
@property
def feed(self):
return self._feed
@feed.setter
def feed(self, value):
self._feed = value
@property
def xbase(self):
return self._xbase
@xbase.setter
def xbase(self, value):
self._xbase = value
@property
def ybase(self):
return self._ybase
@ybase.setter
def ybase(self, value):
self._ybase = value
@property
def dx(self):
return self._dx
@dx.setter
def dx(self, value):
self._dx = value
@property
def dy(self):
return self._dy
@dy.setter
def dy(self, value):
self._dy = value
def set_xy_step(self):
models.MultiWell.objects.filter(position__exact=self.position).update(dx=self.dx, dy=self.dy)
def set_position(self):
x, y = self.cnc_controller.get_mpos()
self.cnc_controller.wait_for(2.0)
models.MultiWell.objects.filter(position__exact=self.position).update(xbase=x, ybase=y)
self._xbase, self._ybase = x, y
def calib_toggle_debug(self):
""" Active / désactive le mode debug sur le stream."""
aligner = self.process.cam._aligner
aligner.debug = not aligner.debug
return {"state": "debug", "msg": f"Debug: {aligner.debug}"}
+125 -201
View File
@@ -4,6 +4,8 @@ import os
os.environ['OPENCV_LOG_LEVEL']="0" os.environ['OPENCV_LOG_LEVEL']="0"
os.environ['OPENCV_FFMPEG_LOGLEVEL']="0" os.environ['OPENCV_FFMPEG_LOGLEVEL']="0"
import cv2 import cv2
import numpy as np
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from datetime import datetime from datetime import datetime
import time, asyncio, bisect import time, asyncio, bisect
@@ -25,14 +27,16 @@ from modules import reductstore, grbl, utils
## camera devices ## camera devices
from modules.circular_crop import CircularCrop, CropStrategy from modules.circular_crop import CircularCrop, CropStrategy
from .multiwell import MultiWellManager
from . import models from . import models
@dataclass @dataclass
class ProcTag: class ProcessData:
play: bool = True play: bool = True
record: bool = False record: bool = False
uuid: str = None uuid: str = None
session: int = 0 session: int = 0
frame: bytes = None
logger = get_task_logger(__name__) logger = get_task_logger(__name__)
redisDB = Redis(host=settings.REDIS_HOST, port=settings.REDIS_PORT, db=0, decode_responses=True) redisDB = Redis(host=settings.REDIS_HOST, port=settings.REDIS_PORT, db=0, decode_responses=True)
@@ -132,159 +136,12 @@ class CameraRecordManager():
asyncio.run(self.remove_uuid(uuid, start, stop, when=when)) asyncio.run(self.remove_uuid(uuid, start, stop, when=when))
class MultiWellManager:
def __init__(self, position, feed=None, step=None, process=None):
self.set_multiwell(position)
self._feed = feed
self._step = step
self.process = process
self.tag = process.tag
self.scanner = None
def set_multiwell(self, position):
self._position = position
self.well = models.MultiWell.by_position(position)
self._xbase = self.well.xbase
self._ybase = self.well.ybase
self._dx = self.well.dx
self._dy = self.well.dy
def _start_test(self):
self.scanner.start()
def _start(self, machine, session, observations):
xynext = []
for obs in observations:
xynext.append((obs.multiwell.xbase, obs.multiwell.ybase))
xynext.append((0, 0))
pos = 1
self.tag.session = session.id
started = timezone.now()
for obs in observations:
conf = obs.multiwell.config()
self.scanner = grbl.GridScanner(machine, process=self.process, **conf)
obs.started = timezone.now()
obs.save()
xnext, ynext = xynext[pos]
pos +=1
self.scanner.start(xnext=xnext, ynext=ynext, position=obs.multiwell.position)
obs.finished = timezone.now()
obs.save()
session.finished = timezone.now()
session.active = False
session.scanning_task.enabled = False
session.save()
logger.info(f"==== Session {session.name} terminée à {session.finished} après {session.finished - started} secondes.")
def scan_test(self, machine, duration=5.0):
conf = self.well.config()
conf['duration'] = duration
conf['feed'] = self.feed
conf['xnext'] = self._xbase
conf['ynext'] = self._ybase
self.tag.session = 0
self.scanner = grbl.GridScanner(machine, process=self.process, **conf)
Thread(target=self._start_test, daemon=True).start()
def scan(self, machine, sid):
try:
session = models.Session.objects.get(pk=sid)
observations = models.SessionObservation.observation_by_session(sid)
Thread(target=self._start, args=(machine, session, observations, ), daemon=True).start()
except Exception as e:
print("MultiWellManager::scan error", e)
def halt(self):
if self.scanner:
self.scanner.halt()
@property
def position(self):
return self._position
@position.setter
def position(self, value):
self._position = value
@property
def step(self):
return self._step
@step.setter
def step(self, value):
self._step = value
@property
def feed(self):
return self._feed
@feed.setter
def feed(self, value):
self._feed = value
@property
def xbase(self):
return self._xbase
@xbase.setter
def xbase(self, value):
self._xbase = value
@property
def ybase(self):
return self._ybase
@ybase.setter
def ybase(self, value):
self._ybase = value
@property
def dx(self):
return self._dx
@dx.setter
def dx(self, value):
self._dx = value
@property
def dy(self):
return self._dy
@dy.setter
def dy(self, value):
self._dy = value
def set_xy_step(self):
models.MultiWell.objects.filter(position__exact=self.position).update(dx=self.dx, dy=self.dy)
def set_position(self, machine):
x, y = machine.get_mpos()
machine.wait_for(2.0)
models.MultiWell.objects.filter(position__exact=self.position).update(xbase=x, ybase=y)
self._xbase, self._ybase = x, y
class ScannerProcess(Task): class ScannerProcess(Task):
'''
video_quality = settings.VIDEO_JPG_QUALITY
image_quality = settings.IMAGE_JPG_QUALITY
video_fps = settings.VIDEO_FPS
video_width = settings.VIDEO_WIDTH
video_height = settings.VIDEO_HEIGHT
crop_radius = settings.CALIBRATION_CROP_RADIUS def __init__(self, use_tracking=False):
default_multiwell = settings.CALIBRATION_DEFAULT_MULTIWELL
default_feed = settings.CALIBRATION_DEFAULT_FEED
default_step = settings.CALIBRATION_DEFAULT_STEP'''
def __init__(self):
super().__init__() super().__init__()
self.use_tracking = use_tracking
self.channel_layer = get_channel_layer() self.channel_layer = get_channel_layer()
self.group = f'scanner_proc' self.group = f'scanner_proc'
self.stop_event = Event() self.stop_event = Event()
@@ -294,7 +151,7 @@ class ScannerProcess(Task):
self.multiwel = None self.multiwel = None
self.conf = None self.conf = None
self.record_queue = Queue() self.record_queue = Queue()
self.tag = ProcTag() self.data = ProcessData()
self.manager = None self.manager = None
self.recordDB = CameraRecordManager(cameraDB) self.recordDB = CameraRecordManager(cameraDB)
@@ -313,21 +170,37 @@ class ScannerProcess(Task):
self.video_fps = self.conf.video_frame_rate self.video_fps = self.conf.video_frame_rate
self.video_width = self.conf.video_width_capture self.video_width = self.conf.video_width_capture
self.video_height = self.conf.video_height_capture self.video_height = self.conf.video_height_capture
self.crop_radius = self.conf.calibration_crop_radius self.crop_radius = self.conf.calibration_crop_radius
self.default_multiwell = self.conf.calibration_default_multiwell
self.default_feed = self.conf.calibration_default_feed
self.default_step = self.conf.calibration_default_step
self.video_jpg_quality = [int(cv2.IMWRITE_JPEG_QUALITY), self.video_quality] self.video_jpg_quality = [int(cv2.IMWRITE_JPEG_QUALITY), self.video_quality]
self.image_jpg_quality = [int(cv2.IMWRITE_JPEG_QUALITY), self.image_quality] self.image_jpg_quality = [int(cv2.IMWRITE_JPEG_QUALITY), self.image_quality]
self.grbl_xmax = self.conf.grbl_xmax self.grbl_xmax = self.conf.grbl_xmax
self.grbl_ymax = self.conf.grbl_ymax self.grbl_ymax = self.conf.grbl_ymax
#self.crop = CircularCrop(radius=self.crop_radius, strategy=CropStrategy.CROP_JPEG, jpeg_quality=self.image_quality)
self.crop = self.set_crop_radius(self.crop_radius) self.crop = self.set_crop_radius(self.crop_radius)
if settings.TEST_VIDEOFILE:
from modules.videofile_capture import VideoFileCapture
self.cam = VideoFileCapture(
video_file=settings.MEDIA_ROOT / 'simulation' / 'part4-5fps.mp4',
fps=self.video_fps,
width=self.video_width,
height=self.video_height,
jpeg_quality=self.video_quality,
use_tracking=self.use_tracking,
px_per_mm = self.conf.px_per_mm,
display=self._display,
video_lists=[],
)
''' '''
if not self.conf.use_rpicam: settings.MEDIA_ROOT / 'simulation' / 'part1-5fps.mp4',
settings.MEDIA_ROOT / 'simulation' / 'part2-5fps.mp4',
settings.MEDIA_ROOT / 'simulation' / 'part3-5fps.mp4',
settings.MEDIA_ROOT / 'simulation' / 'part4-5fps.mp4',
settings.MEDIA_ROOT / 'simulation' / 'part5-5fps.mp4',
]
)
'''
elif not self.conf.use_rpicam:
from modules.webcam_capture import WebcamCapture from modules.webcam_capture import WebcamCapture
self.cam = WebcamCapture( self.cam = WebcamCapture(
device_index=self.conf.webcam_device_index, device_index=self.conf.webcam_device_index,
@@ -335,6 +208,9 @@ class ScannerProcess(Task):
width=self.video_width, width=self.video_width,
height=self.video_height, height=self.video_height,
jpeg_quality=self.video_quality, jpeg_quality=self.video_quality,
use_tracking=self.use_tracking,
px_per_mm = self.conf.px_per_mm,
display=self._display,
) )
else: else:
from modules.picamera2_capture import PiCamera2Capture from modules.picamera2_capture import PiCamera2Capture
@@ -343,27 +219,15 @@ class ScannerProcess(Task):
width=self.video_width, width=self.video_width,
height=self.video_height, height=self.video_height,
jpeg_quality=self.video_quality, jpeg_quality=self.video_quality,
) use_tracking=self.use_tracking,
''' px_per_mm = self.conf.px_per_mm,
from modules.videofile_capture import VideoFileCapture display=self._display,
self.cam = VideoFileCapture(
video_file=settings.MEDIA_ROOT / 'simulation' / 'part2-5fps.mp4',
fps=self.video_fps,
width=self.video_width,
height=self.video_height,
jpeg_quality=self.video_quality,
video_lists = [
settings.MEDIA_ROOT / 'simulation' / 'part1-5fps.mp4',
settings.MEDIA_ROOT / 'simulation' / 'part2-5fps.mp4',
settings.MEDIA_ROOT / 'simulation' / 'part3-5fps.mp4',
settings.MEDIA_ROOT / 'simulation' / 'part4-5fps.mp4',
settings.MEDIA_ROOT / 'simulation' / 'part5-5fps.mp4',
]
) )
self.cam.set_frame_callback(self._on_frame) self.cam.set_frame_callback(self._on_frame)
self.cam.set_median(False) self.cam._active_median = False
self.cam.set_circular_crop(None) self.cam.set_circular_crop(None)
self.stop_event.clear() self.stop_event.clear()
self.start_services() self.start_services()
except Exception as e: except Exception as e:
@@ -399,10 +263,11 @@ class ScannerProcess(Task):
self._send(**msg) self._send(**msg)
def _on_frame(self, jpeg_bytes: bytes, ts: datetime, metrics: dict) -> None: def _on_frame(self, jpeg_bytes: bytes, ts: datetime, metrics: dict) -> None:
if self.tag.record: self.data.frame = jpeg_bytes
if self.data.record:
# record images # record images
self.record_queue.put((self.tag.uuid, ts, jpeg_bytes, metrics)) self.record_queue.put((self.data.uuid, ts, jpeg_bytes, metrics))
if self.tag.play: if self.data.play:
# play image # play image
self._send(ts=ts.timestamp(), jpeg=base64.b64encode(jpeg_bytes).decode(), **metrics) self._send(ts=ts.timestamp(), jpeg=base64.b64encode(jpeg_bytes).decode(), **metrics)
@@ -411,7 +276,7 @@ class ScannerProcess(Task):
while not self.stop_event.is_set(): while not self.stop_event.is_set():
try: try:
(uuid, ts, frame, metrics) = self.record_queue.get() (uuid, ts, frame, metrics) = self.record_queue.get()
labels = dict(fps=self.video_fps, session=self.tag.session, detected="1" if metrics.get("detected") else "0") labels = dict(fps=self.video_fps, session=self.data.session, detected="1" if metrics.get("detected") else "0")
if metrics.get("detected"): if metrics.get("detected"):
labels.update({ labels.update({
"cx" : str(metrics["cx"]), "cx" : str(metrics["cx"]),
@@ -441,13 +306,7 @@ class ScannerProcess(Task):
pubsub = redisDB.pubsub() pubsub = redisDB.pubsub()
pubsub.subscribe(self.group) pubsub.subscribe(self.group)
self._init_grbl() self._init_grbl()
self.manager = MultiWellManager(process=self)
self.manager = MultiWellManager(
self.default_multiwell,
feed=self.default_feed,
step=self.default_step,
process=self
)
for message in pubsub.listen(): for message in pubsub.listen():
try: try:
@@ -466,7 +325,7 @@ class ScannerProcess(Task):
topic = cmd.get("topic") topic = cmd.get("topic")
if topic == 'init': if topic == 'init':
self.cam.set_circular_crop(self.crop) self.cam.set_circular_crop(self.crop)
self.cam.set_median(is_median=False) self.cam._active_median = False
self.grbl.go_origin(feed=self.manager.feed) self.grbl.go_origin(feed=self.manager.feed)
elif topic == 'scan': elif topic == 'scan':
@@ -474,63 +333,127 @@ class ScannerProcess(Task):
if sid == "0": if sid == "0":
self._send(state='error', msg=str(_('La session est nulle!...'))) self._send(state='error', msg=str(_('La session est nulle!...')))
else: else:
self.cam.set_median(is_median=False) self.cam._active_median = False
self.manager.scan(self.grbl, sid) self.manager.scanning(sid)
elif cmd["type"]=="calibrate": elif cmd["type"]=="calibrate":
topic = cmd.get("topic") topic = cmd.get("topic")
value = cmd.get("value") value = cmd.get("value")
buttons = None
if topic == 'init': if topic == 'init':
self.manager.feed = int(cmd.get("feed", self.default_feed)) self.manager.set_default_values(
self.manager.step = float(cmd.get("step", self.default_step)) feed=int(cmd.get("feed")),
position = cmd.get("position", self.default_multiwell) step=float(cmd.get("step")),
if self.manager.position != position: duration=float(cmd.get("duration"))
)
position = cmd.get("position")
self.manager.set_multiwell(position) self.manager.set_multiwell(position)
self.cam.set_circular_crop(None) self.cam.set_circular_crop(None)
self.cam.set_median(is_median=False) self.cam._active_median = False
buttons = self.manager.multiwell_buttons()
elif topic == 'up': elif topic == 'up':
self.grbl.move_relative(dy=self.manager.step, feed=self.manager.feed) self.grbl.move_relative(dy=self.manager.step, feed=self.manager.feed)
elif topic == 'down': elif topic == 'down':
self.grbl.move_relative(dy=-self.manager.step, feed=self.manager.feed) self.grbl.move_relative(dy=-self.manager.step, feed=self.manager.feed)
elif topic == 'right': elif topic == 'right':
self.grbl.move_relative(dx=self.manager.step, feed=self.manager.feed) self.grbl.move_relative(dx=self.manager.step, feed=self.manager.feed)
elif topic == 'left': elif topic == 'left':
self.grbl.move_relative(dx=-self.manager.step, feed=self.manager.feed) self.grbl.move_relative(dx=-self.manager.step, feed=self.manager.feed)
elif topic == 'median': elif topic == 'median':
self.cam.set_median(is_median=value) self.cam._active_median = not self.cam._active_median
elif topic == 'crop':
self.cam.set_circular_crop(self.crop) if value else self.cam.set_circular_crop(None)
continue continue
elif topic == 'crop':
self.cam._active_crop = not self.cam._active_crop
self.cam.set_circular_crop(self.crop) if self.cam._active_crop else self.cam.set_circular_crop(None)
continue
elif topic == 'crop_radius': elif topic == 'crop_radius':
self.conf.calibration_crop_radius=int(value) self.conf.calibration_crop_radius=int(value)
self.crop = self.set_crop_radius(self.conf.calibration_crop_radius) self.crop = self.set_crop_radius(self.conf.calibration_crop_radius)
self.conf.save() self.conf.save()
self.cam.set_circular_crop(self.crop) self.cam.set_circular_crop(self.crop)
continue continue
elif topic == 'position': elif topic == 'position':
self.manager.set_multiwell(value) self.manager.set_multiwell(value)
buttons = self.manager.multiwell_buttons()
elif topic == 'step': elif topic == 'step':
self.manager.step = float(value) self.manager.step = float(value)
elif topic == 'feed': elif topic == 'feed':
self.manager.feed = int(value) self.manager.feed = int(value)
elif topic == 'duration':
self.manager.duration = float(value)
elif topic == 'goto_0': elif topic == 'goto_0':
self.grbl.go_origin(feed=self.manager.feed) self.grbl.go_origin(feed=self.manager.feed)
self.manager.well_iterator.reset()
elif topic == 'goto_xy': elif topic == 'goto_xy':
self.grbl.move_to(self.manager.xbase, self.manager.ybase, feed=self.manager.feed) self.grbl.move_to(self.manager.xbase, self.manager.ybase, feed=self.manager.feed)
self.manager.well_iterator.reset()
elif topic == 'xy_base': elif topic == 'xy_base':
self.manager.set_position(self.grbl) self.manager.set_position()
elif topic == 'dx': elif topic == 'dx':
self.manager.dx = float(value) self.manager.dx = float(value)
elif topic == 'dy': elif topic == 'dy':
self.manager.dy = float(value) self.manager.dy = float(value)
elif topic == 'xy_step': elif topic == 'xy_step':
self.manager.set_xy_step() self.manager.set_xy_step()
elif topic == 'test': elif topic == 'test':
self.manager.scan_test(self.grbl) pass
#self.manager.scan_test()
continue continue
elif topic == 'center':
#self.manager.scan_test()
dx_mm = self.cam._last_detection["offset_x_mm"]
dy_mm = self.cam._last_detection["offset_y_mm"]
self.grbl.move_to(self.grbl.x + dx_mm, self.grbl.y + dy_mm, feed=150)
msg = f"Correction CNC move_relative(dx={dx_mm:.3f}, dy={dy_mm:.3f})"
self._send(state='center', msg=msg)
continue
elif topic == 'halt': elif topic == 'halt':
self.manager.halt() self.manager.halt_scanning()
continue
elif topic == 'calib_debug':
msg = self.manager.calib_toggle_debug()
self._send(**msg)
continue
elif topic == 'previous':
msg = self.manager.previous_well()
self._send(**msg)
continue
elif topic == 'next':
msg = self.manager.next_well()
self._send(**msg)
continue
elif topic == 'goto':
msg = self.manager.goto_well(int(value))
self._send(**msg)
continue
elif topic == 'set_well':
msg = self.manager.set_well_position()
self._send(**msg)
continue continue
self._send( self._send(
@@ -541,7 +464,8 @@ class ScannerProcess(Task):
xy=True, xy=True,
dxy=True, dxy=True,
dx=self.manager.dx, dx=self.manager.dx,
dy=self.manager.dy dy=self.manager.dy,
buttons=buttons,
) )
except Exception as e: except Exception as e:
@@ -32,3 +32,17 @@
align-self: start; align-self: start;
grid-area: move; grid-area: move;
} }
.well {
padding: 0.2em;
}
.well-btn {
display: grid;
grid-template-columns: repeat(6, 1fr);
justify-items: center;
align-items: center;
}
@@ -9,9 +9,6 @@ class ScannerManager {
this.debug_count = 0 this.debug_count = 0
} }
toggle_median() { this.axes = !this.axes; return this.axes; }
toggle_crop() { this.croping = !this.croping; return this.croping; }
init_controls() { init_controls() {
this.ts = sId("_ts"); this.ts = sId("_ts");
this.cx = sId("_cx"); this.cx = sId("_cx");
@@ -30,6 +27,7 @@ class ScannerManager {
const down = sId("_down"); const down = sId("_down");
const left = sId("_left"); const left = sId("_left");
const right = sId("_right"); const right = sId("_right");
this.duration = sId("_duration");
this.feed = sId("_feed"); this.feed = sId("_feed");
this.step = sId("_step"); this.step = sId("_step");
this.well = sId("_well"); this.well = sId("_well");
@@ -40,34 +38,50 @@ class ScannerManager {
this.xbase = sId("_xbase"); this.xbase = sId("_xbase");
this.ybase = sId("_ybase"); this.ybase = sId("_ybase");
this.debug = sId("_debug"); this.debug = sId("_debug");
this.well_btn = sId("_well_btn");
const test = sId("_test"); const test = sId("_test");
const halt = sId("_halt"); const halt = sId("_halt");
const calib_debug = sId("_calib_debug");
const calib_center = sId("_calib_center");
const previous = sId("_previous");
const next = sId("_next");
const set_well = sId("_set_well");
const median = sId("_median"); const median = sId("_median");
const crop = sId("_crop"); const crop = sId("_crop");
const crop_radius = sId("_crop_radius"); const crop_radius = sId("_crop_radius");
up.addEventListener('mousedown', (e) => { this._send({ type: 'calibrate', topic: "up" }); }); up.addEventListener('mousedown', (e) => { this._send({ type: 'calibrate', topic: "up" }); });
down.addEventListener('mousedown', (e) => { this._send({ type: 'calibrate', topic: "down" }); }); down.addEventListener('mousedown', (e) => { this._send({ type: 'calibrate', topic: "down" }); });
left.addEventListener('mousedown', (e) => { this._send({ type: 'calibrate', topic: "left" }); }); left.addEventListener('mousedown', (e) => { this._send({ type: 'calibrate', topic: "left" }); });
right.addEventListener('mousedown', (e) => { this._send({ type: 'calibrate', topic: "right" }); }); right.addEventListener('mousedown', (e) => { this._send({ type: 'calibrate', topic: "right" }); });
goto_0.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "goto_0" }); }); goto_0.addEventListener('click', (e) => { this.clear_buttons(); this._send({ type: 'calibrate', topic: "goto_0" }); });
goto_xy.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "goto_xy" }); }); goto_xy.addEventListener('click', (e) => { this.clear_buttons(); this._send({ type: 'calibrate', topic: "goto_xy" }); });
xy_base.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "xy_base" }); }); xy_base.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "xy_base" }); });
xy_step.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "xy_step" }); }); xy_step.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "xy_step" }); });
median.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "median", value: this.toggle_median() }); }); calib_debug.addEventListener('click',(e) => { this._send({ type: 'calibrate', topic: "calib_debug" }); });
crop.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "crop", value: this.toggle_crop() }); }); previous.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "previous" }); });
next.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "next" }); });
set_well.addEventListener('click',(e) => { this._send({ type: 'calibrate', topic: "set_well" }); });
median.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "median" }); });
crop.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "crop" }); });
crop_radius.addEventListener('change',(e) => { this._send({ type: 'calibrate', topic: "crop_radius", value: crop_radius.value }); }); crop_radius.addEventListener('change',(e) => { this._send({ type: 'calibrate', topic: "crop_radius", value: crop_radius.value }); });
this.well.addEventListener("change", (e) => { this._send({ type: 'calibrate', topic: "position", value: e.target.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.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.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.dx.addEventListener("change", (e) => { this._send({ type: 'calibrate', topic: "dx", value: e.target.value }); }); this.dx.addEventListener("change", (e) => { this._send({ type: 'calibrate', topic: "dx", value: e.target.value }); });
this.dy.addEventListener("change", (e) => { this._send({ type: 'calibrate', topic: "dy", value: e.target.value }); }); this.dy.addEventListener("change", (e) => { this._send({ type: 'calibrate', topic: "dy", value: e.target.value }); });
test.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "test" }); }); test.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "test" }); });
calib_center.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "center" }); });
halt.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "halt" }); }); halt.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "halt" }); });
} }
@@ -85,7 +99,7 @@ class ScannerManager {
if (payload.state) { this.debug.insertAdjacentHTML('afterbegin', `<li>[ ${++this.debug_count} - ${payload.state} ]: ${payload.msg}</li>`); } if (payload.state) { this.debug.insertAdjacentHTML('afterbegin', `<li>[ ${++this.debug_count} - ${payload.state} ]: ${payload.msg}</li>`); }
if (payload.ts) { this.ts.textContent = timestampToLocalISOString(payload.ts); } if (payload.ts) { this.ts.textContent = timestampToLocalISOString(payload.ts); }
if (payload.detected) { if (payload.detected && use_tracking) {
this.cx.textContent = payload.cx; this.cy.textContent = payload.cy; this.cx.textContent = payload.cx; this.cy.textContent = payload.cy;
this.speed_px_s.textContent = payload.speed_px_s; this.speed_px_s.textContent = payload.speed_px_s;
this.axial_speed.textContent = payload.axial_speed; this.axial_speed.textContent = payload.axial_speed;
@@ -93,19 +107,24 @@ class ScannerManager {
this.area_px.textContent = payload.area_px; this.area_px.textContent = payload.area_px;
this.frame_count.textContent = payload.count; this.frame_count.textContent = payload.count;
} }
if (payload.buttons) { this.well_btn.innerHTML = payload.buttons; }
} catch(e) { console.log(e); } } catch(e) { console.log(e); }
} }
clear_buttons() { document.querySelectorAll('button.w3-button.well').forEach(btn => {btn.classList.remove('w3-green'); }); }
goto_well(b) { this.clear_buttons(); b.classList.add('w3-green'); this._send({ type: 'calibrate', topic: "goto", value: b.value }); }
init() { init() {
this.axes = 0; this.axes = 0;
this.cropping = 0; this.cropping = 0;
this.clear_buttons();
this._send({ this._send({
type: 'calibrate', type: 'calibrate',
topic: "init", topic: "init",
feed: this.feed.value, feed: this.feed.value,
step: this.step.value, step: this.step.value,
position: this.well.value position: this.well.value,
duration: this.duration.value
}); });
} }
start() { this._send({ type: 'scanner', topic: "start"}); } start() { this._send({ type: 'scanner', topic: "start"}); }
@@ -48,7 +48,7 @@ class ScannerManager {
if (payload.xy) { this.x.textContent=payload.x.toFixed(2); this.y.textContent=payload.y.toFixed(2); } if (payload.xy) { this.x.textContent=payload.x.toFixed(2); this.y.textContent=payload.y.toFixed(2); }
if (payload.state) { this.debug.insertAdjacentHTML('afterbegin', `<li>[ ${++this.debug_count} - ${payload.state} ]: ${payload.msg}</li>`); } if (payload.state) { this.debug.insertAdjacentHTML('afterbegin', `<li>[ ${++this.debug_count} - ${payload.state} ]: ${payload.msg}</li>`); }
if (payload.ts) { this.ts.textContent = timestampToLocalISOString(payload.ts); } if (payload.ts) { this.ts.textContent = timestampToLocalISOString(payload.ts); }
if (payload.detected) { if (payload.detected && use_tracking) {
this.cx.textContent = payload.cx; this.cy.textContent = payload.cy; this.cx.textContent = payload.cx; this.cy.textContent = payload.cy;
this.speed_px_s.textContent = payload.speed_px_s; this.speed_px_s.textContent = payload.speed_px_s;
this.axial_speed.textContent = payload.axial_speed; this.axial_speed.textContent = payload.axial_speed;
+2 -1
View File
@@ -3,6 +3,7 @@ import asyncio
from celery import shared_task, group, chord, chain from celery import shared_task, group, chord, chain
from celery.utils.log import get_task_logger from celery.utils.log import get_task_logger
from django.utils import timezone from django.utils import timezone
from django.conf import settings
from .process import ScannerProcess, ReplayProcess from .process import ScannerProcess, ReplayProcess
from .export_tasks import shm_download_video, export_images_zip, export_video_mp4 from .export_tasks import shm_download_video, export_images_zip, export_video_mp4
@@ -18,7 +19,7 @@ class ScannerTaskManager:
def start_scanner(self): def start_scanner(self):
if self.scanner is None: if self.scanner is None:
self.scanner = ScannerProcess() self.scanner = ScannerProcess(use_tracking=settings.TRACKING)
self.scanner.start() self.scanner.start()
def stop_scanner(self): def stop_scanner(self):
@@ -1,5 +1,5 @@
{% extends 'scanner/base.html' %} {% extends 'scanner/base.html' %}
{% load i18n home_tags %} {% load i18n home_tags scanner_tags %}
{% block styles %} {% block styles %}
{{ block.super }} {{ block.super }}
@@ -11,15 +11,15 @@
<div class="container w3-black"> <div class="container w3-black">
<div class="header"> <div class="header">
<div class="w3-row w3-row-padding"> <div class="w3-row w3-row-padding">
<div class="w3-col" style="width:30%"> <div class="w3-col" style="width:15%">
{% trans 'Position multi-puit' %}<br> {% trans 'Position multi-puit' %}<br>
<select id="_well" class="w3-select"> <select id="_well" class="w3-select">
{% for w in wells %} {% for w in wells %}
<option value="{{ w.position }}" {% if w.position == 'HG' %}selected{% endif %}>{{ w }}</option> <option value="{{ w.position }}" {% if w.position == default_position %}selected{% endif %}>{{ w }}</option>
{% endfor %} {% endfor %}
</select> </select>
</div> </div>
<div class="w3-col" style="width:20%"> <div class="w3-col" style="width:15%">
<div>{% trans 'Vitesse' %}</div> <div>{% trans 'Vitesse' %}</div>
<select id="_feed" class="w3-select"> <select id="_feed" class="w3-select">
<option value="500">500 mm/mn</option> <option value="500">500 mm/mn</option>
@@ -32,7 +32,7 @@
</select> </select>
</div> </div>
<div class="w3-col" style="width:20%"> <div class="w3-col" style="width:15%">
<div>{% trans 'Pas' %}</div> <div>{% trans 'Pas' %}</div>
<select id="_step" class="w3-select"> <select id="_step" class="w3-select">
<option value="0.1">0.1 mm</option> <option value="0.1">0.1 mm</option>
@@ -50,67 +50,113 @@
<option value="50.0">50.0 mm</option> <option value="50.0">50.0 mm</option>
</select> </select>
</div> </div>
<div class="w3-col" style="width:30%"> <div class="w3-col" style="width:15%">
<div>{% trans 'Durée' %}</div>
<select id="_duration" class="w3-select" title="{% trans 'Durée entre vidéos' %}">
<option value="1.0">1 s</option>
<option value="2.0">2 s</option>
<option value="3.0">3 s</option>
<option value="4.0">4 s</option>
<option value="5.0" selected>5 s</option>
<option value="10">10 s</option>
</select>
</div>
<div class="w3-col" style="width:40%">
<div class="w3-margin-top w3-padding w3-right"> <div class="w3-margin-top w3-padding w3-right">
<span id="_ts"></span><br> <span id="_ts"></span><br>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div class="move w3-padding-small w3-center">
<div class="move w3-center">
<div class="w3-row"> <div class="w3-row">
<div class="w3-half">{% trans 'dx' %}<br><input id="_dx" type="number" min="15.0" max="25.0" step="0.01" value=""/></div> <div class="w3-half">{% trans 'dx' %}<br><input id="_dx" type="number" min="15.0" max="25.0" step="0.01" value=""/></div>
<div class="w3-half">{% trans 'dy' %}<br><input id="_dy" type="number" min="15.0" max="25.0" step="0.01" value=""/></div> <div class="w3-half">{% trans 'dy' %}<br><input id="_dy" type="number" min="15.0" max="25.0" step="0.01" value=""/></div>
</div> <div class="w3-col">
<button id="_xy-step" class="w3-button w3-warning w3-round-large w3-block w3-large"> <button id="_xy-step" class="w3-button w3-warning w3-round-large w3-block w3-large">
<i class="fa-solid fa-left-right"></i> {% trans 'Définir (dx, dy)' %} <i class="fa-solid fa-left-right"></i> {% trans 'Définir (dx, dy)' %}
</button> </button>
<hr> </div>
<div><button id="_up" class="w3-button w3-dark-xlight w3-round-large w3-margin-small w3-block">&#x2191; +Y {% trans 'En haut' %}</button></div> </div>
<div><button id="_left" class="w3-button w3-dark-xlight w3-round-large w3-margin-small w3-block">&#x2190; -X {% trans 'A gauches' %}</button></div>
<div><button id="_right" class="w3-button w3-dark-xlight w3-round-large w3-margin-small w3-block">&#x2192; +X {% trans 'A droite' %}</button></div> <div><button id="_up" class="w3-button w3-dark-xlight w3-round-large w3-margin-small w3-block">&#x2191; +Y {% trans 'A Droite' %}</button></div>
<div><button id="_down" class="w3-button w3-dark-xlight w3-round-large w3-margin-small w3-block">&#x2193; -Y {% trans 'En Bas' %}</button></div> <div><button id="_left" class="w3-button w3-dark-xlight w3-round-large w3-margin-small w3-block">&#x2190; -X {% trans 'En bas' %}</button></div>
<hr> <div><button id="_right" class="w3-button w3-dark-xlight w3-round-large w3-margin-small w3-block">&#x2192; +X {% trans 'En haut' %}</button></div>
<button id="_xy-base" class="w3-button w3-warning w3-round-large w3-margin-small w3-block">{% trans 'Définir base ' %}</button> <div><button id="_down" class="w3-button w3-dark-xlight w3-round-large w3-margin-small w3-block">&#x2193; -Y {% trans 'A Gauche' %}</button></div>
<div class="w3-row">
<div id="_well_btn" class="w3-col"></div>
<div class="w3-half">
<button id="_previous" class="w3-button w3-light-blue w3-round-large w3-block" title="{% trans 'Précédent' %}">
<i class="fa-solid fa-circle-left w3-large"></i>
</button>
</div>
<div class="w3-half">
<button id="_next" class="w3-button w3-light-blue w3-round-large w3-block" title="{% trans 'Suivant' %}">
<i class="fa-solid fa-circle-right w3-large"></i>
</button>
</div>
<div class="w3-col">
<button id="_set_well" class="w3-button w3-warning w3-round-large w3-block">
<i class="fa-solid fa-circle-check"></i> {% trans 'Définir position' %}
</button>
</div>
<div class="w3-col">
<button id="_calib_center" class="w3-button w3-warning w3-round-large w3-block">
<i class="fa-regular fa-circle"></i> {% trans 'Centrer manuel' %}
</button>
</div>
</div>
</div> </div>
<div class="scan w3-center"> <div class="scan w3-center">
<div class="w3-row"> <div class="w3-row">
<div class="w3-half">X<br><span id="_x"></span></div> <div class="w3-half">X<br><span id="_x"></span></div>
<div class="w3-half">Y<br><span id="_y"></span></div> <div class="w3-half">Y<br><span id="_y"></span></div>
</div> </div>
<button id="_goto-0" class="w3-button w3-light-blue w3-round w3-round-large w3-margin-small w3-block">Origine (0, 0)</button> <button id="_goto-0" class="w3-button w3-light-blue w3-round w3-round-large w3-margin-small w3-block">Origine (0, 0)</button>
<button id="_goto-xy" class="w3-button w3-light-blue w3-round-large w3-margin-small w3-block w3-margin-bottom"> <button id="_goto-xy" class="w3-button w3-light-blue w3-round-large w3-margin-small w3-block w3-margin-bottom">
{% trans 'Aller à la base' %}<br>(<span id="_xbase"></span>, <span id="_ybase"></span>) {% trans 'Aller à la base' %}<br>(<span id="_xbase"></span>, <span id="_ybase"></span>)
</button> </button>
<button id="_xy-base" class="w3-button w3-warning w3-round-large w3-margin-small w3-block">
<i class="fa-solid fa-circle-check"></i> {% trans 'Définir base' %}
</button>
<hr> <hr>
<button id="_median" class="w3-button w3-teal w3-round-large w3-margin-small w3-block"><i class="fa-solid fa-crosshairs"></i> {% trans 'Axes' %}</button> <button id="_calib_debug" class="w3-button w3-teal w3-round-large w3-margin-small w3-block">
<button id="_crop" class="w3-button w3-teal w3-round-large w3-margin-small w3-block w3-margin-bottom"><i class="fa-solid fa-crop"></i> {% trans 'Recadrer' %}</button> <i class="fa-solid fa-triangle-exclamation"></i> {% trans 'Debug' %}
</button>
<button id="_median" class="w3-button w3-teal w3-round-large w3-margin-small w3-block">
<i class="fa-solid fa-crosshairs"></i> {% trans 'Axes' %}
</button>
<button id="_crop" class="w3-button w3-teal w3-round-large w3-margin-small w3-block w3-margin-bottom">
<i class="fa-solid fa-crop"></i> {% trans 'Recadrer' %}
</button>
<span> <span>
{% trans 'Rayon' %}: <input id="_crop_radius" type="number" min="100" max="1200" step="1" value="{{ conf.calibration_crop_radius }}" title="{% trans 'Rayon de cadrage' %}"/> {% trans 'Rayon' %}: <input id="_crop_radius" type="number" min="100" max="1200" step="1" value="{{ conf.calibration_crop_radius }}" title="{% trans 'Rayon de cadrage' %}"/>
</span> </span>
<hr> <hr>
<button id="_test" class="w3-button w3-warning w3-round-large w3-margin-small w3-block">{% trans 'Tester le cirduit' %}</button> <button id="_test" class="w3-button w3-warning w3-round-large w3-margin-small w3-block">
<button id="_halt" class="w3-button w3-red w3-round-large w3-margin-small w3-block"><i class="fa-solid fa-hand"></i> {% trans 'ARRET' %}</button> <i class="fa-solid fa-circle-check"></i> {% trans 'Tester le cirduit' %}
</button>
<button id="_halt" class="w3-button w3-red w3-round-large w3-margin-small w3-block">
<i class="fa-solid fa-hand"></i> {% trans 'ARRET' %}
</button>
</div> </div>
{% include 'scanner/scan-image.html' %} {% include 'scanner/scan-image.html' %}
</div> </div>
<ul id="_debug" class="w3-scroll-y" style="height: 30vh"></ul> <ul id="_debug" class="w3-scroll-y" style="height: 30vh"></ul>
{% endblock %} {% endblock %}
{% block js_footer %} {% block js_footer %}
{{ block.super }} {{ block.super }}
<script src="/static/scanner/js/calibration.js"></script>
<script> <script>
const container = sId("scan-img"); const container = sId("scan-img");
const ws_route = "{{ ws_route }}"; const ws_route = "{{ ws_route }}";
// ---- Point d'entrée ---- const use_tracking = "{{ use_tracking }}" == "True";
(async () => { </script>
<script src="/static/scanner/js/calibration.js"></script>
<script>
const manager = new ScannerManager(container); const manager = new ScannerManager(container);
const protocol = location.protocol === "https:" ? "wss" : "ws"; const protocol = location.protocol === "https:" ? "wss" : "ws";
const wsUrl = `${protocol}://${location.host}/${ws_route}`; const wsUrl = `${protocol}://${location.host}/${ws_route}`;
@@ -118,7 +164,8 @@
socket.setManager(manager); socket.setManager(manager);
socket.connect(); socket.connect();
manager.registerSocket(socket); manager.registerSocket(socket);
})();
function goto_well(b) { manager.goto_well(b); }
</script> </script>
{% endblock %} {% endblock %}
@@ -43,11 +43,13 @@
{% block js_footer %} {% block js_footer %}
{{ block.super }} {{ block.super }}
<script src="/static/scanner/js/main.js"></script>
<script> <script>
const container = sId("scan-img"); const container = sId("scan-img");
const ws_route = "{{ ws_route }}"; const ws_route = "{{ ws_route }}";
const use_tracking = "{{ use_tracking }}" == "True";
</script>
<script src="/static/scanner/js/main.js"></script>
<script>
// ---- Point d'entrée ---- // ---- Point d'entrée ----
(async () => { (async () => {
const manager = new ScannerManager(container); const manager = new ScannerManager(container);
@@ -1,5 +1,6 @@
<div class="scanner w3-row"> <div class="scanner w3-row">
{% if use_tracking %}
<div class="w3-col w3-small" style="width:180px"> <div class="w3-col w3-small" style="width:180px">
<div>Num: <span id="_count"></span></div> <div>Num: <span id="_count"></span></div>
<div>Aire: <span id="_area_px"></span></div> <div>Aire: <span id="_area_px"></span></div>
@@ -10,6 +11,7 @@
<div>V.Ax: <span id="_axial_speed"></span> px/s</div> <div>V.Ax: <span id="_axial_speed"></span> px/s</div>
<div>Ax pos: <span id="_axial_pos"></span></div> <div>Ax pos: <span id="_axial_pos"></span></div>
</div> </div>
{% endif %}
<div class="w3-rest"> <div class="w3-rest">
<img id="scan-img" class="w3-image"> <img id="scan-img" class="w3-image">
</div> </div>
@@ -1,6 +1,7 @@
# encoding: utf-8 # encoding: utf-8
from django import template from django import template
from django.utils.html import mark_safe from django.utils.html import mark_safe
from .. import models
register = template.Library() register = template.Library()
@@ -25,3 +26,4 @@ def multiwell_cards(sid, observations):
return mark_safe("\n".join(multiwells)) return mark_safe("\n".join(multiwells))
+3
View File
@@ -33,11 +33,14 @@ def stats_view(request):
return JsonResponse({"error": str(e)}, status=500) return JsonResponse({"error": str(e)}, status=500)
def global_context(request, **ctx): def global_context(request, **ctx):
default_multiwell = models.MultiWell.objects.filter(default=True).first()
return dict( return dict(
app_title=settings.APP_TITLE, app_title=settings.APP_TITLE,
app_sub_title=settings.APP_SUB_TITLE, app_sub_title=settings.APP_SUB_TITLE,
domain_server=settings.DOMAIN_SERVER, domain_server=settings.DOMAIN_SERVER,
use_tracking=settings.TRACKING,
conf=models.Configuration.objects.filter(active=True).first(), conf=models.Configuration.objects.filter(active=True).first(),
default_position = default_multiwell.position or 'HD',
**ctx **ctx
) )