65 Commits

Author SHA1 Message Date
denis defolie 88cdc69f7d model multi -well-96 2026-07-11 11:46:07 +02:00
denis defolie c0a57a8920 scanner-correction 2026-07-11 10:55:30 +02:00
denis defolie 299bfad872 readme-doc 2026-07-10 15:01:47 +02:00
denis defolie c6145f250b readme&context 2026-07-10 10:02:20 +02:00
denis defolie 90d271dc2b csrf-correction 2026-07-10 09:49:36 +02:00
denis 695e59174a grbl-settings 2026-07-08 20:06:40 +02:00
denis e4bd67b686 video-plate-calibration:
README/CONTEXT
2026-06-03 22:35:36 +02:00
denis 9bb8fc1bce Video plate capture: calibration, edge enhance, auto-detect well borders 2026-06-03 17:56:23 +02:00
denis 4b42c03756 context / gitignore 2026-05-30 08:31:26 +02:00
denis 084c289a95 backup 2026-05-19 11:15:54 +02:00
denis 308ddaa048 export-all-verif 2026-05-19 10:53:51 +02:00
denis 5477de46fe capture 2026-05-17 19:42:49 +02:00
denis adf8d24d14 store summary 2026-05-16 21:44:08 +02:00
denis cb10957fa6 metrics 2026-05-16 12:20:25 +02:00
denis da44ab5340 export 2026-05-15 22:28:34 +02:00
denis 9abede4b4a readme 2026-05-15 19:03:18 +02:00
denis 47ea0a6be2 documentation 2026-05-15 18:55:23 +02:00
denis 6eac697bd2 export 2026-05-15 18:16:46 +02:00
denis dc63da69d9 translate 2026-05-15 16:14:52 +02:00
denis 7760d7ae7c export video/image 2026-05-15 16:14:26 +02:00
denis 4fb7fa8fd3 translate 2026-05-14 17:42:23 +02:00
denis 3c730ecdc0 translate 2026-05-14 17:35:02 +02:00
denis b5b28dd5e1 translate 2026-05-14 17:30:47 +02:00
denis 0bab26c45a translate 2026-05-14 17:10:51 +02:00
denis e9256f538c documentation 2026-05-14 15:59:55 +02:00
denis ed67438739 doc database 2026-05-14 09:19:03 +02:00
denis e1e1174db7 planarian 2026-05-13 13:22:43 +02:00
denis 01acef913b export/import metrics 2026-05-13 13:21:49 +02:00
denis 15c01c483f export csv 2026-05-11 23:04:04 +02:00
denis 13141bc46a experiment 2026-05-10 22:28:13 +02:00
denis 500950017f planarian 2026-05-09 23:02:11 +02:00
denis c7caccd951 scanning 2026-05-09 13:07:42 +02:00
denis 5ba8e04ddf calibration 2026-05-09 09:45:27 +02:00
denis 563069d3c6 tracking 2026-05-08 11:48:46 +02:00
denis 1575445df4 tracking 2026-05-07 11:13:51 +02:00
denis 1bc7e5eb9e simulation 2026-05-06 23:35:31 +02:00
denis 22ec82c895 readme 2026-05-05 23:36:03 +02:00
denis 8b98f39619 simulation 2026-05-05 23:11:15 +02:00
denis ee919ab1cf readme 2026-05-05 22:52:51 +02:00
denis 0fc0d537be readme 2026-05-05 20:03:02 +02:00
denis c28ccdb33c readme 2026-05-05 20:00:02 +02:00
denis dcbcabfce5 readme 2026-05-05 19:48:42 +02:00
denis 3aaa88a1aa simulation 2026-05-05 19:38:41 +02:00
denis d946de7b63 simulation 2026-05-05 14:59:09 +02:00
denis 5c78c042a4 planarian 2026-05-04 23:05:59 +02:00
denis c0c3c37963 metrics 2026-05-04 19:05:40 +02:00
denis 3ecf0a1b6b simulation 2026-05-04 10:32:14 +02:00
denis ebdeb5f651 simulation 2026-05-03 23:11:00 +02:00
denis ce32bee3e8 calibration 2026-05-03 13:24:25 +02:00
denis 0f22451516 simulate 2026-05-03 09:29:08 +02:00
denis 309e2d4f45 planarian 2026-05-02 22:00:41 +02:00
denis 5b4b1e63a6 planarian 2026-05-02 17:26:17 +02:00
denis 3f746b6b3f planarian 2026-05-02 17:19:44 +02:00
denis c16a874ebd export final 2026-05-01 08:34:50 +02:00
denis 4731a8bef8 scanning delayed 2026-04-30 22:28:08 +02:00
denis be1da62dbc scanning delayed 2026-04-30 22:26:51 +02:00
denis b9bcdae282 export 2026-04-30 11:33:30 +02:00
denis f53b8d4932 readme 2026-04-29 12:36:32 +02:00
denis d922102ef5 readme 2026-04-29 12:34:56 +02:00
denis d7e634c96e readme 2026-04-29 11:55:31 +02:00
denis 58307333b9 readme 2026-04-29 11:54:40 +02:00
denis 1aca6bba75 readme 2026-04-29 11:46:53 +02:00
denis 4bde92bd6d readme 2026-04-29 11:21:15 +02:00
denis 94c7cf38f5 readme 2026-04-29 11:02:33 +02:00
denis 22e32cad26 readme 2026-04-29 11:01:13 +02:00
116 changed files with 16526 additions and 3463 deletions
+41
View File
@@ -0,0 +1,41 @@
# ── Secrets & credentials ──────────────────────────────────────────────────
test_tube_scanner/.env
*.pem
*.key
*.crt
*.p12
*.pfx
credentials*.json
secrets*.json
.secrets
# ── Données capturées / exports / backups ──────────────────────────────────
datas/
# ── Python / venv ──────────────────────────────────────────────────────────
.venv/
__pycache__/
*.py[cod]
*.pyo
# ── Django runtime ─────────────────────────────────────────────────────────
*.sqlite3
*.db
test_tube_scanner/staticfiles/
test_tube_scanner/media/
test_tube_scanner/logs/
*.log
# ── Celery ─────────────────────────────────────────────────────────────────
celerybeat-schedule
celerybeat.pid
*.pid
# ── Éditeurs & OS ──────────────────────────────────────────────────────────
.idea/
.vscode/
*.swp
*.swo
*~
.DS_Store
Thumbs.db
+257
View File
@@ -0,0 +1,257 @@
# PlanarianScanner — Contexte technique
Système d'imagerie automatisé pour le suivi comportemental de planaires (*Platyhelminthes*).
Développé par dd@linuxtarn.org pour le **Laboratoire de Biologie, Université Champollion, Albi**.
---
## Matériel cible
| Composant | Détail |
|---|---|
| Carte | Raspberry Pi 4 |
| Caméra | ArduCam haute définition (Picamera2) |
| Motorisation | Bras CNC L2544 piloté en GRBL via port série |
| Grille | 4 plaques multi-puits de 6×4 = 96 puits (Ø 16 mm) |
| Réseau | LAN — export Samba (CIFS) / rsync SSH |
---
## Stack technique
| Couche | Technologie |
|---|---|
| Backend | Django 5.1 + Django Channels (WebSocket) |
| Serveur ASGI | Daphne |
| Broker/cache | Redis |
| Tâches async | Celery + django-celery-beat (one-shot via ClockedSchedule) |
| Vision | OpenCV (headless) + Picamera2 |
| Stockage frames | ReductStore (base time série haute performance) |
| BDD | MariaDB (prod) — sqlite3 (dev) |
| Export distant | Samba client (CIFS) / rsync |
| Plateforme | Raspberry Pi 4, Debian 64-bit Trixie |
| Python | 3.13 — venv dans `.venv/` |
---
## Structure du dépôt
```
PlanarianScanner/
├── etc/ # Scripts d'installation et configs système
│ ├── 1-install-sys.sh # Dépendances système
│ ├── 2-cargo-reductstore-install.sh # Build ReductStore (~15 min sur RPi4)
│ ├── 3-install-samba-client.sh
│ ├── 4-install_mariadb.sh
│ ├── 5-install_adminer.sh
│ ├── 6-install_django_app.sh # Init Django (migrations, fixtures, collectstatic)
│ ├── db/ # Fixtures JSON initiales (configuration, multiwell, well)
│ ├── requirements.txt
│ ├── scanner_service.conf # Supervisor : Django + Celery workers
│ ├── reductstore_service.conf
│ └── nginx_service.conf
├── datas/ # Données hors Django (gitignored)
│ ├── medias/ # Images et vidéos capturées
│ ├── exports/csv/ # Exports CSV EthoVision
│ ├── remote/exports/ # Dossier cible des transferts distants
│ └── backup/mariadb/ # Sauvegardes MariaDB
├── assets/ # Logo, screenshots
├── test_tube_scanner/ # Racine du projet Django
│ ├── home/ # Package projet (settings, urls, wsgi, asgi, celery)
│ ├── scanner/ # App scanner (CNC, multi-puits, sessions, exports)
│ ├── planarian/ # App suivi planaires (métriques, export CSV)
│ ├── modules/ # Modules partagés (capture, GRBL, tracker, metrics…)
│ ├── manage.py
│ ├── run-server.sh
│ ├── run-workers.sh
│ ├── planarian_sim.py # Simulateur standalone (CLI)
│ └── .env / .env.example
└── browser.py # Ouverture navigateur local (utilitaire)
```
---
## Applications Django
### `scanner` — Pilotage CNC et acquisition
Modèles principaux :
| Modèle | Rôle |
|---|---|
| `Configuration` | Config globale active (caméra, GRBL, tracking, calibration) |
| `MultiWell` | Plaque multi-puits (position HG/HD/BG/BD, grille 6×4, pas XY, crop_radius) |
| `Well` | Puit individuel (nom Ai..Di) |
| `WellPosition` | Position XY mm d'un puit dans un MultiWell + px_per_mm (calibration optique caméra) |
| `VideoPlate` | Vidéo plaque entière associée à un MultiWell — champs : `px_per_mm` (échelle vidéo ~15 px/mm), `x_origin_mm` / `y_origin_mm` (origine CNC stable, indépendante de la calibration) |
| `Experiment` | Session de capture sur un MultiWell (durée, début/fin) |
| `Session` | Groupe d'expériences avec planification (ClockedSchedule one-shot) |
| `SessionExperiment` | Liaison Session ↔ Experiment |
| `ExperimentWell` | Liaison Experiment ↔ Well (puits actifs) |
Signaux Django :
- `post_save(MultiWell)` → génère automatiquement les `WellPosition` en serpentin
- `post_save(Session)` → crée les `PeriodicTask` Celery Beat (export + scanning)
- `post_delete(Session)` → supprime les `PeriodicTask` associées
Tâches Celery (`scanner/tasks.py`, `scanner/export_tasks.py`) :
- `run_scanning(session_id)` — parcours serpentin des puits (GRBL + capture)
- `run_session_exports(session_id)` — génération ZIP JPEG + MP4 + transfert distant
### `planarian` — Suivi multi-individus et métriques
Modèle `ExperimentConfig` : paramètres de tracking par puit (px_per_mm, fps, seuils).
---
## Modules partagés (`modules/`)
| Module | Rôle |
|---|---|
| `grbl.py` | Pilotage CNC via port série (G-code, homing, déplacement XY) |
| `grbl_simulator.py` | Simulateur GRBL pour dev sans matériel |
| `capture_interface.py` | Interface abstraite de capture — crop circulaire, edge enhance, debug overlay, tracking |
| `picamera2_capture.py` | Capture ArduCam via Picamera2 |
| `webcam_capture.py` | Capture webcam via OpenCV |
| `videofile_capture.py` | Lecture fichier vidéo (test/sim) |
| `videoplate_capture.py` | Capture par crop dynamique dans une vidéo plaque entière — position GRBL → région extraite, hot swap vidéo, plein cadre à l'origine |
| `planarian_tracker.py` | Tracking multi-individus : MOG2 + algorithme hongrois (`scipy`) |
| `planarian_metrics.py` | Métriques par frame et summary (mobilité, thigmo, photo, chemo, social) |
| `tube_aligner.py` | Détection HoughCircles + CLAHE, calibration optique, plage rayon configurable (`set_radius_range`) |
| `circular_crop.py` | Découpe circulaire des images de puit |
| `reductstore.py` | Interface ReductStore (stockage/lecture frames time série) |
| `system_stats.py` | Stats système (CPU, RAM, disque — affichage dashboard) |
---
## Métriques de tracking
**Par frame** : velocity, distance, moving, mobility_state, dist_to_wall_mm, near_wall,
dist_to_light_mm, heading_to_light_deg, fleeing_light, dist_to_food_mm, heading_to_food_deg,
approaching_food, in_food_zone, nearest_neighbour_mm, in_avoid_zone, in_aggreg_zone,
chem_repulsion_level.
**Summary** : totaux et pourcentages EthoVision-compatibles pour mobilité, thigmotactisme,
phototactisme, chimiotactisme, interactions sociales.
Export CSV compatible **EthoVision XT**.
---
## Configuration runtime
Fichier `.env` (python-decouple) dans `test_tube_scanner/` :
```
SECRET_KEY, DEBUG, DOMAIN_SERVER, ALLOWED_HOSTS, CSRF_TRUSTED_ORIGINS
APP_DATAS # chemin relatif vers datas/ (ex: ../datas)
DJANGO_APP # nom de l'app (home)
REDIS_URL # ex: redis://localhost:6379/0
REDUCTSTORE_URL # ex: http://localhost:8383
DB_* # MariaDB credentials
```
---
## Démarrage des services
Tous gérés par **Supervisor** :
```bash
# Interface web Supervisor
http://root:toor@<ip>:9001
# CLI
sudo supervisorctl start|stop|restart reductstore
sudo supervisorctl start|stop|restart test_tube:*
```
En dev :
```bash
cd test_tube_scanner
./manage.py runserver 0.0.0.0:8000
# Workers Celery séparés :
./run-workers.sh
```
Accès réseau : ajouter `<ip-rpi4> scanner.local` dans `/etc/hosts` des clients.
---
## Simulateur standalone
`test_tube_scanner/planarian_sim.py` — simulation CLI d'une arène circulaire (Ø 16 mm, 500×500 px),
export CSV EthoVision par planaire.
```bash
python3 planarian_sim.py --count 5 --thigmotaxis 0.4
python3 planarian_sim.py --count 5 --photo-mode fixed --photo-x 0.2 --photo-y 0.2 --photo-strength 0.6
```
`test_tube_scanner/make_videos.sh` — génération de 24 vidéos de simulation (une par puit).
---
## Mode capture vidéo plaque (`capture_type == 'video'`)
Alternative à la caméra ArduCam : une **vidéo de la plaque entière** enregistrée une fois,
rejouée en boucle pendant les scans. Adapté aux labos sans Raspberry Pi ou pour tests hors matériel.
### Flux
```
VideoPlateCapture.capture_frame()
→ lit la frame courante de la vidéo
→ extrait un carré 2r×2r centré sur (GRBL_x, GRBL_y) converti en pixels
→ si GRBL à (0,0) : retourne la frame entière (vue plaque)
→ sinon : retourne le crop du puit courant
→ process_frame()
→ edge_enhance (optionnel, CLAHE + Canny overlay vert)
→ TubeAligner.detect_tube() (optionnel, debug HoughCircles)
→ crop circulaire (optionnel)
```
### Calibration vidéo
- `VideoPlate.px_per_mm` : échelle de la vidéo (~15 px/mm) — **différent** de `WellPosition.px_per_mm` (~50 px/mm, optique caméra)
- `VideoPlate.x_origin_mm` / `y_origin_mm` : position CNC correspondant au pixel (0,0) de la vidéo — **stable**, jamais modifiée par la calibration des puits
- `MultiWell.crop_radius` : rayon du crop en pixels — contrôle la taille de la vue par puit
### Contrôles calibration UI (boutons)
| Bouton | Action |
|---|---|
| Debug | Active `TubeAligner.debug` — détection HoughCircles en continu |
| Overlay | Affiche/masque les annotations de détection sans couper la détection |
| Contours | Active edge enhance (CLAHE + Canny overlay vert) sur la frame propre |
| Recadrer | Active le crop circulaire + navigue vers la position Base (mode vidéo) |
### `TubeAligner.set_radius_range(min_ratio, max_ratio)`
Ajuste la plage de recherche HoughCircles en fraction de `min(w,h)` :
- Mode caméra : `0.260.37` (tube occupe ~30% du champ)
- Mode vidéo : `0.380.47` (puit remplit le crop, ratio ~0.430.50)
---
## Déploiement réseau isolé (labo sans internet)
```
GitHub ←→ Portable (internet) ←→ Routeur OpenWrt ←→ Machine labo (SSH)
```
Mise à jour sans internet :
```bash
# Sur la machine labo (une fois)
git config receive.denyCurrentBranch updateInstead
# Sur le portable — ajouter le labo comme remote
git remote add labo ssh://user@<ip-labo>/chemin/PlanarianScanner
# Workflow répétable
git pull origin video-plate-calibration # portable ← GitHub
git push labo video-plate-calibration # labo ← portable
```
---
## Licence
GPL-3.0
+96 -30
View File
@@ -48,9 +48,18 @@ d'analyse distantes.
--- ---
## Fonctionnalités ## Fonctionnalités
### Application 1: Scanner de tube à essais
- Pilotage du bras CNC en GRBL — déplacement automatique puits par puits - Pilotage du bras CNC en GRBL — déplacement automatique puits par puits
- Calibration des multi-puits avec synchro base de données - Calibration des multi-puits avec synchro base de données
- Acquisition image haute définition via ArduCam (OpenCV + Picamera2) - Trois modes de capture :
- **ArduCam** (Picamera2) — caméra haute définition montée sur le bras
- **Webcam** — via OpenCV (développement / test)
- **Vidéo plaque** (`VideoPlateCapture`) — crop dynamique dans une vidéo plaque entière rejouée en boucle ; adapté aux scans sans caméra embarquée
- Calibration assistée :
- Détection automatique du centre du puit (Hough + CLAHE, plage rayon adaptable selon le mode)
- Overlay Canny vert pour visualiser les bords en conditions d'éclairage difficile
- Contrôles temps réel : Debug, Overlay annotations, Contours, Recadrage
- Stockage des frames en base time série ReductStore - Stockage des frames en base time série ReductStore
- Sessions de scan paramétrables (grille complète ou sélection de puits) - Sessions de scan paramétrables (grille complète ou sélection de puits)
- Export asynchrone (Celery) : - Export asynchrone (Celery) :
@@ -62,12 +71,43 @@ d'analyse distantes.
- Interface administration Django (sqlite3 ou mariadb ou postgresql) - Interface administration Django (sqlite3 ou mariadb ou postgresql)
- Suivi de progression des tâches longues par polling - Suivi de progression des tâches longues par polling
Supporte plusieurs planaires avec paramètres configurables via django ou csv. ### Application 2: Détection de planaires et suivi multi-individus dans un tube.
Export CSV par planaire compatible EthoVision XT.
### Seuils EthoVision par défaut (configurables via django ou csv) [🎬 Vidéo Simulation planaires](https://youtu.be/pkzClmBp_KM)
- Supporte plusieurs planaires avec paramètres configurables via django ou csv.
- Stratégie :
- Soustraction de fond MOG2 (léger sur Raspberry Pi 4)
- Détection de tous les contours valides (surface >= min_area_px)
- Association frame-à-frame par distance euclidienne minimale
via algorithme hongrois (scipy.optimize.linear_sum_assignment)
- Un état inter-frame indépendant par individu (PlanarianState)
- Retourne une liste de résultats, un par individu suivi
- Export CSV par planaire compatible EthoVision XT.
- Métriques par frame :
- Mobilité : velocity, distance, moving, mobility_state
- Thigmo : dist_to_wall_mm, near_wall
- Photo : dist_to_light_mm, heading_to_light_deg, fleeing_light
- Chemo : dist_to_food_mm, heading_to_food_deg, approaching_food, in_food_zone
- Social : nearest_neighbour_mm, in_avoid_zone, in_aggreg_zone, chem_repulsion_level
- Métriques résumé (summary) :
- Mobilité : movedCenter_pointTotal_mm, velocity_mean_mm_s, durations par état
- Thigmo : thigmotaxis_pct_time_near_wall
- Photo : photo_pct_time_fleeing, photo_mean_dist_mm, photo_latency_s
- Chemo : chemo_pct_time_approaching, chemo_pct_time_in_zone,
chemo_latency_s, chemo_mean_dist_mm
- Social : social_pct_time_avoiding, social_pct_time_aggregating,
social_mean_nn_mm, social_contact_events
- Seuils EthoVision par défaut (configurables via django ou csv)
- **Immobile** : déplacement < 0.2 mm/s - **Immobile** : déplacement < 0.2 mm/s
- **Mobile** : 0.2 à 1.5 mm/s - **Mobile** : 0.2 à 1.5 mm/s
@@ -83,23 +123,43 @@ Export CSV par planaire compatible EthoVision XT.
| MobileFrequency / Duration | mobility_state | mobility_mobile_frequency/duration_s | | MobileFrequency / Duration | mobility_state | mobility_mobile_frequency/duration_s |
| Highly mobileFrequency / Duration | mobility_state | mobility_highly_mobile_frequency/duration_s | | Highly mobileFrequency / Duration | mobility_state | mobility_highly_mobile_frequency/duration_s |
### Métriques calculées - Comportements
- Distance totale parcourue (mm) → movedCenter-pointTotalmm
- Vitesse instantanée (mm/s) → VelocityCenter-pointMeanmm/s
- Durée cumulée en mouvement (s) → MovementMoving
- Durée cumulée à l'arrêt (s) → MovementNot Moving
- Fréquence et durée par état de mobilité → Mobility state (EthoVision)
- Distance à la paroi (mm) → thigmotactisme
### Comportements
- **Thigmotactisme** : attraction vers la paroi (--thigmotaxis) - **Thigmotactisme** : attraction vers la paroi (--thigmotaxis)
- **Phototactisme** : fuite de la lumière (--photo-mode, --photo-strength) - **Phototactisme** : fuite de la lumière (--photo-mode, --photo-strength)
- **Chimiotactisme** : attraction vers une source de nourriture (--chemo-strength) - **Chimiotactisme** : attraction vers une source de nourriture (--chemo-strength)
- **Inter-individus** : évitement de contact, agrégation, répulsion chimique - **Inter-individus** : évitement de contact, agrégation, répulsion chimique
### Application 4: Simulation de planaires
- planarian_sim.py
Espace circulaire de 16mm de diamètre, 500x500px
Supporte plusieurs planaires avec paramètres configurables via arguments CLI.
Export CSV par planaire compatible EthoVision XT.
Comportements simulés :
- Thigmotactisme : attraction vers la paroi (--thigmotaxis)
- Phototactisme : fuite de la lumière (--photo-mode, --photo-strength)
- Chimiotactisme : attraction vers une source de nourriture (--chemo-strength)
- Inter-individus : évitement de contact, agrégation, répulsion chimique
Usage:
python3 planarian_sim.py [options]
Exemples:
python3 planarian_sim.py --count 5 --thigmotaxis 0.4
python3 planaire_sim.py --count 5 --photo-mode fixed --photo-x 0.2 --photo-y 0.2 --photo-strength 0.6
python3 planarian_sim.py --count 5 --chemo-x 0.7 --chemo-y 0.5 --chemo-strength 0.5
python3 planarian_sim.py --count 5 --avoid-strength 0.6 --aggreg-strength 0.2
- make_videos.sh
- Générateur de vidéos paramétrables
Usage:
- ./make_video.sh (génère le fichier par défaut)
- ./make_video.sh all (génère 24 vidéos pour 24 tubes à essais)
--- ---
## Architecture ## Architecture
@@ -185,13 +245,6 @@ ou
sudo supervisorctl start|stop|restart reductstore sudo supervisorctl start|stop|restart reductstore
sudo supervisorctl start|stop|restart test_tube:* sudo supervisorctl start|stop|restart test_tube:*
Ajouter scanner.local au fichier hosts des clients web:
ip.du.rasp.berry scanner.local
- linux : /etc/hosts
- windows: C:\Windows\System32\drivers\etc\hosts
- mac : /private/etc/hosts"
``` ```
## Organisation du dépôt ## Organisation du dépôt
@@ -199,8 +252,6 @@ ip.du.rasp.berry scanner.local
```bash ```bash
PlanarianScanner/ PlanarianScanner/
├── assets ├── assets
│   ├── calibration-auto.jpg
│   ├── calibration-auto.mp4
│   ├── calibration-auto.png │   ├── calibration-auto.png
│   └── logo.png │   └── logo.png
├── browser.py ├── browser.py
@@ -222,7 +273,6 @@ PlanarianScanner/
│   ├── scanner_service.conf │   ├── scanner_service.conf
│   └── supervisor-inet_http.conf │   └── supervisor-inet_http.conf
├── LICENSE ├── LICENSE
├── logo.png
├── README.md ├── README.md
└── test_tube_scanner └── test_tube_scanner
├── home ├── home
@@ -312,14 +362,30 @@ PlanarianScanner/
--- ---
## Procédure de calibration en 4 étapes ## Procédure de calibration
1. Activer "Debug détection" → voir le cercle et les zones sur le stream
Calibration auto ### Mode caméra (ArduCam / Webcam)
1. **Debug** → active la détection HoughCircles en continu (cercle + zones affiché)
2. **Overlay** → affiche/masque les annotations sans couper la détection
3. **Recadrer** → isole le puit et navigue vers la position Base
4. **Calibrage auto** → centrage automatique puit par puit avec sauvegarde
![Aperçu de la vidéo](assets/calibration-auto.png) Calibration auto ### Mode vidéo plaque
![Vidéo Calibration auto 🎬](https://www.linuxtarn.org/media/original_videos/calibration-auto.mp4) Vidéo Calibration auto > **Note** : ce mode permet de piloter le scanner sans caméra embarquée sur le bras CNC.
> Une vidéo de la plaque entière est enregistrée une seule fois puis rejouée en boucle ;
> chaque déplacement GRBL extrait dynamiquement la zone du puit courant dans cette vidéo.
> Idéal pour les tests sans matériel ou les laboratoires ne disposant pas de caméra ArduCam.
1. Créer un enregistrement `VideoPlate` dans l'admin (upload vidéo, `px_per_mm`, `x_origin_mm`, `y_origin_mm`)
2. **Contours** → overlay Canny vert pour repérer les bords des puits selon l'éclairage
3. **Debug** → détection Hough adaptée (plage rayon élargie pour puit plein cadre)
4. **Recadrer** → active le crop + déplace vers la Base
5. Naviguer puit par puit, sauvegarder les positions
![Aperçu calibration auto](assets/calibration-auto.png)
[🎬 Vidéo Calibration auto](https://youtu.be/6RueJ3onUoY)
+382
View File
@@ -0,0 +1,382 @@
# ![Planarians](assets/logo.png) PlanarianScanner
> Automated imaging system for behavioral tracking of planarians
> (C) dd@linuxtarn.org for the Biology Laboratory, Champollion University, Albi
---
## Overview
**PlanarianScanner** is a web application developed for monitoring the activity
and movements of **planarians** (*Platyhelminthes*) in laboratory research.
The system controls a motorized multi-well scanner composed of a CNC arm (GRBL)
and a high-definition ArduCam camera mounted on a Raspberry Pi 4. It enables
automated image acquisition on a **6×4 wells × 4 plates** grid,
high-performance storage of captures, and export to remote analysis machines.
---
## Hardware
| Component | Details |
|---|---|
| Board | Raspberry Pi 4 |
| Camera | High-definition ArduCam |
| Motion system | CNC arm (L2544) controlled by GRBL |
| Well grid | 6×4 × 4 multi-well plates |
| Network | Local LAN — Samba/rsync export |
---
## Technical Stack
| Layer | Technology |
|---|---|
| Backend | Django + Django Channels |
| Real-time | Redis (broker + channel layer) |
| Acquisition | OpenCV + Picamera2 |
| Storage | ReductStore (high-performance time series) |
| Asynchronous tasks | Celery + django-celery-beat |
| Export | Samba (CIFS), rsync/SSH |
| Platform | Raspberry Pi 4 — Debian Linux |
---
## Features
### Application 1: Test Tube Scanner
- CNC arm control through GRBL — automatic well-by-well movement
- Multi-well calibration with database synchronization
- Three capture modes:
- **ArduCam** (Picamera2) — high-definition camera mounted on the arm
- **Webcam** — via OpenCV (development / testing)
- **Plate video** (`VideoPlateCapture`) — dynamic crop from a full-plate video replayed in loop; suitable for scans without an embedded camera
- Assisted calibration:
- Automatic well-center detection (Hough + CLAHE, adjustable radius range per mode)
- Green Canny overlay to visualize well borders under difficult lighting
- Real-time controls: Debug, Annotation Overlay, Edge Enhance, Crop
- Frame storage in ReductStore time-series database
- Configurable scan sessions (full grid or selected wells)
- Asynchronous export (Celery):
- ZIP archive of JPEG images per session
- MP4 video generated from captured frames
- Automatic transfer of exports to remote machines (Linux / Windows)
- Nightly export scheduling via django-celery-beat
- Real-time web interface (Django Channels / WebSocket)
- Django administration interface (sqlite3 or mariadb or postgresql)
- Long-task progress tracking through polling
### Application 2: Planarian Detection and Multi-Individual Tracking in a Tube
[🎬 Planarian Simulation Video](https://youtu.be/pkzClmBp_KM)
- Supports multiple planarians with configurable parameters via Django or CSV.
- Strategy:
- MOG2 background subtraction (lightweight on Raspberry Pi 4)
- Detection of all valid contours (surface >= min_area_px)
- Frame-to-frame association using minimum Euclidean distance
via the Hungarian algorithm (scipy.optimize.linear_sum_assignment)
- Independent inter-frame state per individual (PlanarianState)
- Returns a list of results, one for each tracked individual
- Per-planarian CSV export compatible with EthoVision XT.
- Metrics per frame:
- Mobility : velocity, distance, moving, mobility_state
- Thigmo : dist_to_wall_mm, near_wall
- Photo : dist_to_light_mm, heading_to_light_deg, fleeing_light
- Chemo : dist_to_food_mm, heading_to_food_deg, approaching_food, in_food_zone
- Social : nearest_neighbour_mm, in_avoid_zone, in_aggreg_zone, chem_repulsion_level
- Summary metrics:
- Mobility : movedCenter_pointTotal_mm, velocity_mean_mm_s, state durations
- Thigmo : thigmotaxis_pct_time_near_wall
- Photo : photo_pct_time_fleeing, photo_mean_dist_mm, photo_latency_s
- Chemo : chemo_pct_time_approaching, chemo_pct_time_in_zone,
chemo_latency_s, chemo_mean_dist_mm
- Social : social_pct_time_avoiding, social_pct_time_aggregating,
social_mean_nn_mm, social_contact_events
- Default EthoVision thresholds (configurable via Django or CSV)
- **Immobile** : movement < 0.2 mm/s
- **Mobile** : 0.2 to 1.5 mm/s
- **Highly mobile** : > 1.5 mm/s
| EthoVision | CSV frames | CSV summary |
|---|---|---|
| movedCenter-pointTotalmm | total_distance_mm | movedCenter_pointTotal_mm |
| VelocityCenter-pointMeanmm/s | velocity_mm_s | velocity_mean_mm_s |
| MovementMoving | moving, duration_moving_s | movement_moving_duration_s |
| MovementNot Moving | duration_stopped_s | movement_not_moving_duration_s |
| ImmobileFrequency / Duration | mobility_state | mobility_immobile_frequency/duration_s |
| MobileFrequency / Duration | mobility_state | mobility_mobile_frequency/duration_s |
| Highly mobileFrequency / Duration | mobility_state | mobility_highly_mobile_frequency/duration_s |
- Behaviors
- **Thigmotaxis** : wall attraction (--thigmotaxis)
- **Phototaxis** : fleeing from light (--photo-mode, --photo-strength)
- **Chemotaxis** : attraction toward a food source (--chemo-strength)
- **Inter-individuals** : contact avoidance, aggregation, chemical repulsion
### Application 4: Planarian Simulation
- planarian_sim.py
Circular space of 16 mm diameter, 500x500 px
Supports multiple planarians with configurable parameters via CLI arguments.
Per-planarian CSV export compatible with EthoVision XT.
Simulated behaviors:
- Thigmotaxis : wall attraction (--thigmotaxis)
- Phototaxis : fleeing from light (--photo-mode, --photo-strength)
- Chemotaxis : attraction toward a food source (--chemo-strength)
- Inter-individual : contact avoidance, aggregation, chemical repulsion
Usage:
python3 planarian_sim.py [options]
Examples:
python3 planarian_sim.py --count 5 --thigmotaxis 0.4
python3 planarian_sim.py --count 5 --photo-mode fixed --photo-x 0.2 --photo-y 0.2 --photo-strength 0.6
python3 planarian_sim.py --count 5 --chemo-x 0.7 --chemo-y 0.5 --chemo-strength 0.5
python3 planarian_sim.py --count 5 --avoid-strength 0.6 --aggreg-strength 0.2
- make_videos.sh
- Configurable video generator
Usage:
- ./make_video.sh (generates the default file)
- ./make_video.sh all (generates 24 videos for 24 test tubes)
---
## Architecture
```text
Raspberry Pi 4
├── Django (web interface + API)
│ ├── Django Channels ←→ Redis (real-time WebSocket)
│ └── Celery workers
│ ├── scanning(session_id) — well traversal
│ ├── export_images_zip() — JPEG ZIP generation
│ ├── export_video_mp4() — MP4 generation (OpenCV)
│ └── transfer → /mnt/exports — Samba share
├── ArduCam ← Picamera2 / OpenCV — HD capture
├── CNC GRBL ← Serial — XY movement
└── ReductStore — frame time-series storage
Installation
Full documentation coming soon.
Using piImager, install Raspberry Pi OS 64-bit Trixie on the Raspberry Pi 4.<br>
Customize your Raspberry Pi with at least SSH enabled (SSH key or password).<br>
Later, for convenience, you may install a VNC server.
ssh rpi4@ip.of.raspi
git clone https://github.com/your-repo/planarianscanner.git
git@github.com:deunix-educ/PlanarianScanner.git
# modify environment variables if needed
cp .env.example .env
# Edit .env : SECRET_KEY, REDIS_URL, REDUCTSTORE_URL, ...
cd PlanarianScanner/etc
chmod +x *.sh
# install system libraries
./1-install-sys.sh
# compile reductstore (~15 min on Raspberry Pi 4)
./2-cargo-reductstore-install.sh
# install samba client
./3-install-samba-client.sh
# install mariadb
./4-install_mariadb.sh
# install adminer
./5-install_adminer.sh
# Configure Django applications
./6-install_django_app.sh
# test
sudo supervisorctl stop test_tube:*
./manage.py runserver 0.0.0.0:8000
# local test
# http://127.0.0.1:8000
# remote test
# http://ip.of.raspi:8000
# end of test
sudo supervisorctl restart test_tube:*
Starting services:
All services are accessible through supervisor
http://root:toor@ip-of-raspi:9001
or
sudo supervisorctl start|stop|restart reductstore
sudo supervisorctl start|stop|restart test_tube:*
Repository Organization
PlanarianScanner/
├── assets
│ ├── calibration-auto.png
│ └── logo.png
├── browser.py
├── etc
│ ├── 1-install-sys.sh
│ ├── 2-cargo-reductstore-install.sh
│ ├── 3-install-samba-client.sh
│ ├── 4-install_mariadb.sh
│ ├── 5-install_adminer.sh
│ ├── 6-install_django_app.sh
│ ├── db
│ │ ├── configuration.json
│ │ ├── multiwell.json
│ │ └── well.json
│ ├── install-linux-samba-server.sh
│ ├── nginx_service.conf
│ ├── reductstore_service.conf
│ ├── requirements.txt
│ ├── scanner_service.conf
│ └── supervisor-inet_http.conf
├── LICENSE
├── README.md
└── test_tube_scanner
├── home
│ ├── apps.py
│ ├── asgi.py
│ ├── celerymodule.py
│ ├── context_processors.py
│ ├── __init__.py
│ ├── locale
│ ├── management
│ ├── middleware.py
│ ├── __pycache__
│ ├── settings.py
│ ├── static
│ ├── templates
│ ├── templatetags
│ ├── urls.py
│ ├── views.py
│ └── wsgi.py
├── logs
│ ├── celery.log
│ └── test_tube.log
├── manage.py
├── media
│ ├── images
│ └── simulation
├── modules
│ ├── capture_interface.py
│ ├── circular_crop.py
│ ├── grbl.py
│ ├── __init__.py
│ ├── picamera2_capture_basic.py
│ ├── picamera2_capture.py
│ ├── planarian_metrics.py
│ ├── planarian_tracker.py
│ ├── __pycache__
│ ├── reductstore.py
│ ├── system_stats.py
│ ├── tube_aligner.py
│ ├── utils.py
│ ├── videofile_capture.py
│ └── webcam_capture.py
├── planarian
│ ├── admin.py
│ ├── apps.py
│ ├── forms.py
│ ├── __init__.py
│ ├── migrations
│ ├── models.py
│ ├── __pycache__
│ ├── templates
│ ├── tests.py
│ ├── urls.py
│ └── views.py
├── run-workers.sh
├── scanner
│ ├── admin.py
│ ├── apps.py
│ ├── constants.py
│ ├── consumers.py
│ ├── export_tasks.py
│ ├── __init__.py
│ ├── migrations
│ ├── models.py
│ ├── multiwell.py
│ ├── process.py
│ ├── __pycache__
│ ├── routing.py
│ ├── static
│ ├── tasks.py
│ ├── templates
│ ├── templatetags
│ ├── tests.py
│ ├── urls.py
│ └── views.py
├── staticfiles
│ ├── admin
│ ├── css
│ ├── img
│ ├── js
│ ├── scanner
│ └── webfonts
└── templates
└── admin
## Calibration Procedure
### Camera mode (ArduCam / Webcam)
1. **Debug** → enables continuous HoughCircles detection (circle + zones displayed)
2. **Overlay** → shows/hides annotations without stopping detection
3. **Crop** → isolates the well and navigates to the Base position
4. **Auto calibration** → automatic well-by-well centering with position save
### Plate video mode
> **Note**: this mode lets you drive the scanner without a camera mounted on the CNC arm.
> A single recording of the full plate is made once and replayed in a loop; each GRBL move
> dynamically crops the current well's region from that video. Ideal for hardware-free
> testing or labs without an ArduCam.
1. Create a `VideoPlate` record in admin (upload video, set `px_per_mm`, `x_origin_mm`, `y_origin_mm`)
2. **Edge Enhance** → green Canny overlay to locate well borders under variable lighting
3. **Debug** → Hough detection with wider radius range (well fills the crop)
4. **Crop** → activates circular crop + moves to Base position
5. Navigate well by well and save positions
![Auto calibration preview](assets/calibration-auto.png)
[🎬 Auto Calibration Video](https://youtu.be/6RueJ3onUoY)
## Status
![status](https://img.shields.io/badge/statut-en%20développement-orange)
![platform](https://img.shields.io/badge/plateforme-Raspberry%20Pi%204-red)
![python](https://img.shields.io/badge/python-3.11%2B-blue)
![django](https://img.shields.io/badge/django-4.2%2B-green)
![license](https://img.shields.io/badge/licence-GPL3-lightgrey)
---
## License
GPL-3.0 — Open-source project, developed for sharing and scientific reproducibility.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 213 KiB

Binary file not shown.
+7 -6
View File
@@ -1,4 +1,4 @@
!/bin/bash #!/bin/bash
echo "==== System installation for rpi" echo "==== System installation for rpi"
echo echo
@@ -7,8 +7,8 @@ sudo apt upgrade
ETC="$(pwd)" ETC="$(pwd)"
mkdir -p $HOME/exports mkdir -p "$HOME"/exports
mkdir -p$HOME/medias mkdir -p "$HOME"/medias
mkdir -p /mnt/exports mkdir -p /mnt/exports
cp ../test_tube_scanner/.env.example ../test_tube_scanner/.env cp ../test_tube_scanner/.env.example ../test_tube_scanner/.env
@@ -17,15 +17,15 @@ echo
# system # system
echo "==== system essential" echo "==== system essential"
sudo apt -y install build-essential openssl git pkg-config redis supervisor sqlitebrowser samba-client cifs-utils gettext sudo apt -y install build-essential openssl git pkg-config redis supervisor sqlitebrowser samba-client cifs-utils gettext avahi-daemon
echo "==== python3 install" echo "==== python3 install"
sudo apt -y install python3-dev python3-pip python3-venv libpq-dev default-libmysqlclient-dev libmariadb-dev python3-picamera2 sudo apt -y install python3-dev python3-pip python3-venv libpq-dev default-libmysqlclient-dev libmariadb-dev python3-picamera2
echo "==== supervisor http access login:pass => root:toor" echo "==== supervisor http access login:pass => root:toor"
sudo cp supervisor-inet_http.conf /etc/supervisor/conf.d/ 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"/scanner_service.conf /etc/supervisor/conf.d/
sudo ln -s $ETC/reductstore_service.conf /etc/supervisor/conf.d/ sudo ln -s "$ETC"/reductstore_service.conf /etc/supervisor/conf.d/
echo "==== restart supervisor " echo "==== restart supervisor "
sudo systemctl restart supervisor sudo systemctl restart supervisor
@@ -33,6 +33,7 @@ sudo systemctl restart supervisor
echo "==== python env with system site packages for picamera2" echo "==== python env with system site packages for picamera2"
rm -rf ../.venv rm -rf ../.venv
python -m venv --system-site-packages ../.venv python -m venv --system-site-packages ../.venv
# shellcheck disable=SC1091
source ../.venv/bin/activate source ../.venv/bin/activate
echo "==== pip requirements" echo "==== pip requirements"
+2 -2
View File
@@ -1,4 +1,4 @@
!/bin/bash #!/bin/bash
echo "--- install reductstore" echo "--- install reductstore"
echo " machine raspberry pi4" echo " machine raspberry pi4"
echo " Compilation finished `release` profile [optimized] target(s) in 16m 31s" echo " Compilation finished `release` profile [optimized] target(s) in 16m 31s"
@@ -11,4 +11,4 @@ cargo --version
rustc --version rustc --version
cargo install reductstore cargo install reductstore
mkdir -p $HOME/medias mkdir -p $HOME/reduct-media
+2
View File
@@ -50,6 +50,8 @@ sudo bash -c "echo '//$samba_server/$public_share /mnt/samba/public cifs guest,u
echo "Montage des partages Samba..." echo "Montage des partages Samba..."
sudo mount -a sudo mount -a
sudo mkdir -p /mnt/samba/public/images /mnt/samba/public/videos
# Vérification du montage # Vérification du montage
echo "Vérification des partages montés :" echo "Vérification des partages montés :"
df -h | grep samba df -h | grep samba
+1 -2
View File
@@ -3,8 +3,7 @@
# Script d'installation et de configuration de MariaDB # Script d'installation et de configuration de MariaDB
# Utilisation : source mariadb_config.sh && ./install_mariadb.sh # Utilisation : source mariadb_config.sh && ./install_mariadb.sh
ENV_FILE="../test_tube_scanner/.env" source ../test_tube_scanner/.env
source $ENV_FILE
# Vérifie que le fichier de configuration est sourcé # Vérifie que le fichier de configuration est sourcé
if [ -z "$DATABASE_ROOT_PASSWORD" ] || [ -z "$DATABASE_NAME" ] || [ -z "$DATABASE_USER" ] || [ -z "$DATABASE_PASSWORD" ]; then if [ -z "$DATABASE_ROOT_PASSWORD" ] || [ -z "$DATABASE_NAME" ] || [ -z "$DATABASE_USER" ] || [ -z "$DATABASE_PASSWORD" ]; then
+2 -2
View File
@@ -1,8 +1,8 @@
[program:reductstore] [program:reductstore]
process_name=%(program_name)s process_name=%(program_name)s
priority=10 priority=10
environment=RS_DATA_PATH="/home/rpi4/medias" environment=RS_DATA_PATH="/home/rpi4/reduct-media"
directory=/home/rpi4/medias directory=/home/rpi4/reduct-media
command=/home/rpi4/.cargo/bin/reductstore command=/home/rpi4/.cargo/bin/reductstore
user=rpi4 user=rpi4
group=rpi4 group=rpi4
+3 -1
View File
@@ -11,6 +11,7 @@ twisted[tls,http2]
celery[redis] celery[redis]
python-decouple python-decouple
PyYaml PyYaml
requests
reduct-py reduct-py
python-dateutil python-dateutil
psutil psutil
@@ -19,4 +20,5 @@ opencv-python-headless
mysqlclient mysqlclient
psycopg2 psycopg2
pyserial pyserial
scipy
django-stubs[compatible-mypy]
+7 -5
View File
@@ -4,12 +4,14 @@ programs=webUI,planification,services
[program:webUI] [program:webUI]
process_name=%(program_name)s process_name=%(program_name)s
priority=500 priority=500
directory=/home/rpi4/PlanarianScanner/test_tube_scanner directory=/home/rpi4/PlanarianScanner/test_tube_scanner
command= command=/home/rpi4/PlanarianScanner/test_tube_scanner/run-server.sh
/home/rpi4/PlanarianScanner/.venv/bin/daphne # /home/rpi4/PlanarianScanner/.venv/bin/daphne
-b 0.0.0.0 # -b 0.0.0.0
-p 8000 # -p 8000
home.asgi:application # home.asgi:application
user=rpi4 user=rpi4
group=rpi4 group=rpi4
stopasgroup=true stopasgroup=true
+16
View File
@@ -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
}
+7 -2
View File
@@ -2,6 +2,7 @@
# django app # django app
DEBUG=True DEBUG=True
IS_LOGGING=True IS_LOGGING=True
GRBL_SIMULATION = False
# app # app
DJANGO_APP=Test Tube Scanner DJANGO_APP=Test Tube Scanner
@@ -19,6 +20,8 @@ APP_DATAS=test_tube_scanner
EXPORTS_LOCAL_PATH=/home/rpi4/exports EXPORTS_LOCAL_PATH=/home/rpi4/exports
EXPORT_REMOTE_PATH=/mnt/exports EXPORT_REMOTE_PATH=/mnt/exports
REDUCTSTORE_PATH=/home/rpi4/medias REDUCTSTORE_PATH=/home/rpi4/medias
CSV_EXPORT_DIR=/home/rpi4/exports/csv
BACKUP_DIR=/home/rpi4/backup/mariadb
#### ####
# django email # django email
@@ -39,12 +42,14 @@ SECRET_KEY="django-insecure-0)4_w=pjv1ex7=s=c=ii3g@fx_=8fb=hxk€3bpk1)uj(0ph0t)
USER=rpi4 USER=rpi4
GROUP=rpi4 GROUP=rpi4
DOMAIN_SERVER=scanner.local DOMAIN_SERVER=scanner.local
ALLOWED_HOSTS=127.0.0.1,localhost ALLOWED_HOSTS=127.0.0.1,localhost,scanner.local,192.168.250.230,192.168.1.200
CSRF_TRUSTED_ORIGINS=http://127.0.0.1:8000,http://localhost:8000 CSRF_TRUSTED_ORIGINS=http://127.0.0.1:8000,http://localhost:8000,http://scanner.local,http://192.168.1.200:8000,http://192.168.250.230:8000
#### ####
# server app # server app
LOCAL_IP_SERVER=192.168.1.200
SERVER_HOST_IP=0.0.0.0 SERVER_HOST_IP=0.0.0.0
SERVER_HOST_PORT=8000 SERVER_HOST_PORT=8000
+61
View File
@@ -0,0 +1,61 @@
'''
Created on 18 mai 2026
@author: denis
'''
from pathlib import Path
from datetime import datetime
import subprocess
import gzip
import shutil
from django.conf import settings
BACKUP_DIR = Path(settings.BACKUP_DIR)
def mariadb_backup():
BACKUP_DIR.mkdir(parents=True, exist_ok=True)
db = settings.DATABASES["default"]
db_name = db["NAME"]
db_user = db["USER"]
db_password = db["PASSWORD"]
db_host = db.get("HOST", "localhost")
db_port = str(db.get("PORT", 3306))
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
sql_file = BACKUP_DIR / f"{db_name}_{timestamp}.sql"
gz_file = BACKUP_DIR / f"{db_name}_{timestamp}.sql.gz"
cmd = [
"mariadb-dump",
"--single-transaction",
"--quick",
"--routines",
"--triggers",
"-h", db_host,
"-P", db_port,
"-u", db_user,
f"-p{db_password}",
db_name,
]
with open(sql_file, "wb") as f:
result = subprocess.run(
cmd,
stdout=f,
stderr=subprocess.PIPE,
check=True,
)
with open(sql_file, "rb") as f_in:
with gzip.open(gz_file, "wb") as f_out:
shutil.copyfileobj(f_in, f_out)
sql_file.unlink()
return str(gz_file)
+12
View File
@@ -0,0 +1,12 @@
'''
Created on 18 mai 2026
@author: denis
'''
from celery import shared_task
from .services import mariadb_backup
@shared_task
def backup_mariadb_task():
path = mariadb_backup()
return {"status": "ok", "backup": path, }
+3
View File
@@ -8,3 +8,6 @@ os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'home.settings')
appc = Celery('home') appc = Celery('home')
appc.config_from_object('django.conf:settings', namespace='CELERY') appc.config_from_object('django.conf:settings', namespace='CELERY')
appc.autodiscover_tasks() appc.autodiscover_tasks()
worker_prefetch_multiplier = 1
task_acks_late = True
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -31,7 +31,7 @@ class Command(BaseCommand):
) )
print() print()
print(f"Export video: {job.id}") print(f"Export video: {job.id}") # type: ignore[union-attr]
except Exception as e: except Exception as e:
print("Export video uuid error", e) print("Export video uuid error", e)
@@ -13,7 +13,7 @@ def create_user():
if User.objects.count() == 0: if User.objects.count() == 0:
for email, username, password, is_superuser in settings.ADMINS: for email, username, password, is_superuser in settings.ADMINS:
if is_superuser: if is_superuser:
User.objects.create_superuser( User.objects.create_superuser( # type: ignore[attr-defined]
email=email, email=email,
username=username, username=username,
password=password, password=password,
@@ -21,7 +21,7 @@ def create_user():
is_superuser=is_superuser, is_superuser=is_superuser,
) )
else: else:
User.objects.create_user( User.objects.create_user( # type: ignore[attr-defined]
email=email, email=email,
username=username, username=username,
password=password, password=password,
@@ -2,7 +2,7 @@
# encoding: utf-8 # encoding: utf-8
from uuid import uuid4 from uuid import uuid4
from django.core.management import BaseCommand 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): class Command(BaseCommand):
def add_arguments(self, parser): def add_arguments(self, parser):
@@ -7,7 +7,6 @@ from django.core.management.base import BaseCommand
from scanner import tasks as scanner_tasks from scanner import tasks as scanner_tasks
class Command(BaseCommand): class Command(BaseCommand):
help = "Démarre les tâches Celery." help = "Démarre les tâches Celery."
@@ -18,9 +17,9 @@ class Command(BaseCommand):
def handle(self, *args, **options): # @UnusedVariable def handle(self, *args, **options): # @UnusedVariable
#task = options['task'] #task = options['task']
try: try:
scanner_tasks.scanner_start.delay() # @UndefinedVariable scanner_tasks.scanner_start.delay() # @UndefinedVariable
scanner_tasks.replay_start.delay() # @UndefinedVariable scanner_tasks.replay_start.delay() # @UndefinedVariable
except Exception as e: except Exception as e:
print(e) print(e)
@@ -4,7 +4,11 @@ Created on 19 janv. 2026
@author: denis @author: denis
''' '''
from django.core.management.base import BaseCommand 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 import time
+34 -13
View File
@@ -19,9 +19,7 @@ BASE_DIR = Path(__file__).resolve().parent.parent
print("Django BASE_DIR:", BASE_DIR) print("Django BASE_DIR:", BASE_DIR)
PACKAGE_DIR = BASE_DIR.parent 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 # Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/5.0/howto/deployment/checklist/ # See https://docs.djangoproject.com/en/5.0/howto/deployment/checklist/
@@ -37,10 +35,10 @@ SECRET_KEY = config("SECRET_KEY")
DEBUG = config('DEBUG', cast=bool) DEBUG = config('DEBUG', cast=bool)
DOMAIN_SERVER = config("DOMAIN_SERVER") 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, '*'] 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}'] CSRF_TRUSTED_ORIGINS += [f'http://{DOMAIN_SERVER}', f'https://{DOMAIN_SERVER}']
SECURE_CROSS_ORIGIN_OPENER_POLICY = 'same-origin' SECURE_CROSS_ORIGIN_OPENER_POLICY = 'same-origin'
@@ -61,6 +59,7 @@ INSTALLED_APPS = [
'django_celery_results', 'django_celery_results',
'celery', 'celery',
'home', 'home',
'backup',
'scanner', 'scanner',
'planarian', 'planarian',
@@ -216,12 +215,11 @@ LOCALE_PATHS = (
STATIC_URL = '/static/' STATIC_URL = '/static/'
STATIC_ROOT = APP_DATAS / 'staticfiles' STATIC_ROOT = APP_DATAS / 'staticfiles'
print("Django application STATIC_ROOT:", STATIC_ROOT)
MEDIA_URL = '/media/' MEDIA_URL = '/media/'
MEDIA_ROOT = APP_DATAS / 'media' MEDIA_ROOT = APP_DATAS / 'media'
print("Django application MEDIA_ROOT:", MEDIA_ROOT) print("Django application MEDIA_ROOT:", MEDIA_ROOT, "\nDjango application STATIC_ROOT:", STATIC_ROOT)
""" """
@@ -239,8 +237,9 @@ DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
## LOGGING ## LOGGING
# CRITICAL=50, ERROR=40, WARN=30, INFO=20, DEBUG=10 and NOTSET=0 # 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 = config('IS_LOGGING', cast=bool)
IS_LOGGING = False
LOGGING = None if not IS_LOGGING else { LOGGING = None if not IS_LOGGING else {
'version': 1, 'version': 1,
@@ -347,6 +346,7 @@ DJANGO_CELERY_BEAT_TZ_AWARE = False
CELERY_ENABLE_UTC = True CELERY_ENABLE_UTC = True
CELERY_TASK_TRACK_STARTED = True CELERY_TASK_TRACK_STARTED = True
CELERY_TASK_TIME_LIMIT = 30 * 60 CELERY_TASK_TIME_LIMIT = 30 * 60
CELERY_TASK_SOFT_TIME_LIMIT = 20 * 60
CELERY_BEAT_SCHEDULER = 'django_celery_beat.schedulers:DatabaseScheduler' CELERY_BEAT_SCHEDULER = 'django_celery_beat.schedulers:DatabaseScheduler'
CELERY_ACCEPT_CONTENT = ['application/json'] CELERY_ACCEPT_CONTENT = ['application/json']
CELERY_RESULT_SERIALIZER = 'json' CELERY_RESULT_SERIALIZER = 'json'
@@ -356,6 +356,15 @@ CELERY_RESULT_EXTENDED = True
CELERY_RESULT_BACKEND = 'django-db' CELERY_RESULT_BACKEND = 'django-db'
DJANGO_CELERY_RESULTS_TASK_ID_MAX_LENGTH=191 DJANGO_CELERY_RESULTS_TASK_ID_MAX_LENGTH=191
from celery.schedules import crontab
CELERY_BEAT_SCHEDULE = {
"mariadb-backup-every-night": {
"task": "backup.tasks.backup_mariadb_task",
"schedule": crontab(hour=2, minute=0),
},
}
## reductstore ## reductstore
# #
REDUCTSTORE_TOKEN = config('REDUCTSTORE_TOKEN') REDUCTSTORE_TOKEN = config('REDUCTSTORE_TOKEN')
@@ -364,6 +373,8 @@ REDUCTSTORE_PORT = config('REDUCTSTORE_PORT', cast=int)
REDUCTSTORE_URL = f'http://{REDUCTSTORE_HOST}:{REDUCTSTORE_PORT}' REDUCTSTORE_URL = f'http://{REDUCTSTORE_HOST}:{REDUCTSTORE_PORT}'
REDUCTSTORE_PATH = config("REDUCTSTORE_PATH") REDUCTSTORE_PATH = config("REDUCTSTORE_PATH")
BACKUP_DIR = config('BACKUP_DIR')
## servers app ## servers app
# #
USER = config('USER') USER = config('USER')
@@ -371,6 +382,8 @@ GROUP = config('GROUP')
SERVER_HOST_PORT = config('SERVER_HOST_PORT', cast=int) SERVER_HOST_PORT = config('SERVER_HOST_PORT', cast=int)
SERVER_HOST_IP = config('SERVER_HOST_IP') SERVER_HOST_IP = config('SERVER_HOST_IP')
LOCAL_IP_SERVER = config('LOCAL_IP_SERVER')
# ws # ws
SCANNER_WEBSOCKET_ROUTE = 'ws/scanner' SCANNER_WEBSOCKET_ROUTE = 'ws/scanner'
REPLAY_WEBSOCKET_ROUTE = 'ws/replay' REPLAY_WEBSOCKET_ROUTE = 'ws/replay'
@@ -385,20 +398,28 @@ DATETIME_FORMAT = '%d-%m-%Y-%m %H:%M:%S'
#=========================== #===========================
# default configuration # default configuration
# #
#===========================
# rpicam 4056x3040 2028x1080 2028x1520 # rpicam 4056x3040 2028x1080 2028x1520
#===========================
GRBL_SIMULATION = config('GRBL_SIMULATION', cast=bool)
EXPORTS_LOCAL_PATH = config("EXPORTS_LOCAL_PATH") EXPORTS_LOCAL_PATH = config("EXPORTS_LOCAL_PATH")
EXPORT_REMOTE_PATH = config("EXPORT_REMOTE_PATH") EXPORT_REMOTE_PATH = config("EXPORT_REMOTE_PATH")
EXPORT_DESTINATIONS = ["local", "remote"] EXPORT_DESTINATIONS = ["local", "remote"]
#EXPORT_DESTINATIONS = ["remote"] # only remote
TEST_VIDEOFILE = False CSV_EXPORT_DIR = config("CSV_EXPORT_DIR") # ou None pour mémoire uniquement
TRACKING = True ## tracker default parameters
#
TRACKER_TUBE_AXIS = "vertical" TRACKER_TUBE_AXIS = "vertical"
TRACKER_MIN_AREA = 200 TRACKER_MIN_AREA = 20 # surface min planaire px x px
TRACKER_MAX_AREA_RATIO = 0.1 # 5% de la frame = surface max acceptable
TRACKER_MAX_PLANARIANS = 4
TRACKER_MERGE_KERNEL_SIZE = 15 # augmenter si fragments résiduels
TRACKER_MIN_CONTOUR_DIST_PX = 40 # augmenter si IDs multiples persistent
CALIBRATION_AUTO_DURATION = 45.0 CALIBRATION_AUTO_DURATION = 45.0
CALIBRATION_AUTO_TIMEOUT = 2.5 CALIBRATION_AUTO_TIMEOUT = 2.5
@@ -0,0 +1,98 @@
/* ============================================================
doc_calibration.css
Styles spécifiques à la page de calibration du scanner.
Dépend de doc_database.css (variables, composants de base).
============================================================ */
/* ─── Badge teal ─── */
.badge-teal {
background: rgba(45,212,191,.12);
color: var(--accent-teal);
border: 1px solid rgba(45,212,191,.3);
}
/* ─── Badge blue ─── */
.badge-blue {
background: rgba(88,166,255,.12);
color: var(--accent-blue);
border: 1px solid rgba(88,166,255,.3);
}
/* ─── Tag teal ─── */
.tag-teal {
background: rgba(45,212,191,.12);
color: var(--accent-teal);
border: 1px solid rgba(45,212,191,.2);
}
/* ─── Schéma des 3 zones de l'écran ─── */
.screen-layout {
display: grid;
grid-template-columns: 1fr 2fr 1fr;
gap: 8px;
margin-bottom: 24px;
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
background: var(--bg-card);
}
.screen-zone {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
padding: 20px 10px;
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
text-align: center;
border-right: 1px solid var(--border);
transition: background .15s;
}
.screen-zone:last-child { border-right: none; }
.screen-zone i { font-size: 20px; }
.zone-left { color: var(--accent-indigo); }
.zone-left i { color: var(--accent-indigo); }
.zone-center {
background: rgba(45,212,191,.04);
color: var(--accent-teal);
}
.zone-center i { font-size: 26px; color: var(--accent-teal); }
.zone-right { color: var(--accent-orange); }
.zone-right i { color: var(--accent-orange); }
/* ─── Barre visuelle des seuils de centrage ─── */
.center-bar {
display: flex;
margin-top: 12px;
border-radius: var(--radius-sm);
overflow: hidden;
height: 28px;
font-size: 11px;
font-weight: 700;
}
.cb-seg {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
}
.cb-red { background: rgba(248,81,73,.18); color: var(--accent-red); border: 1px solid rgba(248,81,73,.3); }
.cb-yellow { background: rgba(227,179,65,.18); color: var(--accent-yellow); border: 1px solid rgba(227,179,65,.3); }
.cb-green { background: rgba(63,185,80,.18); color: var(--accent-green); border: 1px solid rgba(63,185,80,.3); }
/* ─── Responsive ─── */
@media (max-width: 768px) {
.screen-layout {
grid-template-columns: 1fr;
}
.screen-zone {
border-right: none;
border-bottom: 1px solid var(--border);
padding: 14px;
}
.screen-zone:last-child { border-bottom: none; }
}
@@ -0,0 +1,521 @@
/* ============================================================
doc_database.css
Feuille de style de la documentation base de données
Dépendances : w3.css, all.css (Font Awesome)
Fond sombre, style industriel/technique
============================================================ */
/* ─── Variables globales ─── */
:root {
--bg-base: #0d1117;
--bg-surface: #161b22;
--bg-card: #1c2128;
--bg-card-hover: #21262d;
--border: #30363d;
--border-strong: #484f58;
--text-primary: #e6edf3;
--text-secondary: #8b949e;
--text-muted: #6e7681;
--text-code: #79c0ff;
--accent-blue: #58a6ff;
--accent-cyan: #39d353;
--accent-green: #3fb950;
--accent-teal: #2dd4bf;
--accent-orange: #f0883e;
--accent-yellow: #e3b341;
--accent-red: #f85149;
--accent-purple: #bc8cff;
--accent-indigo: #818cf8;
--nav-width: 240px;
--header-h: 90px;
--radius: 8px;
--radius-sm: 4px;
--shadow: 0 4px 16px rgba(0,0,0,.4);
--font-mono: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
--font-body: 'Segoe UI', system-ui, sans-serif;
}
/* ─── Reset minimal ─── */
*, *::before, *::after { box-sizing: border-box; }
a { color: var(--accent-blue); text-decoration: none; }
/*a:hover { text-decoration: underline; }*/
ul { padding: 0; margin: 0; list-style: none; }
/* ─── Mise en page principale ─── */
.doc-wrapper {
display: grid;
grid-template-columns: var(--nav-width) 1fr;
grid-template-rows: auto 1fr;
grid-template-areas:
"header header"
"sidenav content";
min-height: 100vh;
background: var(--bg-base);
color: var(--text-primary);
font-family: var(--font-body);
font-size: 14px;
line-height: 1.6;
}
/* ─── En-tête ─── */
.doc-header {
grid-area: header;
background: linear-gradient(135deg, #1a2236 0%, #0d1117 60%, #1a1a2e 100%);
border-bottom: 1px solid var(--border-strong);
padding: 20px 28px;
}
.doc-header-inner {
display: flex;
align-items: center;
gap: 18px;
max-width: 1200px;
}
.doc-header-icon {
width: 52px; height: 52px;
background: linear-gradient(135deg, var(--accent-blue), var(--accent-indigo));
border-radius: var(--radius);
display: flex; align-items: center; justify-content: center;
font-size: 22px;
color: #fff;
flex-shrink: 0;
box-shadow: 0 4px 12px rgba(88,166,255,.3);
}
.doc-title {
margin: 0;
font-size: 22px;
font-weight: 700;
color: var(--text-primary);
}
.doc-subtitle {
margin: 2px 0 0;
font-size: 13px;
color: var(--text-secondary);
}
/* ─── Navigation latérale ─── */
.doc-sidenav {
grid-area: sidenav;
background: var(--bg-surface);
border-right: 1px solid var(--border);
padding: 20px 0;
position: sticky;
top: 0;
height: 100vh;
overflow-y: auto;
}
.nav-section-label {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: .08em;
color: var(--text-muted);
padding: 0 16px 8px;
margin: 0;
}
.nav-link {
display: flex;
align-items: center;
gap: 8px;
padding: 7px 16px;
color: var(--text-secondary);
font-size: 13px;
transition: all .15s;
border-left: 2px solid transparent;
}
.nav-link:hover {
color: var(--text-primary);
background: var(--bg-card);
text-decoration: none;
}
.nav-link.active {
color: var(--accent-blue);
border-left-color: var(--accent-blue);
background: rgba(88,166,255,.08);
}
.nav-link.nav-sub {
padding-left: 28px;
font-size: 12.5px;
}
.nav-link.nav-sub-2 {
padding-left: 42px;
font-size: 12px;
}
/* ─── Contenu principal ─── */
.doc-content {
grid-area: content;
padding: 28px 32px;
max-width: 1100px;
}
/* ─── Sections ─── */
.doc-section {
margin-bottom: 48px;
}
.subsection {
margin-top: 32px;
padding-top: 24px;
border-top: 1px solid var(--border);
}
.sub-subsection {
margin-top: 24px;
}
.section-header {
margin-bottom: 20px;
}
.section-header h2 {
margin: 6px 0 6px;
font-size: 20px;
font-weight: 700;
color: var(--text-primary);
display: flex;
align-items: center;
gap: 10px;
}
.section-desc {
margin: 0;
color: var(--text-secondary);
font-size: 13px;
}
.subsection-title {
font-size: 16px;
font-weight: 600;
margin: 0 0 14px;
color: var(--text-primary);
display: flex;
align-items: center;
gap: 8px;
}
.table-title {
font-size: 14px;
font-weight: 600;
margin: 0 0 12px;
color: var(--text-primary);
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.table-desc {
color: var(--text-secondary);
font-weight: 400;
font-size: 12.5px;
}
/* ─── Badges de section ─── */
.section-badge {
display: inline-block;
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: .06em;
padding: 2px 8px;
border-radius: 20px;
margin-bottom: 6px;
}
.badge-time { background: rgba(57,211,83,.12); color: var(--accent-green); border: 1px solid rgba(57,211,83,.3); }
.badge-orange { background: rgba(240,136,62,.12); color: var(--accent-orange); border: 1px solid rgba(240,136,62,.3);}
/* ─── Ligne de cartes ─── */
.info-row {
display: flex;
gap: 14px;
flex-wrap: wrap;
margin-bottom: 14px;
}
/* ─── Cartes d'information ─── */
.info-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 16px;
display: flex;
gap: 14px;
flex: 1;
min-width: 220px;
transition: background .15s, box-shadow .15s;
border-left-width: 3px;
}
.info-card:hover {
background: var(--bg-card-hover);
box-shadow: var(--shadow);
}
.info-card.full-width { flex: 1 1 100%; }
.card-icon {
width: 36px; height: 36px;
border-radius: var(--radius-sm);
display: flex; align-items: center; justify-content: center;
font-size: 16px;
flex-shrink: 0;
opacity: .85;
}
.card-body { flex: 1; min-width: 0; }
.card-body h4 {
margin: 0 0 6px;
font-size: 13.5px;
font-weight: 600;
color: var(--text-primary);
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.card-body p {
margin: 0 0 8px;
color: var(--text-secondary);
font-size: 13px;
}
/* Accents colorés des cartes */
.card-accent-blue { border-left-color: var(--accent-blue); }
.card-accent-blue .card-icon { background: rgba(88,166,255,.12); color: var(--accent-blue); }
.card-accent-cyan { border-left-color: var(--accent-cyan); }
.card-accent-cyan .card-icon { background: rgba(57,211,83,.12); color: var(--accent-cyan); }
.card-accent-green { border-left-color: var(--accent-green); }
.card-accent-green .card-icon { background: rgba(63,185,80,.12); color: var(--accent-green); }
.card-accent-teal { border-left-color: var(--accent-teal); }
.card-accent-teal .card-icon { background: rgba(45,212,191,.12); color: var(--accent-teal); }
.card-accent-orange { border-left-color: var(--accent-orange); }
.card-accent-orange .card-icon { background: rgba(240,136,62,.12); color: var(--accent-orange); }
.card-accent-yellow { border-left-color: var(--accent-yellow); }
.card-accent-yellow .card-icon { background: rgba(227,179,65,.12); color: var(--accent-yellow); }
.card-accent-red { border-left-color: var(--accent-red); }
.card-accent-red .card-icon { background: rgba(248,81,73,.12); color: var(--accent-red); }
.card-accent-purple { border-left-color: var(--accent-purple); }
.card-accent-purple .card-icon { background: rgba(188,140,255,.12);color: var(--accent-purple); }
.card-accent-indigo { border-left-color: var(--accent-indigo); }
.card-accent-indigo .card-icon { background: rgba(129,140,248,.12);color: var(--accent-indigo); }
/* ─── Lien d'accès ─── */
.access-link {
font-family: var(--font-mono);
font-size: 12.5px;
color: var(--accent-blue);
word-break: break-all;
}
.access-link .fa-external-link-alt { font-size: 10px; margin-left: 4px; }
/* ─── Listes documentaires ─── */
.doc-list {
display: flex;
flex-direction: column;
gap: 6px;
margin: 0;
padding: 0;
}
.doc-list li {
display: flex;
align-items: flex-start;
gap: 8px;
color: var(--text-secondary);
font-size: 13px;
padding: 4px 0;
border-bottom: 1px solid rgba(48,54,61,.5);
}
.doc-list li:last-child { border-bottom: none; }
.doc-list li i {
width: 16px;
text-align: center;
margin-top: 2px;
flex-shrink: 0;
}
/* ─── Tags ─── */
.tag-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 8px;
}
.tag-list.inline {
display: inline-flex;
margin-top: 0;
margin-left: 4px;
}
.tag {
font-size: 11px;
padding: 2px 8px;
border-radius: 20px;
font-weight: 600;
}
.tag-blue { background: rgba(88,166,255,.12); color: var(--accent-blue); border: 1px solid rgba(88,166,255,.2); }
.tag-green { background: rgba(63,185,80,.12); color: var(--accent-green); border: 1px solid rgba(63,185,80,.2); }
.tag-orange { background: rgba(240,136,62,.12); color: var(--accent-orange); border: 1px solid rgba(240,136,62,.2);}
.tag-gray { background: rgba(110,118,129,.12); color: var(--text-secondary);border: 1px solid rgba(110,118,129,.2);}
/* ─── Grille de puits ─── */
.well-grid {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 10px;
}
/* Grille serpentin : 6 colonnes fixes, 4 lignes (D/C/B/A) */
.well-grid--serpentine {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 5px;
margin-top: 12px;
}
.well-chip {
font-family: var(--font-mono);
font-size: 12px;
font-weight: 700;
padding: 5px 4px;
border-radius: var(--radius-sm);
text-align: center;
letter-spacing: .03em;
/* couleur par défaut (teal) */
background: rgba(45,212,191,.1);
border: 1px solid rgba(45,212,191,.25);
color: var(--accent-teal);
}
/* Couleurs distinctes par ligne */
.wc-d {
background: rgba(248,81,73,.10);
border-color: rgba(248,81,73,.25);
color: var(--accent-red);
}
.wc-c {
background: rgba(240,136,62,.10);
border-color: rgba(240,136,62,.25);
color: var(--accent-orange);
}
.wc-b {
background: rgba(88,166,255,.10);
border-color: rgba(88,166,255,.25);
color: var(--accent-blue);
}
.wc-a {
background: rgba(63,185,80,.10);
border-color: rgba(63,185,80,.25);
color: var(--accent-green);
}
/* ─── Grille de position multi-puits ─── */
.position-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin: 10px 0;
}
.pos-cell {
background: rgba(129,140,248,.08);
border: 1px solid rgba(129,140,248,.2);
border-radius: var(--radius-sm);
padding: 8px 10px;
text-align: center;
font-weight: 700;
font-size: 13px;
color: var(--accent-indigo);
transition: background .15s;
}
.pos-cell.active:hover {
background: rgba(129,140,248,.18);
}
.pos-cell small {
display: block;
font-weight: 400;
font-size: 11px;
color: var(--text-secondary);
}
/* ─── Badges inline ─── */
.badge-auto {
font-size: 10px;
background: rgba(63,185,80,.12);
color: var(--accent-green);
border: 1px solid rgba(63,185,80,.25);
padding: 1px 6px;
border-radius: 20px;
margin-left: 4px;
font-weight: 600;
}
.badge-important {
font-size: 10px;
background: rgba(248,81,73,.15);
color: var(--accent-red);
border: 1px solid rgba(248,81,73,.3);
padding: 1px 6px;
border-radius: 20px;
margin-left: 4px;
font-weight: 700;
}
/* ─── Boîte d'alerte ─── */
.alert-box {
margin-top: 10px;
background: rgba(248,81,73,.08);
border: 1px solid rgba(248,81,73,.25);
border-radius: var(--radius-sm);
padding: 8px 12px;
color: var(--accent-red);
font-size: 12.5px;
font-weight: 600;
display: flex;
align-items: center;
gap: 8px;
}
/* ─── Description de tâche ─── */
.task-desc {
margin: 3px 0 0;
color: var(--text-muted);
font-size: 12px;
}
/* ─── Aide contextuelle ─── */
.hint {
margin: 8px 0 0;
color: var(--text-muted);
font-size: 12px;
}
/* ─── Code inline ─── */
code {
font-family: var(--font-mono);
font-size: 12.5px;
color: var(--text-code);
background: rgba(88,166,255,.07);
padding: 1px 5px;
border-radius: 3px;
border: 1px solid rgba(88,166,255,.12);
}
/* ─── Couleurs utilitaires ─── */
.text-gold { color: #d4a017; }
.text-blue { color: var(--accent-blue); }
.text-gray { color: var(--text-muted); }
.text-green { color: var(--accent-green); }
.text-cyan { color: var(--accent-cyan); }
/* ─── Scrollbar personnalisée ─── */
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: var(--bg-base); }
::-webkit-scrollbar-thumb { background: var(--border-strong); border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: var(--text-muted); }
/* ─── Responsive ─── */
@media (max-width: 768px) {
.doc-wrapper {
grid-template-columns: 1fr;
grid-template-areas:
"header"
"content";
}
.doc-sidenav { display: none; }
.doc-content { padding: 16px; }
.info-card { min-width: 100%; }
}
@@ -0,0 +1,126 @@
/* ============================================================
doc_experiments.css
Styles spécifiques à la page "Démarrer des expériences".
Dépend de doc_database.css + doc_calibration.css.
============================================================ */
/* ─── Badges supplémentaires ─── */
.badge-green {
background: rgba(63,185,80,.12);
color: var(--accent-green);
border: 1px solid rgba(63,185,80,.3);
}
.badge-purple {
background: rgba(188,140,255,.12);
color: var(--accent-purple);
border: 1px solid rgba(188,140,255,.3);
}
.badge-orange {
background: rgba(240,136,62,.12);
color: var(--accent-orange);
border: 1px solid rgba(240,136,62,.3);
}
/* ─── Tags supplémentaires ─── */
.tag-purple {
background: rgba(188,140,255,.12);
color: var(--accent-purple);
border: 1px solid rgba(188,140,255,.2);
}
.tag-yellow {
background: rgba(227,179,65,.12);
color: var(--accent-yellow);
border: 1px solid rgba(227,179,65,.2);
}
/* ─── Étapes numérotées ─── */
ol.doc-steps {
list-style: none;
padding: 0;
margin: 8px 0 0;
counter-reset: step-counter;
display: flex;
flex-direction: column;
gap: 8px;
}
ol.doc-steps li {
display: flex;
align-items: flex-start;
gap: 10px;
color: var(--text-secondary);
font-size: 13px;
counter-increment: step-counter;
padding: 6px 8px;
background: rgba(255,255,255,.02);
border-radius: var(--radius-sm);
border: 1px solid var(--border);
transition: background .12s;
}
ol.doc-steps li:hover {
background: rgba(255,255,255,.05);
}
ol.doc-steps li::before {
content: counter(step-counter);
min-width: 20px;
height: 20px;
border-radius: 50%;
background: rgba(88,166,255,.15);
color: var(--accent-blue);
font-size: 11px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-top: 1px;
}
ol.doc-steps li i {
width: 16px;
text-align: center;
margin-top: 2px;
flex-shrink: 0;
}
/* ─── Alert info (bleu, non critique) ─── */
.alert-box.alert-info {
background: rgba(88,166,255,.08);
border-color: rgba(88,166,255,.25);
color: var(--accent-blue);
}
/* ─── Titre de groupe de métriques ─── */
.metrics-group-title {
font-size: 13px;
font-weight: 600;
color: var(--text-secondary);
margin: 16px 0 10px;
display: flex;
align-items: center;
gap: 8px;
text-transform: uppercase;
letter-spacing: .05em;
}
.metrics-group-title i {
color: var(--text-muted);
}
/* ─── Cartes métriques : padding réduit ─── */
.metrics-card {
min-width: 180px;
}
.metrics-card .card-body h4 {
font-size: 13px;
margin-bottom: 8px;
}
/* ─── Tags métriques : inline compact ─── */
.metric-tags {
display: flex;
flex-wrap: wrap;
gap: 5px;
margin-top: 4px;
}
.metric-tags .tag {
font-family: var(--font-mono);
font-size: 11px;
}
@@ -0,0 +1,114 @@
/* ============================================================
doc_media.css
Styles spécifiques à la page "Gestionnaire de médias".
Dépend de doc_database.css + doc_calibration.css
+ doc_experiments.css
+ doc_scanning.css
+ doc_results.css.
============================================================ */
/* ─── Badge purple ─── */
.badge-purple {
background: rgba(188,140,255,.12);
color: var(--accent-purple);
border: 1px solid rgba(188,140,255,.3);
}
/* ─── Prévisualisation grille ─── */
.grid-preview {
display: flex;
align-items: center;
gap: 10px;
margin-top: 14px;
flex-wrap: wrap;
}
.gp-col {
width: 28px; height: 28px;
border-radius: 50%;
background: rgba(188,140,255,.12);
border: 1px solid rgba(188,140,255,.25);
color: var(--accent-purple);
display: flex; align-items: center; justify-content: center;
font-size: 13px;
cursor: default;
}
/* Chaque démo de grille */
.gp-demo {
display: grid;
gap: 3px;
flex: 1;
min-width: 60px;
max-width: 120px;
}
.gp-demo span {
height: 22px;
border-radius: 3px;
background: rgba(188,140,255,.15);
border: 1px solid rgba(188,140,255,.2);
}
.gp-2 { grid-template-columns: repeat(2, 1fr); }
.gp-3 { grid-template-columns: repeat(3, 1fr); }
.gp-4 { grid-template-columns: repeat(4, 1fr); }
/* ─── Contrôles lecteur vidéo ─── */
.player-controls {
display: flex;
gap: 8px;
margin-top: 10px;
flex-wrap: wrap;
}
.player-btn {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 10px 16px;
border-radius: var(--radius-sm);
font-size: 11px;
font-weight: 600;
flex: 1;
min-width: 60px;
border: 1px solid var(--border);
transition: background .15s;
}
.player-btn i { font-size: 16px; }
.player-btn span { color: var(--text-secondary); }
.btn-play {
background: rgba(63,185,80,.08);
border-color: rgba(63,185,80,.25);
color: var(--accent-green);
}
.btn-play:hover { background: rgba(63,185,80,.16); }
.btn-pause {
background: rgba(227,179,65,.08);
border-color: rgba(227,179,65,.25);
color: var(--accent-yellow);
}
.btn-pause:hover { background: rgba(227,179,65,.16); }
.btn-stop {
background: rgba(248,81,73,.08);
border-color: rgba(248,81,73,.25);
color: var(--accent-red);
}
.btn-stop:hover { background: rgba(248,81,73,.16); }
/* ─── Badge export différé ─── */
.deferred-badge {
display: inline-flex;
align-items: center;
gap: 6px;
margin-top: 10px;
padding: 5px 12px;
border-radius: 20px;
font-size: 12px;
font-weight: 600;
background: rgba(45,212,191,.08);
border: 1px solid rgba(45,212,191,.25);
color: var(--accent-teal);
}
@@ -0,0 +1,32 @@
/* ============================================================
doc_results.css
Styles spécifiques à la page "Exploiter les résultats".
Dépend de doc_database.css + doc_calibration.css
+ doc_experiments.css
+ doc_scanning.css.
============================================================ */
/* ─── Bloc chemin serveur ─── */
.path-block {
display: flex;
align-items: center;
gap: 12px;
margin-top: 10px;
padding: 10px 14px;
background: rgba(45,212,191,.06);
border: 1px solid rgba(45,212,191,.2);
border-radius: var(--radius-sm);
color: var(--accent-teal);
}
.path-block i {
font-size: 16px;
flex-shrink: 0;
}
.path-block code {
color: var(--accent-teal);
background: transparent;
border: none;
font-size: 13px;
letter-spacing: .03em;
word-break: break-all;
}
@@ -0,0 +1,54 @@
/* ============================================================
doc_scanning.css
Styles spécifiques à la page "Balayage des puits".
Dépend de doc_database.css + doc_calibration.css
+ doc_experiments.css.
============================================================ */
/* ─── Badge indigo ─── */
.badge-indigo {
background: rgba(129,140,248,.12);
color: var(--accent-indigo);
border: 1px solid rgba(129,140,248,.3);
}
/* ─── Badge red ─── */
.badge-red {
background: rgba(248,81,73,.12);
color: var(--accent-red);
border: 1px solid rgba(248,81,73,.3);
}
/* ─── Badge superadmin ─── */
.badge-superadmin {
font-size: 10px;
background: rgba(188,140,255,.15);
color: var(--accent-purple);
border: 1px solid rgba(188,140,255,.3);
padding: 1px 8px;
border-radius: 20px;
margin-left: 6px;
font-weight: 700;
font-family: var(--font-mono);
}
/* ─── Exemple UUID ─── */
.uuid-example {
display: flex;
align-items: center;
gap: 10px;
margin-top: 10px;
padding: 8px 12px;
background: rgba(227,179,65,.06);
border: 1px dashed rgba(227,179,65,.25);
border-radius: var(--radius-sm);
color: var(--accent-yellow);
font-size: 12.5px;
}
.uuid-example code {
color: var(--accent-yellow);
background: transparent;
border: none;
font-size: 12.5px;
letter-spacing: .04em;
}
+11 -1
View File
@@ -1,6 +1,6 @@
/* /*
** custom.js ** custom.js
** GNU GENERAL PUBLIC LICENSE: (c) DD miraceti.net return document.querySelectorAll(id); ** GNU GENERAL PUBLIC LICENSE: (c) DD miraceti.net
*/ */
let s = function(sel) { return document.querySelector(sel); }; let s = function(sel) { return document.querySelector(sel); };
let ss = function(sel) { return document.querySelectorAll(sel); }; let ss = function(sel) { return document.querySelectorAll(sel); };
@@ -40,3 +40,13 @@ function timestampToLocalISOString(timestamp) {
return toLocalISOString(date); return toLocalISOString(date);
} }
function getCookie(name) {
const match = document.cookie.match('(?:^|; )' + name + '=([^;]*)');
return match ? decodeURIComponent(match[1]) : null;
}
function csrfFetch(url, options = {}) {
options.headers = { ...(options.headers || {}), 'X-CSRFToken': getCookie('csrftoken') };
return fetch(url, options);
}
@@ -0,0 +1,473 @@
{% extends 'scanner/base.html' %}
{% load i18n %}
{% block columns %}{% endblock %}
{% block styles %}
{{ block.super }}
<link rel="stylesheet" href="/static/css/doc_database.css">
<link rel="stylesheet" href="/static/css/doc_calibration.css">
{% endblock %}
{% block content %}
<div class="doc-wrapper">
<!-- ─── En-tête ─── -->
<header class="doc-header">
<div class="doc-header-inner">
<div class="doc-header-icon" style="background: linear-gradient(135deg, var(--accent-teal), var(--accent-blue));">
<i class="fas fa-crosshairs"></i>
</div>
<div>
<h1 class="doc-title">{% trans "Calibration du scanner" %}</h1>
<p class="doc-subtitle">{% trans "Procédure, organisation de l'écran et commandes disponibles" %}</p>
</div>
</div>
</header>
<!-- ─── Navigation latérale ─── -->
<nav class="doc-sidenav" id="sidenav">
<p class="nav-section-label">{% trans "Navigation" %}</p>
<a href="#section-intro" class="nav-link active"><i class="fas fa-info-circle"></i> {% trans "Présentation" %}</a>
<a href="#section-screen" class="nav-link"> <i class="fas fa-desktop"></i> {% trans "Organisation écran" %}</a>
<a href="#section-left" class="nav-link nav-sub"><i class="fas fa-arrows-alt"></i> {% trans "Panneau gauche" %}</a>
<a href="#section-center" class="nav-link nav-sub"><i class="fas fa-video"></i> {% trans "Centre — vidéo live" %}</a>
<a href="#section-right" class="nav-link nav-sub"><i class="fas fa-sliders-h"></i> {% trans "Panneau droit" %}</a>
<a href="#section-centering" class="nav-link nav-sub-2"><i class="fas fa-dot-circle"></i> {% trans "Centrage" %}</a>
<a href="#section-base" class="nav-link nav-sub-2"><i class="fas fa-map-pin"></i> {% trans "Définir la base" %}</a>
</nav>
<!-- ─── Contenu principal ─── -->
<main class="doc-content">
<!-- ============================================================ -->
<!-- SECTION 1 : Présentation -->
<!-- ============================================================ -->
<section class="doc-section" id="section-intro">
<div class="section-header">
<span class="section-badge badge-teal">{% trans "Prérequis" %}</span>
<h2><i class="fas fa-crosshairs"></i> {% trans "Calibration" %}</h2>
<p class="section-desc">
{% trans "La calibration est indispensable au fonctionnement du scanner." %}<br>
<i class="fas fa-exclamation-triangle w3-text-red"></i> {% trans "Affichage des logs en bas de page" %}
</p>
</div>
<div class="info-row">
<!-- Pourquoi calibrer -->
<div class="info-card card-accent-teal">
<div class="card-icon"><i class="fas fa-bullseye"></i></div>
<div class="card-body">
<h4>{% trans "Objectifs" %}</h4>
<ul class="doc-list">
<li>
<i class="fas fa-camera text-blue"></i>
{% trans "Pointer l'axe de la caméra dans l'axe du tube." %}
</li>
<li>
<i class="fas fa-crop-alt text-cyan"></i>
{% trans "Recadrer l'image pour porter l'origine du suivi des planaires au centre du tube." %}
</li>
</ul>
</div>
</div>
<!-- Avantage recadrage -->
<div class="info-card card-accent-green">
<div class="card-icon"><i class="fas fa-compress-arrows-alt"></i></div>
<div class="card-body">
<h4>{% trans "Avantage du recadrage" %}</h4>
<ul class="doc-list">
<li>
<i class="fas fa-hdd text-green"></i>
{% trans "Réduit la taille des images stockées." %}
</li>
</ul>
</div>
</div>
</div>
<!-- IMPORTANT : base de départ -->
<div class="info-row">
<div class="info-card card-accent-red full-width">
<div class="card-icon"><i class="fas fa-exclamation-triangle"></i></div>
<div class="card-body">
<h4>{% trans "IMPORTANT — Avant toute manipulation" %} <span class="badge-important">{% trans "IMPORTANT" %}</span></h4>
<p>{% trans "Pour tout multi-puits, trouver la base de départ avant de commencer." %}</p>
</div>
</div>
</div>
</section>
<!-- ============================================================ -->
<!-- SECTION 2 : Organisation de l'écran -->
<!-- ============================================================ -->
<section class="doc-section" id="section-screen">
<div class="section-header">
<span class="section-badge badge-blue">{% trans "Interface" %}</span>
<h2><i class="fas fa-desktop"></i> {% trans "Organisation de l'écran" %}</h2>
<p class="section-desc">{% trans "L'écran de calibration est divisé en trois zones fonctionnelles." %}</p>
</div>
<!-- Schéma des 3 zones -->
<div class="screen-layout">
<div class="screen-zone zone-left">
<i class="fas fa-arrows-alt"></i>
<span>{% trans "Déplacements" %}</span>
</div>
<div class="screen-zone zone-center">
<i class="fas fa-video"></i>
<span>{% trans "Vidéo live" %}</span>
</div>
<div class="screen-zone zone-right">
<i class="fas fa-sliders-h"></i>
<span>{% trans "Commandes" %}</span>
</div>
</div>
<!-- ─── Panneau GAUCHE ─── -->
<div class="subsection" id="section-left">
<h3 class="subsection-title">
<i class="fas fa-arrow-left"></i> {% trans "Panneau gauche — Déplacements caméra" %}
</h3>
<div class="info-row">
<!-- Sélection & vitesse -->
<div class="info-card card-accent-indigo">
<div class="card-icon"><i class="fas fa-exchange-alt"></i></div>
<div class="card-body">
<h4>{% trans "Sélection & vitesse" %}</h4>
<ul class="doc-list">
<li><i class="fas fa-th"></i> {% trans "Changer de multi-puits" %}</li>
<li><i class="fas fa-tachometer-alt"></i> {% trans "Modifier la vitesse (mm/mn)" %}</li>
<li><i class="fas fa-ruler-horizontal"></i> {% trans "Modifier le pas de déplacement (mm)" %}</li>
</ul>
</div>
</div>
<!-- Manœuvre manuelle -->
<div class="info-card card-accent-blue">
<div class="card-icon"><i class="fas fa-gamepad"></i></div>
<div class="card-body">
<h4>{% trans "Manœuvre manuelle" %}</h4>
<ul class="doc-list">
<li>
<i class="fas fa-arrows-alt"></i>
{% trans "Déplacement" %}
<div class="tag-list inline">
<span class="tag tag-blue">±X</span>
<span class="tag tag-blue">±Y</span>
</div>
</li>
<li><i class="fas fa-save"></i> {% trans "Sauver la position courante" %}</li>
</ul>
</div>
</div>
<!-- Déplacement puit à puit -->
<div class="info-card card-accent-cyan">
<div class="card-icon"><i class="fas fa-step-forward"></i></div>
<div class="card-body">
<h4>{% trans "Navigation puit à puit" %}</h4>
<ul class="doc-list">
<li><i class="fas fa-arrow-right text-cyan"></i> {% trans "Boutons de déplacement puit à puit" %}</li>
<li><i class="fas fa-compress-arrows-alt text-cyan"></i> {% trans "Bouton centrage manuel" %}</li>
<li><i class="fas fa-magic text-cyan"></i> {% trans "Bouton calibrage automatique" %}</li>
</ul>
</div>
</div>
</div>
<!-- Sous-section : Définir la base -->
<div class="sub-subsection" id="section-base">
<h4 class="table-title">
<i class="fas fa-map-pin"></i>
{% trans "Définir la base" %}
<span class="badge-important">{% trans "IMPORTANT" %}</span>
</h4>
<div class="info-row">
<!-- Création en BDD -->
<div class="info-card card-accent-orange">
<div class="card-icon"><i class="fas fa-database"></i></div>
<div class="card-body">
<h4>{% trans "À la création du multi-puits en base" %}</h4>
<ul class="doc-list">
<li>
<i class="fas fa-map-marker-alt text-orange"></i>
{% trans "Fixe la base par défaut en" %} <code>(50, 50)</code>
</li>
<li>
<i class="fas fa-calculator text-orange"></i>
{% trans "Calcule et enregistre toutes les positions des puits dans" %}
<code>scanner.wellposition</code>
<p class="task-desc">
<i class="fas fa-bolt text-green"></i>
{% trans "Permet de gagner du temps au centrage manuel ou automatique." %}
</p>
</li>
</ul>
</div>
</div>
<!-- Donnée positions générées -->
<div class="info-card card-accent-yellow">
<div class="card-icon"><i class="fas fa-sync-alt"></i></div>
<div class="card-body">
<h4>
{% trans "Positions générées" %} — <code>scanner.multiwell</code>
<span class="badge-important">{% trans "ATTENTION" %}</span>
</h4>
<ul class="doc-list">
<li>
<i class="fas fa-check-circle text-green"></i>
{% trans "À la création : donnée fixée à" %} <strong>{% trans "Oui" %}</strong>
</li>
<li>
<i class="fas fa-times-circle text-red"></i>
{% trans "Non → efface" %} <code>WellPosition</code>
{% trans "et recalcule toutes les positions" %}
</li>
</ul>
</div>
</div>
</div>
<!-- Bouton Définir la base -->
<div class="info-row">
<div class="info-card card-accent-blue full-width">
<div class="card-icon"><i class="fas fa-flag-checkered"></i></div>
<div class="card-body">
<h4>{% trans "Bouton « Définir la base »" %}</h4>
<ul class="doc-list">
<li>
<i class="fas fa-map-marker text-blue"></i>
{% trans "Fixe la base aux coordonnées" %} <code>(X, Y)</code> {% trans "courantes" %}
</li>
<li>
<i class="fas fa-redo text-blue"></i>
{% trans "Recalcule toutes les positions des puits dans" %} <code>scanner.wellposition</code>
</li>
</ul>
</div>
</div>
</div>
</div>
<!-- Sous-section : Centrage -->
<div class="sub-subsection" id="section-centering">
<h4 class="table-title">
<i class="fas fa-dot-circle"></i>
{% trans "Centrage manuel / automatique" %}
<span class="badge-important">{% trans "ATTENTION" %}</span>
</h4>
<div class="info-row">
<!-- Conditions -->
<div class="info-card card-accent-purple">
<div class="card-icon"><i class="fas fa-lightbulb"></i></div>
<div class="card-body">
<h4>{% trans "Conditions requises" %}</h4>
<ul class="doc-list">
<li>
<i class="fas fa-sun text-yellow"></i>
{% trans "Éclairage important des puits — éviter les ombres." %}
</li>
<li>
<i class="fas fa-toggle-on text-blue"></i>
{% trans "Actif uniquement si Debug" %} <strong>{% trans "ET" %}</strong> {% trans "Recadrage activés (commandes à droite)." %}
</li>
</ul>
</div>
</div>
<!-- Indicateurs visuels -->
<div class="info-card card-accent-indigo">
<div class="card-icon"><i class="fas fa-circle-notch"></i></div>
<div class="card-body">
<h4>{% trans "Indicateurs visuels de centrage" %}</h4>
<ul class="doc-list">
<li>
<i class="fas fa-circle" style="color:#f85149;"></i>
<strong class="text-red">{% trans "Cercle rouge" %}</strong>
— {% trans "déplacement centre" %} <code>&lt; 20 px</code>
</li>
<li>
<i class="fas fa-circle" style="color:#e3b341;"></i>
<strong style="color:var(--accent-yellow);">{% trans "Cercle jaune" %}</strong>
— {% trans "au plus près" %} <code>&lt; 5 px</code>
</li>
<li>
<i class="fas fa-circle" style="color:#3fb950;"></i>
<strong class="text-green">{% trans "Cercle vert" %}</strong>
— {% trans "centré" %}
</li>
</ul>
<!-- Barre visuelle des seuils -->
<div class="center-bar">
<div class="cb-seg cb-red">
<span>&lt; 20 px</span>
</div>
<div class="cb-seg cb-yellow">
<span>&lt; 5 px</span>
</div>
<div class="cb-seg cb-green">
<span>{% trans "OK" %}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- ─── CENTRE ─── -->
<div class="subsection" id="section-center">
<h3 class="subsection-title">
<i class="fas fa-video"></i> {% trans "Centre — Vidéo live" %}
</h3>
<div class="info-row">
<div class="info-card card-accent-teal full-width">
<div class="card-icon"><i class="fas fa-film"></i></div>
<div class="card-body">
<h4>{% trans "Flux vidéo en temps réel" %}</h4>
<p>{% trans "Affiche le flux caméra en direct, avec ou sans recadrage selon l'état de la commande." %}</p>
<div class="tag-list">
<span class="tag tag-teal">{% trans "Recadrage actif" %}</span>
<span class="tag tag-gray">{% trans "Image pleine" %}</span>
</div>
</div>
</div>
</div>
</div>
<!-- ─── PANNEAU DROIT ─── -->
<div class="subsection" id="section-right">
<h3 class="subsection-title">
<i class="fas fa-arrow-right"></i> {% trans "Panneau droit — Commandes" %}
</h3>
<div class="info-row">
<!-- Navigation rapide -->
<div class="info-card card-accent-blue">
<div class="card-icon"><i class="fas fa-location-arrow"></i></div>
<div class="card-body">
<h4>{% trans "Navigation rapide" %}</h4>
<ul class="doc-list">
<li><i class="fas fa-home text-blue"></i> {% trans "Déplacer à l'origine (0, 0)" %}</li>
<li><i class="fas fa-map-pin text-blue"></i> {% trans "Aller à la base du multi-puits" %}</li>
</ul>
</div>
</div>
<!-- Bascules -->
<div class="info-card card-accent-indigo">
<div class="card-icon"><i class="fas fa-toggle-on"></i></div>
<div class="card-body">
<h4>{% trans "Bascules" %}</h4>
<ul class="doc-list">
<li>
<i class="fas fa-bug text-purple"></i>
{% trans "Debug" %}
<div class="tag-list inline">
<span class="tag tag-green">{% trans "Oui" %}</span>
<span class="tag tag-gray">{% trans "Non" %}</span>
</div>
</li>
<li>
<i class="fas fa-ruler-combined text-purple"></i>
{% trans "Tracer axes" %}
<div class="tag-list inline">
<span class="tag tag-green">{% trans "Oui" %}</span>
<span class="tag tag-gray">{% trans "Non" %}</span>
</div>
</li>
<li>
<i class="fas fa-crop-alt text-purple"></i>
{% trans "Recadrage" %}
<div class="tag-list inline">
<span class="tag tag-green">{% trans "Oui" %}</span>
<span class="tag tag-gray">{% trans "Non" %}</span>
</div>
</li>
</ul>
</div>
</div>
</div>
<div class="info-row">
<!-- Rayon de recadrage -->
<div class="info-card card-accent-cyan">
<div class="card-icon"><i class="fas fa-circle-notch"></i></div>
<div class="card-body">
<h4>{% trans "Rayon de recadrage" %}</h4>
<p>{% trans "Ajuster le recadrage en pixels." %}</p>
</div>
</div>
<!-- Durée test -->
<div class="info-card card-accent-orange">
<div class="card-icon"><i class="fas fa-stopwatch"></i></div>
<div class="card-body">
<h4>{% trans "Durée entre puits" %}</h4>
<p>{% trans "Durée de pause entre chaque puit lors du test du circuit." %}</p>
</div>
</div>
<!-- Tester le circuit -->
<div class="info-card card-accent-green">
<div class="card-icon"><i class="fas fa-route"></i></div>
<div class="card-body">
<h4>{% trans "Tester le circuit" %}</h4>
<p>{% trans "Vérifier les déplacements en parcourant automatiquement tous les puits." %}</p>
</div>
</div>
</div>
<!-- ARRÊT -->
<div class="info-row">
<div class="info-card card-accent-red full-width">
<div class="card-icon"><i class="fas fa-hand-paper"></i></div>
<div class="card-body">
<h4>
{% trans "ARRÊT" %}
<span class="badge-important">{% trans "URGENCE" %}</span>
</h4>
<div class="alert-box">
<i class="fas fa-stop-circle"></i>
{% trans "Stoppe immédiatement tout mouvement du scanner." %}
</div>
</div>
</div>
</div>
</div><!-- end section-right -->
</section><!-- end section-screen -->
</main>
</div>
{% endblock %}
{% block js_footer %}
{{ block.super }}
<script>
/* Mise en évidence du lien actif dans la navigation latérale */
(function () {
const links = document.querySelectorAll('.nav-link');
const sections = document.querySelectorAll('[id^="section-"]');
const observer = new IntersectionObserver(
(entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
links.forEach(l => l.classList.remove('active'));
const target = document.querySelector(`.nav-link[href="#${entry.target.id}"]`);
if (target) target.classList.add('active');
}
});
},
{ rootMargin: '-10% 0px -80% 0px' }
);
sections.forEach(s => observer.observe(s));
})();
</script>
{% endblock %}
@@ -0,0 +1,510 @@
{% extends 'scanner/base.html' %}
{% load i18n %}
{% block columns %}{% endblock %}
{% block styles %}
{{ block.super }}
<link href="/static/css/doc_database.css" rel="stylesheet">
{% endblock %}
{% block content %}
<div class="doc-wrapper">
<!-- En-tête de la documentation -->
<header class="doc-header">
<div class="doc-header-inner">
<div class="doc-header-icon">
<i class="fas fa-database"></i>
</div>
<div>
<h1 class="doc-title">{% trans "Architecture des bases de données" %}</h1>
<p class="doc-subtitle">{% trans "Planarian Scanner — Infrastructure de stockage et modèles de données" %}</p>
</div>
</div>
</header>
<!-- Navigation latérale -->
<nav class="doc-sidenav" id="sidenav">
<p class="nav-section-label">{% trans "Navigation" %}</p>
<a href="#section-reductstore" class="nav-link active">
<i class="fas fa-wave-square"></i> ReductStore
</a>
<a href="#section-mariadb" class="nav-link">
<i class="fas fa-server"></i> MariaDB
</a>
<a href="#section-auth" class="nav-link nav-sub">
<i class="fas fa-shield-alt"></i> {% trans "Authentification" %}
</a>
<a href="#section-planarian" class="nav-link nav-sub">
<i class="fas fa-microscope"></i> Planarian
</a>
<a href="#section-scanner" class="nav-link nav-sub">
<i class="fas fa-camera"></i> Scanner
</a>
<a href="#section-config" class="nav-link nav-sub-2">
<i class="fas fa-sliders-h"></i> {% trans "Configuration" %}
</a>
<a href="#section-wells" class="nav-link nav-sub-2">
<i class="fas fa-circle"></i> {% trans "Puits" %}
</a>
<a href="#section-multiwell" class="nav-link nav-sub-2">
<i class="fas fa-th"></i> {% trans "Multi-puits" %}
</a>
<a href="#section-wellpos" class="nav-link nav-sub-2">
<i class="fas fa-map-marker-alt"></i> {% trans "Positions" %}
</a>
<a href="#section-experiments" class="nav-link nav-sub-2">
<i class="fas fa-flask"></i> {% trans "Expériences" %}
</a>
<a href="#section-sessions" class="nav-link nav-sub-2">
<i class="fas fa-play-circle"></i> {% trans "Sessions" %}
</a>
<a href="#section-celery" class="nav-link nav-sub-2">
<i class="fas fa-tasks"></i> Celery Beat
</a>
</nav>
<!-- Contenu principal -->
<main class="doc-content">
<!-- ============================================================ -->
<!-- SECTION 1 : ReductStore -->
<!-- ============================================================ -->
<section class="doc-section" id="section-reductstore">
<div class="section-header">
<span class="section-badge badge-time">{% trans "Séries temporelles" %}</span>
<h2><i class="fas fa-wave-square"></i> ReductStore</h2>
<p class="section-desc">{% trans "Base de données de séries temporelles pour les images et métriques de l'application." %}</p>
</div>
<div class="info-row">
<div class="info-card card-accent-blue">
<div class="card-icon"><i class="fas fa-link"></i></div>
<div class="card-body">
<h4>{% trans "Accès" %}</h4>
<a href="http://192.168.1.200:8383/ui/dashboard" target="_blank" class="access-link">
192.168.1.200:8383/ui/dashboard <i class="fas fa-external-link-alt"></i>
</a>
</div>
</div>
</div>
<h3 class="subsection-title"><i class="fas fa-bucket"></i> {% trans "Buckets" %}</h3>
<div class="info-row">
<div class="info-card card-accent-cyan">
<div class="card-icon"><i class="fas fa-images"></i></div>
<div class="card-body">
<h4><code>camera</code></h4>
<p>{% trans "Stockage des images capturées par la caméra." %}</p>
<div class="tag-list">
<span class="tag tag-blue">JPEG</span>
<span class="tag tag-gray">{% trans "Séries temporelles" %}</span>
</div>
</div>
</div>
<div class="info-card card-accent-cyan">
<div class="card-icon"><i class="fas fa-chart-line"></i></div>
<div class="card-body">
<h4><code>planarian_metrics</code></h4>
<p>{% trans "Métriques de suivi des planaires." %}</p>
<div class="tag-list">
<span class="tag tag-green">JSON</span>
<span class="tag tag-gray">{% trans "Séries temporelles" %}</span>
</div>
</div>
</div>
</div>
</section>
<!-- ============================================================ -->
<!-- SECTION 2 : MariaDB -->
<!-- ============================================================ -->
<section class="doc-section" id="section-mariadb">
<div class="section-header">
<span class="section-badge badge-orange">{% trans "Base relationnelle" %}</span>
<h2><i class="fas fa-server"></i> MariaDB</h2>
<p class="section-desc">{% trans "Base de données relationnelle principale de l'application Django." %}</p>
</div>
<div class="info-row">
<div class="info-card card-accent-orange">
<div class="card-icon"><i class="fas fa-database"></i></div>
<div class="card-body">
<h4>Adminer</h4>
<a href="http://192.168.1.200/adminer/" target="_blank" class="access-link">
192.168.1.200/adminer/ <i class="fas fa-external-link-alt"></i>
</a>
</div>
</div>
<div class="info-card card-accent-orange">
<div class="card-icon"><i class="fas fa-user-shield"></i></div>
<div class="card-body">
<h4>{% trans "Administration Django" %}</h4>
<a href="http://192.168.1.200/admin" target="_blank" class="access-link">
192.168.1.200/admin <i class="fas fa-external-link-alt"></i>
</a>
</div>
</div>
</div>
<!-- -------------------------------------------------------- -->
<!-- Authentification -->
<!-- -------------------------------------------------------- -->
<div class="subsection" id="section-auth">
<h3 class="subsection-title"><i class="fas fa-shield-alt"></i> {% trans "Authentification & Autorisation" %}</h3>
<div class="info-row">
<div class="info-card card-accent-purple">
<div class="card-icon"><i class="fas fa-users-cog"></i></div>
<div class="card-body">
<h4>{% trans "Groupes" %}</h4>
<ul class="doc-list">
<li><i class="fas fa-crown text-gold"></i> Admin</li>
<li><i class="fas fa-user-tie text-blue"></i> Staff</li>
<li><i class="fas fa-user text-gray"></i> {% trans "Staff limité" %}</li>
</ul>
</div>
</div>
<div class="info-card card-accent-purple">
<div class="card-icon"><i class="fas fa-user-circle"></i></div>
<div class="card-body">
<h4>{% trans "Utilisateurs" %}</h4>
<ul class="doc-list">
<li><i class="fas fa-star text-gold"></i> superadmin</li>
</ul>
</div>
</div>
</div>
</div>
<!-- -------------------------------------------------------- -->
<!-- Planarian -->
<!-- -------------------------------------------------------- -->
<div class="subsection" id="section-planarian">
<h3 class="subsection-title"><i class="fas fa-microscope"></i> {% trans "Application Planarian" %}</h3>
<div class="info-row">
<div class="info-card card-accent-green full-width">
<div class="card-icon"><i class="fas fa-vials"></i></div>
<div class="card-body">
<h4><code>planarian_experimentconfig</code></h4>
<p>{% trans "Configurations des expériences de suivi des planaires." %}</p>
</div>
</div>
</div>
</div>
<!-- -------------------------------------------------------- -->
<!-- Scanner -->
<!-- -------------------------------------------------------- -->
<div class="subsection" id="section-scanner">
<h3 class="subsection-title"><i class="fas fa-camera"></i> {% trans "Application Scanner" %}</h3>
<!-- Configuration -->
<div class="sub-subsection" id="section-config">
<h4 class="table-title">
<i class="fas fa-sliders-h"></i>
<code>scanner_configuration</code>
<span class="table-desc">{% trans "Constantes et valeurs par défaut" %}</span>
</h4>
<div class="info-row">
<div class="info-card card-accent-blue">
<div class="card-icon"><i class="fas fa-camera-retro"></i></div>
<div class="card-body">
<h4>{% trans "Caméra" %}</h4>
<ul class="doc-list">
<li>
<i class="fas fa-plug"></i>
{% trans "Capture" %} :
<div class="tag-list inline">
<span class="tag tag-blue">Arducam</span>
<span class="tag tag-gray">Webcam</span>
<span class="tag tag-gray">{% trans "Fichier vidéo" %}</span>
</div>
</li>
<li><i class="fas fa-film"></i> {% trans "Fréquence vidéos (fps)" %}</li>
<li><i class="fas fa-arrows-alt-h"></i> {% trans "Largeur de capture vidéo" %}</li>
<li><i class="fas fa-arrows-alt-v"></i> {% trans "Hauteur de capture vidéo" %}</li>
</ul>
</div>
</div>
<div class="info-card card-accent-blue">
<div class="card-icon"><i class="fas fa-crosshairs"></i></div>
<div class="card-body">
<h4>{% trans "Suivi / Tracking" %}</h4>
<ul class="doc-list">
<li><i class="fas fa-cog"></i> {% trans "Valeurs par défaut du tracking" %}</li>
</ul>
</div>
</div>
</div>
</div>
<!-- Puits -->
<div class="sub-subsection" id="section-wells">
<h4 class="table-title">
<i class="fas fa-circle"></i>
<code>scanner_well</code>
<span class="table-desc">{% trans "Noms des puits" %}</span>
</h4>
<div class="info-row">
<div class="info-card card-accent-teal full-width">
<div class="card-icon"><i class="fas fa-th-large"></i></div>
<div class="card-body">
<p>{% trans "Contient uniquement les noms des puits disponibles." %}</p>
<p class="hint"><i class="fas fa-exchange-alt"></i> {% trans "Lecture en serpentin dans le sens des ±X" %}</p>
<!-- Grille serpentin : D1→D6 / C6←C1 / B1→B6 / A6←A1 -->
<div class="well-grid well-grid--serpentine">
{# Ligne D : gauche → droite (D1..D6) #}
<span class="well-chip wc-d">D1</span><span class="well-chip wc-d">D2</span>
<span class="well-chip wc-d">D3</span><span class="well-chip wc-d">D4</span>
<span class="well-chip wc-d">D5</span><span class="well-chip wc-d">D6</span>
{# Ligne C : droite → gauche (C6..C1) #}
<span class="well-chip wc-c">C6</span><span class="well-chip wc-c">C5</span>
<span class="well-chip wc-c">C4</span><span class="well-chip wc-c">C3</span>
<span class="well-chip wc-c">C2</span><span class="well-chip wc-c">C1</span>
{# Ligne B : gauche → droite (B1..B6) #}
<span class="well-chip wc-b">B1</span><span class="well-chip wc-b">B2</span>
<span class="well-chip wc-b">B3</span><span class="well-chip wc-b">B4</span>
<span class="well-chip wc-b">B5</span><span class="well-chip wc-b">B6</span>
{# Ligne A : droite → gauche (A6..A1) #}
<span class="well-chip wc-a">A6</span><span class="well-chip wc-a">A5</span>
<span class="well-chip wc-a">A4</span><span class="well-chip wc-a">A3</span>
<span class="well-chip wc-a">A2</span><span class="well-chip wc-a">A1</span>
</div>
</div>
</div>
</div>
</div>
<!-- Multi-puits -->
<div class="sub-subsection" id="section-multiwell">
<h4 class="table-title">
<i class="fas fa-th"></i>
<code>scanner_multiwell</code>
<span class="table-desc">{% trans "Géométrie et déplacements" %}</span>
</h4>
<div class="info-row">
<div class="info-card card-accent-indigo">
<div class="card-icon"><i class="fas fa-map"></i></div>
<div class="card-body">
<h4>{% trans "Position sur la table" %}</h4>
<div class="position-grid">
<div class="pos-cell active">HG<br><small>{% trans "Haut gauche" %}</small></div>
<div class="pos-cell active">HD<br><small>{% trans "Haut droit" %}</small></div>
<div class="pos-cell active">BG<br><small>{% trans "Bas gauche" %}</small></div>
<div class="pos-cell active">BD<br><small>{% trans "Bas droit" %}</small></div>
</div>
<p class="hint"><i class="fas fa-info-circle"></i> {% trans "4 positions disponibles sur la table" %}</p>
<p class="hint"><i class="fas fa-info-circle"></i> {% trans "(0, 0) de le CNC est en HD" %}</p>
<p class="hint"><i class="fas fa-info-circle"></i> {% trans "HD --> HG +X" %}</p>
<p class="hint"><i class="fas fa-info-circle"></i> {% trans "HD --> BD +Y" %}</p>
</div>
</div>
<div class="info-card card-accent-indigo">
<div class="card-icon"><i class="fas fa-ruler-combined"></i></div>
<div class="card-body">
<h4>{% trans "Géométrie" %}</h4>
<ul class="doc-list">
<li><i class="fas fa-grip-lines"></i> {% trans "Nombre de lignes" %}</li>
<li><i class="fas fa-grip-lines-vertical"></i> {% trans "Nombre de colonnes" %}</li>
<li><i class="fas fa-circle"></i> {% trans "Diamètre des puits" %}</li>
<li><i class="fas fa-font"></i> {% trans "Définition des lignes (A, B, C, D)" %}</li>
<li><i class="fas fa-exchange-alt"></i> {% trans "Lecture en serpentin ±X (D→C→B→A)" %}</li>
</ul>
</div>
</div>
<div class="info-card card-accent-indigo">
<div class="card-icon"><i class="fas fa-route"></i></div>
<div class="card-body">
<h4>{% trans "Déplacements" %}</h4>
<ul class="doc-list">
<li><i class="fas fa-sort-numeric-down"></i> {% trans "Ordre de lecture" %}</li>
<li><i class="fas fa-stopwatch"></i> {% trans "Durée de capture (calibration)" %}</li>
<li>
<i class="fas fa-location-arrow"></i>
{% trans "Origine X" %}
<span class="badge-auto">{% trans "Calibration" %}</span>
</li>
<li>
<i class="fas fa-location-arrow fa-rotate-90"></i>
{% trans "Origine Y" %}
<span class="badge-auto">{% trans "Calibration" %}</span>
</li>
<li><i class="fas fa-arrows-alt-h"></i> {% trans "Pas X mesuré" %}</li>
<li><i class="fas fa-arrows-alt-v"></i> {% trans "Pas Y mesuré" %}</li>
<li><i class="fas fa-tachometer-alt"></i> {% trans "Vitesse (mm/mn)" %}</li>
</ul>
</div>
</div>
</div>
<div class="info-row">
<div class="info-card card-accent-yellow full-width">
<div class="card-icon"><i class="fas fa-exclamation-triangle"></i></div>
<div class="card-body">
<h4>{% trans "Positions générées" %}</h4>
<p>{% trans "Si les positions ne sont pas encore générées, le système efface" %} <code>WellPosition</code> {% trans "et recalcule toutes les positions des puits." %}</p>
</div>
</div>
</div>
</div>
<!-- Position des puits -->
<div class="sub-subsection" id="section-wellpos">
<h4 class="table-title">
<i class="fas fa-map-marker-alt"></i>
<code>scanner_wellposition</code>
<span class="table-desc">{% trans "Positions calibrées des puits" %}</span>
</h4>
<div class="info-row">
<div class="info-card card-accent-cyan">
<div class="card-icon"><i class="fas fa-drafting-compass"></i></div>
<div class="card-body">
<h4>{% trans "Calibration" %}</h4>
<p>{% trans "Manuelle ou automatique." %}</p>
<ul class="doc-list">
<li><i class="fas fa-arrows-alt-h"></i> {% trans "Axe X (mm)" %} <span class="badge-auto">{% trans "Calibration" %}</span></li>
<li><i class="fas fa-arrows-alt-v"></i> {% trans "Axe Y (mm)" %} <span class="badge-auto">{% trans "Calibration" %}</span></li>
</ul>
</div>
</div>
<div class="info-card card-accent-red">
<div class="card-icon"><i class="fas fa-exclamation-circle"></i></div>
<div class="card-body">
<h4>{% trans "Pixels par mm" %} <span class="badge-important">{% trans "IMPORTANT" %}</span></h4>
<p>{% trans "Facteur de calibration optique calculé en fonction de la taille de l'image." %}</p>
<div class="alert-box">
<i class="fas fa-redo"></i>
{% trans "Recalibrer si la taille de l'image est modifiée !" %}
</div>
</div>
</div>
</div>
</div>
<!-- Expériences -->
<div class="sub-subsection" id="section-experiments">
<h4 class="table-title">
<i class="fas fa-flask"></i>
<code>scanner_experiment</code> &amp; <code>scanner_experimentwell</code>
<span class="table-desc">{% trans "Expériences et puits associés" %}</span>
</h4>
<div class="info-row">
<div class="info-card card-accent-green">
<div class="card-icon"><i class="fas fa-vial"></i></div>
<div class="card-body">
<h4>{% trans "Valeurs importantes" %}</h4>
<ul class="doc-list">
<li><i class="fas fa-link"></i> {% trans "Multi-puits lié" %}</li>
<li><i class="fas fa-clock"></i> {% trans "Durée de capture (secondes)" %}</li>
<li><i class="fas fa-fingerprint"></i> {% trans "Identifiant d'expérience" %}</li>
</ul>
</div>
</div>
<div class="info-card card-accent-green">
<div class="card-icon"><i class="fas fa-magic"></i></div>
<div class="card-body">
<h4>{% trans "Création automatique" %}</h4>
<p>{% trans "À la création d'une expérience, les puits liés sont automatiquement créés dans" %} <code>scanner_experimentwell</code>.</p>
</div>
</div>
</div>
</div>
<!-- Sessions -->
<div class="sub-subsection" id="section-sessions">
<h4 class="table-title">
<i class="fas fa-play-circle"></i>
<code>scanner_session</code> &amp; <code>scanner_sessionexperiment</code>
<span class="table-desc">{% trans "Sessions d'expériences" %}</span>
</h4>
<div class="info-row">
<div class="info-card card-accent-blue">
<div class="card-icon"><i class="fas fa-layer-group"></i></div>
<div class="card-body">
<h4>{% trans "Regroupement" %}</h4>
<p>{% trans "Regroupe les expériences lors d'un balayage dans" %} <code>scanner_sessionexperiment</code>.</p>
<div class="tag-list">
<span class="tag tag-blue">{% trans "4 multi-puits maximum" %}</span>
</div>
</div>
</div>
<div class="info-card card-accent-blue">
<div class="card-icon"><i class="fas fa-tasks"></i></div>
<div class="card-body">
<h4>{% trans "2 tâches Celery créées" %}</h4>
<ul class="doc-list">
<li>
<i class="fas fa-play text-green"></i>
<code>scanning_session</code>
<p class="task-desc">{% trans "Lancement différé d'un balayage de session." %}</p>
</li>
<li>
<i class="fas fa-file-export text-cyan"></i>
<code>export_session</code>
<p class="task-desc">
{% trans "Export images et vidéos :" %}
<span class="tag tag-gray">{% trans "Local" %}</span>
<span class="tag tag-gray">{% trans "Support distant" %}</span>
</p>
</li>
</ul>
</div>
</div>
</div>
</div>
<!-- Celery Beat -->
<div class="sub-subsection" id="section-celery">
<h4 class="table-title">
<i class="fas fa-tasks"></i>
<code>django_celery_beat_periodictask</code>
<span class="table-desc">{% trans "Gestion des tâches différées" %}</span>
</h4>
<div class="info-row">
<div class="info-card card-accent-orange full-width">
<div class="card-icon"><i class="fas fa-clock"></i></div>
<div class="card-body">
<p>{% trans "Gère le déclenchement et la planification des tâches Celery différées." %}</p>
<div class="tag-list">
<span class="tag tag-orange">Celery Beat</span>
<span class="tag tag-gray">{% trans "Tâches périodiques" %}</span>
</div>
</div>
</div>
</div>
</div>
</div><!-- end section-scanner -->
</section><!-- end section-mariadb -->
</main><!-- end doc-content -->
</div><!-- end doc-wrapper -->
{% endblock %}
{% block js_footer %}
{{ block.super }}
<script>
/* Mise en évidence du lien actif dans la navigation latérale */
(function () {
const links = document.querySelectorAll('.nav-link');
const sections = document.querySelectorAll('.doc-section, .subsection, .sub-subsection');
/* Observer d'intersection pour la navigation active */
const observer = new IntersectionObserver(
(entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
links.forEach(l => l.classList.remove('active'));
const target = document.querySelector(`.nav-link[href="#${entry.target.id}"]`);
if (target) target.classList.add('active');
}
});
},
{ rootMargin: '-10% 0px -80% 0px' }
);
sections.forEach(s => { if (s.id) observer.observe(s); });
})();
</script>
{% endblock %}
@@ -0,0 +1,541 @@
{% extends 'scanner/base.html' %}
{% load i18n %}
{% block columns %}{% endblock %}
{% block styles %}
{{ block.super }}
<link rel="stylesheet" href="/static/css/doc_database.css">
<link rel="stylesheet" href="/static/css/doc_calibration.css>
<link rel="stylesheet" href="/static/css/doc_experiments.css">
{% endblock %}
{% block content %}
<div class="doc-wrapper">
<!-- ─── En-tête ─── -->
<header class="doc-header">
<div class="doc-header-inner">
<div class="doc-header-icon" style="background: linear-gradient(135deg, var(--accent-green), var(--accent-teal));">
<i class="fas fa-flask"></i>
</div>
<div>
<h1 class="doc-title">{% trans "Démarrer des expériences" %}</h1>
<p class="doc-subtitle">{% trans "Sessions, expériences, configurations et import CSV — interface Administration Django" %}</p>
</div>
</div>
</header>
<!-- ─── Navigation latérale ─── -->
<nav class="doc-sidenav" id="sidenav">
<p class="nav-section-label">{% trans "Navigation" %}</p>
<a href="#section-intro" class="nav-link active"><i class="fas fa-info-circle"></i> {% trans "Présentation" %}</a>
<a href="#section-workflow" class="nav-link"> <i class="fas fa-random"></i> {% trans "Ordre de création" %}</a>
<a href="#section-session" class="nav-link"> <i class="fas fa-play-circle"></i> {% trans "Session" %}</a>
<a href="#section-session-data" class="nav-link nav-sub"><i class="fas fa-table"></i> {% trans "Données" %}</a>
<a href="#section-session-tasks" class="nav-link nav-sub"><i class="fas fa-clock"></i> {% trans "Tâches périodiques" %}</a>
<a href="#section-experiment" class="nav-link"> <i class="fas fa-vial"></i> {% trans "Expérience" %}</a>
<a href="#section-exp-data" class="nav-link nav-sub"><i class="fas fa-table"></i> {% trans "Données" %}</a>
<a href="#section-exp-create" class="nav-link nav-sub"><i class="fas fa-magic"></i> {% trans "Création automatique" %}</a>
<a href="#section-metrics" class="nav-link nav-sub"><i class="fas fa-chart-line"></i> {% trans "Métriques" %}</a>
<a href="#section-config" class="nav-link"> <i class="fas fa-cog"></i> {% trans "Configuration puits" %}</a>
<a href="#section-csv" class="nav-link"> <i class="fas fa-file-csv"></i> {% trans "Import CSV" %}</a>
</nav>
<!-- ─── Contenu principal ─── -->
<main class="doc-content">
<!-- ============================================================ -->
<!-- SECTION 1 : Présentation -->
<!-- ============================================================ -->
<section class="doc-section" id="section-intro">
<div class="section-header">
<span class="section-badge badge-green">{% trans "Administration Django" %}</span>
<h2><i class="fas fa-flask"></i> {% trans "Démarrer des expériences" %}</h2>
<p class="section-desc">
{% trans "C'est l'interface Administration Django qui est chargée de créer les données." %}
</p>
</div>
<div class="info-row">
<div class="info-card card-accent-green full-width">
<div class="card-icon"><i class="fas fa-th-large"></i></div>
<div class="card-body">
<h4>{% trans "Principe général" %}</h4>
<ul class="doc-list">
<li>
<i class="fas fa-link text-green"></i>
{% trans "Une expérience est toujours affectée à un multi-puits." %}
</li>
<li>
<i class="fas fa-th text-green"></i>
{% trans "Il y a 4 emplacements multi-puits, donc" %}
<strong>{% trans "4 expériences maximum par session." %}</strong>
</li>
</ul>
</div>
</div>
</div>
</section>
<!-- ============================================================ -->
<!-- SECTION 2 : Ordre de création -->
<!-- ============================================================ -->
<section class="doc-section" id="section-workflow">
<div class="section-header">
<span class="section-badge badge-blue">{% trans "Workflow" %}</span>
<h2><i class="fas fa-random"></i> {% trans "Ordre de création" %}</h2>
<p class="section-desc">{% trans "Les deux approches sont équivalentes — au choix." %}</p>
</div>
<div class="info-row">
<!-- Option A -->
<div class="info-card card-accent-indigo">
<div class="card-icon"><i class="fas fa-list-ol"></i></div>
<div class="card-body">
<h4><i class="fas fa-play-circle text-indigo"></i> {% trans "Option A — Session en premier" %}</h4>
<ol class="doc-steps">
<li><i class="fas fa-plus-circle"></i> {% trans "Créer la session" %}</li>
<li><i class="fas fa-vial"></i> {% trans "Créer les expériences (4 maxi)" %}</li>
<li><i class="fas fa-link"></i> {% trans "Revenir sur la session et ajouter les expériences" %}</li>
</ol>
</div>
</div>
<!-- Option B -->
<div class="info-card card-accent-blue">
<div class="card-icon"><i class="fas fa-list-ol"></i></div>
<div class="card-body">
<h4><i class="fas fa-vial text-blue"></i> {% trans "Option B — Expériences en premier" %}</h4>
<ol class="doc-steps">
<li><i class="fas fa-vial"></i> {% trans "Créer les expériences" %}</li>
<li><i class="fas fa-plus-circle"></i> {% trans "Créer la session" %}</li>
<li><i class="fas fa-link"></i> {% trans "Ajouter les expériences à la session" %}</li>
</ol>
</div>
</div>
</div>
</section>
<!-- ============================================================ -->
<!-- SECTION 3 : Session -->
<!-- ============================================================ -->
<section class="doc-section" id="section-session">
<div class="section-header">
<span class="section-badge badge-purple">scanner_session</span>
<h2><i class="fas fa-play-circle"></i> {% trans "Session" %}</h2>
</div>
<!-- Données importantes -->
<div class="sub-subsection" id="section-session-data">
<h4 class="table-title">
<i class="fas fa-table"></i> {% trans "Données importantes" %}
</h4>
<div class="info-row">
<div class="info-card card-accent-purple">
<div class="card-icon"><i class="fas fa-id-card"></i></div>
<div class="card-body">
<h4>{% trans "Identification" %}</h4>
<ul class="doc-list">
<li><i class="fas fa-heading text-purple"></i> {% trans "Titre" %}</li>
<li><i class="fas fa-user text-purple"></i> {% trans "Auteur" %}</li>
</ul>
</div>
</div>
<!-- Expériences de la session -->
<div class="info-card card-accent-indigo">
<div class="card-icon"><i class="fas fa-layer-group"></i></div>
<div class="card-body">
<h4>{% trans "Expériences d'une session" %}</h4>
<p>{% trans "Ajouter les expériences à la suite dans l'inline" %} <code>scanner_sessionexperiment</code>.</p>
<div class="tag-list">
<span class="tag tag-blue">{% trans "4 maximum" %}</span>
</div>
</div>
</div>
</div>
</div>
<!-- Tâches périodiques -->
<div class="sub-subsection" id="section-session-tasks">
<h4 class="table-title">
<i class="fas fa-clock"></i> {% trans "Options tâches périodiques" %}
</h4>
<div class="info-row">
<div class="info-card card-accent-orange">
<div class="card-icon"><i class="fas fa-file-export"></i></div>
<div class="card-body">
<h4>{% trans "Date d'exportation" %}</h4>
<p>{% trans "Crée une tâche différée pour exporter automatiquement les images et vidéos à la date choisie." %}</p>
<div class="tag-list">
<span class="tag tag-orange">export_session</span>
</div>
</div>
</div>
<div class="info-card card-accent-teal">
<div class="card-icon"><i class="fas fa-calendar-alt"></i></div>
<div class="card-body">
<h4>{% trans "Date du balayage" %}</h4>
<p>{% trans "Planifie le déclenchement du balayage de la session à une date ultérieure." %}</p>
<div class="tag-list">
<span class="tag tag-teal">scanning_session</span>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- ============================================================ -->
<!-- SECTION 4 : Expérience -->
<!-- ============================================================ -->
<section class="doc-section" id="section-experiment">
<div class="section-header">
<span class="section-badge badge-green">scanner_experiment</span>
<h2><i class="fas fa-vial"></i> {% trans "Expérience" %}</h2>
</div>
<!-- Données -->
<div class="sub-subsection" id="section-exp-data">
<h4 class="table-title"><i class="fas fa-table"></i> {% trans "Données" %}</h4>
<div class="info-row">
<div class="info-card card-accent-green">
<div class="card-icon"><i class="fas fa-id-card"></i></div>
<div class="card-body">
<h4>{% trans "Identification" %}</h4>
<ul class="doc-list">
<li><i class="fas fa-heading text-green"></i> {% trans "Titre" %}</li>
<li><i class="fas fa-user text-green"></i> {% trans "Auteur" %}</li>
<li><i class="fas fa-comment-alt text-green"></i> {% trans "Commentaires" %}</li>
</ul>
</div>
</div>
<div class="info-card card-accent-red">
<div class="card-icon"><i class="fas fa-exclamation-circle"></i></div>
<div class="card-body">
<h4>{% trans "Paramètres critiques" %} <span class="badge-important">{% trans "IMPORTANT" %}</span></h4>
<ul class="doc-list">
<li>
<i class="fas fa-th text-red"></i>
{% trans "Choix du multi-puits" %}
</li>
<li>
<i class="fas fa-stopwatch text-red"></i>
{% trans "Durée de la prise de vue (secondes)" %}
</li>
</ul>
</div>
</div>
</div>
</div>
<!-- Création automatique -->
<div class="sub-subsection" id="section-exp-create">
<h4 class="table-title">
<i class="fas fa-magic"></i> {% trans "Fonctionnement à la création" %}
</h4>
<div class="info-row">
<!-- experimentwell -->
<div class="info-card card-accent-blue">
<div class="card-icon"><i class="fas fa-circle-notch"></i></div>
<div class="card-body">
<h4>{% trans "Création/MAJ" %} <code>scanner.experimentwell</code></h4>
<ul class="doc-list">
<li>
<i class="fas fa-plus text-blue"></i>
{% trans "Création de chaque puit individuel." %}
</li>
<li>
<i class="fas fa-check-circle text-green"></i>
{% trans "Par défaut, tous les puits sont actifs." %}
</li>
</ul>
<div class="alert-box alert-info">
<i class="fas fa-info-circle"></i>
{% trans "Il est possible d'ignorer le balayage d'un ou plusieurs puits au choix." %}
</div>
</div>
</div>
<!-- experimentconfig -->
<div class="info-card card-accent-cyan">
<div class="card-icon"><i class="fas fa-sliders-h"></i></div>
<div class="card-body">
<h4>{% trans "Création/MAJ" %} <code>planarian.experimentconfig</code></h4>
<p>{% trans "Paramètres d'une expérience PlanarianScanner." %}</p>
<ul class="doc-list">
<li>
<i class="fas fa-user-cog text-cyan"></i>
{% trans "Créé depuis Django admin" %}
</li>
<li>
<i class="fas fa-file-csv text-cyan"></i>
{% trans "Ou import CSV depuis l'admin" %}
</li>
<li>
<i class="fas fa-pencil-alt text-muted"></i>
<em>{% trans "Données descriptives supplémentaires (à définir)" %}</em>
</li>
</ul>
</div>
</div>
</div>
</div>
<!-- Métriques -->
<div class="sub-subsection" id="section-metrics">
<h4 class="table-title">
<i class="fas fa-chart-line"></i>
{% trans "Intégration métriques EthoVision XT + comportementales" %}
</h4>
<!-- Métriques par frame -->
<h5 class="metrics-group-title">
<i class="fas fa-film"></i> {% trans "Métriques par frame" %}
</h5>
<div class="info-row">
<div class="info-card card-accent-blue metrics-card">
<div class="card-icon"><i class="fas fa-running"></i></div>
<div class="card-body">
<h4>{% trans "Mobilité" %}</h4>
<div class="metric-tags">
<span class="tag tag-blue">velocity</span>
<span class="tag tag-blue">distance</span>
<span class="tag tag-blue">moving</span>
<span class="tag tag-blue">mobility_state</span>
</div>
</div>
</div>
<div class="info-card card-accent-orange metrics-card">
<div class="card-icon"><i class="fas fa-border-style"></i></div>
<div class="card-body">
<h4>{% trans "Thigmotaxie" %}</h4>
<div class="metric-tags">
<span class="tag tag-orange">dist_to_wall_mm</span>
<span class="tag tag-orange">near_wall</span>
</div>
</div>
</div>
<div class="info-card card-accent-yellow metrics-card">
<div class="card-icon"><i class="fas fa-sun"></i></div>
<div class="card-body">
<h4>{% trans "Phototaxie" %}</h4>
<div class="metric-tags">
<span class="tag tag-yellow">dist_to_light_mm</span>
<span class="tag tag-yellow">heading_to_light_deg</span>
<span class="tag tag-yellow">fleeing_light</span>
</div>
</div>
</div>
</div>
<div class="info-row">
<div class="info-card card-accent-green metrics-card">
<div class="card-icon"><i class="fas fa-utensils"></i></div>
<div class="card-body">
<h4>{% trans "Chimiotaxie" %}</h4>
<div class="metric-tags">
<span class="tag tag-green">dist_to_food_mm</span>
<span class="tag tag-green">heading_to_food_deg</span>
<span class="tag tag-green">approaching_food</span>
<span class="tag tag-green">in_food_zone</span>
</div>
</div>
</div>
<div class="info-card card-accent-purple metrics-card">
<div class="card-icon"><i class="fas fa-users"></i></div>
<div class="card-body">
<h4>{% trans "Social" %}</h4>
<div class="metric-tags">
<span class="tag tag-purple">nearest_neighbour_mm</span>
<span class="tag tag-purple">in_avoid_zone</span>
<span class="tag tag-purple">in_aggreg_zone</span>
<span class="tag tag-purple">chem_repulsion_level</span>
</div>
</div>
</div>
</div>
<!-- Métriques résumé -->
<h5 class="metrics-group-title" style="margin-top:20px;">
<i class="fas fa-clipboard-list"></i> {% trans "Métriques résumé (summary)" %}
</h5>
<div class="info-row">
<div class="info-card card-accent-blue metrics-card">
<div class="card-icon"><i class="fas fa-running"></i></div>
<div class="card-body">
<h4>{% trans "Mobilité" %}</h4>
<div class="metric-tags">
<span class="tag tag-blue">movedCenter_pointTotal_mm</span>
<span class="tag tag-blue">velocity_mean_mm_s</span>
<span class="tag tag-blue">{% trans "durations par état" %}</span>
</div>
</div>
</div>
<div class="info-card card-accent-orange metrics-card">
<div class="card-icon"><i class="fas fa-border-style"></i></div>
<div class="card-body">
<h4>{% trans "Thigmotaxie" %}</h4>
<div class="metric-tags">
<span class="tag tag-orange">thigmotaxis_pct_time_near_wall</span>
</div>
</div>
</div>
<div class="info-card card-accent-yellow metrics-card">
<div class="card-icon"><i class="fas fa-sun"></i></div>
<div class="card-body">
<h4>{% trans "Phototaxie" %}</h4>
<div class="metric-tags">
<span class="tag tag-yellow">photo_pct_time_fleeing</span>
<span class="tag tag-yellow">photo_mean_dist_mm</span>
<span class="tag tag-yellow">photo_latency_s</span>
</div>
</div>
</div>
</div>
<div class="info-row">
<div class="info-card card-accent-green metrics-card">
<div class="card-icon"><i class="fas fa-utensils"></i></div>
<div class="card-body">
<h4>{% trans "Chimiotaxie" %}</h4>
<div class="metric-tags">
<span class="tag tag-green">chemo_pct_time_approaching</span>
<span class="tag tag-green">chemo_pct_time_in_zone</span>
<span class="tag tag-green">chemo_latency_s</span>
<span class="tag tag-green">chemo_mean_dist_mm</span>
</div>
</div>
</div>
<div class="info-card card-accent-purple metrics-card">
<div class="card-icon"><i class="fas fa-users"></i></div>
<div class="card-body">
<h4>{% trans "Social" %}</h4>
<div class="metric-tags">
<span class="tag tag-purple">social_pct_time_avoiding</span>
<span class="tag tag-purple">social_pct_time_aggregating</span>
<span class="tag tag-purple">social_mean_nn_mm</span>
<span class="tag tag-purple">social_contact_events</span>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- ============================================================ -->
<!-- SECTION 5 : Configuration des puits -->
<!-- ============================================================ -->
<section class="doc-section" id="section-config">
<div class="section-header">
<span class="section-badge badge-teal">{% trans "Interface Scanner" %}</span>
<h2><i class="fas fa-cog"></i> {% trans "Configuration des expériences" %}</h2>
<p class="section-desc">{% trans "Sélectionner l'expérience et le puit à modifier pour compléter la fiche." %}</p>
</div>
<div class="info-row">
<div class="info-card card-accent-teal full-width">
<div class="card-icon"><i class="fas fa-edit"></i></div>
<div class="card-body">
<h4>{% trans "Procédure" %}</h4>
<ol class="doc-steps">
<li><i class="fas fa-mouse-pointer"></i> {% trans "Sélectionner l'expérience dans la liste" %}</li>
<li><i class="fas fa-circle"></i> {% trans "Sélectionner le puit à modifier" %}</li>
<li><i class="fas fa-pen"></i> {% trans "Compléter la fiche de configuration" %}</li>
</ol>
</div>
</div>
</div>
</section>
<!-- ============================================================ -->
<!-- SECTION 6 : Import CSV -->
<!-- ============================================================ -->
<section class="doc-section" id="section-csv">
<div class="section-header">
<span class="section-badge badge-orange">{% trans "Import" %}</span>
<h2><i class="fas fa-file-csv"></i> {% trans "Importer des configurations depuis CSV" %}</h2>
</div>
<div class="info-row">
<!-- Menu gauche -->
<div class="info-card card-accent-indigo">
<div class="card-icon"><i class="fas fa-bars"></i></div>
<div class="card-body">
<h4>{% trans "Menu gauche" %}</h4>
<ol class="doc-steps">
<li><i class="fas fa-play-circle"></i> {% trans "Sélectionner la session" %}</li>
<li><i class="fas fa-check-square"></i> {% trans "Cocher l'expérience souhaitée" %}</li>
</ol>
</div>
</div>
<!-- Zone de dépôt -->
<div class="info-card card-accent-blue">
<div class="card-icon"><i class="fas fa-cloud-upload-alt"></i></div>
<div class="card-body">
<h4>{% trans "Zone d'import" %}</h4>
<ol class="doc-steps">
<li><i class="fas fa-file-csv"></i> {% trans "Glisser-déposer le fichier CSV" %}</li>
<li><i class="fas fa-upload"></i> {% trans "Cliquer sur Importer" %}</li>
</ol>
</div>
</div>
</div>
<!-- Astuce CSV -->
<div class="info-row">
<div class="info-card card-accent-yellow full-width">
<div class="card-icon"><i class="fas fa-lightbulb"></i></div>
<div class="card-body">
<h4>{% trans "Astuce — Générer le template CSV depuis Django admin" %}</h4>
<ol class="doc-steps">
<li>
<i class="fas fa-th-list"></i>
{% trans "Aller dans" %}
<strong>{% trans "Planarian Configurations des expériences" %}</strong>
</li>
<li>
<i class="fas fa-check-square"></i>
{% trans "Cocher le puit de l'expérience ou un groupe d'expériences" %}
</li>
<li>
<i class="fas fa-bolt"></i>
{% trans "Action :" %} <strong>{% trans "Exporter un template CSV…" %}</strong>
</li>
<li>
<i class="fas fa-download"></i>
{% trans "Vérifier l'emplacement du fichier téléchargé (dossier Download)." %}
</li>
</ol>
</div>
</div>
</div>
</section>
</main>
</div>
{% endblock %}
{% block js_footer %}
{{ block.super }}
<script>
/* Mise en évidence du lien actif dans la navigation latérale */
(function () {
const links = document.querySelectorAll('.nav-link');
const sections = document.querySelectorAll('[id^="section-"]');
const observer = new IntersectionObserver(
(entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
links.forEach(l => l.classList.remove('active'));
const target = document.querySelector(`.nav-link[href="#${entry.target.id}"]`);
if (target) target.classList.add('active');
}
});
},
{ rootMargin: '-10% 0px -80% 0px' }
);
sections.forEach(s => observer.observe(s));
})();
</script>
{% endblock %}
@@ -0,0 +1,287 @@
{% extends 'scanner/base.html' %}
{% load i18n %}
{% block columns %}{% endblock %}
{% block styles %}
{{ block.super }}
<link rel="stylesheet" href="/static/css/doc_database.css">
<link rel="stylesheet" href="/static/css/doc_calibration.css>
<link rel="stylesheet" href="/static/css/doc_experiments.css">
<link rel="stylesheet" href="/static/css/doc_scanning.css">
<link rel="stylesheet" href="/static/css/doc_results.css">
<link rel="stylesheet" href="/static/css/doc_media.css">
{% endblock %}
{% block content %}
<div class="doc-wrapper">
<!-- ─── En-tête ─── -->
<header class="doc-header">
<div class="doc-header-inner">
<div class="doc-header-icon" style="background: linear-gradient(135deg, var(--accent-purple), var(--accent-blue));">
<i class="fas fa-photo-video"></i>
</div>
<div>
<h1 class="doc-title">{% trans "Gestionnaire de médias" %}</h1>
<p class="doc-subtitle">{% trans "Consultation, rediffusion et export des images et vidéos" %}</p>
</div>
</div>
</header>
<!-- ─── Navigation latérale ─── -->
<nav class="doc-sidenav" id="sidenav">
<p class="nav-section-label">{% trans "Navigation" %}</p>
<a href="#section-grid" class="nav-link active"><i class="fas fa-th"></i> {% trans "Grille d'affichage" %}</a>
<a href="#section-images" class="nav-link"> <i class="fas fa-images"></i> {% trans "Gestionnaire d'images" %}</a>
<a href="#section-img-nav" class="nav-link nav-sub"><i class="fas fa-bars"></i> {% trans "Menu gauche" %}</a>
<a href="#section-img-view"class="nav-link nav-sub"><i class="fas fa-eye"></i> {% trans "Affichage & export" %}</a>
<a href="#section-videos" class="nav-link"> <i class="fas fa-film"></i> {% trans "Rediffusion vidéos" %}</a>
<a href="#section-vid-nav" class="nav-link nav-sub"><i class="fas fa-bars"></i> {% trans "Menu gauche" %}</a>
<a href="#section-vid-view"class="nav-link nav-sub"><i class="fas fa-play-circle"></i> {% trans "Lecteur & export" %}</a>
</nav>
<!-- ─── Contenu principal ─── -->
<main class="doc-content">
<!-- ============================================================ -->
<!-- SECTION 1 : Grille d'affichage -->
<!-- ============================================================ -->
<section class="doc-section" id="section-grid">
<div class="section-header">
<span class="section-badge badge-purple">{% trans "Affichage" %}</span>
<h2><i class="fas fa-th"></i> {% trans "Grille d'affichage" %}</h2>
<p class="section-desc">{% trans "Contrôle situé en haut à gauche de l'interface." %}</p>
</div>
<div class="info-row">
<div class="info-card card-accent-purple full-width">
<div class="card-icon"><i class="fas fa-columns"></i></div>
<div class="card-body">
<h4>{% trans "Nombre de colonnes" %}</h4>
<p>{% trans "Réduire ou augmenter dynamiquement le nombre de colonnes d'affichage des médias." %}</p>
<!-- Visualisation colonnes -->
<div class="grid-preview">
<div class="gp-col"><i class="fas fa-minus"></i></div>
<div class="gp-demo gp-2">
<span></span><span></span>
</div>
<div class="gp-demo gp-3">
<span></span><span></span><span></span>
</div>
<div class="gp-demo gp-4">
<span></span><span></span><span></span><span></span>
</div>
<div class="gp-col"><i class="fas fa-plus"></i></div>
</div>
</div>
</div>
</div>
</section>
<!-- ============================================================ -->
<!-- SECTION 2 : Gestionnaire d'images -->
<!-- ============================================================ -->
<section class="doc-section" id="section-images">
<div class="section-header">
<span class="section-badge badge-blue">{% trans "Images" %}</span>
<h2><i class="fas fa-images"></i> {% trans "Gestionnaire d'images" %}</h2>
</div>
<!-- Menu gauche -->
<div class="sub-subsection" id="section-img-nav">
<h4 class="table-title"><i class="fas fa-bars"></i> {% trans "Menu gauche — Sélection" %}</h4>
<div class="info-row">
<div class="info-card card-accent-indigo full-width">
<div class="card-icon"><i class="fas fa-mouse-pointer"></i></div>
<div class="card-body">
<ol class="doc-steps">
<li><i class="fas fa-play-circle"></i> {% trans "Sélectionner la session" %}</li>
<li><i class="fas fa-check-square"></i> {% trans "Cocher l'expérience" %}</li>
<li><i class="fas fa-circle"></i> {% trans "Choisir le puit" %}</li>
</ol>
</div>
</div>
</div>
</div>
<!-- Affichage & export -->
<div class="sub-subsection" id="section-img-view">
<h4 class="table-title"><i class="fas fa-eye"></i> {% trans "Affichage des images & export" %}</h4>
<div class="info-row">
<!-- Téléchargement unitaire -->
<div class="info-card card-accent-blue">
<div class="card-icon"><i class="fas fa-download"></i></div>
<div class="card-body">
<h4>{% trans "Téléchargement unitaire" %}</h4>
<p>{% trans "Chaque image affichée dispose d'un bouton de téléchargement individuel." %}</p>
</div>
</div>
<!-- Export global images -->
<div class="info-card card-accent-orange">
<div class="card-icon"><i class="fas fa-file-archive"></i></div>
<div class="card-body">
<h4>
{% trans "Exporter l'ensemble des images" %}
<span class="badge-important">{% trans "ATTENTION" %}</span>
</h4>
<ul class="doc-list">
<li>
<i class="fas fa-hourglass-half text-orange"></i>
{% trans "La procédure peut être longue." %}
</li>
<li>
<i class="fas fa-clock text-orange"></i>
{% trans "Préférer l'export différé." %}
</li>
</ul>
<div class="deferred-badge">
<i class="fas fa-calendar-alt"></i>
{% trans "Export différé recommandé" %}
</div>
</div>
</div>
</div>
</div>
</section>
<!-- ============================================================ -->
<!-- SECTION 3 : Rediffusion de vidéos -->
<!-- ============================================================ -->
<section class="doc-section" id="section-videos">
<div class="section-header">
<span class="section-badge badge-teal">{% trans "Vidéos" %}</span>
<h2><i class="fas fa-film"></i> {% trans "Rediffusion de vidéos" %}</h2>
</div>
<!-- Menu gauche -->
<div class="sub-subsection" id="section-vid-nav">
<h4 class="table-title"><i class="fas fa-bars"></i> {% trans "Menu gauche — Sélection" %}</h4>
<div class="info-row">
<div class="info-card card-accent-indigo full-width">
<div class="card-icon"><i class="fas fa-mouse-pointer"></i></div>
<div class="card-body">
<ol class="doc-steps">
<li><i class="fas fa-play-circle"></i> {% trans "Sélectionner la session" %}</li>
<li><i class="fas fa-check-square"></i> {% trans "Cocher l'expérience" %}</li>
<li><i class="fas fa-circle"></i> {% trans "Choisir le puit" %}</li>
</ol>
</div>
</div>
</div>
</div>
<!-- Lecteur & export -->
<div class="sub-subsection" id="section-vid-view">
<h4 class="table-title"><i class="fas fa-play-circle"></i> {% trans "Lecteur vidéo & export" %}</h4>
<div class="info-row">
<!-- Contrôles lecture -->
<div class="info-card card-accent-teal">
<div class="card-icon"><i class="fas fa-gamepad"></i></div>
<div class="card-body">
<h4>{% trans "Contrôles de lecture" %}</h4>
<!-- Boutons transport -->
<div class="player-controls">
<div class="player-btn btn-play">
<i class="fas fa-play"></i>
<span>{% trans "Marche" %}</span>
</div>
<div class="player-btn btn-pause">
<i class="fas fa-pause"></i>
<span>{% trans "Pause" %}</span>
</div>
<div class="player-btn btn-stop">
<i class="fas fa-stop"></i>
<span>{% trans "Arrêt" %}</span>
</div>
</div>
<!-- Sliders -->
<ul class="doc-list" style="margin-top:12px;">
<li>
<i class="fas fa-tachometer-alt text-teal"></i>
{% trans "Slider vitesse de lecture" %}
</li>
<li>
<i class="fas fa-stream text-teal"></i>
{% trans "Slider timeline (position dans la vidéo)" %}
</li>
</ul>
</div>
</div>
<!-- Téléchargements -->
<div class="info-card card-accent-blue">
<div class="card-icon"><i class="fas fa-download"></i></div>
<div class="card-body">
<h4>{% trans "Téléchargements" %}</h4>
<ul class="doc-list">
<li>
<i class="fas fa-image text-blue"></i>
{% trans "Télécharger l'image courante" %}
</li>
<li>
<i class="fas fa-film text-blue"></i>
{% trans "Télécharger la vidéo complète" %}
</li>
</ul>
</div>
</div>
</div>
<!-- Export global vidéos -->
<div class="info-row">
<div class="info-card card-accent-orange full-width">
<div class="card-icon"><i class="fas fa-file-video"></i></div>
<div class="card-body">
<h4>
{% trans "Exporter l'ensemble des vidéos" %}
<span class="badge-important">{% trans "ATTENTION" %}</span>
</h4>
<ul class="doc-list">
<li>
<i class="fas fa-hourglass-half text-orange"></i>
{% trans "La procédure peut être longue." %}
</li>
<li>
<i class="fas fa-clock text-orange"></i>
{% trans "Préférer l'export différé." %}
</li>
</ul>
<div class="deferred-badge">
<i class="fas fa-calendar-alt"></i>
{% trans "Export différé recommandé" %}
</div>
</div>
</div>
</div>
</div>
</section>
</main>
</div>
{% endblock %}
{% block js_footer %}
{{ block.super }}
<script>
/* Mise en évidence du lien actif dans la navigation latérale */
(function () {
const links = document.querySelectorAll('.nav-link');
const sections = document.querySelectorAll('[id^="section-"]');
const observer = new IntersectionObserver(
(entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
links.forEach(l => l.classList.remove('active'));
const target = document.querySelector(`.nav-link[href="#${entry.target.id}"]`);
if (target) target.classList.add('active');
}
});
},
{ rootMargin: '-10% 0px -80% 0px' }
);
sections.forEach(s => observer.observe(s));
})();
</script>
{% endblock %}
@@ -0,0 +1,204 @@
{% extends 'scanner/base.html' %}
{% load i18n %}
{% block columns %}{% endblock %}
{% block styles %}
{{ block.super }}
<link rel="stylesheet" href="/static/css/doc_database.css">
<link rel="stylesheet" href="/static/css/doc_calibration.css>
<link rel="stylesheet" href="/static/css/doc_experiments.css">
<link rel="stylesheet" href="/static/css/doc_scanning.css">
<link rel="stylesheet" href="/static/css/doc_results.css">
{% endblock %}
{% block content %}
<div class="doc-wrapper">
<!-- ─── En-tête ─── -->
<header class="doc-header">
<div class="doc-header-inner">
<div class="doc-header-icon" style="background: linear-gradient(135deg, var(--accent-green), var(--accent-blue));">
<i class="fas fa-chart-bar"></i>
</div>
<div>
<h1 class="doc-title">{% trans "Exploiter les résultats" %}</h1>
<p class="doc-subtitle">{% trans "Export CSV des métriques par session, expérience et planaire" %}</p>
</div>
</div>
</header>
<!-- ─── Navigation latérale ─── -->
<nav class="doc-sidenav" id="sidenav">
<p class="nav-section-label">{% trans "Navigation" %}</p>
<a href="#section-intro" class="nav-link active"><i class="fas fa-info-circle"></i> {% trans "Présentation" %}</a>
<a href="#section-left" class="nav-link"> <i class="fas fa-bars"></i> {% trans "Menu gauche" %}</a>
<a href="#section-export" class="nav-link nav-sub"><i class="fas fa-server"></i> {% trans "Export serveur" %}</a>
<a href="#section-right" class="nav-link"> <i class="fas fa-download"></i> {% trans "Partie droite" %}</a>
<a href="#section-generate" class="nav-link nav-sub"><i class="fas fa-file-csv"></i> {% trans "Générer le CSV" %}</a>
</nav>
<!-- ─── Contenu principal ─── -->
<main class="doc-content">
<!-- ============================================================ -->
<!-- SECTION 1 : Présentation -->
<!-- ============================================================ -->
<section class="doc-section" id="section-intro">
<div class="section-header">
<span class="section-badge badge-green">{% trans "Export CSV" %}</span>
<h2><i class="fas fa-chart-bar"></i> {% trans "Exploiter les résultats des expériences" %}</h2>
<p class="section-desc">{% trans "Les métriques de suivi des planaires sont exportables au format CSV depuis l'interface Scanner." %}</p>
</div>
</section>
<!-- ============================================================ -->
<!-- SECTION 2 : Menu gauche -->
<!-- ============================================================ -->
<section class="doc-section" id="section-left">
<div class="section-header">
<span class="section-badge badge-blue">{% trans "Sélection" %}</span>
<h2><i class="fas fa-bars"></i> {% trans "Menu gauche" %}</h2>
</div>
<div class="info-row">
<div class="info-card card-accent-indigo">
<div class="card-icon"><i class="fas fa-mouse-pointer"></i></div>
<div class="card-body">
<h4>{% trans "Sélection" %}</h4>
<ol class="doc-steps">
<li><i class="fas fa-play-circle"></i> {% trans "Sélectionner la session" %}</li>
<li><i class="fas fa-check-square"></i> {% trans "Cocher l'expérience souhaitée" %}</li>
<li><i class="fas fa-file-export"></i> {% trans "Cliquer sur Exporter les métriques de l'expérience" %}</li>
</ol>
</div>
</div>
</div>
<!-- Export serveur -->
<div class="sub-subsection" id="section-export">
<h4 class="table-title">
<i class="fas fa-server"></i> {% trans "Export côté serveur" %}
<span class="badge-important">{% trans "ATTENTION" %}</span>
</h4>
<div class="info-row">
<!-- Durée -->
<div class="info-card card-accent-orange">
<div class="card-icon"><i class="fas fa-hourglass-half"></i></div>
<div class="card-body">
<h4>{% trans "Durée variable" %}</h4>
<div class="alert-box" style="margin-bottom:0;">
<i class="fas fa-exclamation-triangle"></i>
{% trans "La procédure peut être longue selon le nombre de planaires à suivre." %}
</div>
</div>
</div>
<!-- Tâche de fond -->
<div class="info-card card-accent-blue">
<div class="card-icon"><i class="fas fa-cogs"></i></div>
<div class="card-body">
<h4>{% trans "Tâche de fond" %}</h4>
<p>{% trans "Le service est exécuté en arrière-plan (Celery)." %}</p>
<div class="tag-list">
<span class="tag tag-blue">export_session</span>
</div>
</div>
</div>
</div>
<!-- Emplacement fichiers -->
<div class="info-row">
<div class="info-card card-accent-teal full-width">
<div class="card-icon"><i class="fas fa-folder-open"></i></div>
<div class="card-body">
<h4>{% trans "Emplacement des fichiers sur le serveur" %}</h4>
<div class="path-block">
<i class="fas fa-hdd"></i>
<code>/home/rpi4/export/csv/*.csv</code>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- ============================================================ -->
<!-- SECTION 3 : Partie droite — téléchargement client -->
<!-- ============================================================ -->
<section class="doc-section" id="section-right">
<div class="section-header">
<span class="section-badge badge-green">{% trans "Téléchargement" %}</span>
<h2><i class="fas fa-download"></i> {% trans "Partie droite — Téléchargement client" %}</h2>
</div>
<div class="sub-subsection" id="section-generate">
<div class="info-row">
<!-- Sélections -->
<div class="info-card card-accent-indigo">
<div class="card-icon"><i class="fas fa-sliders-h"></i></div>
<div class="card-body">
<h4>{% trans "Paramètres" %}</h4>
<ul class="doc-list">
<li>
<i class="fas fa-circle text-teal"></i>
{% trans "Choisir le puit" %}
</li>
<li>
<i class="fas fa-hashtag text-indigo"></i>
{% trans "Choisir l'index du planaire" %}
<p class="task-desc">
<i class="fas fa-info-circle"></i>
{% trans "Le nombre de planaires est défini dans" %}
<em>{% trans "Paramètres d'une expérience" %}</em>.
</p>
</li>
</ul>
</div>
</div>
<!-- Bouton Générer -->
<div class="info-card card-accent-green">
<div class="card-icon"><i class="fas fa-file-download"></i></div>
<div class="card-body">
<h4>{% trans "Bouton Générer" %}</h4>
<p>{% trans "Télécharge le fichier CSV directement sur le poste client." %}</p>
<div class="alert-box alert-info">
<i class="fas fa-hourglass-half"></i>
{% trans "La procédure peut être longue." %}
</div>
</div>
</div>
</div>
</div>
</section>
</main>
</div>
{% endblock %}
{% block js_footer %}
{{ block.super }}
<script>
/* Mise en évidence du lien actif dans la navigation latérale */
(function () {
const links = document.querySelectorAll('.nav-link');
const sections = document.querySelectorAll('[id^="section-"]');
const observer = new IntersectionObserver(
(entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
links.forEach(l => l.classList.remove('active'));
const target = document.querySelector(`.nav-link[href="#${entry.target.id}"]`);
if (target) target.classList.add('active');
}
});
},
{ rootMargin: '-10% 0px -80% 0px' }
);
sections.forEach(s => observer.observe(s));
})();
</script>
{% endblock %}
@@ -0,0 +1,266 @@
{% extends 'scanner/base.html' %}
{% load i18n %}
{% block columns %}{% endblock %}
{% block styles %}
{{ block.super }}
<link rel="stylesheet" href="/static/css/doc_database.css">
<link rel="stylesheet" href="/static/css/doc_calibration.css>
<link rel="stylesheet" href="/static/css/doc_experiments.css">
<link rel="stylesheet" href="/static/css/doc_scanning.css">
{% endblock %}
{% block content %}
<div class="doc-wrapper">
<!-- ─── En-tête ─── -->
<header class="doc-header">
<div class="doc-header-inner">
<div class="doc-header-icon" style="background: linear-gradient(135deg, var(--accent-indigo), var(--accent-purple));">
<i class="fas fa-route"></i>
</div>
<div>
<h1 class="doc-title">{% trans "Balayage des puits" %}</h1>
<p class="doc-subtitle">{% trans "Lancement, suivi et procédure en cas d'arrêt" %}</p>
</div>
</div>
</header>
<!-- ─── Navigation latérale ─── -->
<nav class="doc-sidenav" id="sidenav">
<p class="nav-section-label">{% trans "Navigation" %}</p>
<a href="#section-intro" class="nav-link active"><i class="fas fa-info-circle"></i> {% trans "Présentation" %}</a>
<a href="#section-cmd" class="nav-link"> <i class="fas fa-gamepad"></i> {% trans "Commandes" %}</a>
<a href="#section-start" class="nav-link nav-sub"><i class="fas fa-play"></i> {% trans "Lancer le balayage" %}</a>
<a href="#section-stop" class="nav-link nav-sub"><i class="fas fa-stop"></i> {% trans "Arrêt" %}</a>
<a href="#section-recover" class="nav-link"> <i class="fas fa-undo-alt"></i> {% trans "Procédure après arrêt" %}</a>
<a href="#section-reduct" class="nav-link nav-sub"><i class="fas fa-database"></i> ReductStore</a>
</nav>
<!-- ─── Contenu principal ─── -->
<main class="doc-content">
<!-- ============================================================ -->
<!-- SECTION 1 : Présentation -->
<!-- ============================================================ -->
<section class="doc-section" id="section-intro">
<div class="section-header">
<span class="section-badge badge-indigo">{% trans "Balayage" %}</span>
<h2><i class="fas fa-route"></i> {% trans "Balayage des puits" %}</h2>
</div>
<div class="info-row">
<div class="info-card card-accent-indigo">
<div class="card-icon"><i class="fas fa-crop-alt"></i></div>
<div class="card-body">
<h4>{% trans "Recadrage forcé" %}</h4>
<p>{% trans "Le balayage force toujours le recadrage de l'image." %}</p>
<p>{% trans "Le balayage est disponible quand la session est marquée ACTIVE" %}</p>
<div class="alert-box alert-info">
<i class="fas fa-info-circle"></i>
{% trans "Les boutons Axes et Recadrer sont affichés à titre indicatif uniquement." %}
</div>
</div>
</div>
</div>
</section>
<!-- ============================================================ -->
<!-- SECTION 2 : Commandes -->
<!-- ============================================================ -->
<section class="doc-section" id="section-cmd">
<div class="section-header">
<span class="section-badge badge-blue">{% trans "Interface" %}</span>
<h2><i class="fas fa-gamepad"></i> {% trans "Commandes importantes" %}</h2>
</div>
<!-- Lancer le balayage -->
<div class="sub-subsection" id="section-start">
<h4 class="table-title">
<i class="fas fa-play"></i> {% trans "Lancer le balayage" %}
</h4>
<div class="info-row">
<div class="info-card card-accent-green full-width">
<div class="card-icon"><i class="fas fa-play-circle"></i></div>
<div class="card-body">
<ul class="doc-list">
<li>
<i class="fas fa-shoe-prints text-green"></i>
{% trans "Suivi pas à pas de tous les puits de la session." %}
</li>
<li>
<i class="fas fa-terminal text-green"></i>
{% trans "Les logs s'affichent en bas de l'écran en temps réel." %}
</li>
</ul>
</div>
</div>
</div>
</div>
<!-- ARRÊT -->
<div class="sub-subsection" id="section-stop">
<h4 class="table-title">
<i class="fas fa-stop"></i> {% trans "ARRÊT" %}
<span class="badge-important">{% trans "ATTENTION" %}</span>
</h4>
<div class="info-row">
<div class="info-card card-accent-green">
<div class="card-icon"><i class="fas fa-images"></i></div>
<div class="card-body">
<h4><i class="fas fa-check-circle text-green"></i> {% trans "Images" %}</h4>
<p>{% trans "Les images capturées jusqu'à l'arrêt seront enregistrées." %}</p>
</div>
</div>
<div class="info-card card-accent-red">
<div class="card-icon"><i class="fas fa-chart-line"></i></div>
<div class="card-body">
<h4><i class="fas fa-times-circle text-red"></i> {% trans "Données" %}</h4>
<p>{% trans "Les données de suivi ne seront pas enregistrées." %}</p>
</div>
</div>
</div>
</div>
</section>
<!-- ============================================================ -->
<!-- SECTION 3 : Procédure après arrêt -->
<!-- ============================================================ -->
<section class="doc-section" id="section-recover">
<div class="section-header">
<span class="section-badge badge-red">{% trans "Récupération" %}</span>
<h2><i class="fas fa-undo-alt"></i> {% trans "Procédure en cas d'arrêt" %}</h2>
</div>
<!-- Étapes Django -->
<div class="info-row">
<div class="info-card card-accent-orange full-width">
<div class="card-icon"><i class="fas fa-redo-alt"></i></div>
<div class="card-body">
<h4>{% trans "Réinitialiser la session" %}</h4>
<ol class="doc-steps">
<li>
<i class="fas fa-trash-alt text-red"></i>
{% trans "Supprimer la session en cours depuis l'administration Django." %}
</li>
<li>
<i class="fas fa-plus-circle text-green"></i>
{% trans "Créer une nouvelle session." %}
</li>
</ol>
</div>
</div>
</div>
<!-- ReductStore — superadmin -->
<div class="sub-subsection" id="section-reduct">
<h4 class="table-title">
<i class="fas fa-database"></i>
{% trans "Nettoyage ReductStore" %}
<span class="badge-superadmin">superadmin</span>
</h4>
<div class="info-row">
<div class="info-card card-accent-red full-width">
<div class="card-icon"><i class="fas fa-exclamation-triangle"></i></div>
<div class="card-body">
<h4>{% trans "Accès ReductStore" %}</h4>
<a href="http://192.168.1.200:8383/ui/dashboard" target="_blank" class="access-link">
192.168.1.200:8383/ui/dashboard <i class="fas fa-external-link-alt"></i>
</a>
</div>
</div>
</div>
<div class="info-row">
<!-- Bucket camera -->
<div class="info-card card-accent-cyan">
<div class="card-icon"><i class="fas fa-images"></i></div>
<div class="card-body">
<h4>
{% trans "Bucket" %} <code>camera</code>
</h4>
<ol class="doc-steps">
<li>
<i class="fas fa-search"></i>
{% trans "Localiser les entrées correspondant aux UUIDs des expériences." %}
</li>
<li>
<i class="fas fa-trash-alt text-red"></i>
{% trans "Supprimer les UUIDs des expériences concernées." %}
</li>
</ol>
</div>
</div>
<!-- Bucket planarian_metrics -->
<div class="info-card card-accent-green">
<div class="card-icon"><i class="fas fa-chart-line"></i></div>
<div class="card-body">
<h4>
{% trans "Bucket" %} <code>planarian_metrics</code>
</h4>
<ol class="doc-steps">
<li>
<i class="fas fa-tag"></i>
{% trans "Repérer les enregistrements dont le label correspond aux UUIDs." %}
</li>
<li>
<i class="fas fa-trash-alt text-red"></i>
{% trans "Supprimer ces enregistrements." %}
</li>
</ol>
<div class="tag-list" style="margin-top:10px;">
<span class="tag tag-gray">label: uuid</span>
</div>
</div>
</div>
</div>
<!-- Résumé visuel uuid -->
<div class="info-row">
<div class="info-card card-accent-yellow full-width">
<div class="card-icon"><i class="fas fa-key"></i></div>
<div class="card-body">
<h4>{% trans "Identifier les UUIDs à supprimer" %}</h4>
<p>{% trans "Les UUIDs des expériences sont visibles dans l'administration Django, dans la session supprimée ou dans les logs affichés pendant le balayage." %}</p>
<div class="uuid-example">
<i class="fas fa-fingerprint"></i>
<code>3-HD-A4</code>
</div>
</div>
</div>
</div>
</div>
</section>
</main>
</div>
{% endblock %}
{% block js_footer %}
{{ block.super }}
<script>
/* Mise en évidence du lien actif dans la navigation latérale */
(function () {
const links = document.querySelectorAll('.nav-link');
const sections = document.querySelectorAll('[id^="section-"]');
const observer = new IntersectionObserver(
(entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
links.forEach(l => l.classList.remove('active'));
const target = document.querySelector(`.nav-link[href="#${entry.target.id}"]`);
if (target) target.classList.add('active');
}
});
},
{ rootMargin: '-10% 0px -80% 0px' }
);
sections.forEach(s => observer.observe(s));
})();
</script>
{% endblock %}
@@ -2,12 +2,14 @@
{% if messages %} {% if messages %}
<div class="alert"> <div class="alert">
{% for message in messages %} {% for message in messages %}
<div class="w3-bar {% if message.tags %}{{ message.tags }}{% endif %}" style="width: 100%" > <div class="w3-panel w3-round
{% if message.tags == 'success' %}w3-green
{% elif message.tags == 'error' %}w3-red
{% elif message.tags == 'warning' %}w3-orange
{% else %}w3-blue{% endif %}" style="width: 100%" >
<div class="w3-bar-item">{{ message }}</div> <div class="w3-bar-item">{{ message }}</div>
<div class="w3-bar-item w3-button w3-right" onclick="this.parentElement.style.display='none'">&times;</div> <div class="w3-bar-item w3-button w3-right" onclick="this.parentElement.style.display='none'">&times;</div>
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
{% endif %} {% endif %}
+1
View File
@@ -38,6 +38,7 @@ urlpatterns += i18n_patterns(
path('', RedirectView.as_view(url='/scanner/calibration/', permanent=True), name='redirect_to_mainboard'), path('', RedirectView.as_view(url='/scanner/calibration/', permanent=True), name='redirect_to_mainboard'),
path('scanner/', include('scanner.urls', namespace='scanner')), path('scanner/', include('scanner.urls', namespace='scanner')),
path('planarian/', include('planarian.urls', namespace='planarian')),
) )
if settings.DEBUG: if settings.DEBUG:
+102
View File
@@ -0,0 +1,102 @@
#!/bin/bash
# Génère 24 vidéos pour simuler le balayage d'un multi-puit de 6x24
# A1..A6, B1..B6, C1..C6, D1..D6
#
MODE="$1"
PATH="./media/simulation"
default_width=1000 # px
default_height=1000 # px
default_diameter=16.0 # mm
#fps duration bg-color arena-color arena-border shadow-color body-color body-dark body-light head-color thresh-immobile thresh-mobile
BASE="15 60 #EBEBEB #FAFAFA #8C8C8C #C8C8C8 #A5A5A5 #373737 #D2D2D2 #828282 0.2 1.5"
#photo-mode photo-strength photo-x photo-y photo-sine-freq photo-radius
PHOTO_0="none 0.50 0.50 0.50 0.10 0.30"
PHOTO_1="fixed 0.50 0.50 0.50 0.10 0.40"
PHOTO_2="sine 0.50 0.50 0.50 0.10 0.50"
PHOTO_3="radial 0.50 0.50 0.50 0.10 0.60"
#chemo-strength chemo-x chemo-y chemo-radius
CHEMO_0="0.0 0.70 0.70 2.0"
CHEMO_1="0.5 0.70 0.70 2.0"
CHEMO_2="1.0 0.70 0.70 2.0"
#avoid-strength avoid-radius aggreg-strength aggreg-radius chem-repulsion chem-decay
AVOID_0="0.0 3.0 0.0 6.0 0.0 0.95"
AVOID_1="0.5 3.0 0.0 6.0 0.5 0.75"
AVOID_2="1.0 3.0 0.0 6.0 1.0 0.65"
#length width
SIZE_0="0.40 0.30"
SIZE_1="0.45 0.35"
SIZE_2="0.50 0.40"
SIZE_3="0.55 0.45"
SIZE_4="0.65 0.45"
SIZE_5="0.7 0.25"
declare -A DEFAULT
declare -A ALL
#=========================== count size seed base thigmotaxis photo chemo avoid
DEFAULT[default_simulation]="4 $SIZE_0 32 $BASE 0.45 $PHOTO_0 $CHEMO_0 $AVOID_1"
#======= count size seed base thigmotaxis photo chemo avoid
ALL[A1]="4 $SIZE_0 32 $BASE 0.45 $PHOTO_0 $CHEMO_0 $AVOID_0"
ALL[A2]="4 $SIZE_1 64 $BASE 0.50 $PHOTO_1 $CHEMO_1 $AVOID_1"
ALL[A3]="4 $SIZE_2 96 $BASE 0.55 $PHOTO_1 $CHEMO_2 $AVOID_2"
ALL[A4]="4 $SIZE_3 128 $BASE 0.60 $PHOTO_2 $CHEMO_0 $AVOID_1"
ALL[A5]="4 $SIZE_4 192 $BASE 0.65 $PHOTO_2 $CHEMO_1 $AVOID_0"
ALL[A6]="4 $SIZE_5 240 $BASE 0.75 $PHOTO_3 $CHEMO_2 $AVOID_2"
ALL[B1]="4 $SIZE_0 32 $BASE 0.45 $PHOTO_3 $CHEMO_0 $AVOID_0"
ALL[B2]="4 $SIZE_1 64 $BASE 0.50 $PHOTO_0 $CHEMO_1 $AVOID_1"
ALL[B3]="4 $SIZE_2 96 $BASE 0.55 $PHOTO_0 $CHEMO_2 $AVOID_0"
ALL[B4]="4 $SIZE_3 128 $BASE 0.65 $PHOTO_0 $CHEMO_1 $AVOID_2"
ALL[B5]="4 $SIZE_4 192 $BASE 0.85 $PHOTO_0 $CHEMO_2 $AVOID_0"
ALL[B6]="4 $SIZE_5 240 $BASE 0.95 $PHOTO_0 $CHEMO_0 $AVOID_0"
ALL[C1]="4 $SIZE_0 32 $BASE 0.40 $PHOTO_0 $CHEMO_0 $AVOID_1"
ALL[C2]="4 $SIZE_1 64 $BASE 0.30 $PHOTO_0 $CHEMO_0 $AVOID_0"
ALL[C3]="4 $SIZE_2 96 $BASE 0.55 $PHOTO_0 $CHEMO_1 $AVOID_0"
ALL[C4]="4 $SIZE_3 128 $BASE 0.45 $PHOTO_0 $CHEMO_2 $AVOID_2"
ALL[C5]="4 $SIZE_4 192 $BASE 0.50 $PHOTO_0 $CHEMO_0 $AVOID_0"
ALL[C6]="4 $SIZE_5 240 $BASE 0.65 $PHOTO_0 $CHEMO_1 $AVOID_0"
ALL[D1]="4 $SIZE_0 32 $BASE 0.70 $PHOTO_0 $CHEMO_2 $AVOID_1"
ALL[D2]="4 $SIZE_1 64 $BASE 0.65 $PHOTO_0 $CHEMO_0 $AVOID_0"
ALL[D3]="4 $SIZE_2 96 $BASE 0.75 $PHOTO_0 $CHEMO_1 $AVOID_2"
ALL[D4]="4 $SIZE_3 128 $BASE 0.85 $PHOTO_0 $CHEMO_0 $AVOID_0"
ALL[D5]="4 $SIZE_4 192 $BASE 0.65 $PHOTO_0 $CHEMO_2 $AVOID_0"
ALL[D6]="4 $SIZE_5 240 $BASE 0.45 $PHOTO_0 $CHEMO_0 $AVOID_0"
export_video() {
local -n arguments=$1
for key in "${!arguments[@]}"; do
args="${arguments[$key]}"
read -r count length width seed fps duration bg_color arena_color arena_border shadow_color \
body_color body_dark body_light head_color thresh_immobile thresh_mobile thigmotaxis \
photo_mode photo_strength photo_x photo_y photo_sine_freq photo_radius chemo_strength chemo_x chemo_y chemo_radius \
avoid_strength avoid_radius aggreg_strength aggreg_radius chem_repulsion chem_decay <<< "$args"
echo "==== Exécution de $PATH/$key.mp4"
./planarian_sim.py --output "$PATH/$key.mp4" --default_width "$default_width" --default_height "$default_height" --default_diameter "$default_diameter" --no-csv \
--count "$count" --length "$length" --width "$width" --duration "$duration" --fps "$fps" --seed "$seed" \
--bg-color "$bg_color" --arena-color "$arena_color" --arena-border "$arena_border" --shadow-color "$shadow_color" \
--body-color "$body_color" --body-dark "$body_dark" --body-light "$body_light" --head-color "$head_color" \
--thresh-immobile "$thresh_immobile" --thresh-mobile "$thresh_mobile" --thigmotaxis "$thigmotaxis" \
--photo-mode "$photo_mode" --photo-strength "$photo_strength" --photo-x "$photo_x" --photo-y "$photo_y" --photo-sine-freq "$photo_sine_freq" --photo-radius "$photo_radius" \
--chemo-strength "$chemo_strength" --chemo-x "$chemo_x" --chemo-y "$chemo_y" --chemo-radius "$chemo_radius" \
--avoid-strength "$avoid_strength" --avoid-radius "$avoid_radius" --aggreg-strength "$aggreg_strength" --aggreg-radius "$aggreg_radius" \
--chem-repulsion "$chem_repulsion" --chem-decay "$chem_decay"
done
}
if [ "$MODE" = "all" ]; then
export_video ALL
else
export_video DEFAULT
fi
+130 -25
View File
@@ -21,11 +21,14 @@ import logging
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from typing import Optional, Callable, TYPE_CHECKING from typing import Optional, Callable, TYPE_CHECKING
from asgiref.sync import async_to_sync
from django.conf import settings from django.conf import settings
from modules.planarian_tracker import PlanarianTracker from modules.planarian_tracker import PlanarianTracker
from modules.planarian_metrics import ExperimentParams, EthoVisionMetrics
from modules.tube_aligner import TubeAligner from modules.tube_aligner import TubeAligner
if TYPE_CHECKING: if TYPE_CHECKING:
from .circular_crop import CircularCrop # Evite l'import circulaire au runtime from .circular_crop import CircularCrop # Evite l'import circulaire au runtime
@@ -49,16 +52,27 @@ class VideoCaptureInterface(abc.ABC):
# Cadence par défaut en images par seconde # Cadence par défaut en images par seconde
DEFAULT_FPS: float = 5.0 DEFAULT_FPS: float = 5.0
def __init__(self, fps: float = DEFAULT_FPS, use_tracking: bool = False, display=None, parent=None): DEFAULT_TRACKER_CONFIG = dict(
tube_axis = settings.TRACKER_TUBE_AXIS,
min_area_px = settings.TRACKER_MIN_AREA,
max_area_ratio = settings.TRACKER_MAX_AREA_RATIO,
max_planarians = settings.TRACKER_MAX_PLANARIANS,
merge_kernel_size = settings.TRACKER_MERGE_KERNEL_SIZE,
min_contour_dist_px = settings.TRACKER_MIN_CONTOUR_DIST_PX,
)
def __init__(self, fps: float = DEFAULT_FPS, use_tracking: bool = False, display=None, parent=None, jpeg_quality=85):
""" """
Initialise l'interface de capture. Initialise l'interface de capture.
:param fps: Cadence cible en images par seconde :param fps: Cadence cible en images par seconde
""" """
self._fps: float = fps self._fps: float = fps
self.use_tracking = use_tracking
self.display = display self.display = display
self.parent = parent self.parent = parent
self.use_tracking = use_tracking
self.jpeg_quality = jpeg_quality
self._interval: float = 1.0 / fps # Intervalle en secondes entre chaque capture self._interval: float = 1.0 / fps # Intervalle en secondes entre chaque capture
self._running: bool = False # Indique si la capture est en cours self._running: bool = False # Indique si la capture est en cours
self._thread: Optional[threading.Thread] = None self._thread: Optional[threading.Thread] = None
@@ -67,12 +81,17 @@ class VideoCaptureInterface(abc.ABC):
self._circular_crop: Optional["CircularCrop"] = None # Recadrage circulaire optionnel self._circular_crop: Optional["CircularCrop"] = None # Recadrage circulaire optionnel
self._active_median = False self._active_median = False
self._active_crop = False self._active_crop = False
self._active_edge_enhance = False
self._error_occured = False self._error_occured = False
self._tracker = PlanarianTracker( self._tracker: PlanarianTracker | None = None
tube_axis = settings.TRACKER_TUBE_AXIS, self._metrics: list[EthoVisionMetrics] | None = None
min_area_px = settings.TRACKER_MIN_AREA, self._params: ExperimentParams | None = None
) self._clientDB = self.parent.metricDB
# Tracker générique, pour simulation
self.on_test_well_change(**self.DEFAULT_TRACKER_CONFIG)
self._aligner = TubeAligner( self._aligner = TubeAligner(
grbl_threshold_px = 20, # au-delà → correction GRBL grbl_threshold_px = 20, # au-delà → correction GRBL
dead_zone_px = 5, # en-dessous → rien à faire dead_zone_px = 5, # en-dessous → rien à faire
@@ -80,13 +99,66 @@ class VideoCaptureInterface(abc.ABC):
) )
self.align_detection = None # résultat du test self.align_detection = None # résultat du test
def on_well_change(self):
"""
Appelé par le CNC lors du changement de puits.
Réinitialise le fond appris et l'état inter-frame du tracker.
"""
self._tracker.reset()
def on_test_well_change(self, **cfg):
try:
if self.use_tracking and cfg:
self._tracker = PlanarianTracker(**cfg)
logger.info(f"Create test with conf: {cfg}")
except Exception as e:
logger.error(f"Error creating tracker with conf {cfg}: {e}")
self._tracker = None
def _flush_current_well(self, uuid=""):
"""Stocke les résumés du puits courant — appelé avant tout changement."""
if not self._metrics or not self._params:
return
for pid, m in enumerate(self._metrics):
async_to_sync(self._clientDB.store_summary)(
summary = m.summary(),
experiment = self._params.experiment,
well = self._params.well,
planarian = pid,
uuid = uuid,
)
logger.warning(f'_flush_current_well: {self._params.well} planaire: {pid}')
self._metrics.clear()
def on_well_change(self, cfg, uuid="", draw_contours=False):
"""
Appelé par la CNC lors du changement de puits.
Réinitialise le fond appris et l'état inter-frame du tracker.
Construit les métriques aussi
"""
if not self.use_tracking or not cfg:
return
# 1. Sauvegarder les résumés du puits qu'on quitte
self._flush_current_well(uuid) # ← ferme le puits courant
# 2. Reconstruire pour le nouveau puit _metrics_list
params = cfg.to_params_dict()
self._params = ExperimentParams(params)
self._metrics = [self._params.build_metrics() for _ in range(self._params.planarian_count)]
self._tracker = PlanarianTracker(
tube_axis = self._params.tube_axis,
min_area_px = self._params.min_area_px,
max_area_ratio = self._params.max_area_ratio,
max_planarians = self._params.planarian_count,
merge_kernel_size = self._params.merge_kernel_size,
min_contour_dist_px = self._params.min_contour_dist_px,
draw_contours = draw_contours,
)
def on_scan_complete(self):
if self.use_tracking:
self._flush_current_well() # ← ferme le dernier puits
def set_draw_contours(self, draw: bool = True):
if self._tracker:
self._tracker.draw_contours = draw
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Méthodes abstraites — obligatoires dans les sous-classes # Méthodes abstraites — obligatoires dans les sous-classes
@@ -213,10 +285,30 @@ class VideoCaptureInterface(abc.ABC):
msg= f"{self.__class__.__name__}: recadrage circulaire désactivé" msg= f"{self.__class__.__name__}: recadrage circulaire désactivé"
logger.info(msg) logger.info(msg)
if self.display is not None:
self.display(state='circular_crop', msg=msg) 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. Applique le post-traitement configuré sur une image brute.
@@ -227,30 +319,47 @@ class VideoCaptureInterface(abc.ABC):
:return: Image traitée (JPEG ou PNG selon la stratégie) :return: Image traitée (JPEG ou PNG selon la stratégie)
""" """
metrics = {"detected": False} metrics = {"detected": False}
if self._circular_crop is not None: if self._circular_crop is not None:
jpeg = self._circular_crop.process(jpeg_bytes) jpeg = self._circular_crop.process(jpeg_bytes)
nparr = np.frombuffer(jpeg, np.uint8) nparr = np.frombuffer(jpeg, np.uint8)
frame = cv2.imdecode(nparr, cv2.IMREAD_COLOR) frame = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
if frame is None: if frame is None:
return jpeg, metrics return jpeg, metrics
try:
# Mode debug # 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: if self._aligner.debug:
self.align_detection = self._aligner.detect_tube(frame) self.align_detection = self._aligner.detect_tube(frame)
annotated = self.align_detection.get('frame_annotated') annotated = self.align_detection.get('frame_annotated')
frame = annotated if annotated is not None else frame frame = annotated if annotated is not None else frame
##
# mode racking # mode tracking
if self.use_tracking: if self.use_tracking:
ts = datetime.now(timezone.utc).timestamp() ts = datetime.now(timezone.utc).timestamp()
frame, metrics = self._tracker.process(frame, ts) frame, metrics = self._tracker.process(frame, ts)
##
ok, buf = cv2.imencode(".jpg", frame, [cv2.IMWRITE_JPEG_QUALITY, 85]) ok, buf = cv2.imencode(".jpg", frame, [cv2.IMWRITE_JPEG_QUALITY, self.jpeg_quality])
if ok: if ok:
jpeg = buf.tobytes() jpeg = buf.tobytes()
return jpeg, metrics return jpeg, metrics
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 return jpeg_bytes, metrics
def save_frame(self, jpeg_bytes: bytes, directory: str = ".", prefix: str = "frame") -> Path: def save_frame(self, jpeg_bytes: bytes, directory: str = ".", prefix: str = "frame") -> Path:
@@ -325,16 +434,12 @@ class VideoCaptureInterface(abc.ABC):
## ##
jpeg, metrics = self.process_frame(jpeg) # Recadrage circulaire si configuré jpeg, metrics = self.process_frame(jpeg) # Recadrage circulaire si configuré
metrics.update({
"count": self._frame_count,
})
self._frame_count += 1 self._frame_count += 1
ts = datetime.now(timezone.utc) ts = datetime.now(timezone.utc)
if self._on_frame: if self._on_frame:
try: try:
self._on_frame(jpeg, ts, metrics) self._on_frame(jpeg, ts, metrics, self._frame_count)
except Exception as cb_err: # noqa: BLE001 except Exception as cb_err: # noqa: BLE001
logger.error("Erreur dans le callback image : %s", cb_err) logger.error("Erreur dans le callback image : %s", cb_err)
+1 -1
View File
@@ -66,7 +66,7 @@ class CircularCrop:
# Cache du masque pour éviter de le recalculer à chaque frame # Cache du masque pour éviter de le recalculer à chaque frame
self._mask_cache: Optional[np.ndarray] = None 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 # API publique
+7 -7
View File
@@ -12,6 +12,7 @@ import logging
import serial import serial
import time import time
import threading import threading
from typing import Callable, Any
logging.basicConfig(level=logging.INFO) logging.basicConfig(level=logging.INFO)
@@ -41,11 +42,10 @@ class GRBLController:
if y_max is not None: if y_max is not None:
self.Y_MAX = y_max self.Y_MAX = y_max
self._state = send_callback self._state: Callable[..., Any] = send_callback if send_callback is not None else self._send_msg
if self._state is None:
self._state = self._send_msg
self.x, self.y = 0, 0 self.x: float | None = None
self.y: float | None = None
#self.start_connection() #self.start_connection()
@@ -67,8 +67,8 @@ class GRBLController:
try: try:
self.ser = serial.Serial(self.port, self.baudrate, timeout=self.timeout, exclusive=True) self.ser = serial.Serial(self.port, self.baudrate, timeout=self.timeout, exclusive=True)
# CRITIQUE : # CRITIQUE :
self.ser.setDTR(False) self.ser.setDTR(False) # type: ignore[attr-defined]
self.ser.setRTS(False) self.ser.setRTS(False) # type: ignore[attr-defined]
self.clear_buffer() self.clear_buffer()
self._wake_up() self._wake_up()
@@ -192,7 +192,7 @@ class GRBLController:
def move_relative(self, dx=0, dy=0, feed=1000): def move_relative(self, dx=0, dy=0, feed=1000):
x, y = self.get_mpos() # Position actuelle 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): def move_relative__(self, dx=0, dy=0, feed=1000):
self.send("G91") # Mode relatif self.send("G91") # Mode relatif
+300
View File
@@ -0,0 +1,300 @@
'''
Simulateur GCode pour tester sans CNC physique.
GRBLController (simulé):
Reproduit fidèlement l'API de grbl.py
Simule les mouvements (X, Y) avec délai proportionnel au feed rate
Le mode absolu est retenu
Aucune dépendance à pyserial
Created on 07 mai 2026
@author: denis@miraceti.net
'''
import logging
import time
import threading
import math
from typing import Callable, Any
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
class GRBLController:
'''
Simulateur du contrôleur GRBL 1.1f (L2544 Laser Engraving Machine).
API 100% identique à grbl.py — interchangeable sans modifier le code appelant.
Les délais de déplacement sont calculés à partir du feed rate et de la distance.
'''
X_MAX = 350
Y_MAX = 250
X_MIN = 0
Y_MIN = 0
# Facteur de compression du temps simulé (1.0 = temps réel, 0.1 = 10x plus rapide)
TIME_SCALE = 0.1
def __init__(self, port='/dev/ttyUSB0', baudrate=115200, timeout=1, send_callback=None, x_max=None, y_max=None):
logger.info(f"GRBLController SIMULATOR::init begin {port} device port")
self.port = port
self.baudrate = baudrate
self.timeout = timeout
if x_max is not None:
self.X_MAX = x_max
if y_max is not None:
self.Y_MAX = y_max
self._state: Callable[..., Any] = send_callback if send_callback is not None else self._send_msg
# Position courante simulée
self.x: float | None = None
self.y: float | None = None
# État interne de la machine simulée
self._machine_state = 'Idle' # Idle | Run | Alarm
self._connected = False
# -------------------------------------------------------------------------
# Méthodes utilitaires
# -------------------------------------------------------------------------
def wait_for(self, delay=1.0):
# Applique le facteur de compression temporelle
threading.Event().wait(delay * self.TIME_SCALE)
def _send_msg(self, **msg):
# Callback par défaut : simple affichage console
print(msg)
# -------------------------------------------------------------------------
# Simulation de la couche série (pas de port réel)
# -------------------------------------------------------------------------
def clear_buffer(self):
# Rien à vider : pas de port série physique
logger.debug("SIMULATOR::clear_buffer (no-op)")
def start_connection(self):
'''Simule l'ouverture de la connexion série et l'initialisation GRBL.'''
logger.info(f"SIMULATOR::start_connection on {self.port} @ {self.baudrate} baud")
self._state(state='serial', msg="Grbl 1.1f ['$' for help]")
self._connected = True
self._wake_up()
self._init_machine()
logger.info("SIMULATOR::start_connection started")
def _init_machine(self):
# Envoie les commandes d'initialisation (simulées)
self.send("G21") # Unités en mm
self.send("G90") # Mode absolu
def _clamp(self, x, y):
self.clear_buffer()
x = max(self.X_MIN, min(self.X_MAX, x))
y = max(self.Y_MIN, min(self.Y_MAX, y))
return x, y
def _wake_up(self):
# Simule l'envoi des octets de réveil et la réponse GRBL
logger.debug("SIMULATOR::_wake_up")
self.wait_for(1)
self._state(state='serial', msg="") # ligne vide typique de GRBL au démarrage
self.clear_buffer()
# -------------------------------------------------------------------------
# Envoi de commandes
# -------------------------------------------------------------------------
def send(self, cmd, wait_ok=True, timeout=5):
try:
return self._send(cmd, wait_ok, timeout)
except Exception as e:
self._state(state='error', msg=f"Error send {cmd} command: {e}")
self.close()
self.start_connection()
def recover(self):
self._state(state='recover', msg="Erreur, récupération de GRBL...")
self.wait_for(1)
self._wake_up()
def _send(self, cmd, wait_ok=True, timeout=5):
'''Simule l'envoi d'une commande GCode et retourne "ok".'''
self._state(state='send', msg=f">>> {cmd}")
logger.debug(f"SIMULATOR::_send {cmd}")
# Interprète les commandes de mouvement pour mettre à jour la position interne
self._interpret_gcode(cmd)
if not wait_ok:
return None
# Simule une réponse "ok" immédiate
return "ok"
def _interpret_gcode(self, cmd):
'''
Analyse le GCode pour mettre à jour x, y et simuler le délai de déplacement.
Gère : G0, G1, G53 G1, G92, G21, G90, G91, $X, $H.
'''
cmd_upper = cmd.strip().upper()
# --- Commandes sans mouvement ---
if cmd_upper in ("G21", "G90", "G91", "$X"):
return
if cmd_upper == "$H":
# Homing : retour à l'origine avec délai simulé
self._machine_state = 'Run'
self._state(state='send', msg="SIMULATOR: homing...")
distance = math.hypot(self.x 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'
return
# --- Extraction des coordonnées X, Y et du feed F ---
tokens = cmd_upper.replace(',', ' ').split()
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'):
try:
new_x = float(token[1:])
except ValueError:
pass
elif token.startswith('Y'):
try:
new_y = float(token[1:])
except ValueError:
pass
elif token.startswith('F'):
try:
feed = float(token[1:])
except ValueError:
pass
# --- G92 : redéfinit la position courante sans déplacement ---
if 'G92' in tokens:
self.x = new_x
self.y = new_y
logger.debug(f"SIMULATOR: G92 position set to ({self.x:.2f}, {self.y:.2f})")
return
# --- Mouvement effectif (G0, G1, G53 G1, etc.) ---
has_move = any(t in tokens for t in ('G0', 'G1', 'G53'))
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
self.y = new_y
self._machine_state = 'Idle'
logger.debug(f"SIMULATOR: moved to ({self.x:.2f}, {self.y:.2f})")
def _simulate_move_delay(self, distance_mm, feed):
'''Simule le temps de déplacement : distance / feed (mm/min) → secondes.'''
if feed <= 0:
return
duration = (distance_mm / feed) * 60.0 # feed est en mm/min
self.wait_for(duration)
# -------------------------------------------------------------------------
# Status machine
# -------------------------------------------------------------------------
def get_status(self):
'''Retourne un status GRBL simulé au format <State|MPos:x,y,z>.'''
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
def reset_grbl(self):
self.send("$X") # Réinitialise les alarmes
self.wait_idle()
self.send("$H") # Homing
self.wait_idle()
def _mpos(self, status):
if "MPos" in status:
mpos = status.split("MPos:")[1].split("|")[0]
x, y, *_ = mpos.split(",")
self._state(state='Mpos', msg=f"pos >>> ({x}, {y})")
return float(x), float(y)
return None, None
def get_mpos(self):
return self._mpos(self.get_status())
def wait_idle(self, timeout=20):
'''Attend que la machine soit à l'état Idle (immédiat en simulation).'''
start = time.time()
while True:
if time.time() - start > timeout:
raise TimeoutError("Délai d'attente pour Idle dépassé")
status = self.get_status()
self.x, self.y = self._mpos(status)
self._state(xy=True, x=self.x, y=self.y)
if status and "Idle" in status:
break
self.wait_for(0.1)
# -------------------------------------------------------------------------
# Commandes de haut niveau (identiques à grbl.py)
# -------------------------------------------------------------------------
def send_command(self, cmd):
self.send(cmd)
self.wait_idle()
def move_to(self, x, y, feed=1000):
x, y = self._clamp(x, y)
cmd = f"G53 G1 X{x:.2f} Y{y:.2f} F{feed}"
self.send_command(cmd)
def move_relative(self, dx=0, dy=0, feed=1000):
x, y = self.get_mpos() # Position actuelle
self.move_to((x 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
cmd = f"G0 X{dx} Y{dy} F{feed}"
self.send(cmd)
self.send("G90") # Retour en mode absolu
self.wait_idle()
def go_origin(self, feed=1000):
self.move_to(0, 0, feed=feed)
self.wait_for(2.0)
def set_position(self, x, y):
x, y = self._clamp(x, y)
cmd = f"G92 X{x:.2f} Y{y:.2f}"
self.send(cmd)
self.wait_for(2.0)
def move_up(self, step=10, feed=1000):
self.move_relative(dy=step, feed=feed)
def move_down(self, step=10, feed=1000):
self.move_relative(dy=-step, feed=feed)
def move_left(self, step=10, feed=1000):
self.move_relative(dx=-step, feed=feed)
def move_right(self, step=10, feed=1000):
self.move_relative(dx=step, feed=feed)
def close(self):
# Simule la fermeture du port série
self._connected = False
logger.info("SIMULATOR::close — connexion simulée fermée")
@@ -59,7 +59,7 @@ class PiCamera2Capture(VideoCaptureInterface):
:param use_video_config: True = VideoConfiguration (flux continu, basse latence) :param use_video_config: True = VideoConfiguration (flux continu, basse latence)
False = StillConfiguration (haute résolution, plus lent) False = StillConfiguration (haute résolution, plus lent)
""" """
super().__init__(fps=fps, use_tracking=use_tracking, display=display, parent=parent) super().__init__(fps=fps, use_tracking=use_tracking, display=display, parent=parent, jpeg_quality=jpeg_quality)
self._width: int = width self._width: int = width
self._height: int = height self._height: int = height
self._jpeg_quality: int = jpeg_quality self._jpeg_quality: int = jpeg_quality
File diff suppressed because it is too large Load Diff
+506 -205
View File
@@ -1,36 +1,221 @@
# modules/planarian_tracker.py """
''' modules/planarian_tracker.py
Created on 16 avr. 2026
Détection et suivi multi-individus de planaires dans un tube.
Supporte de 1 à MAX_PLANARIANS planaires par tube.
Stratégie :
- Soustraction de fond MOG2 (léger sur Raspberry Pi 4)
- Détection de tous les contours valides (surface >= min_area_px)
- Association frame-à-frame par distance euclidienne minimale
via algorithme hongrois (scipy.optimize.linear_sum_assignment)
- Un état inter-frame indépendant par individu (PlanarianState)
- Retourne une liste de résultats, un par individu suivi
Created on 25 avr. 2026
@author: denis @author: denis
''' """
import cv2 import cv2
import logging
import numpy as np import numpy as np
from scipy.optimize import linear_sum_assignment
import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Nombre maximum de planaires suivis simultanément par tube
MAX_PLANARIANS = 10
# Distance maximale en pixels entre deux positions consécutives
# pour qu'une association soit acceptée (évite les sauts aberrants)
MAX_ASSOC_DIST_PX = 80
# Couleurs d'annotation par individu (BGR)
# Cycle automatique si plus de planaires que de couleurs
INDIVIDUAL_COLORS = [
(255, 255, 0), # cyan
( 0, 165, 255), # orange
(255, 0, 255), # magenta
( 0, 255, 255), # jaune
(128, 0, 255), # violet
( 0, 255, 128), # vert clair
(255, 128, 0), # bleu clair
( 0, 128, 255), # orange foncé
(128, 255, 0), # vert-jaune
(255, 0, 128), # rose
]
# Couleur du contour principal (individu le plus grand)
COLOR_LARGEST = (255, 255, 0) # cyan
COLOR_OTHER = ( 0, 255, 0) # vert
COLOR_CENTER = ( 0, 0, 255) # rouge
# ---------------------------------------------------------------------------
# État inter-frame d'un individu
# ---------------------------------------------------------------------------
class PlanarianState:
"""
Mémorise la position et le timestamp de la dernière détection
pour un planaire individuel.
Un PlanarianState par slot (index 0 à max_planarians-1).
Quand un slot n'est pas associé à un contour sur plusieurs frames
consécutives, il est marqué comme perdu (lost).
"""
# Nombre de frames sans détection avant de considérer l'individu perdu
MAX_LOST_FRAMES = 5
def __init__(self, idx: int):
"""
Args:
idx : index de l'individu (0-based)
"""
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):
"""
Met à jour la position suite à une association réussie.
Args:
cx, cy : position du centre de masse en pixels
ts : timestamp de la frame
"""
self.cx = cx
self.cy = cy
self.ts = ts
self.lost = 0
self.active = True
def mark_lost(self):
"""Incrémente le compteur de perte — appelé quand aucun contour n'est associé."""
self.lost += 1
@property
def is_lost(self) -> bool:
"""Retourne True si l'individu est considéré perdu (trop de frames sans détection)."""
return self.lost >= self.MAX_LOST_FRAMES
def compute_speed(self, cx: int, cy: int, ts: float, tube_axis: str) -> tuple:
"""
Calcule la vitesse instantanée depuis la position précédente.
Args:
cx, cy : position courante en pixels
ts : timestamp courant
tube_axis : "vertical" ou "horizontal"
Returns:
tuple (speed_px_s, axial_speed) ou (0.0, 0.0) si état vide
"""
if self.cx is None or self.cy is None or self.ts is None:
return 0.0, 0.0
dt = ts - self.ts
if dt <= 0:
return 0.0, 0.0
dx = cx - self.cx
dy = cy - self.cy
speed_px_s = float(np.sqrt(dx**2 + dy**2) / dt)
axial_speed = float((dy / dt) if tube_axis == "vertical" else (dx / dt))
return speed_px_s, axial_speed
def reset(self):
"""Réinitialise l'état de cet individu."""
self.cx = None
self.cy = None
self.ts = None
self.lost = 0
self.active = False
# ---------------------------------------------------------------------------
# Tracker multi-individus
# ---------------------------------------------------------------------------
class PlanarianTracker: class PlanarianTracker:
""" """
Détection et suivi d'une planaire dans un tube. Détection et suivi multi-individus de planaires dans un tube.
Instancié une fois par caméra active, réutilisé frame à frame. Instancié une fois par caméra active, réutilisé frame à frame.
Utilise la soustraction de fond MOG2 — léger sur Raspberry Pi 4. Utilise la soustraction de fond MOG2 — léger sur Raspberry Pi 4.
Association frame-à-frame par algorithme hongrois (distance euclidienne).
Usage :
tracker = PlanarianTracker(tube_axis="vertical", max_planarians=3)
while capturing:
frame_out, results = tracker.process(frame, ts)
# results : liste de dicts, un par individu détecté
for r in results:
metrics.update(r, planarian_id=r["planarian_id"])
""" """
def __init__(self, tube_axis: str = "vertical", min_area_px: int = 20): # Nombre de frames d'initialisation MOG2 ignorées (fond non appris)
# Axe du tube : "vertical" (cy) ou "horizontal" (cx) WARMUP_FRAMES = 10
def __init__(
self,
tube_axis: str = "vertical",
min_area_px: int = 20,
max_area_ratio: float = 0.10,
max_planarians: int = 1,
merge_kernel_size: int = 15,
min_contour_dist_px:int = 40,
draw_contours: bool = True,
):
"""
Args:
tube_axis : axe principal du tube — "vertical" (cy) ou "horizontal" (cx)
min_area_px : surface minimale d'un contour pour être considéré valide (px²)
max_area_ratio : surface maximale d'un contour en fraction de la frame (défaut 10%)
filtre les faux positifs du fond non encore appris par MOG2
max_planarians : nombre maximum de planaires à suivre simultanément (1-10)
merge_kernel_size : taille du kernel elliptique de fusion des fragments (px).
Régler ≈ largeur du planaire en pixels. Défaut : 15.
min_contour_dist_px : distance min entre deux contours pour les considérer
comme individus distincts. Défaut : 40px.
"""
self.tube_axis = tube_axis self.tube_axis = tube_axis
self.min_area_px = min_area_px self.min_area_px = min_area_px
self.max_area_ratio = max_area_ratio
self.max_planarians = max(1, min(max_planarians, MAX_PLANARIANS))
self.draw_contours = draw_contours
# Etat inter-frame # Un état inter-frame par slot individu
self._prev_cx = None self._states = [PlanarianState(i) for i in range(self.max_planarians)]
self._prev_cy = None
self._prev_ts = None # Taille du kernel de fusion morphologique (pixels) —
# doit être proche de la largeur du planaire en pixels.
# Trop petit : fragments non fusionnés → IDs multiples.
# Trop grand : deux planaires proches fusionnés en un seul.
self.merge_kernel_size = merge_kernel_size
# Distance minimale en pixels entre deux contours distincts.
# En-dessous : le plus petit est considéré comme fragment du plus grand.
self.min_contour_dist_px = min_contour_dist_px
# Soustracteur de fond adaptatif MOG2 # Soustracteur de fond adaptatif MOG2
self._bg_sub = cv2.createBackgroundSubtractorMOG2( self._bg_sub = self._make_bg_sub()
# Compteur de frames d'initialisation — MOG2 retourne du bruit
# pendant les premières WARMUP_FRAMES frames
self._warmup_count = 0
@staticmethod
def _make_bg_sub():
"""Crée et retourne un soustracteur de fond MOG2."""
return cv2.createBackgroundSubtractorMOG2(
history = 50, history = 50,
varThreshold = 25, varThreshold = 25,
detectShadows= False, detectShadows= False,
@@ -38,205 +223,321 @@ class PlanarianTracker:
def reset(self): def reset(self):
""" """
Réinitialise l'état inter-frame — appeler lors du changement de puits. Réinitialise l'état inter-frame complet.
À appeler lors du changement de puits.
""" """
self._prev_cx = None for s in self._states:
self._prev_cy = None s.reset()
self._prev_ts = None self._bg_sub = self._make_bg_sub()
# Réinitialise le fond appris self._warmup_count = 0
self._bg_sub = cv2.createBackgroundSubtractorMOG2( # Les paramètres morphologiques (merge_kernel_size, min_contour_dist_px)
history = 50, # sont conservés — ils ne dépendent pas du puits
varThreshold = 25,
detectShadows= False,
)
'''
def process(self, frame: np.ndarray, ts: float) -> dict:
"""
Analyse une frame décodée numpy.
Retourne un dict de métriques attachable aux labels ReductStore.
:param frame: Frame BGR décodée (numpy array)
:param ts: Timestamp epoch secondes (float)
:return: dict métriques
"""
result = self._empty_result(ts)
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
fg_mask = self._bg_sub.apply(gray)
# Nettoyage morphologique du masque
kernel = np.ones((3, 3), np.uint8)
fg_mask = cv2.morphologyEx(fg_mask, cv2.MORPH_OPEN, kernel)
fg_mask = cv2.morphologyEx(fg_mask, cv2.MORPH_CLOSE, kernel)
contours, _ = cv2.findContours(
fg_mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE
)
if not contours:
self._update_prev(None, None, ts)
return result
# Plus grand contour = planaire
largest = max(contours, key=cv2.contourArea)
area = cv2.contourArea(largest)
if area < self.min_area_px:
self._update_prev(None, None, ts)
return result
# Centre de masse
M = cv2.moments(largest)
if M["m00"] == 0:
return result
cx = int(M["m10"] / M["m00"])
cy = int(M["m01"] / M["m00"])
h, w = frame.shape[:2]
# Position normalisée sur l'axe du tube (0.0 → 1.0)
axial_pos = (cy / h) if self.tube_axis == "vertical" else (cx / w)
# Vitesse calculée entre frames
speed_px_s = None
axial_speed = None
if self._prev_cx is not None and self._prev_ts is not None:
dt = ts - self._prev_ts
if dt > 0:
dx = cx - self._prev_cx
dy = cy - self._prev_cy
speed_px_s = float(np.sqrt(dx**2 + dy**2) / dt)
# Vitesse signée sur l'axe du tube
# + = vers bas/droite, - = vers haut/gauche
axial_speed = float((dy / dt) if self.tube_axis == "vertical" else (dx / dt))
result.update({
"detected" : True,
"cx" : cx,
"cy" : cy,
"area_px" : int(area),
"speed_px_s" : round(speed_px_s, 3) if speed_px_s is not None else 0.0,
"axial_speed" : round(axial_speed, 3) if axial_speed is not None else 0.0,
"axial_pos" : round(axial_pos, 4),
})
self._update_prev(cx, cy, ts)
return result
'''
def process(self, frame: np.ndarray, ts: float) -> tuple[np.ndarray, dict]:
"""
Analyse une frame et dessine les contours détectés directement sur l'image.
Retourne (frame_annotée, métriques).
Contours fins Vert (0,255,0) Tous les contours valides détectés
Contour épais Cyan (255,255,0) Planaire principale (plus grand contour)
Croix + cercle Rouge (0,0,255) Centre de masse exact
Texte Blanc Vitesse px/s + position axiale normalisée
"""
result = self._empty_result(ts)
frame_out = frame.copy() # copie pour ne pas modifier l'original
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
fg_mask = self._bg_sub.apply(gray)
kernel = np.ones((3, 3), np.uint8)
fg_mask = cv2.morphologyEx(fg_mask, cv2.MORPH_OPEN, kernel)
fg_mask = cv2.morphologyEx(fg_mask, cv2.MORPH_CLOSE, kernel)
contours, _ = cv2.findContours(
fg_mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE
)
if not contours:
self._update_prev(None, None, ts)
return frame_out, result
# Filtre les contours significatifs
valid_contours = [c for c in contours if cv2.contourArea(c) >= self.min_area_px]
if not valid_contours:
self._update_prev(None, None, ts)
return frame_out, result
# Dessine tous les contours valides en vert fin
cv2.drawContours(frame_out, valid_contours, -1, (0, 255, 0), 1)
# Plus grand contour = planaire principale
largest = max(valid_contours, key=cv2.contourArea)
area = cv2.contourArea(largest)
# Contour principal en cyan plus épais
cv2.drawContours(frame_out, [largest], -1, (255, 255, 0), 2)
M = cv2.moments(largest)
if M["m00"] == 0:
return frame_out, result
cx = int(M["m10"] / M["m00"])
cy = int(M["m01"] / M["m00"])
h, w = frame.shape[:2]
axial_pos = (cy / h) if self.tube_axis == "vertical" else (cx / w)
speed_px_s = None
axial_speed = None
if self._prev_cx is not None and self._prev_ts is not None:
dt = ts - self._prev_ts
if dt > 0:
dx = cx - self._prev_cx
dy = cy - self._prev_cy
speed_px_s = float(np.sqrt(dx**2 + dy**2) / dt)
axial_speed = float((dy / dt) if self.tube_axis == "vertical" else (dx / dt))
# Croix sur le centre de masse
cross_size = 8
cv2.line(frame_out, (cx - cross_size, cy), (cx + cross_size, cy), (0, 0, 255), 1)
cv2.line(frame_out, (cx, cy - cross_size), (cx, cy + cross_size), (0, 0, 255), 1)
# Cercle centré sur la planaire
cv2.circle(frame_out, (cx, cy), 12, (0, 0, 255), 1)
# Texte vitesse + position axiale
label = f"v={speed_px_s:.1f}px/s ax={axial_pos:.2f}" if speed_px_s is not None else f"ax={axial_pos:.2f}"
cv2.putText(
frame_out, label,
(max(cx - 60, 0), max(cy - 18, 12)),
cv2.FONT_HERSHEY_SIMPLEX, 0.4, (255, 255, 255), 1, cv2.LINE_AA,
)
result.update({
"detected" : True,
"cx" : cx,
"cy" : cy,
"area_px" : int(area),
"speed_px_s" : round(speed_px_s, 3) if speed_px_s is not None else 0.0,
"axial_speed" : round(axial_speed, 3) if axial_speed is not None else 0.0,
"axial_pos" : round(axial_pos, 4),
})
self._update_prev(cx, cy, ts)
return frame_out, result
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
def _empty_result(self, ts: float) -> dict: # Interface principale
return { # ------------------------------------------------------------------ #
def process(self, frame: np.ndarray, ts: float) -> tuple:
"""
Analyse une frame, associe les contours aux individus connus,
dessine les annotations et retourne les métriques.
Args:
frame : image BGR (numpy array)
ts : timestamp de la frame (float, secondes epoch)
Returns:
tuple (frame_annotée, results)
frame_annotée : copie BGR avec contours, croix et textes
results : liste de dicts — un dict par planaire actif détecté.
Chaque dict contient :
planarian_id int index de l'individu (0-based)
detected bool True si détecté cette frame
cx, cy int centre de masse en pixels
area_px int surface du contour (px²)
speed_px_s float vitesse totale (px/s)
axial_speed float vitesse axiale (px/s)
axial_pos float position axiale normalisée (0-1)
timestamp float ts de la frame
"""
frame_out = frame.copy()
h, w = frame.shape[:2]
# --- Extraction du premier plan ---
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
fg_mask = self._bg_sub.apply(gray)
# --- Morphologie : fusion des fragments du corps ---
# Un planaire ondulant est souvent segmenté en plusieurs contours
# (tête, milieu, queue). La dilatation fusionne les fragments proches
# avant la détection des contours.
# Kernel 3×3 : supprime le bruit fin (OPEN)
# Kernel merge_kernel : fusionne les fragments du corps (CLOSE + DILATE)
noise_kernel = np.ones((3, 3), np.uint8)
merge_kernel = cv2.getStructuringElement(
cv2.MORPH_ELLIPSE, (self.merge_kernel_size, self.merge_kernel_size)
)
fg_mask = cv2.morphologyEx(fg_mask, cv2.MORPH_OPEN, noise_kernel)
fg_mask = cv2.morphologyEx(fg_mask, cv2.MORPH_CLOSE, merge_kernel)
fg_mask = cv2.dilate(fg_mask, merge_kernel, iterations=1)
# Warmup MOG2 : les premières WARMUP_FRAMES frames retournent du bruit
# (fond non encore appris) — on les alimente mais on ne détecte rien
self._warmup_count += 1
if self._warmup_count <= self.WARMUP_FRAMES:
return frame_out, []
contours, _ = cv2.findContours(
fg_mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE
)
# Surface maximale admissible : fraction de la frame
# Filtre les faux positifs du fond (contours couvrant toute l'image)
max_area_px = h * w * self.max_area_ratio
# Filtrage : surface min ET surface max, triés par surface décroissante
valid = sorted(
[c for c in contours
if self.min_area_px <= cv2.contourArea(c) <= max_area_px],
key=cv2.contourArea,
reverse=True,
)
# --- Suppression des fragments résiduels trop proches ---
# Si plusieurs contours valides ont leur centre à moins de
# min_contour_dist_px les uns des autres, seul le plus grand est conservé.
# Évite les cas où la fusion morphologique est incomplète.
filtered = []
for c in valid:
M = cv2.moments(c)
if M["m00"] == 0:
continue
cx_c = int(M["m10"] / M["m00"])
cy_c = int(M["m01"] / M["m00"])
too_close = False
for kept in filtered:
Mk = cv2.moments(kept)
if Mk["m00"] == 0:
continue
cx_k = int(Mk["m10"] / Mk["m00"])
cy_k = int(Mk["m01"] / Mk["m00"])
dist = np.sqrt((cx_c - cx_k)**2 + (cy_c - cy_k)**2)
if dist < self.min_contour_dist_px:
too_close = True
break
if not too_close:
filtered.append(c)
# Limiter au nombre maximum de planaires attendus
valid = filtered[:self.max_planarians]
# --- Calcul des centres de masse des contours détectés ---
detections = [] # liste de (cx, cy, area, contour)
for c in valid:
M = cv2.moments(c)
if M["m00"] == 0:
continue
cx = int(M["m10"] / M["m00"])
cy = int(M["m01"] / M["m00"])
area = cv2.contourArea(c)
detections.append((cx, cy, int(area), c))
# --- Association hongroise détections → slots individus ---
assignments = self._hungarian_assign(detections)
# --- Mise à jour des états et construction des résultats ---
results = []
for slot_idx, det_idx in assignments.items():
state = self._states[slot_idx]
if det_idx is None:
# Aucune détection associée à ce slot
state.mark_lost()
if state.active and not state.is_lost:
# L'individu était suivi : on retourne un résultat "perdu"
results.append(self._lost_result(slot_idx, ts))
continue
cx, cy, area, contour = detections[det_idx]
# Calcul de la vitesse depuis la position précédente
speed_px_s, axial_speed = state.compute_speed(cx, cy, ts, self.tube_axis)
axial_pos = (cy / h) if self.tube_axis == "vertical" else (cx / w)
# Mise à jour de l'état
state.update(cx, cy, ts)
if self.draw_contours:
# Annotation visuelle
color = INDIVIDUAL_COLORS[slot_idx % len(INDIVIDUAL_COLORS)]
cv2.drawContours(frame_out, [contour], -1, color, 2)
self._draw_center(frame_out, cx, cy, slot_idx, speed_px_s, axial_pos, color)
results.append({
"planarian_id": slot_idx,
"detected": True,
"cx": cx,
"cy": cy,
"area_px": area,
"speed_px_s": round(speed_px_s, 3),
"axial_speed": round(axial_speed, 3),
"axial_pos": round(axial_pos, 4),
"timestamp": ts, "timestamp": ts,
})
# Marquer les slots non présents dans les assignments comme perdus
assigned_slots = set(assignments.keys())
for state in self._states:
if state.idx not in assigned_slots:
state.mark_lost()
return frame_out, results
# ------------------------------------------------------------------ #
# Association hongroise
# ------------------------------------------------------------------ #
def _hungarian_assign(self, detections: list) -> dict:
"""
Associe les détections courantes aux slots individus connus
via l'algorithme hongrois (coût = distance euclidienne).
Contrainte : une association n'est acceptée que si la distance
est inférieure à MAX_ASSOC_DIST_PX (évite les sauts aberrants).
Args:
detections : liste de (cx, cy, area, contour)
Returns:
dict {slot_idx: det_idx | None}
det_idx = None si aucune détection assignée à ce slot
"""
n_slots = self.max_planarians
n_dets = len(detections)
if n_dets == 0:
# Aucune détection : tous les slots sont "perdus"
return {i: None for i in range(n_slots)}
# Slots actifs (déjà vus au moins une fois et non perdus)
active_slots = [s for s in self._states if s.active and not s.is_lost]
if not active_slots:
# Première frame ou tous perdus : attribution séquentielle simple
assignment = {}
for i in range(n_slots):
assignment[i] = i if i < n_dets else None
return assignment
# --- Construction de la matrice de coût (distance euclidienne) ---
cost = np.full((len(active_slots), n_dets), fill_value=1e6)
for si, state in enumerate(active_slots):
for di, (cx, cy, _, _) in enumerate(detections):
dist = np.sqrt((cx - state.cx)**2 + (cy - state.cy)**2)
cost[si, di] = dist
# --- Algorithme hongrois ---
row_ind, col_ind = linear_sum_assignment(cost)
# Construire le dict d'association
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):
if cost[ri, ci] <= MAX_ASSOC_DIST_PX:
slot_idx = active_slots[ri].idx
assignment[slot_idx] = ci
assigned_dets.add(ci)
# --- Nouvelles détections non assignées → slots inactifs libres ---
free_slots = [s for s in self._states if not s.active or s.is_lost]
new_dets = [di for di in range(n_dets) if di not in assigned_dets]
for state, det_idx in zip(free_slots, new_dets):
assignment[state.idx] = det_idx
return assignment
# ------------------------------------------------------------------ #
# Dessin des annotations
# ------------------------------------------------------------------ #
def _draw_center(
self,
frame: np.ndarray,
cx: int,
cy: int,
idx: int,
speed_px_s: float,
axial_pos: float,
color: tuple,
):
"""
Dessine la croix, le cercle et le label de vitesse/position
pour un individu.
Args:
frame : image à annoter (en place)
cx, cy : centre de masse en pixels
idx : index de l'individu
speed_px_s : vitesse en px/s
axial_pos : position axiale normalisée
color : couleur BGR de l'individu
"""
cross = 8
cv2.line(frame, (cx - cross, cy), (cx + cross, cy), COLOR_CENTER, 1)
cv2.line(frame, (cx, cy - cross), (cx, cy + cross), COLOR_CENTER, 1)
cv2.circle(frame, (cx, cy), 12, color, 1)
# Badge numéro individu
cv2.circle(frame, (cx + 14, cy - 14), 8, color, -1)
cv2.putText(
frame, str(idx),
(cx + 10, cy - 10),
cv2.FONT_HERSHEY_SIMPLEX, 0.35, (0, 0, 0), 1, cv2.LINE_AA,
)
# Texte vitesse + position axiale
label = (
f"#{idx} v={speed_px_s:.1f}px/s ax={axial_pos:.2f}"
if speed_px_s > 0
else f"#{idx} ax={axial_pos:.2f}"
)
cv2.putText(
frame, label,
(max(cx - 60, 0), max(cy - 22, 12)),
cv2.FONT_HERSHEY_SIMPLEX, 0.35, (255, 255, 255), 1, cv2.LINE_AA,
)
# ------------------------------------------------------------------ #
# Résultats vides / perdus
# ------------------------------------------------------------------ #
def _lost_result(self, planarian_id: int, ts: float) -> dict:
"""
Retourne un résultat pour un individu temporairement non détecté.
Args:
planarian_id : index de l'individu
ts : timestamp de la frame courante
Returns:
dict avec detected=False et les dernières coordonnées connues
"""
state = self._states[planarian_id]
return {
"planarian_id": planarian_id,
"detected": False, "detected": False,
"cx" : 0, "cx": state.cx or 0,
"cy" : 0, "cy": state.cy or 0,
"area_px": 0, "area_px": 0,
"speed_px_s": 0.0, "speed_px_s": 0.0,
"axial_speed": 0.0, "axial_speed": 0.0,
"axial_pos": 0.0, "axial_pos": 0.0,
"timestamp": ts,
} }
def _update_prev(self, cx, cy, ts):
self._prev_cx = cx
self._prev_cy = cy
self._prev_ts = ts
+1 -2
View File
@@ -27,12 +27,11 @@ class ReductStoreBase(ABC):
self.bucket: Bucket = asyncio.run(self.create_bucket()) self.bucket: Bucket = asyncio.run(self.create_bucket())
logger.info(f"==== {url} token:{api_token}") logger.info(f"==== {url} token:{api_token}")
async def create_bucket(self): async def create_bucket(self):
settings = BucketSettings( settings = BucketSettings(
quota_type=self.quota_type, quota_type=self.quota_type,
quota_size=self.quota_size, quota_size=self.quota_size,
exist_ok=True,
) )
return await self.client.create_bucket(self.bucket_name, settings, exist_ok=True) return await self.client.create_bucket(self.bucket_name, settings, exist_ok=True)
+1 -1
View File
@@ -111,7 +111,7 @@ def _update_cache():
_timer.start() _timer.start()
def start_background_updater(interval_seconds: int = None): def start_background_updater(interval_seconds: int = 0):
global REFRESH_INTERVAL, _timer global REFRESH_INTERVAL, _timer
if interval_seconds: if interval_seconds:
REFRESH_INTERVAL = interval_seconds REFRESH_INTERVAL = interval_seconds
+21 -6
View File
@@ -29,6 +29,17 @@ class TubeAligner:
self.debug = debug self.debug = debug
self.display = display self.display = display
self.TUBE_DIAMETER_MM = 16.0 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: def set_tube_diameter(self, tube_diameter: float = 16.0) -> None:
@@ -64,14 +75,18 @@ class TubeAligner:
frame_out = frame.copy() frame_out = frame.copy()
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) 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) 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 # 3 configurations légèrement différentes — vote majoritaire
# Fonctionne sur fond sombre ET fond clair
configs = [ configs = [
dict(param1=50, param2=30, minRadius=int(min(w,h)*0.26), maxRadius=int(min(w,h)*0.36)), dict(param1=50, param2=30, minRadius=int(min(w,h)*lo), maxRadius=int(min(w,h)*hi)),
dict(param1=60, param2=30, minRadius=int(min(w,h)*0.26), maxRadius=int(min(w,h)*0.37)), dict(param1=60, param2=28, minRadius=int(min(w,h)*lo), maxRadius=int(min(w,h)*(hi+0.01))),
dict(param1=50, param2=28, minRadius=int(min(w,h)*0.25), maxRadius=int(min(w,h)*0.365)), 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 = [], [], [] all_cx, all_cy, all_r = [], [], []
@@ -93,7 +108,7 @@ class TubeAligner:
if not all_cx: if not all_cx:
msg = f"TubeAligner: aucun cercle détecté ({w}x{h})" msg = f"TubeAligner: aucun cercle détecté ({w}x{h})"
result["msg"] =msg 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) frame_out = self._draw_debug_no_detection(frame_out, cx_img, cy_img)
result["frame_annotated"] = frame_out result["frame_annotated"] = frame_out
return result return result
@@ -120,7 +135,7 @@ class TubeAligner:
else: else:
action = "grbl" action = "grbl"
if self.debug: if self.debug and self.draw_annotations:
frame_out = self._draw_debug( frame_out = self._draw_debug(
frame_out, cx_img, cy_img, frame_out, cx_img, cy_img,
tx, ty, tr, tx, ty, tr,
+3
View File
@@ -70,6 +70,7 @@ def get_tmpfs_info(mount_point="/ramdisk"):
return f"{n:.1f}PB" return f"{n:.1f}PB"
usage = None usage = None
part = None
for part in psutil.disk_partitions(all=True): for part in psutil.disk_partitions(all=True):
if part.mountpoint == mount_point and part.fstype.lower() == "tmpfs": if part.mountpoint == mount_point and part.fstype.lower() == "tmpfs":
usage = psutil.disk_usage(part.mountpoint) usage = psutil.disk_usage(part.mountpoint)
@@ -81,6 +82,7 @@ def get_tmpfs_info(mount_point="/ramdisk"):
print(f" Free: {usage.free} bytes ({sizeof(usage.free)})") print(f" Free: {usage.free} bytes ({sizeof(usage.free)})")
print(f" Percent used: {usage.percent}%") print(f" Percent used: {usage.percent}%")
break break
if usage and part:
return { return {
"percent": usage.percent, "percent": usage.percent,
"mount": part.mountpoint, "mount": part.mountpoint,
@@ -92,6 +94,7 @@ def get_tmpfs_info(mount_point="/ramdisk"):
} }
def get_cpu_info(): def get_cpu_info():
# cpu percent par coeur et moyennes load # cpu percent par coeur et moyennes load
return { return {
+7 -18
View File
@@ -33,12 +33,11 @@ class VideoFileCapture(VideoCaptureInterface):
def __init__( def __init__(
self, self,
video_file: str = None, video_file: str | None = None,
fps: float = VideoCaptureInterface.DEFAULT_FPS, fps: float = VideoCaptureInterface.DEFAULT_FPS,
jpeg_quality: int = 85, jpeg_quality: int = 85,
width: Optional[int] = None, width: Optional[int] = None,
height: Optional[int] = None, height: Optional[int] = None,
video_lists = [],
use_tracking: bool = False, use_tracking: bool = False,
display = None, display = None,
parent = None, parent = None,
@@ -50,33 +49,23 @@ class VideoFileCapture(VideoCaptureInterface):
:param width: Largeur souhaitée (None = valeur par défaut du pilote) :param width: Largeur souhaitée (None = valeur par défaut du pilote)
:param height: Hauteur souhaitée (None = valeur par défaut du pilote) :param height: Hauteur souhaitée (None = valeur par défaut du pilote)
""" """
super().__init__(fps=fps, use_tracking=use_tracking, display=display, parent=parent) 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._jpeg_quality: int = jpeg_quality
self._width: Optional[int] = width self._width: Optional[int] = width
self._height: Optional[int] = height self._height: Optional[int] = height
self._video_lists = video_lists
self.ptf = 0
self._cap = None # Instance cv2.VideoCapture self._cap = None # Instance cv2.VideoCapture
def get_file(self): def set_video_file(self, vf):
if self._video_lists: self._video_file = vf
self._video_file = self._video_lists[self.ptf]
self.ptf += 1
if self.ptf >= len(self._video_lists):
self.ptf = 0
return self._video_file return self._video_file
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Implémentation des méthodes abstraites # Implémentation des méthodes abstraites
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def open(self) -> None: def open(self) -> None:
"""Ouvre le flux V4L2 via OpenCV et configure la résolution.""" """Ouvre le flux V4L2 via OpenCV et configure la résolution."""
self.get_file()
self._cap = cv2.VideoCapture(self._video_file) self._cap = cv2.VideoCapture(self._video_file)
if not self._cap.isOpened(): if not self._cap.isOpened():
@@ -145,8 +134,8 @@ class VideoFileCapture(VideoCaptureInterface):
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@property @property
def video_file(self) -> int: def video_file(self) -> str | None:
"""Index du périphérique V4L2.""" """Fichier vidéo."""
return self._video_file return self._video_file
@property @property
@@ -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
+1 -1
View File
@@ -49,7 +49,7 @@ class WebcamCapture(VideoCaptureInterface):
:param width: Largeur souhaitée (None = valeur par défaut du pilote) :param width: Largeur souhaitée (None = valeur par défaut du pilote)
:param height: Hauteur souhaitée (None = valeur par défaut du pilote) :param height: Hauteur souhaitée (None = valeur par défaut du pilote)
""" """
super().__init__(fps=fps, use_tracking=use_tracking, display=display, parent=parent) super().__init__(fps=fps, use_tracking=use_tracking, display=display, parent=parent, jpeg_quality=jpeg_quality)
self._device_index: int = device_index self._device_index: int = device_index
self._jpeg_quality: int = jpeg_quality self._jpeg_quality: int = jpeg_quality
self._width: Optional[int] = width self._width: Optional[int] = width
+16 -11
View File
@@ -8,29 +8,34 @@ from .models import ExperimentConfig
@admin.register(ExperimentConfig) @admin.register(ExperimentConfig)
class ExperimentConfigAdmin(admin.ModelAdmin): class ExperimentConfigAdmin(admin.ModelAdmin):
"""Admin Django pour les configurations d'expérience.""" """Admin Django pour les configurations d'expérience."""
readonly_fields = ('experiment', ) readonly_fields = ('experiment', 'px_per_mm', 'fps', 'well_radius_mm',)
list_display = ("experiment", "well", "px_per_mm", "fps", list_display = ("experiment_key", "well", "active", "planarian_count", "px_per_mm", "fps",
"thresh_immobile", "thresh_mobile", "thresh_immobile", "thresh_mobile",
"photo_mode", "chemo_strength", "created_at") "photo_mode", "chemo_strength", "created_at", )
list_filter = ("photo_mode", "tube_axis")
search_fields = ("experiment", "well", "description") list_filter = ("experiment_key__session_experiments__session", "experiment_key", "photo_mode", "thigmotaxis_wall_dist_mm", "planarian_count")
search_fields = ("experiment_key", "well_name", "description")
ordering = ("-created_at",) ordering = ("-created_at",)
fieldsets = ( fieldsets = (
(_("Identification"), { (_("Identification"), {
"fields": ("experiment", "well", "description"), "fields": ("experiment_key", "well", "active", "description"),
}), }),
(_("Calibration optique"), { (_("Calibration optique: générée lors de la calibration"), {
"fields": ("px_per_mm", "fps", "well_radius_mm"), "fields": ("px_per_mm", "fps", "well_radius_mm"),
"classes": ("collapse",),
}), }),
(_("Seuils de mobilité EthoVision"), { (_("Seuils de mobilité EthoVision"), {
"fields": ("thresh_immobile", "thresh_mobile"), "fields": ("thresh_immobile", "thresh_mobile"),
"classes": ("collapse",),
}), }),
(_("Tracker"), { (_("Tracker"), {
"fields": ("tube_axis", "min_area_px", "planarian_count"), "fields": ("tube_axis", "min_area_px", "max_area_ratio", "planarian_count", "merge_kernel_size", "min_contour_dist_px"),
"classes": ("collapse",),
}), }),
(_("Thigmotactisme"), { (_("Thigmotactisme"), {
"fields": ("thigmotaxis_wall_dist_mm",), "fields": ("thigmotaxis_wall_dist_mm",),
"classes": ("collapse",),
}), }),
(_("Phototactisme"), { (_("Phototactisme"), {
"fields": ("photo_mode", "photo_strength", "photo_x", "photo_y"), "fields": ("photo_mode", "photo_strength", "photo_x", "photo_y"),
@@ -52,19 +57,19 @@ class ExperimentConfigAdmin(admin.ModelAdmin):
@admin.action(description=_("Exporter un template CSV de ces configurations")) @admin.action(description=_("Exporter un template CSV de ces configurations"))
def export_csv_template(self, request, queryset): def export_csv_template(self, request, queryset):
import csv import csv
from django.http import HttpResponse from django.http import FileResponse
from io import StringIO from io import StringIO
output = StringIO() output = StringIO()
fields = [f.name for f in ExperimentConfig._meta.fields if f.name != "id"] # @UndefinedVariable fields = [f.name for f in ExperimentConfig._meta.fields if f.name != "id"] # @UndefinedVariable
writer = csv.DictWriter(output, fieldnames=fields) writer = csv.DictWriter(output, fieldnames=fields)
writer.writeheader() writer.writeheader()
for obj in queryset: for obj in queryset:
row = {f: getattr(obj, f) for f in fields} row = {f: getattr(obj, f) for f in fields}
writer.writerow(row) writer.writerow(row)
response = HttpResponse(output.getvalue(), content_type="text/csv") response = FileResponse(output.getvalue(), content_type='text/csv')
response["Content-Disposition"] = 'attachment; filename="experiment_configs.csv"' response["Content-Disposition"] = 'attachment; filename="experiment_configs.csv"'
return response return response
@@ -0,0 +1,68 @@
import logging
import asyncio
from asgiref.sync import async_to_sync
from django.conf import settings
from modules.planarian_metrics import ReductStoreClient
logger = logging.getLogger(__name__)
def _get_reduct_client() -> ReductStoreClient:
"""Instancie le client ReductStore depuis les settings Django."""
return ReductStoreClient(url=settings.REDUCTSTORE_URL, token=settings.REDUCTSTORE_TOKEN)
def export_csv_sync(
*,
experiment,
well,
uuid,
planarian,
record_type,
start=None,
stop=None,
):
#@async_to_sync
async def _run():
client = _get_reduct_client()
await client.connect()
return await client.export_csv_response(
experiment=experiment,
well=well,
uuid=uuid,
planarian=planarian,
record_type=record_type,
start=start,
stop=stop,
)
#return _run()
return asyncio.run(_run())
def export_csv_file_sync(
*,
experiment,
well,
uuid,
planarian,
record_type,
):
async def _run():
client = _get_reduct_client()
await client.connect()
try:
return await client.export_csv(
experiment=experiment,
well=well,
uuid=uuid,
planarian=planarian,
record_type=record_type,
output_dir=settings.CSV_EXPORT_DIR,
)
finally:
await client.close()
return asyncio.run(_run())
-91
View File
@@ -1,91 +0,0 @@
# planarian/forms.py
import csv
import io
from django import forms
from django.utils.translation import gettext_lazy as _
from .models import ExperimentConfig
class ExperimentConfigForm(forms.ModelForm):
"""Formulaire de saisie/modification d'un ExperimentConfig."""
class Meta:
model = ExperimentConfig
fields = "__all__"
widgets = {
"description": forms.Textarea(attrs={"rows": 3}),
}
def clean(self):
cleaned = super().clean()
if cleaned.get("thresh_immobile", 0) >= cleaned.get("thresh_mobile", 1):
raise forms.ValidationError(
_("Le seuil Immobile doit être inférieur au seuil Mobile.")
)
if cleaned.get("avoid_radius_mm", 0) >= cleaned.get("aggreg_radius_mm", 1):
raise forms.ValidationError(
_("Le rayon d'évitement doit être inférieur au rayon d'agrégation.")
)
return cleaned
class CsvImportForm(forms.Form):
"""Formulaire d'import de paramètres depuis un fichier CSV."""
csv_file = forms.FileField(
label=_("Fichier CSV"),
help_text=_(
"Colonnes obligatoires : experiment, well, px_per_mm, fps. "
"Toutes les autres colonnes sont optionnelles."
),
)
overwrite = forms.BooleanField(
required=False,
initial=False,
label=_("Écraser les configurations existantes"),
)
def clean_csv_file(self):
f = self.cleaned_data["csv_file"]
try:
content = f.read().decode("utf-8")
reader = csv.DictReader(io.StringIO(content))
rows = list(reader)
except Exception as e:
raise forms.ValidationError(_("Fichier CSV invalide : %(err)s") % {"err": e})
required = {"experiment", "well", "px_per_mm", "fps"}
if rows:
missing = required - set(rows[0].keys())
if missing:
raise forms.ValidationError(
_("Colonnes manquantes : %(cols)s") % {"cols": ", ".join(missing)}
)
self.csv_rows = rows
return f
class ExportCsvForm(forms.Form):
"""Formulaire de demande d'export CSV depuis ReductStore."""
experiment = forms.CharField(label=_("Expérience"), max_length=100)
well = forms.CharField(label=_("Puits"), max_length=20)
planarian = forms.IntegerField(label=_("Index planaire"), initial=0, min_value=0)
record_type = forms.ChoiceField(
label=_("Type d'enregistrement"),
choices=[("frame", _("Frame par frame")), ("summary", _("Résumé"))],
initial="frame",
)
start_dt = forms.DateTimeField(
label=_("Début (UTC)"),
required=False,
widget=forms.DateTimeInput(attrs={"type": "datetime-local"}),
)
stop_dt = forms.DateTimeField(
label=_("Fin (UTC)"),
required=False,
widget=forms.DateTimeInput(attrs={"type": "datetime-local"}),
)
@@ -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 <contour>')),
('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'],
},
),
]
@@ -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')},
),
]
+56 -45
View File
@@ -1,13 +1,17 @@
# planarian/models.py # planarian/models.py
from django.db import models from django.db import models
from django.dispatch import receiver #from django.conf import settings
from django.db.models.signals import post_save
from django.conf import settings
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.contrib.auth.models import User from django.contrib.auth.models import User
from scanner.models import Experiment, Well, WellPosition from scanner.models import Experiment, Well
from scanner.constants import ScannerConstants
WELL_CHOICES = []
def get_well_choices():
wells = Well.objects.order_by('name').all()
for well in wells:
WELL_CHOICES.append((well.name, well.name))
class ExperimentConfig(models.Model): class ExperimentConfig(models.Model):
""" """
@@ -15,22 +19,15 @@ class ExperimentConfig(models.Model):
Peut être créé depuis Django admin, une vue formulaire ou un import CSV. Peut être créé depuis Django admin, une vue formulaire ou un import CSV.
""" """
author = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name="Auteur", null=True, blank=True) author = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name="Auteur", null=True, blank=True)
experiment_key = models.ForeignKey(Experiment, verbose_name="Expérience", on_delete=models.CASCADE, null=True, blank=False)
experiment = models.CharField(_("Identifiant expérience"), max_length=128, null=True, blank=False, default='Identifier' )
well = models.CharField(_("Puit"), help_text=_("Nom du puit"), max_length=8, choices=WELL_CHOICES, null=True, blank=False, default='A1' )
# --- Identification --- description = models.TextField( blank=True, verbose_name=_("Description"), default="-")
idendifier = models.CharField(
max_length=100,
verbose_name=_("Identifiant d'expérience"),
help_text=_("Ex : exp_2026_04_25_ctrl"),
)
experiment = models.ForeignKey(Experiment, on_delete=models.CASCADE, related_name="experiment_well" , null=True, blank=True)
well = models.ForeignKey(Well, verbose_name="Puit", on_delete=models.CASCADE, related_name="well_experiment", null=True, blank=True )
description = models.TextField(
blank=True,
verbose_name=_("Description"),
)
created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Créé le")) created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Créé le"))
active = models.BooleanField(_("Active"), default=True)
# --- Calibration optique --- # --- Calibration optique ---
# px_per_mm, fps, well_radius_mm # px_per_mm, fps, well_radius_mm
@@ -42,10 +39,12 @@ class ExperimentConfig(models.Model):
fps = models.FloatField( fps = models.FloatField(
default=5.0, default=5.0,
verbose_name=_("FPS de capture"), verbose_name=_("FPS de capture"),
help_text=_("Image de capture en img/s"),
) )
well_radius_mm = models.FloatField( well_radius_mm = models.FloatField(
default=8.0, default=8.0,
verbose_name=_("Rayon du puits (mm)"), verbose_name=_("Rayon du puits"),
help_text=_("En mm"),
) )
# --- Seuils de mobilité EthoVision --- # --- Seuils de mobilité EthoVision ---
@@ -69,11 +68,30 @@ class ExperimentConfig(models.Model):
default=20, default=20,
verbose_name=_("Surface min détection (px²)"), verbose_name=_("Surface min détection (px²)"),
) )
max_area_ratio = models.FloatField(
default=0.10,
verbose_name=_("Surface max contour (fraction de la frame)"),
help_text=_("Ratio de la surface du puits, ex: 0.10 pour 10%"),
)
planarian_count = models.IntegerField( planarian_count = models.IntegerField(
default=1, default=1,
verbose_name=_("Nombre de planaires"), verbose_name=_("Nombre de planaires"),
) )
merge_kernel_size = models.PositiveIntegerField(
_("Taille du kernel"),
help_text=_("taille du kernel elliptique de fusion des fragments (px). Augmenter si fragments résiduels"),
default=15
)
min_contour_dist_px = models.PositiveIntegerField(
_("Distance <contour>"),
help_text=_("Distance min entre deux contours pour les considérer comme individus distincts. Défaut : 40px. Augmenter si IDs multiples persistent"),
default=40
)
# --- Thigmotactisme --- # --- Thigmotactisme ---
thigmotaxis_wall_dist_mm = models.FloatField( thigmotaxis_wall_dist_mm = models.FloatField(
default=1.0, default=1.0,
@@ -107,23 +125,33 @@ class ExperimentConfig(models.Model):
avoid_radius_mm = models.FloatField(default=3.0, verbose_name=_("Rayon évitement (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)")) aggreg_radius_mm = models.FloatField(default=6.0, verbose_name=_("Rayon agrégation (mm)"))
class Meta: class Meta:
verbose_name = _("Configuration expérience") verbose_name = _("Configuration d'une expérience")
verbose_name_plural = _("Configurations expériences") verbose_name_plural = _("Configurations des expériences")
unique_together = ("experiment", "well") unique_together = ("experiment_key", "well")
ordering = ["-created_at"] ordering = ["-created_at"]
def __str__(self): def __str__(self):
return f"{self.experiment} / {self.well.name}" return f"{self.experiment_key}:{self.well}"
def save(self, *args, **kwargs):
if not self.author:
self.author = self.experiment_key.author
self.experiment = self.experiment_key.identifier
super().save(*args, **kwargs)
def get_session(self): def get_session(self):
return self.experiment.session_experiments.first() if self.experiment else None se = self.experiment_key.session_experiments.first() if self.experiment_key else None
return se.session
def to_params_dict(self) -> dict: def to_params_dict(self) -> dict:
"""Retourne un dict compatible avec ExperimentParams.""" """Retourne un dict compatible avec ExperimentParams."""
return { return {
"experiment": self.idendifier, "experiment": self.experiment,
"well": self.well.name, "well": self.well,
"px_per_mm": self.px_per_mm, "px_per_mm": self.px_per_mm,
"fps": self.fps, "fps": self.fps,
"well_radius_mm": self.well_radius_mm, "well_radius_mm": self.well_radius_mm,
@@ -131,6 +159,9 @@ class ExperimentConfig(models.Model):
"thresh_mobile": self.thresh_mobile, "thresh_mobile": self.thresh_mobile,
"tube_axis": self.tube_axis, "tube_axis": self.tube_axis,
"min_area_px": self.min_area_px, "min_area_px": self.min_area_px,
"max_area_ratio": self.max_area_ratio,
"merge_kernel_size": self.merge_kernel_size,
"min_contour_dist_px": self.min_contour_dist_px,
"planarian_count": self.planarian_count, "planarian_count": self.planarian_count,
"thigmotaxis_wall_dist_mm": self.thigmotaxis_wall_dist_mm, "thigmotaxis_wall_dist_mm": self.thigmotaxis_wall_dist_mm,
"photo_mode": self.photo_mode, "photo_mode": self.photo_mode,
@@ -143,23 +174,3 @@ class ExperimentConfig(models.Model):
"aggreg_radius_mm": self.aggreg_radius_mm, "aggreg_radius_mm": self.aggreg_radius_mm,
} }
def save(self, *args, **kwargs):
session = self.get_session()
position = self.experiment.multiwell.position
dte = self.experiment.multiwell.finished.isoformat()
self.idendifier = f'{session}-{position}-{self.well.name}-{dte}'
super().save(*args, **kwargs)
@receiver(post_save, sender=ExperimentConfig)
def create_well_position(sender, instance, created, **kwargs):
active_well = WellPosition.active_well(instance.multiwel, instance.well)
instance.px_per_mm = active_well.px_per_mm
instance.well_radius_mm = instance.experiment.multiwell.diameter / 2
conf = ScannerConstants().get()
instance.fps = conf.video_frame_rate
instance.save()
@@ -0,0 +1,12 @@
.multiwell_cards {
display: grid;
grid-template-columns: repeat(6, 1fr);
justify-items: center;
align-items: center;
}
button.multiwell {
padding: 0.2em ;
}
+80
View File
@@ -0,0 +1,80 @@
# tasks.py
from celery import shared_task, group
from celery.utils.log import get_task_logger
from scanner.models import SessionExperiment, get_uuid_from_session, Experiment
from .models import ExperimentConfig
from .export_service import export_csv_file_sync
logger = get_task_logger(__name__)
@shared_task(bind=True, autoretry_for=(Exception,), retry_backoff=True, retry_kwargs={"max_retries": 3},)
def export_single_metric(self, *, experiment, well, uuid, planarian, record_type,):
"""
Exporte UN fichier CSV.
"""
logger.info( f"Export start {experiment} {well} {planarian} {record_type}")
f, n = export_csv_file_sync(
experiment=experiment,
well=well,
uuid=uuid,
planarian=planarian,
record_type=record_type,
)
logger.info( f"Export done {f} ({n} rows)" )
return {"file": f, "rows": n, }
@shared_task
def export_experiment_metrics_task(experiment_id):
experiment = Experiment.objects.filter(pk=experiment_id).first()
if not experiment:
return
jobs = []
configs = (ExperimentConfig.objects.filter(experiment_key_id=experiment.pk).order_by("well"))
for conf in configs:
well = conf.well
count = conf.planarian_count
session = conf.get_session()
uuid = get_uuid_from_session(session.id, conf.experiment_key.multiwell.position, well, )
for record_type in ["frame", "summary"]:
for planarian in range(count):
'''
jobs.append(
export_single_metric.s(
experiment=experiment.identifier,
well=well,
uuid=uuid,
planarian=planarian,
record_type=record_type,
)
)
'''
try:
f, n = export_csv_file_sync(
experiment=experiment.identifier,
well=well,
uuid=uuid,
planarian=planarian,
record_type=record_type,
)
logger.info( f"Export done {f} ({n} rows)" )
except Exception as e:
logger.exception(e)
'''
result = group(jobs).apply_async()
logger.info(f"{len(jobs)} exports launched group_id={result.id}")
return {"group_id": result.id,"jobs": len(jobs), }'''
@shared_task
def export_session_metrics_task(session_id):
experiments = SessionExperiment.experiment_by_session(session_id, active=False)
for experiment in experiments:
export_experiment_metrics_task.delay(experiment.id)
@@ -0,0 +1,76 @@
{% extends "scanner/base.html" %}
{% load i18n home_tags %}
{% block styles %}
{{ block.super }}
<link href="/static/planarian/css/planarian.css" rel="stylesheet">
{% endblock %}
{% block columns %}{% endblock %}
{% block sidebar_list %}
<a href="/" class="w3-bar-item w3-btn w3-hover-opacity"><i class="fa-solid fa-house w3-text-orange w3-xlarge"></i> {% trans "Retour accueil" %}</a>
<form method="post" class="w3-bar-item" action="">
{% csrf_token %}
<select id="_sid" name="_sid" class="w3-select w3-margin-bottom" onchange="this.form.submit()" title="{% trans "Ensemble d'expériences" %}">
<option value="0">---- {% trans "Session" %}</option>
{% for s in sessions %}
<option value="{{ s.id }}" {% if s.id == current_session.id %}selected{% endif %}>{{ s }}</option>
{% endfor %}
</select>
<div class="w3-margin-left w3-margin-bottom">
{% include "scanner/experiment-inc.html" %}
</div>
</form>
{% block export_csv %}
{% if current_session and current_experiment %}
{% url "planarian:api-export-csv" as url_export %}
<a href="#" class="w3-bar-item w3-btn w3-hover-opacity" onclick="export_csv('{{ url_export }}', {{ current_experiment.id }}, 'experiment_csv')">
<i class="fa-solid fa-file-export w3-text-red w3-xlarge"></i> {% trans "Exporter les metrics de l'expérience" %}<br>
<span class="w3-margin-left-5">&nbsp;$> {{ export_csv_destination }}
</a>
<a href="#" class="w3-bar-item w3-btn w3-hover-opacity" onclick="export_csv('{{ url_export }}', {{ current_session.id }}, 'session_csv')">
<i class="fa-solid fa-file-export w3-text-pink w3-xxlarge"></i> {% trans "Exporter les metrics de la session" %}<br>
<span class="w3-margin-left-5">&nbsp;$> {{ export_csv_destination }}
</a>
{% endif %}
{% endblock %}
{% endblock %}
{% block js_footer %}
{{ block.super }}
<script>
const export_msg = "{% trans "Confirmer l'export des vidéos. ATTENTION le processus peut être long!" %}";
// Auto-dismiss messages after 5 seconds
document.addEventListener('DOMContentLoaded', function() {
const messages = document.querySelectorAll('.alert');
messages.forEach(function(message) {
setTimeout(function() {
// Fade out effect
message.style.transition = 'opacity 0.5s ease-out';
message.style.opacity = '0';
// Remove from DOM after fade
setTimeout(function() {
message.remove();
}, 500);
}, 15000); // 15 seconds
});
});
function export_csv(url, pid, mode) {
csrfFetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: mode,
pid: pid,
})
})
.then(response => response.json())
.then(res => { alert(res.msg); })
.catch(error => { console.error('Erreur:', error); });
}
</script>
{% endblock %}
@@ -1,503 +0,0 @@
{% extends "scanner/base.html" %}
{% load i18n %}
{% block content %}
<div class="w3-container w3-padding-32" style="max-width:960px; margin:auto;">
<!-- En-tête de la page -->
<div class="w3-panel w3-teal w3-round-large w3-padding-16" style="margin-bottom:2rem;">
<div class="w3-row w3-bar-item">
<span class="w3-xlarge">
<i class="w3-margin-right">🔬</i>
{% if object %}
{% trans "Modifier la configuration" %} — {{ object }}
{% else %}
{% trans "Nouvelle configuration d'expérience" %}
{% endif %}
</span>
</div>
</div>
<!-- Messages Django -->
{% for message in messages %}
<div class="w3-panel w3-round
{% if message.tags == 'success' %}w3-green
{% elif message.tags == 'error' %}w3-red
{% elif message.tags == 'warning' %}w3-orange
{% else %}w3-blue{% endif %}">
<p>{{ message }}</p>
</div>
{% endfor %}
<form method="post" novalidate>
{% csrf_token %}
<!-- Erreurs globales du formulaire -->
{% if form.non_field_errors %}
<div class="w3-panel w3-red w3-round">
{% for error in form.non_field_errors %}
<p>⚠ {{ error }}</p>
{% endfor %}
</div>
{% endif %}
<!-- ============================================================
Section 1 : Identification
============================================================ -->
<div class="w3-card-4 w3-round-large w3-margin-bottom">
<header class="w3-container w3-teal w3-round-top-large">
<h3 class="w3-text-white">{% trans "Identification" %}</h3>
</header>
<div class="w3-container w3-padding-24">
<div class="w3-row-padding">
<!-- Experiment -->
<div class="w3-col m6 s12 w3-margin-bottom">
<label class="w3-text-teal"><b>{{ form.experiment.label }}</b></label>
{% if form.experiment.help_text %}
<p class="w3-small w3-text-grey" style="margin:0 0 4px;">{{ form.experiment.help_text }}</p>
{% endif %}
{{ form.experiment }}
{% if form.experiment.errors %}
<span class="w3-text-red w3-small">{{ form.experiment.errors|join:", " }}</span>
{% endif %}
</div>
<!-- Well -->
<div class="w3-col m6 s12 w3-margin-bottom">
<label class="w3-text-teal"><b>{{ form.well.label }}</b></label>
{% if form.well.help_text %}
<p class="w3-small w3-text-grey" style="margin:0 0 4px;">{{ form.well.help_text }}</p>
{% endif %}
{{ form.well }}
{% if form.well.errors %}
<span class="w3-text-red w3-small">{{ form.well.errors|join:", " }}</span>
{% endif %}
</div>
</div>
<!-- Description -->
<div class="w3-margin-bottom">
<label class="w3-text-teal"><b>{{ form.description.label }}</b></label>
{{ form.description }}
{% if form.description.errors %}
<span class="w3-text-red w3-small">{{ form.description.errors|join:", " }}</span>
{% endif %}
</div>
</div>
</div>
<!-- ============================================================
Section 2 : Calibration optique
============================================================ -->
<div class="w3-card-4 w3-round-large w3-margin-bottom">
<header class="w3-container w3-blue-grey w3-round-top-large">
<h3 class="w3-text-white">{% trans "Calibration optique" %}</h3>
</header>
<div class="w3-container w3-padding-24">
<div class="w3-row-padding">
<div class="w3-col m4 s12 w3-margin-bottom">
<label class="w3-text-blue-grey"><b>{{ form.px_per_mm.label }}</b></label>
{% if form.px_per_mm.help_text %}
<p class="w3-small w3-text-grey" style="margin:0 0 4px;">{{ form.px_per_mm.help_text }}</p>
{% endif %}
{{ form.px_per_mm }}
{% if form.px_per_mm.errors %}
<span class="w3-text-red w3-small">{{ form.px_per_mm.errors|join:", " }}</span>
{% endif %}
</div>
<div class="w3-col m4 s12 w3-margin-bottom">
<label class="w3-text-blue-grey"><b>{{ form.fps.label }}</b></label>
{{ form.fps }}
{% if form.fps.errors %}
<span class="w3-text-red w3-small">{{ form.fps.errors|join:", " }}</span>
{% endif %}
</div>
<div class="w3-col m4 s12 w3-margin-bottom">
<label class="w3-text-blue-grey"><b>{{ form.well_radius_mm.label }}</b></label>
{{ form.well_radius_mm }}
{% if form.well_radius_mm.errors %}
<span class="w3-text-red w3-small">{{ form.well_radius_mm.errors|join:", " }}</span>
{% endif %}
</div>
</div>
</div>
</div>
<!-- ============================================================
Section 3 : Seuils de mobilité EthoVision
============================================================ -->
<div class="w3-card-4 w3-round-large w3-margin-bottom">
<header class="w3-container w3-indigo w3-round-top-large">
<h3 class="w3-text-white">{% trans "Seuils de mobilité EthoVision XT" %}</h3>
</header>
<div class="w3-container w3-padding-24">
<!-- Visualisation des seuils -->
<div class="w3-light-grey w3-round w3-margin-bottom" style="height:32px; position:relative; overflow:hidden;">
<div style="position:absolute; left:0; width:13%; height:100%; background:#c0392b; display:flex; align-items:center; justify-content:center;">
<span class="w3-small w3-text-white"><b>{% trans "Immobile" %}</b></span>
</div>
<div style="position:absolute; left:13%; width:27%; height:100%; background:#e67e22; display:flex; align-items:center; justify-content:center;">
<span class="w3-small w3-text-white"><b>{% trans "Mobile" %}</b></span>
</div>
<div style="position:absolute; left:40%; right:0; height:100%; background:#27ae60; display:flex; align-items:center; justify-content:center;">
<span class="w3-small w3-text-white"><b>{% trans "Très mobile" %}</b></span>
</div>
</div>
<p class="w3-small w3-text-grey" style="margin-top:-8px; margin-bottom:16px;">
{% trans "Représentation indicative des zones de mobilité (défauts EthoVision : 0.2 / 1.5 mm/s)" %}
</p>
<div class="w3-row-padding">
<div class="w3-col m6 s12 w3-margin-bottom">
<label class="w3-text-indigo"><b>{{ form.thresh_immobile.label }}</b></label>
<p class="w3-small w3-text-grey" style="margin:0 0 4px;">{% trans "En-dessous : Immobile (mm/s)" %}</p>
{{ form.thresh_immobile }}
{% if form.thresh_immobile.errors %}
<span class="w3-text-red w3-small">{{ form.thresh_immobile.errors|join:", " }}</span>
{% endif %}
</div>
<div class="w3-col m6 s12 w3-margin-bottom">
<label class="w3-text-indigo"><b>{{ form.thresh_mobile.label }}</b></label>
<p class="w3-small w3-text-grey" style="margin:0 0 4px;">{% trans "En-dessous : Mobile, au-delà : Très mobile (mm/s)" %}</p>
{{ form.thresh_mobile }}
{% if form.thresh_mobile.errors %}
<span class="w3-text-red w3-small">{{ form.thresh_mobile.errors|join:", " }}</span>
{% endif %}
</div>
</div>
</div>
</div>
<!-- ============================================================
Section 4 : Tracker
============================================================ -->
<div class="w3-card-4 w3-round-large w3-margin-bottom">
<header class="w3-container w3-dark-grey w3-round-top-large">
<h3 class="w3-text-white">{% trans "Tracker" %}</h3>
</header>
<div class="w3-container w3-padding-24">
<div class="w3-row-padding">
<div class="w3-col m4 s12 w3-margin-bottom">
<label class="w3-text-dark-grey"><b>{{ form.tube_axis.label }}</b></label>
{{ form.tube_axis }}
{% if form.tube_axis.errors %}
<span class="w3-text-red w3-small">{{ form.tube_axis.errors|join:", " }}</span>
{% endif %}
</div>
<div class="w3-col m4 s12 w3-margin-bottom">
<label class="w3-text-dark-grey"><b>{{ form.min_area_px.label }}</b></label>
{{ form.min_area_px }}
{% if form.min_area_px.errors %}
<span class="w3-text-red w3-small">{{ form.min_area_px.errors|join:", " }}</span>
{% endif %}
</div>
<div class="w3-col m4 s12 w3-margin-bottom">
<label class="w3-text-dark-grey"><b>{{ form.planarian_count.label }}</b></label>
{{ form.planarian_count }}
{% if form.planarian_count.errors %}
<span class="w3-text-red w3-small">{{ form.planarian_count.errors|join:", " }}</span>
{% endif %}
</div>
</div>
</div>
</div>
<!-- ============================================================
Section 5 : Comportements (accordéon W3.CSS)
============================================================ -->
<div class="w3-card-4 w3-round-large w3-margin-bottom">
<header class="w3-container w3-teal w3-round-top-large">
<h3 class="w3-text-white">{% trans "Comportements" %}</h3>
</header>
<!-- --- Thigmotactisme --- -->
<div class="w3-container w3-padding-16 w3-border-bottom">
<button type="button" class="w3-button w3-block w3-left-align w3-hover-light-grey"
onclick="toggleSection('thigmo')">
<span class="w3-large">🫧</span>
<b class="w3-margin-left">{% trans "Thigmotactisme" %}</b>
<span class="w3-small w3-text-grey w3-margin-left">
{% trans "Attraction vers la paroi du puits" %}
</span>
<span id="thigmo-icon" class="w3-right"></span>
</button>
<div id="thigmo" class="w3-padding-top">
<div class="w3-row-padding">
<div class="w3-col m6 s12">
<label class="w3-text-teal"><b>{{ form.thigmotaxis_wall_dist_mm.label }}</b></label>
<p class="w3-small w3-text-grey" style="margin:0 0 4px;">
{% trans "Distance à la paroi considérée « près du bord » (mm)" %}
</p>
{{ form.thigmotaxis_wall_dist_mm }}
{% if form.thigmotaxis_wall_dist_mm.errors %}
<span class="w3-text-red w3-small">{{ form.thigmotaxis_wall_dist_mm.errors|join:", " }}</span>
{% endif %}
</div>
</div>
</div>
</div>
<!-- --- Phototactisme --- -->
<div class="w3-container w3-padding-16 w3-border-bottom">
<button type="button" class="w3-button w3-block w3-left-align w3-hover-light-grey"
onclick="toggleSection('photo')">
<span class="w3-large">💡</span>
<b class="w3-margin-left">{% trans "Phototactisme" %}</b>
<span class="w3-small w3-text-grey w3-margin-left">
{% trans "Fuite de la lumière" %}
</span>
<span id="photo-icon" class="w3-right"></span>
</button>
<div id="photo" class="w3-hide w3-padding-top">
<div class="w3-row-padding">
<div class="w3-col m6 s12 w3-margin-bottom">
<label class="w3-text-teal"><b>{{ form.photo_mode.label }}</b></label>
{{ form.photo_mode }}
{% if form.photo_mode.errors %}
<span class="w3-text-red w3-small">{{ form.photo_mode.errors|join:", " }}</span>
{% endif %}
</div>
<div class="w3-col m6 s12 w3-margin-bottom">
<label class="w3-text-teal"><b>{{ form.photo_strength.label }}</b></label>
<p class="w3-small w3-text-grey" style="margin:0 0 4px;">{% trans "0.0 = désactivé → 1.0 = fort" %}</p>
{{ form.photo_strength }}
{% if form.photo_strength.errors %}
<span class="w3-text-red w3-small">{{ form.photo_strength.errors|join:", " }}</span>
{% endif %}
</div>
<div class="w3-col m6 s12 w3-margin-bottom">
<label class="w3-text-teal"><b>{{ form.photo_x.label }}</b></label>
{{ form.photo_x }}
{% if form.photo_x.errors %}
<span class="w3-text-red w3-small">{{ form.photo_x.errors|join:", " }}</span>
{% endif %}
</div>
<div class="w3-col m6 s12 w3-margin-bottom">
<label class="w3-text-teal"><b>{{ form.photo_y.label }}</b></label>
{{ form.photo_y }}
{% if form.photo_y.errors %}
<span class="w3-text-red w3-small">{{ form.photo_y.errors|join:", " }}</span>
{% endif %}
</div>
</div>
</div>
</div>
<!-- --- Chimiotactisme --- -->
<div class="w3-container w3-padding-16 w3-border-bottom">
<button type="button" class="w3-button w3-block w3-left-align w3-hover-light-grey"
onclick="toggleSection('chemo')">
<span class="w3-large">🍖</span>
<b class="w3-margin-left">{% trans "Chimiotactisme" %}</b>
<span class="w3-small w3-text-grey w3-margin-left">
{% trans "Attraction vers une source de nourriture" %}
</span>
<span id="chemo-icon" class="w3-right"></span>
</button>
<div id="chemo" class="w3-hide w3-padding-top">
<div class="w3-row-padding">
<div class="w3-col m4 s12 w3-margin-bottom">
<label class="w3-text-teal"><b>{{ form.chemo_strength.label }}</b></label>
<p class="w3-small w3-text-grey" style="margin:0 0 4px;">{% trans "0.0 = désactivé → 1.0 = fort" %}</p>
{{ form.chemo_strength }}
{% if form.chemo_strength.errors %}
<span class="w3-text-red w3-small">{{ form.chemo_strength.errors|join:", " }}</span>
{% endif %}
</div>
<div class="w3-col m4 s12 w3-margin-bottom">
<label class="w3-text-teal"><b>{{ form.chemo_x.label }}</b></label>
{{ form.chemo_x }}
{% if form.chemo_x.errors %}
<span class="w3-text-red w3-small">{{ form.chemo_x.errors|join:", " }}</span>
{% endif %}
</div>
<div class="w3-col m4 s12 w3-margin-bottom">
<label class="w3-text-teal"><b>{{ form.chemo_y.label }}</b></label>
{{ form.chemo_y }}
{% if form.chemo_y.errors %}
<span class="w3-text-red w3-small">{{ form.chemo_y.errors|join:", " }}</span>
{% endif %}
</div>
<div class="w3-col m6 s12 w3-margin-bottom">
<label class="w3-text-teal"><b>{{ form.chemo_radius_mm.label }}</b></label>
{{ form.chemo_radius_mm }}
{% if form.chemo_radius_mm.errors %}
<span class="w3-text-red w3-small">{{ form.chemo_radius_mm.errors|join:", " }}</span>
{% endif %}
</div>
</div>
</div>
</div>
<!-- --- Interactions inter-individus --- -->
<div class="w3-container w3-padding-16">
<button type="button" class="w3-button w3-block w3-left-align w3-hover-light-grey"
onclick="toggleSection('social')">
<span class="w3-large">🔀</span>
<b class="w3-margin-left">{% trans "Interactions inter-individus" %}</b>
<span class="w3-small w3-text-grey w3-margin-left">
{% trans "Évitement et agrégation" %}
</span>
<span id="social-icon" class="w3-right"></span>
</button>
<div id="social" class="w3-hide w3-padding-top">
<div class="w3-row-padding">
<div class="w3-col m6 s12 w3-margin-bottom">
<label class="w3-text-teal"><b>{{ form.avoid_radius_mm.label }}</b></label>
<p class="w3-small w3-text-grey" style="margin:0 0 4px;">{% trans "Rayon de répulsion courte portée (mm)" %}</p>
{{ form.avoid_radius_mm }}
{% if form.avoid_radius_mm.errors %}
<span class="w3-text-red w3-small">{{ form.avoid_radius_mm.errors|join:", " }}</span>
{% endif %}
</div>
<div class="w3-col m6 s12 w3-margin-bottom">
<label class="w3-text-teal"><b>{{ form.aggreg_radius_mm.label }}</b></label>
<p class="w3-small w3-text-grey" style="margin:0 0 4px;">{% trans "Rayon d'attraction longue portée — doit être > rayon évitement (mm)" %}</p>
{{ form.aggreg_radius_mm }}
{% if form.aggreg_radius_mm.errors %}
<span class="w3-text-red w3-small">{{ form.aggreg_radius_mm.errors|join:", " }}</span>
{% endif %}
</div>
</div>
</div>
</div>
</div><!-- fin card comportements -->
<!-- ============================================================
Boutons d'action
============================================================ -->
<div class="w3-row-padding w3-margin-top">
<div class="w3-col m8 s12 w3-margin-bottom">
<button type="submit" class="w3-button w3-teal w3-round w3-large w3-padding-large">
{% if object %}
💾 {% trans "Enregistrer les modifications" %}
{% else %}
{% trans "Créer la configuration" %}
{% endif %}
</button>
<a href="{% url 'planarian:experiment-list' %}"
class="w3-button w3-light-grey w3-round w3-large w3-padding-large w3-margin-left">
✖ {% trans "Annuler" %}
</a>
</div>
{% if object %}
<div class="w3-col m4 s12 w3-right-align w3-margin-bottom">
<a href="{% url 'planarian:export-csv' %}?experiment={{ object.experiment }}&well={{ object.well }}"
class="w3-button w3-blue w3-round w3-large w3-padding-large">
📥 {% trans "Exporter CSV" %}
</a>
</div>
{% endif %}
</div>
</form><!-- fin form -->
</div><!-- fin container -->
<!-- ============================================================
Styles W3.CSS pour les champs de formulaire Django
(Django génère des <input> bruts sans classes — on les stylise ici)
============================================================ -->
<style>
/* Champs texte, number, select */
input[type="text"],
input[type="number"],
input[type="email"],
textarea,
select {
width: 100%;
padding: 8px 12px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 15px;
box-sizing: border-box;
margin-top: 2px;
background-color: #fafafa;
transition: border-color 0.2s;
}
input[type="text"]:focus,
input[type="number"]:focus,
textarea:focus,
select:focus {
border-color: #009688; /* teal W3.CSS */
outline: none;
background-color: #fff;
}
/* Champ en erreur */
input.errorfield, select.errorfield, textarea.errorfield {
border-color: #f44336;
background-color: #fff8f8;
}
/* Label au-dessus du champ */
label {
display: block;
margin-bottom: 2px;
font-size: 14px;
}
/* Sections accordéon */
[id$="-icon"] {
transition: transform 0.2s;
}
</style>
<script>
/**
* Bascule la visibilité d'une section accordéon.
* @param {string} id - Identifiant de la section (sans le préfixe)
*/
function toggleSection(id) {
const section = document.getElementById(id);
const icon = document.getElementById(id + '-icon');
if (section.classList.contains('w3-hide')) {
section.classList.remove('w3-hide');
if (icon) icon.textContent = '▲';
} else {
section.classList.add('w3-hide');
if (icon) icon.textContent = '▼';
}
}
/* Ouvrir automatiquement les sections qui contiennent des erreurs */
document.addEventListener('DOMContentLoaded', function () {
['thigmo', 'photo', 'chemo', 'social'].forEach(function (id) {
const section = document.getElementById(id);
if (section && section.querySelector('.w3-text-red')) {
section.classList.remove('w3-hide');
const icon = document.getElementById(id + '-icon');
if (icon) icon.textContent = '▲';
}
});
});
</script>
{% endblock %}
@@ -1,334 +0,0 @@
{% extends "base.html" %}
{% load i18n %}
{% block content %}
<div class="w3-container w3-padding-32" style="max-width:1200px; margin:auto;">
<!-- En-tête -->
<div class="w3-panel w3-teal w3-round-large w3-padding-16 w3-margin-bottom">
<div class="w3-row">
<div class="w3-col s12 m8 w3-bar-item">
<span class="w3-xlarge">
<i class="w3-margin-right">🔬</i>
{% trans "Configurations d'expériences" %}
</span>
</div>
<div class="w3-col s12 m4 w3-bar-item w3-right-align" style="padding-top:6px;">
<a href="{% url 'planarian:experiment-new' %}"
class="w3-button w3-white w3-round w3-text-teal">
{% trans "Nouvelle configuration" %}
</a>
</div>
</div>
</div>
<!-- Messages Django -->
{% for message in messages %}
<div class="w3-panel w3-round
{% if message.tags == 'success' %}w3-green
{% elif message.tags == 'error' %}w3-red
{% elif message.tags == 'warning' %}w3-orange
{% else %}w3-blue{% endif %}">
<p>{{ message }}</p>
</div>
{% endfor %}
<!-- Barre d'outils : recherche + import -->
<div class="w3-row-padding w3-margin-bottom">
<!-- Recherche côté client -->
<div class="w3-col m7 s12 w3-margin-bottom">
<div class="w3-border w3-round" style="display:flex; align-items:center; background:#fafafa;">
<span style="padding:0 10px; font-size:18px; color:#888;">🔍</span>
<input type="text" id="search-input"
placeholder="{% trans 'Filtrer par expérience, puits…' %}"
class="w3-input w3-border-0"
style="background:transparent;"
oninput="filterTable(this.value)">
</div>
</div>
<!-- Boutons import / export -->
<div class="w3-col m5 s12 w3-right-align w3-margin-bottom">
<a href="{% url 'planarian:import-params' %}"
class="w3-button w3-blue-grey w3-round w3-margin-right">
📂 {% trans "Importer CSV" %}
</a>
<a href="{% url 'planarian:export-csv' %}"
class="w3-button w3-blue w3-round">
📥 {% trans "Exporter CSV" %}
</a>
</div>
</div>
<!-- ============================================================
Tableau des configurations
============================================================ -->
{% if configs %}
<!-- Compteur -->
<p class="w3-text-grey w3-small" id="row-count">
{{ configs|length }} {% trans "configuration(s)" %}
</p>
<div class="w3-responsive w3-card-4 w3-round-large">
<table class="w3-table w3-striped w3-hoverable w3-bordered" id="config-table">
<thead>
<tr class="w3-teal">
<th onclick="sortTable(0)" class="sortable" title="{% trans 'Trier' %}">
{% trans "Expérience" %} <span class="sort-icon"></span>
</th>
<th onclick="sortTable(1)" class="sortable" title="{% trans 'Trier' %}">
{% trans "Puits" %} <span class="sort-icon"></span>
</th>
<th onclick="sortTable(2)" class="sortable w3-hide-small" title="{% trans 'Trier' %}">
{% trans "px/mm" %} <span class="sort-icon"></span>
</th>
<th class="w3-hide-small">{% trans "FPS" %}</th>
<th class="w3-hide-small">{% trans "Seuils (mm/s)" %}</th>
<th class="w3-hide-small">{% trans "Comportements" %}</th>
<th onclick="sortTable(6)" class="sortable w3-hide-small" title="{% trans 'Trier' %}">
{% trans "Créé le" %} <span class="sort-icon"></span>
</th>
<th>{% trans "Actions" %}</th>
</tr>
</thead>
<tbody id="config-tbody">
{% for cfg in configs %}
<tr class="config-row">
<!-- Expérience -->
<td>
<b>{{ cfg.experiment }}</b>
{% if cfg.description %}
<br><span class="w3-small w3-text-grey">{{ cfg.description|truncatechars:40 }}</span>
{% endif %}
</td>
<!-- Puits -->
<td>
<span class="w3-tag w3-teal w3-round">{{ cfg.well }}</span>
</td>
<!-- px/mm -->
<td class="w3-hide-small">{{ cfg.px_per_mm }}</td>
<!-- FPS -->
<td class="w3-hide-small">{{ cfg.fps }}</td>
<!-- Seuils de mobilité -->
<td class="w3-hide-small">
<span class="w3-tag w3-red w3-round w3-small"
title="{% trans 'Immobile' %}">
&lt; {{ cfg.thresh_immobile }}
</span>
<span class="w3-tag w3-orange w3-round w3-small"
title="{% trans 'Mobile' %}">
&lt; {{ cfg.thresh_mobile }}
</span>
</td>
<!-- Comportements actifs -->
<td class="w3-hide-small">
{% if cfg.thigmotaxis_wall_dist_mm > 0 %}
<span class="w3-tag w3-blue-grey w3-round w3-small w3-margin-right"
title="{% trans 'Thigmotactisme actif' %}">🫧</span>
{% endif %}
{% if cfg.photo_mode != 'none' and cfg.photo_strength > 0 %}
<span class="w3-tag w3-yellow w3-round w3-small w3-margin-right"
title="{% trans 'Phototactisme actif' %} ({{ cfg.photo_mode }})">💡</span>
{% endif %}
{% if cfg.chemo_strength > 0 %}
<span class="w3-tag w3-green w3-round w3-small w3-margin-right"
title="{% trans 'Chimiotactisme actif' %}">🍖</span>
{% endif %}
{% if cfg.avoid_radius_mm > 0 or cfg.aggreg_radius_mm > 0 %}
<span class="w3-tag w3-purple w3-round w3-small"
title="{% trans 'Interactions inter-individus' %}">🔀</span>
{% endif %}
</td>
<!-- Date de création -->
<td class="w3-hide-small w3-small w3-text-grey">
{{ cfg.created_at|date:"d/m/Y H:i" }}
</td>
<!-- Actions -->
<td style="white-space:nowrap;">
<a href="{% url 'planarian:experiment-edit' cfg.pk %}"
class="w3-button w3-small w3-teal w3-round"
title="{% trans 'Modifier' %}"></a>
<a href="{% url 'planarian:export-csv' %}?experiment={{ cfg.experiment }}&well={{ cfg.well }}"
class="w3-button w3-small w3-blue w3-round"
title="{% trans 'Exporter CSV' %}">📥</a>
<button type="button"
class="w3-button w3-small w3-red w3-round"
title="{% trans 'Supprimer' %}"
onclick="confirmDelete('{{ cfg.pk }}', '{{ cfg.experiment }}', '{{ cfg.well }}')">
🗑
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div><!-- fin responsive -->
<!-- Message "aucun résultat" (filtrage JS) -->
<div id="no-results" class="w3-panel w3-pale-yellow w3-round w3-margin-top" style="display:none;">
<p>{% trans "Aucune configuration ne correspond à la recherche." %}</p>
</div>
{% else %}
<!-- Liste vide -->
<div class="w3-panel w3-pale-blue w3-round-large w3-padding-32" style="text-align:center;">
<p style="font-size:3rem; margin:0;">🔬</p>
<h3>{% trans "Aucune configuration pour l'instant." %}</h3>
<p class="w3-text-grey">
{% trans "Créez une première configuration ou importez un fichier CSV." %}
</p>
<a href="{% url 'planarian:experiment-new' %}"
class="w3-button w3-teal w3-round w3-large w3-margin-right">
{% trans "Nouvelle configuration" %}
</a>
<a href="{% url 'planarian:import-params' %}"
class="w3-button w3-blue-grey w3-round w3-large">
📂 {% trans "Importer CSV" %}
</a>
</div>
{% endif %}
</div><!-- fin container -->
<!-- ============================================================
Modal de confirmation de suppression
============================================================ -->
<div id="delete-modal" class="w3-modal">
<div class="w3-modal-content w3-round-large w3-card-4" style="max-width:420px; margin:auto; margin-top:10%;">
<header class="w3-container w3-red w3-round-top-large">
<h3 class="w3-text-white">🗑 {% trans "Confirmer la suppression" %}</h3>
</header>
<div class="w3-container w3-padding-24">
<p id="delete-msg"></p>
<form id="delete-form" method="post" action="">
{% csrf_token %}
<input type="hidden" name="_method" value="delete">
<button type="submit" class="w3-button w3-red w3-round w3-margin-right">
🗑 {% trans "Supprimer" %}
</button>
<button type="button" class="w3-button w3-light-grey w3-round"
onclick="document.getElementById('delete-modal').style.display='none'">
✖ {% trans "Annuler" %}
</button>
</form>
</div>
</div>
</div>
<!-- ============================================================
Styles
============================================================ -->
<style>
/* En-têtes de colonnes triables */
th.sortable {
cursor: pointer;
user-select: none;
}
th.sortable:hover {
background-color: #00796b;
}
.sort-icon {
font-size: 11px;
opacity: 0.7;
margin-left: 4px;
}
th.sort-asc .sort-icon::after { content: " ▲"; opacity: 1; }
th.sort-desc .sort-icon::after { content: " ▼"; opacity: 1; }
/* Tableau responsive */
#config-table td, #config-table th {
vertical-align: middle;
padding: 10px 12px;
}
</style>
<!-- ============================================================
JavaScript : filtrage + tri + modal suppression
============================================================ -->
<script>
// --- Filtrage dynamique ---
function filterTable(query) {
const q = query.toLowerCase().trim();
const rows = document.querySelectorAll('.config-row');
const noRes = document.getElementById('no-results');
const count = document.getElementById('row-count');
let visible = 0;
rows.forEach(function (row) {
const text = row.textContent.toLowerCase();
if (!q || text.includes(q)) {
row.style.display = '';
visible++;
} else {
row.style.display = 'none';
}
});
noRes.style.display = (visible === 0 && q) ? 'block' : 'none';
if (count) {
count.textContent = visible + ' {% trans "configuration(s)" %}';
}
}
// --- Tri de colonnes ---
let sortDir = {};
function sortTable(colIdx) {
const tbody = document.getElementById('config-tbody');
const rows = Array.from(tbody.querySelectorAll('.config-row'));
const th = document.querySelectorAll('#config-table thead th')[colIdx];
// Alterner ascendant / descendant
sortDir[colIdx] = sortDir[colIdx] === 'asc' ? 'desc' : 'asc';
// Réinitialiser les classes de toutes les colonnes
document.querySelectorAll('#config-table thead th').forEach(function (h) {
h.classList.remove('sort-asc', 'sort-desc');
});
th.classList.add(sortDir[colIdx] === 'asc' ? 'sort-asc' : 'sort-desc');
rows.sort(function (a, b) {
const cellA = a.querySelectorAll('td')[colIdx]?.textContent.trim() || '';
const cellB = b.querySelectorAll('td')[colIdx]?.textContent.trim() || '';
// Tri numérique si possible, sinon alphabétique
const numA = parseFloat(cellA);
const numB = parseFloat(cellB);
const cmp = (!isNaN(numA) && !isNaN(numB))
? numA - numB
: cellA.localeCompare(cellB, '{{ LANGUAGE_CODE|default:"fr" }}');
return sortDir[colIdx] === 'asc' ? cmp : -cmp;
});
rows.forEach(function (row) { tbody.appendChild(row); });
}
// --- Modal de suppression ---
function confirmDelete(pk, experiment, well) {
document.getElementById('delete-msg').textContent =
'{% trans "Supprimer la configuration" %} ' + experiment + ' / ' + well + ' ?';
document.getElementById('delete-form').action =
'{% url "planarian:experiment-list" %}' + pk + '/delete/';
document.getElementById('delete-modal').style.display = 'block';
}
// Fermer le modal en cliquant en dehors
window.onclick = function (e) {
const modal = document.getElementById('delete-modal');
if (e.target === modal) modal.style.display = 'none';
};
</script>
{% endblock %}
@@ -1,38 +1,10 @@
{% extends "base.html" %} {% extends "planarian/base.html" %}
{% load i18n %} {% load i18n %}
{% block content %} {% block content %}
<div class="w3-container w3-padding" style="max-width:760px; margin:auto;">
<div class="w3-container w3-padding-32" style="max-width:760px; margin:auto;"> {% if current_session and current_experiment %}
{% include "inc/alert.html" %}
<!-- En-tête -->
<div class="w3-panel w3-blue w3-round-large w3-padding-16 w3-margin-bottom">
<div class="w3-row">
<div class="w3-col s12 m8 w3-bar-item">
<span class="w3-xlarge">
📥 {% trans "Exporter les données vers CSV" %}
</span>
</div>
<div class="w3-col s12 m4 w3-bar-item w3-right-align" style="padding-top:6px;">
<a href="{% url 'planarian:experiment-list' %}"
class="w3-button w3-white w3-round w3-text-blue">
← {% trans "Retour à la liste" %}
</a>
</div>
</div>
</div>
<!-- Messages Django -->
{% for message in messages %}
<div class="w3-panel w3-round
{% if message.tags == 'success' %}w3-green
{% elif message.tags == 'error' %}w3-red
{% elif message.tags == 'warning' %}w3-orange
{% else %}w3-blue{% endif %}">
<p>{{ message }}</p>
</div>
{% endfor %}
<!-- Explication --> <!-- Explication -->
<div class="w3-panel w3-pale-blue w3-round w3-margin-bottom"> <div class="w3-panel w3-pale-blue w3-round w3-margin-bottom">
<p> <p>
@@ -43,104 +15,54 @@
{% trans "Colonnes exportées compatibles EthoVision XT : distance, vitesse, états de mobilité, thigmotactisme." %} {% trans "Colonnes exportées compatibles EthoVision XT : distance, vitesse, états de mobilité, thigmotactisme." %}
</p> </p>
</div> </div>
<!-- Formulaire --> <!-- Formulaire -->
<form method="post" novalidate> <form method="post" novalidate>
{% csrf_token %} {% csrf_token %}
<input type="hidden" id="_sid" name="_sid" value="{{ current_session.id }}">
<input type="hidden" id="_expid" name="_expid" value="{{ current_experiment.id }}">
{% if form.non_field_errors %}
<div class="w3-panel w3-red w3-round">
{% for error in form.non_field_errors %}
<p>⚠ {{ error }}</p>
{% endfor %}
</div>
{% endif %}
<div class="w3-card-4 w3-round-large"> <div class="w3-card-4 w3-border w3-round w3-round-large">
<header class="w3-container w3-blue w3-round-top-large"> <header class="w3-container w3-blue w3-round w3-round-top-large">
<h3 class="w3-text-white">{% trans "Paramètres d'export" %}</h3> <h3 class="w3-text-white">{% trans "Paramètres d'export" %}</h3>
</header> </header>
<div class="w3-container w3-padding-24"> <div class="w3-container w3-padding-24">
<!-- Ligne 1 : expérience + puits --> <!-- Ligne 1 : expérience + puits -->
<div class="w3-row-padding w3-margin-bottom"> <div class="w3-row-padding w3-margin-bottom">
<div class="w3-col m7 s12 w3-margin-bottom"> <div class="w3-col m7 s12 w3-margin-bottom">
<label class="w3-text-blue"><b>{{ form.experiment.label }}</b></label> <label class="w3-text-blue"><b>{% trans "Expérience" %}</b></label>
{% if form.experiment.help_text %} <input type="text" name="experiment" value="{{ current_experiment.identifier }}" readonly/>
<p class="w3-small w3-text-grey" style="margin:0 0 4px;">{{ form.experiment.help_text }}</p>
{% endif %}
<!-- Saisie libre ou sélection depuis les configs existantes -->
<input list="experiment-list" name="experiment" id="id_experiment"
value="{{ form.experiment.value|default:'' }}"
class="w3-input w3-border w3-round"
placeholder="{% trans 'Ex : exp_2026_04_25' %}">
<datalist id="experiment-list">
{% for cfg in experiment_choices %}
<option value="{{ cfg }}">
{% endfor %}
</datalist>
{% if form.experiment.errors %}
<span class="w3-text-red w3-small">{{ form.experiment.errors|join:", " }}</span>
{% endif %}
</div> </div>
<div class="w3-col m5 s12 w3-margin-bottom"> <div class="w3-col m5 s12 w3-margin-bottom">
<label class="w3-text-blue"><b>{{ form.well.label }}</b></label> <label class="w3-text-blue"><b>{% trans "Puits" %}</b></label>
<input list="well-list" name="well" id="id_well" <select name="well">
value="{{ form.well.value|default:'' }}" {% for well in well_choices %}
class="w3-input w3-border w3-round" <option value="{{ well.name }}">{{ well.name }}</option>
placeholder="{% trans 'Ex : A1' %}">
<datalist id="well-list">
{% for w in well_choices %}
<option value="{{ w }}">
{% endfor %} {% endfor %}
</datalist> </select>
{% if form.well.errors %}
<span class="w3-text-red w3-small">{{ form.well.errors|join:", " }}</span>
{% endif %}
</div> </div>
</div> </div>
<!-- Ligne 2 : planaire + type d'enregistrement --> <!-- Ligne 2 : planaire + type d'enregistrement -->
<div class="w3-row-padding w3-margin-bottom"> <div class="w3-row-padding w3-margin-bottom">
<div class="w3-col m4 s12 w3-margin-bottom"> <div class="w3-col m4 s12 w3-margin-bottom">
<label class="w3-text-blue"><b>{{ form.planarian.label }}</b></label> <label class="w3-text-blue"><b>{% trans "Index planaire" %}</b></label>
<p class="w3-small w3-text-grey" style="margin:0 0 4px;"> <p class="w3-small w3-text-light-grey" style="margin:0 0 4px;">
{% trans "Index du planaire dans le puits (commence à 0)" %} {% trans "Index du planaire dans le puits (commence à 0)" %}
</p> </p>
{{ form.planarian }} <input type="number" name="planarian" step="1" min="0" max="20" value="{{ planarian }}" />
{% if form.planarian.errors %}
<span class="w3-text-red w3-small">{{ form.planarian.errors|join:", " }}</span>
{% endif %}
</div> </div>
<div class="w3-col m8 s12 w3-margin-bottom"> <div class="w3-col m8 s12 w3-margin-bottom">
<label class="w3-text-blue"><b>{{ form.record_type.label }}</b></label> <label class="w3-text-blue"><b>{% trans "Type d'enregistrement" %}</b></label>
<p class="w3-small w3-text-grey" style="margin:0 0 4px;"> <p class="w3-small w3-text-light-grey" style="margin:0 0 4px;">
{% trans "Frame par frame : une ligne par image. Résumé : métriques agrégées de la session." %} {% trans "Frame par frame : une ligne par image. Résumé : métriques agrégées de la session." %}
</p> </p>
<!-- Radio buttons stylisés W3 --> <select name="record_type">
<div class="w3-row-padding" style="margin-top:6px;"> <option value="frame">{% trans "Frame par frame" %}</option>
{% for radio in form.record_type %} <option value="summary">{% trans "Résumé" %}</option>
<div class="w3-col m6 s12"> </select>
<label class="w3-button w3-block w3-round w3-border
{% if radio.data.value == form.record_type.value %}w3-blue w3-text-white{% else %}w3-white{% endif %}"
style="text-align:center; margin-bottom:6px; cursor:pointer;">
{{ radio.tag }} {{ radio.choice_label }}
</label>
</div> </div>
{% endfor %}
</div> </div>
{% if form.record_type.errors %}
<span class="w3-text-red w3-small">{{ form.record_type.errors|join:", " }}</span>
{% endif %}
</div>
</div>
<!-- Ligne 3 : plage temporelle (optionnel) --> <!-- Ligne 3 : plage temporelle (optionnel) -->
<div class="w3-border-top w3-padding-top w3-margin-top"> <div class="w3-border-top w3-padding-top w3-margin-top">
<p class="w3-text-blue-grey"> <p class="w3-text-blue-grey">
@@ -150,36 +72,26 @@
</span> </span>
</p> </p>
<div class="w3-row-padding"> <div class="w3-row-padding">
<div class="w3-col m6 s12 w3-margin-bottom"> <div class="w3-col m6 s12 w3-margin-bottom">
<label class="w3-text-blue-grey">{{ form.start_dt.label }}</label> <label class="w3-text-blue-grey">{% trans "Début (UTC)" %}</label>
{{ form.start_dt }} <input type="datetime-local" class="datetime" name="start_dt">
{% if form.start_dt.errors %}
<span class="w3-text-red w3-small">{{ form.start_dt.errors|join:", " }}</span>
{% endif %}
</div> </div>
<div class="w3-col m6 s12 w3-margin-bottom"> <div class="w3-col m6 s12 w3-margin-bottom">
<label class="w3-text-blue-grey">{{ form.stop_dt.label }}</label> <label class="w3-text-blue-grey">{% trans "Fin (UTC)" %}</label>
{{ form.stop_dt }} <input type="datetime-local" class="datetime" name="stop_dt">
{% if form.stop_dt.errors %} </div>
<span class="w3-text-red w3-small">{{ form.stop_dt.errors|join:", " }}</span>
{% endif %}
</div>
</div> </div>
</div> </div>
</div><!-- fin padding --> </div><!-- fin padding -->
</div><!-- fin card --> </div><!-- fin card -->
<!-- Boutons --> <!-- Boutons -->
<div class="w3-row-padding w3-margin-top"> <div class="w3-row-padding w3-margin-top">
<div class="w3-col s12"> <div class="w3-col s12">
<button type="submit" class="w3-button w3-blue w3-round w3-large w3-padding-large"> <button type="submit" name="valid" class="w3-button w3-blue w3-large w3-padding-large" value="ok">
📥 {% trans "Générer et télécharger le CSV" %} 📥 {% trans "Générer et télécharger le CSV" %}
</button> </button>
<a href="{% url 'planarian:experiment-list' %}" <a href="/"
class="w3-button w3-light-grey w3-round w3-large w3-padding-large w3-margin-left"> class="w3-button w3-light-grey w3-round w3-large w3-padding-large w3-margin-left">
✖ {% trans "Annuler" %} ✖ {% trans "Annuler" %}
</a> </a>
@@ -187,10 +99,9 @@
</div> </div>
</form> </form>
<!-- Rappel des colonnes exportées --> <!-- Rappel des colonnes exportées -->
<div class="w3-card w3-round-large w3-margin-top"> <div class="w3-card w3-round-large w3-margin-top w3-border w3-round w3-round-large w3-margin-bottom">
<header class="w3-container w3-blue-grey w3-round-top-large"> <header class="w3-container w3-blue-grey w3-round w3-round-top-large">
<h4 class="w3-text-white" style="margin:8px 0;"> <h4 class="w3-text-white" style="margin:8px 0;">
{% trans "Colonnes du fichier CSV exporté" %} {% trans "Colonnes du fichier CSV exporté" %}
</h4> </h4>
@@ -223,9 +134,7 @@
</div> </div>
</div> </div>
</div> </div>
</div><!-- fin container --> </div><!-- fin container -->
<style> <style>
input[type="text"], input[type="text"],
input[type="number"], input[type="number"],
@@ -245,10 +154,25 @@
outline: none; outline: none;
background-color: #fff; background-color: #fff;
} }
/* Masquer le radio natif, laisser le label stylisé */
.w3-button input[type="radio"] {
display: none;
}
</style> </style>
{% else %}
<div class="w3-container w3-padding-64" style="max-width:760px; margin:auto;">
<h3 class="w3-panel w3-blue w3-round-xlarge w3-padding-64 w3-center"> {% trans "Choisir une session" %}</h3>
</div>
{% endif %}
{% endblock %} {% endblock %}
@@ -1,55 +1,25 @@
{% extends "base.html" %} {% extends "planarian/base.html" %}
{% load i18n %} {% load i18n %}
{% block export_csv %}{% endblock %}
{% block content %} {% block content %}
<div class="w3-container w3-padding-32" style="max-width:760px; margin:auto;"> <div class="w3-container w3-padding-32" style="max-width:760px; margin:auto;">
{% if current_session and current_experiment %}
<!-- En-tête --> {% include "inc/alert.html" %}
<div class="w3-panel w3-blue-grey w3-round-large w3-padding-16 w3-margin-bottom">
<div class="w3-row">
<div class="w3-col s12 m8 w3-bar-item">
<span class="w3-xlarge">
📂 {% trans "Importer des configurations depuis CSV" %}
</span>
</div>
<div class="w3-col s12 m4 w3-bar-item w3-right-align" style="padding-top:6px;">
<a href="{% url 'planarian:experiment-list' %}"
class="w3-button w3-white w3-round w3-text-blue-grey">
← {% trans "Retour à la liste" %}
</a>
</div>
</div>
</div>
<!-- Messages Django -->
{% for message in messages %}
<div class="w3-panel w3-round
{% if message.tags == 'success' %}w3-green
{% elif message.tags == 'error' %}w3-red
{% elif message.tags == 'warning' %}w3-orange
{% else %}w3-blue{% endif %}">
<p>{{ message }}</p>
</div>
{% endfor %}
<!-- Formulaire d'import --> <!-- Formulaire d'import -->
<form method="post" enctype="multipart/form-data" novalidate id="import-form"> <form method="post" enctype="multipart/form-data" novalidate id="import-form">
{% csrf_token %} {% csrf_token %}
<input type="hidden" id="_sid" name="_sid" value="{{ current_session.id }}">
<input type="hidden" id="_expid" name="_expid" value="{{ current_experiment.id }}">
{% if form.non_field_errors %} <div class="w3-card-4 w3-margin-bottom w3-border w3-round w3-round-large">
<div class="w3-panel w3-red w3-round"> <header class="w3-container w3-blue-grey w3-round w3-round-top-large">
{% for error in form.non_field_errors %} <h3 class="w3-text-white">{% trans "Fichier CSV à importer" %}: <span class="w3-large w3-text-lime">{{ current_experiment }}</span></h3>
<p>⚠ {{ error }}</p>
{% endfor %}
</div>
{% endif %}
<div class="w3-card-4 w3-round-large w3-margin-bottom">
<header class="w3-container w3-blue-grey w3-round-top-large">
<h3 class="w3-text-white">{% trans "Fichier CSV à importer" %}</h3>
</header> </header>
<div class="w3-container w3-padding-24"> <div class="w3-container w3-padding-24">
<p class="w3-light-blue w3-padding w3-border w3-round" style="margin:0 0 4px;">
{% trans "Colonnes obligatoires : well, px_per_mm, fps." %}<br>
{% trans "Toutes les autres colonnes sont optionnelles." %}
<!-- Zone de dépôt de fichier --> <!-- Zone de dépôt de fichier -->
<div id="drop-zone" <div id="drop-zone"
@@ -68,22 +38,12 @@
{% trans "ou cliquez pour sélectionner" %} {% trans "ou cliquez pour sélectionner" %}
</span> </span>
</p> </p>
<!-- Input fichier caché --> <!-- Input fichier caché -->
<input type="file" id="id_csv_file" name="csv_file" <input type="file" id="id_csv_file" name="csv_file"
accept=".csv,text/csv" accept=".csv,text/csv"
style="display:none;" style="display:none;"
onchange="fileSelected(this)"> onchange="fileSelected(this)">
</div> </div>
{% if form.csv_file.errors %}
<div class="w3-panel w3-red w3-round w3-margin-top">
{% for error in form.csv_file.errors %}
<p>⚠ {{ error }}</p>
{% endfor %}
</div>
{% endif %}
<!-- Aperçu du fichier sélectionné --> <!-- Aperçu du fichier sélectionné -->
<div id="file-info" class="w3-panel w3-pale-green w3-round w3-margin-top" style="display:none;"> <div id="file-info" class="w3-panel w3-pale-green w3-round w3-margin-top" style="display:none;">
<p id="file-name" class="w3-text-green"><b></b></p> <p id="file-name" class="w3-text-green"><b></b></p>
@@ -93,44 +53,37 @@
<!-- Option : écraser --> <!-- Option : écraser -->
<div class="w3-margin-top w3-padding-top w3-border-top"> <div class="w3-margin-top w3-padding-top w3-border-top">
<label class="w3-text-blue-grey" style="cursor:pointer; display:flex; align-items:center; gap:10px;"> <label class="w3-text-blue-grey" style="cursor:pointer; display:flex; align-items:center; gap:10px;">
{{ form.overwrite }} <input type="checkbox" name="overwriteoverwrite" class="w3-check" value="1" />
<span> <span>
<b>{{ form.overwrite.label }}</b><br> <b>{% trans "Écraser les configurations existantes" %}</b><br>
<span class="w3-small w3-text-grey"> <span class="w3-small w3-text-grey">
{% trans "Si décoché, les configurations déjà existantes (même experiment + well) seront ignorées." %} {% trans "Si décoché, les configurations déjà existantes (même experiment + well) seront ignorées." %}
</span> </span>
</span> </span>
</label> </label>
{% if form.overwrite.errors %}
<span class="w3-text-red w3-small">{{ form.overwrite.errors|join:", " }}</span>
{% endif %}
</div>
</div> </div>
</div> </div>
</div>
<!-- Boutons --> <!-- Boutons -->
<div class="w3-row-padding w3-margin-bottom"> <div class="w3-row-padding w3-margin-bottom">
<div class="w3-col s12"> <div class="w3-col s12">
<button type="submit" id="submit-btn" <button type="submit" id="submit-btn" name="valid" value="ok"
class="w3-button w3-blue-grey w3-round w3-large w3-padding-large" class="w3-button w3-blue-grey w3-round w3-large w3-padding-large"
disabled> disabled>
📂 {% trans "Importer" %} 📂 {% trans "Importer" %}
</button> </button>
<a href="{% url 'planarian:experiment-list' %}" <a href="/"
class="w3-button w3-light-grey w3-round w3-large w3-padding-large w3-margin-left"> class="w3-button w3-light-grey w3-round w3-large w3-padding-large w3-margin-left">
✖ {% trans "Annuler" %} ✖ {% trans "Annuler" %}
</a> </a>
</div> </div>
</div> </div>
</form> </form>
<!-- ============================================================ <!-- ============================================================
Format attendu du CSV Format attendu du CSV
============================================================ --> ============================================================ -->
<div class="w3-card-4 w3-round-large"> <div class="w3-card-4 w3-round-large w3-border w3-round w3-round-large">
<header class="w3-container w3-teal w3-round-top-large"> <header class="w3-container w3-teal w3-round w3-round-top-large">
<h3 class="w3-text-white">{% trans "Format du fichier CSV" %}</h3> <h3 class="w3-text-white">{% trans "Format du fichier CSV" %}</h3>
</header> </header>
<div class="w3-container w3-padding-24"> <div class="w3-container w3-padding-24">
@@ -191,42 +144,30 @@
</thead> </thead>
<tbody> <tbody>
<tr><td><code>well_radius_mm</code></td> <td>8.0</td> <td>{% trans "Rayon du puits (mm)" %}</td></tr> <tr><td><code>well_radius_mm</code></td> <td>8.0</td> <td>{% trans "Rayon du puits (mm)" %}</td></tr>
<tr><td><code>thresh_immobile</code></td> <td>0.2</td> <td>{% trans "Seuil Immobile EthoVision (mm/s)" %}</td></tr> <tr class="w3-grey"><td><code>thresh_immobile</code></td> <td>0.2</td> <td>{% trans "Seuil Immobile EthoVision (mm/s)" %}</td></tr>
<tr><td><code>thresh_mobile</code></td> <td>1.5</td> <td>{% trans "Seuil Mobile EthoVision (mm/s)" %}</td></tr> <tr><td><code>thresh_mobile</code></td> <td>1.5</td> <td>{% trans "Seuil Mobile EthoVision (mm/s)" %}</td></tr>
<tr><td><code>tube_axis</code></td> <td>vertical</td> <td>{% trans "Axe du tube : vertical | horizontal" %}</td></tr> <tr class="w3-grey"><td><code>tube_axis</code></td> <td>vertical</td> <td>{% trans "Axe du tube : vertical | horizontal" %}</td></tr>
<tr><td><code>min_area_px</code></td> <td>20</td> <td>{% trans "Surface min de détection (px²)" %}</td></tr> <tr><td><code>min_area_px</code></td> <td>20</td> <td>{% trans "Surface min de détection (px²)" %}</td></tr>
<tr><td><code>planarian_count</code></td> <td>1</td> <td>{% trans "Nombre de planaires par puits" %}</td></tr> <tr class="w3-grey"><td><code>planarian_count</code></td> <td>1</td> <td>{% trans "Nombre de planaires par puits" %}</td></tr>
<tr><td><code>photo_mode</code></td> <td>none</td> <td>{% trans "Phototactisme : none | fixed | sine | radial" %}</td></tr> <tr><td><code>photo_mode</code></td> <td>none</td> <td>{% trans "Phototactisme : none | fixed | sine | radial" %}</td></tr>
<tr><td><code>photo_strength</code></td> <td>0.0</td> <td>{% trans "Intensité phototactisme (0-1)" %}</td></tr> <tr class="w3-grey"><td><code>photo_strength</code></td> <td>0.0</td> <td>{% trans "Intensité phototactisme (0-1)" %}</td></tr>
<tr><td><code>chemo_strength</code></td> <td>0.0</td> <td>{% trans "Intensité chimiotactisme (0-1)" %}</td></tr> <tr><td><code>chemo_strength</code></td> <td>0.0</td> <td>{% trans "Intensité chimiotactisme (0-1)" %}</td></tr>
<tr><td><code>chemo_radius_mm</code></td> <td>2.0</td> <td>{% trans "Rayon zone nourriture (mm)" %}</td></tr> <tr class="w3-grey"><td><code>chemo_radius_mm</code></td> <td>2.0</td> <td>{% trans "Rayon zone nourriture (mm)" %}</td></tr>
<tr><td><code>avoid_radius_mm</code></td> <td>3.0</td> <td>{% trans "Rayon évitement inter-individus (mm)" %}</td></tr> <tr><td><code>avoid_radius_mm</code></td> <td>3.0</td> <td>{% trans "Rayon évitement inter-individus (mm)" %}</td></tr>
<tr><td><code>aggreg_radius_mm</code></td> <td>6.0</td> <td>{% trans "Rayon agrégation inter-individus (mm)" %}</td></tr> <tr class="w3-grey"><td><code>aggreg_radius_mm</code></td> <td>6.0</td> <td>{% trans "Rayon agrégation inter-individus (mm)" %}</td></tr>
</tbody> </tbody>
</table> </table>
</div> </div>
<!-- Exemple de fichier --> <!-- Exemple de fichier -->
<p><b>{% trans "Exemple minimal" %}</b></p> <p><b>{% trans "Exemple minimal" %}</b></p>
<div class="w3-code w3-round" style="font-size:12px; overflow-x:auto;"> <div class="w3-code w3-round w3-text-grey" style="font-size:12px; overflow-x:auto;">
experiment,well,px_per_mm,fps,thresh_immobile,thresh_mobile,photo_mode<br> experiment,well,px_per_mm,fps,thresh_immobile,thresh_mobile,photo_mode<br>
exp_ctrl_01,A1,26.25,10,0.2,1.5,none<br> exp_ctrl_01,A1,26.25,10,0.2,1.5,none<br>
exp_ctrl_01,A2,26.25,10,0.2,1.5,none<br> exp_ctrl_01,A2,26.25,10,0.2,1.5,none<br>
exp_light_01,B1,26.25,10,0.2,1.5,fixed<br> exp_light_01,B1,26.25,10,0.2,1.5,fixed<br>
exp_light_01,B2,26.25,10,0.2,1.5,fixed<br> exp_light_01,B2,26.25,10,0.2,1.5,fixed<br>
</div> </div>
<!-- Téléchargement du template -->
<div class="w3-margin-top">
<a href="{% url 'planarian:experiment-list' %}?export_template=1"
class="w3-button w3-teal w3-round w3-small">
⬇ {% trans "Télécharger un template CSV vide" %}
</a>
<span class="w3-small w3-text-grey w3-margin-left">
{% trans "Ou exportez vos configurations existantes depuis l'admin Django." %}
</span>
</div>
</div> </div>
</div> </div>
@@ -322,5 +263,9 @@ exp_light_01,B2,26.25,10,0.2,1.5,fixed<br>
return (bytes / 1048576).toFixed(1) + ' MB'; return (bytes / 1048576).toFixed(1) + ' MB';
} }
</script> </script>
{% else %}
<div class="w3-container w3-padding-64 w3-margin" style="max-width:760px; margin:auto;">
<h3 class="w3-panel w3-blue w3-round-xlarge w3-padding-64 w3-center"> {% trans "Choisir une session" %}<br>{% trans "Puis une expérience." %}</h3>
</div>
{% endif %}
{% endblock %} {% endblock %}
+4 -8
View File
@@ -6,16 +6,12 @@ from planarian import views
app_name = "planarian" app_name = "planarian"
urlpatterns = [ urlpatterns = [
# Configurations expériences
path("experiments/", views.ExperimentConfigListView.as_view(), name="experiment-list"),
path("experiments/new/", views.ExperimentConfigFormView.as_view(), name="experiment-new"),
path("experiments/<int:pk>/",views.ExperimentConfigFormView.as_view(), name="experiment-edit"),
# Import / export # Import / export
path("import/", views.ImportParamsView.as_view(), name="import-params"), path("import/csv/", views.import_csv_view, name="import-params"),
path("export/", views.ExportCsvView.as_view(), name="export-csv"), path("export/csv/", views.export_csv_view, name="export-csv"),
path("api/export/csv/", views.export_metrics, name="api-export-csv"),
# API JSON pour le front-end # API JSON pour le front-end
path("api/tracking/", views.TrackingDataView.as_view(), name="tracking-data-api"), path("api/tracking/", views.TrackingDataView.as_view(), name="tracking-data-api"),
] ]
+164 -105
View File
@@ -2,89 +2,154 @@
#import asyncio #import asyncio
import logging import logging
import csv
import io
import json
from asgiref.sync import async_to_sync from asgiref.sync import async_to_sync
from django.conf import settings from django.conf import settings
from django.contrib import messages from django.contrib import messages
from django.http import HttpResponse, JsonResponse from django.http import JsonResponse, FileResponse
from django.shortcuts import get_object_or_404, redirect #, render from django.shortcuts import redirect
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.shortcuts import render #, redirect
from django.views.decorators.http import require_GET
from django.contrib.auth.decorators import login_required
from django.views import View from django.views import View
from django.views.generic import FormView, ListView
from .forms import CsvImportForm, ExperimentConfigForm, ExportCsvForm
from .models import ExperimentConfig
from modules.planarian_metrics import ExperimentParams, ReductStoreClient from modules.planarian_metrics import ExperimentParams, ReductStoreClient
from modules.system_stats import get_cached_stats, start_background_updater
from scanner.constants import ScannerConstants
from .tasks import export_experiment_metrics_task, export_session_metrics_task
from .export_service import export_csv_sync
from scanner import models, views as scanner_views
from .models import ExperimentConfig
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
start_background_updater()
def is_staff_or_admin(user):
return user.is_staff or user.is_superuser
@require_GET
def stats_view(request):
"""
Retourne tout le cache (shm, cpu_info, memory_info, disk_info, updated_at)
"""
try:
data = get_cached_stats()
return JsonResponse(data, safe=False)
except Exception as e:
return JsonResponse({"error": str(e)}, status=500)
def _get_reduct_client() -> ReductStoreClient: def _get_reduct_client() -> ReductStoreClient:
"""Instancie le client ReductStore depuis les settings Django.""" """Instancie le client ReductStore depuis les settings Django."""
return ReductStoreClient( return ReductStoreClient(url=settings.REDUCTSTORE_URL, token=settings.REDUCTSTORE_TOKEN)
url = getattr(settings, "REDUCTSTORE_URL", "http://localhost:8383"),
token = getattr(settings, "REDUCTSTORE_TOKEN", ""),
bucket = getattr(settings, "REDUCTSTORE_BUCKET", "planarian_metrics"), def global_context(request, **ctx):
conf = ScannerConstants().get()
return dict(
app_title=settings.APP_TITLE,
app_sub_title=settings.APP_SUB_TITLE,
domain_server=settings.DOMAIN_SERVER,
local_ip_server=settings.LOCAL_IP_SERVER,
host_port=settings.SERVER_HOST_PORT,
export_csv_destination=settings.CSV_EXPORT_DIR,
conf=conf,
**ctx
) )
# --------------------------------------------------------------------------- def get_active_session(request, session_id=None, experiment_id=None):
# Vue : liste des configurations cursid = session_id or request.POST.get('_sid')
# --------------------------------------------------------------------------- expid = experiment_id or request.POST.get('_expid')
class ExperimentConfigListView(ListView):
"""Liste toutes les configurations expériences."""
model = ExperimentConfig
template_name = "planarian/experiment_list.html"
context_object_name = "configs"
ordering = ["-created_at"]
current_session = models.Session.get_session(cursid)
experiments, current_experiment = scanner_views.get_not_active_experiments(current_session, expid)
context = dict(
current_session = current_session,
current_experiment = current_experiment,
experiments=experiments or [],
sessions=models.Session.objects.filter(active=False).all(),
well_choices=models.Well.objects.order_by('name').all(),
)
return context
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Vue : création / modification d'une configuration # Vue :Export CSV depuis ReductStore
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@login_required
def export_metrics(request):
data = json.loads(request.body.decode() or "{}")
action = data.get("action")
pid = data.get("pid")
if action=='experiment_csv':
experiment = models.Experiment.objects.filter(pk=pid).first()
if experiment:
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:
export_session_metrics_task(pid) # @UndefinedVariable
return JsonResponse({"success": True, "msg": str(_("Métrics en cours de téléchargement"))})
return JsonResponse({"success": False, "msg": str(_("Erreur: les métrics non pas été téléchargés"))})
class ExperimentConfigFormView(FormView):
"""Formulaire de saisie des paramètres d'une expérience."""
template_name = "planarian/experiment_form.html" @login_required
form_class = ExperimentConfigForm def export_csv_view(request):
session_context = get_active_session(request)
ctx = {
'choice_title': _("Export vers un fichier CSV depuis ReductStore"),
'well': 'A1',
'planarian': "0",
'record_type': 'frame',
**session_context
}
if request.method == 'POST':
valid = request.POST.get('valid')
session = session_context['current_session']
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.pk, experiment.multiwell.position, well_name) # type: ignore[union-attr]
'''
csv_content, filename = export_csv(request, uuid)
if csv_content:
response = FileResponse(csv_content, content_type="text/csv; charset=utf-8")
response["Content-Disposition"] = f'attachment; filename="{filename}"'
return response'''
csv_content, n = export_csv_sync(
experiment=experiment.identifier, # type: ignore[union-attr]
well=well_name,
uuid=uuid,
planarian=request.POST.get("planarian"),
record_type=request.POST.get("record_type"),
start=request.POST.get("start_dt"),
stop=request.POST.get("stop_dt"),
)
if csv_content:
filename = (
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)}")
response = FileResponse(csv_content, content_type="text/csv; charset=utf-8")
response["Content-Disposition"] = (f'attachment; filename="{filename}"')
return response
def get_form(self, form_class=None): messages.warning(request, _("Aucune donnée trouvée."))
pk = self.kwargs.get("pk") return render(request, "planarian/export_csv.html", context=global_context(request, **ctx))
if pk:
instance = get_object_or_404(ExperimentConfig, pk=pk)
return ExperimentConfigForm(self.request.POST or None, instance=instance)
return ExperimentConfigForm(self.request.POST or None)
def form_valid(self, form):
form.save()
messages.success(self.request, _("Configuration sauvegardée."))
return redirect("planarian:experiment-list")
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Vue : import CSV de paramètres # Vue : import CSV de paramètres
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def import_csv(request, current_experiment, rows, overwrite):
class ImportParamsView(FormView):
"""
Import de configurations d'expérience depuis un fichier CSV.
Une ligne CSV = un puits = un ExperimentConfig.
Colonnes CSV obligatoires : experiment, well, px_per_mm, fps
Toutes les autres colonnes correspondent aux champs du modèle.
"""
template_name = "planarian/import_params.html"
form_class = CsvImportForm
def form_valid(self, form):
rows = form.csv_rows
overwrite = form.cleaned_data["overwrite"]
created = 0 created = 0
updated = 0 updated = 0
errors = 0 errors = 0
@@ -95,13 +160,13 @@ class ImportParamsView(FormView):
d = params.to_dict() d = params.to_dict()
obj, is_new = ExperimentConfig.objects.get_or_create( obj, is_new = ExperimentConfig.objects.get_or_create(
experiment = d["experiment"], experiment = current_experiment.identifier,
well = d["well"], well = d.get("well"),
) )
if is_new or overwrite: if is_new or overwrite:
for k, v in d.items(): for k, v in d.items():
if k not in ("experiment", "well") and hasattr(obj, k): if k not in ["well", "experiment", "author", "experiment_key", "active"] and hasattr(obj, k):
setattr(obj, k, v) setattr(obj, k, v)
obj.save() obj.save()
if is_new: if is_new:
@@ -112,68 +177,58 @@ class ImportParamsView(FormView):
except Exception as e: except Exception as e:
logger.warning(f"Ligne ignorée ({row}): {e}") logger.warning(f"Ligne ignorée ({row}): {e}")
errors += 1 errors += 1
messages.success( messages.success(
self.request, request,
_("Import terminé : %(c)d créés, %(u)d mis à jour, %(e)d erreurs.") _("Import terminé : %(c)d créés, %(u)d mis à jour, %(e)d erreurs.")
% {"c": created, "u": updated, "e": errors}, % {"c": created, "u": updated, "e": errors},
) )
return redirect("planarian:experiment-list")
return redirect("redirect_to_mainboard")
# --------------------------------------------------------------------------- @login_required
# Vue : export CSV depuis ReductStore def import_csv_view(request):
# ---------------------------------------------------------------------------
class ExportCsvView(FormView):
""" """
Export des données de tracking depuis ReductStore vers un fichier CSV. Import de configurations d'expérience depuis un fichier CSV.
Retourne le fichier en téléchargement HTTP. Une ligne CSV = un puits = un ExperimentConfig.
Colonnes CSV obligatoires: well, px_per_mm, fps
Toutes les autres colonnes correspondent aux champs du modèle.
""" """
session_context = get_active_session(request)
if request.method == 'POST':
valid = request.POST.get('valid')
current_experiment = session_context.get('current_experiment')
template_name = "planarian/export_csv.html" if valid == 'ok' and current_experiment:
form_class = ExportCsvForm
def form_valid(self, form):
d = form.cleaned_data
@async_to_sync
async def _do_export():
client = _get_reduct_client()
await client.connect()
try: try:
csv_content, n = await client.export_csv_response( f = request.FILES.get('csv_file')
experiment = d["experiment"], overwrite = request.POST.get("overwrite")
well = d["well"], try:
planarian = d["planarian"], content = f.read().decode("utf-8-sig")
record_type = d["record_type"], reader = csv.DictReader(io.StringIO(content))
start = d.get("start_dt"), rows = list(reader)
stop = d.get("stop_dt"), except Exception as e:
) msg = f'Fichier CSV invalide : {e}'
finally: raise Exception(msg)
await client.close()
return csv_content, n
csv_content, n = _do_export() required = {"well", "px_per_mm", "fps"}
if rows:
if not csv_content: missing = required - set(rows[0].keys())
messages.warning(self.request, _("Aucune donnée trouvée.")) if missing:
return self.form_invalid(form) msg = _("Colonnes manquantes : %(cols)s") % {"cols": ", ".join(missing)}
raise Exception(msg)
filename = (
f"{d['experiment']}_{d['well']}_planaire{d['planarian']}"
f"_{d['record_type']}.csv"
)
response = HttpResponse(csv_content, content_type="text/csv; charset=utf-8")
response["Content-Disposition"] = f'attachment; filename="{filename}"'
messages.success(self.request, _("%(n)d lignes exportées.") % {"n": n})
return response
return import_csv(request, current_experiment, rows, overwrite)
except Exception as e:
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))
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Vue API JSON : données de tracking (pour polling front-end) # Vue API JSON : données de tracking (pour polling front-end)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TrackingDataView(View): class TrackingDataView(View):
""" """
API JSON retournant les métriques de tracking d'un planaire. API JSON retournant les métriques de tracking d'un planaire.
@@ -202,9 +257,13 @@ class TrackingDataView(View):
planarian = planarian, planarian = planarian,
record_type = record_type, record_type = record_type,
) )
finally: except Exception as e:
await client.close() logger.error(f"Erreur fetching tracking data: {e}")
records = _fetch() records = _fetch()
return JsonResponse({"count": len(records), "records": records}) return JsonResponse({"count": len(records), "records": records})
def get_context_data(self, **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)
+1335
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -0,0 +1,5 @@
#!/bin/bash
echo "==== Starting server development in debug mode port 8000 ..."
echo "===="
../.venv/bin/python manage.py runserver 0.0.0.0:8000
+162 -9
View File
@@ -1,8 +1,10 @@
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.contrib import admin
from django.db.models import Q from django.db.models import Q
from . import models from . import models
class WellAdmin(admin.ModelAdmin): class WellAdmin(admin.ModelAdmin):
model = models.Well model = models.Well
list_display = ('name', 'author',) list_display = ('name', 'author',)
@@ -10,24 +12,74 @@ class WellAdmin(admin.ModelAdmin):
class ConfigurationAdmin(admin.ModelAdmin): class ConfigurationAdmin(admin.ModelAdmin):
list_display = ('name', 'author', 'capture_type', 'video_width_capture', 'video_height_capture', 'video_frame_rate', 'active',) list_display = ('name', 'author', 'capture_type', 'video_width_capture', 'video_height_capture', 'video_frame_rate', 'active',)
fieldsets = (
(_("Identification"), {
"fields": ("name", "author", "active"),
}),
(_("Dashboard"), {
"fields": ("sidebar_width", "default_grid_columns",),"classes": ("collapse",),
}),
(_("opencv"), {
"fields": ("opencv_fourcc_format", "opencv_video_type"),"classes": ("collapse",),
}),
(_("Grbl"), {
"fields": ("grbl_xmax", "grbl_ymax"),
"classes": ("collapse",),
}),
(_("Camera"), {
"fields": ("scan_simulation", "capture_type", "webcam_device_index", "image_quality", "video_jpeg_quality", "video_frame_rate", "video_width_capture", "video_height_capture"),
"classes": ("collapse",),
}),
(_("Calibration / Balayage"), {
"fields": ("tube_axis", "calibration_crop_radius", "calibration_default_multiwell", "calibration_default_feed", "calibration_default_step", "calibration_default_duration"),
"classes": ("collapse",),
}),
(_("Tracking: valeurs par défaut"), {
"fields": ("tracking", "tracking_setting", "min_area_px", "max_area_ratio", "max_planarians", "merge_kernel_size", "min_contour_dist_px"),
"classes": ("collapse",),
}),
)
class MultiWellAdmin(admin.ModelAdmin): class MultiWellAdmin(admin.ModelAdmin):
list_filter = ('author', ) 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", "capture_video", "active"),
}),
(_("Géométrie"), {
"fields": ("cols", "rows", "diameter", "crop_radius", "row_def", "row_order"),"classes": ("collapse",),
}),
(_("Déplacement"), {
"fields": ("order", "duration", "xbase", "ybase", "dx", "dy", "feed"),"classes": ("collapse",),
}),
(_("Positions générées"), {
"fields": ("well_position",),
}),
)
class WellPositionAdmin(admin.ModelAdmin): class WellPositionAdmin(admin.ModelAdmin):
list_filter = ('author', 'multiwell') list_filter = ('author', 'multiwell')
list_display = ('multiwell__position', 'well__name', 'order', 'x', 'y', 'px_per_mm', 'author',) list_display = ('multiwell__position', 'well__name', 'order', 'x', 'y', 'px_per_mm', 'author',)
#class ExperimentConfigInline(admin.TabularInline): class ExperimentWellInline(admin.TabularInline):
# model = models.ExperimentConfig model = models.ExperimentWell
# extra = 0 extra = 0
#ordering = ('experiment__multiwell__wellposition__order',)
class ExperimentAdmin(admin.ModelAdmin): class ExperimentAdmin(admin.ModelAdmin):
#inlines = (ExperimenConfigInline,) inlines = (ExperimentWellInline, )
list_filter = ('session_experiments__session', 'author', ) list_filter = ('session_experiments__session', 'author', )
list_display = ('title', 'author', 'multiwell', 'created', 'started', 'finished') list_display = ('title', 'author', 'identifier', 'duration', 'multiwell', 'created', 'started', 'finished')
readonly_fields = ('created', 'started', 'finished', ) readonly_fields = ('created', 'identifier', 'started', 'finished', )
class SessionExperimentInlineAdmin(admin.TabularInline): class SessionExperimentInlineAdmin(admin.TabularInline):
model = models.SessionExperiment model = models.SessionExperiment
@@ -51,7 +103,7 @@ class SessionExperimentInlineAdmin(admin.TabularInline):
class SessionAdmin(admin.ModelAdmin): class SessionAdmin(admin.ModelAdmin):
list_filter = ('author',) list_filter = ('author',)
inlines = (SessionExperimentInlineAdmin, ) inlines = (SessionExperimentInlineAdmin, )
list_display = ('name', 'author', 'created', 'finished', 'active', 'expected_export', 'expected_scanning', ) list_display = ('name', 'id', 'author', 'created', 'finished', 'active', 'expected_export', 'expected_scanning', )
readonly_fields = ( readonly_fields = (
'created', 'created',
'finished', 'finished',
@@ -63,6 +115,107 @@ class SessionAdmin(admin.ModelAdmin):
'scanning_finished_at' '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(
'<video src="{}" controls style="max-width:480px;max-height:320px;'
'border-radius:4px;"></video>',
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.Configuration, ConfigurationAdmin)
admin.site.register(models.Well, WellAdmin) admin.site.register(models.Well, WellAdmin)
admin.site.register(models.MultiWell, MultiWellAdmin) admin.site.register(models.MultiWell, MultiWellAdmin)
+17 -1
View File
@@ -19,15 +19,23 @@ class DefaultConfig:
webcam_device_index: int = 2 webcam_device_index: int = 2
image_quality: int = 90 image_quality: int = 90
video_jpeg_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_width_capture: int = 2028
video_height_capture: int = 1520 video_height_capture: int = 1520
scan_simulation: bool = False
calibration_crop_radius: int = 500 calibration_crop_radius: int = 500
calibration_default_multiwell: str = 'HD' calibration_default_multiwell: str = 'HD'
calibration_default_feed: int = 1000 calibration_default_feed: int = 1000
calibration_default_step: float = 1.0 calibration_default_step: float = 1.0
calibration_default_duration: float = 3.0 calibration_default_duration: float = 3.0
tracking: bool = False tracking: bool = False
tracking_setting: bool = False
tube_axis: str = 'vertical'
min_area_px: int = 20
max_area_ratio: float = 0.10
max_planarians: int = 1
merge_kernel_size: int = 15
min_contour_dist_px: int = 40
class ScannerConstants: class ScannerConstants:
@@ -44,4 +52,12 @@ class ScannerConstants:
def get(self): def get(self):
return self.conf 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)
-3
View File
@@ -33,9 +33,6 @@ class ScannerConsumer(AsyncWebsocketConsumer):
if msg_type in ["scanner", "calibrate"]: if msg_type in ["scanner", "calibrate"]:
redisDB.publish(self.this_group, json.dumps(data)) redisDB.publish(self.this_group, json.dumps(data))
async def replay_message(self, event):
await self.send(text_data=json.dumps(event["text"]))
class ReplayConsumer(AsyncWebsocketConsumer): class ReplayConsumer(AsyncWebsocketConsumer):
+37 -49
View File
@@ -7,7 +7,7 @@ import posix_ipc
import mmap import mmap
import cv2 import cv2
import numpy as np import numpy as np
from django.http import JsonResponse, HttpResponse #from django.http import JsonResponse, HttpResponse
from django.conf import settings from django.conf import settings
from reduct.time import unix_timestamp_to_iso from reduct.time import unix_timestamp_to_iso
@@ -34,24 +34,20 @@ def delete_file_later(path):
async def remove_video_by_uuid(uuid, start_ts=None, end_ts=None, when=None): async def remove_video_by_uuid(uuid, start_ts=None, end_ts=None, when=None):
record_manager = CameraRecordManager(cameraDB) 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): async def remove_video(uuid, start_ts, end_ts, when=None):
try:
await remove_video_by_uuid(uuid, start_ts, end_ts, when=when) await remove_video_by_uuid(uuid, start_ts, end_ts, when=when)
return JsonResponse({'state': 'ok'}, status=200)
except Exception as e:
return JsonResponse({'error': str(e)}, status=500)
async def shm_download_video(uuid, start_ts, end_ts, frame_rate=5, opencv_fourcc_format='mp4v', opencv_video_type='mp4'): 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: try:
record_manager = CameraRecordManager(cameraDB) record_manager = CameraRecordManager(cameraDB)
total_size = await record_manager.size(uuid, start_ts, end_ts) total_size = await record_manager.size(uuid, start_ts, end_ts)
# segment de mémoire partagée pour stocker les frames # 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}" shm_name = f"/video_frames_{uuid}"
try: try:
shm = posix_ipc.SharedMemory(shm_name, posix_ipc.O_CREAT | posix_ipc.O_EXCL, size=shm_size) shm = posix_ipc.SharedMemory(shm_name, posix_ipc.O_CREAT | posix_ipc.O_EXCL, size=shm_size)
@@ -79,14 +75,16 @@ async def shm_download_video(uuid, start_ts, end_ts, frame_rate=5, opencv_fourcc
total += 1 total += 1
if not frame_sizes: if not frame_sizes:
return JsonResponse({'error': 'Aucune frame trouvée'}, status=404) 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}") #video_path = os.path.join(settings.MEDIA_ROOT, f"output.{opencv_video_type}")
fourcc = cv2.VideoWriter_fourcc(* opencv_fourcc_format) fourcc = cv2.VideoWriter_fourcc(*opencv_fourcc_format) # type: ignore[attr-defined]
# Lit les frames depuis la mémoire partagée # Lit les frames depuis la mémoire partagée
current_offset = 0 current_offset = 0
i = 0 i = 0
video = None
for size in frame_sizes: for size in frame_sizes:
frame_bytes = mm[current_offset:current_offset + size] frame_bytes = mm[current_offset:current_offset + size]
nparr = np.frombuffer(frame_bytes, np.uint8) nparr = np.frombuffer(frame_bytes, np.uint8)
@@ -97,36 +95,17 @@ async def shm_download_video(uuid, start_ts, end_ts, frame_rate=5, opencv_fourcc
video.write(frame) video.write(frame)
current_offset += size current_offset += size
progress_bar(i + 1, total, prefix=f'Progression {uuid}:', suffix='Terminé', length=30) progress_bar(i + 1, total, prefix=f'Progress {uuid}:', suffix='Ended', length=30)
i+=1 i+=1
if video:
video.release() video.release()
# Nettoie la mémoire partagée # Nettoie la mémoire partagée
shm.unlink() shm.unlink()
# Vérifier que le fichier existe return { "status": 404, "success": True, "video_path": video_path}
if not os.path.exists(video_path):
logger.error(f"Fichier non créé: {video_path}")
return JsonResponse({'error': 'Erreur création vidéo'}, status=500)
# Lit le fichier vidéo généré
with open(video_path, 'rb') as f:
video_bytes = f.read()
# Retourne la vidéo en réponse
response = HttpResponse(video_bytes, content_type='video/mp4')
response['Content-Disposition'] = f'attachment; filename="{video_path}"'
response['Content-Length'] = os.path.getsize(video_path)
# Supprime le fichier temporaire
os.remove(video_path)
return response
except Exception as e: except Exception as e:
logger.error(f"shm_download_video: {e}") return {"status": 500, "success": False, "video_path": video_path, "error": str(e)}
return JsonResponse({'error': str(e)}, status=500)
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
## ##
@@ -145,6 +124,10 @@ def _copy_to_destinations(source_path: str, filename: str) -> dict:
""" """
results = {"local": None, "remote": None} results = {"local": None, "remote": None}
filename = filename.replace(':', '_')
logger.info("%s %s", source_path, filename)
for dest in settings.EXPORT_DESTINATIONS: for dest in settings.EXPORT_DESTINATIONS:
if dest == "local": if dest == "local":
@@ -152,9 +135,9 @@ def _copy_to_destinations(source_path: str, filename: str) -> dict:
results["local"] = source_path results["local"] = source_path
elif dest == "remote": elif dest == "remote":
remote_path = os.path.join(settings.EXPORT_REMOTE_DIR, filename) remote_path = os.path.join(settings.EXPORT_REMOTE_PATH, filename)
try: try:
if not remote_mount_available(settings.EXPORT_REMOTE_DIR): if not remote_mount_available(settings.EXPORT_REMOTE_PATH):
logger.warning("Partage Samba non disponible, copie ignorée") logger.warning("Partage Samba non disponible, copie ignorée")
results["remote_error"] = "Montage indisponible" results["remote_error"] = "Montage indisponible"
continue continue
@@ -203,7 +186,7 @@ async def export_images_zip(
# --- Chargement des frames en mémoire partagée --- # --- Chargement des frames en mémoire partagée ---
record_manager = CameraRecordManager(cameraDB) record_manager = CameraRecordManager(cameraDB)
total_size = await record_manager.size(uuid, start_ts, end_ts) 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: try:
shm = posix_ipc.SharedMemory( shm = posix_ipc.SharedMemory(
@@ -243,11 +226,12 @@ async def export_images_zip(
total += 1 total += 1
if not frame_sizes: if not frame_sizes:
return {"status": "error", "message": "Aucune frame trouvée"} return {"status": "error", "message": "No frame found!..."}
# --- Génération du ZIP --- # --- Génération du ZIP ---
max_zip_bytes = max_zip_size_mb * 1024 * 1024 if max_zip_size_mb > 0 else 0 max_zip_bytes = max_zip_size_mb * 1024 * 1024 if max_zip_size_mb > 0 else 0
ts_s = unix_timestamp_to_iso(start_ts) ts_s = unix_timestamp_to_iso(start_ts)
zip_filename = f"{uuid}_{ts_s}.zip" zip_filename = f"{uuid}_{ts_s}.zip"
zip_path = os.path.join(settings.EXPORTS_LOCAL_PATH, 'images', zip_filename) zip_path = os.path.join(settings.EXPORTS_LOCAL_PATH, 'images', zip_filename)
os.makedirs(os.path.dirname(zip_path), exist_ok=True) os.makedirs(os.path.dirname(zip_path), exist_ok=True)
@@ -292,19 +276,21 @@ async def export_images_zip(
zf.writestr(f"{uuid}_{ts_iso}.jpg", buf.tobytes()) zf.writestr(f"{uuid}_{ts_iso}.jpg", buf.tobytes())
written += 1 written += 1
progress_bar(i + 1, total, prefix=f'Progression {uuid}:', suffix='Terminé', length=30) progress_bar(i + 1, total, prefix=f'Progress {uuid}:', suffix='Ended', length=30)
i+=1 i+=1
current_offset += size current_offset += size
## Copie vers les destinations (local + Samba) ## Copie vers les destinations (local + Samba)
#destinations = _copy_to_destinations(zip_path, zip_filename) filename = os.path.join('images', zip_filename)
destinations = _copy_to_destinations(zip_path, filename)
return { return {
"status": "success", "status": "success",
"zip_path": zip_path, "zip_path": zip_path,
"frames_written": written, "frames_written": written,
"frames_skipped": skipped, "frames_skipped": skipped,
"jpeg_quality": jpeg_quality, "jpeg_quality": jpeg_quality,
#"destinations": destinations, "destinations": destinations,
} }
except Exception as exc: except Exception as exc:
@@ -357,7 +343,7 @@ async def export_video_mp4(
# --- Chargement des frames en mémoire partagée --- # --- Chargement des frames en mémoire partagée ---
record_manager = CameraRecordManager(cameraDB) record_manager = CameraRecordManager(cameraDB)
total_size = await record_manager.size(uuid, start_ts, end_ts) 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: try:
shm = posix_ipc.SharedMemory( shm = posix_ipc.SharedMemory(
@@ -395,7 +381,7 @@ async def export_video_mp4(
total +=1 total +=1
if not frame_sizes: if not frame_sizes:
return {"status": "error", "message": "Aucune frame trouvée"} return {"status": "error", "message": "No frame found!..."}
# --- Génération du MP4 --- # --- Génération du MP4 ---
@@ -407,7 +393,7 @@ async def export_video_mp4(
) )
os.makedirs(os.path.dirname(video_path), exist_ok=True) 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 skipped = 0
written = 0 written = 0
current_offset = 0 current_offset = 0
@@ -451,18 +437,20 @@ async def export_video_mp4(
written += 1 written += 1
current_offset += size current_offset += size
progress_bar(i + 1, total, prefix=f'Progression {uuid}:', suffix='Terminé', length=30) progress_bar(i + 1, total, prefix=f'Progress {uuid}:', suffix='Ended', length=30)
i+=1 i+=1
if video: if video:
video.release() video.release()
if not os.path.exists(video_path): if not os.path.exists(video_path):
return {"status": "error", "message": f"Fichier {opencv_video_type} non créé"} return {"status": "error", "message": f"File {opencv_video_type} not created!..."}
## Copie vers les destinations (local + Samba) ## Copie vers les destinations (local + Samba)
#filename = os.path.basename(video_path) filename = os.path.basename(video_path)
#destinations = _copy_to_destinations(video_path, filename) filename = os.path.join('videos', filename)
destinations = _copy_to_destinations(video_path, filename)
return { return {
"status": "success", "status": "success",
"video_path": video_path, "video_path": video_path,
@@ -470,7 +458,7 @@ async def export_video_mp4(
"frames_skipped": skipped, "frames_skipped": skipped,
"frame_rate": frame_rate, "frame_rate": frame_rate,
"file_size_mb": round(os.path.getsize(video_path) / 1024 / 1024, 2), "file_size_mb": round(os.path.getsize(video_path) / 1024 / 1024, 2),
#"destinations": destinations, "destinations": destinations,
} }
except Exception as exc: except Exception as exc:
@@ -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 <contour>')),
('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')},
},
),
]
@@ -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',
),
]
@@ -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'),
),
]
@@ -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'],
},
),
]
@@ -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'),
),
]
@@ -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'),
),
]
@@ -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)'),
),
]
@@ -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.',
),
),
]
@@ -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)'),
),
]
+254 -42
View File
@@ -4,22 +4,36 @@
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
import uuid import uuid
import json import json
from pathlib import Path
from django_celery_beat.models import PeriodicTask, ClockedSchedule from django_celery_beat.models import PeriodicTask, ClockedSchedule
from django.dispatch import receiver from django.dispatch import receiver
from django.db.models.signals import post_save, post_delete from django.db.models.signals import post_save, post_delete
from django.utils import timezone from django.utils import timezone
from django.db import models from django.db import models
from django.contrib.auth.models import User from django.contrib.auth.models import User
MULTIWELL_POSITION = [ MULTIWELL_POSITION = [
('HG', _("HG-Haut gauche")), ('HG', _("MP 4x6: HG-Haut gauche")),
('HD', _("HD-Haut droit")), ('HD', _("MP 4x6: HD-Haut droit")),
('BG', _("BG-Bas gauche")), ('BG', _("MP 4x6: BG-Bas gauche")),
('BD', _("BD-Bas droit")), ('BD', _("MP 4x6: BD-Bas droit")),
('BM', _("BM-Bas milieu")), ('HG_6', _("MP 2x3: HG-Haut gauche")),
('HM', _("HM-Haut milieu")), ('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")),
('HG_96', _("MP 8x12: HG-Haut gauche")),
('HD_96', _("MP 8x12: HD-Haut droit")),
('BG_96', _("MP 8x12: BG-Bas gauche")),
('BD_96', _("MP 8x12: BD-Bas droit")),
] ]
FOURCC_FORMAT = [ FOURCC_FORMAT = [
@@ -35,12 +49,18 @@ VIDEO_TYPE = [
CAPTURE_TYPE = [ CAPTURE_TYPE = [
('rpi', _("Arducam")), ('rpi', _("Arducam")),
('webcam', _("Webcam")), ('webcam', _("Webcam")),
('file', _("mp4")), ('file', _("Simulation Fichier vidéo (mp4, avi)")),
('video', _("Fichier vidéo (mp4, avi)")),
]
TUBE_AXIS_TYPE = [
('vertical', _("Vertical")),
('horizontal', _("Horizontal")),
] ]
class Configuration(models.Model): class Configuration(models.Model):
name = models.CharField(_("Nom de la Configuration"), help_text=_("Nom de la configuration"), max_length=100, null=True, blank=False, default=_("Configuration par défaut")) name = models.CharField(_("Nom de la Configuration"), help_text=_("Nom de la configuration"), max_length=100, null=True, blank=False, default=_("Configuration par défaut"))
author = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name="Auteur", null=True, blank=True) author = models.ForeignKey(User, on_delete=models.SET_NULL, verbose_name="Auteur", null=True, blank=True)
# Dashboard configuration # Dashboard configuration
sidebar_width = models.CharField(_("Barre latérale"), help_text=_("Largeur barre latérale (css)"), max_length=32, null=True, blank=False, default="350px") sidebar_width = models.CharField(_("Barre latérale"), help_text=_("Largeur barre latérale (css)"), max_length=32, null=True, blank=False, default="350px")
default_grid_columns = models.PositiveSmallIntegerField(_("Colonnes de la grille par défaut"), help_text=_("Nombre de colonnes de la grille par défaut"), blank=False, default=3) default_grid_columns = models.PositiveSmallIntegerField(_("Colonnes de la grille par défaut"), help_text=_("Nombre de colonnes de la grille par défaut"), blank=False, default=3)
@@ -51,7 +71,8 @@ class Configuration(models.Model):
grbl_xmax = models.FloatField(_("Grbl Xmax"), help_text=_("CNC Grbl Xmax en mm"), blank=False, default=350.0) grbl_xmax = models.FloatField(_("Grbl Xmax"), help_text=_("CNC Grbl Xmax en mm"), blank=False, default=350.0)
grbl_ymax = models.FloatField(_("Grbl Ymax"), help_text=_("CNC Grbl Ymax en mm"), blank=False, default=250.0) grbl_ymax = models.FloatField(_("Grbl Ymax"), help_text=_("CNC Grbl Ymax en mm"), blank=False, default=250.0)
# camera configuration # camera configuration
capture_type = models.CharField(_("Capture"), help_text=_("Type de capture"), default='rpi', max_length=8, choices=CAPTURE_TYPE, null=True, blank=False) capture_type = models.CharField(_("Capture"), help_text=_("Type de capture. Nécessite un redémarrage en cas de modification à chaud!"), default='rpi', max_length=8, choices=CAPTURE_TYPE, null=True, blank=False)
webcam_device_index = models.PositiveSmallIntegerField(_("Index de la webcam"), help_text=_("Index de la webcam (0, 1, ...) si présente"), default=2) webcam_device_index = models.PositiveSmallIntegerField(_("Index de la webcam"), help_text=_("Index de la webcam (0, 1, ...) si présente"), default=2)
image_quality = models.PositiveSmallIntegerField(_("Qualité JPEG"), help_text=_("Qualité JPEG (1-100) pour les images exportées"), default=90) image_quality = models.PositiveSmallIntegerField(_("Qualité JPEG"), help_text=_("Qualité JPEG (1-100) pour les images exportées"), default=90)
video_jpeg_quality = models.PositiveSmallIntegerField(_("Qualité JPEG pour les vidéos"), help_text=_("Qualité JPEG (1-100) pour les images extraites des vidéos"), default=90) video_jpeg_quality = models.PositiveSmallIntegerField(_("Qualité JPEG pour les vidéos"), help_text=_("Qualité JPEG (1-100) pour les images extraites des vidéos"), default=90)
@@ -59,6 +80,7 @@ class Configuration(models.Model):
video_width_capture = models.PositiveSmallIntegerField(_("Largeur de capture vidéo"), help_text=_("Largeur de capture vidéo en pixels"), default=1280) video_width_capture = models.PositiveSmallIntegerField(_("Largeur de capture vidéo"), help_text=_("Largeur de capture vidéo en pixels"), default=1280)
video_height_capture = models.PositiveSmallIntegerField(_("Hauteur de capture vidéo"), help_text=_("Hauteur de capture vidéo en pixels"), default=720) video_height_capture = models.PositiveSmallIntegerField(_("Hauteur de capture vidéo"), help_text=_("Hauteur de capture vidéo en pixels"), default=720)
# Calibration # Calibration
scan_simulation = models.BooleanField(_("Simuler balayage"), help_text=_("Autorise la simulation du balayage"), default=False)
calibration_crop_radius = models.PositiveSmallIntegerField(_("Rayon de découpe pour la calibration"), help_text=_("Rayon en pixels pour découper les images de calibration en px"), default=150) calibration_crop_radius = models.PositiveSmallIntegerField(_("Rayon de découpe pour la calibration"), help_text=_("Rayon en pixels pour découper les images de calibration en px"), default=150)
calibration_default_multiwell = models.CharField(_("Multi-puits de calibration par défaut"), help_text=_("Position du multi-puits de calibration par défaut"), max_length=8, choices=MULTIWELL_POSITION, default='HG') calibration_default_multiwell = models.CharField(_("Multi-puits de calibration par défaut"), help_text=_("Position du multi-puits de calibration par défaut"), max_length=8, choices=MULTIWELL_POSITION, default='HG')
calibration_default_feed = models.PositiveIntegerField(_("Vitesse de calibration"), help_text=_("Vitesse de déplacement pour la calibration en mm/mn"), default=1000) calibration_default_feed = models.PositiveIntegerField(_("Vitesse de calibration"), help_text=_("Vitesse de déplacement pour la calibration en mm/mn"), default=1000)
@@ -66,10 +88,17 @@ class Configuration(models.Model):
calibration_default_duration = models.FloatField(_("Duruée calibration"), help_text=_("Durée de pose entre chaque puits en s"), default=3.0) calibration_default_duration = models.FloatField(_("Duruée calibration"), help_text=_("Durée de pose entre chaque puits en s"), default=3.0)
# tracking # tracking
tracking = models.BooleanField(_("Suivi"), help_text=_("Suivi et analyse des planaires"), default=False) tracking = models.BooleanField(_("Suivi"), help_text=_("Suivi et analyse des planaires"), default=False)
tracking_setting = models.BooleanField(_("Réglage dans calibration"), help_text=_("Autorise le réglage des valeurs par défaut dans la calibration"), default=False)
tube_axis = models.CharField(_("Axe du puit"), help_text=_("Axe du tube"), default='vertical', max_length=16, choices=TUBE_AXIS_TYPE, null=True, blank=False)
min_area_px = models.PositiveIntegerField(_("Surface minimale"), help_text=_("surface minimale d'un contour pour être considéré valide (px²)"), default=20)
max_area_ratio = models.FloatField(_("surface maximale "), help_text=_("surface maximale d'un contour en fraction de la frame (défaut 10%)"), default=0.10)
max_planarians = models.PositiveIntegerField(_("Max planaire"), help_text=_("nombre maximum de planaires à suivre simultanément (1-10)"), default=1)
merge_kernel_size = models.PositiveIntegerField(_("Taille du kernel"), help_text=_("taille du kernel elliptique de fusion des fragments (px). Augmenter si fragments résiduels"), default=15)
min_contour_dist_px = models.PositiveIntegerField(_("Distance <contour>"), help_text=_("Distance min entre deux contours pour les considérer comme individus distincts. Défaut : 40px. Augmenter si IDs multiples persistent"), default=40)
# #
active = models.BooleanField(_("Actif"), default=False) active = models.BooleanField(_("Actif"), default=False)
@classmethod @classmethod
def active_config(cls): def active_config(cls):
return Configuration.objects.filter(active=True).first() return Configuration.objects.filter(active=True).first()
@@ -83,7 +112,7 @@ class Configuration(models.Model):
return f'{self.name}' return f'{self.name}'
class Well(models.Model): class Well(models.Model):
author = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name="Auteur", null=True, blank=True) author = models.ForeignKey(User, on_delete=models.SET_NULL, verbose_name="Auteur", null=True, blank=True)
name = models.CharField(_("Nom"), help_text=_("Nom du puit: Ai..Di"), unique=True, max_length=4, null=True, blank=True) name = models.CharField(_("Nom"), help_text=_("Nom du puit: Ai..Di"), unique=True, max_length=4, null=True, blank=True)
class Meta: class Meta:
@@ -91,26 +120,27 @@ class Well(models.Model):
verbose_name = _("Puit") verbose_name = _("Puit")
verbose_name_plural = _("Puits") verbose_name_plural = _("Puits")
def __str__(self): def __str__(self):
return f'{self.name}' return f'{self.name}'
class MultiWell(models.Model): class MultiWell(models.Model):
# Identification
label = models.CharField(_("Label"), help_text=_("Label du multi-puit"), max_length=100, null=True, blank=True) label = models.CharField(_("Label"), help_text=_("Label du multi-puit"), max_length=100, null=True, blank=True)
author = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name="Auteur", 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) 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) 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) 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) 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_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") 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) order = models.PositiveSmallIntegerField(_("Ordre"), help_text=_('Ordre de lecture du multi-puit'), blank=False, default=0)
duration = models.PositiveIntegerField(_("Durée"), help_text=_('Durée du film en secondes'), blank=False, default=120) duration = models.PositiveIntegerField(_("Durée"), help_text=_('Durée de capture en secondes pour la calibration'), blank=False, default=10)
xbase = models.FloatField(_("Origine X"), help_text=_('Base origine X en mm'), blank=False, default=50.0) xbase = models.FloatField(_("Origine X"), help_text=_('Base origine X en mm'), blank=False, default=50.0)
ybase = models.FloatField(_("Origine Y"), help_text=_('Base origine Y en mm'), blank=False, default=50.0) ybase = models.FloatField(_("Origine Y"), help_text=_('Base origine Y en mm'), blank=False, default=50.0)
@@ -119,9 +149,9 @@ class MultiWell(models.Model):
feed = models.PositiveIntegerField(_("Vitesse"), help_text=_('Vitesse déplacement en mm/mn '), blank=False, default=1000) 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) 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) active = models.BooleanField(_("Active"), default=True)
def config(self): def config(self):
return dict( return dict(
position=self.position, position=self.position,
@@ -154,7 +184,7 @@ class MultiWell(models.Model):
return MultiWell.objects.filter(active=True).all() return MultiWell.objects.filter(active=True).all()
class Meta: class Meta:
ordering = ['order', ] ordering = ['label', 'order', ]
verbose_name = _("Multi-puits") verbose_name = _("Multi-puits")
verbose_name_plural = _("Multi-puits") verbose_name_plural = _("Multi-puits")
@@ -164,7 +194,7 @@ class MultiWell(models.Model):
class WellPosition(models.Model): class WellPosition(models.Model):
author = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name="Auteur", null=True, blank=True) author = models.ForeignKey(User, on_delete=models.SET_NULL, verbose_name="Auteur", null=True, blank=True)
well = models.ForeignKey(Well, verbose_name=_("Puit"), on_delete=models.SET_NULL, null=True, blank=True) well = models.ForeignKey(Well, verbose_name=_("Puit"), on_delete=models.SET_NULL, null=True, blank=True)
multiwell = models.ForeignKey(MultiWell, verbose_name=_("Multi-puits"), on_delete=models.SET_NULL, null=True, blank=True) multiwell = models.ForeignKey(MultiWell, verbose_name=_("Multi-puits"), on_delete=models.SET_NULL, null=True, blank=True)
@@ -175,13 +205,17 @@ class WellPosition(models.Model):
@classmethod @classmethod
def active_well(cls, multiwel, well): def active_well(cls, multiwell, well):
return WellPosition.objects.filter(multiwel_id=multiwel.id, well_id=well.id).first() return WellPosition.objects.filter(multiwell_id=multiwell.id, well_id=well.id).first()
@classmethod
def well_by_multiwell(cls, multiwell):
return WellPosition.objects.filter(multiwell_id=multiwell.id).all()
class Meta: class Meta:
ordering = ['order'] ordering = ['order']
unique_together = ["multiwell", "well"] unique_together = ["multiwell", "well"]
verbose_name = _("Position d'un puit") verbose_name = _("Position du puit")
verbose_name_plural = _("Position des puits") verbose_name_plural = _("Position des puits")
def __str__(self): def __str__(self):
@@ -190,8 +224,8 @@ class WellPosition(models.Model):
@receiver(post_save, sender=MultiWell) @receiver(post_save, sender=MultiWell)
def create_well_position(sender, instance, created, **kwargs): def create_well_position(sender, instance, created, **kwargs):
if created: #if created:
pass # pass
if not instance.well_position: if not instance.well_position:
row_order = instance.row_order.split(',') row_order = instance.row_order.split(',')
n = 0 n = 0
@@ -222,19 +256,33 @@ def create_well_position(sender, instance, created, **kwargs):
class Experiment(models.Model): class Experiment(models.Model):
title = models.CharField(_("Titre de l'expérience"), max_length=100, null=True, blank=False) title = models.CharField(_("Titre de l'expérience"), max_length=100, null=True, blank=False)
comment = models.TextField(_("Commentaires"), help_text=_("Descriptions de l'expérience"), null=True, blank=True) comment = models.TextField(_("Commentaires"), help_text=_("Descriptions de l'expérience"), null=True, blank=True)
author = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name="Auteur", null=True, blank=True) identifier = models.CharField(_("Identifiant d'expérience"), unique=True, max_length=100, null=True, blank=False )
author = models.ForeignKey(User, on_delete=models.SET_NULL, verbose_name="Auteur", null=True, blank=True)
multiwell = models.ForeignKey(MultiWell, verbose_name=_("Multi-puits"), on_delete=models.SET_NULL, null=True, blank=True) multiwell = models.ForeignKey(MultiWell, verbose_name=_("Multi-puits"), on_delete=models.SET_NULL, null=True, blank=True)
duration = models.PositiveIntegerField(_("Durée"), help_text=_('Durée de la prise de vue en secondes'), blank=False, default=120)
created = models.DateTimeField(_("Date de création"), default=timezone.now) created = models.DateTimeField(_("Date de création"), default=timezone.now)
started = models.DateTimeField (_("Date de début"), null=True, blank=True) started = models.DateTimeField (_("Date de début"), null=True, blank=True)
finished = models.DateTimeField (_("Date de fin"), null=True, blank=True) finished = models.DateTimeField (_("Date de fin"), null=True, blank=True)
def save(self, *args, **kwargs):
self.identifier = f'{self.multiwell.position}_{self.created.isoformat()[:19]}'
super().save(*args, **kwargs)
@classmethod
def by_identifier(cls, identifier):
return Experiment.objects.filter(identifier__exact=identifier).first()
class Meta: class Meta:
ordering = ['-created', ] ordering = ['-created', ]
verbose_name = _("Expérience") verbose_name = _("Expérience")
verbose_name_plural = _("Expériences") verbose_name_plural = _("Expériences")
def __str__(self): def __str__(self):
return f'{self.title}: {self.created} {self.multiwell.order}' return f'{self.identifier} [ {self.title} ]'
class Session(models.Model): class Session(models.Model):
@@ -246,10 +294,10 @@ class Session(models.Model):
ERROR = "error", _("Erreur") ERROR = "error", _("Erreur")
name = models.CharField(_("Nom de la session"), help_text=_("Session d'expérience. 4 Multi-puits maximum"), max_length=100, null=True, blank=False) name = models.CharField(_("Nom de la session"), help_text=_("Session d'expérience. 4 Multi-puits maximum"), max_length=100, null=True, blank=False)
author = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name="Auteur", null=True, blank=True) author = models.ForeignKey(User, on_delete=models.SET_NULL, verbose_name="Auteur", null=True, blank=True)
active = models.BooleanField(_("Active"), default=True) active = models.BooleanField(_("Active"), default=True)
expected_export = models.DateTimeField(_("Date d'exportation"), help_text=_("Date d'exportation prévue"), null=True, blank=True) expected_export = models.DateTimeField(_("Exportation auto"), help_text=_("Date d'exportation prévue"), null=True, blank=True)
expected_scanning = models.DateTimeField(_("Date du balayage"), help_text=_("Date du balayage prévue"), null=True, blank=True) expected_scanning = models.DateTimeField(_("Bbalayage auto"), help_text=_("Date du balayage prévue"), null=True, blank=True)
created = models.DateTimeField(_("Date de création"), default=timezone.now) created = models.DateTimeField(_("Date de création"), default=timezone.now)
finished = models.DateTimeField (_("Date de fin"), null=True, blank=True) finished = models.DateTimeField (_("Date de fin"), null=True, blank=True)
@@ -271,14 +319,19 @@ class Session(models.Model):
scanning_finished_at = models.DateTimeField(_("Balayage terminé à"), null=True, blank=True) scanning_finished_at = models.DateTimeField(_("Balayage terminé à"), null=True, blank=True)
@classmethod
def get_session(cls, sid):
return Session.objects.filter(pk=sid).first()
class Meta: class Meta:
ordering = ['-created', ] ordering = ['-created', ]
verbose_name = _("Session d'expérience") verbose_name = _("Session")
verbose_name_plural = _("Sessions d'expériences") verbose_name_plural = _("Sessions")
def __str__(self): def __str__(self):
state = _("Terminée") if not self.active else _("Active") state = _("Terminée") if not self.active else _("Active")
return f'{self.name}: {state}' return f'[ {self.pk} ] {self.name} ({state})'
@receiver(post_save, sender=Session) @receiver(post_save, sender=Session)
@@ -305,8 +358,8 @@ def create_periodic_task(sender, instance, created, **kwargs):
) )
# Sauvegarde sans re-déclencher le signal # Sauvegarde sans re-déclencher le signal
Session.objects.filter(pk=instance.pk).update(export_task=export_task) Session.objects.filter(pk=instance.pk).update(export_task=export_task)
except: except Exception as e:
pass print("create_periodic_task error", e)
if instance.expected_scanning: if instance.expected_scanning:
try: try:
@@ -340,15 +393,21 @@ def delete_periodic_task(sender, instance, **kwargs):
instance.scanning_task.delete() instance.scanning_task.delete()
def get_uuid_from_session(session_id, multiwel_position, well_name):
return f'{session_id}-{multiwel_position}-{well_name}'
class SessionExperiment(models.Model): class SessionExperiment(models.Model):
author = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name="Auteur", null=True, blank=True) author = models.ForeignKey(User, on_delete=models.SET_NULL, verbose_name="Auteur", null=True, blank=True)
session = models.ForeignKey(Session, verbose_name=_("Session"), on_delete=models.SET_NULL, null=True, blank=True) session = models.ForeignKey(Session, verbose_name=_("Session"), on_delete=models.CASCADE, null=True, blank=True)
experiment = models.ForeignKey(Experiment, verbose_name=_("Expérience"), on_delete=models.SET_NULL, null=True, blank=True, related_name="session_experiments") experiment = models.ForeignKey(Experiment, verbose_name=_("Expérience"), on_delete=models.CASCADE, null=True, blank=True, related_name="session_experiments")
@classmethod @classmethod
def experiment_by_session(cls, session_id, active=True): def experiment_by_session(cls, session_id, active=True):
return [ ss.experiment for ss in SessionExperiment.objects.filter(session__id=session_id, session__active=active).order_by('experiment__multiwell__order') ] return [ ss.experiment for ss in SessionExperiment.objects.filter(session__id=session_id, session__active=active).order_by('experiment__multiwell__order') ]
@classmethod @classmethod
def uuid_from_session(cls, sid): def uuid_from_session(cls, sid):
experiments = [ss.experiment for ss in SessionExperiment.objects.filter(session__id=sid, session__active=False)] experiments = [ss.experiment for ss in SessionExperiment.objects.filter(session__id=sid, session__active=False)]
@@ -361,13 +420,166 @@ class SessionExperiment(models.Model):
uuid_list.append(uuid) uuid_list.append(uuid)
return uuid_list return uuid_list
@classmethod
def get_uuid(cls, session_id, experiment_id, well_name):
ss = SessionExperiment.objects.filter(session__id=session_id, experiment_id=experiment_id).first()
if ss:
return get_uuid_from_session(session_id, ss.experiment.multiwel.position, well_name)
return None
class Meta: class Meta:
ordering = ['session',] ordering = ['session',]
unique_together = ["session", "experiment"] unique_together = ["session", "experiment"]
verbose_name = _("Session expérience") verbose_name = _("Expérience d'une session")
verbose_name_plural = _("Sessions expériences") verbose_name_plural = _("Expériences d'une session")
def __str__(self): def __str__(self):
return f'{self.session.name}' return f'{self.session.id}: {self.experiment.title}'
class ExperimentWell(models.Model):
author = models.ForeignKey(User, on_delete=models.SET_NULL, verbose_name="Auteur", null=True, blank=True)
experiment = models.ForeignKey(Experiment, verbose_name=_("Expérience"), on_delete=models.CASCADE, null=True, blank=True, related_name="experimentwell")
well = models.ForeignKey(Well, verbose_name=_("Puit"), on_delete=models.SET_NULL, null=True, blank=True, related_name="wellexperiment")
active = models.BooleanField(_("Active"), default=True)
@classmethod
def well_by_experiment(cls, experiment_id):
return ExperimentWell.objects.filter(experiment__id=experiment_id, active=True).order_by('well__name')
@classmethod
def wellname_by_experiment(cls, experiment_id):
return [ ew.well.name for ew in ExperimentWell.objects.filter(experiment__id=experiment_id, active=True).order_by('well__name') ]
class Meta:
ordering = ['experiment', 'well']
unique_together = ["experiment", "well", ]
verbose_name = _("Expérience puit")
verbose_name_plural = _("Expériences puits")
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
from .constants import ScannerConstants
wellposition = WellPosition.well_by_multiwell(instance.multiwell)
for wp in wellposition:
ExperimentWell.objects.get_or_create(experiment=instance, well=wp.well, author=instance.author, defaults={'active':True})
ExperimentConfig.objects.get_or_create(
experiment_key=instance,
well=wp.well.name,
author=instance.author,
experiment=instance.identifier,
defaults={
'px_per_mm': wp.px_per_mm,
'fps': ScannerConstants().get().video_frame_rate,
'well_radius_mm': instance.multiwell.diameter / 2,
}
)
+211 -57
View File
@@ -7,20 +7,21 @@ Created on 20 avr. 2026
@author: denis @author: denis
''' '''
import logging import logging
import time import time
from threading import Thread, Event from threading import Thread, Event
#from django.utils.translation import gettext_lazy as _ #from django.utils.translation import gettext_lazy as _
from django.utils import timezone 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 django.conf import settings
from planarian.models import ExperimentConfig
from . import models from . import models
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class WellIterator: class WellIterator:
"""Itérateur personnalisé pour naviguer dans les Wells""" """Itérateur personnalisé pour naviguer dans les Wells"""
@@ -75,8 +76,11 @@ class WellIterator:
class MultiWellManager: class MultiWellManager:
def __init__(self, process): def __init__(self, process):
self.process = process self.process = process
self.cnc_controller = process.grbl self.cnc_controller = process.grbl
logger.info(f"MultiWellManager initialized with CNC controller: {self.cnc_controller}")
self.stop_playing = Event() self.stop_playing = Event()
self.well_iterator = None self.well_iterator = None
self.multiwel = None self.multiwel = None
@@ -85,83 +89,190 @@ class MultiWellManager:
self.scan_thread = None self.scan_thread = None
self.test_thread = None self.test_thread = None
self.tracker_config = dict(
tube_axis = settings.TRACKER_TUBE_AXIS,
min_area_px = self.process.conf.min_area_px,
max_area_ratio = self.process.conf.max_area_ratio,
max_planarians = self.process.conf.max_planarians,
merge_kernel_size = self.process.conf.merge_kernel_size,
min_contour_dist_px = self.process.conf.min_contour_dist_px,
)
def set_tracker_config(self):
self.tracker_config = dict(
tube_axis = settings.TRACKER_TUBE_AXIS,
min_area_px = self.process.conf.min_area_px,
max_area_ratio = self.process.conf.max_area_ratio,
max_planarians = self.process.conf.max_planarians,
merge_kernel_size = self.process.conf.merge_kernel_size,
min_contour_dist_px = self.process.conf.min_contour_dist_px,
)
logger.info(f"Tracker config: {self.tracker_config}")
def set_default_values(self, feed=None, step=None, duration=None): def set_default_values(self, feed=None, step=None, duration=None):
self._feed = feed or self.process.conf.calibration_default_feed self._feed = feed or self.process.conf.calibration_default_feed
self._step = step or self.process.conf.calibration_default_step self._step = step or self.process.conf.calibration_default_step
self._duration = duration or self.process.conf.calibration_default_duration self._duration = duration or self.process.conf.calibration_default_duration
self.px_per_mm = 50.0 self.px_per_mm = 50.0
def init_manager_values(self):
def set_multiwell(self, position=None): wells = models.WellPosition.objects.filter(multiwell_id=self.multiwell.pk).order_by('order').all() # type: ignore[union-attr]
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.well_iterator = WellIterator(wells)
self.position = self.multiwell.position self.position = self.multiwell.position
self._xbase = self.multiwell.xbase self._xbase = self.multiwell.xbase
self._ybase = self.multiwell.ybase self._ybase = self.multiwell.ybase
self._dx = self.multiwell.dx self._dx = self.multiwell.dx
self._dy = self.multiwell.dy 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)
self.init_manager_values()
return self.multiwell.config() 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): def multiwell_buttons(self, btn_class="w3-button", onclick=''' onclick="goto_well(this)"'''):
multiwells = [] multiwells = []
multiwells.append('''<div class="w3-border well-btn">''') multiwells.append('''<div class="w3-border well-btn">''')
for wl in self.well_iterator: for wl in self.well_iterator:
multiwells.append(f"""<button class="w3-button well" value="{wl.order}" onclick="goto_well(this)">{wl.well.name}</button>""") multiwells.append(f"""<button class="{btn_class} well" value="{wl.order}"{onclick}>{wl.well.name}</button>""")
multiwells.append('''</div>''') multiwells.append('''</div>''')
self.well_iterator.reset() self.well_iterator.reset()
return mark_safe("\n".join(multiwells)) return mark_safe("\n".join(multiwells))
def set_circular_crop(self, crop_radius):
crop = self.process.set_crop_radius(crop_radius)
self.process.cam.set_circular_crop(crop)
def _grid_scanning_capture(self, uuid, duration): 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}'
if self.process.use_tracking:
cfg = ExperimentConfig.objects.filter(experiment_key_id=experiment.id, well=well.name).first()
if not cfg:
raise Exception(f"Configuration d'expérience introuvable pour {experiment} / {well}")
# reset PlanarianTracker => on_well_change
self.process.cam.on_well_change(cfg, uuid=uuid, draw_contours=False)
## start recording
self.process.data.uuid = uuid self.process.data.uuid = uuid
if not simulate:
self.process.data.record = True self.process.data.record = True
self.process._send(current=well_position.order)
msg = f"Starting capture for {uuid} ordre: {well_position.order}"
logger.info(msg)
self.process._send(well_state=msg)
start = time.monotonic() start = time.monotonic()
while not self.stop_playing.is_set(): while not self.stop_playing.is_set():
if time.monotonic() - start > duration: ## stop after duration in experiemnt now
if time.monotonic() - start > experiment.duration:
break break
self.cnc_controller.wait_for(1.0) self.cnc_controller.wait_for(0.1)
self.process.cam._flush_current_well(uuid)
logger.info(f"Arrêter l'enregistrement {uuid}")
self.process.data.record = False self.process.data.record = False
self.process.data.uuid = None self.process.data.uuid = None
msg = f"{uuid}: capture done..."
except Exception as e:
msg = f"error during capture - {e}"
logger.error(msg)
finally:
self.process.cam._flush_current_well(uuid)
def _grid_scanning(self, experiment, xnext=0, ynext=0): logger.info(msg)
self.process._send(scan_state=msg)
def _capture_file_simulation(self, name):
vf = settings.MEDIA_ROOT / 'simulation' / f'{name}.mp4'
if vf.exists():
self.process.cam._video_file = str(vf)
self.process.cam._error_occured = True
logger.info(f"Simulating capture with file {vf}")
def _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):
names = models.ExperimentWell.wellname_by_experiment(experiment.id)
if welposition.well.name not in names:
return False
return True
def _grid_scanning(self, experiment, xnext=0, ynext=0, simulate=False):
try:
multiwell = experiment.multiwell multiwell = experiment.multiwell
wells = models.WellPosition.objects.filter(multiwell_id=multiwell.id).order_by('order').all() wellpositions = models.WellPosition.objects.filter(multiwell_id=multiwell.id).order_by('order').all()
cam = self.process.cam cam = self.process.cam
cam._aligner.set_tube_diameter(multiwell.diameter) cam._aligner.set_tube_diameter(multiwell.diameter)
self.stop_playing = Event() for wl in wellpositions:
for wl in wells:
if self.stop_playing.is_set(): if self.stop_playing.is_set():
break break
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)
uuid = f'{self.process.data.session}-{multiwell.position}-{wl.well.name}'
self._grid_scanning_capture(uuid, multiwell.duration)
## change file
if self.process.conf.capture_type == 'file': if self.process.conf.capture_type == 'file':
self.process.cam._error_occured = True self._capture_file_simulation(wl.well.name)
elif self.process.conf.capture_type == 'video':
self._capture_video_simulation(wl)
self.process._send(scan_state=f"{uuid}: capture") self._grid_scanning_capture(experiment, wl, simulate=simulate)
msg =f"Scan terminé — retour à l'origine (X={xnext:.1f} Y={ynext:.1f})"
logger.info(f"Scan terminé — retour à l'origine (X={xnext:.1f} Y={ynext:.1f})") logger.info(msg)
self.cnc_controller.move_to(xnext, ynext, feed=multiwell.feed*2) self.process._send(state='scan_finished', msg=msg)
return True
except Exception as e:
msg = f"Error during grid scanning - {e}"
logger.error(msg)
self.process._send(state='error', msg=msg)
return False
finally:
self.cnc_controller.move_to(xnext, ynext, feed=self.feed*2)
def _start_scanning(self, session, experiments): def _start_scanning(self, session, experiments, simulate=False):
result = False
try:
self.process.get_config() # get video configuration if updated
self.process.cam._aligner.debug = False self.process.cam._aligner.debug = False
self.stop_playing.clear()
xynext = [] xynext = []
for obs in experiments: for obs in experiments:
xynext.append((obs.multiwell.xbase, obs.multiwell.ybase)) xynext.append((obs.multiwell.xbase, obs.multiwell.ybase))
@@ -171,20 +282,26 @@ class MultiWellManager:
self.process.data.session = session.id self.process.data.session = session.id
started = timezone.now() started = timezone.now()
for obs in experiments: for obs in experiments:
msg = f"Starting scan for {obs} (well {pos}/{len(experiments)})"
logger.warning(msg)
self.process._send(well_state=msg)
if self.stop_playing.is_set(): if self.stop_playing.is_set():
break break
obs.started = timezone.now() obs.started = timezone.now()
obs.save() obs.save()
xnext, ynext = xynext[pos] xnext, ynext = xynext[pos]
pos +=1 pos +=1
self._grid_scanning(obs, xnext=xnext, ynext=ynext) result = self._grid_scanning(obs, xnext=xnext, ynext=ynext, simulate=simulate)
obs.finished = timezone.now() obs.finished = timezone.now()
obs.save() obs.save()
session.finished = timezone.now() session.finished = timezone.now()
if self.stop_playing.is_set(): if self.stop_playing.is_set() or not result:
msg = f"Session {session.name} abandonnée à {session.finished} après {session.finished - started} secondes." msg = f"Session {session.name} abandonnée à {session.finished} après {session.finished - started} secondes."
else: else:
if not simulate:
session.active = False session.active = False
if session.scanning_task: if session.scanning_task:
session.scanning_task.enabled = False session.scanning_task.enabled = False
@@ -192,7 +309,12 @@ class MultiWellManager:
msg = f"Session {session.name} terminée à {session.finished} après {session.finished - started} secondes." msg = f"Session {session.name} terminée à {session.finished} après {session.finished - started} secondes."
logger.info(msg) logger.info(msg)
self.process._send(scan_state=msg) self.process._send(scan_state=msg)
except Exception as e:
logger.error("Error during scanning process", e)
finally:
self.scan_thread = None self.scan_thread = None
self.goto_0()
self.process._send(current=self.get_well_order())
def halt_scanning(self): def halt_scanning(self):
@@ -200,36 +322,65 @@ class MultiWellManager:
self.stop_playing.set() self.stop_playing.set()
self.well_iterator.reset() self.well_iterator.reset()
self.process.cam._aligner.debug = False self.process.cam._aligner.debug = False
self.scan_thread = None
self.test_thread = None
def scanning(self, sid): def scan_process(self, sid, simulate=False):
try:
if self.scan_thread: if self.scan_thread:
return return
session = models.Session.objects.get(pk=sid) session = models.Session.objects.get(pk=sid)
experiments = models.SessionExperiment.experiment_by_session(sid) experiments = models.SessionExperiment.experiment_by_session(sid)
self.scan_thread = Thread(target=self._start_scanning, args=(session, experiments, ), daemon=True).start()
except Exception as e: self.scan_thread = Thread(target=self._start_scanning, args=(session, experiments, simulate, ), daemon=True)
print("MultiWellManager::scan error", e) self.scan_thread.start()
def previous_well(self): def previous_well(self):
wl = self.well_iterator.previous() 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) self.cnc_controller.move_to(wl.x, wl.y, feed=wl.multiwell.feed)
return {"state": "previous", "msg": f">>> ({wl.x}, {wl.y})"} return {"state": "previous", "msg": f">>> {wl.well.name}: ({wl.x}, {wl.y})"}
def next_well(self): def next_well(self):
wl = self.well_iterator.next() 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) self.cnc_controller.move_to(wl.x, wl.y, feed=wl.multiwell.feed)
return {"state": "next", "msg": f">>> ({wl.x}, {wl.y})"} return {"state": "next", "msg": f">>> {wl.well.name}: ({wl.x}, {wl.y})"}
def goto_well(self, numwell): def goto_well(self, numwell):
wl = self.well_iterator.seek(numwell) wl = self.well_iterator.seek(numwell)
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) self.cnc_controller.move_to(wl.x, wl.y, feed=wl.multiwell.feed)
return {"state": "goto", "msg": f">>> ({wl.x}, {wl.y})"} return {"state": "goto", "msg": f">>> {wl.well.name}: ({wl.x}, {wl.y})"}
def goto_xy(self):
wl = self.well_iterator.seek(0)
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(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')
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): def set_well_position(self):
wl = self.well_iterator.get_current() wl = self.well_iterator.get_current()
@@ -243,17 +394,22 @@ class MultiWellManager:
def _scanning_test(self, auto=False): def _scanning_test(self, auto=False):
self.stop_playing = Event() self.stop_playing.clear()
cam = self.process.cam cam = self.process.cam
cam._aligner.set_tube_diameter(self.multiwell.diameter) 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
try:
start_test = time.monotonic() start_test = time.monotonic()
try:
for wl in self.well_iterator: for wl in self.well_iterator:
if self.stop_playing.is_set(): if self.stop_playing.is_set():
break break
self.cnc_controller.wait_for(2.0) self.cnc_controller.wait_for(2.0)
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)
elif self.process.conf.capture_type == 'video':
self._capture_video_simulation(wl)
self.process._send(current=wl.order) self.process._send(current=wl.order)
start = time.monotonic() start = time.monotonic()
@@ -287,23 +443,24 @@ class MultiWellManager:
self.process._send(state='center', msg=msg) self.process._send(state='center', msg=msg)
self.cnc_controller.wait_for(0.1) self.cnc_controller.wait_for(0.1)
logger.info("Fin du centrage") logger.info("Fin du test")
except Exception as e: except Exception as e:
print(e) logger.error("Error during scanning test", e)
finally:
self.test_thread = None
self.well_iterator.reset() self.well_iterator.reset()
self.process.cam._aligner.debug = False self.process.cam._aligner.debug = False
logger.info(f"Scan terminé — retour à l'origine (X=0, Y=0) en {int(time.monotonic()-start_test)} s") logger.info(f"Scan terminé — retour à l'origine (X=0, Y=0) en {int(time.monotonic()-start_test)} s")
self.cnc_controller.move_to(0, 0, feed=self.multiwell.feed*2) self.goto_0()
self.test_thread = None self.process._send(current=self.get_well_order())
def scan_test(self, auto=False): def scan_test(self, auto=False):
if self.test_thread: if self.test_thread:
return return
self.test_thread = Thread(target=self._scanning_test, args=(auto, ), daemon=True).start() self.test_thread = Thread(target=self._scanning_test, args=(auto, ), daemon=True)
self.test_thread.start()
@property @property
def position(self): def position(self):
@@ -369,14 +526,12 @@ class MultiWellManager:
def dy(self, value): def dy(self, value):
self._dy = value self._dy = value
def get_well_order(self): def get_well_order(self):
wl = self.well_iterator.get_current() wl = self.well_iterator.get_current()
if wl: if wl:
return wl.order return wl.order
return None return None
def set_position(self): def set_position(self):
x, y = self.cnc_controller.get_mpos() x, y = self.cnc_controller.get_mpos()
self.cnc_controller.wait_for(2.0) self.cnc_controller.wait_for(2.0)
@@ -387,17 +542,16 @@ class MultiWellManager:
wl.px_per_mm = self.px_per_mm wl.px_per_mm = self.px_per_mm
wl.save() wl.save()
def calib_toggle_debug(self): def calib_toggle_debug(self):
""" Active / désactive le mode debug sur le stream.""" """ Active / désactive le mode debug sur le stream."""
aligner = self.process.cam._aligner aligner = self.process.cam._aligner
aligner.debug = not aligner.debug aligner.debug = not aligner.debug
return {"state": "debug", "msg": f"Debug: {aligner.debug}"} return {"state": "debug", "value": aligner.debug, "msg": f"Debug: {aligner.debug}"}
def set_calib_debug(self, value=True): def set_calib_debug(self, value=True):
""" Active / désactive le mode debug sur le stream.""" """ Active / désactive le mode debug sur le stream."""
aligner = self.process.cam._aligner aligner = self.process.cam._aligner
aligner.debug = value aligner.debug = value
return {"state": "debug", "msg": f"Debug: {aligner.debug}"} return {"state": "debug", "value": aligner.debug, "msg": f"Debug: {aligner.debug}"}
+209 -51
View File
@@ -21,20 +21,25 @@ from celery.exceptions import Ignore
from celery.utils.log import get_task_logger from celery.utils.log import get_task_logger
from redis import Redis from redis import Redis
from dataclasses import dataclass from dataclasses import dataclass
from modules import reductstore, grbl, utils from modules import reductstore, utils, planarian_metrics
## camera devices ## camera devices
from modules.circular_crop import CircularCrop, CropStrategy from modules.circular_crop import CircularCrop, CropStrategy
from .multiwell import MultiWellManager from .multiwell import MultiWellManager
from .constants import ScannerConstants from .constants import ScannerConstants
from . import models from .models import MultiWell
# CNC
if not settings.GRBL_SIMULATION:
from modules.grbl import GRBLController # @UnusedImport
else:
from modules.grbl_simulator import GRBLController # @Reimport
@dataclass @dataclass
class ProcessData: class ProcessData:
play: bool = True play: bool = True
record: bool = False record: bool = False
uuid: str = None uuid: str | None = None
session: int = 0 session: int = 0
tube_diameter: float = 16.0 tube_diameter: float = 16.0
@@ -42,6 +47,8 @@ class ProcessData:
logger = get_task_logger(__name__) logger = get_task_logger(__name__)
redisDB = Redis(host=settings.REDIS_HOST, port=settings.REDIS_PORT, db=0, decode_responses=True) redisDB = Redis(host=settings.REDIS_HOST, port=settings.REDIS_PORT, db=0, decode_responses=True)
cameraDB = reductstore.ReductStore(name='camera') cameraDB = reductstore.ReductStore(name='camera')
planarianDB = planarian_metrics.ReductStoreClient(url=settings.REDUCTSTORE_URL, token=settings.REDUCTSTORE_TOKEN)
async_to_sync(planarianDB.connect)()
class CameraRecordManager(): class CameraRecordManager():
@@ -139,11 +146,12 @@ class CameraRecordManager():
class ScannerProcess(Task): class ScannerProcess(Task):
name='scanner.scanner_process'
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self.channel_layer = get_channel_layer() self.channel_layer = get_channel_layer()
self.group = f'scanner_proc' self.group = 'scanner_proc'
self.stop_event = Event() self.stop_event = Event()
self.cam = None self.cam = None
self.grbl = None self.grbl = None
@@ -153,6 +161,7 @@ class ScannerProcess(Task):
self.data = ProcessData() self.data = ProcessData()
self.manager = None self.manager = None
self.recordDB = CameraRecordManager(cameraDB) self.recordDB = CameraRecordManager(cameraDB)
self.metricDB = planarianDB
def __call__(self, *args, **kwargs): def __call__(self, *args, **kwargs):
return self.start(*args, **kwargs) return self.start(*args, **kwargs)
@@ -160,9 +169,13 @@ class ScannerProcess(Task):
def set_crop_radius(self, radius): def set_crop_radius(self, radius):
return CircularCrop(radius=radius, strategy=CropStrategy.CROP_JPEG, jpeg_quality=self.image_quality) return CircularCrop(radius=radius, strategy=CropStrategy.CROP_JPEG, jpeg_quality=self.image_quality)
def start(self, *args, **kwargs): def get_config(self):
try: '''
self.conf = ScannerConstants().get() Constants:
reset si besoin les constantes vidéo
'''
self.constants = ScannerConstants()
self.conf = self.constants.get()
self.use_tracking = self.conf.tracking self.use_tracking = self.conf.tracking
self.video_quality = self.conf.video_jpeg_quality self.video_quality = self.conf.video_jpeg_quality
@@ -170,25 +183,29 @@ class ScannerProcess(Task):
self.video_fps = self.conf.video_frame_rate self.video_fps = self.conf.video_frame_rate
self.video_width = self.conf.video_width_capture 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.crop_radius = self.conf.calibration_crop_radius
self.video_jpg_quality = [int(cv2.IMWRITE_JPEG_QUALITY), self.video_quality] self.video_jpg_quality = [int(cv2.IMWRITE_JPEG_QUALITY), self.video_quality]
self.image_jpg_quality = [int(cv2.IMWRITE_JPEG_QUALITY), self.image_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_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) self.crop = self.set_crop_radius(self.crop_radius)
capture_type = self.conf.capture_type capture_type = self.conf.capture_type
if capture_type == 'file': if capture_type == 'file':
video_lists = []
wells = models.Well.objects.all()
for wl in wells:
video_lists.append(str( settings.MEDIA_ROOT / 'simulation' / f'{wl.name}.mp4') )
from modules.videofile_capture import VideoFileCapture from modules.videofile_capture import VideoFileCapture
self.cam = VideoFileCapture( self.cam = VideoFileCapture(
video_file=settings.MEDIA_ROOT / 'simulation' / 'A1.mp4', video_file=settings.MEDIA_ROOT / 'simulation' / 'default_simulation.mp4',
fps=self.video_fps, fps=self.video_fps,
width=self.video_width, width=self.video_width,
height=self.video_height, height=self.video_height,
@@ -196,7 +213,29 @@ class ScannerProcess(Task):
use_tracking=self.use_tracking, use_tracking=self.use_tracking,
display=self._display, display=self._display,
parent=self, parent=self,
video_lists=video_lists, )
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': elif capture_type == 'webcam':
from modules.webcam_capture import WebcamCapture from modules.webcam_capture import WebcamCapture
@@ -226,7 +265,12 @@ class ScannerProcess(Task):
self.cam._active_median = False self.cam._active_median = False
self.cam.set_circular_crop(None) self.cam.set_circular_crop(None)
self.grbl = grbl.GRBLController( # 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, send_callback=self._display,
x_max=self.conf.grbl_xmax, x_max=self.conf.grbl_xmax,
y_max=self.conf.grbl_ymax y_max=self.conf.grbl_ymax
@@ -234,6 +278,8 @@ class ScannerProcess(Task):
self.stop_event.clear() self.stop_event.clear()
self.start_services() self.start_services()
return self.name
except Exception as e: except Exception as e:
logger.error(f"Scanner started error: {e}") logger.error(f"Scanner started error: {e}")
raise Ignore() raise Ignore()
@@ -250,11 +296,14 @@ class ScannerProcess(Task):
self.stop_event.set() self.stop_event.set()
def start_services(self): def start_services(self):
Thread(target=self._listen_to_redis, daemon=True).start() Thread(target=self._init_manager, daemon=True).start()
Thread(target=self._recording, daemon=True).start() Thread(target=self._recording, daemon=True).start()
Thread(target=self._init_grbl, daemon=True).start() Thread(target=self._init_grbl, daemon=True).start()
Thread(target=self._listen_to_redis, daemon=True).start()
self.cam.start() self.cam.start()
logger.warning(f"Scanner services started ...")
def _send(self, **payload): def _send(self, **payload):
async_to_sync(self.channel_layer.group_send)( async_to_sync(self.channel_layer.group_send)(
self.group, { self.group, {
@@ -267,30 +316,53 @@ class ScannerProcess(Task):
if self.grbl: if self.grbl:
self._send(**msg) self._send(**msg)
def _on_frame(self, jpeg_bytes: bytes, ts: datetime, metrics: dict) -> None:
def _store_metric(self, uuid, metrics, ts):
if not isinstance(metrics, list):
return
for r in metrics:
pid = r["planarian_id"]
record = self.cam._metrics[pid].update(r, well_radius_mm=self.cam._params.well_radius_mm)
async_to_sync(planarianDB.store_metric)(
record,
self.cam._params.experiment,
self.cam._params.well,
uuid=uuid,
planarian=pid,
ts_us=ts,
)
def _store_frame(self, uuid, frame, ts, frame_count):
labels = {
"fps": self.video_fps,
"record_type": 'frame',
"frame_count": frame_count,
}
self.recordDB.write(uuid, frame, ts=ts, labels=labels)
def _on_frame(self, jpeg_bytes: bytes, ts: datetime, metrics: dict, frame_count: int = 0) -> None:
try:
if self.data.record: if self.data.record:
self.record_queue.put((self.data.uuid, ts, jpeg_bytes, metrics)) self.record_queue.put((self.data.uuid, ts, jpeg_bytes, metrics, frame_count))
if self.data.play: if self.data.play:
self._send(ts=ts.timestamp(), jpeg=base64.b64encode(jpeg_bytes).decode(), **metrics) jpeg=base64.b64encode(jpeg_bytes).decode()
self._send(ts=ts.timestamp(), jpeg=jpeg, frame_count=frame_count)
except Exception as e:
logger.error(e)
def _recording(self): def _recording(self):
logger.info(f"Scanner {self.group}: start recorder") logger.info(f"Scanner {self.group}: start recorder")
while not self.stop_event.is_set(): while not self.stop_event.is_set():
try: try:
(uuid, ts, frame, metrics) = self.record_queue.get() (uuid, ts, frame, metrics, frame_count) = self.record_queue.get()
labels = dict(fps=self.video_fps, session=self.data.session, detected="1" if metrics.get("detected") else "0") if self.cam.use_tracking:
self._store_metric(uuid, metrics, ts.timestamp())
if metrics.get("detected"): self._store_frame(uuid, frame, ts, frame_count)
labels.update({
"cx" : str(metrics["cx"]),
"cy" : str(metrics["cy"]),
"area_px" : str(metrics["area_px"]),
"speed_px_s" : str(metrics["speed_px_s"]),
"axial_pos" : str(metrics["axial_pos"]),
"axial_speed" : str(metrics["axial_speed"]),
})
self.recordDB.write(uuid, frame, labels, ts=ts)
self.record_queue.task_done() self.record_queue.task_done()
except Exception as e: except Exception as e:
logger.error(f'recorder: {e}') logger.error(f'recorder: {e}')
@@ -302,18 +374,23 @@ class ScannerProcess(Task):
self._send(state='serial', msg=f"Connected {self.grbl.port}") self._send(state='serial', msg=f"Connected {self.grbl.port}")
self.grbl.go_origin(feed=feed) self.grbl.go_origin(feed=feed)
if self.conf.capture_type == 'file':
self.manager._capture_file_simulation('zero')
self.grbl.wait_for(2.0) self.grbl.wait_for(2.0)
def _init_manager(self):
logger.info(f"==== Start MultiWellManager!")
self.manager = MultiWellManager(process=self)
self.manager.set_calib_debug(False)
Event().wait(2.0)
def _listen_to_redis(self): def _listen_to_redis(self):
try:
logger.info(f"==== Scanner {self.group}: listen via redisDB") logger.info(f"==== Scanner {self.group}: listen via redisDB")
pubsub = redisDB.pubsub() pubsub = redisDB.pubsub()
pubsub.subscribe(self.group) pubsub.subscribe(self.group)
#self._init_grbl() try:
self.manager = MultiWellManager(process=self)
self.manager.set_calib_debug(False)
for message in pubsub.listen(): for message in pubsub.listen():
try: try:
#logger.info(f"{message}") #logger.info(f"{message}")
@@ -327,21 +404,58 @@ class ScannerProcess(Task):
continue 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": if cmd["type"]=="scanner":
topic = cmd.get("topic") topic = cmd.get("topic")
if topic == 'init': 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._active_median = False
self.grbl.go_origin(feed=self.manager.feed) self.cam.set_edge_enhance(False)
if self.conf.capture_type == 'video':
elif topic == 'scan': 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)
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') sid = cmd.get("session", '0')
if sid == "0": if sid == "0":
self._send(state='error', msg=str(_('La session est nulle!...'))) self._send(state='error', msg=str(_('La session est nulle!...')))
else: else:
try:
self.cam._active_median = False self.cam._active_median = False
self.manager.scanning(sid) self.cam.set_edge_enhance(False)
simulate = (topic=='simulate')
self.manager.scan_process(sid, simulate)
self._send(state=topic, msg=str(_('Balayage démarré...')))
except Exception as e:
logger.error(f"Scan error: {e}")
self._send(state='error', msg=str(_('Erreur lors du démarrage du balayage...')))
continue
self._send(
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": elif cmd["type"]=="calibrate":
topic = cmd.get("topic") topic = cmd.get("topic")
value = cmd.get("value") value = cmd.get("value")
@@ -356,6 +470,8 @@ class ScannerProcess(Task):
self.manager.set_multiwell(position) self.manager.set_multiwell(position)
self.cam.set_circular_crop(None) self.cam.set_circular_crop(None)
self.cam._active_median = False self.cam._active_median = False
self.cam.set_edge_enhance(False)
self.cam.set_draw_contours(False)
buttons = self.manager.multiwell_buttons() buttons = self.manager.multiwell_buttons()
elif topic == 'up': elif topic == 'up':
@@ -372,18 +488,34 @@ class ScannerProcess(Task):
elif topic == 'median': elif topic == 'median':
self.cam._active_median = not self.cam._active_median 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 continue
elif topic == 'crop': elif topic == 'crop':
self.cam._active_crop = not self.cam._active_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 continue
elif topic == 'crop_radius': elif topic == 'crop_radius':
self.conf.calibration_crop_radius=int(value) self.conf.calibration_crop_radius=int(value)
self.crop = self.set_crop_radius(self.conf.calibration_crop_radius) 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.cam.set_circular_crop(self.crop)
self.manager.update_crop_radius(int(value))
continue continue
elif topic == 'position': elif topic == 'position':
@@ -400,12 +532,10 @@ class ScannerProcess(Task):
self.manager.duration = float(value) self.manager.duration = float(value)
elif topic == 'goto_0': elif topic == 'goto_0':
self.grbl.go_origin(feed=self.manager.feed) self.manager.goto_0()
self.manager.well_iterator.reset()
elif topic == 'goto_xy': elif topic == 'goto_xy':
self.grbl.move_to(self.manager.xbase, self.manager.ybase, feed=self.manager.feed) self.manager.goto_xy()
self.manager.well_iterator.seek(0)
elif topic == 'xy_base': elif topic == 'xy_base':
self.manager.set_position() self.manager.set_position()
@@ -417,6 +547,12 @@ class ScannerProcess(Task):
elif topic == 'auto': elif topic == 'auto':
self.manager.set_calib_debug(True) self.manager.set_calib_debug(True)
self.cam.set_circular_crop(self.crop) 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) self.manager.scan_test(auto=True)
continue continue
@@ -431,12 +567,22 @@ class ScannerProcess(Task):
elif topic == 'halt': elif topic == 'halt':
self.manager.halt_scanning() self.manager.halt_scanning()
elif topic == 'calib_debug': 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() msg = self.manager.calib_toggle_debug()
self._send(**msg) self._send(**msg)
continue 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': elif topic == 'previous':
msg = self.manager.previous_well() msg = self.manager.previous_well()
self._send(**msg) self._send(**msg)
@@ -453,6 +599,17 @@ class ScannerProcess(Task):
msg = self.manager.set_well_position() msg = self.manager.set_well_position()
self._send(**msg) self._send(**msg)
elif topic == 'draw':
draw = (value=="1")
self.cam.set_draw_contours(draw)
self._send(state=topic, msg=f"Tracking contour: {draw}")
elif topic in ['min_area_px', 'max_area_ratio', 'max_planarians', 'merge_kernel_size', 'min_contour_dist_px']:
value = int(value) if topic in ['min_area_px', 'max_planarians', 'merge_kernel_size', 'min_contour_dist_px'] else float(value)
self.manager.tracker_config[topic] = value
self.cam.on_test_well_change(**self.manager.tracker_config)
self._send(state=topic, msg=f"Value changed {value}")
self._send( self._send(
xbase=self.manager.xbase, xbase=self.manager.xbase,
ybase=self.manager.ybase, ybase=self.manager.ybase,
@@ -463,6 +620,7 @@ class ScannerProcess(Task):
dx=self.manager.dx, dx=self.manager.dx,
dy=self.manager.dy, dy=self.manager.dy,
buttons=buttons, buttons=buttons,
columns=self.manager.multiwell.cols,
current=self.manager.get_well_order(), current=self.manager.get_well_order(),
) )
except Exception as e: except Exception as e:
@@ -624,12 +782,11 @@ class ReplayProcess(Task):
logger.info(f"==== ReplayProcess stopped.") logger.info(f"==== ReplayProcess stopped.")
def _listen_to_redis(self): def _listen_to_redis(self):
try:
loop = None loop = None
logger.info(f"==== ReplayProcess {self.group}: listen via redisDB") logger.info(f"==== ReplayProcess {self.group}: listen via redisDB")
pubsub = redisDB.pubsub() pubsub = redisDB.pubsub()
pubsub.subscribe(self.group) pubsub.subscribe(self.group)
try:
for message in pubsub.listen(): for message in pubsub.listen():
try: try:
if self.stop_event.is_set(): if self.stop_event.is_set():
@@ -681,6 +838,7 @@ class ReplayProcess(Task):
logger.error(f'ReplayProcess::listen_to_redis: {e}') logger.error(f'ReplayProcess::listen_to_redis: {e}')
finally: finally:
self.running.set() self.running.set()
if loop:
utils.stop_async(loop) utils.stop_async(loop)
pubsub.unsubscribe() pubsub.unsubscribe()
pubsub.close() pubsub.close()
@@ -5,10 +5,8 @@
.well-btn { .well-btn {
display: grid; display: grid;
grid-template-columns: repeat(6, 1fr); grid-template-columns: repeat(var(--well-columns, 6), 1fr);
justify-items: center; justify-items: center;
align-items: center; align-items: center;
} }
@@ -1 +0,0 @@
@@ -0,0 +1,11 @@
.well {
padding: 0.2em;
}
.well-btn {
display: grid;
grid-template-columns: repeat(var(--well-columns, 6), 1fr);
justify-items: center;
align-items: center;
}
@@ -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;
}
@@ -33,15 +33,26 @@ class ScannerManager {
this.well = options.well; this.well = options.well;
this.debug = options.debug; this.debug = options.debug;
this.calib_debug = options.calib_debug; this.calib_debug = options.calib_debug;
this.draw_debug = options.draw_debug;
this.calib_center= options.calib_center; this.calib_center= options.calib_center;
this.previous = options.previous; this.previous = options.previous;
this.next = options.next; this.next = options.next;
this.set_well = options.set_well; this.set_well = options.set_well;
this.well_btn = options.well_btn; this.well_btn = options.well_btn;
this.median = options.median; this.median = options.median;
this.edge_enhance = options.edge_enhance;
this.crop = options.crop; this.crop = options.crop;
this.crop_radius = options.crop_radius; this.crop_radius = options.crop_radius;
this.calib_auto = options.calib_auto; this.calib_auto = options.calib_auto;
try {
this.min_area_px = options.min_area_px;
this.max_area_ratio = options.max_area_ratio;
this.max_planarians = options.max_planarians;
this.merge_kernel_size = options.merge_kernel_size;
this.min_contour_dist_px = options.min_contour_dist_px;
this.draw = options.draw;
} catch(e) {}
} }
init_controls() { init_controls() {
@@ -53,24 +64,35 @@ class ScannerManager {
this.goto_0.addEventListener('click', (e) => { this.clear_buttons(); this._send({ type: 'calibrate', topic: "goto_0" }); }); 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.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.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.set_well.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "set_well" }); });
this.median.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "median" }); }); 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.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.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.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.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.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.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.test.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "test" }); });
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_center.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "center" }); });
this.calib_auto.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "auto" }); }); this.calib_auto.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "auto" }); });
this.halt.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "halt" }); }); this.min_area_px.addEventListener('change', (e) => { this._send({ type: 'calibrate', topic: "min_area_px", value: e.target.value }); });
this.max_area_ratio.addEventListener('change', (e) => { this._send({ type: 'calibrate', topic: "max_area_ratio", value: e.target.value }); });
this.max_planarians.addEventListener('change', (e) => { this._send({ type: 'calibrate', topic: "max_planarians", value: e.target.value}); });
this.merge_kernel_size.addEventListener('change', (e) => { this._send({ type: 'calibrate', topic: "merge_kernel_size", value: e.target.value}); });
this.min_contour_dist_px.addEventListener('change', (e) => { this._send({ type: 'calibrate', topic: "min_contour_dist_px", value: e.target.value }); });
this.draw.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "draw", value: e.target.value }); });
} catch(e) {console.log(e);}
} }
registerSocket(socket) { registerSocket(socket) {
@@ -83,25 +105,32 @@ class ScannerManager {
if (payload.jpeg) { this.container.src = `data:image/jpeg;base64,${payload.jpeg}`; } if (payload.jpeg) { this.container.src = `data:image/jpeg;base64,${payload.jpeg}`; }
if (payload.xbase) { this.xbase.textContent = payload.xbase; this.ybase.textContent = payload.ybase; } if (payload.xbase) { this.xbase.textContent = payload.xbase; this.ybase.textContent = payload.ybase; }
if (payload.xy) { this.x.textContent=payload.x.toFixed(2); this.y.textContent=payload.y.toFixed(2); } if (payload.xy) { this.x.textContent=payload.x.toFixed(2); this.y.textContent=payload.y.toFixed(2); }
if (payload.state) { this.debug.insertAdjacentHTML('afterbegin', `<li>[ ${++this.debug_count} - ${payload.state} ]: ${payload.msg}</li>`); } 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';
}
this.debug.insertAdjacentHTML('afterbegin', `<li>[ ${++this.debug_count} - ${payload.state} ]: ${payload.msg}</li>`);
}
if (payload.ts) { this.ts.textContent = timestampToLocalISOString(payload.ts); } if (payload.ts) { this.ts.textContent = timestampToLocalISOString(payload.ts); }
if (payload.detected && use_tracking) { if (payload.buttons) {
this.cx.textContent = payload.cx; this.cy.textContent = payload.cy; this.well_btn.innerHTML = payload.buttons;
this.speed_px_s.textContent = payload.speed_px_s; document.documentElement.style.setProperty('--well-columns', payload.columns);
this.axial_speed.textContent = payload.axial_speed;
this.axial_pos.textContent = payload.axial_pos;
this.area_px.textContent = payload.area_px;
this.frame_count.textContent = payload.count;
} }
if (payload.buttons) { this.well_btn.innerHTML = payload.buttons; }
if (payload.current >= 0) { if (payload.current >= 0) {
document.querySelectorAll('button.w3-button.well').forEach(btn => { document.querySelectorAll('button.w3-button.well').forEach(btn => {
if (btn.value==payload.current) { btn.classList.add('w3-green'); return; } if (btn.value==payload.current) { btn.classList.add('w3-green'); return; }
btn.classList.remove('w3-green'); btn.classList.remove('w3-green');
}); });
} }
} catch(e) { console.log(e); } } catch(e) { console.log(e); }
} }
@@ -171,7 +171,7 @@ class ReplayManager {
const ok = confirm(`Télécharger le fichier ?\n\n${filename}`); const ok = confirm(`Télécharger le fichier ?\n\n${filename}`);
if (!ok) return false; if (!ok) return false;
fetch(this.video_endpoint, { csrfFetch(this.video_endpoint, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({

Some files were not shown because too many files have changed in this diff Show More