From 1bc7e5eb9eb8a6d913ba523a5d3d970c820056e0 Mon Sep 17 00:00:00 2001 From: denis defolie Date: Wed, 6 May 2026 23:35:31 +0200 Subject: [PATCH] simulation --- test_tube_scanner/home/settings.py | 1 + .../modules/capture_interface.py | 28 ++-- .../modules/planarian_tracker.py | 5 +- test_tube_scanner/planarian/models.py | 2 +- test_tube_scanner/scanner/consumers.py | 1 - test_tube_scanner/scanner/multiwell.py | 135 ++++++++++-------- test_tube_scanner/scanner/process.py | 73 ++++++---- .../scanner/static/scanner/js/scanning.js | 3 + test_tube_scanner/scanner/tasks.py | 61 ++++++++ .../scanner/templates/scanner/base.html | 2 +- .../scanner/templates/scanner/scanning.html | 6 +- test_tube_scanner/scanner/urls.py | 1 + test_tube_scanner/scanner/views.py | 19 ++- 13 files changed, 232 insertions(+), 105 deletions(-) diff --git a/test_tube_scanner/home/settings.py b/test_tube_scanner/home/settings.py index b209a9b..0bb2a92 100644 --- a/test_tube_scanner/home/settings.py +++ b/test_tube_scanner/home/settings.py @@ -239,6 +239,7 @@ DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' LOGGING_FILE = config('LOGGING_FILE') IS_LOGGING = config('IS_LOGGING', cast=bool) +IS_LOGGING = False LOGGING = None if not IS_LOGGING else { 'version': 1, diff --git a/test_tube_scanner/modules/capture_interface.py b/test_tube_scanner/modules/capture_interface.py index 9e0554a..8ce3843 100644 --- a/test_tube_scanner/modules/capture_interface.py +++ b/test_tube_scanner/modules/capture_interface.py @@ -84,7 +84,7 @@ class VideoCaptureInterface(abc.ABC): self._tracker = None self._metrics = None - self._paramss = None + self._params = None # Tracker générique, pour simulation self.on_test_well_change(**self.DEFAULT_TRACKER_CONFIG) @@ -94,14 +94,17 @@ class VideoCaptureInterface(abc.ABC): dead_zone_px = 5, # en-dessous → rien à faire display = display, ) - self.align_detection = None # résultat du test + self.align_detection = None # résultat du test def on_test_well_change(self, **cfg): - if self.use_tracking and cfg: - self._tracker = PlanarianTracker(**cfg) - logger.warning(f"New tracker conf: {cfg}") - + try: + if self.use_tracking and cfg: + self._tracker = PlanarianTracker(**cfg) + logger.info("Tracker de test créé avec conf : %s", cfg) + except Exception as e: + logger.error(f"Error creating tracker with conf {cfg}: {e}") + self._tracker = None def on_well_change(self, cfg): """ @@ -112,10 +115,12 @@ class VideoCaptureInterface(abc.ABC): if not self.use_tracking: return - params = self.DEFAULT_TRACKER_CONFIG if not cfg else cfg.to_params_dict() - + params = self.DEFAULT_TRACKER_CONFIG if not cfg else cfg.to_params_dict() self._params = ExperimentParams(params) - self._metrics = self._params.build_metrics() + #self._metrics = self._params.build_metrics() + + self._metrics = [self._params.build_metrics() for _ in range(self._params.planarian_count)] + self._tracker = PlanarianTracker( tube_axis = self._params.tube_axis, min_area_px = self._params.min_area_px, @@ -265,12 +270,13 @@ class VideoCaptureInterface(abc.ABC): :return: Image traitée (JPEG ou PNG selon la stratégie) """ metrics = {"detected": False} + if self._circular_crop is not None: jpeg = self._circular_crop.process(jpeg_bytes) nparr = np.frombuffer(jpeg, np.uint8) frame = cv2.imdecode(nparr, cv2.IMREAD_COLOR) if frame is None: - return jpeg, metrics + return jpeg, metrics try: # Mode debug if self._aligner.debug: @@ -278,7 +284,7 @@ class VideoCaptureInterface(abc.ABC): annotated = self.align_detection.get('frame_annotated') frame = annotated if annotated is not None else frame ## - # mode racking + # mode tracking if self.use_tracking: ts = datetime.now(timezone.utc).timestamp() frame, metrics = self._tracker.process(frame, ts) diff --git a/test_tube_scanner/modules/planarian_tracker.py b/test_tube_scanner/modules/planarian_tracker.py index c818a50..8ef2c01 100644 --- a/test_tube_scanner/modules/planarian_tracker.py +++ b/test_tube_scanner/modules/planarian_tracker.py @@ -20,6 +20,10 @@ import cv2 import numpy as np from scipy.optimize import linear_sum_assignment +import logging + + +logger = logging.getLogger(__name__) # Nombre maximum de planaires suivis simultanément par tube MAX_PLANARIANS = 10 @@ -386,7 +390,6 @@ class PlanarianTracker: for state in self._states: if state.idx not in assigned_slots: state.mark_lost() - return frame_out, results # ------------------------------------------------------------------ # diff --git a/test_tube_scanner/planarian/models.py b/test_tube_scanner/planarian/models.py index 684946a..b02d98e 100644 --- a/test_tube_scanner/planarian/models.py +++ b/test_tube_scanner/planarian/models.py @@ -135,7 +135,7 @@ class ExperimentConfig(models.Model): def to_params_dict(self) -> dict: """Retourne un dict compatible avec ExperimentParams.""" return { - "experiment": self.idendifier, + "experiment": self.identifier, "well": self.well.name, "px_per_mm": self.px_per_mm, "fps": self.fps, diff --git a/test_tube_scanner/scanner/consumers.py b/test_tube_scanner/scanner/consumers.py index 3daf173..a465ecc 100644 --- a/test_tube_scanner/scanner/consumers.py +++ b/test_tube_scanner/scanner/consumers.py @@ -24,7 +24,6 @@ class ScannerConsumer(AsyncWebsocketConsumer): logger.info( f"==== Disconnect from {self.this_group}") async def scanner_message(self, event): - logger.info( f"===={event.get('type')}") await self.send(text_data=json.dumps(event["text"])) ## Receive message from WebSocket diff --git a/test_tube_scanner/scanner/multiwell.py b/test_tube_scanner/scanner/multiwell.py index 1aa0f37..0abce48 100644 --- a/test_tube_scanner/scanner/multiwell.py +++ b/test_tube_scanner/scanner/multiwell.py @@ -7,6 +7,8 @@ Created on 20 avr. 2026 @author: denis ''' + + import logging import time from threading import Thread, Event @@ -18,10 +20,8 @@ from planarian.models import ExperimentConfig from . import models -logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) - class WellIterator: """Itérateur personnalisé pour naviguer dans les Wells""" @@ -76,9 +76,12 @@ class WellIterator: 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 @@ -86,6 +89,19 @@ class MultiWellManager: 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, @@ -93,8 +109,8 @@ class MultiWellManager: 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 @@ -136,7 +152,8 @@ class MultiWellManager: well = well_position.well multiwell = experiment.multiwell if self.process.use_tracking: - # Paramètres d'une expérience PlanarianScanner + logger.info("# Paramètres d'une expérience PlanarianScanner") + cfg = ExperimentConfig.objects.get(experiment_id=experiment.id, well_id=well.id) # reset PlanarianTracker => on_well_change self.process.cam.on_well_change(cfg) @@ -151,7 +168,7 @@ class MultiWellManager: while not self.stop_playing.is_set(): if time.monotonic() - start > multiwell.duration: break - self.cnc_controller.wait_for(1.0) + self.cnc_controller.wait_for(0.1) self.process.data.record = False self.process.data.uuid = None @@ -175,10 +192,12 @@ class MultiWellManager: ## change file if self.process.conf.capture_type == 'file': - vf = settings.MEDIA_ROOT / 'simulation' / f'{wl.name}.mp4' + 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 + self.process.cam._error_occured = True + + logger.info(f"Simulating capture with file {vf}") self._grid_scanning_capture(experiment, wl, simulate=simulate) @@ -188,58 +207,60 @@ class MultiWellManager: def _start_scanning(self, session, experiments, simulate=False): - self.process.cam._aligner.debug = False - 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: + try: + self.process.cam._aligner.debug = False + 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: + if self.stop_playing.is_set(): + break + obs.started = timezone.now() + obs.save() + xnext, ynext = xynext[pos] + pos +=1 + 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(): - break - obs.started = timezone.now() - obs.save() - xnext, ynext = xynext[pos] - pos +=1 - 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(): - 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) - self.scan_thread = None + 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) + finally: + self.scan_thread = None def halt_scanning(self): self.process.data.record = False self.stop_playing.set() self.well_iterator.reset() - self.process.cam._aligner.debug = False + self.process.cam._aligner.debug = False + self.scan_thread = None + self.test_thread = None - def scanning(self, sid, simulate=False): - try: - 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).start() - except Exception as e: - print("MultiWellManager::scan error", e) + 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): @@ -316,22 +337,24 @@ class MultiWellManager: self.process._send(state='center', msg=msg) self.cnc_controller.wait_for(0.1) - logger.info("Fin du centrage") + logger.info("Fin du test") except Exception as e: - print(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.cnc_controller.move_to(0, 0, feed=self.multiwell.feed*2) - self.test_thread = None def scan_test(self, auto=False): if self.test_thread: return - self.test_thread = Thread(target=self._scanning_test, args=(auto, ), daemon=True).start() + self.test_thread = Thread(target=self._scanning_test, args=(auto, ), daemon=True) + self.test_thread.start() @property diff --git a/test_tube_scanner/scanner/process.py b/test_tube_scanner/scanner/process.py index 5463b79..0d92d02 100644 --- a/test_tube_scanner/scanner/process.py +++ b/test_tube_scanner/scanner/process.py @@ -27,7 +27,7 @@ from modules import reductstore, grbl, utils, planarian_metrics from modules.circular_crop import CircularCrop, CropStrategy from .multiwell import MultiWellManager from .constants import ScannerConstants -#from . import models +from . import models @dataclass @@ -248,10 +248,12 @@ class ScannerProcess(Task): self.stop_event.set() def start_services(self): - Thread(target=self._listen_to_redis, daemon=True).start() + Thread(target=self._init_manager, daemon=True).start() Thread(target=self._recording, daemon=True).start() Thread(target=self._init_grbl, daemon=True).start() + Thread(target=self._listen_to_redis, daemon=True).start() self.cam.start() + logger.warning(f"Scanner services started ...") def _send(self, **payload): @@ -267,20 +269,21 @@ class ScannerProcess(Task): self._send(**msg) - def _store_metrics(self, uuid, metrics, ts): + def _store_metric(self, uuid, metrics, ts): + + if not isinstance(metrics, list): + return for r in metrics: - pid = r["planarian_id"] + pid = r["planarian_id"] record = self.cam._metrics[pid].update(r, well_radius_mm=self.cam._params.well_radius_mm) - logger.warning(f"{record}") - - async_to_sync(planarianDB.store_metrics)( + + async_to_sync(planarianDB.store_metric)( record, self.cam._params.experiment, - self.cam._params.well_name, + self.cam._params.well, entry_name=uuid, planarian=pid, record_type='metrics', - ts_us=ts, ) def _store_frame(self, uuid, frame, ts, frame_count): @@ -292,15 +295,14 @@ class ScannerProcess(Task): self.recordDB.write(uuid, frame, ts=ts, labels=labels) def _on_frame(self, jpeg_bytes: bytes, ts: datetime, metrics: dict, frame_count: int = 0) -> None: - - if self.data.record: - self.record_queue.put((self.data.uuid, ts, jpeg_bytes, metrics, frame_count)) - if self.data.play: - try: - jpeg=base64.b64encode(jpeg_bytes).decode() - self._send(ts=ts.timestamp(), jpeg=jpeg, frame_count=frame_count) - except Exception as e: - logger.error(f"----_on_frame: {e}") + try: + if self.data.record: + self.record_queue.put((self.data.uuid, ts, jpeg_bytes, metrics, frame_count)) + if self.data.play: + jpeg=base64.b64encode(jpeg_bytes).decode() + self._send(ts=ts.timestamp(), jpeg=jpeg, frame_count=frame_count) + except Exception as e: + logger.error(e) def _recording(self): @@ -308,8 +310,11 @@ class ScannerProcess(Task): while not self.stop_event.is_set(): try: (uuid, ts, frame, metrics, frame_count) = self.record_queue.get() - self._store_metrics(uuid, metrics, ts) - self._frame_store(uuid, frame, ts, frame_count) + if self.cam.use_tracking: + self._store_metric(uuid, metrics, ts) + + self._store_frame(uuid, frame, ts, frame_count) + self.record_queue.task_done() except Exception as e: logger.error(f'recorder: {e}') @@ -324,15 +329,19 @@ class ScannerProcess(Task): self.grbl.wait_for(2.0) + def _init_manager(self): + logger.info(f"==== Start MultiWellManager!") + self.manager = MultiWellManager(process=self) + self.manager.set_calib_debug(False) + Event().wait(2.0) + + def _listen_to_redis(self): try: logger.info(f"==== Scanner {self.group}: listen via redisDB") pubsub = redisDB.pubsub() pubsub.subscribe(self.group) - #self._init_grbl() - self.manager = MultiWellManager(process=self) - self.manager.set_calib_debug(False) - + for message in pubsub.listen(): try: #logger.info(f"{message}") @@ -356,12 +365,22 @@ class ScannerProcess(Task): elif topic == 'scan' or topic == 'simulate': logger.info(f"==== Scan {cmd}") sid = cmd.get("session", '0') + if sid == "0": self._send(state='error', msg=str(_('La session est nulle!...'))) else: - self.cam._active_median = False - self.manager.scanning(sid, simulate=(topic=='simulate')) - self._send(state=topic, msg=str(_('Balayage démarré...'))) + + try: + self.cam._active_median = False + simulate = (topic=='simulate') + self.manager.scan_process(sid, simulate) + + self._send(state=topic, msg=str(_('Balayage démarré...'))) + except Exception as e: + logger.error(f"Scan error: {e}") + self._send(state='error', msg=str(_('Erreur lors du démarrage du balayage...'))) + + continue elif cmd["type"]=="calibrate": topic = cmd.get("topic") diff --git a/test_tube_scanner/scanner/static/scanner/js/scanning.js b/test_tube_scanner/scanner/static/scanner/js/scanning.js index e8c3b75..5a404cc 100644 --- a/test_tube_scanner/scanner/static/scanner/js/scanning.js +++ b/test_tube_scanner/scanner/static/scanner/js/scanning.js @@ -23,6 +23,7 @@ class ScannerManager { this.area_px = options.area_px; this.frame_count = options.frame_count; this.scan_state = options.scan_state; + this.sim_bt = options.simulate; } init_controls() { @@ -30,6 +31,7 @@ class ScannerManager { this.crop.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "crop" }); }); this.scan_bt.addEventListener('click', (e) => { this.scan(); }); this.halt_bt.addEventListener('click', (e) => { this.halt(); }); + this.sim_bt.addEventListener('click', (e) => { this.simulate(); }); } registerSocket(socket) { @@ -58,6 +60,7 @@ class ScannerManager { init() { this.axes = 0; this.cropping = 1; this._send({ type: 'scanner', topic: "init", }); } scan() { this._send({ type: 'scanner', topic: "scan", session: this.session.value ? this.session.value: "0" }); } + simulate() { this._send({ type: 'scanner', topic: "simulate", session: this.session.value ? this.session.value: "0" }); } halt() { this._send({ type: 'calibrate', topic: "halt" }); } _send(message) { this.socket.send(message); } diff --git a/test_tube_scanner/scanner/tasks.py b/test_tube_scanner/scanner/tasks.py index 2260801..45c9b92 100644 --- a/test_tube_scanner/scanner/tasks.py +++ b/test_tube_scanner/scanner/tasks.py @@ -1,6 +1,7 @@ # tasks.py import asyncio import json +import requests from celery import shared_task, group, chord, chain from celery.utils.log import get_task_logger from django.utils import timezone @@ -214,4 +215,64 @@ def on_scanning_done(result: dict, session_id: str): session.save(update_fields=["scanning_status", "scanning_finished_at"]) logger.info("==== Scanning done: session %s", session_id) + + +@shared_task +def http_request(req_type='json_post', url=None, user=None, passwd=None, headers={}, **kwargs): + + def json_post(): + return requests.post(url, json=kwargs, headers=headers, auth=(user, passwd), timeout=3) + + def post(): + return requests.post(url, params=kwargs, headers=headers, auth=(user, passwd), timeout=3) + + def get(): + return requests.get(url, params=kwargs, headers=headers, auth=(user, passwd), timeout=3) + + request = { + 'json_post': json_post, + 'post': post, + 'get':get + } + retries = 3 + for attempt in range(retries): + try: + response = request[req_type]() + response.raise_for_status() + try: + data = response.json() + except ValueError: + data = response.text + + resp = {"success": True, "data": data} + logger.info(data) + return resp + + except requests.exceptions.Timeout: + logger.error(f"{attempt + 1}/{retries}: Délai d'attente dépassé") + except requests.exceptions.ConnectionError: + logger.error(f"{attempt + 1}/{retries}: Erreur de connexion") + except requests.exceptions.HTTPError as e: + raise + except Exception as e: + logger.error(f"Erreur inattendue: {str(e)}") + +@shared_task +def supervisor_restart_service(params): + """ + Redémarre un service. + + """ + try: + logger.warning("==== Supervisor scanner restart all services") + http_request( + req_type='get', + url=f'http://localhost:9001/{params}', + user='root', + passwd='toor', + headers={} + ) + except Exception as e: + logger.error(f"supervisor_restart_all_services error {e}") + \ No newline at end of file diff --git a/test_tube_scanner/scanner/templates/scanner/base.html b/test_tube_scanner/scanner/templates/scanner/base.html index 68682e0..b1e3874 100644 --- a/test_tube_scanner/scanner/templates/scanner/base.html +++ b/test_tube_scanner/scanner/templates/scanner/base.html @@ -92,7 +92,7 @@ {% trans "Logs des services" %} - + {% endif %} diff --git a/test_tube_scanner/scanner/templates/scanner/scanning.html b/test_tube_scanner/scanner/templates/scanner/scanning.html index a6fc1a1..5df6cb4 100644 --- a/test_tube_scanner/scanner/templates/scanner/scanning.html +++ b/test_tube_scanner/scanner/templates/scanner/scanning.html @@ -38,13 +38,13 @@
-
+
-
-
diff --git a/test_tube_scanner/scanner/urls.py b/test_tube_scanner/scanner/urls.py index 1bbfc6a..cd65b78 100644 --- a/test_tube_scanner/scanner/urls.py +++ b/test_tube_scanner/scanner/urls.py @@ -19,5 +19,6 @@ urlpatterns = [ path('replay/', views.replay_view, name='replay'), path('api/stats/', views.stats_view, name='api_stats'), path('api/video/', views.download_api, name='download_api'), + path('api/restart/', views.restart_all, name='restart_all'), path('api/export/', views.export_api, name='export_api'), ] diff --git a/test_tube_scanner/scanner/views.py b/test_tube_scanner/scanner/views.py index 89491be..d18b55b 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 +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 +from .tasks import download_video, export_all_images, export_all_videos, supervisor_restart_service from .process import CameraRecordManager, cameraDB from . import models from .constants import ScannerConstants @@ -157,6 +157,17 @@ def images_view(request): ## replay +@require_GET +@csrf_exempt +def restart_all(request): + #http://scanner.local:9001/index.html?processname=test_tube%3Aservices&action=restart + #http://scanner.local:9001/index.html?processname=test_tube%3AwebUI&action=start + #http://scanner.local:9001/index.html?action=restartall + #supervisor_restart_service('index.html?action=restartall') + supervisor_restart_service('index.html?processname=test_tube%3Aservices&action=restart') + return JsonResponse({"state": True}) + + @require_POST @csrf_exempt def download_api(request): @@ -167,8 +178,8 @@ def download_api(request): return download_video.delay(uuid, dt_start, dt_stop, frame_rate=frame_rate) # @UndefinedVariable else: return JsonResponse({"state": False}) - - + + def get_video(uuid): oldest, latest = async_to_sync(reductstore.old_last_dates)(cameraDB, entry_name=uuid) filters = record_manager.set_filters(test=False)