diff --git a/etc/1-install-sys.sh b/etc/1-install-sys.sh index abe7a5f..f72606a 100755 --- a/etc/1-install-sys.sh +++ b/etc/1-install-sys.sh @@ -7,8 +7,8 @@ sudo apt upgrade ETC="$(pwd)" -mkdir -p $HOME/exports -mkdir -p$HOME/medias +mkdir -p "$HOME"/exports +mkdir -p "$HOME"/medias mkdir -p /mnt/exports cp ../test_tube_scanner/.env.example ../test_tube_scanner/.env @@ -24,8 +24,8 @@ sudo apt -y install python3-dev python3-pip python3-venv libpq-dev default-libmy echo "==== supervisor http access login:pass => root:toor" sudo cp supervisor-inet_http.conf /etc/supervisor/conf.d/ -sudo ln -s $ETC/scanner_service.conf /etc/supervisor/conf.d/ -sudo ln -s $ETC/reductstore_service.conf /etc/supervisor/conf.d/ +sudo ln -s "$ETC"/scanner_service.conf /etc/supervisor/conf.d/ +sudo ln -s "$ETC"/reductstore_service.conf /etc/supervisor/conf.d/ echo "==== restart supervisor " sudo systemctl restart supervisor @@ -33,6 +33,7 @@ sudo systemctl restart supervisor echo "==== python env with system site packages for picamera2" rm -rf ../.venv python -m venv --system-site-packages ../.venv +# shellcheck disable=SC1091 source ../.venv/bin/activate echo "==== pip requirements" diff --git a/etc/requirements.txt b/etc/requirements.txt index 831936a..860a4da 100644 --- a/etc/requirements.txt +++ b/etc/requirements.txt @@ -11,6 +11,7 @@ twisted[tls,http2] celery[redis] python-decouple PyYaml +requests reduct-py python-dateutil psutil @@ -20,4 +21,4 @@ mysqlclient psycopg2 pyserial scipy - +django-stubs[compatible-mypy] diff --git a/pyrightconfig.json b/pyrightconfig.json new file mode 100644 index 0000000..eadbf96 --- /dev/null +++ b/pyrightconfig.json @@ -0,0 +1,16 @@ +{ + "venvPath": ".", + "venv": ".venv", + "pythonVersion": "3.13", + "extraPaths": ["test_tube_scanner"], + "typeCheckingMode": "basic", + "reportMissingImports": false, + "reportMissingModuleSource": false, + "reportOptionalMemberAccess": false, + "reportOptionalSubscript": false, + "reportOptionalIterable": false, + "reportPossiblyUnboundVariable": "warning", + "reportCallIssue": false, + "reportArgumentType": false, + "reportAttributeAccessIssue": false +} diff --git a/test_tube_scanner/home/management/commands/export_video.py b/test_tube_scanner/home/management/commands/export_video.py index bbd5361..b928478 100644 --- a/test_tube_scanner/home/management/commands/export_video.py +++ b/test_tube_scanner/home/management/commands/export_video.py @@ -31,7 +31,7 @@ class Command(BaseCommand): ) print() - print(f"Export video: {job.id}") + print(f"Export video: {job.id}") # type: ignore[union-attr] except Exception as e: print("Export video uuid error", e) diff --git a/test_tube_scanner/home/management/commands/init_data.py b/test_tube_scanner/home/management/commands/init_data.py index 8635c92..f0adfe4 100644 --- a/test_tube_scanner/home/management/commands/init_data.py +++ b/test_tube_scanner/home/management/commands/init_data.py @@ -13,7 +13,7 @@ def create_user(): if User.objects.count() == 0: for email, username, password, is_superuser in settings.ADMINS: if is_superuser: - User.objects.create_superuser( + User.objects.create_superuser( # type: ignore[attr-defined] email=email, username=username, password=password, @@ -21,7 +21,7 @@ def create_user(): is_superuser=is_superuser, ) else: - User.objects.create_user( + User.objects.create_user( # type: ignore[attr-defined] email=email, username=username, password=password, diff --git a/test_tube_scanner/home/management/commands/secret_key.py b/test_tube_scanner/home/management/commands/secret_key.py index 7341945..cc1c6a7 100644 --- a/test_tube_scanner/home/management/commands/secret_key.py +++ b/test_tube_scanner/home/management/commands/secret_key.py @@ -2,7 +2,7 @@ # encoding: utf-8 from uuid import uuid4 from django.core.management import BaseCommand -from django.core.management.utils import get_random_string; +from django.utils.crypto import get_random_string class Command(BaseCommand): def add_arguments(self, parser): diff --git a/test_tube_scanner/home/management/commands/test_L2544.py b/test_tube_scanner/home/management/commands/test_L2544.py index 8b6ec01..ce6d191 100644 --- a/test_tube_scanner/home/management/commands/test_L2544.py +++ b/test_tube_scanner/home/management/commands/test_L2544.py @@ -4,7 +4,11 @@ Created on 19 janv. 2026 @author: denis ''' from django.core.management.base import BaseCommand -from modules.grbl import GRBLController, wait_for +from modules.grbl import GRBLController +import threading + +def wait_for(delay: float = 1.0) -> None: + threading.Event().wait(delay) import time diff --git a/test_tube_scanner/home/settings.py b/test_tube_scanner/home/settings.py index a5397c6..5e96b1e 100644 --- a/test_tube_scanner/home/settings.py +++ b/test_tube_scanner/home/settings.py @@ -19,7 +19,7 @@ BASE_DIR = Path(__file__).resolve().parent.parent print("Django BASE_DIR:", BASE_DIR) PACKAGE_DIR = BASE_DIR.parent -APP_DATAS = PACKAGE_DIR / config('APP_DATAS') +APP_DATAS = PACKAGE_DIR / str(config('APP_DATAS')) # Quick-start development settings - unsuitable for production # See https://docs.djangoproject.com/en/5.0/howto/deployment/checklist/ @@ -35,10 +35,10 @@ SECRET_KEY = config("SECRET_KEY") DEBUG = config('DEBUG', cast=bool) DOMAIN_SERVER = config("DOMAIN_SERVER") -ALLOWED_HOSTS = config('ALLOWED_HOSTS', cast=Csv()) +ALLOWED_HOSTS: list = list(config('ALLOWED_HOSTS', cast=Csv())) ALLOWED_HOSTS += [DOMAIN_SERVER, '*'] -CSRF_TRUSTED_ORIGINS = config('CSRF_TRUSTED_ORIGINS', cast=Csv()) +CSRF_TRUSTED_ORIGINS: list = list(config('CSRF_TRUSTED_ORIGINS', cast=Csv())) CSRF_TRUSTED_ORIGINS += [f'http://{DOMAIN_SERVER}', f'https://{DOMAIN_SERVER}'] SECURE_CROSS_ORIGIN_OPENER_POLICY = 'same-origin' @@ -237,7 +237,7 @@ DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' ## LOGGING # CRITICAL=50, ERROR=40, WARN=30, INFO=20, DEBUG=10 and NOTSET=0 -LOGGING_FILE = config('LOGGING_FILE') +LOGGING_FILE: str = str(config('LOGGING_FILE')) IS_LOGGING = config('IS_LOGGING', cast=bool) IS_LOGGING = False diff --git a/test_tube_scanner/modules/capture_interface.py b/test_tube_scanner/modules/capture_interface.py index 4dd5e68..f5a1fe1 100644 --- a/test_tube_scanner/modules/capture_interface.py +++ b/test_tube_scanner/modules/capture_interface.py @@ -25,7 +25,7 @@ from asgiref.sync import async_to_sync from django.conf import settings from modules.planarian_tracker import PlanarianTracker -from modules.planarian_metrics import ExperimentParams +from modules.planarian_metrics import ExperimentParams, EthoVisionMetrics from modules.tube_aligner import TubeAligner @@ -81,11 +81,12 @@ class VideoCaptureInterface(abc.ABC): self._circular_crop: Optional["CircularCrop"] = None # Recadrage circulaire optionnel self._active_median = False self._active_crop = False + self._active_edge_enhance = False self._error_occured = False - self._tracker = None - self._metrics = None - self._params = None + self._tracker: PlanarianTracker | None = None + self._metrics: list[EthoVisionMetrics] | None = None + self._params: ExperimentParams | None = None self._clientDB = self.parent.metricDB # Tracker générique, pour simulation @@ -284,10 +285,30 @@ class VideoCaptureInterface(abc.ABC): msg= f"{self.__class__.__name__}: recadrage circulaire désactivé" logger.info(msg) - self.display(state='circular_crop', msg=msg) + if self.display is not None: + self.display(state='circular_crop', msg=msg) - def process_frame(self, jpeg_bytes: bytes) -> bytes: + def set_edge_enhance(self, enabled: bool) -> None: + """Active ou désactive le filtre de mise en évidence des contours (calibration).""" + self._active_edge_enhance = enabled + logger.info(f"{self.__class__.__name__}: edge_enhance={enabled}") + if self.display is not None: + self.display(state='edge_enhance', value=enabled, msg=f"Edge enhance: {enabled}") + + def _apply_edge_enhance(self, frame: np.ndarray) -> np.ndarray: + """Overlay Canny vert additif sur l'image originale. + Flou fort avant détection pour ne garder que les bords dominants (rebord du puit). + """ + gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) + blurred = cv2.GaussianBlur(gray, (9, 9), 2) + edges = cv2.Canny(blurred, 80, 200) + edges = cv2.dilate(edges, np.ones((3, 3), np.uint8), iterations=1) + overlay = np.zeros_like(frame) + overlay[edges > 0] = [0, 255, 0] # vert sur fond noir + return cv2.addWeighted(frame, 1.0, overlay, 1.0, 0) # additif : image inchangée hors bords + + def process_frame(self, jpeg_bytes: bytes) -> tuple[bytes, dict]: """ Applique le post-traitement configuré sur une image brute. @@ -305,8 +326,12 @@ class VideoCaptureInterface(abc.ABC): frame = cv2.imdecode(nparr, cv2.IMREAD_COLOR) if frame is None: return jpeg, metrics - try: - # Mode debug + try: + # Edge enhance sur la frame propre, avant les annotations + if self._active_edge_enhance: + frame = self._apply_edge_enhance(frame) + ## + # Mode debug (annotations par-dessus) if self._aligner.debug: self.align_detection = self._aligner.detect_tube(frame) annotated = self.align_detection.get('frame_annotated') @@ -324,7 +349,17 @@ class VideoCaptureInterface(abc.ABC): except Exception as e: logger.error(e) - + + # Pas de circular crop — appliquer edge enhance si actif + if self._active_edge_enhance: + nparr = np.frombuffer(jpeg_bytes, np.uint8) + frame = cv2.imdecode(nparr, cv2.IMREAD_COLOR) + if frame is not None: + frame = self._apply_edge_enhance(frame) + ok, buf = cv2.imencode(".jpg", frame, [cv2.IMWRITE_JPEG_QUALITY, self.jpeg_quality]) + if ok: + return buf.tobytes(), metrics + return jpeg_bytes, metrics def save_frame(self, jpeg_bytes: bytes, directory: str = ".", prefix: str = "frame") -> Path: diff --git a/test_tube_scanner/modules/circular_crop.py b/test_tube_scanner/modules/circular_crop.py index 745127c..21b9a73 100644 --- a/test_tube_scanner/modules/circular_crop.py +++ b/test_tube_scanner/modules/circular_crop.py @@ -66,7 +66,7 @@ class CircularCrop: # Cache du masque pour éviter de le recalculer à chaque frame self._mask_cache: Optional[np.ndarray] = None - self._mask_shape: Optional[tuple[int, int, int]] = None # (H, W, strategy) + self._mask_shape: Optional[tuple[int, ...]] = None # (H, W, cx, cy, radius) # ------------------------------------------------------------------ # API publique diff --git a/test_tube_scanner/modules/grbl.py b/test_tube_scanner/modules/grbl.py index 18a8d2e..b008a64 100644 --- a/test_tube_scanner/modules/grbl.py +++ b/test_tube_scanner/modules/grbl.py @@ -12,6 +12,7 @@ import logging import serial import time import threading +from typing import Callable, Any logging.basicConfig(level=logging.INFO) @@ -41,11 +42,10 @@ class GRBLController: 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 + self._state: Callable[..., Any] = send_callback if send_callback is not None else self._send_msg - self.x, self.y = 0, 0 + self.x: float | None = None + self.y: float | None = None #self.start_connection() @@ -67,8 +67,8 @@ class GRBLController: try: self.ser = serial.Serial(self.port, self.baudrate, timeout=self.timeout, exclusive=True) # CRITIQUE : - self.ser.setDTR(False) - self.ser.setRTS(False) + self.ser.setDTR(False) # type: ignore[attr-defined] + self.ser.setRTS(False) # type: ignore[attr-defined] self.clear_buffer() self._wake_up() @@ -192,7 +192,7 @@ class GRBLController: 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) + self.move_to((x or 0) + dx, (y or 0) + dy, feed=feed) def move_relative__(self, dx=0, dy=0, feed=1000): self.send("G91") # Mode relatif diff --git a/test_tube_scanner/modules/grbl_simulator.py b/test_tube_scanner/modules/grbl_simulator.py index 789fcf3..eb2a007 100644 --- a/test_tube_scanner/modules/grbl_simulator.py +++ b/test_tube_scanner/modules/grbl_simulator.py @@ -15,6 +15,7 @@ import logging import time import threading import math +from typing import Callable, Any logging.basicConfig(level=logging.INFO) @@ -47,12 +48,11 @@ class GRBLController: 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 + self._state: Callable[..., Any] = send_callback if send_callback is not None else self._send_msg # Position courante simulée - self.x, self.y = 0.0, 0.0 + self.x: float | None = None + self.y: float | None = None # État interne de la machine simulée self._machine_state = 'Idle' # Idle | Run | Alarm @@ -150,7 +150,7 @@ class GRBLController: # 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) + distance = math.hypot(self.x or 0.0, self.y or 0.0) self._simulate_move_delay(distance, feed=3000) self.x, self.y = 0.0, 0.0 self._machine_state = 'Idle' @@ -158,7 +158,9 @@ class GRBLController: # --- 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 + new_x: float = self.x or 0.0 + new_y: float = self.y or 0.0 + feed: float = 1000.0 for token in tokens: if token.startswith('X'): @@ -186,8 +188,10 @@ class GRBLController: # --- 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) + cur_x = self.x or 0.0 + cur_y = self.y or 0.0 + if has_move and (new_x != cur_x or new_y != cur_y): + distance = math.hypot(new_x - cur_x, new_y - cur_y) self._machine_state = 'Run' self._simulate_move_delay(distance, feed) self.x = new_x @@ -208,7 +212,9 @@ class GRBLController: def get_status(self): '''Retourne un status GRBL simulé au format .''' - status = f"<{self._machine_state}|MPos:{self.x:.3f},{self.y:.3f},0.000|FS:0,0>" + x = self.x or 0.0 + y = self.y or 0.0 + status = f"<{self._machine_state}|MPos:{x:.3f},{y:.3f},0.000|FS:0,0>" logger.debug(f"SIMULATOR::get_status → {status}") return status @@ -257,7 +263,7 @@ class GRBLController: 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) + self.move_to((x or 0.0) + dx, (y or 0.0) + dy, feed=feed) def move_relative__(self, dx=0, dy=0, feed=1000): self.send("G91") # Mode relatif diff --git a/test_tube_scanner/modules/planarian_metrics.py b/test_tube_scanner/modules/planarian_metrics.py index aadd745..a9e2ad7 100644 --- a/test_tube_scanner/modules/planarian_metrics.py +++ b/test_tube_scanner/modules/planarian_metrics.py @@ -282,7 +282,7 @@ class EthoVisionMetrics: self.total_distance_mm += dist_mm # Vecteur de déplacement (pour calculs d'angle) - if self._prev_cx_mm is not None: + if self._prev_cx_mm is not None and self._prev_cy_mm is not None: move_dx = cx_mm - self._prev_cx_mm move_dy = cy_mm - self._prev_cy_mm else: @@ -570,6 +570,36 @@ class ExperimentParams: **BEHAVIOUR_DEFAULTS, } + # Attributs issus de REQUIRED + experiment: str + well: str + px_per_mm: float + fps: float + # Attributs issus de DEFAULTS + well_radius_mm: float + thresh_immobile: float + thresh_mobile: float + planarian_count: int + tube_axis: str + min_area_px: int + max_area_ratio: float + merge_kernel_size: int + min_contour_dist_px: int + # Attributs issus de BEHAVIOUR_DEFAULTS + thigmotaxis_wall_dist_mm: float + photo_mode: str + photo_strength: float + photo_x: float + photo_y: float + photo_flee_angle_deg: float + chemo_strength: float + chemo_x: float + chemo_y: float + chemo_radius_mm: float + chemo_approach_angle_deg: float + avoid_radius_mm: float + aggreg_radius_mm: float + def __init__(self, data: dict): missing = self.REQUIRED - set(data.keys()) if missing: diff --git a/test_tube_scanner/modules/planarian_tracker.py b/test_tube_scanner/modules/planarian_tracker.py index b47b8f7..577ed2a 100644 --- a/test_tube_scanner/modules/planarian_tracker.py +++ b/test_tube_scanner/modules/planarian_tracker.py @@ -75,12 +75,12 @@ class PlanarianState: Args: idx : index de l'individu (0-based) """ - self.idx = idx - self.cx = None - self.cy = None - self.ts = None - self.lost = 0 # compteur de frames sans détection - self.active = False # vrai si l'individu a été détecté au moins une fois + self.idx: int = idx + self.cx: int | None = None + self.cy: int | None = None + self.ts: float | None = None + self.lost: int = 0 # compteur de frames sans détection + self.active: bool = False # vrai si l'individu a été détecté au moins une fois def update(self, cx: int, cy: int, ts: float): """ @@ -117,7 +117,7 @@ class PlanarianState: Returns: tuple (speed_px_s, axial_speed) ou (0.0, 0.0) si état vide """ - if self.cx is None or self.ts is None: + if self.cx is None or self.cy is None or self.ts is None: return 0.0, 0.0 dt = ts - self.ts @@ -445,7 +445,7 @@ class PlanarianTracker: row_ind, col_ind = linear_sum_assignment(cost) # Construire le dict d'association - assignment = {i: None for i in range(n_slots)} + assignment: dict[int, int | None] = {i: None for i in range(n_slots)} assigned_dets = set() for ri, ci in zip(row_ind, col_ind): diff --git a/test_tube_scanner/modules/reductstore.py b/test_tube_scanner/modules/reductstore.py index 225ab84..eb3f54f 100644 --- a/test_tube_scanner/modules/reductstore.py +++ b/test_tube_scanner/modules/reductstore.py @@ -32,7 +32,6 @@ class ReductStoreBase(ABC): settings = BucketSettings( quota_type=self.quota_type, quota_size=self.quota_size, - exist_ok=True, ) return await self.client.create_bucket(self.bucket_name, settings, exist_ok=True) diff --git a/test_tube_scanner/modules/system_stats.py b/test_tube_scanner/modules/system_stats.py index 8ca80a5..c471bc9 100644 --- a/test_tube_scanner/modules/system_stats.py +++ b/test_tube_scanner/modules/system_stats.py @@ -111,7 +111,7 @@ def _update_cache(): _timer.start() -def start_background_updater(interval_seconds: int = None): +def start_background_updater(interval_seconds: int = 0): global REFRESH_INTERVAL, _timer if interval_seconds: REFRESH_INTERVAL = interval_seconds diff --git a/test_tube_scanner/modules/tube_aligner.py b/test_tube_scanner/modules/tube_aligner.py index 31f4673..d13ecd3 100644 --- a/test_tube_scanner/modules/tube_aligner.py +++ b/test_tube_scanner/modules/tube_aligner.py @@ -22,13 +22,24 @@ class TubeAligner: grbl_threshold_px : int = 20, dead_zone_px : int = 5, debug : bool = False, # ← activable depuis la vue - display = None, # display function + display = None, # display function ): self.grbl_threshold_px = grbl_threshold_px self.dead_zone_px = dead_zone_px self.debug = debug self.display = display self.TUBE_DIAMETER_MM = 16.0 + # Plage de recherche du rayon en fraction de min(w,h) + # Défaut : tube occupe ~30% du champ (camera). + # Mode vidéo : puit remplit le crop → ratio ~0.50 → appeler set_radius_range(0.35, 0.52) + self._min_radius_ratio = 0.26 + self._max_radius_ratio = 0.37 + self.draw_annotations = True # masquer l'overlay sans couper la détection + + def set_radius_range(self, min_ratio: float, max_ratio: float) -> None: + """Ajuste la plage de recherche HoughCircles. Appeler avant detect_tube().""" + self._min_radius_ratio = min_ratio + self._max_radius_ratio = max_ratio def set_tube_diameter(self, tube_diameter: float = 16.0) -> None: @@ -64,14 +75,18 @@ class TubeAligner: frame_out = frame.copy() gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) + # CLAHE pour renforcer le contraste local (paroi du puit sur fond gris uniforme) + clahe = cv2.createCLAHE(clipLimit=3.0, tileGridSize=(8, 8)) + gray = clahe.apply(gray) blurred = cv2.GaussianBlur(gray, (15, 15), 3) - + + lo = self._min_radius_ratio + hi = self._max_radius_ratio # 3 configurations légèrement différentes — vote majoritaire - # Fonctionne sur fond sombre ET fond clair configs = [ - dict(param1=50, param2=30, minRadius=int(min(w,h)*0.26), maxRadius=int(min(w,h)*0.36)), - dict(param1=60, param2=30, minRadius=int(min(w,h)*0.26), maxRadius=int(min(w,h)*0.37)), - dict(param1=50, param2=28, minRadius=int(min(w,h)*0.25), maxRadius=int(min(w,h)*0.365)), + dict(param1=50, param2=30, minRadius=int(min(w,h)*lo), maxRadius=int(min(w,h)*hi)), + dict(param1=60, param2=28, minRadius=int(min(w,h)*lo), maxRadius=int(min(w,h)*(hi+0.01))), + dict(param1=50, param2=26, minRadius=int(min(w,h)*(lo-0.01)), maxRadius=int(min(w,h)*(hi+0.005))), ] all_cx, all_cy, all_r = [], [], [] @@ -93,7 +108,7 @@ class TubeAligner: if not all_cx: msg = f"TubeAligner: aucun cercle détecté ({w}x{h})" result["msg"] =msg - if self.debug: + if self.debug and self.draw_annotations: frame_out = self._draw_debug_no_detection(frame_out, cx_img, cy_img) result["frame_annotated"] = frame_out return result @@ -120,7 +135,7 @@ class TubeAligner: else: action = "grbl" - if self.debug: + if self.debug and self.draw_annotations: frame_out = self._draw_debug( frame_out, cx_img, cy_img, tx, ty, tr, diff --git a/test_tube_scanner/modules/utils.py b/test_tube_scanner/modules/utils.py index 95e9801..c38dfd7 100644 --- a/test_tube_scanner/modules/utils.py +++ b/test_tube_scanner/modules/utils.py @@ -70,6 +70,7 @@ def get_tmpfs_info(mount_point="/ramdisk"): return f"{n:.1f}PB" usage = None + part = None for part in psutil.disk_partitions(all=True): if part.mountpoint == mount_point and part.fstype.lower() == "tmpfs": usage = psutil.disk_usage(part.mountpoint) @@ -81,15 +82,17 @@ def get_tmpfs_info(mount_point="/ramdisk"): print(f" Free: {usage.free} bytes ({sizeof(usage.free)})") print(f" Percent used: {usage.percent}%") break - return { - "percent": usage.percent, - "mount": part.mountpoint, - "device": part.device, - "fstype": part.fstype, - "total": usage.total, - "used": usage.used, - "free": usage.free, - } + if usage and part: + return { + "percent": usage.percent, + "mount": part.mountpoint, + "device": part.device, + "fstype": part.fstype, + "total": usage.total, + "used": usage.used, + "free": usage.free, + } + def get_cpu_info(): diff --git a/test_tube_scanner/modules/videofile_capture.py b/test_tube_scanner/modules/videofile_capture.py index b9b1c57..0c57a0c 100644 --- a/test_tube_scanner/modules/videofile_capture.py +++ b/test_tube_scanner/modules/videofile_capture.py @@ -33,7 +33,7 @@ class VideoFileCapture(VideoCaptureInterface): def __init__( self, - video_file: str = None, + video_file: str | None = None, fps: float = VideoCaptureInterface.DEFAULT_FPS, jpeg_quality: int = 85, width: Optional[int] = None, @@ -50,7 +50,7 @@ class VideoFileCapture(VideoCaptureInterface): :param height: Hauteur souhaitée (None = valeur par défaut du pilote) """ super().__init__(fps=fps, use_tracking=use_tracking, display=display, parent=parent, jpeg_quality=jpeg_quality) - self._video_file: str = video_file + self._video_file: str | None = video_file self._jpeg_quality: int = jpeg_quality self._width: Optional[int] = width self._height: Optional[int] = height @@ -134,8 +134,8 @@ class VideoFileCapture(VideoCaptureInterface): # ------------------------------------------------------------------ @property - def video_file(self) -> int: - """Index du périphérique V4L2.""" + def video_file(self) -> str | None: + """Fichier vidéo.""" return self._video_file @property diff --git a/test_tube_scanner/modules/videoplate_capture.py b/test_tube_scanner/modules/videoplate_capture.py new file mode 100644 index 0000000..c9456d9 --- /dev/null +++ b/test_tube_scanner/modules/videoplate_capture.py @@ -0,0 +1,226 @@ +""" +VideoPlateCapture — capture par extraction de région dans une vidéo plaque entière. + +La vidéo montre l'ensemble de la plaque multi-puits (vue de dessus). +La position GRBL (x, y en mm) détermine la région extraite via px_per_mm. +Le résultat est un carré centré sur le puits courant, compatible avec +le recadrage circulaire de process_frame() comme pour toute autre capture. + +Flux : video frame → crop carré (GRBL pos) → CircularCrop → tracking/display + +Hot swap : set_video_file(path) remplace la vidéo sans arrêter la capture. +Thread-safe via _cap_lock. +""" +import os +os.environ['OPENCV_LOG_LEVEL'] = "0" +os.environ['OPENCV_FFMPEG_LOGLEVEL'] = "0" +import cv2 +import numpy as np +import logging +import threading +from pathlib import Path + +from modules.capture_interface import VideoCaptureInterface, CaptureError + +logger = logging.getLogger(__name__) + + +class VideoPlateCapture(VideoCaptureInterface): + """ + Lecture d'une vidéo de plaque complète avec crop dynamique à la position GRBL. + + La position GRBL (x_mm, y_mm) est convertie en coordonnées pixel via px_per_mm. + Un carré de côté 2*crop_radius_px est extrait à cette position, puis + process_frame() applique le masque circulaire habituel. + + La vidéo boucle automatiquement. La cadence est adaptée via frame_step + pour correspondre au fps cible. + + Calibration : set_px_per_mm() met à jour le facteur de conversion à chaud. + Hot swap : set_video_file(path) change la vidéo sans interruption. + """ + + def __init__( + self, + video_dir, + fps: float = 5.0, + jpeg_quality: int = 90, + use_tracking: bool = False, + display=None, + parent=None, + crop_radius_px: int = 150, + px_per_mm: float = 15.0, + x_offset_mm: float = 0.0, + y_offset_mm: float = 0.0, + initial_video_path: str | None = None, + ): + super().__init__(fps, use_tracking, display, parent, jpeg_quality) + self._video_dir = Path(video_dir) + self._cap: cv2.VideoCapture | None = None + self._cap_lock = threading.Lock() + self._video_path: Path | None = ( + Path(initial_video_path) if initial_video_path else None + ) + self._frame_w: int = 0 + self._frame_h: int = 0 + self._crop_radius_px: int = crop_radius_px + self._px_per_mm: float = px_per_mm + self._x_offset_mm: float = x_offset_mm + self._y_offset_mm: float = y_offset_mm + self._frame_step: int = 1 + + # ------------------------------------------------------------------ + # API publique + # ------------------------------------------------------------------ + + def set_px_per_mm(self, px_per_mm: float) -> None: + """Met à jour le facteur de conversion mm → pixel à chaud (depuis WellPosition).""" + self._px_per_mm = px_per_mm + logger.info(f"VideoPlateCapture: px_per_mm={px_per_mm:.3f}") + + def set_crop_radius_px(self, r: int) -> None: + """Met à jour le rayon de découpe en pixels à chaud (depuis MultiWell.crop_radius).""" + self._crop_radius_px = r + logger.info(f"VideoPlateCapture: crop_radius_px={r}") + + def set_offset_mm(self, x_offset_mm: float, y_offset_mm: float) -> None: + """Met à jour l'offset d'origine (xbase, ybase du MultiWell) à chaud.""" + self._x_offset_mm = x_offset_mm + self._y_offset_mm = y_offset_mm + + def set_video_file(self, path: str) -> None: + """ + Hot swap : remplace la vidéo courante sans arrêter la capture. + Thread-safe — peut être appelé depuis n'importe quel thread. + """ + new_cap = cv2.VideoCapture(path) + if not new_cap.isOpened(): + logger.error(f"VideoPlateCapture hot swap: impossible d'ouvrir {path}") + return + + new_w = int(new_cap.get(cv2.CAP_PROP_FRAME_WIDTH)) + new_h = int(new_cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) + nat_fps = new_cap.get(cv2.CAP_PROP_FPS) or self._fps + new_step = max(1, round(nat_fps / self._fps)) + + with self._cap_lock: + old_cap = self._cap + self._cap = new_cap + self._video_path = Path(path) + self._frame_w = new_w + self._frame_h = new_h + self._frame_step = new_step + + if old_cap: + old_cap.release() + + logger.info(f"VideoPlateCapture: hot swap → {Path(path).name} {new_w}×{new_h}") + + @property + def video_path(self) -> Path | None: + return self._video_path + + # ------------------------------------------------------------------ + # Implémentation VideoCaptureInterface + # ------------------------------------------------------------------ + + def open(self) -> None: + path = self._video_path if (self._video_path and self._video_path.exists()) \ + else self._find_video() + if path is None: + raise CaptureError(f"Aucune vidéo trouvée dans {self._video_dir}") + + cap = cv2.VideoCapture(str(path)) + if not cap.isOpened(): + raise CaptureError(f"Impossible d'ouvrir {path.name}") + + nat_fps = cap.get(cv2.CAP_PROP_FPS) or self._fps + + with self._cap_lock: + self._cap = cap + self._video_path = path + self._frame_w = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)) + self._frame_h = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) + self._frame_step = max(1, round(nat_fps / self._fps)) + + logger.info( + f"VideoPlateCapture: {path.name} {self._frame_w}×{self._frame_h} " + f"@ {nat_fps:.1f} fps → step={self._frame_step}, " + f"px_per_mm={self._px_per_mm:.2f}" + ) + + def close(self) -> None: + with self._cap_lock: + cap, self._cap = self._cap, None + if cap: + cap.release() + + def is_available(self) -> bool: + with self._cap_lock: + return self._cap is not None and self._cap.isOpened() + + def capture_frame(self) -> bytes: + with self._cap_lock: + if self._cap is None or not self._cap.isOpened(): + raise CaptureError("Vidéo non disponible") + + # Avancer dans la vidéo pour correspondre au fps cible + for _ in range(self._frame_step - 1): + self._cap.grab() + + ret, frame = self._cap.read() + if not ret: + self._cap.set(cv2.CAP_PROP_POS_FRAMES, 0) + ret, frame = self._cap.read() + if not ret: + raise CaptureError("Impossible de relire la vidéo") + + frame_w = self._frame_w + frame_h = self._frame_h + + # Position GRBL (mm) → coordonnées pixel dans la vidéo plaque + grbl = getattr(self.parent, 'grbl', None) if self.parent else None + x_mm = float(getattr(grbl, 'x', None) or 0.0) + y_mm = float(getattr(grbl, 'y', None) or 0.0) + + # À l'origine CNC (0, 0) : retourner la plaque entière à sa résolution native + if x_mm == 0.0 and y_mm == 0.0: + ok, buf = cv2.imencode('.jpg', frame, [cv2.IMWRITE_JPEG_QUALITY, self.jpeg_quality]) + if not ok: + raise CaptureError("Encodage JPEG échoué") + return buf.tobytes() + + cx = int((x_mm - self._x_offset_mm) * self._px_per_mm) + cy = int((y_mm - self._y_offset_mm) * self._px_per_mm) + r = self._crop_radius_px + + # Extraction du carré centré sur le puits courant + x1 = max(0, cx - r) + y1 = max(0, cy - r) + x2 = min(frame_w, cx + r) + y2 = min(frame_h, cy + r) + crop = frame[y1:y2, x1:x2] + + # Padding noir si le crop déborde du bord de la vidéo + if crop.shape[0] != 2 * r or crop.shape[1] != 2 * r: + padded = np.zeros((2 * r, 2 * r, 3), dtype=np.uint8) + padded[:crop.shape[0], :crop.shape[1]] = crop + crop = padded + + ok, buf = cv2.imencode('.jpg', crop, [cv2.IMWRITE_JPEG_QUALITY, self.jpeg_quality]) + if not ok: + raise CaptureError("Encodage JPEG échoué") + return buf.tobytes() + + # ------------------------------------------------------------------ + # Privé + # ------------------------------------------------------------------ + + def _find_video(self) -> Path | None: + """Retourne le premier fichier vidéo trouvé dans video_dir.""" + self._video_dir.mkdir(parents=True, exist_ok=True) + for ext in ('*.mp4', '*.avi', '*.MP4', '*.AVI'): + files = sorted(self._video_dir.glob(ext)) + if files: + return files[0] + return None diff --git a/test_tube_scanner/planarian/migrations/0001_initial.py b/test_tube_scanner/planarian/migrations/0001_initial.py new file mode 100644 index 0000000..f057011 --- /dev/null +++ b/test_tube_scanner/planarian/migrations/0001_initial.py @@ -0,0 +1,56 @@ +# Generated by Django 6.0.5 on 2026-05-31 07:42 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='ExperimentConfig', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('experiment', models.CharField(default='Identifier', max_length=128, null=True, verbose_name='Identifiant expérience')), + ('well', models.CharField(choices=[], default='A1', help_text='Nom du puit', max_length=8, null=True, verbose_name='Puit')), + ('description', models.TextField(blank=True, default='-', verbose_name='Description')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Créé le')), + ('active', models.BooleanField(default=True, verbose_name='Active')), + ('px_per_mm', models.FloatField(default=26.25, help_text='Facteur de calibration optique', verbose_name='Pixels par mm')), + ('fps', models.FloatField(default=5.0, help_text='Image de capture en img/s', verbose_name='FPS de capture')), + ('well_radius_mm', models.FloatField(default=8.0, help_text='En mm', verbose_name='Rayon du puits')), + ('thresh_immobile', models.FloatField(default=0.2, verbose_name='Seuil Immobile (mm/s)')), + ('thresh_mobile', models.FloatField(default=1.5, verbose_name='Seuil Mobile (mm/s)')), + ('tube_axis', models.CharField(choices=[('vertical', 'Vertical'), ('horizontal', 'Horizontal')], default='vertical', max_length=10, verbose_name='Axe du tube')), + ('min_area_px', models.IntegerField(default=20, verbose_name='Surface min détection (px²)')), + ('max_area_ratio', models.FloatField(default=0.1, help_text='Ratio de la surface du puits, ex: 0.10 pour 10%', verbose_name='Surface max contour (fraction de la frame)')), + ('planarian_count', models.IntegerField(default=1, verbose_name='Nombre de planaires')), + ('merge_kernel_size', models.PositiveIntegerField(default=15, help_text='taille du kernel elliptique de fusion des fragments (px). Augmenter si fragments résiduels', verbose_name='Taille du kernel')), + ('min_contour_dist_px', models.PositiveIntegerField(default=40, help_text='Distance min entre deux contours pour les considérer comme individus distincts. Défaut : 40px. Augmenter si IDs multiples persistent', verbose_name='Distance ')), + ('thigmotaxis_wall_dist_mm', models.FloatField(default=1.0, verbose_name='Distance paroi thigmotactisme (mm)')), + ('photo_mode', models.CharField(choices=[('none', 'Désactivé'), ('fixed', 'Source fixe'), ('sine', 'Source sinusoïdale'), ('radial', 'Gradient radial')], default='none', max_length=10, verbose_name='Mode phototactisme')), + ('photo_strength', models.FloatField(default=0.0, verbose_name='Intensité phototactisme')), + ('photo_x', models.FloatField(default=0.5, verbose_name='Source lumière X (0-1)')), + ('photo_y', models.FloatField(default=0.5, verbose_name='Source lumière Y (0-1)')), + ('chemo_strength', models.FloatField(default=0.0, verbose_name='Intensité chimiotactisme')), + ('chemo_x', models.FloatField(default=0.5, verbose_name='Nourriture X (0-1)')), + ('chemo_y', models.FloatField(default=0.5, verbose_name='Nourriture Y (0-1)')), + ('chemo_radius_mm', models.FloatField(default=2.0, verbose_name='Rayon nourriture (mm)')), + ('avoid_radius_mm', models.FloatField(default=3.0, verbose_name='Rayon évitement (mm)')), + ('aggreg_radius_mm', models.FloatField(default=6.0, verbose_name='Rayon agrégation (mm)')), + ('author', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='Auteur')), + ], + options={ + 'verbose_name': "Configuration d'une expérience", + 'verbose_name_plural': 'Configurations des expériences', + 'ordering': ['-created_at'], + }, + ), + ] diff --git a/test_tube_scanner/planarian/migrations/0002_initial.py b/test_tube_scanner/planarian/migrations/0002_initial.py new file mode 100644 index 0000000..55476a4 --- /dev/null +++ b/test_tube_scanner/planarian/migrations/0002_initial.py @@ -0,0 +1,26 @@ +# Generated by Django 6.0.5 on 2026-05-31 07:42 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('planarian', '0001_initial'), + ('scanner', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='experimentconfig', + name='experiment_key', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='scanner.experiment', verbose_name='Expérience'), + ), + migrations.AlterUniqueTogether( + name='experimentconfig', + unique_together={('experiment_key', 'well')}, + ), + ] diff --git a/test_tube_scanner/planarian/migrations/__init__.py b/test_tube_scanner/planarian/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/test_tube_scanner/planarian/tasks.py b/test_tube_scanner/planarian/tasks.py index aa69eeb..bb3eddf 100644 --- a/test_tube_scanner/planarian/tasks.py +++ b/test_tube_scanner/planarian/tasks.py @@ -33,7 +33,7 @@ def export_experiment_metrics_task(experiment_id): return jobs = [] - configs = (ExperimentConfig.objects.filter(experiment_key_id=experiment.id).order_by("well")) + configs = (ExperimentConfig.objects.filter(experiment_key_id=experiment.pk).order_by("well")) for conf in configs: well = conf.well count = conf.planarian_count diff --git a/test_tube_scanner/planarian/templates/planarian/base.html b/test_tube_scanner/planarian/templates/planarian/base.html index c793344..09d156c 100644 --- a/test_tube_scanner/planarian/templates/planarian/base.html +++ b/test_tube_scanner/planarian/templates/planarian/base.html @@ -1,5 +1,5 @@ {% extends "scanner/base.html" %} -{% load i18n home_tags scanner_tags %} +{% load i18n home_tags %} {% block styles %} {{ block.super }} diff --git a/test_tube_scanner/planarian/views.py b/test_tube_scanner/planarian/views.py index ff44561..9e59751 100644 --- a/test_tube_scanner/planarian/views.py +++ b/test_tube_scanner/planarian/views.py @@ -93,7 +93,7 @@ def export_metrics(request): if action=='experiment_csv': experiment = models.Experiment.objects.filter(pk=pid).first() if experiment: - export_experiment_metrics_task.delay(experiment.id) # @UndefinedVariable + export_experiment_metrics_task.delay(experiment.pk) # @UndefinedVariable return JsonResponse({"success": True, "msg": str(_("Métrics en cours de téléchargement"))}) if action=='session_csv': if pid: @@ -118,7 +118,7 @@ def export_csv_view(request): experiment = session_context['current_experiment'] if valid == 'ok' and session and experiment: well_name = request.POST.get('well') - uuid = models.get_uuid_from_session(session.id, experiment.multiwell.position, well_name) + uuid = models.get_uuid_from_session(session.pk, experiment.multiwell.position, well_name) # type: ignore[union-attr] ''' csv_content, filename = export_csv(request, uuid) if csv_content: @@ -126,7 +126,7 @@ def export_csv_view(request): response["Content-Disposition"] = f'attachment; filename="{filename}"' return response''' csv_content, n = export_csv_sync( - experiment=experiment.identifier, + experiment=experiment.identifier, # type: ignore[union-attr] well=well_name, uuid=uuid, planarian=request.POST.get("planarian"), @@ -136,7 +136,7 @@ def export_csv_view(request): ) if csv_content: filename = ( - f"{experiment.identifier}_{well_name}-{request.POST.get('planarian')}_" + f"{experiment.identifier}_{well_name}-{request.POST.get('planarian')}_" # type: ignore[union-attr] f"{request.POST.get('record_type')}.csv" ) logger.info(f"Export CSV: {n} lignes, content size={len(csv_content)}") @@ -223,8 +223,8 @@ def import_csv_view(request): return import_csv(request, current_experiment, rows, overwrite) except Exception as e: - messages.error(request, msg) - logger.error(msg) + messages.error(request, e) + logger.error(e) ctx = { 'choice_title': _("Importer des configurations depuis un fichier CSV"), **session_context } return render(request, "planarian/import_params.html", context=global_context(request, **ctx)) @@ -266,6 +266,6 @@ class TrackingDataView(View): return JsonResponse({"count": len(records), "records": records}) def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) + context = super().get_context_data(**kwargs) # type: ignore[attr-defined] return global_context(self.request, choice_title=str(_("Métriques de tracking d'un planaire")), **context) diff --git a/test_tube_scanner/planarian_sim.py b/test_tube_scanner/planarian_sim.py index 92f12d0..40faa2d 100755 --- a/test_tube_scanner/planarian_sim.py +++ b/test_tube_scanner/planarian_sim.py @@ -29,6 +29,7 @@ try: from planarian_metrics import EthoVisionMetrics HAS_METRICS = True except ImportError: + EthoVisionMetrics = None # type: ignore[assignment] HAS_METRICS = False import numpy as np import math @@ -1007,8 +1008,8 @@ def draw_arena(frame, cfg, width, height, arena_center, arena_radius_px, mm_to_p cv2.circle(overlay, arena_center, arena_radius_px + r_off, (245, 243, 238), 3) cv2.addWeighted(overlay, alpha / 255.0, frame, 1 - alpha / 255.0, 0, frame) - cv2.circle(frame, arena_center, arena_radius_px, arena_border, 2) - cv2.circle(frame, arena_center, arena_radius_px + 4, (200, 198, 192), 1) + cv2.circle(frame, arena_center, arena_radius_px, arena_border, 4) + cv2.circle(frame, arena_center, arena_radius_px + 8, (200, 198, 192), 1) bar_len = int(mm_to_px) bx, by = width - 40, height - 25 @@ -1191,14 +1192,18 @@ def main(): } metrics_list = [] if HAS_METRICS: + assert EthoVisionMetrics is not None for _ in planaires: - metrics_list.append(EthoVisionMetrics( + metric = EthoVisionMetrics( px_per_mm = MM_TO_PX, fps = args.fps, thresh_immobile = args.thresh_immobile, thresh_mobile = args.thresh_mobile, behaviour = behaviour, - )) + ) + if metric: + metrics_list.append(metric) + # --- Arène de base --- arena_base = np.zeros((HEIGHT, WIDTH, 3), dtype=np.uint8) @@ -1208,7 +1213,7 @@ def main(): output_path = args.output if not output_path.endswith(".mp4"): output_path += ".mp4" - fourcc = cv2.VideoWriter_fourcc(*'mp4v') + fourcc = cv2.VideoWriter_fourcc(*'mp4v') # type: ignore[attr-defined] out = cv2.VideoWriter(output_path, fourcc, args.fps, (WIDTH, HEIGHT)) print(f"Simulation : {args.count} planaire(s), {TOTAL_FRAMES} frames ({args.duration}s à {args.fps} fps)") diff --git a/test_tube_scanner/scanner/admin.py b/test_tube_scanner/scanner/admin.py index 67b4c4a..8f5810b 100644 --- a/test_tube_scanner/scanner/admin.py +++ b/test_tube_scanner/scanner/admin.py @@ -1,4 +1,6 @@ +from pathlib import Path from django.utils.translation import gettext_lazy as _ +from django.utils.html import format_html from django.contrib import admin from django.db.models import Q from . import models @@ -43,14 +45,14 @@ class ConfigurationAdmin(admin.ModelAdmin): class MultiWellAdmin(admin.ModelAdmin): list_filter = ('author', ) - list_display = ('label', 'position', 'author', 'order', 'xbase', 'ybase', 'duration', 'feed', 'default', 'well_position', 'active',) - + list_display = ('label', 'position', 'author', 'order', 'xbase', 'ybase', 'duration', 'feed', 'default', 'well_position', 'capture_video', 'active',) + ordering = ('label', 'order') fieldsets = ( (_("Identification"), { - "fields": ("label", "author", "position", "default", "active"), + "fields": ("label", "author", "position", "default", "capture_video", "active"), }), (_("Géométrie"), { - "fields": ("cols", "rows", "diameter", "row_def", "row_order"),"classes": ("collapse",), + "fields": ("cols", "rows", "diameter", "crop_radius", "row_def", "row_order"),"classes": ("collapse",), }), (_("Déplacement"), { "fields": ("order", "duration", "xbase", "ybase", "dx", "dy", "feed"),"classes": ("collapse",), @@ -113,6 +115,107 @@ class SessionAdmin(admin.ModelAdmin): 'scanning_finished_at' ) +@admin.register(models.VideoPlate) +class VideoPlateAdmin(admin.ModelAdmin): + + list_display = ['multiwell', 'label', 'video_filename', 'active', + 'fps_display', 'duration_display', 'resolution_display', 'uploaded_at'] + list_filter = ['multiwell', 'active'] + list_editable = ['active'] + readonly_fields = [ + 'native_fps', 'duration_s', 'frame_w', 'frame_h', + 'uploaded_at', 'resolution_display', 'video_preview', + ] + fields = [ + 'multiwell', 'label', 'video_file', 'active', 'px_per_mm', + 'x_origin_mm', 'y_origin_mm', + 'video_preview', + 'native_fps', 'duration_s', 'frame_w', 'frame_h', 'uploaded_at', + ] + + class Media: + css = {'all': ('scanner/css/video_upload.css',)} + js = ('scanner/js/video_upload.js',) + + # ------------------------------------------------------------------ + # Colonnes liste + # ------------------------------------------------------------------ + + @admin.display(description=_("Fichier"), ordering='video_file') + def video_filename(self, obj): + return obj.video_filename + + @admin.display(description=_("FPS")) + def fps_display(self, obj): + return f"{obj.native_fps:.2f}" if obj.native_fps else "—" + + @admin.display(description=_("Durée")) + def duration_display(self, obj): + if not obj.duration_s: + return "—" + m, s = divmod(int(obj.duration_s), 60) + return f"{m}:{s:02d}" + + @admin.display(description=_("Résolution")) + def resolution_display(self, obj): + return obj.resolution + + # ------------------------------------------------------------------ + # Aperçu vidéo (readonly field) + # ------------------------------------------------------------------ + + @admin.display(description=_("Aperçu")) + def video_preview(self, obj): + if not obj.video_file: + return "—" + return format_html( + '', + obj.video_file.url, + ) + + # ------------------------------------------------------------------ + # Sauvegarde : extraction des métadonnées + # ------------------------------------------------------------------ + + def save_model(self, request, obj, form, change): + super().save_model(request, obj, form, change) + if obj.video_file: + self._extract_metadata(obj) + + def _extract_metadata(self, obj): + try: + import cv2 + cap = cv2.VideoCapture(obj.video_file.path) + if cap.isOpened(): + fps = cap.get(cv2.CAP_PROP_FPS) + fc = cap.get(cv2.CAP_PROP_FRAME_COUNT) + models.VideoPlate.objects.filter(pk=obj.pk).update( + native_fps = fps, + duration_s = (fc / fps) if fps else None, + frame_w = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)), + frame_h = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)), + ) + cap.release() + except Exception: + pass + + # ------------------------------------------------------------------ + # Suppression : efface aussi le fichier physique + # ------------------------------------------------------------------ + + def delete_model(self, request, obj): + if obj.video_file: + Path(obj.video_file.path).unlink(missing_ok=True) + super().delete_model(request, obj) + + def delete_queryset(self, request, queryset): + for obj in queryset: + if obj.video_file: + Path(obj.video_file.path).unlink(missing_ok=True) + super().delete_queryset(request, queryset) + + admin.site.register(models.Configuration, ConfigurationAdmin) admin.site.register(models.Well, WellAdmin) admin.site.register(models.MultiWell, MultiWellAdmin) diff --git a/test_tube_scanner/scanner/constants.py b/test_tube_scanner/scanner/constants.py index 606e126..3e03ae8 100644 --- a/test_tube_scanner/scanner/constants.py +++ b/test_tube_scanner/scanner/constants.py @@ -19,7 +19,7 @@ class DefaultConfig: webcam_device_index: int = 2 image_quality: int = 90 video_jpeg_quality: int = 90 - video_frame_rate: int = 5.0 + video_frame_rate: float = 5.0 video_width_capture: int = 2028 video_height_capture: int = 1520 scan_simulation: bool = False @@ -52,4 +52,12 @@ class ScannerConstants: def get(self): return self.conf + @classmethod + def get_config(cls): + return Configuration.objects.filter(active=True).first() + + def save_config(self): + d = asdict(self.conf) + Configuration.objects.filter(active=True).update(**d) + \ No newline at end of file diff --git a/test_tube_scanner/scanner/export_tasks.py b/test_tube_scanner/scanner/export_tasks.py index 5e55cbf..1d481fd 100644 --- a/test_tube_scanner/scanner/export_tasks.py +++ b/test_tube_scanner/scanner/export_tasks.py @@ -34,19 +34,20 @@ def delete_file_later(path): async def remove_video_by_uuid(uuid, start_ts=None, end_ts=None, when=None): record_manager = CameraRecordManager(cameraDB) - await record_manager.remove(uuid, start_ts, end_ts) + await record_manager.remove_uuid(uuid, start_ts, end_ts) async def remove_video(uuid, start_ts, end_ts, when=None): await remove_video_by_uuid(uuid, start_ts, end_ts, when=when) async def shm_download_video(uuid, start_ts, end_ts, frame_rate=5, opencv_fourcc_format='mp4v', opencv_video_type='mp4'): + video_path = os.path.join(settings.MEDIA_ROOT, f"output.{opencv_video_type}") try: record_manager = CameraRecordManager(cameraDB) total_size = await record_manager.size(uuid, start_ts, end_ts) # segment de mémoire partagée pour stocker les frames - shm_size = int(total_size * 1.5) + shm_size = int((total_size or 0) * 1.5) shm_name = f"/video_frames_{uuid}" try: shm = posix_ipc.SharedMemory(shm_name, posix_ipc.O_CREAT | posix_ipc.O_EXCL, size=shm_size) @@ -77,12 +78,13 @@ async def shm_download_video(uuid, start_ts, end_ts, frame_rate=5, opencv_fourcc raise Exception("No frame found!") #return JsonResponse({'error': 'Aucune frame trouvée'}, status=404) - video_path = os.path.join(settings.MEDIA_ROOT, f"output.{opencv_video_type}") - fourcc = cv2.VideoWriter_fourcc(* opencv_fourcc_format) + #video_path = os.path.join(settings.MEDIA_ROOT, f"output.{opencv_video_type}") + fourcc = cv2.VideoWriter_fourcc(*opencv_fourcc_format) # type: ignore[attr-defined] # Lit les frames depuis la mémoire partagée current_offset = 0 i = 0 + video = None for size in frame_sizes: frame_bytes = mm[current_offset:current_offset + size] nparr = np.frombuffer(frame_bytes, np.uint8) @@ -94,8 +96,9 @@ async def shm_download_video(uuid, start_ts, end_ts, frame_rate=5, opencv_fourcc current_offset += size progress_bar(i + 1, total, prefix=f'Progress {uuid}:', suffix='Ended', length=30) - i+=1 - video.release() + i+=1 + if video: + video.release() # Nettoie la mémoire partagée shm.unlink() @@ -183,7 +186,7 @@ async def export_images_zip( # --- Chargement des frames en mémoire partagée --- record_manager = CameraRecordManager(cameraDB) total_size = await record_manager.size(uuid, start_ts, end_ts) - shm_size = int(total_size * 1.5) + shm_size = int((total_size or 0) * 1.5) try: shm = posix_ipc.SharedMemory( @@ -336,11 +339,11 @@ async def export_video_mp4( video = None shm_name = f"/vid_frames_{uuid}" - try: + try: # --- Chargement des frames en mémoire partagée --- record_manager = CameraRecordManager(cameraDB) total_size = await record_manager.size(uuid, start_ts, end_ts) - shm_size = int(total_size * 1.5) + shm_size = int((total_size or 0) * 1.5) try: shm = posix_ipc.SharedMemory( @@ -390,7 +393,7 @@ async def export_video_mp4( ) os.makedirs(os.path.dirname(video_path), exist_ok=True) - fourcc = cv2.VideoWriter_fourcc(*opencv_fourcc_format) + fourcc = cv2.VideoWriter_fourcc(*opencv_fourcc_format) # type: ignore[attr-defined] skipped = 0 written = 0 current_offset = 0 diff --git a/test_tube_scanner/scanner/migrations/0001_initial.py b/test_tube_scanner/scanner/migrations/0001_initial.py new file mode 100644 index 0000000..b076bf0 --- /dev/null +++ b/test_tube_scanner/scanner/migrations/0001_initial.py @@ -0,0 +1,197 @@ +# Generated by Django 6.0.5 on 2026-05-31 07:42 + +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('django_celery_beat', '0019_alter_periodictasks_options'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Configuration', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(default='Configuration par défaut', help_text='Nom de la configuration', max_length=100, null=True, verbose_name='Nom de la Configuration')), + ('sidebar_width', models.CharField(default='350px', help_text='Largeur barre latérale (css)', max_length=32, null=True, verbose_name='Barre latérale')), + ('default_grid_columns', models.PositiveSmallIntegerField(default=3, help_text='Nombre de colonnes de la grille par défaut', verbose_name='Colonnes de la grille par défaut')), + ('opencv_fourcc_format', models.CharField(choices=[('mp4v', 'MP4'), ('XVID', 'XVID')], default='mp4v', help_text='Opencv fourcc format', max_length=8, null=True, verbose_name='Fourcc')), + ('opencv_video_type', models.CharField(choices=[('mp4', 'MP4'), ('avi', 'AVI')], default='mp4', help_text='Opencv video type', max_length=8, null=True, verbose_name='Video type')), + ('grbl_xmax', models.FloatField(default=350.0, help_text='CNC Grbl Xmax en mm', verbose_name='Grbl Xmax')), + ('grbl_ymax', models.FloatField(default=250.0, help_text='CNC Grbl Ymax en mm', verbose_name='Grbl Ymax')), + ('capture_type', models.CharField(choices=[('rpi', 'Arducam'), ('webcam', 'Webcam'), ('file', 'Simulation Fichier vidéo (mp4, avi)'), ('video', 'Fichier vidéo (mp4, avi)')], default='rpi', help_text='Type de capture. Nécessite un redémarrage en cas de modification à chaud!', max_length=8, null=True, verbose_name='Capture')), + ('webcam_device_index', models.PositiveSmallIntegerField(default=2, help_text='Index de la webcam (0, 1, ...) si présente', verbose_name='Index de la webcam')), + ('image_quality', models.PositiveSmallIntegerField(default=90, help_text='Qualité JPEG (1-100) pour les images exportées', verbose_name='Qualité JPEG')), + ('video_jpeg_quality', models.PositiveSmallIntegerField(default=90, help_text='Qualité JPEG (1-100) pour les images extraites des vidéos', verbose_name='Qualité JPEG pour les vidéos')), + ('video_frame_rate', models.FloatField(default=5.0, help_text="Fréquence d'extraction des images des vidéos (images par seconde)", verbose_name='Fréquence vidéos (fps)')), + ('video_width_capture', models.PositiveSmallIntegerField(default=1280, help_text='Largeur de capture vidéo en pixels', verbose_name='Largeur de capture vidéo')), + ('video_height_capture', models.PositiveSmallIntegerField(default=720, help_text='Hauteur de capture vidéo en pixels', verbose_name='Hauteur de capture vidéo')), + ('scan_simulation', models.BooleanField(default=False, help_text='Autorise la simulation du balayage', verbose_name='Simuler balayage')), + ('calibration_crop_radius', models.PositiveSmallIntegerField(default=150, help_text='Rayon en pixels pour découper les images de calibration en px', verbose_name='Rayon de découpe pour la calibration')), + ('calibration_default_multiwell', models.CharField(choices=[('HG', 'HG-Haut gauche'), ('HD', 'HD-Haut droit'), ('BG', 'BG-Bas gauche'), ('BD', 'BD-Bas droit')], default='HG', help_text='Position du multi-puits de calibration par défaut', max_length=8, verbose_name='Multi-puits de calibration par défaut')), + ('calibration_default_feed', models.PositiveIntegerField(default=1000, help_text='Vitesse de déplacement pour la calibration en mm/mn', verbose_name='Vitesse de calibration')), + ('calibration_default_step', models.FloatField(default=1.0, help_text='Pas de déplacement pour la calibration en mm', verbose_name='Pas de calibration')), + ('calibration_default_duration', models.FloatField(default=3.0, help_text='Durée de pose entre chaque puits en s', verbose_name='Duruée calibration')), + ('tracking', models.BooleanField(default=False, help_text='Suivi et analyse des planaires', verbose_name='Suivi')), + ('tracking_setting', models.BooleanField(default=False, help_text='Autorise le réglage des valeurs par défaut dans la calibration', verbose_name='Réglage dans calibration')), + ('tube_axis', models.CharField(choices=[('vertical', 'Vertical'), ('horizontal', 'Horizontal')], default='vertical', help_text='Axe du tube', max_length=16, null=True, verbose_name='Axe du puit')), + ('min_area_px', models.PositiveIntegerField(default=20, help_text="surface minimale d'un contour pour être considéré valide (px²)", verbose_name='Surface minimale')), + ('max_area_ratio', models.FloatField(default=0.1, help_text="surface maximale d'un contour en fraction de la frame (défaut 10%)", verbose_name='surface maximale ')), + ('max_planarians', models.PositiveIntegerField(default=1, help_text='nombre maximum de planaires à suivre simultanément (1-10)', verbose_name='Max planaire')), + ('merge_kernel_size', models.PositiveIntegerField(default=15, help_text='taille du kernel elliptique de fusion des fragments (px). Augmenter si fragments résiduels', verbose_name='Taille du kernel')), + ('min_contour_dist_px', models.PositiveIntegerField(default=40, help_text='Distance min entre deux contours pour les considérer comme individus distincts. Défaut : 40px. Augmenter si IDs multiples persistent', verbose_name='Distance ')), + ('active', models.BooleanField(default=False, verbose_name='Actif')), + ('author', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='Auteur')), + ], + options={ + 'verbose_name': 'Configuration', + 'verbose_name_plural': 'Configuration', + 'ordering': ['id'], + }, + ), + migrations.CreateModel( + name='MultiWell', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('label', models.CharField(blank=True, help_text='Label du multi-puit', max_length=100, null=True, verbose_name='Label')), + ('position', models.CharField(choices=[('HG', 'HG-Haut gauche'), ('HD', 'HD-Haut droit'), ('BG', 'BG-Bas gauche'), ('BD', 'BD-Bas droit')], help_text='Position du multi-puits sur la table', max_length=8, null=True, unique=True, verbose_name='Position')), + ('default', models.BooleanField(default=False, help_text='Multi-puit par défaut', verbose_name='Par défaut')), + ('cols', models.PositiveSmallIntegerField(default=6, help_text='Nombre de colonnes', verbose_name='Colonnes')), + ('rows', models.PositiveSmallIntegerField(default=4, help_text='Nombre de lignes', verbose_name='Lignes')), + ('diameter', models.FloatField(default=16.0, help_text='Diamètre des tubes en mm', verbose_name='Diamètre')), + ('row_def', models.CharField(default='A,B,C,D', help_text='Définition des lignes', max_length=16, null=True, verbose_name='Définition')), + ('row_order', models.CharField(default='D,C,B,A', help_text='Ordre ligne de puit. Lecture en serpentin dans le sens des +- X', max_length=16, null=True, verbose_name='Ordre ligne')), + ('crop_radius', models.PositiveSmallIntegerField(default=500, help_text='Rayon en pixels pour recadrer les images en px', verbose_name='Rayon de découpe recadrage')), + ('order', models.PositiveSmallIntegerField(default=0, help_text='Ordre de lecture du multi-puit', verbose_name='Ordre')), + ('duration', models.PositiveIntegerField(default=10, help_text='Durée de capture en secondes pour la calibration', verbose_name='Durée')), + ('xbase', models.FloatField(default=50.0, help_text='Base origine X en mm', verbose_name='Origine X')), + ('ybase', models.FloatField(default=50.0, help_text='Base origine Y en mm', verbose_name='Origine Y')), + ('dx', models.FloatField(default=19.5, help_text='Pas ou interval sur X en mm', verbose_name='Pas X')), + ('dy', models.FloatField(default=19.5, help_text='Pas ou interval sur Y en mm', verbose_name='Pas Y')), + ('feed', models.PositiveIntegerField(default=1000, help_text='Vitesse déplacement en mm/mn ', verbose_name='Vitesse')), + ('well_position', models.BooleanField(default=False, help_text='Positions des puits générées ?. Non => efface WellPosition et recalcule les positions', verbose_name='Positions')), + ('active', models.BooleanField(default=True, verbose_name='Active')), + ('author', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='Auteur')), + ], + options={ + 'verbose_name': 'Multi-puits', + 'verbose_name_plural': 'Multi-puits', + 'ordering': ['order'], + }, + ), + migrations.CreateModel( + name='Experiment', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=100, null=True, verbose_name="Titre de l'expérience")), + ('comment', models.TextField(blank=True, help_text="Descriptions de l'expérience", null=True, verbose_name='Commentaires')), + ('identifier', models.CharField(max_length=100, null=True, unique=True, verbose_name="Identifiant d'expérience")), + ('duration', models.PositiveIntegerField(default=120, help_text='Durée de la prise de vue en secondes', verbose_name='Durée')), + ('created', models.DateTimeField(default=django.utils.timezone.now, verbose_name='Date de création')), + ('started', models.DateTimeField(blank=True, null=True, verbose_name='Date de début')), + ('finished', models.DateTimeField(blank=True, null=True, verbose_name='Date de fin')), + ('author', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='Auteur')), + ('multiwell', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='scanner.multiwell', verbose_name='Multi-puits')), + ], + options={ + 'verbose_name': 'Expérience', + 'verbose_name_plural': 'Expériences', + 'ordering': ['-created'], + }, + ), + migrations.CreateModel( + name='Session', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(help_text="Session d'expérience. 4 Multi-puits maximum", max_length=100, null=True, verbose_name='Nom de la session')), + ('active', models.BooleanField(default=True, verbose_name='Active')), + ('expected_export', models.DateTimeField(blank=True, help_text="Date d'exportation prévue", null=True, verbose_name='Exportation auto')), + ('expected_scanning', models.DateTimeField(blank=True, help_text='Date du balayage prévue', null=True, verbose_name='Bbalayage auto')), + ('created', models.DateTimeField(default=django.utils.timezone.now, verbose_name='Date de création')), + ('finished', models.DateTimeField(blank=True, null=True, verbose_name='Date de fin')), + ('export_status', models.CharField(choices=[('pending', 'En attente'), ('running', 'En cours'), ('done', 'Terminé'), ('error', 'Erreur')], default='pending', max_length=16, verbose_name='Status exportation')), + ('export_exported_at', models.DateTimeField(blank=True, null=True, verbose_name='Exportation terminée à')), + ('scanning_status', models.CharField(choices=[('pending', 'En attente'), ('running', 'En cours'), ('done', 'Terminé'), ('error', 'Erreur')], default='pending', max_length=16, verbose_name='Status scanning')), + ('scanning_finished_at', models.DateTimeField(blank=True, null=True, verbose_name='Balayage terminé à')), + ('author', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='Auteur')), + ('export_task', models.OneToOneField(blank=True, help_text="Programmation de l'exportation des vidéos et images", null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='export_session', to='django_celery_beat.periodictask', verbose_name='Export médias')), + ('scanning_task', models.OneToOneField(blank=True, help_text='Programmation du lancement du balayage', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='scanning_session', to='django_celery_beat.periodictask', verbose_name='Lancer le balayage')), + ], + options={ + 'verbose_name': 'Session', + 'verbose_name_plural': 'Sessions', + 'ordering': ['-created'], + }, + ), + migrations.CreateModel( + name='Well', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(blank=True, help_text='Nom du puit: Ai..Di', max_length=4, null=True, unique=True, verbose_name='Nom')), + ('author', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='Auteur')), + ], + options={ + 'verbose_name': 'Puit', + 'verbose_name_plural': 'Puits', + 'ordering': ['name'], + }, + ), + migrations.CreateModel( + name='SessionExperiment', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('author', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='Auteur')), + ('experiment', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='session_experiments', to='scanner.experiment', verbose_name='Expérience')), + ('session', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='scanner.session', verbose_name='Session')), + ], + options={ + 'verbose_name': "Expérience d'une session", + 'verbose_name_plural': "Expériences d'une session", + 'ordering': ['session'], + 'unique_together': {('session', 'experiment')}, + }, + ), + migrations.CreateModel( + name='ExperimentWell', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('active', models.BooleanField(default=True, verbose_name='Active')), + ('author', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='Auteur')), + ('experiment', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='experimentwell', to='scanner.experiment', verbose_name='Expérience')), + ('well', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='wellexperiment', to='scanner.well', verbose_name='Puit')), + ], + options={ + 'verbose_name': 'Expérience puit', + 'verbose_name_plural': 'Expériences puits', + 'ordering': ['experiment', 'well'], + 'unique_together': {('experiment', 'well')}, + }, + ), + migrations.CreateModel( + name='WellPosition', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('order', models.PositiveSmallIntegerField(default=0, help_text='Ordre de lecture du puit', verbose_name='Ordre')), + ('x', models.FloatField(default=10.0, help_text='Axe X en mm', verbose_name='X')), + ('y', models.FloatField(default=10.0, help_text='Axe Y en mm', verbose_name='Y')), + ('px_per_mm', models.FloatField(default=50.0, help_text='Facteur de calibration optique', verbose_name='Pixels par mm')), + ('author', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='Auteur')), + ('multiwell', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='scanner.multiwell', verbose_name='Multi-puits')), + ('well', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='scanner.well', verbose_name='Puit')), + ], + options={ + 'verbose_name': 'Position du puit', + 'verbose_name_plural': 'Position des puits', + 'ordering': ['order'], + 'unique_together': {('multiwell', 'well')}, + }, + ), + ] diff --git a/test_tube_scanner/scanner/migrations/0002_remove_multiwell_crop_radius.py b/test_tube_scanner/scanner/migrations/0002_remove_multiwell_crop_radius.py new file mode 100644 index 0000000..354eeeb --- /dev/null +++ b/test_tube_scanner/scanner/migrations/0002_remove_multiwell_crop_radius.py @@ -0,0 +1,17 @@ +# Generated by Django 6.0.5 on 2026-05-31 07:43 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('scanner', '0001_initial'), + ] + + operations = [ + migrations.RemoveField( + model_name='multiwell', + name='crop_radius', + ), + ] diff --git a/test_tube_scanner/scanner/migrations/0003_multiwell_crop_radius.py b/test_tube_scanner/scanner/migrations/0003_multiwell_crop_radius.py new file mode 100644 index 0000000..077b566 --- /dev/null +++ b/test_tube_scanner/scanner/migrations/0003_multiwell_crop_radius.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.5 on 2026-05-31 07:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('scanner', '0002_remove_multiwell_crop_radius'), + ] + + operations = [ + migrations.AddField( + model_name='multiwell', + name='crop_radius', + field=models.PositiveSmallIntegerField(default=500, help_text='Rayon en pixels pour recadrer les images en px', verbose_name='Rayon de découpe recadrage'), + ), + ] diff --git a/test_tube_scanner/scanner/migrations/0004_add_videoplate.py b/test_tube_scanner/scanner/migrations/0004_add_videoplate.py new file mode 100644 index 0000000..fe8691d --- /dev/null +++ b/test_tube_scanner/scanner/migrations/0004_add_videoplate.py @@ -0,0 +1,44 @@ +# Generated by Django 6.0.5 on 2026-05-31 11:01 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('scanner', '0003_multiwell_crop_radius'), + ] + + operations = [ + migrations.AlterField( + model_name='configuration', + name='calibration_default_multiwell', + field=models.CharField(choices=[('HG', 'MP 6x24: HG-Haut gauche'), ('HD', 'MP 6x24: HD-Haut droit'), ('BG', 'MP 6x24: BG-Bas gauche'), ('BD', 'MP 6x24: BD-Bas droit'), ('HG_6', 'MP 2x3: HG-Haut gauche'), ('HD_6', 'MP 2x3: HD-Haut droit'), ('BG_6', 'MP 2x3: BG-Bas gauche'), ('BD_6', 'MP 2x3: BD-Bas droit'), ('HG_12', 'MP 3x4: HG-Haut gauche'), ('HD_12', 'MP 3x4: HD-Haut droit'), ('BG_12', 'MP 3x4: BG-Bas gauche'), ('BD_12', 'MP 3x4: BD-Bas droit'), ('HG_48', 'MP 6x8: HG-Haut gauche'), ('HD_48', 'MP 6x8: HD-Haut droit'), ('BG_48', 'MP 6x8: BG-Bas gauche'), ('BD_48', 'MP 6x8: BD-Bas droit')], default='HG', help_text='Position du multi-puits de calibration par défaut', max_length=8, verbose_name='Multi-puits de calibration par défaut'), + ), + migrations.AlterField( + model_name='multiwell', + name='position', + field=models.CharField(choices=[('HG', 'MP 6x24: HG-Haut gauche'), ('HD', 'MP 6x24: HD-Haut droit'), ('BG', 'MP 6x24: BG-Bas gauche'), ('BD', 'MP 6x24: BD-Bas droit'), ('HG_6', 'MP 2x3: HG-Haut gauche'), ('HD_6', 'MP 2x3: HD-Haut droit'), ('BG_6', 'MP 2x3: BG-Bas gauche'), ('BD_6', 'MP 2x3: BD-Bas droit'), ('HG_12', 'MP 3x4: HG-Haut gauche'), ('HD_12', 'MP 3x4: HD-Haut droit'), ('BG_12', 'MP 3x4: BG-Bas gauche'), ('BD_12', 'MP 3x4: BD-Bas droit'), ('HG_48', 'MP 6x8: HG-Haut gauche'), ('HD_48', 'MP 6x8: HD-Haut droit'), ('BG_48', 'MP 6x8: BG-Bas gauche'), ('BD_48', 'MP 6x8: BD-Bas droit')], help_text='Position du multi-puits sur la table', max_length=8, null=True, unique=True, verbose_name='Position'), + ), + migrations.CreateModel( + name='VideoPlate', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('label', models.CharField(blank=True, max_length=200, verbose_name='Label')), + ('video_file', models.FileField(blank=True, null=True, upload_to='videos/', verbose_name='Fichier vidéo')), + ('active', models.BooleanField(default=True, verbose_name='Active')), + ('uploaded_at', models.DateTimeField(auto_now_add=True, verbose_name='Déposé le')), + ('native_fps', models.FloatField(blank=True, null=True, verbose_name='FPS natif')), + ('duration_s', models.FloatField(blank=True, null=True, verbose_name='Durée (s)')), + ('frame_w', models.PositiveIntegerField(blank=True, null=True, verbose_name='Largeur (px)')), + ('frame_h', models.PositiveIntegerField(blank=True, null=True, verbose_name='Hauteur (px)')), + ('multiwell', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='video_plates', to='scanner.multiwell', verbose_name='Multi-puits')), + ], + options={ + 'verbose_name': 'Vidéo plaque', + 'verbose_name_plural': 'Vidéos plaque', + 'ordering': ['multiwell__order', '-uploaded_at'], + }, + ), + ] diff --git a/test_tube_scanner/scanner/migrations/0005_alter_multiwell_options_multiwell_capture_video.py b/test_tube_scanner/scanner/migrations/0005_alter_multiwell_options_multiwell_capture_video.py new file mode 100644 index 0000000..edf3607 --- /dev/null +++ b/test_tube_scanner/scanner/migrations/0005_alter_multiwell_options_multiwell_capture_video.py @@ -0,0 +1,22 @@ +# Generated by Django 6.0.5 on 2026-06-02 08:25 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('scanner', '0004_add_videoplate'), + ] + + operations = [ + migrations.AlterModelOptions( + name='multiwell', + options={'ordering': ['label', 'order'], 'verbose_name': 'Multi-puits', 'verbose_name_plural': 'Multi-puits'}, + ), + migrations.AddField( + model_name='multiwell', + name='capture_video', + field=models.BooleanField(default=False, help_text='Ce multi-puit servira pour la capture vidéo', verbose_name='Capture vidéo'), + ), + ] diff --git a/test_tube_scanner/scanner/migrations/0006_alter_multiwell_capture_video_and_more.py b/test_tube_scanner/scanner/migrations/0006_alter_multiwell_capture_video_and_more.py new file mode 100644 index 0000000..4936668 --- /dev/null +++ b/test_tube_scanner/scanner/migrations/0006_alter_multiwell_capture_video_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 6.0.5 on 2026-06-02 08:28 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('scanner', '0005_alter_multiwell_options_multiwell_capture_video'), + ] + + operations = [ + migrations.AlterField( + model_name='multiwell', + name='capture_video', + field=models.BooleanField(default=False, help_text='Ce multi-puit servira pour la capture vidéo', verbose_name='Vidéo'), + ), + migrations.AlterField( + model_name='multiwell', + name='default', + field=models.BooleanField(default=False, help_text='Multi-puit par défaut', verbose_name='Défaut'), + ), + ] diff --git a/test_tube_scanner/scanner/migrations/0007_add_videoplate_px_per_mm.py b/test_tube_scanner/scanner/migrations/0007_add_videoplate_px_per_mm.py new file mode 100644 index 0000000..43731a1 --- /dev/null +++ b/test_tube_scanner/scanner/migrations/0007_add_videoplate_px_per_mm.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.5 on 2026-06-02 09:51 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('scanner', '0006_alter_multiwell_capture_video_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='videoplate', + name='px_per_mm', + field=models.FloatField(default=15.0, help_text='Facteur pixels/mm dans la vidéo plaque. À calibrer selon la résolution de la caméra plaque.', verbose_name='Pixels par mm (vidéo plaque)'), + ), + ] diff --git a/test_tube_scanner/scanner/migrations/0008_add_videoplate_origin.py b/test_tube_scanner/scanner/migrations/0008_add_videoplate_origin.py new file mode 100644 index 0000000..a548803 --- /dev/null +++ b/test_tube_scanner/scanner/migrations/0008_add_videoplate_origin.py @@ -0,0 +1,29 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('scanner', '0007_add_videoplate_px_per_mm'), + ] + + operations = [ + migrations.AddField( + model_name='videoplate', + name='x_origin_mm', + field=models.FloatField( + default=0.0, + verbose_name='Origine X (mm)', + help_text='Position CNC X correspondant au pixel 0 de la vidéo plaque (mm). Défaut 0.', + ), + ), + migrations.AddField( + model_name='videoplate', + name='y_origin_mm', + field=models.FloatField( + default=0.0, + verbose_name='Origine Y (mm)', + help_text='Position CNC Y correspondant au pixel 0 de la vidéo plaque (mm). Défaut 0.', + ), + ), + ] diff --git a/test_tube_scanner/scanner/migrations/0009_alter_videoplate_x_origin_mm_and_more.py b/test_tube_scanner/scanner/migrations/0009_alter_videoplate_x_origin_mm_and_more.py new file mode 100644 index 0000000..e8385ff --- /dev/null +++ b/test_tube_scanner/scanner/migrations/0009_alter_videoplate_x_origin_mm_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 6.0.5 on 2026-06-03 09:13 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('scanner', '0008_add_videoplate_origin'), + ] + + operations = [ + migrations.AlterField( + model_name='videoplate', + name='x_origin_mm', + field=models.FloatField(default=0.0, help_text='Position CNC X correspondant au bord gauche de la vidéo plaque (mm). Défaut 0.', verbose_name='Origine X (mm)'), + ), + migrations.AlterField( + model_name='videoplate', + name='y_origin_mm', + field=models.FloatField(default=0.0, help_text='Position CNC Y correspondant au bord haut de la vidéo plaque (mm). Défaut 0.', verbose_name='Origine Y (mm)'), + ), + ] diff --git a/test_tube_scanner/scanner/migrations/__init__.py b/test_tube_scanner/scanner/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/test_tube_scanner/scanner/models.py b/test_tube_scanner/scanner/models.py index fe1ee6c..58274d6 100644 --- a/test_tube_scanner/scanner/models.py +++ b/test_tube_scanner/scanner/models.py @@ -4,6 +4,7 @@ from django.utils.translation import gettext_lazy as _ import uuid import json +from pathlib import Path from django_celery_beat.models import PeriodicTask, ClockedSchedule from django.dispatch import receiver from django.db.models.signals import post_save, post_delete @@ -13,10 +14,22 @@ from django.contrib.auth.models import User MULTIWELL_POSITION = [ - ('HG', _("HG-Haut gauche")), - ('HD', _("HD-Haut droit")), - ('BG', _("BG-Bas gauche")), - ('BD', _("BD-Bas droit")), + ('HG', _("MP 6x24: HG-Haut gauche")), + ('HD', _("MP 6x24: HD-Haut droit")), + ('BG', _("MP 6x24: BG-Bas gauche")), + ('BD', _("MP 6x24: BD-Bas droit")), + ('HG_6', _("MP 2x3: HG-Haut gauche")), + ('HD_6', _("MP 2x3: HD-Haut droit")), + ('BG_6', _("MP 2x3: BG-Bas gauche")), + ('BD_6', _("MP 2x3: BD-Bas droit")), + ('HG_12', _("MP 3x4: HG-Haut gauche")), + ('HD_12', _("MP 3x4: HD-Haut droit")), + ('BG_12', _("MP 3x4: BG-Bas gauche")), + ('BD_12', _("MP 3x4: BD-Bas droit")), + ('HG_48', _("MP 6x8: HG-Haut gauche")), + ('HD_48', _("MP 6x8: HD-Haut droit")), + ('BG_48', _("MP 6x8: BG-Bas gauche")), + ('BD_48', _("MP 6x8: BD-Bas droit")), ] FOURCC_FORMAT = [ @@ -32,7 +45,8 @@ VIDEO_TYPE = [ CAPTURE_TYPE = [ ('rpi', _("Arducam")), ('webcam', _("Webcam")), - ('file', _("Fichier vidéo (mp4, avi)")), + ('file', _("Simulation Fichier vidéo (mp4, avi)")), + ('video', _("Fichier vidéo (mp4, avi)")), ] TUBE_AXIS_TYPE = [ @@ -81,7 +95,6 @@ class Configuration(models.Model): # active = models.BooleanField(_("Actif"), default=False) - @classmethod def active_config(cls): return Configuration.objects.filter(active=True).first() @@ -102,7 +115,6 @@ class Well(models.Model): ordering = ['name', ] verbose_name = _("Puit") verbose_name_plural = _("Puits") - def __str__(self): return f'{self.name}' @@ -113,13 +125,15 @@ class MultiWell(models.Model): label = models.CharField(_("Label"), help_text=_("Label du multi-puit"), max_length=100, null=True, blank=True) author = models.ForeignKey(User, on_delete=models.SET_NULL, verbose_name="Auteur", null=True, blank=True) position = models.CharField(_("Position"), help_text=_('Position du multi-puits sur la table'), unique=True, max_length=8, choices=MULTIWELL_POSITION, null=True, blank=False) - default = models.BooleanField(_("Par défaut"), help_text=_('Multi-puit par défaut'), default=False) + default = models.BooleanField(_("Défaut"), help_text=_('Multi-puit par défaut'), default=False) # Configuration cols = models.PositiveSmallIntegerField(_("Colonnes"), help_text=_('Nombre de colonnes'), blank=False, default=6) rows = models.PositiveSmallIntegerField(_("Lignes"), help_text=_('Nombre de lignes'), blank=False, default=4) diameter = models.FloatField(_("Diamètre"), help_text=_('Diamètre des tubes en mm'), blank=False, default=16.0) row_def = models.CharField(_("Définition"), help_text=_('Définition des lignes'), max_length=16, null=True, blank=False, default="A,B,C,D") 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") + crop_radius = models.PositiveSmallIntegerField(_("Rayon de découpe recadrage"), help_text=_("Rayon en pixels pour recadrer les images en px"), blank=False, default=500) + # 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 de capture en secondes pour la calibration'), blank=False, default=10) @@ -129,11 +143,11 @@ class MultiWell(models.Model): dx = models.FloatField(_("Pas X"), help_text=_('Pas ou interval sur X en mm'), blank=False, default=19.5) dy = models.FloatField(_("Pas Y"), help_text=_('Pas ou interval sur Y en mm'), blank=False, default=19.5) feed = models.PositiveIntegerField(_("Vitesse"), help_text=_('Vitesse déplacement en mm/mn '), blank=False, default=1000) - + well_position = models.BooleanField(_("Positions"), help_text=_('Positions des puits générées ?. Non => efface WellPosition et recalcule les positions'), default=False) + capture_video = models.BooleanField(_("Vidéo"), help_text=_('Ce multi-puit servira pour la capture vidéo'), default=False) active = models.BooleanField(_("Active"), default=True) - def config(self): return dict( position=self.position, @@ -166,7 +180,7 @@ class MultiWell(models.Model): return MultiWell.objects.filter(active=True).all() class Meta: - ordering = ['order', ] + ordering = ['label', 'order', ] verbose_name = _("Multi-puits") verbose_name_plural = _("Multi-puits") @@ -206,8 +220,8 @@ class WellPosition(models.Model): @receiver(post_save, sender=MultiWell) def create_well_position(sender, instance, created, **kwargs): - if created: - pass + #if created: + # pass if not instance.well_position: row_order = instance.row_order.split(',') n = 0 @@ -302,7 +316,7 @@ class Session(models.Model): @classmethod - def get_session(self, sid): + def get_session(cls, sid): return Session.objects.filter(pk=sid).first() @@ -313,7 +327,7 @@ class Session(models.Model): def __str__(self): state = _("Terminée") if not self.active else _("Active") - return f'[ {self.id} ] {self.name} ({state})' + return f'[ {self.pk} ] {self.name} ({state})' @receiver(post_save, sender=Session) @@ -444,6 +458,106 @@ class ExperimentWell(models.Model): def __str__(self): return f'{self.experiment.title}' +class VideoPlate(models.Model): + """Vidéo d'une plaque multi-puits entière, utilisée en mode capture_type='video'.""" + multiwell = models.ForeignKey( + MultiWell, + on_delete=models.CASCADE, + verbose_name=_("Multi-puits"), + related_name='video_plates', + ) + label = models.CharField(_("Label"), max_length=200, blank=True) + video_file = models.FileField( + _("Fichier vidéo"), + upload_to='videos/', + null=True, + blank=True, + ) + active = models.BooleanField(_("Active"), default=True) + uploaded_at = models.DateTimeField(_("Déposé le"), auto_now_add=True) + # Calibration vidéo plaque : pixels par mm dans la vidéo (≠ calibration caméra individuelle) + px_per_mm = models.FloatField( + _("Pixels par mm (vidéo plaque)"), + default=15.0, + help_text=_("Facteur pixels/mm dans la vidéo plaque. À calibrer selon la résolution de la caméra plaque."), + ) + # Origine : position CNC (mm) correspondant au pixel (0, 0) de la vidéo. + # Indépendant de MultiWell.xbase — ne change pas à la recalibration des puits. + x_origin_mm = models.FloatField( + _("Origine X (mm)"), + default=0.0, + help_text=_("Position CNC X correspondant au bord gauche de la vidéo plaque (mm). Défaut 0."), + ) + y_origin_mm = models.FloatField( + _("Origine Y (mm)"), + default=0.0, + help_text=_("Position CNC Y correspondant au bord haut de la vidéo plaque (mm). Défaut 0."), + ) + # Métadonnées extraites automatiquement à l'upload + native_fps = models.FloatField(_("FPS natif"), null=True, blank=True) + duration_s = models.FloatField(_("Durée (s)"), null=True, blank=True) + frame_w = models.PositiveIntegerField(_("Largeur (px)"), null=True, blank=True) + frame_h = models.PositiveIntegerField(_("Hauteur (px)"), null=True, blank=True) + + @classmethod + def active_for(cls, multiwell_position: str) -> "VideoPlate | None": + return cls.objects.filter(multiwell__position=multiwell_position, active=True).first() + + @classmethod + def active_video(cls) -> "VideoPlate | None": + return cls.objects.filter(active=True).first() + + @property + def video_filename(self) -> str: + return Path(self.video_file.name).name if self.video_file else "—" + + @property + def resolution(self) -> str: + if self.frame_w and self.frame_h: + return f"{self.frame_w}×{self.frame_h}" + return "—" + + class Meta: + ordering = ['multiwell__order', '-uploaded_at'] + verbose_name = _("Vidéo plaque") + verbose_name_plural = _("Vidéos plaque") + + def __str__(self) -> str: + return f"{self.multiwell.position} — {self.label or self.video_filename}" + + +@receiver(post_save, sender=VideoPlate) +def notify_video_plate_change(sender, instance, **kwargs): + """Hot swap : publie sur Redis quand une vidéo active est enregistrée.""" + if not instance.active or not instance.video_file: + return + try: + from redis import Redis + from django.conf import settings as django_settings + r = Redis( + host=django_settings.REDIS_HOST, + port=django_settings.REDIS_PORT, + db=0, + decode_responses=True, + ) + r.publish('scanner_proc', json.dumps({ + 'type': 'scanner', + 'topic': 'video_plate', + 'multiwell': instance.multiwell.position, + 'path': instance.video_file.path, + })) + except Exception: + pass + + +@receiver(post_delete, sender=VideoPlate) +def delete_video_file(sender, instance, **kwargs): + """Supprime le fichier physique quand l'enregistrement est effacé.""" + if instance.video_file: + path = Path(instance.video_file.path) + path.unlink(missing_ok=True) + + @receiver(post_save, sender=Experiment) def create_experiment_well(sender, instance, created, **kwargs): from planarian.models import ExperimentConfig diff --git a/test_tube_scanner/scanner/multiwell.py b/test_tube_scanner/scanner/multiwell.py index 6c8bc2a..09bd60d 100644 --- a/test_tube_scanner/scanner/multiwell.py +++ b/test_tube_scanner/scanner/multiwell.py @@ -14,7 +14,7 @@ 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.utils.safestring import mark_safe from django.conf import settings from planarian.models import ExperimentConfig from . import models @@ -115,42 +115,56 @@ class MultiWellManager: self._duration = duration or self.process.conf.calibration_default_duration self.px_per_mm = 50.0 + def init_manager_values(self): + wells = models.WellPosition.objects.filter(multiwell_id=self.multiwell.pk).order_by('order').all() # type: ignore[union-attr] + 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 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 - + self.init_manager_values() return self.multiwell.config() - + def set_first_multiwell_from_session(self, sid): + experiments = models.SessionExperiment.experiment_by_session(sid) + if experiments: + self.multiwell = experiments[0].multiwell + self.init_manager_values() + def multiwell_buttons(self, btn_class="w3-button", onclick=''' onclick="goto_well(this)"'''): multiwells = [] multiwells.append('''
''') - for wl in self.well_iterator: + for wl in self.well_iterator: multiwells.append(f"""""") multiwells.append('''
''') self.well_iterator.reset() return mark_safe("\n".join(multiwells)) - - - #def _grid_scanning_capture(self, uuid, duration): + + def set_circular_crop(self, crop_radius): + crop = self.process.set_crop_radius(crop_radius) + self.process.cam.set_circular_crop(crop) + + def update_crop_radius(self, value): + self.multiwell.crop_radius = value + self.multiwell.save() + def _grid_scanning_capture(self, experiment, well_position, simulate=False): uuid = None try: well = well_position.well multiwell = experiment.multiwell + # En mode video le crop_radius est piloté par _capture_video_simulation + if self.process.conf.capture_type != 'video': + self.set_circular_crop(multiwell.crop_radius) + ## create uuid for this capture uuid = f'{self.process.data.session}-{multiwell.position}-{well.name}' @@ -198,8 +212,17 @@ class MultiWellManager: 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}") + self.process.cam._error_occured = True + logger.info(f"Simulating capture with file {vf}") + + def _capture_video_simulation(self, well_position): + """Met à jour VideoPlateCapture : crop_radius depuis MultiWell.crop_radius.""" + cam = self.process.cam + r = well_position.multiwell.crop_radius + if hasattr(cam, 'set_crop_radius_px'): + cam.set_crop_radius_px(r) + self.set_circular_crop(r) + logger.info(f"video_simulation: {well_position.well.name} crop_r={r}px") def _is_well_valid(self, welposition, experiment): @@ -222,10 +245,12 @@ class MultiWellManager: if not self._is_well_valid(wl, experiment): continue - self.cnc_controller.move_to(wl.x, wl.y, feed=wl.multiwell.feed) + 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._capture_file_simulation(wl.well.name) + elif self.process.conf.capture_type == 'video': + self._capture_video_simulation(wl) + self._grid_scanning_capture(experiment, wl, simulate=simulate) msg =f"Scan terminé — retour à l'origine (X={xnext:.1f} Y={ynext:.1f})" logger.info(msg) @@ -237,7 +262,7 @@ class MultiWellManager: self.process._send(state='error', msg=msg) return False finally: - self.cnc_controller.move_to(xnext, ynext, feed=multiwell.feed*2) + self.cnc_controller.move_to(xnext, ynext, feed=self.feed*2) def _start_scanning(self, session, experiments, simulate=False): @@ -315,38 +340,46 @@ class MultiWellManager: wl = self.well_iterator.previous() if self.process.conf.capture_type == 'file': self._capture_file_simulation(wl.well.name) + elif self.process.conf.capture_type == 'video': + self._capture_video_simulation(wl) 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})"} - - + 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) - + elif self.process.conf.capture_type == 'video': + self._capture_video_simulation(wl) 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})"} - - + 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._capture_file_simulation(wl.well.name) + elif self.process.conf.capture_type == 'video': + self._capture_video_simulation(wl) 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._capture_file_simulation(wl.well.name) + elif self.process.conf.capture_type == 'video': + self._capture_video_simulation(wl) 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._capture_file_simulation('zero') + elif self.process.conf.capture_type == 'video': + # Plein cadre : désactiver le masque circulaire pour ne pas rogner la vue d'ensemble + self.process.cam.set_circular_crop(None) self.cnc_controller.move_to(0, 0, feed=self.feed*2) def set_well_position(self): @@ -364,18 +397,19 @@ class MultiWellManager: 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 + duration = self.duration if not auto else settings.CALIBRATION_AUTO_DURATION + start_test = time.monotonic() 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._capture_file_simulation(wl.well.name) + elif self.process.conf.capture_type == 'video': + self._capture_video_simulation(wl) self.process._send(current=wl.order) start = time.monotonic() @@ -497,7 +531,7 @@ class MultiWellManager: 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) diff --git a/test_tube_scanner/scanner/process.py b/test_tube_scanner/scanner/process.py index 6557410..d4ba764 100644 --- a/test_tube_scanner/scanner/process.py +++ b/test_tube_scanner/scanner/process.py @@ -27,6 +27,7 @@ from modules import reductstore, utils, planarian_metrics from modules.circular_crop import CircularCrop, CropStrategy from .multiwell import MultiWellManager from .constants import ScannerConstants +from .models import MultiWell # CNC if not settings.GRBL_SIMULATION: @@ -38,7 +39,7 @@ else: class ProcessData: play: bool = True record: bool = False - uuid: str = None + uuid: str | None = None session: int = 0 tube_diameter: float = 16.0 @@ -173,27 +174,31 @@ class ScannerProcess(Task): Constants: reset si besoin les constantes vidéo ''' - self.conf = ScannerConstants().get() + self.constants = ScannerConstants() + self.conf = self.constants.get() self.use_tracking = self.conf.tracking self.video_quality = self.conf.video_jpeg_quality self.image_quality = self.conf.image_quality self.video_fps = self.conf.video_frame_rate self.video_width = self.conf.video_width_capture - self.video_height = self.conf.video_height_capture + self.video_height = self.conf.video_height_capture + self.crop_radius = self.conf.calibration_crop_radius self.video_jpg_quality = [int(cv2.IMWRITE_JPEG_QUALITY), self.video_quality] self.image_jpg_quality = [int(cv2.IMWRITE_JPEG_QUALITY), self.image_quality] return self.conf + def save_config(self): + pass + def start(self, *args, **kwargs): try: self.get_config() self.grbl_xmax = self.conf.grbl_xmax - self.grbl_ymax = self.conf.grbl_ymax - + self.grbl_ymax = self.conf.grbl_ymax self.crop = self.set_crop_radius(self.crop_radius) capture_type = self.conf.capture_type @@ -208,7 +213,30 @@ class ScannerProcess(Task): use_tracking=self.use_tracking, display=self._display, parent=self, - ) + ) + elif capture_type == 'video': + from modules.videoplate_capture import VideoPlateCapture + from .models import VideoPlate + + vp = VideoPlate.active_video() + if not vp: + raise Exception("Aucun VideoPlate actif trouvé — créer un enregistrement dans l'admin.") + initial_path = vp.video_file.path if vp.video_file else None + mw = vp.multiwell + + self.cam = VideoPlateCapture( + video_dir=settings.MEDIA_ROOT / 'videos', + fps=self.video_fps, + jpeg_quality=self.video_quality, + use_tracking=self.use_tracking, + display=self._display, + parent=self, + crop_radius_px=mw.crop_radius, + px_per_mm=vp.px_per_mm, + x_offset_mm=vp.x_origin_mm, + y_offset_mm=vp.y_origin_mm, + initial_video_path=initial_path, + ) elif capture_type == 'webcam': from modules.webcam_capture import WebcamCapture self.cam = WebcamCapture( @@ -237,9 +265,14 @@ class ScannerProcess(Task): self.cam._active_median = False self.cam.set_circular_crop(None) - self.grbl = GRBLController( - send_callback=self._display, - x_max=self.conf.grbl_xmax, + # Mode vidéo : toujours simuler le GRBL (pas de CNC physique) + if capture_type == 'video': + from modules.grbl_simulator import GRBLController as _GRBLCtrl + else: + _GRBLCtrl = GRBLController + self.grbl = _GRBLCtrl( + send_callback=self._display, + x_max=self.conf.grbl_xmax, y_max=self.conf.grbl_ymax ) @@ -354,11 +387,10 @@ class ScannerProcess(Task): def _listen_to_redis(self): + logger.info(f"==== Scanner {self.group}: listen via redisDB") + pubsub = redisDB.pubsub() + pubsub.subscribe(self.group) try: - logger.info(f"==== Scanner {self.group}: listen via redisDB") - pubsub = redisDB.pubsub() - pubsub.subscribe(self.group) - for message in pubsub.listen(): try: #logger.info(f"{message}") @@ -371,18 +403,32 @@ class ScannerProcess(Task): if not isinstance(cmd, dict): continue - self._send(state=cmd["type"], msg=f"Cmd: {cmd.get('topic')} {cmd.get('value', '')}") + self._send(state=cmd["type"], msg=f"Cmd: {cmd.get('topic')} {cmd.get('value', '')}") + ctx = {} if cmd["type"]=="scanner": topic = cmd.get("topic") if topic == 'init': - self.cam.set_circular_crop(self.crop) + sid = cmd.get("sid") + self.manager.set_first_multiwell_from_session(sid) self.cam._active_median = False + self.cam.set_edge_enhance(False) + if self.conf.capture_type == 'video': + self.cam._active_crop = False + self.cam.set_circular_crop(None) + ctx = dict(state="crop", value=self.cam._active_crop) + else: + self.cam.set_circular_crop(self.crop) self.grbl.go_origin(feed=self.manager.feed) self.cam.set_draw_contours(False) - buttons = self.manager.multiwell_buttons(btn_class="w3-btn well", onclick="") - self._send(buttons=buttons) - - elif topic == 'scan' or topic == 'simulate': + + elif topic == 'video_plate': + new_path = cmd.get('path') + if new_path and hasattr(self.cam, 'set_video_file'): + self.cam.set_video_file(new_path) + self._send(state='video_plate', msg=f"Vidéo: {new_path}") + continue + + elif topic == 'scan' or topic == 'simulate': logger.info(f"==== Scan {cmd}") sid = cmd.get("session", '0') @@ -391,6 +437,7 @@ class ScannerProcess(Task): else: try: self.cam._active_median = False + self.cam.set_edge_enhance(False) simulate = (topic=='simulate') self.manager.scan_process(sid, simulate) @@ -401,8 +448,10 @@ class ScannerProcess(Task): continue self._send( - buttons=buttons, + buttons=self.manager.multiwell_buttons(btn_class="w3-btn well", onclick=""), + columns=self.manager.multiwell.cols, current=self.manager.get_well_order(), + **ctx ) elif cmd["type"]=="calibrate": topic = cmd.get("topic") @@ -417,8 +466,9 @@ class ScannerProcess(Task): position = cmd.get("position") self.manager.set_multiwell(position) self.cam.set_circular_crop(None) - self.cam._active_median = False - self.cam.set_draw_contours(False) + self.cam._active_median = False + self.cam.set_edge_enhance(False) + self.cam.set_draw_contours(False) buttons = self.manager.multiwell_buttons() elif topic == 'up': @@ -437,18 +487,32 @@ class ScannerProcess(Task): self.cam._active_median = not self.cam._active_median self._send(state="median", value=self.cam._active_median, msg=f"Median: {self.cam._active_median}") continue + + elif topic == 'edge_enhance': + self.cam.set_edge_enhance(not self.cam._active_edge_enhance) + continue elif topic == 'crop': self.cam._active_crop = not self.cam._active_crop - self.cam.set_circular_crop(self.crop) if self.cam._active_crop else self.cam.set_circular_crop(None) + if self.cam._active_crop: + self.cam.set_circular_crop(self.crop) + # En mode vidéo, naviguer vers le premier puit (Base) + # pour que le crop circulaire soit aligné sur un puit réel + if self.conf.capture_type == 'video': + self.manager.goto_xy() + else: + self.cam.set_circular_crop(None) self._send(state="crop", value=self.cam._active_crop, msg=f"Crop: {self.cam._active_crop}") continue elif topic == 'crop_radius': self.conf.calibration_crop_radius=int(value) self.crop = self.set_crop_radius(self.conf.calibration_crop_radius) - self.conf.save() + self.constants.save_config() # type: ignore[attr-defined] + self.cam.set_circular_crop(self.crop) + + self.manager.update_crop_radius(int(value)) continue elif topic == 'position': @@ -480,6 +544,12 @@ class ScannerProcess(Task): elif topic == 'auto': self.manager.set_calib_debug(True) self.cam.set_circular_crop(self.crop) + # En mode vidéo le puit remplit le crop (ratio ~0.50) ; + # en mode caméra le tube occupe ~30% du champ. + if self.conf.capture_type == 'video': + self.cam._aligner.set_radius_range(0.38, 0.47) + else: + self.cam._aligner.set_radius_range(0.26, 0.37) self.manager.scan_test(auto=True) continue @@ -495,8 +565,19 @@ class ScannerProcess(Task): self.manager.halt_scanning() elif topic == 'calib_debug': + if self.conf.capture_type == 'video': + self.cam._aligner.set_radius_range(0.38, 0.47) + else: + self.cam._aligner.set_radius_range(0.26, 0.37) msg = self.manager.calib_toggle_debug() - self._send(**msg) + self._send(**msg) + continue + + elif topic == 'draw_debug': + a = self.cam._aligner + a.draw_annotations = not a.draw_annotations + self._send(state='draw_debug', value=a.draw_annotations, + msg=f"Draw debug: {a.draw_annotations}") continue elif topic == 'previous': @@ -536,6 +617,7 @@ class ScannerProcess(Task): dx=self.manager.dx, dy=self.manager.dy, buttons=buttons, + columns=self.manager.multiwell.cols, current=self.manager.get_well_order(), ) except Exception as e: @@ -697,12 +779,11 @@ class ReplayProcess(Task): logger.info(f"==== ReplayProcess stopped.") def _listen_to_redis(self): + loop = None + logger.info(f"==== ReplayProcess {self.group}: listen via redisDB") + pubsub = redisDB.pubsub() + pubsub.subscribe(self.group) try: - loop = None - logger.info(f"==== ReplayProcess {self.group}: listen via redisDB") - pubsub = redisDB.pubsub() - pubsub.subscribe(self.group) - for message in pubsub.listen(): try: if self.stop_event.is_set(): @@ -754,7 +835,8 @@ class ReplayProcess(Task): logger.error(f'ReplayProcess::listen_to_redis: {e}') finally: self.running.set() - utils.stop_async(loop) + if loop: + utils.stop_async(loop) pubsub.unsubscribe() pubsub.close() diff --git a/test_tube_scanner/scanner/static/scanner/css/calibration.css b/test_tube_scanner/scanner/static/scanner/css/calibration.css index 56def16..0e48164 100644 --- a/test_tube_scanner/scanner/static/scanner/css/calibration.css +++ b/test_tube_scanner/scanner/static/scanner/css/calibration.css @@ -5,7 +5,7 @@ .well-btn { display: grid; - grid-template-columns: repeat(6, 1fr); + grid-template-columns: repeat(var(--well-columns, 6), 1fr); justify-items: center; align-items: center; } diff --git a/test_tube_scanner/scanner/static/scanner/css/scanning.css b/test_tube_scanner/scanner/static/scanner/css/scanning.css index 97ea60e..affddcc 100644 --- a/test_tube_scanner/scanner/static/scanner/css/scanning.css +++ b/test_tube_scanner/scanner/static/scanner/css/scanning.css @@ -5,7 +5,7 @@ .well-btn { display: grid; - grid-template-columns: repeat(6, 1fr); + grid-template-columns: repeat(var(--well-columns, 6), 1fr); justify-items: center; align-items: center; } diff --git a/test_tube_scanner/scanner/static/scanner/css/video_upload.css b/test_tube_scanner/scanner/static/scanner/css/video_upload.css new file mode 100644 index 0000000..63de191 --- /dev/null +++ b/test_tube_scanner/scanner/static/scanner/css/video_upload.css @@ -0,0 +1,72 @@ +.video-drop-zone { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 80px; + margin-top: 8px; + padding: 12px 20px; + border: 2px dashed #aaa; + border-radius: 6px; + background: #f9f9f9; + cursor: pointer; + transition: border-color 0.2s, background 0.2s; + gap: 8px; +} + +.video-drop-zone:hover, +.video-drop-zone.drag-over { + border-color: #417690; + background: #e8f3fb; +} + +.video-drop-zone.has-file { + border-style: solid; + border-color: #28a745; + background: #f0fff4; +} + +.video-drop-hint { + font-size: 0.9em; + color: #555; + pointer-events: none; + text-align: center; +} + +.video-drop-zone.has-file .video-drop-hint { + color: #28a745; + font-weight: 600; +} + +/* ---- Progress bar ---- */ + +.video-progress-wrap { + display: none; + width: 100%; + max-width: 360px; +} + +.video-progress-bar-track { + width: 100%; + height: 10px; + background: #ddd; + border-radius: 5px; + overflow: hidden; + margin-bottom: 2px; +} + +.video-progress-bar { + height: 10px; + width: 0%; + background: #417690; + border-radius: 5px; + transition: width 0.3s ease; +} + +.video-progress-label { + display: block; + text-align: right; + font-size: 0.8em; + color: #417690; + font-weight: 600; +} diff --git a/test_tube_scanner/scanner/static/scanner/js/calibration.js b/test_tube_scanner/scanner/static/scanner/js/calibration.js index 6ff1d8c..c1601c2 100644 --- a/test_tube_scanner/scanner/static/scanner/js/calibration.js +++ b/test_tube_scanner/scanner/static/scanner/js/calibration.js @@ -32,14 +32,16 @@ class ScannerManager { this.step = options.step; this.well = options.well; this.debug = options.debug; - this.calib_debug = options.calib_debug; - this.calib_center= options.calib_center; + this.calib_debug = options.calib_debug; + this.draw_debug = options.draw_debug; + this.calib_center= options.calib_center; this.previous = options.previous; this.next = options.next; this.set_well = options.set_well; this.well_btn = options.well_btn; - this.median = options.median; - this.crop = options.crop; + this.median = options.median; + this.edge_enhance = options.edge_enhance; + this.crop = options.crop; this.crop_radius = options.crop_radius; this.calib_auto = options.calib_auto; @@ -53,7 +55,7 @@ class ScannerManager { } catch(e) {} } - init_controls() { + init_controls() { this.up.addEventListener('mousedown', (e) => { this._send({ type: 'calibrate', topic: "up" }); }); this.down.addEventListener('mousedown', (e) => { this._send({ type: 'calibrate', topic: "down" }); }); this.left.addEventListener('mousedown', (e) => { this._send({ type: 'calibrate', topic: "left" }); }); @@ -62,33 +64,35 @@ class ScannerManager { this.goto_0.addEventListener('click', (e) => { this.clear_buttons(); this._send({ type: 'calibrate', topic: "goto_0" }); }); this.goto_xy.addEventListener('click', (e) => { this.clear_buttons(); this._send({ type: 'calibrate', topic: "goto_xy" }); }); this.xy_base.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "xy_base" }); }); - - this.calib_debug.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "calib_debug" }); }); - this.previous.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "previous" }); }); - this.next.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "next" }); }); this.set_well.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "set_well" }); }); - - this.median.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "median" }); }); - this.crop.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "crop" }); }); + this.median.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "median" }); }); + this.edge_enhance.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "edge_enhance" }); }); + this.crop.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "crop" }); }); this.crop_radius.addEventListener('change',(e) => { this._send({ type: 'calibrate', topic: "crop_radius", value: this.crop_radius.value }); }); + this.well.addEventListener("change", (e) => { this._send({ type: 'calibrate', topic: "position", value: e.target.value }); }); this.step.addEventListener("change", (e) => { this._send({ type: 'calibrate', topic: "step", value: e.target.value }); }); this.feed.addEventListener("change", (e) => { this._send({ type: 'calibrate', topic: "feed", value: e.target.value }); }); this.duration.addEventListener("change", (e) => { this._send({ type: 'calibrate', topic: "duration", value: e.target.value }); }); this.test.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "test" }); }); - this.calib_center.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "center" }); }); - this.calib_auto.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "auto" }); }); + this.halt.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "halt" }); }); - + this.calib_debug.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "calib_debug" }); }); + this.draw_debug.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "draw_debug" }); }); try { + + this.previous.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "previous" }); }); + this.next.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "next" }); }); + this.calib_center.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "center" }); }); + this.calib_auto.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "auto" }); }); 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) {} + } catch(e) {console.log(e);} } registerSocket(socket) { @@ -104,8 +108,12 @@ class ScannerManager { if (payload.state) { if (payload.state == 'debug') { const span = this.calib_debug.querySelector("span.debug"); span.style.color = payload.value ? '#f0f': '#fff'; + } else if (payload.state == 'draw_debug') { + const span = this.draw_debug.querySelector("span.draw_debug"); span.style.color = payload.value ? '#0ff' : '#888'; } else if (payload.state == 'median') { const span = this.median.querySelector("span.median"); span.style.color = payload.value ? '#f0f' : '#fff'; + } else if (payload.state == 'edge_enhance') { + const span = this.edge_enhance.querySelector("span.edge_enhance"); span.style.color = payload.value ? '#0ff' : '#fff'; } else if (payload.state == 'crop') { const span = this.crop.querySelector("span.crop"); span.style.color = payload.value ? '#f0f' : '#fff'; } @@ -113,7 +121,10 @@ class ScannerManager { } if (payload.ts) { this.ts.textContent = timestampToLocalISOString(payload.ts); } - if (payload.buttons) { this.well_btn.innerHTML = payload.buttons; } + if (payload.buttons) { + this.well_btn.innerHTML = payload.buttons; + document.documentElement.style.setProperty('--well-columns', payload.columns); + } if (payload.current >= 0) { document.querySelectorAll('button.w3-button.well').forEach(btn => { if (btn.value==payload.current) { btn.classList.add('w3-green'); return; } diff --git a/test_tube_scanner/scanner/static/scanner/js/scanning.js b/test_tube_scanner/scanner/static/scanner/js/scanning.js index 59405f2..b74e93a 100644 --- a/test_tube_scanner/scanner/static/scanner/js/scanning.js +++ b/test_tube_scanner/scanner/static/scanner/js/scanning.js @@ -62,6 +62,7 @@ class ScannerManager { if (payload.buttons) { this.well_btn.innerHTML = payload.buttons; const span = this.crop.querySelector("span.crop"); span.style.color = '#f0f'; + document.documentElement.style.setProperty('--well-columns', payload.columns); } if (payload.current >= 0) { document.querySelectorAll('button.w3-btn.well').forEach(btn => { @@ -72,7 +73,7 @@ class ScannerManager { } catch(e) { console.log(e); } } - init() { this.axes = 0; this.cropping = 1; this._send({ type: 'scanner', topic: "init", }); } + init() { this.axes = 0; this.cropping = 1; this._send({ type: 'scanner', topic: "init", sid: this.session.value }); } 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" }); } diff --git a/test_tube_scanner/scanner/static/scanner/js/video_upload.js b/test_tube_scanner/scanner/static/scanner/js/video_upload.js new file mode 100644 index 0000000..bb8fc05 --- /dev/null +++ b/test_tube_scanner/scanner/static/scanner/js/video_upload.js @@ -0,0 +1,141 @@ +(function () { + "use strict"; + + function initDropZone(input) { + var zone = document.createElement("div"); + zone.className = "video-drop-zone"; + + var hint = document.createElement("span"); + hint.className = "video-drop-hint"; + hint.textContent = "⬇ Glisser une vidéo ici ou cliquer pour parcourir"; + zone.appendChild(hint); + + // Progress bar (hidden by default) + var progressWrap = document.createElement("div"); + progressWrap.className = "video-progress-wrap"; + var progressTrack = document.createElement("div"); + progressTrack.className = "video-progress-bar-track"; + var progressBar = document.createElement("div"); + progressBar.className = "video-progress-bar"; + progressTrack.appendChild(progressBar); + progressWrap.appendChild(progressTrack); + var progressLabel = document.createElement("span"); + progressLabel.className = "video-progress-label"; + progressWrap.appendChild(progressLabel); + zone.appendChild(progressWrap); + + input.parentNode.insertBefore(zone, input.nextSibling); + + function setFilename(name) { + hint.textContent = "✓ " + name; + zone.classList.add("has-file"); + } + + function showProgress(pct) { + progressWrap.style.display = "block"; + progressBar.style.width = pct + "%"; + progressLabel.textContent = Math.round(pct) + " %"; + } + + function hideProgress() { + progressWrap.style.display = "none"; + } + + // Drag events + zone.addEventListener("dragenter", function (e) { e.preventDefault(); zone.classList.add("drag-over"); }); + zone.addEventListener("dragover", function (e) { e.preventDefault(); zone.classList.add("drag-over"); }); + zone.addEventListener("dragleave", function () { zone.classList.remove("drag-over"); }); + zone.addEventListener("dragend", function () { zone.classList.remove("drag-over"); }); + + zone.addEventListener("drop", function (e) { + e.preventDefault(); + zone.classList.remove("drag-over"); + var files = e.dataTransfer.files; + if (!files.length) return; + var file = files[0]; + try { + var dt = new DataTransfer(); + dt.items.add(file); + input.files = dt.files; + input.dispatchEvent(new Event("change", { bubbles: true })); + } catch (_) {} + setFilename(file.name); + }); + + // Click on zone → open file picker + zone.addEventListener("click", function () { input.click(); }); + + // Sync when user picks via dialog + input.addEventListener("change", function () { + if (input.files && input.files.length > 0) { + setFilename(input.files[0].name); + } + }); + + // Pre-fill if a file is already set (edit form) + if (input.value) { + var parts = input.value.split(/[\\/]/); + setFilename(parts[parts.length - 1]); + } + + return { showProgress: showProgress, hideProgress: hideProgress, input: input }; + } + + function interceptFormSubmit(form, dropZones) { + form.addEventListener("submit", function (e) { + // Only intercept if at least one file input has a file selected + var hasFile = dropZones.some(function (dz) { + return dz.input.files && dz.input.files.length > 0; + }); + if (!hasFile) return; // normal submit, no big file + + e.preventDefault(); + + var data = new FormData(form); + var xhr = new XMLHttpRequest(); + + // Show progress on the first drop zone that has a file + var active = dropZones.find(function (dz) { + return dz.input.files && dz.input.files.length > 0; + }); + + if (active) active.showProgress(0); + + xhr.upload.addEventListener("progress", function (ev) { + if (ev.lengthComputable && active) { + active.showProgress((ev.loaded / ev.total) * 100); + } + }); + + xhr.addEventListener("load", function () { + if (active) active.hideProgress(); + // Django admin redirects after save — follow the final URL + var finalUrl = xhr.responseURL || form.action; + // The response itself is the resulting admin page; navigate to it + window.location.href = finalUrl; + }); + + xhr.addEventListener("error", function () { + if (active) { + active.hideProgress(); + active.showProgress(0); // reset bar + } + alert("Erreur lors de l'envoi du fichier."); + }); + + xhr.open(form.method || "POST", form.action || window.location.href, true); + xhr.send(data); + }); + } + + document.addEventListener("DOMContentLoaded", function () { + var inputs = Array.from(document.querySelectorAll('input[type="file"]')); + var dropZones = inputs.map(initDropZone); + + // Hook into the closest form that contains these inputs + var form = inputs.length && inputs[0].closest("form"); + if (form && dropZones.length) { + interceptFormSubmit(form, dropZones); + } + }); +}()); diff --git a/test_tube_scanner/scanner/tasks.py b/test_tube_scanner/scanner/tasks.py index f4aa13f..4c48703 100644 --- a/test_tube_scanner/scanner/tasks.py +++ b/test_tube_scanner/scanner/tasks.py @@ -68,15 +68,13 @@ def export_all_images(session_id=None): conf = ScannerConstants().get() if session_id is None: - sessions = [s.id for s in models.Session.objects.filter(active=False)] + sessions = [s.pk for s in models.Session.objects.filter(active=False)] else: sessions = [session_id] - - for session_id in sessions: + job_zip = [] + for session_id in sessions: uuid_list = models.SessionExperiment.uuid_from_session(session_id) - job_zip = [] for uuid in uuid_list: - job = export_images.delay( # @UndefinedVariable uuid, start_ts=None, @@ -109,13 +107,12 @@ def export_all_videos(session_id=None): try: conf = ScannerConstants().get() if session_id is None: - sessions = [s.id for s in models.Session.objects.filter(active=False)] + sessions = [s.pk for s in models.Session.objects.filter(active=False)] else: sessions = [session_id] - + job_mp4 = [] for session_id in sessions: - uuid_list = models.SessionExperiment.uuid_from_session(session_id) - job_mp4 = [] + uuid_list = models.SessionExperiment.uuid_from_session(session_id) for uuid in uuid_list: job = export_videos.delay( # @UndefinedVariable @@ -146,7 +143,7 @@ def run_session_exports(self, session_id: str): logger.error("run_session_exports: session %s introuvable", session_id) return {"status": "error", "message": "Session introuvable"} - session.status = models.Session.Status.RUNNING + session.export_status = models.Session.Status.RUNNING session.save(update_fields=["export_status"]) logger.info(f"run_session_exports [{session_id}]: export démarré") chord( diff --git a/test_tube_scanner/scanner/templates/scanner/base.html b/test_tube_scanner/scanner/templates/scanner/base.html index f8e1520..1113fb3 100644 --- a/test_tube_scanner/scanner/templates/scanner/base.html +++ b/test_tube_scanner/scanner/templates/scanner/base.html @@ -133,10 +133,10 @@
{% endif %} - + {% trans "Balayage multi-puits" %} -
{% trans "Résultats " %}
+
{% trans "Résultats " %}
{% if conf.tracking %} {% trans "Export CSV des expériences" %} diff --git a/test_tube_scanner/scanner/templates/scanner/calibration.html b/test_tube_scanner/scanner/templates/scanner/calibration.html index fcc3d62..57db6a6 100644 --- a/test_tube_scanner/scanner/templates/scanner/calibration.html +++ b/test_tube_scanner/scanner/templates/scanner/calibration.html @@ -1,8 +1,14 @@ {% extends 'scanner/base.html' %} -{% load i18n home_tags scanner_tags %} +{% load i18n home_tags %} {% block styles %} {{ block.super }} + + {% endblock %} {% block columns %}{% endblock %} @@ -84,34 +90,36 @@ +
- -
- +
+
+ +
+
+ +
+
+ +
+
+ +
-
- -
-
- -
-
- -
-
- {% include 'scanner/scan-image.html' %} +
+
@@ -136,17 +144,27 @@
-
+ +
- +
+
+ + +
+
+
+
{% if conf.tracking and conf.tracking_setting %} @@ -248,14 +266,16 @@ well : sId("_well"), debug : sId("_debug"), calib_debug : sId("_calib_debug"), + draw_debug : sId("_draw_debug"), calib_auto : sId("_calib_auto"), calib_center: sId("_calib_center"), previous : sId("_previous"), next : sId("_next"), set_well : sId("_set_well"), well_btn : sId("_well_btn"), - median : sId("_median"), - crop : sId("_crop"), + median : sId("_median"), + edge_enhance : sId("_edge_enhance"), + crop : sId("_crop"), crop_radius : sId("_crop_radius"), min_area_px : sId("_min_area_px"), max_area_ratio : sId("_max_area_ratio"), diff --git a/test_tube_scanner/scanner/templates/scanner/images.html b/test_tube_scanner/scanner/templates/scanner/images.html index 2c5862d..ad68176 100644 --- a/test_tube_scanner/scanner/templates/scanner/images.html +++ b/test_tube_scanner/scanner/templates/scanner/images.html @@ -1,5 +1,5 @@ {% extends 'scanner/base.html' %} -{% load i18n home_tags scanner_tags %} +{% load i18n home_tags %} {% block styles %} {{ block.super }} diff --git a/test_tube_scanner/scanner/templates/scanner/replay.html b/test_tube_scanner/scanner/templates/scanner/replay.html index 7ce51af..42608d2 100644 --- a/test_tube_scanner/scanner/templates/scanner/replay.html +++ b/test_tube_scanner/scanner/templates/scanner/replay.html @@ -1,5 +1,5 @@ {% extends 'scanner/base.html' %} -{% load i18n home_tags scanner_tags %} +{% load i18n home_tags %} {% block styles %} {{ block.super }} diff --git a/test_tube_scanner/scanner/templates/scanner/scan-image.html b/test_tube_scanner/scanner/templates/scanner/scan-image.html index 7a9d594..094752f 100644 --- a/test_tube_scanner/scanner/templates/scanner/scan-image.html +++ b/test_tube_scanner/scanner/templates/scanner/scan-image.html @@ -12,7 +12,7 @@
Ax pos:
{#% endif %#} -
- +
+
\ No newline at end of file diff --git a/test_tube_scanner/scanner/templates/scanner/scanning.html b/test_tube_scanner/scanner/templates/scanner/scanning.html index a45c4fe..e8cbb1d 100644 --- a/test_tube_scanner/scanner/templates/scanner/scanning.html +++ b/test_tube_scanner/scanner/templates/scanner/scanning.html @@ -3,6 +3,11 @@ {% block styles %} {{ block.super }} + {% endblock %} {% block columns %}{% endblock %} @@ -12,11 +17,15 @@
{% trans "Choix de la session" %}
- +
+ {% csrf_token %} + +
+
diff --git a/test_tube_scanner/scanner/templatetags/scanner_tags.py b/test_tube_scanner/scanner/templatetags/scanner_tags.py index 2e733ea..f9d1aed 100644 --- a/test_tube_scanner/scanner/templatetags/scanner_tags.py +++ b/test_tube_scanner/scanner/templatetags/scanner_tags.py @@ -1,13 +1,13 @@ # encoding: utf-8 from django import template -from django.utils.html import mark_safe +from django.utils.safestring import mark_safe register = template.Library() @register.simple_tag def multiwell_cards(sid, experiment): multiwells = [] - row_def = experiment.multiwell.row_def.split(',') + row_def = experiment.multiwell.row_def.split(',') multiwells.append( f'''
{experiment.title}
diff --git a/test_tube_scanner/scanner/views.py b/test_tube_scanner/scanner/views.py index 56029ec..50f8cba 100644 --- a/test_tube_scanner/scanner/views.py +++ b/test_tube_scanner/scanner/views.py @@ -55,7 +55,6 @@ def global_context(request, **ctx): **ctx ) - @login_required @user_passes_test(is_staff_or_admin) def admin_view(request): @@ -126,9 +125,16 @@ def documentation(request, template=None): ## Mainboard @login_required def scanning_view(request): + cursid = 0 + if request.method == 'POST': + cursid = request.POST.get('_session', 0) + if not cursid: + current_session = models.Session.objects.filter(active=True).first() + cursid = current_session.pk + ctx = dict( ws_route=settings.SCANNER_WEBSOCKET_ROUTE, - columns=1, + cursid=int(cursid), sessions=models.Session.objects.filter(active=True).all(), choice_title=_("Balayage multi-puits") ) @@ -137,26 +143,36 @@ def scanning_view(request): ## Calibration @login_required def calibration_view(request): + context = global_context(request) + wells = models.MultiWell.objects.filter(capture_video=False).all() + capture_type = context['conf'].capture_type + config = ScannerConstants.get_config() + + if capture_type == 'video': + mw = models.MultiWell.objects.filter(default=True, capture_video=True).first() + if mw: + context['conf'].default_position = mw.position + wells = models.MultiWell.objects.filter(capture_video=True).all() + ctx = dict( ws_route=settings.SCANNER_WEBSOCKET_ROUTE, - columns=1, - choice_title=_("Calibration"), - wells = models.MultiWell.objects.all(), + choice_title=f'{str(_("Calibration"))} {config.get_capture_type_display()}', + wells = wells, + capture_type=capture_type, ) - return render(request, "scanner/calibration.html", context=global_context(request, **ctx)) + context.update(ctx) + return render(request, "scanner/calibration.html", context=context) - -def get_not_active_experiments(session, expid=None): +def get_not_active_experiments(session, expid=None) -> tuple[list, "models.Experiment | None"]: if session: - experiments = models.SessionExperiment.experiment_by_session(session.id, active=False) or [] + experiments = models.SessionExperiment.experiment_by_session(session.pk, active=False) or [] if experiments and not expid: return experiments, experiments[0] for e in experiments: - if expid == str(e.id): + if expid == str(e.pk): return experiments, e return [], None - ## images def get_images(uuid): oldest, latest, n, images = 0, 0, 0, []