From 563069d3c63210b29ae31e1dd4118dddbfc04361 Mon Sep 17 00:00:00 2001 From: denis defolie Date: Fri, 8 May 2026 11:48:46 +0200 Subject: [PATCH] tracking --- etc/scanner_service.conf | 11 +- test_tube_scanner/.env.example | 6 +- test_tube_scanner/home/settings.py | 4 + .../modules/capture_interface.py | 14 +- test_tube_scanner/modules/grbl_simulator.py | 294 ++++++++++++++++++ .../modules/planarian_tracker.py | 13 +- test_tube_scanner/planarian/admin.py | 2 +- test_tube_scanner/planarian/models.py | 4 +- .../templates/planarian/experiment_form.html | 2 +- test_tube_scanner/run-server.sh | 5 + test_tube_scanner/scanner/constants.py | 1 + test_tube_scanner/scanner/models.py | 15 +- test_tube_scanner/scanner/multiwell.py | 137 ++++---- test_tube_scanner/scanner/process.py | 32 +- .../scanner/static/scanner/js/calibration.js | 39 +-- .../scanner/templates/scanner/base.html | 90 +++--- .../templates/scanner/calibration.html | 13 +- test_tube_scanner/scanner/urls.py | 5 + test_tube_scanner/scanner/views.py | 34 +- 19 files changed, 557 insertions(+), 164 deletions(-) create mode 100644 test_tube_scanner/modules/grbl_simulator.py create mode 100755 test_tube_scanner/run-server.sh diff --git a/etc/scanner_service.conf b/etc/scanner_service.conf index ba2b615..5144c59 100644 --- a/etc/scanner_service.conf +++ b/etc/scanner_service.conf @@ -5,11 +5,12 @@ programs=webUI,planification,services process_name=%(program_name)s priority=500 directory=/home/rpi4/PlanarianScanner/test_tube_scanner -command= - /home/rpi4/PlanarianScanner/.venv/bin/daphne - -b 0.0.0.0 - -p 8000 - home.asgi:application +command=/home/rpi4/PlanarianScanner/test_tube_scanner/run-server.sh +# /home/rpi4/PlanarianScanner/.venv/bin/daphne +# -b 0.0.0.0 +# -p 8000 +# home.asgi:application + user=rpi4 group=rpi4 stopasgroup=true diff --git a/test_tube_scanner/.env.example b/test_tube_scanner/.env.example index a9e77e1..5a9edb8 100644 --- a/test_tube_scanner/.env.example +++ b/test_tube_scanner/.env.example @@ -40,12 +40,14 @@ SECRET_KEY="django-insecure-0)4_w=pjv1ex7=s=c=ii3g@fx_=8fb=hxk€3bpk1)uj(0ph0t) USER=rpi4 GROUP=rpi4 + DOMAIN_SERVER=scanner.local -ALLOWED_HOSTS=127.0.0.1,localhost -CSRF_TRUSTED_ORIGINS=http://127.0.0.1:8000,http://localhost:8000 +ALLOWED_HOSTS=127.0.0.1,localhost,scanner.local,192.168.250.230,192.168.1.200 +CSRF_TRUSTED_ORIGINS=http://127.0.0.1:8000,http://localhost:8000,http://scanner.local,http://192.168.1.200:8000,http://192.168.250.230:8000 #### # server app +LOCAL_IP_SERVER=192.168.1.200 SERVER_HOST_IP=0.0.0.0 SERVER_HOST_PORT=8000 diff --git a/test_tube_scanner/home/settings.py b/test_tube_scanner/home/settings.py index 86143ef..58d5d91 100644 --- a/test_tube_scanner/home/settings.py +++ b/test_tube_scanner/home/settings.py @@ -370,6 +370,8 @@ GROUP = config('GROUP') SERVER_HOST_PORT = config('SERVER_HOST_PORT', cast=int) SERVER_HOST_IP = config('SERVER_HOST_IP') +LOCAL_IP_SERVER = config('LOCAL_IP_SERVER') + # ws SCANNER_WEBSOCKET_ROUTE = 'ws/scanner' REPLAY_WEBSOCKET_ROUTE = 'ws/replay' @@ -387,6 +389,8 @@ DATETIME_FORMAT = '%d-%m-%Y-%m %H:%M:%S' # rpicam 4056x3040 2028x1080 2028x1520 #=========================== +GRBL_SIMULATION = True + EXPORTS_LOCAL_PATH = config("EXPORTS_LOCAL_PATH") EXPORT_REMOTE_PATH = config("EXPORT_REMOTE_PATH") diff --git a/test_tube_scanner/modules/capture_interface.py b/test_tube_scanner/modules/capture_interface.py index 8ce3843..1a3ccf7 100644 --- a/test_tube_scanner/modules/capture_interface.py +++ b/test_tube_scanner/modules/capture_interface.py @@ -101,21 +101,21 @@ class VideoCaptureInterface(abc.ABC): try: if self.use_tracking and cfg: self._tracker = PlanarianTracker(**cfg) - logger.info("Tracker de test créé avec conf : %s", cfg) + logger.info(f"Tracker de test créé avec conf: {cfg}") except Exception as e: logger.error(f"Error creating tracker with conf {cfg}: {e}") self._tracker = None - def on_well_change(self, cfg): + def on_well_change(self, cfg, draw_contours=False): """ Appelé par la CNC lors du changement de puits. Réinitialise le fond appris et l'état inter-frame du tracker. Construit les métriques aussi """ - if not self.use_tracking: + if not self.use_tracking or not cfg: return - params = self.DEFAULT_TRACKER_CONFIG if not cfg else cfg.to_params_dict() + params = cfg.to_params_dict() self._params = ExperimentParams(params) #self._metrics = self._params.build_metrics() @@ -128,9 +128,13 @@ class VideoCaptureInterface(abc.ABC): max_planarians = self._params.planarian_count, merge_kernel_size = self._params.merge_kernel_size, min_contour_dist_px = self._params.min_contour_dist_px, + draw_contours = draw_contours, ) - + def set_draw_contours(self, draw: bool = True): + if self._tracker: + self._tracker.draw_contours = draw + # ------------------------------------------------------------------ # Méthodes abstraites — obligatoires dans les sous-classes # ------------------------------------------------------------------ diff --git a/test_tube_scanner/modules/grbl_simulator.py b/test_tube_scanner/modules/grbl_simulator.py new file mode 100644 index 0000000..789fcf3 --- /dev/null +++ b/test_tube_scanner/modules/grbl_simulator.py @@ -0,0 +1,294 @@ +''' +Simulateur GCode pour tester sans CNC physique. + + GRBLController (simulé): + Reproduit fidèlement l'API de grbl.py + Simule les mouvements (X, Y) avec délai proportionnel au feed rate + Le mode absolu est retenu + Aucune dépendance à pyserial + +Created on 07 mai 2026 + +@author: denis@miraceti.net +''' +import logging +import time +import threading +import math + + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +class GRBLController: + ''' + Simulateur du contrôleur GRBL 1.1f (L2544 Laser Engraving Machine). + API 100% identique à grbl.py — interchangeable sans modifier le code appelant. + Les délais de déplacement sont calculés à partir du feed rate et de la distance. + ''' + X_MAX = 350 + Y_MAX = 250 + X_MIN = 0 + Y_MIN = 0 + + # Facteur de compression du temps simulé (1.0 = temps réel, 0.1 = 10x plus rapide) + TIME_SCALE = 0.1 + + def __init__(self, port='/dev/ttyUSB0', baudrate=115200, timeout=1, send_callback=None, x_max=None, y_max=None): + logger.info(f"GRBLController SIMULATOR::init begin {port} device port") + + self.port = port + self.baudrate = baudrate + self.timeout = timeout + + if x_max is not None: + self.X_MAX = x_max + if y_max is not None: + self.Y_MAX = y_max + + self._state = send_callback + if self._state is None: + self._state = self._send_msg + + # Position courante simulée + self.x, self.y = 0.0, 0.0 + + # État interne de la machine simulée + self._machine_state = 'Idle' # Idle | Run | Alarm + self._connected = False + + # ------------------------------------------------------------------------- + # Méthodes utilitaires + # ------------------------------------------------------------------------- + + def wait_for(self, delay=1.0): + # Applique le facteur de compression temporelle + threading.Event().wait(delay * self.TIME_SCALE) + + def _send_msg(self, **msg): + # Callback par défaut : simple affichage console + print(msg) + + # ------------------------------------------------------------------------- + # Simulation de la couche série (pas de port réel) + # ------------------------------------------------------------------------- + + def clear_buffer(self): + # Rien à vider : pas de port série physique + logger.debug("SIMULATOR::clear_buffer (no-op)") + + def start_connection(self): + '''Simule l'ouverture de la connexion série et l'initialisation GRBL.''' + logger.info(f"SIMULATOR::start_connection on {self.port} @ {self.baudrate} baud") + self._state(state='serial', msg="Grbl 1.1f ['$' for help]") + self._connected = True + self._wake_up() + self._init_machine() + logger.info("SIMULATOR::start_connection started") + + def _init_machine(self): + # Envoie les commandes d'initialisation (simulées) + self.send("G21") # Unités en mm + self.send("G90") # Mode absolu + + def _clamp(self, x, y): + self.clear_buffer() + x = max(self.X_MIN, min(self.X_MAX, x)) + y = max(self.Y_MIN, min(self.Y_MAX, y)) + return x, y + + def _wake_up(self): + # Simule l'envoi des octets de réveil et la réponse GRBL + logger.debug("SIMULATOR::_wake_up") + self.wait_for(1) + self._state(state='serial', msg="") # ligne vide typique de GRBL au démarrage + self.clear_buffer() + + # ------------------------------------------------------------------------- + # Envoi de commandes + # ------------------------------------------------------------------------- + + def send(self, cmd, wait_ok=True, timeout=5): + try: + return self._send(cmd, wait_ok, timeout) + except Exception as e: + self._state(state='error', msg=f"Error send {cmd} command: {e}") + self.close() + self.start_connection() + + def recover(self): + self._state(state='recover', msg="Erreur, récupération de GRBL...") + self.wait_for(1) + self._wake_up() + + def _send(self, cmd, wait_ok=True, timeout=5): + '''Simule l'envoi d'une commande GCode et retourne "ok".''' + self._state(state='send', msg=f">>> {cmd}") + logger.debug(f"SIMULATOR::_send {cmd}") + + # Interprète les commandes de mouvement pour mettre à jour la position interne + self._interpret_gcode(cmd) + + if not wait_ok: + return None + + # Simule une réponse "ok" immédiate + return "ok" + + def _interpret_gcode(self, cmd): + ''' + Analyse le GCode pour mettre à jour x, y et simuler le délai de déplacement. + Gère : G0, G1, G53 G1, G92, G21, G90, G91, $X, $H. + ''' + cmd_upper = cmd.strip().upper() + + # --- Commandes sans mouvement --- + if cmd_upper in ("G21", "G90", "G91", "$X"): + return + if cmd_upper == "$H": + # Homing : retour à l'origine avec délai simulé + self._machine_state = 'Run' + self._state(state='send', msg="SIMULATOR: homing...") + distance = math.hypot(self.x, self.y) + self._simulate_move_delay(distance, feed=3000) + self.x, self.y = 0.0, 0.0 + self._machine_state = 'Idle' + return + + # --- Extraction des coordonnées X, Y et du feed F --- + tokens = cmd_upper.replace(',', ' ').split() + new_x, new_y, feed = self.x, self.y, 1000.0 + + for token in tokens: + if token.startswith('X'): + try: + new_x = float(token[1:]) + except ValueError: + pass + elif token.startswith('Y'): + try: + new_y = float(token[1:]) + except ValueError: + pass + elif token.startswith('F'): + try: + feed = float(token[1:]) + except ValueError: + pass + + # --- G92 : redéfinit la position courante sans déplacement --- + if 'G92' in tokens: + self.x = new_x + self.y = new_y + logger.debug(f"SIMULATOR: G92 position set to ({self.x:.2f}, {self.y:.2f})") + return + + # --- Mouvement effectif (G0, G1, G53 G1, etc.) --- + has_move = any(t in tokens for t in ('G0', 'G1', 'G53')) + if has_move and (new_x != self.x or new_y != self.y): + distance = math.hypot(new_x - self.x, new_y - self.y) + self._machine_state = 'Run' + self._simulate_move_delay(distance, feed) + self.x = new_x + self.y = new_y + self._machine_state = 'Idle' + logger.debug(f"SIMULATOR: moved to ({self.x:.2f}, {self.y:.2f})") + + def _simulate_move_delay(self, distance_mm, feed): + '''Simule le temps de déplacement : distance / feed (mm/min) → secondes.''' + if feed <= 0: + return + duration = (distance_mm / feed) * 60.0 # feed est en mm/min + self.wait_for(duration) + + # ------------------------------------------------------------------------- + # Status machine + # ------------------------------------------------------------------------- + + def get_status(self): + '''Retourne un status GRBL simulé au format .''' + status = f"<{self._machine_state}|MPos:{self.x:.3f},{self.y:.3f},0.000|FS:0,0>" + logger.debug(f"SIMULATOR::get_status → {status}") + return status + + def reset_grbl(self): + self.send("$X") # Réinitialise les alarmes + self.wait_idle() + self.send("$H") # Homing + self.wait_idle() + + def _mpos(self, status): + if "MPos" in status: + mpos = status.split("MPos:")[1].split("|")[0] + x, y, *_ = mpos.split(",") + self._state(state='Mpos', msg=f"pos >>> ({x}, {y})") + return float(x), float(y) + return None, None + + def get_mpos(self): + return self._mpos(self.get_status()) + + def wait_idle(self, timeout=20): + '''Attend que la machine soit à l'état Idle (immédiat en simulation).''' + start = time.time() + while True: + if time.time() - start > timeout: + raise TimeoutError("Délai d'attente pour Idle dépassé") + status = self.get_status() + self.x, self.y = self._mpos(status) + self._state(xy=True, x=self.x, y=self.y) + if status and "Idle" in status: + break + self.wait_for(0.1) + + # ------------------------------------------------------------------------- + # Commandes de haut niveau (identiques à grbl.py) + # ------------------------------------------------------------------------- + + def send_command(self, cmd): + self.send(cmd) + self.wait_idle() + + def move_to(self, x, y, feed=1000): + x, y = self._clamp(x, y) + cmd = f"G53 G1 X{x:.2f} Y{y:.2f} F{feed}" + self.send_command(cmd) + + 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): + self.send("G91") # Mode relatif + cmd = f"G0 X{dx} Y{dy} F{feed}" + self.send(cmd) + self.send("G90") # Retour en mode absolu + self.wait_idle() + + def go_origin(self, feed=1000): + self.move_to(0, 0, feed=feed) + self.wait_for(2.0) + + def set_position(self, x, y): + x, y = self._clamp(x, y) + cmd = f"G92 X{x:.2f} Y{y:.2f}" + self.send(cmd) + self.wait_for(2.0) + + def move_up(self, step=10, feed=1000): + self.move_relative(dy=step, feed=feed) + + def move_down(self, step=10, feed=1000): + self.move_relative(dy=-step, feed=feed) + + def move_left(self, step=10, feed=1000): + self.move_relative(dx=-step, feed=feed) + + def move_right(self, step=10, feed=1000): + self.move_relative(dx=step, feed=feed) + + def close(self): + # Simule la fermeture du port série + self._connected = False + logger.info("SIMULATOR::close — connexion simulée fermée") diff --git a/test_tube_scanner/modules/planarian_tracker.py b/test_tube_scanner/modules/planarian_tracker.py index 8ef2c01..b47b8f7 100644 --- a/test_tube_scanner/modules/planarian_tracker.py +++ b/test_tube_scanner/modules/planarian_tracker.py @@ -172,6 +172,7 @@ class PlanarianTracker: max_planarians: int = 1, merge_kernel_size: int = 15, min_contour_dist_px:int = 40, + draw_contours: bool = True, ): """ Args: @@ -189,6 +190,7 @@ class PlanarianTracker: self.min_area_px = min_area_px self.max_area_ratio = max_area_ratio self.max_planarians = max(1, min(max_planarians, MAX_PLANARIANS)) + self.draw_contours = draw_contours # Un état inter-frame par slot individu self._states = [PlanarianState(i) for i in range(self.max_planarians)] @@ -368,10 +370,13 @@ class PlanarianTracker: # Mise à jour de l'état state.update(cx, cy, ts) - # Annotation visuelle - color = INDIVIDUAL_COLORS[slot_idx % len(INDIVIDUAL_COLORS)] - cv2.drawContours(frame_out, [contour], -1, color, 2) - self._draw_center(frame_out, cx, cy, slot_idx, speed_px_s, axial_pos, color) + + if self.draw_contours: + + # Annotation visuelle + color = INDIVIDUAL_COLORS[slot_idx % len(INDIVIDUAL_COLORS)] + cv2.drawContours(frame_out, [contour], -1, color, 2) + self._draw_center(frame_out, cx, cy, slot_idx, speed_px_s, axial_pos, color) results.append({ "planarian_id": slot_idx, diff --git a/test_tube_scanner/planarian/admin.py b/test_tube_scanner/planarian/admin.py index 5d80cc7..f0470d9 100644 --- a/test_tube_scanner/planarian/admin.py +++ b/test_tube_scanner/planarian/admin.py @@ -21,7 +21,7 @@ class ExperimentConfigAdmin(admin.ModelAdmin): (_("Identification"), { "fields": ("identifier", "experiment", "well", "description"), }), - (_("Calibration optique"), { + (_("Calibration optique: générée lors de la calibration"), { "fields": ("px_per_mm", "fps", "well_radius_mm"), "classes": ("collapse",), }), diff --git a/test_tube_scanner/planarian/models.py b/test_tube_scanner/planarian/models.py index b02d98e..b7c0e6c 100644 --- a/test_tube_scanner/planarian/models.py +++ b/test_tube_scanner/planarian/models.py @@ -77,13 +77,13 @@ class ExperimentConfig(models.Model): merge_kernel_size = models.PositiveIntegerField( _("Taille du kernel"), - help_text=_("taille du kernel elliptique de fusion des fragments (px)"), + help_text=_("taille du kernel elliptique de fusion des fragments (px). Augmenter si fragments résiduels"), default=15 ) min_contour_dist_px = models.PositiveIntegerField( _("Distance "), - help_text=_("Distance min entre deux contours pour les considérer comme individus distincts. Défaut : 40px."), + help_text=_("Distance min entre deux contours pour les considérer comme individus distincts. Défaut : 40px. Augmenter si IDs multiples persistent"), default=40 ) diff --git a/test_tube_scanner/planarian/templates/planarian/experiment_form.html b/test_tube_scanner/planarian/templates/planarian/experiment_form.html index 492d734..7944f26 100644 --- a/test_tube_scanner/planarian/templates/planarian/experiment_form.html +++ b/test_tube_scanner/planarian/templates/planarian/experiment_form.html @@ -254,7 +254,7 @@
diff --git a/test_tube_scanner/run-server.sh b/test_tube_scanner/run-server.sh new file mode 100755 index 0000000..8e77237 --- /dev/null +++ b/test_tube_scanner/run-server.sh @@ -0,0 +1,5 @@ +#!/bin/bash + +echo "==== Starting server development in debug mode port 8000 ..." +echo "====" +../.venv/bin/python manage.py runserver 0.0.0.0:8000 diff --git a/test_tube_scanner/scanner/constants.py b/test_tube_scanner/scanner/constants.py index 1c3bb6a..acec259 100644 --- a/test_tube_scanner/scanner/constants.py +++ b/test_tube_scanner/scanner/constants.py @@ -28,6 +28,7 @@ class DefaultConfig: calibration_default_step: float = 1.0 calibration_default_duration: float = 3.0 tracking: bool = False + tube_axis: str = 'vertical' min_area_px: int = 20 max_area_ratio: float = 0.10 max_planarians: int = 1 diff --git a/test_tube_scanner/scanner/models.py b/test_tube_scanner/scanner/models.py index c37d1bb..662d557 100644 --- a/test_tube_scanner/scanner/models.py +++ b/test_tube_scanner/scanner/models.py @@ -37,6 +37,11 @@ CAPTURE_TYPE = [ ('webcam', _("Webcam")), ('file', _("mp4")), ] + +TUBE_AXIS_TYPE = [ + ('vertical', _("Vertical")), + ('horizontal', _("Horizontal")), +] class Configuration(models.Model): name = models.CharField(_("Nom de la Configuration"), help_text=_("Nom de la configuration"), max_length=100, null=True, blank=False, default=_("Configuration par défaut")) @@ -66,14 +71,15 @@ class Configuration(models.Model): calibration_default_duration = models.FloatField(_("Duruée calibration"), help_text=_("Durée de pose entre chaque puits en s"), default=3.0) # tracking tracking = models.BooleanField(_("Suivi"), help_text=_("Suivi et analyse des planaires"), default=False) + tube_axis = models.CharField(_("Axe du puit"), help_text=_("Axe du tube"), default='vertical', max_length=16, choices=TUBE_AXIS_TYPE, null=True, blank=False) min_area_px = models.PositiveIntegerField(_("Surface minimale"), help_text=_("surface minimale d'un contour pour être considéré valide (px²)"), default=20) max_area_ratio = models.FloatField(_("surface maximale "), help_text=_("surface maximale d'un contour en fraction de la frame (défaut 10%)"), default=0.10) max_planarians = models.PositiveIntegerField(_("Max planaire"), help_text=_("nombre maximum de planaires à suivre simultanément (1-10)"), default=1) - merge_kernel_size = models.PositiveIntegerField(_("Taille du kernel"), help_text=_("taille du kernel elliptique de fusion des fragments (px)"), default=15) - min_contour_dist_px = models.PositiveIntegerField(_("Distance "), help_text=_("Distance min entre deux contours pour les considérer comme individus distincts. Défaut : 40px."), default=40) + merge_kernel_size = models.PositiveIntegerField(_("Taille du kernel"), help_text=_("taille du kernel elliptique de fusion des fragments (px). Augmenter si fragments résiduels"), default=15) + min_contour_dist_px = models.PositiveIntegerField(_("Distance "), help_text=_("Distance min entre deux contours pour les considérer comme individus distincts. Défaut : 40px. Augmenter si IDs multiples persistent"), default=40) # active = models.BooleanField(_("Actif"), default=False) - + @classmethod def active_config(cls): @@ -115,7 +121,7 @@ class MultiWell(models.Model): 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") # Balayage 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 por la calibration'), blank=False, default=10) xbase = models.FloatField(_("Origine X"), help_text=_('Base origine X en mm'), blank=False, default=50.0) ybase = models.FloatField(_("Origine Y"), help_text=_('Base origine Y en mm'), blank=False, default=50.0) @@ -231,6 +237,7 @@ class Experiment(models.Model): author = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name="Auteur", null=True, blank=True) multiwell = models.ForeignKey(MultiWell, verbose_name=_("Multi-puits"), on_delete=models.SET_NULL, null=True, blank=True) + duration = models.PositiveIntegerField(_("Durée"), help_text=_('Durée de la prise de vue en secondes'), blank=False, default=120) created = models.DateTimeField(_("Date de création"), default=timezone.now) started = models.DateTimeField (_("Date de début"), null=True, blank=True) finished = models.DateTimeField (_("Date de fin"), null=True, blank=True) diff --git a/test_tube_scanner/scanner/multiwell.py b/test_tube_scanner/scanner/multiwell.py index 0abce48..2259d34 100644 --- a/test_tube_scanner/scanner/multiwell.py +++ b/test_tube_scanner/scanner/multiwell.py @@ -149,66 +149,83 @@ class MultiWellManager: #def _grid_scanning_capture(self, uuid, duration): def _grid_scanning_capture(self, experiment, well_position, simulate=False): - well = well_position.well - multiwell = experiment.multiwell - if self.process.use_tracking: - logger.info("# Paramètres d'une expérience PlanarianScanner") + try: + well = well_position.well + multiwell = experiment.multiwell + if self.process.use_tracking: + cfg = ExperimentConfig.objects.filter(experiment_id=experiment.id, well_id=well.id).first() + if not cfg: + raise Exception(f"Configuration d'expérience introuvable pour {experiment} / {well}") + # reset PlanarianTracker => on_well_change + self.process.cam.on_well_change(cfg, draw_contours=False) - cfg = ExperimentConfig.objects.get(experiment_id=experiment.id, well_id=well.id) - # reset PlanarianTracker => on_well_change - self.process.cam.on_well_change(cfg) - - uuid = f'{self.process.data.session}-{multiwell.position}-{well.name}' - ## start recording - self.process.data.uuid = uuid - if not simulate: - self.process.data.record = True - - start = time.monotonic() - while not self.stop_playing.is_set(): - if time.monotonic() - start > multiwell.duration: - break - self.cnc_controller.wait_for(0.1) + uuid = f'{self.process.data.session}-{multiwell.position}-{well.name}' + ## start recording + self.process.data.uuid = uuid + if not simulate: + self.process.data.record = True - self.process.data.record = False - self.process.data.uuid = None - - msg = f"{uuid}: capture done" + start = time.monotonic() + while not self.stop_playing.is_set(): + if time.monotonic() - start > multiwell.duration: + break + self.cnc_controller.wait_for(0.1) + + self.process.data.record = False + self.process.data.uuid = None + + msg = f"{uuid}: capture done..." + except Exception as e: + msg = f"error during capture - {e}" + logger.error(msg) + logger.info(msg) - self.process._send(scan_state=msg) - + self.process._send(scan_state=msg) + + + def _capture_file_simulation(self, name): + vf = settings.MEDIA_ROOT / 'simulation' / f'{name}.mp4' + if vf.exists(): + self.process.cam._video_file = str(vf) + self.process.cam._error_occured = True + logger.info(f"Simulating capture with file {vf}") + def _grid_scanning(self, experiment, xnext=0, ynext=0, simulate=False): - multiwell = experiment.multiwell - wells = models.WellPosition.objects.filter(multiwell_id=multiwell.id).order_by('order').all() - cam = self.process.cam - cam._aligner.set_tube_diameter(multiwell.diameter) + try: + multiwell = experiment.multiwell + wells = models.WellPosition.objects.filter(multiwell_id=multiwell.id).order_by('order').all() + cam = self.process.cam + cam._aligner.set_tube_diameter(multiwell.diameter) + + for wl in wells: + if self.stop_playing.is_set(): + break + self.cnc_controller.move_to(wl.x, wl.y, feed=wl.multiwell.feed) + + ## change file + if self.process.conf.capture_type == 'file': + self._capture_file_simulation(wl.well.name) + + self._grid_scanning_capture(experiment, wl, simulate=simulate) + msg =f"Scan terminé — retour à l'origine (X={xnext:.1f} Y={ynext:.1f})" + logger.info(msg) + self.process._send(state='scan_finished', msg=msg) + + except Exception as e: + msg = f"Error during grid scanning - {e}" + logger.error(msg) + self.process._send(state='error', msg=msg) - self.stop_playing = Event() - for wl in wells: - if self.stop_playing.is_set(): - break - self.cnc_controller.move_to(wl.x, wl.y, feed=wl.multiwell.feed) - - ## change file - if self.process.conf.capture_type == 'file': - vf = settings.MEDIA_ROOT / 'simulation' / f'{wl.well.name}.mp4' - if vf.exists(): - self.process.cam._video_file = str(vf) - self.process.cam._error_occured = True - - logger.info(f"Simulating capture with file {vf}") - - self._grid_scanning_capture(experiment, wl, simulate=simulate) - - - 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) + finally: + self.cnc_controller.move_to(xnext, ynext, feed=multiwell.feed*2) def _start_scanning(self, session, experiments, simulate=False): try: self.process.cam._aligner.debug = False + self.stop_playing.clear() + xynext = [] for obs in experiments: xynext.append((obs.multiwell.xbase, obs.multiwell.ybase)) @@ -240,6 +257,8 @@ class MultiWellManager: msg = f"Session {session.name} terminée à {session.finished} après {session.finished - started} secondes." logger.info(msg) self.process._send(scan_state=msg) + except Exception as e: + logger.error("Error during scanning process", e) finally: self.scan_thread = None @@ -265,20 +284,28 @@ class MultiWellManager: def previous_well(self): wl = self.well_iterator.previous() + if self.process.conf.capture_type == 'file': + self._capture_file_simulation(wl.well.name) self.cnc_controller.move_to(wl.x, wl.y, feed=wl.multiwell.feed) - return {"state": "previous", "msg": f">>> ({wl.x}, {wl.y})"} + return {"state": "previous", "msg": f">>> {wl.well.name}: ({wl.x}, {wl.y})"} def next_well(self): wl = self.well_iterator.next() + if self.process.conf.capture_type == 'file': + self._capture_file_simulation(wl.well.name) + self.cnc_controller.move_to(wl.x, wl.y, feed=wl.multiwell.feed) - return {"state": "next", "msg": f">>> ({wl.x}, {wl.y})"} + return {"state": "next", "msg": f">>> {wl.well.name}: ({wl.x}, {wl.y})"} def goto_well(self, numwell): wl = self.well_iterator.seek(numwell) + if self.process.conf.capture_type == 'file': + self._capture_file_simulation(wl.well.name) + self.cnc_controller.move_to(wl.x, wl.y, feed=wl.multiwell.feed) - return {"state": "goto", "msg": f">>> ({wl.x}, {wl.y})"} + return {"state": "goto", "msg": f">>> {wl.well.name}: ({wl.x}, {wl.y})"} def set_well_position(self): @@ -293,7 +320,7 @@ class MultiWellManager: def _scanning_test(self, auto=False): - self.stop_playing = Event() + self.stop_playing.clear() cam = self.process.cam cam._aligner.set_tube_diameter(self.multiwell.diameter) duration = self.duration if not auto else settings.CALIBRATION_AUTO_DURATION @@ -356,7 +383,6 @@ class MultiWellManager: self.test_thread = Thread(target=self._scanning_test, args=(auto, ), daemon=True) self.test_thread.start() - @property def position(self): return self._position @@ -421,14 +447,12 @@ class MultiWellManager: def dy(self, value): self._dy = value - def get_well_order(self): wl = self.well_iterator.get_current() if wl: return wl.order return None - def set_position(self): x, y = self.cnc_controller.get_mpos() self.cnc_controller.wait_for(2.0) @@ -438,7 +462,6 @@ class MultiWellManager: wl.x, wl.y = x, y wl.px_per_mm = self.px_per_mm wl.save() - def calib_toggle_debug(self): """ Active / désactive le mode debug sur le stream.""" diff --git a/test_tube_scanner/scanner/process.py b/test_tube_scanner/scanner/process.py index a378b0b..124597a 100644 --- a/test_tube_scanner/scanner/process.py +++ b/test_tube_scanner/scanner/process.py @@ -21,12 +21,19 @@ from celery.exceptions import Ignore from celery.utils.log import get_task_logger from redis import Redis from dataclasses import dataclass -from modules import reductstore, grbl, utils, planarian_metrics +from modules import reductstore, utils, planarian_metrics ## camera devices from modules.circular_crop import CircularCrop, CropStrategy from .multiwell import MultiWellManager from .constants import ScannerConstants + +# CNC +if not settings.GRBL_SIMULATION: + from modules.grbl import GRBLController # @UnusedImport +else: + from modules.grbl_simulator import GRBLController # @Reimport + from . import models @@ -223,7 +230,7 @@ class ScannerProcess(Task): self.cam._active_median = False self.cam.set_circular_crop(None) - self.grbl = grbl.GRBLController( + self.grbl = GRBLController( send_callback=self._display, x_max=self.conf.grbl_xmax, y_max=self.conf.grbl_ymax @@ -328,6 +335,8 @@ class ScannerProcess(Task): self._send(state='serial', msg=f"Connected {self.grbl.port}") self.grbl.go_origin(feed=feed) + if self.conf.capture_type == 'file': + self.manager._capture_file_simulation('zero') self.grbl.wait_for(2.0) @@ -363,6 +372,7 @@ class ScannerProcess(Task): self.cam.set_circular_crop(self.crop) self.cam._active_median = False self.grbl.go_origin(feed=self.manager.feed) + self.cam.set_draw_contours(False) elif topic == 'scan' or topic == 'simulate': logger.info(f"==== Scan {cmd}") @@ -371,7 +381,6 @@ class ScannerProcess(Task): if sid == "0": self._send(state='error', msg=str(_('La session est nulle!...'))) else: - try: self.cam._active_median = False simulate = (topic=='simulate') @@ -397,7 +406,8 @@ class ScannerProcess(Task): position = cmd.get("position") self.manager.set_multiwell(position) self.cam.set_circular_crop(None) - self.cam._active_median = False + self.cam._active_median = False + self.cam.set_draw_contours(False) buttons = self.manager.multiwell_buttons() elif topic == 'up': @@ -444,10 +454,15 @@ class ScannerProcess(Task): elif topic == 'goto_0': self.grbl.go_origin(feed=self.manager.feed) self.manager.well_iterator.reset() + self.manager.well_iterator.seek(0) + if self.conf.capture_type == 'file': + self.manager._capture_file_simulation('zero') elif topic == 'goto_xy': self.grbl.move_to(self.manager.xbase, self.manager.ybase, feed=self.manager.feed) - self.manager.well_iterator.seek(0) + wl = self.manager.well_iterator.seek(0) + if self.conf.capture_type == 'file': + self.manager._capture_file_simulation(wl.well.name) elif topic == 'xy_base': self.manager.set_position() @@ -495,9 +510,10 @@ class ScannerProcess(Task): msg = self.manager.set_well_position() self._send(**msg) - elif topic == 'track': - self.cam.use_tracking = value=="1" - self._send(state=topic, msg=f"Tracking: {self.cam.use_tracking}") + elif topic == 'draw': + draw = (value=="1") + self.cam.set_draw_contours(draw) + self._send(state=topic, msg=f"Tracking contour: {draw}") elif topic in ['min_area_px', 'max_area_ratio', 'max_planarians', 'merge_kernel_size', 'min_contour_dist_px']: value = int(value) if topic in ['min_area_px', 'max_planarians', 'merge_kernel_size', 'min_contour_dist_px'] else float(value) diff --git a/test_tube_scanner/scanner/static/scanner/js/calibration.js b/test_tube_scanner/scanner/static/scanner/js/calibration.js index 6f8424f..3e97683 100644 --- a/test_tube_scanner/scanner/static/scanner/js/calibration.js +++ b/test_tube_scanner/scanner/static/scanner/js/calibration.js @@ -43,12 +43,14 @@ class ScannerManager { this.crop_radius = options.crop_radius; this.calib_auto = options.calib_auto; - this.min_area_px = options.min_area_px; - this.max_area_ratio = options.max_area_ratio; - this.max_planarians = options.max_planarians; - this.merge_kernel_size = options.merge_kernel_size; - this.min_contour_dist_px = options.min_contour_dist_px; - this.track = options.track; + try { + this.min_area_px = options.min_area_px; + this.max_area_ratio = options.max_area_ratio; + this.max_planarians = options.max_planarians; + this.merge_kernel_size = options.merge_kernel_size; + this.min_contour_dist_px = options.min_contour_dist_px; + this.draw = options.draw; + } catch(e) {} } init_controls() { @@ -79,12 +81,14 @@ class ScannerManager { this.calib_auto.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "auto" }); }); this.halt.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "halt" }); }); - this.min_area_px.addEventListener('change', (e) => { this._send({ type: 'calibrate', topic: "min_area_px", value: e.target.value }); }); - this.max_area_ratio.addEventListener('change', (e) => { this._send({ type: 'calibrate', topic: "max_area_ratio", value: e.target.value }); }); - this.max_planarians.addEventListener('change', (e) => { this._send({ type: 'calibrate', topic: "max_planarians", value: e.target.value}); }); - this.merge_kernel_size.addEventListener('change', (e) => { this._send({ type: 'calibrate', topic: "merge_kernel_size", value: e.target.value}); }); - this.min_contour_dist_px.addEventListener('change', (e) => { this._send({ type: 'calibrate', topic: "min_contour_dist_px", value: e.target.value }); }); - this.track.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "track", value: e.target.value }); }); + try { + this.min_area_px.addEventListener('change', (e) => { this._send({ type: 'calibrate', topic: "min_area_px", value: e.target.value }); }); + this.max_area_ratio.addEventListener('change', (e) => { this._send({ type: 'calibrate', topic: "max_area_ratio", value: e.target.value }); }); + this.max_planarians.addEventListener('change', (e) => { this._send({ type: 'calibrate', topic: "max_planarians", value: e.target.value}); }); + this.merge_kernel_size.addEventListener('change', (e) => { this._send({ type: 'calibrate', topic: "merge_kernel_size", value: e.target.value}); }); + this.min_contour_dist_px.addEventListener('change', (e) => { this._send({ type: 'calibrate', topic: "min_contour_dist_px", value: e.target.value }); }); + this.draw.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "draw", value: e.target.value }); }); + } catch(e) {} } registerSocket(socket) { @@ -99,16 +103,6 @@ class ScannerManager { if (payload.xy) { this.x.textContent=payload.x.toFixed(2); this.y.textContent=payload.y.toFixed(2); } if (payload.state) { this.debug.insertAdjacentHTML('afterbegin', `
  • [ ${++this.debug_count} - ${payload.state} ]: ${payload.msg}
  • `); } if (payload.ts) { this.ts.textContent = timestampToLocalISOString(payload.ts); } - - /* - if (payload.detected && use_tracking) { - this.cx.textContent = payload.cx; this.cy.textContent = payload.cy; - this.speed_px_s.textContent = payload.speed_px_s; - this.axial_speed.textContent = payload.axial_speed; - this.axial_pos.textContent = payload.axial_pos; - this.area_px.textContent = payload.area_px; - this.frame_count.textContent = payload.count; - }*/ if (payload.buttons) { this.well_btn.innerHTML = payload.buttons; } if (payload.current >= 0) { @@ -117,7 +111,6 @@ class ScannerManager { btn.classList.remove('w3-green'); }); } - } catch(e) { console.log(e); } } diff --git a/test_tube_scanner/scanner/templates/scanner/base.html b/test_tube_scanner/scanner/templates/scanner/base.html index b1e3874..fbdb12f 100644 --- a/test_tube_scanner/scanner/templates/scanner/base.html +++ b/test_tube_scanner/scanner/templates/scanner/base.html @@ -11,6 +11,43 @@ + {% if request.user.is_superuser %} + + {% endif %}
    {% endblock %} {% block sidebar %} @@ -63,51 +100,28 @@ {% block sidebar_list %}
    {% block sidebar_command %}{% endblock %} - - {% if request.user.is_superuser %} -
    - - -
    - {% endif %} - {% block sidebar_smenu %} - {% if request.user.is_superuser and request.user.is_staff %} - + {% if request.user.is_superuser or request.user.is_staff %} + + {% trans "Création de sessions d'expériences" %} + +
    + + {% trans "Configuration des expériences" %} + + + {% trans "Gestions des expériences" %} + + {% trans "Calibration" %} - + +
    {% endif %} {% if conf.tracking %} {% trans "Préparation des expériences" %} - {% endif %} + {% endif %} {% trans "Balayage multi-puits" %} diff --git a/test_tube_scanner/scanner/templates/scanner/calibration.html b/test_tube_scanner/scanner/templates/scanner/calibration.html index ada407b..f3cb720 100644 --- a/test_tube_scanner/scanner/templates/scanner/calibration.html +++ b/test_tube_scanner/scanner/templates/scanner/calibration.html @@ -149,10 +149,7 @@
    {% if conf.tracking %} -
    -
    - {% trans 'Configuration du tracking' %} -
    +

    {% trans 'Min contour' %} @@ -172,7 +169,11 @@

    {% trans 'Planaires' %} -
    +
    +
    + + {% trans 'Contour' %} +
    {% endif %}
    @@ -260,7 +261,7 @@ max_planarians : sId("_max_planarians"), merge_kernel_size : sId("_merge_kernel_size"), min_contour_dist_px : sId("_min_contour_dist_px"), - track : sId("_track_it") + draw : sId("_draw") }; diff --git a/test_tube_scanner/scanner/urls.py b/test_tube_scanner/scanner/urls.py index 1bbfc6a..2fade74 100644 --- a/test_tube_scanner/scanner/urls.py +++ b/test_tube_scanner/scanner/urls.py @@ -14,6 +14,11 @@ urlpatterns = [ path('logs/worker', views.supervisor_worker, name='logs_worker'), path('logs/scheduler', views.supervisor_scheduler, name='logs_scheduler'), + path('session/view', views.admin_session_view, name='session_view'), + path('experiment/view', views.admin_experiment_view, name='experiment_view'), + path('experiment/config/view', views.admin_experimentconfig_view, name='experimentconfig_view'), + path('periodictask/view', views.admin_periodictask_view, name='periodictask_view'), + path('scanning/', views.scanning_view, name='scanning'), path('images/', views.images_view, name='images'), path('replay/', views.replay_view, name='replay'), diff --git a/test_tube_scanner/scanner/views.py b/test_tube_scanner/scanner/views.py index e173c15..dc649b2 100644 --- a/test_tube_scanner/scanner/views.py +++ b/test_tube_scanner/scanner/views.py @@ -1,7 +1,7 @@ # from asgiref.sync import async_to_sync import base64, json -from django.shortcuts import render, redirect +from django.shortcuts import render #, redirect from django.http import JsonResponse from django.utils.translation import gettext_lazy as _ from django.views.decorators.http import require_GET, require_POST @@ -12,7 +12,7 @@ from reduct.time import unix_timestamp_to_iso from modules.system_stats import get_cached_stats, start_background_updater from modules import reductstore -from .tasks import download_video, export_all_images, export_all_videos, supervisor_restart_service +from .tasks import download_video, export_all_images, export_all_videos from .process import CameraRecordManager, cameraDB from . import models from .constants import ScannerConstants @@ -39,6 +39,8 @@ def global_context(request, **ctx): app_title=settings.APP_TITLE, app_sub_title=settings.APP_SUB_TITLE, domain_server=settings.DOMAIN_SERVER, + local_ip_server=settings.LOCAL_IP_SERVER, + host_port=settings.SERVER_HOST_PORT, conf=conf, default_position = default_multiwell.position or 'HD', export_destination=settings.EXPORT_DESTINATIONS, @@ -49,29 +51,45 @@ def global_context(request, **ctx): def admin_view(request): return render(request, "scanner/iframe.html", context=global_context(request, link='/admin/')) +@login_required +def admin_session_view(request): + return render(request, "scanner/iframe.html", context=global_context(request, link='/admin/scanner/session/')) + +@login_required +def admin_experiment_view(request): + return render(request, "scanner/iframe.html", context=global_context(request, link='/admin/scanner/experiment/')) + +@login_required +def admin_experimentconfig_view(request): + return render(request, "scanner/iframe.html", context=global_context(request, link='/admin/planarian/experimentconfig/')) + +@login_required +def admin_periodictask_view(request): + return render(request, "scanner/iframe.html", context=global_context(request, link='/admin/django_celery_beat/periodictask/')) + @login_required def reductstore_view(request): - return render(request, "scanner/redirection.html", context=global_context(request, link=f'http://{settings.DOMAIN_SERVER}:8383/')) + return render(request, "scanner/redirection.html", context=global_context(request, link=f'http://{settings.LOCAL_IP_SERVER}:8383/')) @login_required def adminer_view(request): - return render(request, "scanner/redirection.html", context=global_context(request, link=f'http://{settings.DOMAIN_SERVER}/adminer/')) + return render(request, "scanner/redirection.html", context=global_context(request, link=f'http://{settings.LOCAL_IP_SERVER}/adminer/')) @login_required def portainer_view(request): - return render(request, "scanner/redirection.html", context=global_context(request, link=f'http://{settings.DOMAIN_SERVER}:9000/')) + return render(request, "scanner/redirection.html", context=global_context(request, link=f'http://{settings.LOCAL_IP_SERVER}:9000/')) @login_required def supervisor_view(request): - return render(request, "scanner/redirection.html", context=global_context(request, link=f'http://{settings.DOMAIN_SERVER}:9001/')) + return render(request, "scanner/redirection.html", context=global_context(request, link=f'http://{settings.LOCAL_IP_SERVER}:9001/')) @login_required def supervisor_worker(request): - return render(request, "scanner/redirection.html", context=global_context(request, link=f'http://{settings.DOMAIN_SERVER}:9001/logtail/test_tube:services')) + return render(request, "scanner/redirection.html", context=global_context(request, link=f'http://{settings.LOCAL_IP_SERVER}:9001/logtail/test_tube:services')) @login_required def supervisor_scheduler(request): - return render(request, "scanner/redirection.html", context=global_context(request, link=f'http://{settings.DOMAIN_SERVER}:9001/logtail/test_tube:planification')) + return render(request, "scanner/redirection.html", context=global_context(request, link=f'http://{settings.LOCAL_IP_SERVER}:9001/logtail/test_tube:planification')) ## Mainboard @login_required