Files
PlanarianScanner/test_tube_scanner/scanner/multiwell.py
T
2026-05-17 19:42:49 +02:00

524 lines
19 KiB
Python

'''
scanner/multiwell.py
WellIterator: Itérateur personnalisé pour naviguer dans les Wells
MultiWellManager: Manager des multi-puits
Created on 20 avr. 2026
@author: denis
'''
import logging
import time
from threading import Thread, Event
#from django.utils.translation import gettext_lazy as _
from django.utils import timezone
from django.utils.html import mark_safe
from django.conf import settings
from planarian.models import ExperimentConfig
from . import models
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
logger.info(f"MultiWellManager initialized with CNC controller: {self.cnc_controller}")
self.stop_playing = Event()
self.well_iterator = None
self.multiwel = None
self.set_default_values()
self.set_multiwell()
self.scan_thread = None
self.test_thread = None
self.tracker_config = dict(
tube_axis = settings.TRACKER_TUBE_AXIS,
min_area_px = self.process.conf.min_area_px,
max_area_ratio = self.process.conf.max_area_ratio,
max_planarians = self.process.conf.max_planarians,
merge_kernel_size = self.process.conf.merge_kernel_size,
min_contour_dist_px = self.process.conf.min_contour_dist_px,
)
def set_tracker_config(self):
self.tracker_config = dict(
tube_axis = settings.TRACKER_TUBE_AXIS,
min_area_px = self.process.conf.min_area_px,
max_area_ratio = self.process.conf.max_area_ratio,
max_planarians = self.process.conf.max_planarians,
merge_kernel_size = self.process.conf.merge_kernel_size,
min_contour_dist_px = self.process.conf.min_contour_dist_px,
)
logger.info(f"Tracker config: {self.tracker_config}")
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
self.px_per_mm = 50.0
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.WellPosition.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, btn_class="w3-button", onclick=''' onclick="goto_well(this)"'''):
multiwells = []
multiwells.append('''<div class="w3-border well-btn">''')
for wl in self.well_iterator:
multiwells.append(f"""<button class="{btn_class} well" value="{wl.order}"{onclick}>{wl.well.name}</button>""")
multiwells.append('''</div>''')
self.well_iterator.reset()
return mark_safe("\n".join(multiwells))
#def _grid_scanning_capture(self, uuid, duration):
def _grid_scanning_capture(self, experiment, well_position, simulate=False):
uuid = None
try:
well = well_position.well
multiwell = experiment.multiwell
## create uuid for this capture
uuid = f'{self.process.data.session}-{multiwell.position}-{well.name}'
if self.process.use_tracking:
cfg = ExperimentConfig.objects.filter(experiment_key_id=experiment.id, well=well.name).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, uuid=uuid, draw_contours=False)
## start recording
self.process.data.uuid = uuid
if not simulate:
self.process.data.record = True
self.process._send(current=well_position.order)
msg = f"Starting capture for {uuid} ordre: {well_position.order}"
logger.info(msg)
self.process._send(well_state=msg)
start = time.monotonic()
while not self.stop_playing.is_set():
## stop after duration in experiemnt now
if time.monotonic() - start > experiment.duration:
break
self.cnc_controller.wait_for(0.1)
self.process.cam._flush_current_well(uuid)
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)
finally:
self.process.cam._flush_current_well(uuid)
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 _is_well_valid(self, welposition, experiment):
names = models.ExperimentWell.wellname_by_experiment(experiment.id)
if welposition.well.name not in names:
return False
return True
def _grid_scanning(self, experiment, xnext=0, ynext=0, simulate=False):
try:
multiwell = experiment.multiwell
wellpositions = 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 wellpositions:
if self.stop_playing.is_set():
break
if not self._is_well_valid(wl, experiment):
continue
self.cnc_controller.move_to(wl.x, wl.y, feed=wl.multiwell.feed)
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)
return True
except Exception as e:
msg = f"Error during grid scanning - {e}"
logger.error(msg)
self.process._send(state='error', msg=msg)
return False
finally:
self.cnc_controller.move_to(xnext, ynext, feed=multiwell.feed*2)
def _start_scanning(self, session, experiments, simulate=False):
result = False
try:
self.process.get_config() # get video configuration if updated
self.process.cam._aligner.debug = False
self.stop_playing.clear()
xynext = []
for obs in experiments:
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 experiments:
msg = f"Starting scan for {obs} (well {pos}/{len(experiments)})"
logger.warning(msg)
self.process._send(well_state=msg)
if self.stop_playing.is_set():
break
obs.started = timezone.now()
obs.save()
xnext, ynext = xynext[pos]
pos +=1
result = self._grid_scanning(obs, xnext=xnext, ynext=ynext, simulate=simulate)
obs.finished = timezone.now()
obs.save()
session.finished = timezone.now()
if self.stop_playing.is_set() or not result:
msg = f"Session {session.name} abandonnée à {session.finished} après {session.finished - started} secondes."
else:
if not simulate:
session.active = False
if session.scanning_task:
session.scanning_task.enabled = False
session.save()
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
self.goto_0()
self.process._send(current=self.get_well_order())
def halt_scanning(self):
self.process.data.record = False
self.stop_playing.set()
self.well_iterator.reset()
self.process.cam._aligner.debug = False
self.scan_thread = None
self.test_thread = None
def scan_process(self, sid, simulate=False):
if self.scan_thread:
return
session = models.Session.objects.get(pk=sid)
experiments = models.SessionExperiment.experiment_by_session(sid)
self.scan_thread = Thread(target=self._start_scanning, args=(session, experiments, simulate, ), daemon=True)
self.scan_thread.start()
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.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.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.well.name}: ({wl.x}, {wl.y})"}
def goto_xy(self):
wl = self.well_iterator.seek(0)
if self.process.conf.capture_type == 'file':
self._capture_file_simulation(wl.well.name)
self.cnc_controller.move_to(self.xbase, self.ybase, feed=self.feed)
def goto_0(self):
self.well_iterator.reset()
if self.process.conf.capture_type == 'file':
self._capture_file_simulation('zero')
self.cnc_controller.move_to(0, 0, feed=self.feed*2)
def set_well_position(self):
wl = self.well_iterator.get_current()
if wl:
wl.x, wl.y = self.cnc_controller.get_mpos()
wl.save()
if wl.order == 0:
models.MultiWell.objects.filter(position__exact=wl.multiwell.position).update(xbase=wl.x, ybase=wl.y)
return {"state": "well_position", "msg": f">>> saved ({wl.x}, {wl.y})"}
return {"state": "well_position", "msg": f">>> pas de puit"}
def _scanning_test(self, auto=False):
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
try:
start_test = time.monotonic()
for wl in self.well_iterator:
if self.stop_playing.is_set():
break
self.cnc_controller.wait_for(2.0)
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.process._send(current=wl.order)
start = time.monotonic()
while not self.stop_playing.is_set():
if time.monotonic() - start > duration:
break
if auto:
msg = cam.align_detection["msg"]
if cam.align_detection.get('detected'):
if cam.align_detection.get('action')=="grbl":
self.cnc_controller.wait_for(settings.CALIBRATION_AUTO_TIMEOUT)
dx_mm, dy_mm = cam.align_detection["offset_x_mm"], cam.align_detection["offset_y_mm"]
self.cnc_controller.move_to(self.cnc_controller.x + dx_mm, self.cnc_controller.y + dy_mm, feed=150)
self.process._send(state='center', msg=msg)
elif cam.align_detection.get('action') in ['none']:
msg = f"Ok centre trouvé. {msg}"
logger.info(msg)
self.process._send(state='save', msg=msg)
wl.x, wl.y = self.cnc_controller.x, self.cnc_controller.y
wl.px_per_mm = cam.align_detection.get('px_per_mm')
wl.save()
if wl.order == 0:
models.MultiWell.objects.filter(position__exact=self.position).update(xbase=wl.x, ybase=wl.y)
break
else:
logger.info(msg)
self.process._send(state='center', msg=msg)
self.cnc_controller.wait_for(0.1)
logger.info("Fin du test")
except Exception as e:
logger.error("Error during scanning test", e)
finally:
self.test_thread = None
self.well_iterator.reset()
self.process.cam._aligner.debug = False
logger.info(f"Scan terminé — retour à l'origine (X=0, Y=0) en {int(time.monotonic()-start_test)} s")
self.goto_0()
self.process._send(current=self.get_well_order())
def scan_test(self, auto=False):
if self.test_thread:
return
self.test_thread = Thread(target=self._scanning_test, args=(auto, ), daemon=True)
self.test_thread.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 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)
models.MultiWell.objects.filter(position__exact=self.position).update(xbase=x, ybase=y)
self._xbase, self._ybase = x, y
wl = self.well_iterator.seek(0) # base puit 0
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."""
aligner = self.process.cam._aligner
aligner.debug = not aligner.debug
return {"state": "debug", "value": aligner.debug, "msg": f"Debug: {aligner.debug}"}
def set_calib_debug(self, value=True):
""" Active / désactive le mode debug sur le stream."""
aligner = self.process.cam._aligner
aligner.debug = value
return {"state": "debug", "value": aligner.debug, "msg": f"Debug: {aligner.debug}"}