57 Commits

Author SHA1 Message Date
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
88 changed files with 14733 additions and 3199 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
+195
View File
@@ -0,0 +1,195 @@
# 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) |
| `Well` | Puit individuel (nom Ai..Di) |
| `WellPosition` | Position XY mm d'un puit dans un MultiWell + px_per_mm |
| `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 |
| `picamera2_capture.py` | Capture ArduCam via Picamera2 |
| `webcam_capture.py` | Capture webcam via OpenCV |
| `videofile_capture.py` | Lecture fichier vidéo (test/sim) |
| `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` | Alignement et calibration optique du tube |
| `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).
---
## Licence
GPL-3.0
+93 -39
View File
@@ -48,6 +48,8 @@ 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) - Acquisition image haute définition via ArduCam (OpenCV + Picamera2)
@@ -62,44 +64,95 @@ 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)
- **Immobile** : déplacement < 0.2 mm/s
- **Mobile** : 0.2 à 1.5 mm/s
- **Très 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 |
### Métriques calculées
- 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)
- **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
- 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
- **Mobile** : 0.2 à 1.5 mm/s
- **Très 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 |
- Comportements
- **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
### 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
@@ -186,7 +239,9 @@ 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: Ajouter scanner.local au fichier hosts des clients web:
ip.du.rasp.berry scanner.local si 10.8.0.100 est l'ip locale du raspberry pi4 le serveur
10.8.0.100 scanner.local
- linux : /etc/hosts - linux : /etc/hosts
- windows: C:\Windows\System32\drivers\etc\hosts - windows: C:\Windows\System32\drivers\etc\hosts
@@ -199,8 +254,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 +275,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
@@ -315,11 +367,13 @@ PlanarianScanner/
## Procédure de calibration en 4 étapes ## Procédure de calibration en 4 étapes
1. Activer "Debug détection" → voir le cercle et les zones sur le stream 1. Activer "Debug détection" → voir le cercle et les zones sur le stream
2. Activer recadrage pour isoler le tube
Calibration auto Calibration auto
![Aperçu de la vidéo](assets/calibration-auto.png) Calibration auto ![Aperçu de la vidéo](assets/calibration-auto.png) Calibration auto
![Vidéo Calibration auto 🎬](https://www.linuxtarn.org/media/original_videos/calibration-auto.mp4) Vidéo Calibration auto [🎬 Vidéo Calibration auto](https://youtu.be/6RueJ3onUoY)
+362
View File
@@ -0,0 +1,362 @@
# ![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
- High-definition image acquisition via ArduCam (OpenCV + Picamera2)
- 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:*
Add scanner.local to the hosts file on web clients:
if 10.8.0.100 is the Raspberry Pi 4 local IP address of the server
10.8.0.100 scanner.local
- linux : /etc/hosts
- windows: C:\Windows\System32\drivers\etc\hosts
- mac : /private/etc/hosts
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
4-Step Calibration Procedure
Enable "Detection Debug" → display the circle and zones on the stream
Enable cropping to isolate the tube
## 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.
+1 -1
View File
@@ -1,4 +1,4 @@
!/bin/bash #!/bin/bash
echo "==== System installation for rpi" echo "==== System installation for rpi"
echo echo
+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
+1
View File
@@ -19,4 +19,5 @@ opencv-python-headless
mysqlclient mysqlclient
psycopg2 psycopg2
pyserial pyserial
scipy
+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
+6 -2
View File
@@ -19,6 +19,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 +41,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
@@ -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)
+33 -9
View File
@@ -21,8 +21,6 @@ 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 / 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/
@@ -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)
""" """
@@ -241,6 +239,7 @@ DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
LOGGING_FILE = config('LOGGING_FILE') LOGGING_FILE = 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,18 @@ 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 +376,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 +385,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 +401,28 @@ DATETIME_FORMAT = '%d-%m-%Y-%m %H:%M:%S'
#=========================== #===========================
# default configuration # default configuration
# #
#===========================
# rpicam 4056x3040 2028x1080 2028x1520 # rpicam 4056x3040 2028x1080 2028x1520
#===========================
GRBL_SIMULATION = True
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;
}
@@ -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); };
@@ -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
<div class="w3-bar-item">{{ message }}</div> {% if message.tags == 'success' %}w3-green
<div class="w3-bar-item w3-button w3-right" onclick="this.parentElement.style.display='none'">&times;</div> {% elif message.tags == 'error' %}w3-red
</div> {% 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 w3-button w3-right" onclick="this.parentElement.style.display='none'">&times;</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
+101 -31
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
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
@@ -69,24 +83,81 @@ class VideoCaptureInterface(abc.ABC):
self._active_crop = False self._active_crop = False
self._error_occured = False self._error_occured = False
self._tracker = PlanarianTracker( self._tracker = None
tube_axis = settings.TRACKER_TUBE_AXIS, self._metrics = None
min_area_px = settings.TRACKER_MIN_AREA, self._params = 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
display = display, display = display,
) )
self.align_detection = None # résultat du test self.align_detection = None # résultat du test
def on_well_change(self):
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 le CNC lors du changement de puits. Appelé par la CNC lors du changement de puits.
Réinitialise le fond appris et l'état inter-frame du tracker. Réinitialise le fond appris et l'état inter-frame du tracker.
Construit les métriques aussi
""" """
self._tracker.reset() 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
@@ -227,29 +298,32 @@ 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
if self._aligner.debug:
self.align_detection = self._aligner.detect_tube(frame)
annotated = self.align_detection.get('frame_annotated')
frame = annotated if annotated is not None else frame
##
# mode tracking
if self.use_tracking:
ts = datetime.now(timezone.utc).timestamp()
frame, metrics = self._tracker.process(frame, ts)
##
ok, buf = cv2.imencode(".jpg", frame, [cv2.IMWRITE_JPEG_QUALITY, self.jpeg_quality])
if ok:
jpeg = buf.tobytes()
return jpeg, metrics
# Mode debug except Exception as e:
if self._aligner.debug: logger.error(e)
self.align_detection = self._aligner.detect_tube(frame)
annotated = self.align_detection.get('frame_annotated')
frame = annotated if annotated is not None else frame
# mode racking
if self.use_tracking:
ts = datetime.now(timezone.utc).timestamp()
frame, metrics = self._tracker.process(frame, ts)
ok, buf = cv2.imencode(".jpg", frame, [cv2.IMWRITE_JPEG_QUALITY, 85])
if ok:
jpeg = buf.tobytes()
return jpeg, metrics
return jpeg_bytes, metrics return jpeg_bytes, metrics
@@ -325,16 +399,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)
+294
View File
@@ -0,0 +1,294 @@
'''
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
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 = send_callback
if self._state is None:
self._state = self._send_msg
# Position courante simulée
self.x, self.y = 0.0, 0.0
# É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, self.y)
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, new_y, feed = self.x, self.y, 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'))
if has_move and (new_x != self.x or new_y != self.y):
distance = math.hypot(new_x - self.x, new_y - self.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>.'''
status = f"<{self._machine_state}|MPos:{self.x:.3f},{self.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 + dx, y + 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
+513 -212
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 = idx
self.cx = None
self.cy = None
self.ts = None
self.lost = 0 # compteur de frames sans détection
self.active = False # vrai si l'individu a été détecté au moins une fois
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.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
self.tube_axis = tube_axis
self.min_area_px = min_area_px
# Etat inter-frame def __init__(
self._prev_cx = None self,
self._prev_cy = None tube_axis: str = "vertical",
self._prev_ts = None 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.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
# Un état inter-frame par slot individu
self._states = [PlanarianState(i) for i in range(self.max_planarians)]
# 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
# ------------------------------------------------------------------ #
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,
})
# 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 = {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 { return {
"timestamp" : ts, "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 -1
View File
@@ -27,8 +27,8 @@ 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,
+3 -14
View File
@@ -38,7 +38,6 @@ class VideoFileCapture(VideoCaptureInterface):
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 = 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():
+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"}),
)
+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.id).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 scanner_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) {
fetch(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>
{% endfor %}
</div>
{% if form.record_type.errors %}
<span class="w3-text-red w3-small">{{ form.record_type.errors|join:", " }}</span>
{% endif %}
</div> </div>
</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 %}
<span class="w3-text-red w3-small">{{ form.stop_dt.errors|join:", " }}</span>
{% endif %}
</div> </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 %}
+5 -9
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"),
] ]
+190 -129
View File
@@ -2,178 +2,235 @@
#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.views.decorators.csrf import csrf_exempt
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
@csrf_exempt
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.id) # @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.id, experiment.multiwell.position, well_name)
'''
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,
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')}_"
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):
created = 0
updated = 0
errors = 0
class ImportParamsView(FormView): for row in rows:
try:
params = ExperimentParams.from_csv_row(row)
d = params.to_dict()
obj, is_new = ExperimentConfig.objects.get_or_create(
experiment = current_experiment.identifier,
well = d.get("well"),
)
if is_new or overwrite:
for k, v in d.items():
if k not in ["well", "experiment", "author", "experiment_key", "active"] and hasattr(obj, k):
setattr(obj, k, v)
obj.save()
if is_new:
created += 1
else:
updated += 1
except Exception as e:
logger.warning(f"Ligne ignorée ({row}): {e}")
errors += 1
messages.success(
request,
_("Import terminé : %(c)d créés, %(u)d mis à jour, %(e)d erreurs.")
% {"c": created, "u": updated, "e": errors},
)
return redirect("redirect_to_mainboard")
@login_required
def import_csv_view(request):
""" """
Import de configurations d'expérience depuis un fichier CSV. Import de configurations d'expérience depuis un fichier CSV.
Une ligne CSV = un puits = un ExperimentConfig. Une ligne CSV = un puits = un ExperimentConfig.
Colonnes CSV obligatoires : experiment, well, px_per_mm, fps Colonnes CSV obligatoires: well, px_per_mm, fps
Toutes les autres colonnes correspondent aux champs du modèle. 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/import_params.html" if valid == 'ok' and current_experiment:
form_class = CsvImportForm
def form_valid(self, form):
rows = form.csv_rows
overwrite = form.cleaned_data["overwrite"]
created = 0
updated = 0
errors = 0
for row in rows:
try: try:
params = ExperimentParams.from_csv_row(row) f = request.FILES.get('csv_file')
d = params.to_dict() overwrite = request.POST.get("overwrite")
try:
content = f.read().decode("utf-8-sig")
reader = csv.DictReader(io.StringIO(content))
rows = list(reader)
except Exception as e:
msg = f'Fichier CSV invalide : {e}'
raise Exception(msg)
obj, is_new = ExperimentConfig.objects.get_or_create( required = {"well", "px_per_mm", "fps"}
experiment = d["experiment"], if rows:
well = d["well"], missing = required - set(rows[0].keys())
) if missing:
msg = _("Colonnes manquantes : %(cols)s") % {"cols": ", ".join(missing)}
if is_new or overwrite: raise Exception(msg)
for k, v in d.items():
if k not in ("experiment", "well") and hasattr(obj, k):
setattr(obj, k, v)
obj.save()
if is_new:
created += 1
else:
updated += 1
return import_csv(request, current_experiment, rows, overwrite)
except Exception as e: except Exception as e:
logger.warning(f"Ligne ignorée ({row}): {e}") messages.error(request, msg)
errors += 1 logger.error(msg)
ctx = { 'choice_title': _("Importer des configurations depuis un fichier CSV"), **session_context }
messages.success( return render(request, "planarian/import_params.html", context=global_context(request, **ctx))
self.request,
_("Import terminé : %(c)d créés, %(u)d mis à jour, %(e)d erreurs.")
% {"c": created, "u": updated, "e": errors},
)
return redirect("planarian:experiment-list")
# ---------------------------------------------------------------------------
# Vue : export CSV depuis ReductStore
# ---------------------------------------------------------------------------
class ExportCsvView(FormView):
"""
Export des données de tracking depuis ReductStore vers un fichier CSV.
Retourne le fichier en téléchargement HTTP.
"""
template_name = "planarian/export_csv.html"
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:
csv_content, n = await client.export_csv_response(
experiment = d["experiment"],
well = d["well"],
planarian = d["planarian"],
record_type = d["record_type"],
start = d.get("start_dt"),
stop = d.get("stop_dt"),
)
finally:
await client.close()
return csv_content, n
csv_content, n = _do_export()
if not csv_content:
messages.warning(self.request, _("Aucune donnée trouvée."))
return self.form_invalid(form)
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
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 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 +259,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)
return global_context(self.request, choice_title=str(_("Métriques de tracking d'un planaire")), **context)
+1330
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
+58 -8
View File
@@ -1,8 +1,8 @@
from django.utils.translation import gettext_lazy as _
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 +10,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', 'active',)
fieldsets = (
(_("Identification"), {
"fields": ("label", "author", "position", "default", "active"),
}),
(_("Géométrie"), {
"fields": ("cols", "rows", "diameter", "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 +101,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',
+8
View File
@@ -22,12 +22,20 @@ class DefaultConfig:
video_frame_rate: int = 5.0 video_frame_rate: int = 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:
-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):
+28 -43
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
@@ -38,12 +38,7 @@ async def remove_video_by_uuid(uuid, start_ts=None, end_ts=None, when=None):
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'):
try: try:
@@ -79,7 +74,8 @@ 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)
@@ -97,36 +93,16 @@ 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
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 +121,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 +132,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
@@ -243,11 +223,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 +273,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:
@@ -395,7 +378,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 ---
@@ -451,18 +434,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 +455,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:
+126 -32
View File
@@ -7,7 +7,6 @@ import json
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
@@ -18,8 +17,6 @@ MULTIWELL_POSITION = [
('HD', _("HD-Haut droit")), ('HD', _("HD-Haut droit")),
('BG', _("BG-Bas gauche")), ('BG', _("BG-Bas gauche")),
('BD', _("BD-Bas droit")), ('BD', _("BD-Bas droit")),
('BM', _("BM-Bas milieu")),
('HM', _("HM-Haut milieu")),
] ]
FOURCC_FORMAT = [ FOURCC_FORMAT = [
@@ -35,12 +32,17 @@ VIDEO_TYPE = [
CAPTURE_TYPE = [ CAPTURE_TYPE = [
('rpi', _("Arducam")), ('rpi', _("Arducam")),
('webcam', _("Webcam")), ('webcam', _("Webcam")),
('file', _("mp4")), ('file', _("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 +53,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 +62,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,6 +70,14 @@ 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)
@@ -83,7 +95,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:
@@ -97,20 +109,20 @@ class Well(models.Model):
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(_("Par 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")
# 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)
@@ -164,7 +176,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 +187,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):
@@ -222,19 +238,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 +276,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 +301,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(self, 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.id} ] {self.name} ({state})'
@receiver(post_save, sender=Session) @receiver(post_save, sender=Session)
@@ -305,8 +340,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 +375,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 +402,66 @@ 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}'
@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,
}
)
+212 -92
View File
@@ -7,6 +7,8 @@ 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
@@ -14,13 +16,12 @@ from threading import Thread, Event
from django.utils import timezone from django.utils import timezone
from django.utils.html import mark_safe from django.utils.html 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,6 +89,26 @@ 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
@@ -110,89 +134,162 @@ class MultiWellManager:
return self.multiwell.config() return self.multiwell.config()
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 _grid_scanning_capture(self, uuid, duration): #def _grid_scanning_capture(self, uuid, duration):
self.process.data.uuid = uuid def _grid_scanning_capture(self, experiment, well_position, simulate=False):
self.process.data.record = True uuid = None
try:
well = well_position.well
multiwell = experiment.multiwell
start = time.monotonic() ## create uuid for this capture
while not self.stop_playing.is_set(): uuid = f'{self.process.data.session}-{multiwell.position}-{well.name}'
if time.monotonic() - start > duration:
break
self.cnc_controller.wait_for(1.0)
logger.info(f"Arrêter l'enregistrement {uuid}") if self.process.use_tracking:
self.process.data.record = False cfg = ExperimentConfig.objects.filter(experiment_key_id=experiment.id, well=well.name).first()
self.process.data.uuid = None if not cfg:
raise Exception(f"Configuration d'expérience introuvable pour {experiment} / {well}")
# reset PlanarianTracker => on_well_change
self.process.cam.on_well_change(cfg, uuid=uuid, draw_contours=False)
## start recording
self.process.data.uuid = uuid
if not simulate:
self.process.data.record = True
self.process._send(current=well_position.order)
def _grid_scanning(self, experiment, xnext=0, ynext=0): msg = f"Starting capture for {uuid} ordre: {well_position.order}"
multiwell = experiment.multiwell logger.info(msg)
wells = models.WellPosition.objects.filter(multiwell_id=multiwell.id).order_by('order').all() self.process._send(well_state=msg)
cam = self.process.cam
cam._aligner.set_tube_diameter(multiwell.diameter)
self.stop_playing = Event() start = time.monotonic()
for wl in wells: while not self.stop_playing.is_set():
if self.stop_playing.is_set(): ## stop after duration in experiemnt now
break if time.monotonic() - start > experiment.duration:
self.cnc_controller.move_to(wl.x, wl.y, feed=wl.multiwell.feed) break
self.cnc_controller.wait_for(0.1)
uuid = f'{self.process.data.session}-{multiwell.position}-{wl.well.name}' self.process.cam._flush_current_well(uuid)
self._grid_scanning_capture(uuid, multiwell.duration)
## change file self.process.data.record = False
if self.process.conf.capture_type == 'file': self.process.data.uuid = None
self.process.cam._error_occured = True
self.process._send(scan_state=f"{uuid}: capture") msg = f"{uuid}: capture done..."
except Exception as e:
msg = f"error during capture - {e}"
logger.error(msg)
finally:
self.process.cam._flush_current_well(uuid)
logger.info(f"Scan terminé — retour à l'origine (X={xnext:.1f} Y={ynext:.1f})")
self.cnc_controller.move_to(xnext, ynext, feed=multiwell.feed*2)
def _start_scanning(self, session, experiments):
self.process.cam._aligner.debug = False
xynext = []
for obs in experiments:
xynext.append((obs.multiwell.xbase, obs.multiwell.ybase))
xynext.append((0, 0))
pos = 1
self.process.data.session = session.id
started = timezone.now()
for obs in experiments:
if self.stop_playing.is_set():
break
obs.started = timezone.now()
obs.save()
xnext, ynext = xynext[pos]
pos +=1
self._grid_scanning(obs, xnext=xnext, ynext=ynext)
obs.finished = timezone.now()
obs.save()
session.finished = timezone.now()
if self.stop_playing.is_set():
msg = f"Session {session.name} abandonnée à {session.finished} après {session.finished - started} secondes."
else:
session.active = False
if session.scanning_task:
session.scanning_task.enabled = False
session.save()
msg = f"Session {session.name} terminée à {session.finished} après {session.finished - started} secondes."
logger.info(msg) logger.info(msg)
self.process._send(scan_state=msg) self.process._send(scan_state=msg)
self.scan_thread = None
def _capture_file_simulation(self, name):
vf = settings.MEDIA_ROOT / 'simulation' / f'{name}.mp4'
if vf.exists():
self.process.cam._video_file = str(vf)
self.process.cam._error_occured = True
logger.info(f"Simulating capture with file {vf}")
def _is_well_valid(self, welposition, experiment):
names = models.ExperimentWell.wellname_by_experiment(experiment.id)
if welposition.well.name not in names:
return False
return True
def _grid_scanning(self, experiment, xnext=0, ynext=0, simulate=False):
try:
multiwell = experiment.multiwell
wellpositions = models.WellPosition.objects.filter(multiwell_id=multiwell.id).order_by('order').all()
cam = self.process.cam
cam._aligner.set_tube_diameter(multiwell.diameter)
for wl in wellpositions:
if self.stop_playing.is_set():
break
if not self._is_well_valid(wl, experiment):
continue
self.cnc_controller.move_to(wl.x, wl.y, feed=wl.multiwell.feed)
if self.process.conf.capture_type == 'file':
self._capture_file_simulation(wl.well.name)
self._grid_scanning_capture(experiment, wl, simulate=simulate)
msg =f"Scan terminé — retour à l'origine (X={xnext:.1f} Y={ynext:.1f})"
logger.info(msg)
self.process._send(state='scan_finished', msg=msg)
return True
except Exception as e:
msg = f"Error during grid scanning - {e}"
logger.error(msg)
self.process._send(state='error', msg=msg)
return False
finally:
self.cnc_controller.move_to(xnext, ynext, feed=multiwell.feed*2)
def _start_scanning(self, session, experiments, simulate=False):
result = False
try:
self.process.get_config() # get video configuration if updated
self.process.cam._aligner.debug = False
self.stop_playing.clear()
xynext = []
for obs in experiments:
xynext.append((obs.multiwell.xbase, obs.multiwell.ybase))
xynext.append((0, 0))
pos = 1
self.process.data.session = session.id
started = timezone.now()
for obs in experiments:
msg = f"Starting scan for {obs} (well {pos}/{len(experiments)})"
logger.warning(msg)
self.process._send(well_state=msg)
if self.stop_playing.is_set():
break
obs.started = timezone.now()
obs.save()
xnext, ynext = xynext[pos]
pos +=1
result = self._grid_scanning(obs, xnext=xnext, ynext=ynext, simulate=simulate)
obs.finished = timezone.now()
obs.save()
session.finished = timezone.now()
if self.stop_playing.is_set() or not result:
msg = f"Session {session.name} abandonnée à {session.finished} après {session.finished - started} secondes."
else:
if not simulate:
session.active = False
if session.scanning_task:
session.scanning_task.enabled = False
session.save()
msg = f"Session {session.name} terminée à {session.finished} après {session.finished - started} secondes."
logger.info(msg)
self.process._send(scan_state=msg)
except Exception as e:
logger.error("Error during scanning process", e)
finally:
self.scan_thread = None
self.goto_0()
self.process._send(current=self.get_well_order())
def halt_scanning(self): def halt_scanning(self):
@@ -200,36 +297,57 @@ 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() self.scan_thread = Thread(target=self._start_scanning, args=(session, experiments, simulate, ), daemon=True)
except Exception as e: self.scan_thread.start()
print("MultiWellManager::scan error", e)
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)
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)
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)
self.cnc_controller.move_to(wl.x, wl.y, feed=wl.multiwell.feed) if self.process.conf.capture_type == 'file':
return {"state": "goto", "msg": f">>> ({wl.x}, {wl.y})"} self._capture_file_simulation(wl.well.name)
self.cnc_controller.move_to(wl.x, wl.y, feed=wl.multiwell.feed)
return {"state": "goto", "msg": f">>> {wl.well.name}: ({wl.x}, {wl.y})"}
def goto_xy(self):
wl = self.well_iterator.seek(0)
if self.process.conf.capture_type == 'file':
self._capture_file_simulation(wl.well.name)
self.cnc_controller.move_to(self.xbase, self.ybase, feed=self.feed)
def goto_0(self):
self.well_iterator.reset()
if self.process.conf.capture_type == 'file':
self._capture_file_simulation('zero')
self.cnc_controller.move_to(0, 0, feed=self.feed*2)
def set_well_position(self): def set_well_position(self):
wl = self.well_iterator.get_current() wl = self.well_iterator.get_current()
@@ -243,7 +361,7 @@ 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
@@ -252,8 +370,12 @@ class MultiWellManager:
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)
## change file
if self.process.conf.capture_type == 'file':
self._capture_file_simulation(wl.well.name)
self.process._send(current=wl.order) self.process._send(current=wl.order)
start = time.monotonic() start = time.monotonic()
@@ -287,23 +409,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 +492,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 +508,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}"}
+126 -53
View File
@@ -21,14 +21,18 @@ 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
# 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:
@@ -42,6 +46,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 +145,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 +160,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,20 +168,29 @@ 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 get_config(self):
'''
Constants:
reset si besoin les constantes vidéo
'''
self.conf = ScannerConstants().get()
self.use_tracking = self.conf.tracking
self.video_quality = self.conf.video_jpeg_quality
self.image_quality = self.conf.image_quality
self.video_fps = self.conf.video_frame_rate
self.video_width = self.conf.video_width_capture
self.video_height = self.conf.video_height_capture
self.crop_radius = self.conf.calibration_crop_radius
self.video_jpg_quality = [int(cv2.IMWRITE_JPEG_QUALITY), self.video_quality]
self.image_jpg_quality = [int(cv2.IMWRITE_JPEG_QUALITY), self.image_quality]
return self.conf
def start(self, *args, **kwargs): def start(self, *args, **kwargs):
try: try:
self.conf = ScannerConstants().get() self.get_config()
self.use_tracking = self.conf.tracking
self.video_quality = self.conf.video_jpeg_quality
self.image_quality = self.conf.image_quality
self.video_fps = self.conf.video_frame_rate
self.video_width = self.conf.video_width_capture
self.video_height = self.conf.video_height_capture
self.crop_radius = self.conf.calibration_crop_radius
self.video_jpg_quality = [int(cv2.IMWRITE_JPEG_QUALITY), self.video_quality]
self.image_jpg_quality = [int(cv2.IMWRITE_JPEG_QUALITY), self.image_quality]
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
@@ -181,14 +198,9 @@ class ScannerProcess(Task):
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 +208,6 @@ 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 == 'webcam': elif capture_type == 'webcam':
from modules.webcam_capture import WebcamCapture from modules.webcam_capture import WebcamCapture
@@ -226,7 +237,7 @@ 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( self.grbl = GRBLController(
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 +245,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 +263,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 +283,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:
if self.data.record: def _store_metric(self, uuid, metrics, ts):
self.record_queue.put((self.data.uuid, ts, jpeg_bytes, metrics))
if self.data.play: if not isinstance(metrics, list):
self._send(ts=ts.timestamp(), jpeg=base64.b64encode(jpeg_bytes).decode(), **metrics) 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:
self.record_queue.put((self.data.uuid, ts, jpeg_bytes, metrics, frame_count))
if self.data.play:
jpeg=base64.b64encode(jpeg_bytes).decode()
self._send(ts=ts.timestamp(), jpeg=jpeg, frame_count=frame_count)
except Exception as e:
logger.error(e)
def _recording(self): 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,17 +341,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: 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()
self.manager = MultiWellManager(process=self)
self.manager.set_calib_debug(False)
for message in pubsub.listen(): for message in pubsub.listen():
try: try:
@@ -333,15 +378,32 @@ class ScannerProcess(Task):
self.cam.set_circular_crop(self.crop) self.cam.set_circular_crop(self.crop)
self.cam._active_median = False self.cam._active_median = False
self.grbl.go_origin(feed=self.manager.feed) self.grbl.go_origin(feed=self.manager.feed)
self.cam.set_draw_contours(False)
buttons = self.manager.multiwell_buttons(btn_class="w3-btn well", onclick="")
self._send(buttons=buttons)
elif topic == 'scan': 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:
self.cam._active_median = False try:
self.manager.scanning(sid) self.cam._active_median = False
simulate = (topic=='simulate')
self.manager.scan_process(sid, simulate)
self._send(state=topic, msg=str(_('Balayage démarré...')))
except Exception as e:
logger.error(f"Scan error: {e}")
self._send(state='error', msg=str(_('Erreur lors du démarrage du balayage...')))
continue
self._send(
buttons=buttons,
current=self.manager.get_well_order(),
)
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 +418,7 @@ 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_draw_contours(False)
buttons = self.manager.multiwell_buttons() buttons = self.manager.multiwell_buttons()
elif topic == 'up': elif topic == 'up':
@@ -372,11 +435,13 @@ 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 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) self.cam.set_circular_crop(self.crop) if self.cam._active_crop 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':
@@ -400,12 +465,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()
@@ -431,7 +494,6 @@ 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':
msg = self.manager.calib_toggle_debug() msg = self.manager.calib_toggle_debug()
self._send(**msg) self._send(**msg)
@@ -453,6 +515,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,
@@ -10,5 +10,3 @@
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(6, 1fr);
justify-items: center;
align-items: center;
}
@@ -42,6 +42,15 @@ class ScannerManager {
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() {
@@ -71,6 +80,15 @@ class ScannerManager {
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.halt.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "halt" }); });
try {
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) {}
} }
registerSocket(socket) { registerSocket(socket) {
@@ -83,25 +101,25 @@ 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 == 'median') {
const span = this.median.querySelector("span.median"); span.style.color = payload.value ? '#f0f' : '#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) {
this.cx.textContent = payload.cx; this.cy.textContent = payload.cy;
this.speed_px_s.textContent = payload.speed_px_s;
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.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); }
} }
@@ -23,6 +23,9 @@ class ScannerManager {
this.area_px = options.area_px; this.area_px = options.area_px;
this.frame_count = options.frame_count; this.frame_count = options.frame_count;
this.scan_state = options.scan_state; this.scan_state = options.scan_state;
this.sim_bt = options.simulate || null;
this.well_btn = options.well_btn;
this.well_state = options.well_state;
} }
init_controls() { init_controls() {
@@ -30,6 +33,9 @@ class ScannerManager {
this.crop.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "crop" }); }); this.crop.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "crop" }); });
this.scan_bt.addEventListener('click', (e) => { this.scan(); }); this.scan_bt.addEventListener('click', (e) => { this.scan(); });
this.halt_bt.addEventListener('click', (e) => { this.halt(); }); this.halt_bt.addEventListener('click', (e) => { this.halt(); });
if (this.sim_bt)
this.sim_bt.addEventListener('click', (e) => { this.simulate(); });
} }
registerSocket(socket) { registerSocket(socket) {
@@ -41,23 +47,34 @@ class ScannerManager {
try { try {
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.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 == 'median') {
const span = this.median.querySelector("span.median"); span.style.color = payload.value ? '#f0f' : '#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.scan_state) { this.scan_state.textContent=payload.scan_state;} if (payload.scan_state) { this.scan_state.textContent=payload.scan_state;}
if (payload.well_state) { this.well_state.textContent=payload.well_state;}
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; const span = this.crop.querySelector("span.crop"); span.style.color = '#f0f';
this.axial_speed.textContent = payload.axial_speed; }
this.axial_pos.textContent = payload.axial_pos; if (payload.current >= 0) {
this.area_px.textContent = payload.area_px; document.querySelectorAll('button.w3-btn.well').forEach(btn => {
this.frame_count.textContent = payload.count; if (btn.value==payload.current) { btn.classList.add('w3-green'); return; }
btn.classList.remove('w3-green');
});
} }
} catch(e) { console.log(e); } } catch(e) { console.log(e); }
} }
init() { this.axes = 0; this.cropping = 1; this._send({ type: 'scanner', topic: "init", }); } init() { this.axes = 0; this.cropping = 1; this._send({ type: 'scanner', topic: "init", }); }
scan() { this._send({ type: 'scanner', topic: "scan", session: this.session.value ? this.session.value: "0" }); } scan() { this._send({ type: 'scanner', topic: "scan", session: this.session.value ? this.session.value: "0" }); }
simulate() { this._send({ type: 'scanner', topic: "simulate", session: this.session.value ? this.session.value: "0" }); }
halt() { this._send({ type: 'calibrate', topic: "halt" }); } halt() { this._send({ type: 'calibrate', topic: "halt" }); }
_send(message) { this.socket.send(message); } _send(message) { this.socket.send(message); }
+112 -65
View File
@@ -1,68 +1,51 @@
# tasks.py # tasks.py
import asyncio import asyncio
import json
import requests
from celery import shared_task, group, chord, chain from celery import shared_task, group, chord, chain
from celery.utils.log import get_task_logger from celery.utils.log import get_task_logger
from django.utils import timezone from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from .process import ScannerProcess, ReplayProcess from .process import ScannerProcess, ReplayProcess, redisDB
from .export_tasks import shm_download_video, export_images_zip, export_video_mp4 from .export_tasks import shm_download_video, export_images_zip, export_video_mp4
from . import models from .constants import ScannerConstants
from . import models
logger = get_task_logger(__name__) logger = get_task_logger(__name__)
class ScannerTaskManager:
def __init__(self):
self.scanner = None
self.replay = None
def start_scanner(self):
if self.scanner is None:
self.scanner = ScannerProcess()
self.scanner.start()
def stop_scanner(self):
if self.scanner:
self.scanner.stop()
def start_replay(self, latency=5.0):
if self.replay is None:
self.replay = ReplayProcess(latency=latency)
self.replay.start()
def stop_replay(self):
if self.replay:
self.replay.stop()
task_manager = ScannerTaskManager()
@shared_task(bind=True)
def scanner_start(self):
task_manager.start_scanner()
return f"Scanner démarré."
#@shared_task(bind=True)
#def scanner_stop(self):
# task_manager.stop_scanner()
# return f"Scanner arrêté."
@shared_task(bind=True)
def replay_start(self):
task_manager.start_replay()
return f"Replay démarré."
#@shared_task(bind=True)
#def replay_stop(self):
# task_manager.stop_replay()
# return f"Replay arrêté."
@shared_task @shared_task
def download_video(uuid, start_ts, end_ts, frame_rate=10, opencv_fourcc_format='mp4v', opencv_video_type='mp4'): def scanner_start():
scanner = ScannerProcess()
scanner.start()
return str(_("Scanner démarré."))
@shared_task
def replay_start():
replay = ReplayProcess(latency=5.0)
replay.start()
return str(_("Replay démarré."))
@shared_task(bind=True)
def download_video(self, uuid, start_ts, end_ts, frame_rate=5, opencv_fourcc_format='mp4v', opencv_video_type='mp4'):
"""
Exécuter directement la fonction async DANS la tâche Celery.
Sans passer par une wrapper.
"""
try: try:
return asyncio.run(shm_download_video(uuid, start_ts, end_ts, frame_rate, opencv_fourcc_format, opencv_video_type)) logger.info(f"Task {self.request.id}: Processing {uuid} from {start_ts} to {end_ts}")
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
result = loop.run_until_complete(
shm_download_video(uuid, start_ts, end_ts, frame_rate, opencv_fourcc_format, opencv_video_type) )
return result
finally:
loop.close()
except Exception as e: except Exception as e:
logger.error(f"download_video: {e}") logger.error(f"shm_download_video_task error: {e}", exc_info=True)
return { 'status': 'error', 'error': str(e), 'uuid': uuid}
@shared_task @shared_task
def export_images( def export_images(
@@ -78,10 +61,12 @@ def export_images(
except Exception as e: except Exception as e:
logger.error(f"export_images: {e}") logger.error(f"export_images: {e}")
@shared_task @shared_task
def export_all_images(session_id=None): def export_all_images(session_id=None):
try: try:
conf = models.Configuration.objects.filter(active=True).first() conf = ScannerConstants().get()
if session_id is None: if session_id is None:
sessions = [s.id for s in models.Session.objects.filter(active=False)] sessions = [s.id for s in models.Session.objects.filter(active=False)]
else: else:
@@ -91,6 +76,7 @@ def export_all_images(session_id=None):
uuid_list = models.SessionExperiment.uuid_from_session(session_id) uuid_list = models.SessionExperiment.uuid_from_session(session_id)
job_zip = [] job_zip = []
for uuid in uuid_list: for uuid in uuid_list:
job = export_images.delay( # @UndefinedVariable job = export_images.delay( # @UndefinedVariable
uuid, uuid,
start_ts=None, start_ts=None,
@@ -103,11 +89,9 @@ def export_all_images(session_id=None):
except Exception as e: except Exception as e:
logger.error(f"export_images: {e}") logger.error(f"export_images: {e}")
@shared_task @shared_task
def export_videos(uuid: str, def export_videos(uuid: str, start_ts: float | None = None, end_ts: float | None = None, frame_rate: int = 5,
start_ts: float | None = None,
end_ts: float | None = None,
frame_rate: int = 5,
opencv_fourcc_format='mp4v', opencv_fourcc_format='mp4v',
opencv_video_type='mp4', opencv_video_type='mp4',
max_video_size_mb: int = 0, max_video_size_mb: int = 0,
@@ -117,12 +101,13 @@ def export_videos(uuid: str,
return asyncio.run(export_video_mp4(uuid, start_ts, end_ts, frame_rate, opencv_fourcc_format, opencv_video_type, max_video_size_mb, max_width, max_height)) return asyncio.run(export_video_mp4(uuid, start_ts, end_ts, frame_rate, opencv_fourcc_format, opencv_video_type, max_video_size_mb, max_width, max_height))
except Exception as e: except Exception as e:
logger.error(f"export_videos: {e}") logger.error(f"export_videos: {e}")
return {"success": False, "error": str(e)}
@shared_task @shared_task
def export_all_videos(session_id=None): def export_all_videos(session_id=None):
try: try:
conf = models.Configuration.objects.filter(active=True).first() conf = ScannerConstants().get()
if session_id is None: if session_id is None:
sessions = [s.id for s in models.Session.objects.filter(active=False)] sessions = [s.id for s in models.Session.objects.filter(active=False)]
else: else:
@@ -132,6 +117,7 @@ def export_all_videos(session_id=None):
uuid_list = models.SessionExperiment.uuid_from_session(session_id) uuid_list = models.SessionExperiment.uuid_from_session(session_id)
job_mp4 = [] job_mp4 = []
for uuid in uuid_list: for uuid in uuid_list:
job = export_videos.delay( # @UndefinedVariable job = export_videos.delay( # @UndefinedVariable
uuid, uuid,
start_ts=None, start_ts=None,
@@ -162,7 +148,7 @@ def run_session_exports(self, session_id: str):
session.status = models.Session.Status.RUNNING session.status = models.Session.Status.RUNNING
session.save(update_fields=["export_status"]) session.save(update_fields=["export_status"])
logger.info(f"run_session_exports [{session_id}]: export démarré")
chord( chord(
group( group(
export_all_images.s(session_id), export_all_images.s(session_id),
@@ -187,7 +173,7 @@ def on_exports_done(results: list, session_id: str):
logger.error(f"on_exports_done [{session_id}]: échec avec {len(errors)} erreurs") logger.error(f"on_exports_done [{session_id}]: échec avec {len(errors)} erreurs")
else: else:
session.export_status = models.Session.Status.DONE session.export_status = models.Session.Status.DONE
logger.info(f"on_exports_done [{session_id}]: succès export terminé") logger.warning(f"on_exports_done [{session_id}]: succès export terminé")
session.export_exported_at = timezone.now() session.export_exported_at = timezone.now()
session.save(update_fields=["export_status", "export_exported_at"]) session.save(update_fields=["export_status", "export_exported_at"])
@@ -200,11 +186,10 @@ def scanning(session_id: str):
Scanning différé. Scanning différé.
""" """
try: try:
scanner = task_manager.scanner logger.warning("==== scanning session %s start", session_id)
scanner.cam.set_median(is_median=False) group = 'scanner_proc'
scanner.cam.set_circular_crop(scanner.crop) data = { "type": "scanner", "topic": "scan", "session": session_id }
scanner.grbl.go_origin(feed=scanner.manager.feed) redisDB.publish(group, json.dumps(data))
scanner.manager.scan(scanner.grbl, session_id)
except Exception as e: except Exception as e:
logger.error(f"scanning session: {session_id} error {e}") logger.error(f"scanning session: {session_id} error {e}")
return {"status": "error", "message": str(e)} return {"status": "error", "message": str(e)}
@@ -243,4 +228,66 @@ def on_scanning_done(result: dict, session_id: str):
session.scanning_finished_at = timezone.now() session.scanning_finished_at = timezone.now()
session.save(update_fields=["scanning_status", "scanning_finished_at"]) session.save(update_fields=["scanning_status", "scanning_finished_at"])
logger.info("==== Scanning done: session %s", session_id)
@shared_task
def http_request(req_type='json_post', url=None, user=None, passwd=None, headers={}, **kwargs):
def json_post():
return requests.post(url, json=kwargs, headers=headers, auth=(user, passwd), timeout=3)
def post():
return requests.post(url, params=kwargs, headers=headers, auth=(user, passwd), timeout=3)
def get():
return requests.get(url, params=kwargs, headers=headers, auth=(user, passwd), timeout=3)
request = {
'json_post': json_post,
'post': post,
'get':get
}
retries = 3
for attempt in range(retries):
try:
response = request[req_type]()
response.raise_for_status()
try:
data = response.json()
except ValueError:
data = response.text
resp = {"success": True, "data": data}
logger.info(data)
return resp
except requests.exceptions.Timeout:
logger.error(f"{attempt + 1}/{retries}: Délai d'attente dépassé")
except requests.exceptions.ConnectionError:
logger.error(f"{attempt + 1}/{retries}: Erreur de connexion")
except requests.exceptions.HTTPError as e:
raise
except Exception as e:
logger.error(f"Erreur inattendue: {str(e)}")
@shared_task
def supervisor_restart_service(params):
"""
Redémarre un service.
"""
try:
logger.warning("==== Supervisor scanner restart all services")
http_request(
req_type='get',
url=f'http://localhost:9001/{params}',
user='root',
passwd='toor',
headers={}
)
except Exception as e:
logger.error(f"supervisor_restart_all_services error {e}")
@@ -11,21 +11,69 @@
<a href="#" class="w3-bar-item w3-mobile w3-btn w3-hover-opacity w3-right w3-xlarge w3-text-green" onclick="goFullscreen()" title="{% trans 'Ecran plein' %}"> <a href="#" class="w3-bar-item w3-mobile w3-btn w3-hover-opacity w3-right w3-xlarge w3-text-green" onclick="goFullscreen()" title="{% trans 'Ecran plein' %}">
<i class="fa-solid fa-maximize"></i> <i class="fa-solid fa-maximize"></i>
</a> </a>
<div class="w3-dropdown-hover w3-right">
<button class="w3-button w3-dark-plus">&#9776;&nbsp;<span>{{ request.user.username }}</span></button>
<div class="w3-dropdown-content w3-bar-block w3-border w3-dark-low" style="right:.25em;">
{% if request.user.is_superuser %}
<a href="{% url 'scanner:admin' %}" class="w3-bar-item w3-btn w3-hover-opacity" title="{% trans "Accès django" %}">
<i class="fa-solid fa-gear w3-text-pink"></i> {% trans "Base de données" %}
</a>
<a href="{% url 'scanner:supervisor' %}" class="w3-bar-item w3-btn w3-hover-opacity" target="_blank" title="{% trans "Accès système" %}">
<i class="fa-solid fa-gears w3-text-purple"></i> {% trans "Supervision des services" %}
</a>
<a href="{% url 'scanner:reductstore' %}" class="w3-bar-item w3-btn w3-hover-opacity" target="_blank" title="{% trans "Accès système" %}">
<img class="w3-no-padding" src="/static/img/reductstore.png" style="width: 24px"> {% trans "Base de données Reductstore" %}
</a>
<a href="{% url 'scanner:adminer' %}" class="w3-bar-item w3-btn w3-hover-opacity" target="_blank" title="{% trans "Accès système" %}">
<img class="w3-no-padding" src="/static/img/adminer.png" style="width: 28px"> {% trans "Base de données mariadb" %}
</a>
<a href="{% url 'scanner:logs_scheduler' %}" class="w3-bar-item w3-btn w3-hover-opacity" target="_blank">
<i class="fa-regular fa-file-lines w3-text-orange"></i> {% trans "Logs des planifications" %}
</a>
<a href="{% url 'scanner:logs_worker' %}" class="w3-bar-item w3-btn w3-hover-opacity" target="_blank">
<i class="fa-regular fa-file-lines w3-text-amber"></i> {% trans "Logs des services" %}
</a>
{% endif %}
<div class="w3-bar-item w3-blue-grey"><i class="fa-solid fa-circle-question w3-text-amber"></i> {% trans "Guides" %}</div>
<a href="{% url 'scanner:documentation' 'doc_database' %}" class="w3-bar-item w3-btn w3-hover-opacity">
<i class="fa-solid fa-book w3-text-green"></i> {% trans "Descriptif des données" %}
</a>
<a href="{% url 'scanner:documentation' 'doc_calibration' %}" class="w3-bar-item w3-btn w3-hover-opacity">
<i class="fa-solid fa-book w3-text-pink"></i> {% trans "Calibration des multi-puits" %}
</a>
<a href="{% url 'scanner:documentation' 'doc_experiment' %}" class="w3-bar-item w3-btn w3-hover-opacity">
<i class="fa-solid fa-book w3-text-khaki"></i> {% trans "Démarrer des expériences" %}
</a>
<a href="{% url 'scanner:documentation' 'doc_scanning' %}" class="w3-bar-item w3-btn w3-hover-opacity">
<i class="fa-solid fa-book w3-text-orange"></i> {% trans "Balayage des puits" %}
</a>
<a href="{% url 'scanner:documentation' 'doc_results' %}" class="w3-bar-item w3-btn w3-hover-opacity">
<i class="fa-solid fa-book w3-text-amber"></i> {% trans "Exploiter les résultats des expériences" %}
</a>
<a href="{% url 'scanner:documentation' 'doc_medias' %}" class="w3-bar-item w3-btn w3-hover-opacity">
<i class="fa-solid fa-photo-film w3-text-purple"></i> {% trans "Gestionnaires des médias" %}
</a>
<hr class="w3-bar-item divider w3-grey">
<form method="post" action="{% url 'logout' %}">
{% csrf_token %}
<button class="w3-bar-item w3-btn w3-hover-opacity" type="submit">
<i class="fa-solid fa-right-from-bracket w3-text-red"></i> {% trans 'Déconnexion' %}
</button>
</form>
</div>
</div>
</div> </div>
{% endblock %} {% endblock %}
{% block sidebar %} {% block sidebar %}
<div class="w3-dark-xlight w3-border-bottom"> <div class="w3-dark-xlight w3-border-bottom">
<div class="w3-large w3-bold"> <div class="w3-large w3-bold">
<a href="/" class="w3-btn w3-hover-opacity w100 w3-left-align">{{ app_title }}<br> <a href="/" class="w3-btn w3-hover-opacity w100 w3-left-align">{{ app_title }}<br>
<span class="w3-tiny">{{ app_sub_title }}</span> <span class="w3-tiny">{{ app_sub_title }}</span>
</a> </a>
</div> </div>
<form method="post" action="{% url 'logout' %}" class="w3-padding-small">
{% csrf_token %}
<button class="w3-bar-item w3-btn w3-hover-opacity" type="submit" title="{% trans 'Déconnexion' %}">
{{ request.user.username }}<i class="fa-solid fa-right-from-bracket w3-right"></i>
</button>
</form>
</div> </div>
{% block system %} {% block system %}
<div class="w3-light-blue w3-padding-large"> <div class="w3-light-blue w3-padding-large">
@@ -63,59 +111,50 @@
{% block sidebar_list %} {% block sidebar_list %}
<div class="w3-dark-light w3-padding-small"> <div class="w3-dark-light w3-padding-small">
{% block sidebar_command %}{% endblock %} {% block sidebar_command %}{% endblock %}
{% if request.user.is_superuser %}
<div>
<div class="w3-dark-light w3-margin-top-6">
<a href="#" class="w3-btn w3-padding-small" onclick="toggleDisplay(sId('_admin'))"><span class="w3-text-orange w3-xlarge">&#x26F6;</span>
{% trans "Administration" %}
</a>
</div>
<div id="_admin" style="display:none">
<a href="{% url 'scanner:admin' %}" class="w3-bar-item w3-btn w3-hover-opacity w3-margin-left">
<i class="fa-solid fa-gear w3-text-pink"></i> {% trans "Administration base de données" %}
</a>
<a href="{% url 'scanner:reductstore' %}" class="w3-bar-item w3-btn w3-hover-opacity w3-margin-left" target="_blank">
<img class="w3-no-padding" src="/static/img/reductstore.png" style="width: 24px"> {% trans "Base de données Reductstore" %}
</a>
<a href="{% url 'scanner:adminer' %}" class="w3-bar-item w3-btn w3-hover-opacity w3-margin-left" target="_blank">
<img class="w3-no-padding" src="/static/img/adminer.png" style="width: 28px"> {% trans "Base de données mariadb" %}
</a>
<!--a href="{% url 'scanner:portainer' %}" class="w3-bar-item w3-btn w3-hover-opacity" w3-margin-left target="_blank">
<img class="w3-no-padding" src="/static/img/portainer.png" style="width: 28px"> {% trans "Portainer" %}
</a-->
<a href="{% url 'scanner:supervisor' %}" class="w3-bar-item w3-btn w3-hover-opacity w3-margin-left" target="_blank">
<i class="fa-solid fa-gears w3-text-purple"></i> {% trans "Supervisor" %}
</a>
<a href="{% url 'scanner:logs_scheduler' %}" class="w3-bar-item w3-btn w3-hover-opacity w3-margin-left" target="_blank">
<i class="fa-regular fa-file-lines w3-text-orange"></i> {% trans "Logs des planifications" %}
</a>
<a href="{% url 'scanner:logs_worker' %}" class="w3-bar-item w3-btn w3-hover-opacity w3-margin-left" target="_blank">
<i class="fa-regular fa-file-lines w3-text-amber"></i> {% trans "Logs des services" %}
</a>
</div>
</div>
{% endif %}
{% block sidebar_smenu %} {% block sidebar_smenu %}
{% if request.user.is_superuser and request.user.is_staff %} {% if request.user.is_superuser or request.user.is_staff %}
<a href="{% url 'scanner:calibration' %}" class="w3-bar-item w3-btn w3-hover-opacity w3-margin-bottom"> <a href="{% url 'scanner:session_view' %}" class="w3-bar-item w3-btn w3-hover-opacity">
<i class="fa-solid fa-wrench w3-text-red w3-xlarge"></i> {% trans "Calibration" %} <i class="fa-solid fa-gear w3-text-purple w3-xlarge"></i> {% trans "Création de sessions d'expériences" %}
</a> </a>
<hr class="w3-bar-item divider w3-grey">
<a href="{% url 'scanner:experiment_view' %}" class="w3-bar-item w3-btn w3-hover-opacity">
<i class="fa-solid fa-gear w3-text-pink w3-xlarge"></i> {% trans "Gestions des expériences" %}
</a>
<a href="{% url 'scanner:experimentconfig_view' %}" class="w3-bar-item w3-btn w3-hover-opacity">
<i class="fa-solid fa-gears w3-text-pink w3-xlarge"></i> {% trans "Configuration des expériences" %}
</a>
{% if conf.tracking %}
<a href="{% url 'planarian:import-params' %}" class="w3-bar-item w3-btn w3-hover-opacity">
<i class="fa-solid fa-file-import w3-text-lime w3-xlarge"></i> {% trans "Importer des configurations depuis CSV" %}
</a>
{% endif %}
<a href="{% url 'scanner:calibration' %}" class="w3-bar-item w3-btn w3-hover-opacity">
<i class="fa-solid fa-wrench w3-text-orange w3-xlarge"></i> {% trans "Calibration" %}
</a>
<hr class="w3-bar-item divider w3-grey w3-margin-bottom">
{% endif %} {% endif %}
<a href="{% url 'scanner:main' %}" class="w3-bar-item w3-btn w3-hover-opacity"> <a href="{% url 'scanner:scanning' %}" class="w3-bar-item w3-btn w3-hover-opacity">
<i class="fa-solid fa-film w3-text-green w3-xlarge""></i> {% trans "Balayage multi-puits" %} <i class="fa-solid fa-film w3-text-green w3-xlarge""></i> {% trans "Balayage multi-puits" %}
</a> </a>
<div class="w3-bar-item w3-blue-grey"><i class="fa-solid fa-square-poll-vertical w3-text-amber"></i> {% trans "Résultats " %}</div>
{% if conf.tracking %}
<a href="{% url 'planarian:export-csv' %}" class="w3-bar-item w3-btn w3-hover-opacity">
<i class="fa-solid fa-file-export w3-text-lime w3-xlarge"></i> {% trans "Export CSV des expériences" %}
</a>
{% endif %}
<a href="{% url 'scanner:images' %}" class="w3-bar-item w3-btn w3-hover-opacity"> <a href="{% url 'scanner:images' %}" class="w3-bar-item w3-btn w3-hover-opacity">
<i class="fa-regular fa-images w3-text-cyan w3-xlarge" w3-xlarge""></i> {% trans "Gestionnaire d'images" %} <i class="fa-regular fa-images w3-text-cyan w3-xlarge" w3-xlarge""></i> {% trans "Gestionnaire d'images" %}
</a> </a>
<a href="{% url 'scanner:replay' %}" class="w3-bar-item w3-btn w3-hover-opacity"> <a href="{% url 'scanner:replay' %}" class="w3-bar-item w3-btn w3-hover-opacity">
<i class="fa-solid fa-podcast w3-text-ambe w3-xlarge"r"></i> {% trans "Redifusion vidéos" %} <i class="fa-solid fa-podcast w3-text-amber w3-xlarge"r"></i> {% trans "Redifusion vidéos" %}
</a>
<a href="{% url 'scanner:export_medias' %}" class="w3-bar-item w3-btn w3-hover-opacity">
<i class="fa-solid fa-file-export w3-text-pink w3-xlarge"></i> {% trans "Exporter les fichiers multimédias" %}
</a> </a>
{% endblock %}
{% endblock %}
</div> </div>
{% endblock %} {% endblock %}
{% endblock %} {% endblock %}
{% block content %}{% endblock %} {% block content %}{% endblock %}
@@ -86,6 +86,7 @@
</div> </div>
<div class="w3-row w3-row-padding"> <div class="w3-row w3-row-padding">
<div id="_well_btn" class="w3-col w3-margin-bottom"></div> <div id="_well_btn" class="w3-col w3-margin-bottom"></div>
<div class="w3-third"> <div class="w3-third">
<button id="_previous" class="w3-button w3-dark-xlight w3-round-large w3-padding-small"> <button id="_previous" class="w3-button w3-dark-xlight w3-round-large w3-padding-small">
<span class="w3-small">{% trans 'Précéd.' %}</span><br><i class="fa-solid fa-circle-left w3-xlarge"></i> <span class="w3-small">{% trans 'Précéd.' %}</span><br><i class="fa-solid fa-circle-left w3-xlarge"></i>
@@ -133,22 +134,50 @@
</div> </div>
<div class="w3-row w3-row-padding"> <div class="w3-row w3-row-padding">
<div class="w3-half"> <div class="w3-half">
<button id="_calib_debug" class="w3-button w3-dark-xlight w3-round-large w3-padding-1" title="{% trans 'Ce mode permet les réglages graphiques' %}"> <button id="_calib_debug" class="w3-button w3-dark-xlight w3-round-large w3-padding-1 w3-block" title="{% trans 'Ce mode permet les réglages graphiques' %}">
{% trans 'Debug' %}<br><i class="fa-solid fa-triangle-exclamation w3-text-amber w3-xlarge"></i> <span class="debug">{% trans 'Debug' %}</span><br><i class="fa-solid fa-triangle-exclamation w3-text-amber w3-xlarge"></i>
</button> </button>
</div> </div>
<div class="w3-half"> <div class="w3-half">
<button id="_median" class="w3-button w3-dark-xlight w3-round-large w3-padding-1"> <button id="_median" class="w3-button w3-dark-xlight w3-round-large w3-padding-1 w3-block">
{% trans 'Axes' %}<br><i class="fa-solid fa-crosshairs w3-text-amber w3-xlarge"></i> <span class="median">{% trans 'Axes' %}</span><br><i class="fa-solid fa-crosshairs w3-text-amber w3-xlarge"></i>
</button> </button>
</div> </div>
<div class="w3-col"> <div class="w3-col">
<button id="_crop" class="w3-button w3-dark-xlight w3-round-large w3-padding-small w3-margin-top w3-block" title="{% trans 'Ce mode actionne le pointage graphique' %}"> <button id="_crop" class="w3-button w3-dark-xlight w3-round-large w3-padding-small w3-margin-top w3-block" title="{% trans 'Ce mode actionne le pointage graphique' %}">
{% trans 'Recadrer' %}<br><i class="fa-solid fa-crop w3-text-amber w3-xlarge"></i> <span class="crop">{% trans 'Recadrer pour calibrer / suivre' %}</span><br><i class="fa-solid fa-crop w3-text-amber w3-xlarge"></i>
</button> </button>
</div> </div>
</div> </div>
<div class="w3-row w3-row-padding "> {% if conf.tracking and conf.tracking_setting %}
<div class="w3-row w3-row-padding w3-light-grey w3-padding-small w3-margin-1 w3-round w3-round-large w3-margin-top w3-left-align w3-paddin-top">
<div class="w3-half">
<input id="_min_area_px" type="number" min="5" max="100" step="1" class="w3-round" value="{{ conf.min_area_px }}"
title="{% trans 'surface minimale de contour pour être considéré valide (px carré)' %}"><br>{% trans 'Min contour' %}
</div>
<div class="w3-half">
<input id="_max_area_ratio" type="number" min="0.05" max="1.0" step="0.05" class="w3-round" value="{{ conf.max_area_ratio|stringformat:"s" }}"
title="{% trans 'surface maximale de contour en fraction de la frame (défaut 10%)' %}"><br>{% trans 'Max contour' %}
</div>
<div class="w3-half">
<input id="_merge_kernel_size" type="number" min="5" max="320" step="1" class="w3-round" value="{{ conf.merge_kernel_size }}"
title="{% trans 'Taille du kernel elliptique de fusion des fragments (px)' %}"><br>{% trans 'Taille kernel' %}
</div>
<div class="w3-half">
<input id="_min_contour_dist_px" type="number" min="10" max="320" step="1" class="w3-round" value="{{ conf.min_contour_dist_px }}"
title="{% trans 'Distance min entre deux contours pour les considérer comme individus distincts. Défaut: 40px' %}"><br>{% trans 'Distance min' %}
</div>
<div class="w3-half">
<input id="_max_planarians" type="number" min="1" max="10" step="1" class="w3-round" value="{{ conf.max_planarians }}"
title="{% trans 'Nombre maximum de planaires à suivre simultanément (1-10)' %}"><br>{% trans 'Planaires' %}
</div>
<div class="w3-half">
<input id="_draw" type="checkbox" value="0" onclick="if (this.checked){this.value='1';}else{ this.value='0';}" title="{% trans 'Dessiner le contour' %}">
<span class="w3-nbsp">{% trans 'Contour' %}</span>
</div>
</div>
{% endif %}
<div class="w3-row w3-row-padding">
<div class="w3-half w3-margin-top"> <div class="w3-half w3-margin-top">
<span>{% trans 'Rayon' %} px</span><br> <span>{% trans 'Rayon' %} px</span><br>
<input id="_crop_radius" type="number" min="100" max="1200" step="1" class="w3-round w3-input" title="{% trans 'Rayon du recadrement' %}" <input id="_crop_radius" type="number" min="100" max="1200" step="1" class="w3-round w3-input" title="{% trans 'Rayon du recadrement' %}"
@@ -227,7 +256,13 @@
well_btn : sId("_well_btn"), well_btn : sId("_well_btn"),
median : sId("_median"), median : sId("_median"),
crop : sId("_crop"), crop : sId("_crop"),
crop_radius : sId("_crop_radius") crop_radius : sId("_crop_radius"),
min_area_px : sId("_min_area_px"),
max_area_ratio : sId("_max_area_ratio"),
max_planarians : sId("_max_planarians"),
merge_kernel_size : sId("_merge_kernel_size"),
min_contour_dist_px : sId("_min_contour_dist_px"),
draw : sId("_draw")
}; };
</script> </script>
<script src="/static/scanner/js/calibration.js"></script> <script src="/static/scanner/js/calibration.js"></script>
@@ -0,0 +1,6 @@
{% for ss in experiments %}
<div>
<input class="" type="radio" name="_expid" value="{{ ss.id }}" {% if ss.id == current_experiment.id %}checked{% endif %} onchange="this.form.submit()" >
<label>{{ ss }}</label>
</div>
{% endfor %}
@@ -0,0 +1,68 @@
{% extends 'scanner/base.html' %}
{% load i18n %}
{% 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>
</form>
{% endblock %}
{% block content %}
<div class="w3-container w50 w3-div-center w3-border w3-round w3-round-xlarge w3-margin-top">
<div class="w3-panel w3-pale-blue w3-round w3-round-xlarge w3-margin-bottom">
<p>
{% trans "L'explortation des fichiers multimédia peut durer plusieurs heures." %}
{% trans "Préférer l'exportation différée." %}
</p>
</div>
{% if current_session %}
<form method="post" novalidate class="w3-row w3-row-padding">
{% csrf_token %}
<input type="hidden" name="_sid" value="{{ current_session.id }}">
<div class="w3-col w3-margin-bottom">
<label class="w3-text-blue-grey">{% trans "Auteur" %}</label> {{ current_session.author }}
</div>
<div class="w3-col w3-margin-bottom">
<label class="w3-text-blue-grey">{% trans "Session" %}</label> {{ current_session }}
</div>
<div class="w3-col w3-margin-bottom">
<label class="w3-text-blue-grey">{% trans "Date de création" %}</label> {{ current_session.created }}
</div>
<div class="w3-col w3-margin-bottom">
<label class="w3-text-blue-grey">{% trans "Status" %}</label> {{ current_session.export_status }}
</div>
<div class="w3-col w3-margin-top">
<button type="submit" name="valid" class="w3-button w3-blue w3-round w3-round-large w3-large w3-padding w3-margin-bottom" value="ok">
<i class="fa-solid fa-file-export w3-text-red w3-xlarge"></i> {% trans "Exporter" %}
</button>
<a href="/" class="w3-button w3-light-grey w3-round w3-round-large w3-large w3-padding w3-margin-left w3-margin-bottom">
<span class="w3-xlarge"></span>{% trans "Annuler" %}
</a>
</div>
</form>
{% 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" %}</h3>
</div>
{% endif %}
</div>
{% if current_session and current_session.export_status == 'running' %}
<div class="w3-padding w3-margin-top">
<iframe width="100%" src="{{ url }}" style="border:none; height:30vh;display:block;"></iframe>
</div>
{% endif %}
{% endblock %}
@@ -9,24 +9,29 @@
<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> <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=""> <form method="post" class="w3-bar-item" action="">
{% csrf_token %} {% csrf_token %}
<select id="_sid" name="_sid" class="w3-select w3-margin-bottom" onchange="this.form.submit()"> <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 d'expérience" %}</option> <option value="0">---- {% trans "Session" %}</option>
{% for s in sessions %} {% for s in sessions %}
<option value="{{ s.id }}" {% if s.id == cursid %}selected{% endif %}>{{ s.name }}</option> <option value="{{ s.id }}" {% if s.id == current_session.id %}selected{% endif %}>{{ s }}</option>
{% endfor %} {% endfor %}
</select> </select>
{% if cursid %} <div class="w3-margin-left w3-margin-bottom">
{% multiwell_cards cursid experiments %} {% include "scanner/experiment-inc.html" %}
</div>
{% if current_session.id and current_experiment %}
{% multiwell_cards current_session.id current_experiment %}
{% endif %} {% endif %}
</form> </form>
{% if cursid %} {% if current_session.id %}
<a href="#" class="w3-bar-item w3-btn w3-hover-opacity" onclick="download_all_images()"> <a href="#" class="w3-bar-item w3-btn w3-hover-opacity" onclick="download_all_images({{ current_session.id }})">
<i class="fa-solid fa-file-export w3-text-red w3-xlarge"></i> {% trans "Exporter l'ensemble des images" %} <i class="fa-solid fa-file-export w3-text-red w3-xlarge"></i> {% trans "Exporter l'ensemble des images de la session" %}<br>
<span class="w3-margin-left">{{ export_destination }}
</a> </a>
{% endif %} {% endif %}
{% endblock %} {% endblock %}
{% block content %} {% block content %}
{% if current_session and current_experiment %}
<div id="image-grid"> <div id="image-grid">
{% if images %} {% if images %}
{% for img in images %} {% for img in images %}
@@ -46,6 +51,11 @@
{% endfor %} {% endfor %}
{% endif %} {% endif %}
</div> </div>
{% 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 %}
{% block modal %} {% block modal %}
{% include "scanner/image-preview.html" %} {% include "scanner/image-preview.html" %}
@@ -58,7 +68,8 @@
const duration = parseInt("{{ duration }}"); const duration = parseInt("{{ duration }}");
const uuid_btn = sId("btn-" + "{{ uuid }}"); const uuid_btn = sId("btn-" + "{{ uuid }}");
const image_endpoint = "{% url 'scanner:export_api' %}"; const image_endpoint = "{% url 'scanner:export_api' %}";
const sid = parseInt("{{ cursid }}"); const export_msg = "{% trans "Confirmer l'export des images." %}";
const export_msg_long = "{% trans "ATTENTION le processus peut être long!" %}";
function setGridColumns(col) { container.style.setProperty("--grid-columns", col); updateActiveButton(col); } function setGridColumns(col) { container.style.setProperty("--grid-columns", col); updateActiveButton(col); }
function handleMultiwell(b) { b.classList.add('w3-green'); } function handleMultiwell(b) { b.classList.add('w3-green'); }
@@ -71,7 +82,7 @@
} }
function image_save(name, img) { function image_save(name, img) {
const ok = confirm(`Télécharger le fichier ?\n\n${name}`); const ok = confirm(export_msg);
if (!ok) return false; if (!ok) return false;
fetch(img) fetch(img)
.then(res => res.blob()) .then(res => res.blob())
@@ -87,7 +98,9 @@
}); });
} }
function download_all_images() { function download_all_images(sid) {
const ok = confirm(export_msg +' '+ export_msg_long);
if (!ok) return false;
fetch(image_endpoint, { fetch(image_endpoint, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@@ -95,14 +108,16 @@
action: 'export_images', action: 'export_images',
sid: sid, sid: sid,
}) })
}).then(res => { })
console.log(res); .then(response => response.json())
.then(res => {
}).catch(error => { alert(res.msg);
})
.catch(error => {
console.error('Erreur:', error); console.error('Erreur:', error);
}); });
}
}
setGridColumns(columns); setGridColumns(columns);
if (uuid_btn) if (uuid_btn)
handleMultiwell(uuid_btn); handleMultiwell(uuid_btn);
@@ -11,23 +11,29 @@
<form method="post" class="w3-bar-item" action=""> <form method="post" class="w3-bar-item" action="">
{% csrf_token %} {% csrf_token %}
<select id="_sid" name="_sid" class="w3-select w3-margin-bottom" onchange="this.form.submit()"> <select id="_sid" name="_sid" class="w3-select w3-margin-bottom" onchange="this.form.submit()">
<option value="0">---- {% trans "Session d'expériences" %}</option> <option value="0">---- {% trans "Session" %}</option>
{% for s in sessions %} {% for s in sessions %}
<option value="{{ s.id }}" {% if s.id == cursid %}selected{% endif %}>{{ s.name }}</option> <option value="{{ s.id }}" {% if s.id == current_session.id %}selected{% endif %}>{{ s }}</option>
{% endfor %} {% endfor %}
</select> </select>
{% if cursid %} <div class="w3-margin-left w3-margin-bottom">
{% multiwell_cards cursid experiments %} {% include "scanner/experiment-inc.html" %}
</div>
{% if current_session.id and current_experiment %}
{% multiwell_cards current_session.id current_experiment %}
{% endif %} {% endif %}
</form> </form>
{% if cursid %} {% if current_session.id %}
<a href="#" class="w3-bar-item w3-btn w3-hover-opacity" onclick="download_all_videos()"> <a href="#" class="w3-bar-item w3-btn w3-hover-opacity" onclick="download_all_videos({{ current_session.id }})">
<i class="fa-solid fa-file-export w3-text-red w3-xlarge"></i> {% trans "Exporter l'ensemble des vidéos" %} <i class="fa-solid fa-file-export w3-text-red w3-xlarge"></i> {% trans "Exporter l'ensemble des vidéos" %}<br>
<span class="w3-margin-left">{{ export_destination }}</span>
</a> </a>
{% endif %} {% endif %}
{% endblock %} {% endblock %}
{% block content %} {% block content %}
{% if current_session and current_experiment %}
<div id="replay-grid" class="w3-black"> <div id="replay-grid" class="w3-black">
{% if image %} {% if image %}
<div class="w3-card-4 w3-border"> <div class="w3-card-4 w3-border">
@@ -61,6 +67,11 @@
</div> </div>
{% endif %} {% endif %}
</div> </div>
{% 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 %}
{% block js_footer %} {% block js_footer %}
@@ -70,9 +81,12 @@
const ws_route = "{{ ws_route }}"; const ws_route = "{{ ws_route }}";
const uuid_btn = sId("btn-" + "{{ uuid }}"); const uuid_btn = sId("btn-" + "{{ uuid }}");
const image_endpoint = "{% url 'scanner:export_api' %}"; const image_endpoint = "{% url 'scanner:export_api' %}";
const sid = parseInt("{{ cursid }}"); const export_msg = "{% trans "Confirmer l'export des vidéos. ATTENTION le processus peut être long!" %}";
function download_all_videos() {
function download_all_videos(sid) {
const ok = confirm(export_msg);
if (!ok) return false;
fetch(image_endpoint, { fetch(image_endpoint, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@@ -80,9 +94,12 @@
action: 'export_videos', action: 'export_videos',
sid: sid, sid: sid,
}) })
}).then(res => { })
console.log(res); .then(response => response.json())
}).catch(error => { .then(res => {
alert(res.msg);
})
.catch(error => {
console.error('Erreur:', error); console.error('Erreur:', error);
}); });
} }
@@ -1,7 +1,7 @@
<div class="w3-row"> <div class="w3-row">
{% if conf.tracking %} {#% if conf.tracking %#}
<div class="w3-col w3-small" style="width:180px"> <!--div class="w3-col w3-small" style="width:180px">
<div>Num: <span id="_count"></span></div> <div>Num: <span id="_count"></span></div>
<div>Aire: <span id="_area_px"></span></div> <div>Aire: <span id="_area_px"></span></div>
@@ -10,8 +10,8 @@
<div>V: <span id="_speed_px_s"></span> px/s</div> <div>V: <span id="_speed_px_s"></span> px/s</div>
<div>V.Ax: <span id="_axial_speed"></span> px/s</div> <div>V.Ax: <span id="_axial_speed"></span> px/s</div>
<div>Ax pos: <span id="_axial_pos"></span></div> <div>Ax pos: <span id="_axial_pos"></span></div>
</div> </div-->
{% endif %} {#% endif %#}
<div class="w3-rest"> <div class="w3-rest">
<img id="scan-img" class="w3-image"> <img id="scan-img" class="w3-image">
</div> </div>
@@ -3,7 +3,7 @@
{% block styles %} {% block styles %}
{{ block.super }} {{ block.super }}
<link href="/static/scanner/css/main.css" rel="stylesheet"> <link href="/static/scanner/css/scanning.css" rel="stylesheet">
{% endblock %} {% endblock %}
{% block columns %}{% endblock %} {% block columns %}{% endblock %}
@@ -14,7 +14,7 @@
<div>{% trans "Choix de la session" %}</div> <div>{% trans "Choix de la session" %}</div>
<select id="_session" class="w3-select"> <select id="_session" class="w3-select">
{% for s in sessions %} {% for s in sessions %}
<option value="{{ s.id }}">{{ s.name }}</option> <option value="{{ s.id }}">{{ s }}</option>
{% endfor %} {% endfor %}
</select> </select>
</div> </div>
@@ -25,33 +25,47 @@
<div class="w3-col w3-padding"><span id="_uuid"></span></div> <div class="w3-col w3-padding"><span id="_uuid"></span></div>
</div> </div>
</div> </div>
<div class="w3-row w3-row-padding">
<div class="w3-col"> <div class="w3-half">
<button id="_median" class="w3-button w3-dark-xlight w3-round-large w3-padding-small w3-block"> <button id="_median" class="w3-button w3-dark-xlight w3-round-large w3-block">
{% trans 'Axes' %}&nbsp;<br><i class="fa-solid fa-crosshairs w3-text-amber w3-xlarge"></i> <span class="median">{% trans 'Axes' %}</span><br><i class="fa-solid fa-crosshairs w3-text-amber w3-xlarge"></i>
</button> </button>
</div>
<div class="w3-half">
<button id="_crop" class="w3-button w3-dark-xlight w3-round-large w3-block">
<span class="crop" style="color: red">{% trans 'Recadrer' %}</span><br><i class="fa-solid fa-crop w3-text-amber w3-xlarge"></i>
</button>
</div>
</div>
<div class="w3-row w3-row-padding">
<div class="{% if conf.scan_simulation %}w3-twothird{% else %}w3-col{% endif %} w3-center">
<button id="_scan" class="w3-button w3-light-blue w3-round-large w3-margin-top w3-block">
<span class="w3-bold">{% trans 'Lancer' %}<br>{% trans 'le balayage' %}</span><br><i class="fa-solid fa-circle-check w3-text-blue w3-xlarge"></i>
</button>
</div>
{% if conf.scan_simulation %}
<div class="w3-third w3-center">
<button id="_simulate" class="w3-button w3-dark-xlight w3-round-large w3-margin-top w3-block">
<span class="w3-bold">{% trans 'Simuler' %}<br>{% trans 'le balayage' %}</span><br><i class="fa-solid fa-triangle-exclamation w3-text-orange w3-xlarge"></i>
</button>
</div>
{% endif %}
<div class="w3-col">
<div id="_well_state" class="w3-margin-top w3-small w3-text-amber w3-justify"></div>
<div id="_well_btn"></div>
</div>
<div class="w3-col">
<button id="_halt" class="w3-button w3-red w3-round-large w3-margin-top w3-block">
{% trans 'ARRET' %}<br><i class="fa-solid fa-hand w3-xlarge"></i>
</button>
</div>
</div> </div>
<div class="w3-col"> <div class="w3-col">
<button id="_crop" class="w3-button w3-dark-xlight w3-round-large w3-padding-top w3-margin-top w3-block"> <div class="w3-margin-top w3-padding-small w3-small">
{% trans 'Recadrer' %}<br><i class="fa-solid fa-crop w3-text-amber w3-xlarge"></i>
</button>
</div>
<div class="w3-col">
<button id="_scan" class="w3-button w3-dark-xlight w3-round-large w3-margin-top w3-block">
{% trans 'Lancer le balayage' %}<br><i class="fa-solid fa-circle-check w3-text-orange w3-xlarge"></i>
</button>
</div>
<div class="w3-col">
<button id="_halt" class="w3-button w3-red w3-round-large w3-margin-top w3-block">
{% trans 'ARRET' %}<br><i class="fa-solid fa-hand w3-xlarge"></i>
</button>
</div>
<div class="w3-col">
<div class="w3-margin-top w3-padding w3-small">
<span id="_ts"></span> <span id="_ts"></span>
</div> </div>
<div class="w3-margin-top w3-padding w3-small"> <div class="w3-margin-top w3-small">
<span id="_scan_state" class="w3-text-amber w3-border"></span> <span id="_scan_state" class="w3-text-amber w3-padding w3-justify"></span>
</div> </div>
</div> </div>
</div> </div>
@@ -85,10 +99,14 @@
axial_pos : sId("_axial_pos"), axial_pos : sId("_axial_pos"),
area_px : sId("_area_px"), area_px : sId("_area_px"),
frame_count : sId("_count"), frame_count : sId("_count"),
scan_state: sId("_scan_state") scan_state : sId("_scan_state"),
simulate : sId("_simulate"),
well_btn : sId("_well_btn"),
well_state : sId("_well_state")
}; };
</script> </script>
<script src="/static/scanner/js/main.js"></script> <script src="/static/scanner/js/scanning.js"></script>
<script> <script>
const manager = new ScannerManager(container, options); const manager = new ScannerManager(container, options);
const protocol = location.protocol === "https:" ? "wss" : "ws"; const protocol = location.protocol === "https:" ? "wss" : "ws";
@@ -5,22 +5,19 @@ from django.utils.html import mark_safe
register = template.Library() register = template.Library()
@register.simple_tag @register.simple_tag
def multiwell_cards(sid, experiments): def multiwell_cards(sid, experiment):
multiwells = [] multiwells = []
for obs in experiments: row_def = experiment.multiwell.row_def.split(',')
row_def = obs.multiwell.row_def.split(',') multiwells.append(
multiwells.append( f'''
f''' <div class="w3-padding-small w3-sand">{experiment.title}</div>
<div class="w3-center w3-sand">{obs.title}</div> <div class="w3-border multiwell_cards">
<div class="w3-border multiwell_cards"> ''')
''') for row in range(experiment.multiwell.rows):
for row in range(obs.multiwell.rows): for col in range(experiment.multiwell.cols):
for col in range(obs.multiwell.cols): btn = f'{row_def[row]}{col+1}'
btn = f'{row_def[row]}{col+1}' uuid = f'{sid}-{experiment.multiwell.position}-{btn}'
uuid = f'{sid}-{obs.multiwell.position}-{btn}' multiwells.append(f"""<button id="btn-{uuid}" name="_multiwell" class="multiwell w3-button" value="{uuid}" onclick="this.form.submit()">{btn}</button>""")
multiwells.append(f"""<button id="btn-{uuid}" name="_multiwell" class="multiwell w3-button" value="{uuid}" onclick="this.form.submit()">{btn}</button>""") multiwells.append('''</div>''')
multiwells.append('''</div>''')
return mark_safe("\n".join(multiwells)) return mark_safe("\n".join(multiwells))
+9 -1
View File
@@ -13,10 +13,18 @@ urlpatterns = [
path('supervisor/', views.supervisor_view, name='supervisor'), path('supervisor/', views.supervisor_view, name='supervisor'),
path('logs/worker', views.supervisor_worker, name='logs_worker'), path('logs/worker', views.supervisor_worker, name='logs_worker'),
path('logs/scheduler', views.supervisor_scheduler, name='logs_scheduler'), path('logs/scheduler', views.supervisor_scheduler, name='logs_scheduler'),
path("doc/<slug:template>/", views.documentation, name="documentation"),
path('main/', views.main_view, name='main'), path('session/view', views.admin_session_view, name='session_view'),
path('experiment/view', views.admin_experiment_view, name='experiment_view'),
path('experiment/config/view', views.admin_experimentconfig_view, name='experimentconfig_view'),
path('periodictask/view', views.admin_periodictask_view, name='periodictask_view'),
path('scanning/', views.scanning_view, name='scanning'),
path('images/', views.images_view, name='images'), path('images/', views.images_view, name='images'),
path('replay/', views.replay_view, name='replay'), path('replay/', views.replay_view, name='replay'),
path('export/medias/', views.export_medias, name='export_medias'),
path('api/stats/', views.stats_view, name='api_stats'), path('api/stats/', views.stats_view, name='api_stats'),
path('api/video/', views.download_api, name='download_api'), path('api/video/', views.download_api, name='download_api'),
path('api/export/', views.export_api, name='export_api'), path('api/export/', views.export_api, name='export_api'),
+147 -36
View File
@@ -1,13 +1,16 @@
# #
import os
from asgiref.sync import async_to_sync from asgiref.sync import async_to_sync
import base64, json import base64, json
from django.shortcuts import render from django.shortcuts import render #, redirect
from django.http import JsonResponse from django.http import JsonResponse, FileResponse
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.views.decorators.http import require_GET, require_POST from django.views.decorators.http import require_GET, require_POST
from django.views.decorators.csrf import csrf_exempt from django.views.decorators.csrf import csrf_exempt
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required, user_passes_test
from django.conf import settings from django.conf import settings
from django.utils import timezone
from reduct.time import unix_timestamp_to_iso from reduct.time import unix_timestamp_to_iso
from modules.system_stats import get_cached_stats, start_background_updater from modules.system_stats import get_cached_stats, start_background_updater
from modules import reductstore from modules import reductstore
@@ -21,6 +24,10 @@ record_manager = CameraRecordManager(cameraDB)
start_background_updater() start_background_updater()
def is_staff_or_admin(user):
return user.is_staff or user.is_superuser
@require_GET @require_GET
def stats_view(request): def stats_view(request):
""" """
@@ -32,58 +39,100 @@ def stats_view(request):
except Exception as e: except Exception as e:
return JsonResponse({"error": str(e)}, status=500) return JsonResponse({"error": str(e)}, status=500)
def global_context(request, **ctx): def global_context(request, **ctx):
default_multiwell = models.MultiWell.objects.filter(default=True).first() default_multiwell = models.MultiWell.objects.filter(default=True).first()
conf = ScannerConstants().get() conf = ScannerConstants().get()
print(conf, type(conf))
return dict( return dict(
app_title=settings.APP_TITLE, app_title=settings.APP_TITLE,
app_sub_title=settings.APP_SUB_TITLE, app_sub_title=settings.APP_SUB_TITLE,
domain_server=settings.DOMAIN_SERVER, domain_server=settings.DOMAIN_SERVER,
local_ip_server=settings.LOCAL_IP_SERVER,
host_port=settings.SERVER_HOST_PORT,
conf=conf, conf=conf,
default_position = default_multiwell.position or 'HD', default_position = default_multiwell.position or 'HD',
export_destination=settings.EXPORT_DESTINATIONS,
**ctx **ctx
) )
@login_required @login_required
@user_passes_test(is_staff_or_admin)
def admin_view(request): def admin_view(request):
return render(request, "scanner/iframe.html", context=global_context(request, link='/admin/')) return render(request, "scanner/iframe.html", context=global_context(request, link='/admin/'))
@login_required @login_required
def admin_session_view(request):
return render(request, "scanner/iframe.html", context=global_context(request, link='/admin/scanner/session/'))
@login_required
def admin_experiment_view(request):
return render(request, "scanner/iframe.html", context=global_context(request, link='/admin/scanner/experiment/'))
@login_required
def admin_experimentconfig_view(request):
return render(request, "scanner/iframe.html", context=global_context(request, link='/admin/planarian/experimentconfig/'))
@login_required
def admin_periodictask_view(request):
return render(request, "scanner/iframe.html", context=global_context(request, link='/admin/django_celery_beat/periodictask/'))
@login_required
@user_passes_test(is_staff_or_admin)
def reductstore_view(request): def reductstore_view(request):
return render(request, "scanner/redirection.html", context=global_context(request, link=f'http://{settings.DOMAIN_SERVER}:8383/')) return render(request, "scanner/redirection.html", context=global_context(request, link=f'http://{settings.LOCAL_IP_SERVER}:8383/'))
@login_required @login_required
@user_passes_test(is_staff_or_admin)
def adminer_view(request): def adminer_view(request):
return render(request, "scanner/redirection.html", context=global_context(request, link=f'http://{settings.DOMAIN_SERVER}/adminer/')) return render(request, "scanner/redirection.html", context=global_context(request, link=f'http://{settings.LOCAL_IP_SERVER}/adminer/'))
@login_required @login_required
@user_passes_test(is_staff_or_admin)
def portainer_view(request): def portainer_view(request):
return render(request, "scanner/redirection.html", context=global_context(request, link=f'http://{settings.DOMAIN_SERVER}:9000/')) return render(request, "scanner/redirection.html", context=global_context(request, link=f'http://{settings.LOCAL_IP_SERVER}:9000/'))
@login_required @login_required
@user_passes_test(is_staff_or_admin)
def supervisor_view(request): def supervisor_view(request):
return render(request, "scanner/redirection.html", context=global_context(request, link=f'http://{settings.DOMAIN_SERVER}:9001/')) return render(request, "scanner/redirection.html", context=global_context(request, link=f'http://{settings.LOCAL_IP_SERVER}:9001/'))
@login_required @login_required
@user_passes_test(is_staff_or_admin)
def supervisor_worker(request): def supervisor_worker(request):
return render(request, "scanner/redirection.html", context=global_context(request, link=f'http://{settings.DOMAIN_SERVER}:9001/logtail/test_tube:services')) return render(request, "scanner/redirection.html", context=global_context(request, link=f'http://{settings.LOCAL_IP_SERVER}:9001/logtail/test_tube:services'))
@login_required @login_required
@user_passes_test(is_staff_or_admin)
def supervisor_scheduler(request): def supervisor_scheduler(request):
return render(request, "scanner/redirection.html", context=global_context(request, link=f'http://{settings.DOMAIN_SERVER}:9001/logtail/test_tube:planification')) return render(request, "scanner/redirection.html", context=global_context(request, link=f'http://{settings.LOCAL_IP_SERVER}:9001/logtail/test_tube:planification'))
def documentation(request, template=None):
ctx = dict(
choice_title=_("Documentation")
)
return render(request, f"docs/{template}.html", context=global_context(request, **ctx))
## Mainboard ## Mainboard
@login_required @login_required
def main_view(request): def scanning_view(request):
ctx = dict( ctx = dict(
ws_route=settings.SCANNER_WEBSOCKET_ROUTE, ws_route=settings.SCANNER_WEBSOCKET_ROUTE,
columns=1, columns=1,
sessions=models.Session.objects.filter(active=True).all(), sessions=models.Session.objects.filter(active=True).all(),
choice_title=_("Balayage multi-puits") choice_title=_("Balayage multi-puits")
) )
return render(request, "scanner/main.html", context=global_context(request, **ctx)) return render(request, "scanner/scanning.html", context=global_context(request, **ctx))
## Calibration ## Calibration
@login_required @login_required
@@ -97,6 +146,17 @@ def calibration_view(request):
return render(request, "scanner/calibration.html", context=global_context(request, **ctx)) return render(request, "scanner/calibration.html", context=global_context(request, **ctx))
def get_not_active_experiments(session, expid=None):
if session:
experiments = models.SessionExperiment.experiment_by_session(session.id, active=False) or []
if experiments and not expid:
return experiments, experiments[0]
for e in experiments:
if expid == str(e.id):
return experiments, e
return [], None
## images ## images
def get_images(uuid): def get_images(uuid):
oldest, latest, n, images = 0, 0, 0, [] oldest, latest, n, images = 0, 0, 0, []
@@ -124,35 +184,82 @@ def get_images(uuid):
@login_required @login_required
def images_view(request): def images_view(request):
cursid, duration, uuid, images = 0, 0, "", [] cursid, expid, duration, uuid, images = 0, None, 0, "", []
if request.method == 'POST': if request.method == 'POST':
cursid = request.POST.get('_sid') cursid = request.POST.get('_sid')
expid = request.POST.get('_expid')
uuid = request.POST.get('_multiwell') uuid = request.POST.get('_multiwell')
duration, images = get_images(uuid) duration, images = get_images(uuid)
current_session = models.Session.get_session(cursid)
experiments, current_experiment = get_not_active_experiments(current_session, expid)
ctx = dict( ctx = dict(
choice_title=_("Gestionnaire d'images"), choice_title=_("Gestionnaire d'images"),
sessions=models.Session.objects.filter(active=False).all(), sessions=models.Session.objects.filter(active=False).all(),
experiments=models.SessionExperiment.experiment_by_session(cursid, active=False), experiments=experiments or [],
cursid=int(cursid), current_session=current_session,
current_experiment=current_experiment,
images=images, images=images,
uuid=uuid, uuid=uuid,
duration=duration, duration=duration,
) )
return render(request, "scanner/images.html", context=global_context(request, **ctx)) return render(request, "scanner/images.html", context=global_context(request, **ctx))
@login_required
def export_medias(request):
cursid = 0
if request.method == 'POST':
cursid = request.POST.get('_sid')
valid = request.POST.get('valid')
if valid == 'ok':
try:
session = models.Session.objects.get(pk=cursid)
session.expected_export = timezone.now()
if not session.export_status == "running":
session.export_status = "running"
if session.export_task:
session.export_task.enabled = True
session.export_task.save()
session.save()
except Exception as e:
print("export_medias error", e)
current_session = models.Session.get_session(cursid)
ctx = dict(
choice_title=_("Exporter les fichiers médias"),
sessions=models.Session.objects.filter(active=False).all(),
current_session=current_session,
url=f'http://{settings.LOCAL_IP_SERVER}:9001/logtail/test_tube:services',
)
return render(request, "scanner/export_medias.html", context=global_context(request, **ctx))
## replay
@require_POST @require_POST
@csrf_exempt @csrf_exempt
def download_api(request): def download_api(request):
data = json.loads(request.body.decode() or "{}") data = json.loads(request.body.decode() or "{}")
action = data.get("action") try:
if action=='download': action = data.get("action")
uuid, dt_start, dt_stop, frame_rate = data.get("uuid"), data.get("dt_start"), data.get("dt_stop"), data.get("fps") if action=='download':
return download_video.delay(uuid, dt_start, dt_stop, frame_rate=frame_rate) # @UndefinedVariable uuid, dt_start, dt_stop, frame_rate = data.get("uuid"), data.get("dt_start"), data.get("dt_stop"), data.get("fps")
else: task = download_video.delay(uuid, dt_start, dt_stop, frame_rate=frame_rate) # @UndefinedVariable
return JsonResponse({"state": False}) result = task.get(timeout=300)
if result.get('success'):
video_path = result.get('video_path')
if not os.path.exists(video_path):
raise Exception(f"File not created: {video_path}")
response = FileResponse(open(video_path, 'rb'), content_type='video/mp4')
response['Content-Disposition'] = f'attachment; filename="{video_path}"'
response['Content-Length'] = os.path.getsize(video_path)
os.remove(video_path)
return response
return JsonResponse(result)
except Exception as e:
print("download_api error", e)
return JsonResponse({"state": False, "success": False, "msg": str(_("Erreur d'exportation")), })
def get_video(uuid): def get_video(uuid):
@@ -164,20 +271,24 @@ def get_video(uuid):
@login_required @login_required
def replay_view(request): def replay_view(request):
cursid, oldest, latest, uuid, image = 0, 0, 0, "", "" cursid, expid, oldest, latest, uuid, image = 0, None, 0, 0, "", ""
if request.method == 'POST': if request.method == 'POST':
cursid = request.POST.get('_sid') cursid = request.POST.get('_sid')
expid = request.POST.get('_expid')
uuid = request.POST.get('_multiwell') uuid = request.POST.get('_multiwell')
if uuid: if uuid:
oldest, latest, image = get_video(uuid) oldest, latest, image = get_video(uuid)
current_session = models.Session.get_session(cursid)
experiments, current_experiment = get_not_active_experiments(current_session, expid)
ctx = dict( ctx = dict(
choice_title=_("Gestionnaire de vidéos"), choice_title=_("Gestionnaire de vidéos"),
ws_route=settings.REPLAY_WEBSOCKET_ROUTE, ws_route=settings.REPLAY_WEBSOCKET_ROUTE,
sessions=models.Session.objects.filter(active=False).all(), sessions=models.Session.objects.filter(active=False).all(),
experiments=models.SessionExperiment.experiment_by_session(cursid, active=False), experiments=experiments or [],
cursid=int(cursid), current_session=current_session,
current_experiment=current_experiment,
image=image, image=image,
uuid=uuid, uuid=uuid,
oldest=oldest, oldest=oldest,
@@ -185,20 +296,20 @@ def replay_view(request):
) )
return render(request, "scanner/replay.html", context=global_context(request, **ctx)) return render(request, "scanner/replay.html", context=global_context(request, **ctx))
@require_POST @require_POST
@csrf_exempt @csrf_exempt
def export_api(request): def export_api(request):
data = json.loads(request.body.decode() or "{}") data = json.loads(request.body.decode() or "{}")
session_id = data.get("sid") session_id = data.get("sid")
action = data.get("action") action = data.get("action")
try:
if action == 'export_images': if action == 'export_images':
job_zip = export_all_images(session_id) export_all_images(session_id)
return JsonResponse({"state": True, "tasks": job_zip}) return JsonResponse({"success": True, "msg": str(_("Images en cours de téléchargement"))})
elif action == 'export_videos': elif action == 'export_videos':
job_mp4 = export_all_videos(session_id) export_all_videos(session_id)
return JsonResponse({"state": True, "tasks": job_mp4}) return JsonResponse({"success": True, "msg": str(_("Vidéos en cours de téléchargement"))})
else: except:
return JsonResponse({"state": False}) return JsonResponse({"state": False, "success": False, "msg": str(_("Erreur d'exportation")), })
+65
View File
@@ -0,0 +1,65 @@
#!.trans/bin/python
'''
Created on 11 mai 2025
## pip install argostranslate polib
@author: denis
'''
import argparse
import os
import polib
import argostranslate.package
import argostranslate.translate
DJANGO_PROJECT = "test_tube_scanner"
def install_translation_model(from_lang="en", to_lang="fr"):
print("Cheching translation model...")
argostranslate.package.update_package_index()
available_packages = argostranslate.package.get_available_packages()
try:
package_to_install = next(
p for p in available_packages if p.from_code == from_lang and p.to_code == to_lang
)
print(f"Loading model {from_lang} >> {to_lang}...")
argostranslate.package.install_from_path(package_to_install.download())
print("Installation model ")
except StopIteration:
print("No models found for this language pair.")
exit(1)
def translate_po_file(input_path, output_path, from_lang="en", to_lang="fr"):
print(f"Loading file {input_path}...")
po = polib.pofile(input_path)
print("Start traduction...")
for entry in po:
if entry.msgstr.strip() == "" and entry.msgid.strip():
try:
translation = argostranslate.translate.translate(entry.msgid, from_lang, to_lang)
entry.msgstr = translation
except Exception as e:
print(f"Translation error: {entry.msgid}\n{e}")
continue
po.save(output_path)
print(f"End of translation. Output file in: {output_path}")
def main(app, from_lang, to_lang):
INPUT_FILE = f'{app}/locale/{from_lang}/LC_MESSAGES/django.po'
OUTPUT_FILE = f'{app}/locale/{to_lang}/LC_MESSAGES/django.po'
install_translation_model(from_lang, to_lang)
translate_po_file(INPUT_FILE, OUTPUT_FILE, from_lang, to_lang)
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Translation for Django")
parser.add_argument("--app", type=str, default='home', help="Django application name")
parser.add_argument("--from_lang", type=str, default="fr", help="From language")
parser.add_argument("--to_lang", type=str, default='en', help="To language", required=True)
args = parser.parse_args()
main(f'{DJANGO_PROJECT}/{args.app}', args.from_lang, args.to_lang)
exit(0)