This commit is contained in:
2026-05-08 11:48:46 +02:00
parent 1575445df4
commit 563069d3c6
19 changed files with 557 additions and 164 deletions
+6 -5
View File
@@ -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
+4 -2
View File
@@ -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
+4
View File
@@ -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")
@@ -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,8 +128,12 @@ 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
+294
View File
@@ -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 <State|MPos:x,y,z>.'''
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")
@@ -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,
+1 -1
View File
@@ -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",),
}),
+2 -2
View File
@@ -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 <contour>"),
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
)
@@ -254,7 +254,7 @@
</div>
<!-- ============================================================
Section 5 : Comportements (accordéon W3.CSS)
Section 5 : Comportements
============================================================ -->
<div class="w3-card-4 w3-round-large w3-margin-bottom w3-border w3-round w3-round-large">
<header class="w3-container w3-teal w3-round w3-round-top-large">
+5
View File
@@ -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
+1
View File
@@ -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
+10 -3
View File
@@ -38,6 +38,11 @@ CAPTURE_TYPE = [
('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"))
author = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name="Auteur", null=True, blank=True)
@@ -66,11 +71,12 @@ 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 <contour>"), 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 <contour>"), 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)
@@ -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)
+71 -48
View File
@@ -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
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)
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
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)
msg = f"{uuid}: capture done"
logger.info(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)
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)
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
## change file
if self.process.conf.capture_type == 'file':
self._capture_file_simulation(wl.well.name)
logger.info(f"Simulating capture with file {vf}")
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)
self._grid_scanning_capture(experiment, wl, simulate=simulate)
except Exception as e:
msg = f"Error during grid scanning - {e}"
logger.error(msg)
self.process._send(state='error', msg=msg)
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)
@@ -439,7 +463,6 @@ class MultiWellManager:
wl.px_per_mm = self.px_per_mm
wl.save()
def calib_toggle_debug(self):
""" Active / désactive le mode debug sur le stream."""
aligner = self.process.cam._aligner
+23 -7
View File
@@ -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')
@@ -398,6 +407,7 @@ class ScannerProcess(Task):
self.manager.set_multiwell(position)
self.cam.set_circular_crop(None)
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)
@@ -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) {
@@ -100,16 +104,6 @@ class ScannerManager {
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.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) {
document.querySelectorAll('button.w3-button.well').forEach(btn => {
@@ -117,7 +111,6 @@ class ScannerManager {
btn.classList.remove('w3-green');
});
}
} catch(e) { console.log(e); }
}
@@ -11,6 +11,43 @@
<a href="#" class="w3-bar-item w3-mobile w3-btn w3-hover-opacity w3-right w3-xlarge w3-text-green" onclick="goFullscreen()" title="{% trans 'Ecran plein' %}">
<i class="fa-solid fa-maximize"></i>
</a>
{% if request.user.is_superuser %}
<div class="w3-dropdown-hover w3-right">
<button class="w3-button w3-dark-plus">&#9776;&nbsp;<span>{% trans "Administrations" %}</span></button>
<div class="w3-dropdown-content w3-bar-block w3-border w3-dark-low" style="right:.25em;">
<hr class="w3-bar-item divider w3-grey">
<a href="{% url 'scanner:admin' %}" class="w3-bar-item w3-btn w3-hover-opacity" title="{% trans "Accès django" %}">
<i class="fa-solid fa-gear w3-text-pink"></i> {% trans "Base de données" %}
</a>
<a href="{% url 'scanner:supervisor' %}" class="w3-bar-item w3-btn w3-hover-opacity" target="_blank" title="{% trans "Accès système" %}">
<i class="fa-solid fa-gears w3-text-purple"></i> {% trans "Supervision des services" %}
</a>
<a href="{% url 'scanner:reductstore' %}" class="w3-bar-item w3-btn w3-hover-opacity" target="_blank" title="{% trans "Accès système" %}">
<img class="w3-no-padding" src="/static/img/reductstore.png" style="width: 24px"> {% trans "Base de données Reductstore" %}
</a>
<a href="{% url 'scanner:adminer' %}" class="w3-bar-item w3-btn w3-hover-opacity" target="_blank" title="{% trans "Accès système" %}">
<img class="w3-no-padding" src="/static/img/adminer.png" style="width: 28px"> {% trans "Base de données mariadb" %}
</a>
<a href="{% url 'scanner:logs_scheduler' %}" class="w3-bar-item w3-btn w3-hover-opacity" target="_blank">
<i class="fa-regular fa-file-lines w3-text-orange"></i> {% trans "Logs des planifications" %}
</a>
<a href="{% url 'scanner:logs_worker' %}" class="w3-bar-item w3-btn w3-hover-opacity" target="_blank">
<i class="fa-regular fa-file-lines w3-text-amber"></i> {% trans "Logs des services" %}
</a>
<hr class="w3-bar-item divider w3-grey">
<form method="post" action="{% url 'logout' %}">
{% csrf_token %}
<button class="w3-bar-item w3-btn w3-hover-opacity" type="submit">
<i class="fa-solid fa-right-from-bracket w3-text-red"></i> {% trans 'Déconnexion' %}
</button>
</form>
</div>
</div>
{% endif %}
</div>
{% endblock %}
{% block sidebar %}
@@ -63,45 +100,22 @@
{% block sidebar_list %}
<div class="w3-dark-light w3-padding-small">
{% block sidebar_command %}{% endblock %}
{% if request.user.is_superuser %}
<div>
<div class="w3-dark-light w3-margin-top-6">
<a href="#" class="w3-btn w3-padding-small" onclick="toggleDisplay(sId('_admin'))"><span class="w3-text-orange w3-xlarge">&#x26F6;</span>
{% trans "Administration" %}
</a>
</div>
<div id="_admin" style="display:none">
<a href="{% url 'scanner:admin' %}" class="w3-bar-item w3-btn w3-hover-opacity w3-margin-left">
<i class="fa-solid fa-gear w3-text-pink"></i> {% trans "Administration base de données" %}
</a>
<a href="{% url 'scanner:reductstore' %}" class="w3-bar-item w3-btn w3-hover-opacity w3-margin-left" target="_blank">
<img class="w3-no-padding" src="/static/img/reductstore.png" style="width: 24px"> {% trans "Base de données Reductstore" %}
</a>
<a href="{% url 'scanner:adminer' %}" class="w3-bar-item w3-btn w3-hover-opacity w3-margin-left" target="_blank">
<img class="w3-no-padding" src="/static/img/adminer.png" style="width: 28px"> {% trans "Base de données mariadb" %}
</a>
<!--a href="{% url 'scanner:portainer' %}" class="w3-bar-item w3-btn w3-hover-opacity" w3-margin-left target="_blank">
<img class="w3-no-padding" src="/static/img/portainer.png" style="width: 28px"> {% trans "Portainer" %}
</a-->
<a href="{% url 'scanner:supervisor' %}" class="w3-bar-item w3-btn w3-hover-opacity w3-margin-left" target="_blank">
<i class="fa-solid fa-gears w3-text-purple"></i> {% trans "Supervisor" %}
</a>
<a href="{% url 'scanner:logs_scheduler' %}" class="w3-bar-item w3-btn w3-hover-opacity w3-margin-left" target="_blank">
<i class="fa-regular fa-file-lines w3-text-orange"></i> {% trans "Logs des planifications" %}
</a>
<a href="{% url 'scanner:logs_worker' %}" class="w3-bar-item w3-btn w3-hover-opacity w3-margin-left" target="_blank">
<i class="fa-regular fa-file-lines w3-text-amber"></i> {% trans "Logs des services" %}
</a>
</div>
</div>
{% endif %}
{% block sidebar_smenu %}
{% if request.user.is_superuser and request.user.is_staff %}
<a href="{% url 'scanner:calibration' %}" class="w3-bar-item w3-btn w3-hover-opacity w3-margin-bottom">
{% if request.user.is_superuser or request.user.is_staff %}
<a href="{% url 'scanner:session_view' %}" class="w3-bar-item w3-btn w3-hover-opacity">
<i class="fa-solid fa-gear w3-text-purple w3-xlarge"></i> {% trans "Création de sessions d'expériences" %}
</a>
<hr class="w3-bar-item divider w3-grey">
<a href="{% url 'scanner:experimentconfig_view' %}" class="w3-bar-item w3-btn w3-hover-opacity">
<i class="fa-solid fa-gears w3-text-pink w3-xlarge"></i> {% trans "Configuration des expériences" %}
</a>
<a href="{% url 'scanner:experiment_view' %}" class="w3-bar-item w3-btn w3-hover-opacity">
<i class="fa-solid fa-gear w3-text-pink w3-xlarge"></i> {% trans "Gestions des expériences" %}
</a>
<a href="{% url 'scanner:calibration' %}" class="w3-bar-item w3-btn w3-hover-opacity">
<i class="fa-solid fa-wrench w3-text-red w3-xlarge"></i> {% trans "Calibration" %}
</a>
<hr class="w3-bar-item divider w3-grey w3-margin-bottom">
{% endif %}
{% if conf.tracking %}
<a href="{% url 'planarian:experiment-list' %}" class="w3-bar-item w3-btn w3-hover-opacity">
@@ -149,10 +149,7 @@
</div>
</div>
{% if conf.tracking %}
<div class="w3-row w3-row-padding w3-light-grey w3-margin-1 w3-round w3-round-large w3-margin-top">
<div class="w3-col"><input id="_track_it" type="checkbox" value="0" onclick="if (this.checked){this.value='1';}else{ this.value='0';}">
{% trans 'Configuration du tracking' %}
</div>
<div class="w3-row w3-row-padding w3-light-grey w3-padding-small w3-margin-1 w3-round w3-round-large w3-margin-top w3-left-align w3-paddin-top">
<div class="w3-half">
<input id="_min_area_px" type="number" min="5" max="100" step="1" class="w3-round" value="{{ conf.min_area_px }}"
title="{% trans 'surface minimale de contour pour être considéré valide (px carré)' %}"><br>{% trans 'Min contour' %}
@@ -173,6 +170,10 @@
<input id="_max_planarians" type="number" min="1" max="10" step="1" class="w3-round" value="{{ conf.max_planarians }}"
title="{% trans 'Nombre maximum de planaires à suivre simultanément (1-10)' %}"><br>{% trans 'Planaires' %}
</div>
<div class="w3-half">
<input id="_draw" type="checkbox" value="0" onclick="if (this.checked){this.value='1';}else{ this.value='0';}" title="{% trans 'Dessiner le contour' %}">
<span class="w3-nbsp">{% trans 'Contour' %}</span>
</div>
</div>
{% endif %}
<div class="w3-row w3-row-padding">
@@ -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")
};
</script>
<script src="/static/scanner/js/calibration.js"></script>
+5
View File
@@ -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'),
+26 -8
View File
@@ -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