simulation

This commit is contained in:
2026-05-06 23:35:31 +02:00
parent 22ec82c895
commit 1bc7e5eb9e
13 changed files with 232 additions and 105 deletions
+1
View File
@@ -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,
+12 -6
View File
@@ -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)
@@ -98,10 +98,13 @@ class VideoCaptureInterface(abc.ABC):
def on_test_well_change(self, **cfg):
try:
if self.use_tracking and cfg:
self._tracker = PlanarianTracker(**cfg)
logger.warning(f"New tracker conf: {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):
"""
@@ -113,9 +116,11 @@ class VideoCaptureInterface(abc.ABC):
return
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,6 +270,7 @@ 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)
@@ -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)
@@ -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
# ------------------------------------------------------------------ #
+1 -1
View File
@@ -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,
-1
View File
@@ -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
+38 -15
View File
@@ -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,7 @@ 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,
@@ -96,6 +100,18 @@ class MultiWellManager:
)
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
@@ -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,11 +192,13 @@ 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
logger.info(f"Simulating capture with file {vf}")
self._grid_scanning_capture(experiment, wl, simulate=simulate)
@@ -188,6 +207,7 @@ class MultiWellManager:
def _start_scanning(self, session, experiments, simulate=False):
try:
self.process.cam._aligner.debug = False
xynext = []
for obs in experiments:
@@ -217,10 +237,10 @@ class MultiWellManager:
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
@@ -229,17 +249,18 @@ class MultiWellManager:
self.stop_playing.set()
self.well_iterator.reset()
self.process.cam._aligner.debug = False
self.scan_thread = None
self.test_thread = None
def scanning(self, sid, simulate=False):
try:
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).start()
except Exception as e:
print("MultiWellManager::scan error", e)
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
+35 -16
View File
@@ -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"]
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:
try:
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}")
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,14 +329,18 @@ 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:
@@ -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:
try:
self.cam._active_median = False
self.manager.scanning(sid, simulate=(topic=='simulate'))
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")
@@ -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); }
+61
View File
@@ -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
@@ -215,3 +216,63 @@ def on_scanning_done(result: dict, session_id: str):
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}")
@@ -38,13 +38,13 @@
</div>
</div>
<div class="w3-row w3-row-padding">
<div class="w3-twothird">
<div class="w3-twothird w3-center">
<button id="_scan" class="w3-button w3-light-blue w3-round-large w3-margin-top w3-block">
{% trans 'Lancer' %}<br>{% trans 'le balayage' %}<br><i class="fa-solid fa-circle-check w3-text-blue w3-xlarge"></i>
</button>
</div>
<div class="w3-third">
<button id="_scan" class="w3-button w3-dark-xlight w3-round-large w3-margin-top w3-block">
<div class="w3-third w3-center">
<button id="_simulate" class="w3-button w3-dark-xlight w3-round-large w3-margin-top w3-block">
{% trans 'Simuler' %}<br>{% trans 'le balayage' %}<br><i class="fa-solid fa-triangle-exclamation w3-text-orange w3-xlarge"></i>
</button>
</div>
+1
View File
@@ -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'),
]
+13 -2
View File
@@ -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):