Compare commits
65 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 88cdc69f7d | |||
| c0a57a8920 | |||
| 299bfad872 | |||
| c6145f250b | |||
| 90d271dc2b | |||
| 695e59174a | |||
| e4bd67b686 | |||
| 9bb8fc1bce | |||
| 4b42c03756 | |||
| 084c289a95 | |||
| 308ddaa048 | |||
| 5477de46fe | |||
| adf8d24d14 | |||
| cb10957fa6 | |||
| da44ab5340 | |||
| 9abede4b4a | |||
| 47ea0a6be2 | |||
| 6eac697bd2 | |||
| dc63da69d9 | |||
| 7760d7ae7c | |||
| 4fb7fa8fd3 | |||
| 3c730ecdc0 | |||
| b5b28dd5e1 | |||
| 0bab26c45a | |||
| e9256f538c | |||
| ed67438739 | |||
| e1e1174db7 | |||
| 01acef913b | |||
| 15c01c483f | |||
| 13141bc46a | |||
| 500950017f | |||
| c7caccd951 | |||
| 5ba8e04ddf | |||
| 563069d3c6 | |||
| 1575445df4 | |||
| 1bc7e5eb9e | |||
| 22ec82c895 | |||
| 8b98f39619 | |||
| ee919ab1cf | |||
| 0fc0d537be | |||
| c28ccdb33c | |||
| dcbcabfce5 | |||
| 3aaa88a1aa | |||
| d946de7b63 | |||
| 5c78c042a4 | |||
| c0c3c37963 | |||
| 3ecf0a1b6b | |||
| ebdeb5f651 | |||
| ce32bee3e8 | |||
| 0f22451516 | |||
| 309e2d4f45 | |||
| 5b4b1e63a6 | |||
| 3f746b6b3f | |||
| c16a874ebd | |||
| 4731a8bef8 | |||
| be1da62dbc | |||
| b9bcdae282 | |||
| f53b8d4932 | |||
| d922102ef5 | |||
| d7e634c96e | |||
| 58307333b9 | |||
| 1aca6bba75 | |||
| 4bde92bd6d | |||
| 94c7cf38f5 | |||
| 22e32cad26 |
+41
@@ -0,0 +1,41 @@
|
|||||||
|
# ── Secrets & credentials ──────────────────────────────────────────────────
|
||||||
|
test_tube_scanner/.env
|
||||||
|
*.pem
|
||||||
|
*.key
|
||||||
|
*.crt
|
||||||
|
*.p12
|
||||||
|
*.pfx
|
||||||
|
credentials*.json
|
||||||
|
secrets*.json
|
||||||
|
.secrets
|
||||||
|
|
||||||
|
# ── Données capturées / exports / backups ──────────────────────────────────
|
||||||
|
datas/
|
||||||
|
|
||||||
|
# ── Python / venv ──────────────────────────────────────────────────────────
|
||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.pyo
|
||||||
|
|
||||||
|
# ── Django runtime ─────────────────────────────────────────────────────────
|
||||||
|
*.sqlite3
|
||||||
|
*.db
|
||||||
|
test_tube_scanner/staticfiles/
|
||||||
|
test_tube_scanner/media/
|
||||||
|
test_tube_scanner/logs/
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# ── Celery ─────────────────────────────────────────────────────────────────
|
||||||
|
celerybeat-schedule
|
||||||
|
celerybeat.pid
|
||||||
|
*.pid
|
||||||
|
|
||||||
|
# ── Éditeurs & OS ──────────────────────────────────────────────────────────
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
+257
@@ -0,0 +1,257 @@
|
|||||||
|
# PlanarianScanner — Contexte technique
|
||||||
|
|
||||||
|
Système d'imagerie automatisé pour le suivi comportemental de planaires (*Platyhelminthes*).
|
||||||
|
Développé par dd@linuxtarn.org pour le **Laboratoire de Biologie, Université Champollion, Albi**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Matériel cible
|
||||||
|
|
||||||
|
| Composant | Détail |
|
||||||
|
|---|---|
|
||||||
|
| Carte | Raspberry Pi 4 |
|
||||||
|
| Caméra | ArduCam haute définition (Picamera2) |
|
||||||
|
| Motorisation | Bras CNC L2544 piloté en GRBL via port série |
|
||||||
|
| Grille | 4 plaques multi-puits de 6×4 = 96 puits (Ø 16 mm) |
|
||||||
|
| Réseau | LAN — export Samba (CIFS) / rsync SSH |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Stack technique
|
||||||
|
|
||||||
|
| Couche | Technologie |
|
||||||
|
|---|---|
|
||||||
|
| Backend | Django 5.1 + Django Channels (WebSocket) |
|
||||||
|
| Serveur ASGI | Daphne |
|
||||||
|
| Broker/cache | Redis |
|
||||||
|
| Tâches async | Celery + django-celery-beat (one-shot via ClockedSchedule) |
|
||||||
|
| Vision | OpenCV (headless) + Picamera2 |
|
||||||
|
| Stockage frames | ReductStore (base time série haute performance) |
|
||||||
|
| BDD | MariaDB (prod) — sqlite3 (dev) |
|
||||||
|
| Export distant | Samba client (CIFS) / rsync |
|
||||||
|
| Plateforme | Raspberry Pi 4, Debian 64-bit Trixie |
|
||||||
|
| Python | 3.13 — venv dans `.venv/` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Structure du dépôt
|
||||||
|
|
||||||
|
```
|
||||||
|
PlanarianScanner/
|
||||||
|
├── etc/ # Scripts d'installation et configs système
|
||||||
|
│ ├── 1-install-sys.sh # Dépendances système
|
||||||
|
│ ├── 2-cargo-reductstore-install.sh # Build ReductStore (~15 min sur RPi4)
|
||||||
|
│ ├── 3-install-samba-client.sh
|
||||||
|
│ ├── 4-install_mariadb.sh
|
||||||
|
│ ├── 5-install_adminer.sh
|
||||||
|
│ ├── 6-install_django_app.sh # Init Django (migrations, fixtures, collectstatic)
|
||||||
|
│ ├── db/ # Fixtures JSON initiales (configuration, multiwell, well)
|
||||||
|
│ ├── requirements.txt
|
||||||
|
│ ├── scanner_service.conf # Supervisor : Django + Celery workers
|
||||||
|
│ ├── reductstore_service.conf
|
||||||
|
│ └── nginx_service.conf
|
||||||
|
├── datas/ # Données hors Django (gitignored)
|
||||||
|
│ ├── medias/ # Images et vidéos capturées
|
||||||
|
│ ├── exports/csv/ # Exports CSV EthoVision
|
||||||
|
│ ├── remote/exports/ # Dossier cible des transferts distants
|
||||||
|
│ └── backup/mariadb/ # Sauvegardes MariaDB
|
||||||
|
├── assets/ # Logo, screenshots
|
||||||
|
├── test_tube_scanner/ # Racine du projet Django
|
||||||
|
│ ├── home/ # Package projet (settings, urls, wsgi, asgi, celery)
|
||||||
|
│ ├── scanner/ # App scanner (CNC, multi-puits, sessions, exports)
|
||||||
|
│ ├── planarian/ # App suivi planaires (métriques, export CSV)
|
||||||
|
│ ├── modules/ # Modules partagés (capture, GRBL, tracker, metrics…)
|
||||||
|
│ ├── manage.py
|
||||||
|
│ ├── run-server.sh
|
||||||
|
│ ├── run-workers.sh
|
||||||
|
│ ├── planarian_sim.py # Simulateur standalone (CLI)
|
||||||
|
│ └── .env / .env.example
|
||||||
|
└── browser.py # Ouverture navigateur local (utilitaire)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Applications Django
|
||||||
|
|
||||||
|
### `scanner` — Pilotage CNC et acquisition
|
||||||
|
|
||||||
|
Modèles principaux :
|
||||||
|
|
||||||
|
| Modèle | Rôle |
|
||||||
|
|---|---|
|
||||||
|
| `Configuration` | Config globale active (caméra, GRBL, tracking, calibration) |
|
||||||
|
| `MultiWell` | Plaque multi-puits (position HG/HD/BG/BD, grille 6×4, pas XY, crop_radius) |
|
||||||
|
| `Well` | Puit individuel (nom Ai..Di) |
|
||||||
|
| `WellPosition` | Position XY mm d'un puit dans un MultiWell + px_per_mm (calibration optique caméra) |
|
||||||
|
| `VideoPlate` | Vidéo plaque entière associée à un MultiWell — champs : `px_per_mm` (échelle vidéo ~15 px/mm), `x_origin_mm` / `y_origin_mm` (origine CNC stable, indépendante de la calibration) |
|
||||||
|
| `Experiment` | Session de capture sur un MultiWell (durée, début/fin) |
|
||||||
|
| `Session` | Groupe d'expériences avec planification (ClockedSchedule one-shot) |
|
||||||
|
| `SessionExperiment` | Liaison Session ↔ Experiment |
|
||||||
|
| `ExperimentWell` | Liaison Experiment ↔ Well (puits actifs) |
|
||||||
|
|
||||||
|
Signaux Django :
|
||||||
|
- `post_save(MultiWell)` → génère automatiquement les `WellPosition` en serpentin
|
||||||
|
- `post_save(Session)` → crée les `PeriodicTask` Celery Beat (export + scanning)
|
||||||
|
- `post_delete(Session)` → supprime les `PeriodicTask` associées
|
||||||
|
|
||||||
|
Tâches Celery (`scanner/tasks.py`, `scanner/export_tasks.py`) :
|
||||||
|
- `run_scanning(session_id)` — parcours serpentin des puits (GRBL + capture)
|
||||||
|
- `run_session_exports(session_id)` — génération ZIP JPEG + MP4 + transfert distant
|
||||||
|
|
||||||
|
### `planarian` — Suivi multi-individus et métriques
|
||||||
|
|
||||||
|
Modèle `ExperimentConfig` : paramètres de tracking par puit (px_per_mm, fps, seuils).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Modules partagés (`modules/`)
|
||||||
|
|
||||||
|
| Module | Rôle |
|
||||||
|
|---|---|
|
||||||
|
| `grbl.py` | Pilotage CNC via port série (G-code, homing, déplacement XY) |
|
||||||
|
| `grbl_simulator.py` | Simulateur GRBL pour dev sans matériel |
|
||||||
|
| `capture_interface.py` | Interface abstraite de capture — crop circulaire, edge enhance, debug overlay, tracking |
|
||||||
|
| `picamera2_capture.py` | Capture ArduCam via Picamera2 |
|
||||||
|
| `webcam_capture.py` | Capture webcam via OpenCV |
|
||||||
|
| `videofile_capture.py` | Lecture fichier vidéo (test/sim) |
|
||||||
|
| `videoplate_capture.py` | Capture par crop dynamique dans une vidéo plaque entière — position GRBL → région extraite, hot swap vidéo, plein cadre à l'origine |
|
||||||
|
| `planarian_tracker.py` | Tracking multi-individus : MOG2 + algorithme hongrois (`scipy`) |
|
||||||
|
| `planarian_metrics.py` | Métriques par frame et summary (mobilité, thigmo, photo, chemo, social) |
|
||||||
|
| `tube_aligner.py` | Détection HoughCircles + CLAHE, calibration optique, plage rayon configurable (`set_radius_range`) |
|
||||||
|
| `circular_crop.py` | Découpe circulaire des images de puit |
|
||||||
|
| `reductstore.py` | Interface ReductStore (stockage/lecture frames time série) |
|
||||||
|
| `system_stats.py` | Stats système (CPU, RAM, disque — affichage dashboard) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Métriques de tracking
|
||||||
|
|
||||||
|
**Par frame** : velocity, distance, moving, mobility_state, dist_to_wall_mm, near_wall,
|
||||||
|
dist_to_light_mm, heading_to_light_deg, fleeing_light, dist_to_food_mm, heading_to_food_deg,
|
||||||
|
approaching_food, in_food_zone, nearest_neighbour_mm, in_avoid_zone, in_aggreg_zone,
|
||||||
|
chem_repulsion_level.
|
||||||
|
|
||||||
|
**Summary** : totaux et pourcentages EthoVision-compatibles pour mobilité, thigmotactisme,
|
||||||
|
phototactisme, chimiotactisme, interactions sociales.
|
||||||
|
|
||||||
|
Export CSV compatible **EthoVision XT**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration runtime
|
||||||
|
|
||||||
|
Fichier `.env` (python-decouple) dans `test_tube_scanner/` :
|
||||||
|
|
||||||
|
```
|
||||||
|
SECRET_KEY, DEBUG, DOMAIN_SERVER, ALLOWED_HOSTS, CSRF_TRUSTED_ORIGINS
|
||||||
|
APP_DATAS # chemin relatif vers datas/ (ex: ../datas)
|
||||||
|
DJANGO_APP # nom de l'app (home)
|
||||||
|
REDIS_URL # ex: redis://localhost:6379/0
|
||||||
|
REDUCTSTORE_URL # ex: http://localhost:8383
|
||||||
|
DB_* # MariaDB credentials
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Démarrage des services
|
||||||
|
|
||||||
|
Tous gérés par **Supervisor** :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Interface web Supervisor
|
||||||
|
http://root:toor@<ip>:9001
|
||||||
|
|
||||||
|
# CLI
|
||||||
|
sudo supervisorctl start|stop|restart reductstore
|
||||||
|
sudo supervisorctl start|stop|restart test_tube:*
|
||||||
|
```
|
||||||
|
|
||||||
|
En dev :
|
||||||
|
```bash
|
||||||
|
cd test_tube_scanner
|
||||||
|
./manage.py runserver 0.0.0.0:8000
|
||||||
|
# Workers Celery séparés :
|
||||||
|
./run-workers.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Accès réseau : ajouter `<ip-rpi4> scanner.local` dans `/etc/hosts` des clients.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Simulateur standalone
|
||||||
|
|
||||||
|
`test_tube_scanner/planarian_sim.py` — simulation CLI d'une arène circulaire (Ø 16 mm, 500×500 px),
|
||||||
|
export CSV EthoVision par planaire.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 planarian_sim.py --count 5 --thigmotaxis 0.4
|
||||||
|
python3 planarian_sim.py --count 5 --photo-mode fixed --photo-x 0.2 --photo-y 0.2 --photo-strength 0.6
|
||||||
|
```
|
||||||
|
|
||||||
|
`test_tube_scanner/make_videos.sh` — génération de 24 vidéos de simulation (une par puit).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Mode capture vidéo plaque (`capture_type == 'video'`)
|
||||||
|
|
||||||
|
Alternative à la caméra ArduCam : une **vidéo de la plaque entière** enregistrée une fois,
|
||||||
|
rejouée en boucle pendant les scans. Adapté aux labos sans Raspberry Pi ou pour tests hors matériel.
|
||||||
|
|
||||||
|
### Flux
|
||||||
|
```
|
||||||
|
VideoPlateCapture.capture_frame()
|
||||||
|
→ lit la frame courante de la vidéo
|
||||||
|
→ extrait un carré 2r×2r centré sur (GRBL_x, GRBL_y) converti en pixels
|
||||||
|
→ si GRBL à (0,0) : retourne la frame entière (vue plaque)
|
||||||
|
→ sinon : retourne le crop du puit courant
|
||||||
|
→ process_frame()
|
||||||
|
→ edge_enhance (optionnel, CLAHE + Canny overlay vert)
|
||||||
|
→ TubeAligner.detect_tube() (optionnel, debug HoughCircles)
|
||||||
|
→ crop circulaire (optionnel)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Calibration vidéo
|
||||||
|
- `VideoPlate.px_per_mm` : échelle de la vidéo (~15 px/mm) — **différent** de `WellPosition.px_per_mm` (~50 px/mm, optique caméra)
|
||||||
|
- `VideoPlate.x_origin_mm` / `y_origin_mm` : position CNC correspondant au pixel (0,0) de la vidéo — **stable**, jamais modifiée par la calibration des puits
|
||||||
|
- `MultiWell.crop_radius` : rayon du crop en pixels — contrôle la taille de la vue par puit
|
||||||
|
|
||||||
|
### Contrôles calibration UI (boutons)
|
||||||
|
| Bouton | Action |
|
||||||
|
|---|---|
|
||||||
|
| Debug | Active `TubeAligner.debug` — détection HoughCircles en continu |
|
||||||
|
| Overlay | Affiche/masque les annotations de détection sans couper la détection |
|
||||||
|
| Contours | Active edge enhance (CLAHE + Canny overlay vert) sur la frame propre |
|
||||||
|
| Recadrer | Active le crop circulaire + navigue vers la position Base (mode vidéo) |
|
||||||
|
|
||||||
|
### `TubeAligner.set_radius_range(min_ratio, max_ratio)`
|
||||||
|
Ajuste la plage de recherche HoughCircles en fraction de `min(w,h)` :
|
||||||
|
- Mode caméra : `0.26–0.37` (tube occupe ~30% du champ)
|
||||||
|
- Mode vidéo : `0.38–0.47` (puit remplit le crop, ratio ~0.43–0.50)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Déploiement réseau isolé (labo sans internet)
|
||||||
|
|
||||||
|
```
|
||||||
|
GitHub ←→ Portable (internet) ←→ Routeur OpenWrt ←→ Machine labo (SSH)
|
||||||
|
```
|
||||||
|
|
||||||
|
Mise à jour sans internet :
|
||||||
|
```bash
|
||||||
|
# Sur la machine labo (une fois)
|
||||||
|
git config receive.denyCurrentBranch updateInstead
|
||||||
|
|
||||||
|
# Sur le portable — ajouter le labo comme remote
|
||||||
|
git remote add labo ssh://user@<ip-labo>/chemin/PlanarianScanner
|
||||||
|
|
||||||
|
# Workflow répétable
|
||||||
|
git pull origin video-plate-calibration # portable ← GitHub
|
||||||
|
git push labo video-plate-calibration # labo ← portable
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Licence
|
||||||
|
|
||||||
|
GPL-3.0
|
||||||
|
|
||||||
@@ -48,9 +48,18 @@ d'analyse distantes.
|
|||||||
---
|
---
|
||||||
## Fonctionnalités
|
## Fonctionnalités
|
||||||
|
|
||||||
|
### Application 1: Scanner de tube à essais
|
||||||
|
|
||||||
- Pilotage du bras CNC en GRBL — déplacement automatique puits par puits
|
- Pilotage du bras CNC en GRBL — déplacement automatique puits par puits
|
||||||
- Calibration des multi-puits avec synchro base de données
|
- Calibration des multi-puits avec synchro base de données
|
||||||
- Acquisition image haute définition via ArduCam (OpenCV + Picamera2)
|
- Trois modes de capture :
|
||||||
|
- **ArduCam** (Picamera2) — caméra haute définition montée sur le bras
|
||||||
|
- **Webcam** — via OpenCV (développement / test)
|
||||||
|
- **Vidéo plaque** (`VideoPlateCapture`) — crop dynamique dans une vidéo plaque entière rejouée en boucle ; adapté aux scans sans caméra embarquée
|
||||||
|
- Calibration assistée :
|
||||||
|
- Détection automatique du centre du puit (Hough + CLAHE, plage rayon adaptable selon le mode)
|
||||||
|
- Overlay Canny vert pour visualiser les bords en conditions d'éclairage difficile
|
||||||
|
- Contrôles temps réel : Debug, Overlay annotations, Contours, Recadrage
|
||||||
- Stockage des frames en base time série ReductStore
|
- Stockage des frames en base time série ReductStore
|
||||||
- Sessions de scan paramétrables (grille complète ou sélection de puits)
|
- Sessions de scan paramétrables (grille complète ou sélection de puits)
|
||||||
- Export asynchrone (Celery) :
|
- Export asynchrone (Celery) :
|
||||||
@@ -62,44 +71,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
|
||||||
@@ -185,13 +245,6 @@ ou
|
|||||||
sudo supervisorctl start|stop|restart reductstore
|
sudo supervisorctl start|stop|restart reductstore
|
||||||
sudo supervisorctl start|stop|restart test_tube:*
|
sudo supervisorctl start|stop|restart test_tube:*
|
||||||
|
|
||||||
Ajouter scanner.local au fichier hosts des clients web:
|
|
||||||
ip.du.rasp.berry scanner.local
|
|
||||||
|
|
||||||
- linux : /etc/hosts
|
|
||||||
- windows: C:\Windows\System32\drivers\etc\hosts
|
|
||||||
- mac : /private/etc/hosts"
|
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Organisation du dépôt
|
## Organisation du dépôt
|
||||||
@@ -199,8 +252,6 @@ ip.du.rasp.berry scanner.local
|
|||||||
```bash
|
```bash
|
||||||
PlanarianScanner/
|
PlanarianScanner/
|
||||||
├── assets
|
├── assets
|
||||||
│ ├── calibration-auto.jpg
|
|
||||||
│ ├── calibration-auto.mp4
|
|
||||||
│ ├── calibration-auto.png
|
│ ├── calibration-auto.png
|
||||||
│ └── logo.png
|
│ └── logo.png
|
||||||
├── browser.py
|
├── browser.py
|
||||||
@@ -222,7 +273,6 @@ PlanarianScanner/
|
|||||||
│ ├── scanner_service.conf
|
│ ├── scanner_service.conf
|
||||||
│ └── supervisor-inet_http.conf
|
│ └── supervisor-inet_http.conf
|
||||||
├── LICENSE
|
├── LICENSE
|
||||||
├── logo.png
|
|
||||||
├── README.md
|
├── README.md
|
||||||
└── test_tube_scanner
|
└── test_tube_scanner
|
||||||
├── home
|
├── home
|
||||||
@@ -312,14 +362,30 @@ PlanarianScanner/
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Procédure de calibration en 4 étapes
|
## Procédure de calibration
|
||||||
1. Activer "Debug détection" → voir le cercle et les zones sur le stream
|
|
||||||
|
|
||||||
Calibration auto
|
### Mode caméra (ArduCam / Webcam)
|
||||||
|
1. **Debug** → active la détection HoughCircles en continu (cercle + zones affiché)
|
||||||
|
2. **Overlay** → affiche/masque les annotations sans couper la détection
|
||||||
|
3. **Recadrer** → isole le puit et navigue vers la position Base
|
||||||
|
4. **Calibrage auto** → centrage automatique puit par puit avec sauvegarde
|
||||||
|
|
||||||
 Calibration auto
|
### Mode vidéo plaque
|
||||||
|
|
||||||
 Vidéo Calibration auto
|
> **Note** : ce mode permet de piloter le scanner sans caméra embarquée sur le bras CNC.
|
||||||
|
> Une vidéo de la plaque entière est enregistrée une seule fois puis rejouée en boucle ;
|
||||||
|
> chaque déplacement GRBL extrait dynamiquement la zone du puit courant dans cette vidéo.
|
||||||
|
> Idéal pour les tests sans matériel ou les laboratoires ne disposant pas de caméra ArduCam.
|
||||||
|
|
||||||
|
1. Créer un enregistrement `VideoPlate` dans l'admin (upload vidéo, `px_per_mm`, `x_origin_mm`, `y_origin_mm`)
|
||||||
|
2. **Contours** → overlay Canny vert pour repérer les bords des puits selon l'éclairage
|
||||||
|
3. **Debug** → détection Hough adaptée (plage rayon élargie pour puit plein cadre)
|
||||||
|
4. **Recadrer** → active le crop + déplace vers la Base
|
||||||
|
5. Naviguer puit par puit, sauvegarder les positions
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
[🎬 Vidéo Calibration auto](https://youtu.be/6RueJ3onUoY)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+382
@@ -0,0 +1,382 @@
|
|||||||
|
#  PlanarianScanner
|
||||||
|
|
||||||
|
> Automated imaging system for behavioral tracking of planarians
|
||||||
|
|
||||||
|
> (C) dd@linuxtarn.org for the Biology Laboratory, Champollion University, Albi
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
**PlanarianScanner** is a web application developed for monitoring the activity
|
||||||
|
and movements of **planarians** (*Platyhelminthes*) in laboratory research.
|
||||||
|
|
||||||
|
The system controls a motorized multi-well scanner composed of a CNC arm (GRBL)
|
||||||
|
and a high-definition ArduCam camera mounted on a Raspberry Pi 4. It enables
|
||||||
|
automated image acquisition on a **6×4 wells × 4 plates** grid,
|
||||||
|
high-performance storage of captures, and export to remote analysis machines.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Hardware
|
||||||
|
|
||||||
|
| Component | Details |
|
||||||
|
|---|---|
|
||||||
|
| Board | Raspberry Pi 4 |
|
||||||
|
| Camera | High-definition ArduCam |
|
||||||
|
| Motion system | CNC arm (L2544) controlled by GRBL |
|
||||||
|
| Well grid | 6×4 × 4 multi-well plates |
|
||||||
|
| Network | Local LAN — Samba/rsync export |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Technical Stack
|
||||||
|
|
||||||
|
| Layer | Technology |
|
||||||
|
|---|---|
|
||||||
|
| Backend | Django + Django Channels |
|
||||||
|
| Real-time | Redis (broker + channel layer) |
|
||||||
|
| Acquisition | OpenCV + Picamera2 |
|
||||||
|
| Storage | ReductStore (high-performance time series) |
|
||||||
|
| Asynchronous tasks | Celery + django-celery-beat |
|
||||||
|
| Export | Samba (CIFS), rsync/SSH |
|
||||||
|
| Platform | Raspberry Pi 4 — Debian Linux |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### Application 1: Test Tube Scanner
|
||||||
|
|
||||||
|
- CNC arm control through GRBL — automatic well-by-well movement
|
||||||
|
- Multi-well calibration with database synchronization
|
||||||
|
- Three capture modes:
|
||||||
|
- **ArduCam** (Picamera2) — high-definition camera mounted on the arm
|
||||||
|
- **Webcam** — via OpenCV (development / testing)
|
||||||
|
- **Plate video** (`VideoPlateCapture`) — dynamic crop from a full-plate video replayed in loop; suitable for scans without an embedded camera
|
||||||
|
- Assisted calibration:
|
||||||
|
- Automatic well-center detection (Hough + CLAHE, adjustable radius range per mode)
|
||||||
|
- Green Canny overlay to visualize well borders under difficult lighting
|
||||||
|
- Real-time controls: Debug, Annotation Overlay, Edge Enhance, Crop
|
||||||
|
- Frame storage in ReductStore time-series database
|
||||||
|
- Configurable scan sessions (full grid or selected wells)
|
||||||
|
- Asynchronous export (Celery):
|
||||||
|
- ZIP archive of JPEG images per session
|
||||||
|
- MP4 video generated from captured frames
|
||||||
|
- Automatic transfer of exports to remote machines (Linux / Windows)
|
||||||
|
- Nightly export scheduling via django-celery-beat
|
||||||
|
- Real-time web interface (Django Channels / WebSocket)
|
||||||
|
- Django administration interface (sqlite3 or mariadb or postgresql)
|
||||||
|
- Long-task progress tracking through polling
|
||||||
|
|
||||||
|
### Application 2: Planarian Detection and Multi-Individual Tracking in a Tube
|
||||||
|
|
||||||
|
[🎬 Planarian Simulation Video](https://youtu.be/pkzClmBp_KM)
|
||||||
|
|
||||||
|
- Supports multiple planarians with configurable parameters via Django or CSV.
|
||||||
|
|
||||||
|
- Strategy:
|
||||||
|
|
||||||
|
- MOG2 background subtraction (lightweight on Raspberry Pi 4)
|
||||||
|
- Detection of all valid contours (surface >= min_area_px)
|
||||||
|
- Frame-to-frame association using minimum Euclidean distance
|
||||||
|
via the Hungarian algorithm (scipy.optimize.linear_sum_assignment)
|
||||||
|
- Independent inter-frame state per individual (PlanarianState)
|
||||||
|
- Returns a list of results, one for each tracked individual
|
||||||
|
|
||||||
|
- Per-planarian CSV export compatible with EthoVision XT.
|
||||||
|
- Metrics per frame:
|
||||||
|
|
||||||
|
- Mobility : velocity, distance, moving, mobility_state
|
||||||
|
- Thigmo : dist_to_wall_mm, near_wall
|
||||||
|
- Photo : dist_to_light_mm, heading_to_light_deg, fleeing_light
|
||||||
|
- Chemo : dist_to_food_mm, heading_to_food_deg, approaching_food, in_food_zone
|
||||||
|
- Social : nearest_neighbour_mm, in_avoid_zone, in_aggreg_zone, chem_repulsion_level
|
||||||
|
|
||||||
|
- Summary metrics:
|
||||||
|
|
||||||
|
- Mobility : movedCenter_pointTotal_mm, velocity_mean_mm_s, state durations
|
||||||
|
- Thigmo : thigmotaxis_pct_time_near_wall
|
||||||
|
- Photo : photo_pct_time_fleeing, photo_mean_dist_mm, photo_latency_s
|
||||||
|
- Chemo : chemo_pct_time_approaching, chemo_pct_time_in_zone,
|
||||||
|
chemo_latency_s, chemo_mean_dist_mm
|
||||||
|
- Social : social_pct_time_avoiding, social_pct_time_aggregating,
|
||||||
|
social_mean_nn_mm, social_contact_events
|
||||||
|
|
||||||
|
- Default EthoVision thresholds (configurable via Django or CSV)
|
||||||
|
|
||||||
|
- **Immobile** : movement < 0.2 mm/s
|
||||||
|
- **Mobile** : 0.2 to 1.5 mm/s
|
||||||
|
- **Highly mobile** : > 1.5 mm/s
|
||||||
|
|
||||||
|
| EthoVision | CSV frames | CSV summary |
|
||||||
|
|---|---|---|
|
||||||
|
| movedCenter-pointTotalmm | total_distance_mm | movedCenter_pointTotal_mm |
|
||||||
|
| VelocityCenter-pointMeanmm/s | velocity_mm_s | velocity_mean_mm_s |
|
||||||
|
| MovementMoving | moving, duration_moving_s | movement_moving_duration_s |
|
||||||
|
| MovementNot Moving | duration_stopped_s | movement_not_moving_duration_s |
|
||||||
|
| ImmobileFrequency / Duration | mobility_state | mobility_immobile_frequency/duration_s |
|
||||||
|
| MobileFrequency / Duration | mobility_state | mobility_mobile_frequency/duration_s |
|
||||||
|
| Highly mobileFrequency / Duration | mobility_state | mobility_highly_mobile_frequency/duration_s |
|
||||||
|
|
||||||
|
- Behaviors
|
||||||
|
|
||||||
|
- **Thigmotaxis** : wall attraction (--thigmotaxis)
|
||||||
|
- **Phototaxis** : fleeing from light (--photo-mode, --photo-strength)
|
||||||
|
- **Chemotaxis** : attraction toward a food source (--chemo-strength)
|
||||||
|
- **Inter-individuals** : contact avoidance, aggregation, chemical repulsion
|
||||||
|
|
||||||
|
### Application 4: Planarian Simulation
|
||||||
|
|
||||||
|
- planarian_sim.py
|
||||||
|
|
||||||
|
Circular space of 16 mm diameter, 500x500 px
|
||||||
|
Supports multiple planarians with configurable parameters via CLI arguments.
|
||||||
|
Per-planarian CSV export compatible with EthoVision XT.
|
||||||
|
|
||||||
|
Simulated behaviors:
|
||||||
|
- Thigmotaxis : wall attraction (--thigmotaxis)
|
||||||
|
- Phototaxis : fleeing from light (--photo-mode, --photo-strength)
|
||||||
|
- Chemotaxis : attraction toward a food source (--chemo-strength)
|
||||||
|
- Inter-individual : contact avoidance, aggregation, chemical repulsion
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 planarian_sim.py [options]
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
python3 planarian_sim.py --count 5 --thigmotaxis 0.4
|
||||||
|
python3 planarian_sim.py --count 5 --photo-mode fixed --photo-x 0.2 --photo-y 0.2 --photo-strength 0.6
|
||||||
|
python3 planarian_sim.py --count 5 --chemo-x 0.7 --chemo-y 0.5 --chemo-strength 0.5
|
||||||
|
python3 planarian_sim.py --count 5 --avoid-strength 0.6 --aggreg-strength 0.2
|
||||||
|
|
||||||
|
- make_videos.sh
|
||||||
|
|
||||||
|
- Configurable video generator
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
- ./make_video.sh (generates the default file)
|
||||||
|
- ./make_video.sh all (generates 24 videos for 24 test tubes)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```text
|
||||||
|
Raspberry Pi 4
|
||||||
|
├── Django (web interface + API)
|
||||||
|
│ ├── Django Channels ←→ Redis (real-time WebSocket)
|
||||||
|
│ └── Celery workers
|
||||||
|
│ ├── scanning(session_id) — well traversal
|
||||||
|
│ ├── export_images_zip() — JPEG ZIP generation
|
||||||
|
│ ├── export_video_mp4() — MP4 generation (OpenCV)
|
||||||
|
│ └── transfer → /mnt/exports — Samba share
|
||||||
|
│
|
||||||
|
├── ArduCam ← Picamera2 / OpenCV — HD capture
|
||||||
|
├── CNC GRBL ← Serial — XY movement
|
||||||
|
└── ReductStore — frame time-series storage
|
||||||
|
|
||||||
|
Installation
|
||||||
|
|
||||||
|
Full documentation coming soon.
|
||||||
|
|
||||||
|
Using piImager, install Raspberry Pi OS 64-bit Trixie on the Raspberry Pi 4.<br>
|
||||||
|
Customize your Raspberry Pi with at least SSH enabled (SSH key or password).<br>
|
||||||
|
Later, for convenience, you may install a VNC server.
|
||||||
|
|
||||||
|
ssh rpi4@ip.of.raspi
|
||||||
|
|
||||||
|
git clone https://github.com/your-repo/planarianscanner.git
|
||||||
|
git@github.com:deunix-educ/PlanarianScanner.git
|
||||||
|
|
||||||
|
# modify environment variables if needed
|
||||||
|
cp .env.example .env
|
||||||
|
# Edit .env : SECRET_KEY, REDIS_URL, REDUCTSTORE_URL, ...
|
||||||
|
|
||||||
|
cd PlanarianScanner/etc
|
||||||
|
chmod +x *.sh
|
||||||
|
|
||||||
|
# install system libraries
|
||||||
|
./1-install-sys.sh
|
||||||
|
|
||||||
|
# compile reductstore (~15 min on Raspberry Pi 4)
|
||||||
|
./2-cargo-reductstore-install.sh
|
||||||
|
|
||||||
|
# install samba client
|
||||||
|
./3-install-samba-client.sh
|
||||||
|
|
||||||
|
# install mariadb
|
||||||
|
./4-install_mariadb.sh
|
||||||
|
|
||||||
|
# install adminer
|
||||||
|
./5-install_adminer.sh
|
||||||
|
|
||||||
|
# Configure Django applications
|
||||||
|
./6-install_django_app.sh
|
||||||
|
|
||||||
|
# test
|
||||||
|
sudo supervisorctl stop test_tube:*
|
||||||
|
./manage.py runserver 0.0.0.0:8000
|
||||||
|
|
||||||
|
# local test
|
||||||
|
# http://127.0.0.1:8000
|
||||||
|
|
||||||
|
# remote test
|
||||||
|
# http://ip.of.raspi:8000
|
||||||
|
|
||||||
|
# end of test
|
||||||
|
sudo supervisorctl restart test_tube:*
|
||||||
|
|
||||||
|
Starting services:
|
||||||
|
|
||||||
|
All services are accessible through supervisor
|
||||||
|
http://root:toor@ip-of-raspi:9001
|
||||||
|
or
|
||||||
|
sudo supervisorctl start|stop|restart reductstore
|
||||||
|
sudo supervisorctl start|stop|restart test_tube:*
|
||||||
|
|
||||||
|
Repository Organization
|
||||||
|
PlanarianScanner/
|
||||||
|
├── assets
|
||||||
|
│ ├── calibration-auto.png
|
||||||
|
│ └── logo.png
|
||||||
|
├── browser.py
|
||||||
|
├── etc
|
||||||
|
│ ├── 1-install-sys.sh
|
||||||
|
│ ├── 2-cargo-reductstore-install.sh
|
||||||
|
│ ├── 3-install-samba-client.sh
|
||||||
|
│ ├── 4-install_mariadb.sh
|
||||||
|
│ ├── 5-install_adminer.sh
|
||||||
|
│ ├── 6-install_django_app.sh
|
||||||
|
│ ├── db
|
||||||
|
│ │ ├── configuration.json
|
||||||
|
│ │ ├── multiwell.json
|
||||||
|
│ │ └── well.json
|
||||||
|
│ ├── install-linux-samba-server.sh
|
||||||
|
│ ├── nginx_service.conf
|
||||||
|
│ ├── reductstore_service.conf
|
||||||
|
│ ├── requirements.txt
|
||||||
|
│ ├── scanner_service.conf
|
||||||
|
│ └── supervisor-inet_http.conf
|
||||||
|
├── LICENSE
|
||||||
|
├── README.md
|
||||||
|
└── test_tube_scanner
|
||||||
|
├── home
|
||||||
|
│ ├── apps.py
|
||||||
|
│ ├── asgi.py
|
||||||
|
│ ├── celerymodule.py
|
||||||
|
│ ├── context_processors.py
|
||||||
|
│ ├── __init__.py
|
||||||
|
│ ├── locale
|
||||||
|
│ ├── management
|
||||||
|
│ ├── middleware.py
|
||||||
|
│ ├── __pycache__
|
||||||
|
│ ├── settings.py
|
||||||
|
│ ├── static
|
||||||
|
│ ├── templates
|
||||||
|
│ ├── templatetags
|
||||||
|
│ ├── urls.py
|
||||||
|
│ ├── views.py
|
||||||
|
│ └── wsgi.py
|
||||||
|
├── logs
|
||||||
|
│ ├── celery.log
|
||||||
|
│ └── test_tube.log
|
||||||
|
├── manage.py
|
||||||
|
├── media
|
||||||
|
│ ├── images
|
||||||
|
│ └── simulation
|
||||||
|
├── modules
|
||||||
|
│ ├── capture_interface.py
|
||||||
|
│ ├── circular_crop.py
|
||||||
|
│ ├── grbl.py
|
||||||
|
│ ├── __init__.py
|
||||||
|
│ ├── picamera2_capture_basic.py
|
||||||
|
│ ├── picamera2_capture.py
|
||||||
|
│ ├── planarian_metrics.py
|
||||||
|
│ ├── planarian_tracker.py
|
||||||
|
│ ├── __pycache__
|
||||||
|
│ ├── reductstore.py
|
||||||
|
│ ├── system_stats.py
|
||||||
|
│ ├── tube_aligner.py
|
||||||
|
│ ├── utils.py
|
||||||
|
│ ├── videofile_capture.py
|
||||||
|
│ └── webcam_capture.py
|
||||||
|
├── planarian
|
||||||
|
│ ├── admin.py
|
||||||
|
│ ├── apps.py
|
||||||
|
│ ├── forms.py
|
||||||
|
│ ├── __init__.py
|
||||||
|
│ ├── migrations
|
||||||
|
│ ├── models.py
|
||||||
|
│ ├── __pycache__
|
||||||
|
│ ├── templates
|
||||||
|
│ ├── tests.py
|
||||||
|
│ ├── urls.py
|
||||||
|
│ └── views.py
|
||||||
|
├── run-workers.sh
|
||||||
|
├── scanner
|
||||||
|
│ ├── admin.py
|
||||||
|
│ ├── apps.py
|
||||||
|
│ ├── constants.py
|
||||||
|
│ ├── consumers.py
|
||||||
|
│ ├── export_tasks.py
|
||||||
|
│ ├── __init__.py
|
||||||
|
│ ├── migrations
|
||||||
|
│ ├── models.py
|
||||||
|
│ ├── multiwell.py
|
||||||
|
│ ├── process.py
|
||||||
|
│ ├── __pycache__
|
||||||
|
│ ├── routing.py
|
||||||
|
│ ├── static
|
||||||
|
│ ├── tasks.py
|
||||||
|
│ ├── templates
|
||||||
|
│ ├── templatetags
|
||||||
|
│ ├── tests.py
|
||||||
|
│ ├── urls.py
|
||||||
|
│ └── views.py
|
||||||
|
├── staticfiles
|
||||||
|
│ ├── admin
|
||||||
|
│ ├── css
|
||||||
|
│ ├── img
|
||||||
|
│ ├── js
|
||||||
|
│ ├── scanner
|
||||||
|
│ └── webfonts
|
||||||
|
└── templates
|
||||||
|
└── admin
|
||||||
|
## Calibration Procedure
|
||||||
|
|
||||||
|
### Camera mode (ArduCam / Webcam)
|
||||||
|
1. **Debug** → enables continuous HoughCircles detection (circle + zones displayed)
|
||||||
|
2. **Overlay** → shows/hides annotations without stopping detection
|
||||||
|
3. **Crop** → isolates the well and navigates to the Base position
|
||||||
|
4. **Auto calibration** → automatic well-by-well centering with position save
|
||||||
|
|
||||||
|
### Plate video mode
|
||||||
|
|
||||||
|
> **Note**: this mode lets you drive the scanner without a camera mounted on the CNC arm.
|
||||||
|
> A single recording of the full plate is made once and replayed in a loop; each GRBL move
|
||||||
|
> dynamically crops the current well's region from that video. Ideal for hardware-free
|
||||||
|
> testing or labs without an ArduCam.
|
||||||
|
|
||||||
|
1. Create a `VideoPlate` record in admin (upload video, set `px_per_mm`, `x_origin_mm`, `y_origin_mm`)
|
||||||
|
2. **Edge Enhance** → green Canny overlay to locate well borders under variable lighting
|
||||||
|
3. **Debug** → Hough detection with wider radius range (well fills the crop)
|
||||||
|
4. **Crop** → activates circular crop + moves to Base position
|
||||||
|
5. Navigate well by well and save positions
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
[🎬 Auto Calibration Video](https://youtu.be/6RueJ3onUoY)
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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,4 +1,4 @@
|
|||||||
!/bin/bash
|
#!/bin/bash
|
||||||
|
|
||||||
echo "==== System installation for rpi"
|
echo "==== System installation for rpi"
|
||||||
echo
|
echo
|
||||||
@@ -7,8 +7,8 @@ sudo apt upgrade
|
|||||||
|
|
||||||
ETC="$(pwd)"
|
ETC="$(pwd)"
|
||||||
|
|
||||||
mkdir -p $HOME/exports
|
mkdir -p "$HOME"/exports
|
||||||
mkdir -p$HOME/medias
|
mkdir -p "$HOME"/medias
|
||||||
mkdir -p /mnt/exports
|
mkdir -p /mnt/exports
|
||||||
|
|
||||||
cp ../test_tube_scanner/.env.example ../test_tube_scanner/.env
|
cp ../test_tube_scanner/.env.example ../test_tube_scanner/.env
|
||||||
@@ -17,15 +17,15 @@ echo
|
|||||||
|
|
||||||
# system
|
# system
|
||||||
echo "==== system essential"
|
echo "==== system essential"
|
||||||
sudo apt -y install build-essential openssl git pkg-config redis supervisor sqlitebrowser samba-client cifs-utils gettext
|
sudo apt -y install build-essential openssl git pkg-config redis supervisor sqlitebrowser samba-client cifs-utils gettext avahi-daemon
|
||||||
|
|
||||||
echo "==== python3 install"
|
echo "==== python3 install"
|
||||||
sudo apt -y install python3-dev python3-pip python3-venv libpq-dev default-libmysqlclient-dev libmariadb-dev python3-picamera2
|
sudo apt -y install python3-dev python3-pip python3-venv libpq-dev default-libmysqlclient-dev libmariadb-dev python3-picamera2
|
||||||
|
|
||||||
echo "==== supervisor http access login:pass => root:toor"
|
echo "==== supervisor http access login:pass => root:toor"
|
||||||
sudo cp supervisor-inet_http.conf /etc/supervisor/conf.d/
|
sudo cp supervisor-inet_http.conf /etc/supervisor/conf.d/
|
||||||
sudo ln -s $ETC/scanner_service.conf /etc/supervisor/conf.d/
|
sudo ln -s "$ETC"/scanner_service.conf /etc/supervisor/conf.d/
|
||||||
sudo ln -s $ETC/reductstore_service.conf /etc/supervisor/conf.d/
|
sudo ln -s "$ETC"/reductstore_service.conf /etc/supervisor/conf.d/
|
||||||
|
|
||||||
echo "==== restart supervisor "
|
echo "==== restart supervisor "
|
||||||
sudo systemctl restart supervisor
|
sudo systemctl restart supervisor
|
||||||
@@ -33,6 +33,7 @@ sudo systemctl restart supervisor
|
|||||||
echo "==== python env with system site packages for picamera2"
|
echo "==== python env with system site packages for picamera2"
|
||||||
rm -rf ../.venv
|
rm -rf ../.venv
|
||||||
python -m venv --system-site-packages ../.venv
|
python -m venv --system-site-packages ../.venv
|
||||||
|
# shellcheck disable=SC1091
|
||||||
source ../.venv/bin/activate
|
source ../.venv/bin/activate
|
||||||
|
|
||||||
echo "==== pip requirements"
|
echo "==== pip requirements"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
[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
|
||||||
stopasgroup=true
|
stopasgroup=true
|
||||||
stopsignal=SIGINT
|
stopsignal=SIGINT
|
||||||
autostart=true
|
autostart=true
|
||||||
autorestart=true
|
autorestart=true
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ twisted[tls,http2]
|
|||||||
celery[redis]
|
celery[redis]
|
||||||
python-decouple
|
python-decouple
|
||||||
PyYaml
|
PyYaml
|
||||||
|
requests
|
||||||
reduct-py
|
reduct-py
|
||||||
python-dateutil
|
python-dateutil
|
||||||
psutil
|
psutil
|
||||||
@@ -19,4 +20,5 @@ opencv-python-headless
|
|||||||
mysqlclient
|
mysqlclient
|
||||||
psycopg2
|
psycopg2
|
||||||
pyserial
|
pyserial
|
||||||
|
scipy
|
||||||
|
django-stubs[compatible-mypy]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"venvPath": ".",
|
||||||
|
"venv": ".venv",
|
||||||
|
"pythonVersion": "3.13",
|
||||||
|
"extraPaths": ["test_tube_scanner"],
|
||||||
|
"typeCheckingMode": "basic",
|
||||||
|
"reportMissingImports": false,
|
||||||
|
"reportMissingModuleSource": false,
|
||||||
|
"reportOptionalMemberAccess": false,
|
||||||
|
"reportOptionalSubscript": false,
|
||||||
|
"reportOptionalIterable": false,
|
||||||
|
"reportPossiblyUnboundVariable": "warning",
|
||||||
|
"reportCallIssue": false,
|
||||||
|
"reportArgumentType": false,
|
||||||
|
"reportAttributeAccessIssue": false
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
# django app
|
# django app
|
||||||
DEBUG=True
|
DEBUG=True
|
||||||
IS_LOGGING=True
|
IS_LOGGING=True
|
||||||
|
GRBL_SIMULATION = False
|
||||||
|
|
||||||
# app
|
# app
|
||||||
DJANGO_APP=Test Tube Scanner
|
DJANGO_APP=Test Tube Scanner
|
||||||
@@ -19,6 +20,8 @@ APP_DATAS=test_tube_scanner
|
|||||||
EXPORTS_LOCAL_PATH=/home/rpi4/exports
|
EXPORTS_LOCAL_PATH=/home/rpi4/exports
|
||||||
EXPORT_REMOTE_PATH=/mnt/exports
|
EXPORT_REMOTE_PATH=/mnt/exports
|
||||||
REDUCTSTORE_PATH=/home/rpi4/medias
|
REDUCTSTORE_PATH=/home/rpi4/medias
|
||||||
|
CSV_EXPORT_DIR=/home/rpi4/exports/csv
|
||||||
|
BACKUP_DIR=/home/rpi4/backup/mariadb
|
||||||
|
|
||||||
####
|
####
|
||||||
# django email
|
# django email
|
||||||
@@ -39,12 +42,14 @@ SECRET_KEY="django-insecure-0)4_w=pjv1ex7=s=c=ii3g@fx_=8fb=hxk€3bpk1)uj(0ph0t)
|
|||||||
USER=rpi4
|
USER=rpi4
|
||||||
GROUP=rpi4
|
GROUP=rpi4
|
||||||
|
|
||||||
|
|
||||||
DOMAIN_SERVER=scanner.local
|
DOMAIN_SERVER=scanner.local
|
||||||
ALLOWED_HOSTS=127.0.0.1,localhost
|
ALLOWED_HOSTS=127.0.0.1,localhost,scanner.local,192.168.250.230,192.168.1.200
|
||||||
CSRF_TRUSTED_ORIGINS=http://127.0.0.1:8000,http://localhost:8000
|
CSRF_TRUSTED_ORIGINS=http://127.0.0.1:8000,http://localhost:8000,http://scanner.local,http://192.168.1.200:8000,http://192.168.250.230:8000
|
||||||
|
|
||||||
####
|
####
|
||||||
# server app
|
# server app
|
||||||
|
LOCAL_IP_SERVER=192.168.1.200
|
||||||
SERVER_HOST_IP=0.0.0.0
|
SERVER_HOST_IP=0.0.0.0
|
||||||
SERVER_HOST_PORT=8000
|
SERVER_HOST_PORT=8000
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
@@ -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, }
|
||||||
@@ -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
|
||||||
|
|||||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -31,7 +31,7 @@ class Command(BaseCommand):
|
|||||||
|
|
||||||
)
|
)
|
||||||
print()
|
print()
|
||||||
print(f"Export video: {job.id}")
|
print(f"Export video: {job.id}") # type: ignore[union-attr]
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print("Export video uuid error", e)
|
print("Export video uuid error", e)
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ def create_user():
|
|||||||
if User.objects.count() == 0:
|
if User.objects.count() == 0:
|
||||||
for email, username, password, is_superuser in settings.ADMINS:
|
for email, username, password, is_superuser in settings.ADMINS:
|
||||||
if is_superuser:
|
if is_superuser:
|
||||||
User.objects.create_superuser(
|
User.objects.create_superuser( # type: ignore[attr-defined]
|
||||||
email=email,
|
email=email,
|
||||||
username=username,
|
username=username,
|
||||||
password=password,
|
password=password,
|
||||||
@@ -21,7 +21,7 @@ def create_user():
|
|||||||
is_superuser=is_superuser,
|
is_superuser=is_superuser,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
User.objects.create_user(
|
User.objects.create_user( # type: ignore[attr-defined]
|
||||||
email=email,
|
email=email,
|
||||||
username=username,
|
username=username,
|
||||||
password=password,
|
password=password,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
# encoding: utf-8
|
# encoding: utf-8
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
from django.core.management import BaseCommand
|
from django.core.management import BaseCommand
|
||||||
from django.core.management.utils import get_random_string;
|
from django.utils.crypto import get_random_string
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
def add_arguments(self, parser):
|
def add_arguments(self, parser):
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ from django.core.management.base import BaseCommand
|
|||||||
from scanner import tasks as scanner_tasks
|
from scanner import tasks as scanner_tasks
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
help = "Démarre les tâches Celery."
|
help = "Démarre les tâches Celery."
|
||||||
|
|
||||||
@@ -18,9 +17,9 @@ class Command(BaseCommand):
|
|||||||
def handle(self, *args, **options): # @UnusedVariable
|
def handle(self, *args, **options): # @UnusedVariable
|
||||||
#task = options['task']
|
#task = options['task']
|
||||||
try:
|
try:
|
||||||
|
|
||||||
scanner_tasks.scanner_start.delay() # @UndefinedVariable
|
scanner_tasks.scanner_start.delay() # @UndefinedVariable
|
||||||
scanner_tasks.replay_start.delay() # @UndefinedVariable
|
scanner_tasks.replay_start.delay() # @UndefinedVariable
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(e)
|
print(e)
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,11 @@ Created on 19 janv. 2026
|
|||||||
@author: denis
|
@author: denis
|
||||||
'''
|
'''
|
||||||
from django.core.management.base import BaseCommand
|
from django.core.management.base import BaseCommand
|
||||||
from modules.grbl import GRBLController, wait_for
|
from modules.grbl import GRBLController
|
||||||
|
import threading
|
||||||
|
|
||||||
|
def wait_for(delay: float = 1.0) -> None:
|
||||||
|
threading.Event().wait(delay)
|
||||||
import time
|
import time
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -19,9 +19,7 @@ BASE_DIR = Path(__file__).resolve().parent.parent
|
|||||||
print("Django BASE_DIR:", BASE_DIR)
|
print("Django BASE_DIR:", BASE_DIR)
|
||||||
|
|
||||||
PACKAGE_DIR = BASE_DIR.parent
|
PACKAGE_DIR = BASE_DIR.parent
|
||||||
APP_DATAS = PACKAGE_DIR / config('APP_DATAS')
|
APP_DATAS = PACKAGE_DIR / str(config('APP_DATAS'))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# Quick-start development settings - unsuitable for production
|
# Quick-start development settings - unsuitable for production
|
||||||
# See https://docs.djangoproject.com/en/5.0/howto/deployment/checklist/
|
# See https://docs.djangoproject.com/en/5.0/howto/deployment/checklist/
|
||||||
@@ -37,10 +35,10 @@ SECRET_KEY = config("SECRET_KEY")
|
|||||||
DEBUG = config('DEBUG', cast=bool)
|
DEBUG = config('DEBUG', cast=bool)
|
||||||
|
|
||||||
DOMAIN_SERVER = config("DOMAIN_SERVER")
|
DOMAIN_SERVER = config("DOMAIN_SERVER")
|
||||||
ALLOWED_HOSTS = config('ALLOWED_HOSTS', cast=Csv())
|
ALLOWED_HOSTS: list = list(config('ALLOWED_HOSTS', cast=Csv()))
|
||||||
ALLOWED_HOSTS += [DOMAIN_SERVER, '*']
|
ALLOWED_HOSTS += [DOMAIN_SERVER, '*']
|
||||||
|
|
||||||
CSRF_TRUSTED_ORIGINS = config('CSRF_TRUSTED_ORIGINS', cast=Csv())
|
CSRF_TRUSTED_ORIGINS: list = list(config('CSRF_TRUSTED_ORIGINS', cast=Csv()))
|
||||||
CSRF_TRUSTED_ORIGINS += [f'http://{DOMAIN_SERVER}', f'https://{DOMAIN_SERVER}']
|
CSRF_TRUSTED_ORIGINS += [f'http://{DOMAIN_SERVER}', f'https://{DOMAIN_SERVER}']
|
||||||
|
|
||||||
SECURE_CROSS_ORIGIN_OPENER_POLICY = 'same-origin'
|
SECURE_CROSS_ORIGIN_OPENER_POLICY = 'same-origin'
|
||||||
@@ -61,6 +59,7 @@ INSTALLED_APPS = [
|
|||||||
'django_celery_results',
|
'django_celery_results',
|
||||||
'celery',
|
'celery',
|
||||||
'home',
|
'home',
|
||||||
|
'backup',
|
||||||
'scanner',
|
'scanner',
|
||||||
'planarian',
|
'planarian',
|
||||||
|
|
||||||
@@ -216,12 +215,11 @@ LOCALE_PATHS = (
|
|||||||
|
|
||||||
STATIC_URL = '/static/'
|
STATIC_URL = '/static/'
|
||||||
STATIC_ROOT = APP_DATAS / 'staticfiles'
|
STATIC_ROOT = APP_DATAS / 'staticfiles'
|
||||||
print("Django application STATIC_ROOT:", STATIC_ROOT)
|
|
||||||
|
|
||||||
|
|
||||||
MEDIA_URL = '/media/'
|
MEDIA_URL = '/media/'
|
||||||
MEDIA_ROOT = APP_DATAS / 'media'
|
MEDIA_ROOT = APP_DATAS / 'media'
|
||||||
print("Django application MEDIA_ROOT:", MEDIA_ROOT)
|
print("Django application MEDIA_ROOT:", MEDIA_ROOT, "\nDjango application STATIC_ROOT:", STATIC_ROOT)
|
||||||
|
|
||||||
|
|
||||||
"""
|
"""
|
||||||
@@ -239,8 +237,9 @@ DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
|||||||
## LOGGING
|
## LOGGING
|
||||||
# CRITICAL=50, ERROR=40, WARN=30, INFO=20, DEBUG=10 and NOTSET=0
|
# CRITICAL=50, ERROR=40, WARN=30, INFO=20, DEBUG=10 and NOTSET=0
|
||||||
|
|
||||||
LOGGING_FILE = config('LOGGING_FILE')
|
LOGGING_FILE: str = str(config('LOGGING_FILE'))
|
||||||
IS_LOGGING = config('IS_LOGGING', cast=bool)
|
IS_LOGGING = config('IS_LOGGING', cast=bool)
|
||||||
|
IS_LOGGING = False
|
||||||
|
|
||||||
LOGGING = None if not IS_LOGGING else {
|
LOGGING = None if not IS_LOGGING else {
|
||||||
'version': 1,
|
'version': 1,
|
||||||
@@ -347,6 +346,7 @@ DJANGO_CELERY_BEAT_TZ_AWARE = False
|
|||||||
CELERY_ENABLE_UTC = True
|
CELERY_ENABLE_UTC = True
|
||||||
CELERY_TASK_TRACK_STARTED = True
|
CELERY_TASK_TRACK_STARTED = True
|
||||||
CELERY_TASK_TIME_LIMIT = 30 * 60
|
CELERY_TASK_TIME_LIMIT = 30 * 60
|
||||||
|
CELERY_TASK_SOFT_TIME_LIMIT = 20 * 60
|
||||||
CELERY_BEAT_SCHEDULER = 'django_celery_beat.schedulers:DatabaseScheduler'
|
CELERY_BEAT_SCHEDULER = 'django_celery_beat.schedulers:DatabaseScheduler'
|
||||||
CELERY_ACCEPT_CONTENT = ['application/json']
|
CELERY_ACCEPT_CONTENT = ['application/json']
|
||||||
CELERY_RESULT_SERIALIZER = 'json'
|
CELERY_RESULT_SERIALIZER = 'json'
|
||||||
@@ -356,6 +356,15 @@ CELERY_RESULT_EXTENDED = True
|
|||||||
CELERY_RESULT_BACKEND = 'django-db'
|
CELERY_RESULT_BACKEND = 'django-db'
|
||||||
DJANGO_CELERY_RESULTS_TASK_ID_MAX_LENGTH=191
|
DJANGO_CELERY_RESULTS_TASK_ID_MAX_LENGTH=191
|
||||||
|
|
||||||
|
from celery.schedules import crontab
|
||||||
|
|
||||||
|
CELERY_BEAT_SCHEDULE = {
|
||||||
|
"mariadb-backup-every-night": {
|
||||||
|
"task": "backup.tasks.backup_mariadb_task",
|
||||||
|
"schedule": crontab(hour=2, minute=0),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
## reductstore
|
## reductstore
|
||||||
#
|
#
|
||||||
REDUCTSTORE_TOKEN = config('REDUCTSTORE_TOKEN')
|
REDUCTSTORE_TOKEN = config('REDUCTSTORE_TOKEN')
|
||||||
@@ -364,6 +373,8 @@ REDUCTSTORE_PORT = config('REDUCTSTORE_PORT', cast=int)
|
|||||||
REDUCTSTORE_URL = f'http://{REDUCTSTORE_HOST}:{REDUCTSTORE_PORT}'
|
REDUCTSTORE_URL = f'http://{REDUCTSTORE_HOST}:{REDUCTSTORE_PORT}'
|
||||||
REDUCTSTORE_PATH = config("REDUCTSTORE_PATH")
|
REDUCTSTORE_PATH = config("REDUCTSTORE_PATH")
|
||||||
|
|
||||||
|
BACKUP_DIR = config('BACKUP_DIR')
|
||||||
|
|
||||||
## servers app
|
## servers app
|
||||||
#
|
#
|
||||||
USER = config('USER')
|
USER = config('USER')
|
||||||
@@ -371,6 +382,8 @@ GROUP = config('GROUP')
|
|||||||
SERVER_HOST_PORT = config('SERVER_HOST_PORT', cast=int)
|
SERVER_HOST_PORT = config('SERVER_HOST_PORT', cast=int)
|
||||||
SERVER_HOST_IP = config('SERVER_HOST_IP')
|
SERVER_HOST_IP = config('SERVER_HOST_IP')
|
||||||
|
|
||||||
|
LOCAL_IP_SERVER = config('LOCAL_IP_SERVER')
|
||||||
|
|
||||||
# ws
|
# ws
|
||||||
SCANNER_WEBSOCKET_ROUTE = 'ws/scanner'
|
SCANNER_WEBSOCKET_ROUTE = 'ws/scanner'
|
||||||
REPLAY_WEBSOCKET_ROUTE = 'ws/replay'
|
REPLAY_WEBSOCKET_ROUTE = 'ws/replay'
|
||||||
@@ -385,20 +398,28 @@ DATETIME_FORMAT = '%d-%m-%Y-%m %H:%M:%S'
|
|||||||
#===========================
|
#===========================
|
||||||
# default configuration
|
# default configuration
|
||||||
#
|
#
|
||||||
#===========================
|
|
||||||
# rpicam 4056x3040 2028x1080 2028x1520
|
# rpicam 4056x3040 2028x1080 2028x1520
|
||||||
|
#===========================
|
||||||
|
|
||||||
|
GRBL_SIMULATION = config('GRBL_SIMULATION', cast=bool)
|
||||||
|
|
||||||
EXPORTS_LOCAL_PATH = config("EXPORTS_LOCAL_PATH")
|
EXPORTS_LOCAL_PATH = config("EXPORTS_LOCAL_PATH")
|
||||||
EXPORT_REMOTE_PATH = config("EXPORT_REMOTE_PATH")
|
EXPORT_REMOTE_PATH = config("EXPORT_REMOTE_PATH")
|
||||||
|
|
||||||
EXPORT_DESTINATIONS = ["local", "remote"]
|
EXPORT_DESTINATIONS = ["local", "remote"]
|
||||||
|
#EXPORT_DESTINATIONS = ["remote"] # only remote
|
||||||
|
|
||||||
TEST_VIDEOFILE = False
|
CSV_EXPORT_DIR = config("CSV_EXPORT_DIR") # ou None pour mémoire uniquement
|
||||||
|
|
||||||
TRACKING = True
|
## tracker default parameters
|
||||||
|
#
|
||||||
TRACKER_TUBE_AXIS = "vertical"
|
TRACKER_TUBE_AXIS = "vertical"
|
||||||
TRACKER_MIN_AREA = 200
|
TRACKER_MIN_AREA = 20 # surface min planaire px x px
|
||||||
|
TRACKER_MAX_AREA_RATIO = 0.1 # 5% de la frame = surface max acceptable
|
||||||
|
TRACKER_MAX_PLANARIANS = 4
|
||||||
|
TRACKER_MERGE_KERNEL_SIZE = 15 # augmenter si fragments résiduels
|
||||||
|
TRACKER_MIN_CONTOUR_DIST_PX = 40 # augmenter si IDs multiples persistent
|
||||||
|
|
||||||
CALIBRATION_AUTO_DURATION = 45.0
|
CALIBRATION_AUTO_DURATION = 45.0
|
||||||
CALIBRATION_AUTO_TIMEOUT = 2.5
|
CALIBRATION_AUTO_TIMEOUT = 2.5
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
/* ============================================================
|
||||||
|
doc_calibration.css
|
||||||
|
Styles spécifiques à la page de calibration du scanner.
|
||||||
|
Dépend de doc_database.css (variables, composants de base).
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
/* ─── Badge teal ─── */
|
||||||
|
.badge-teal {
|
||||||
|
background: rgba(45,212,191,.12);
|
||||||
|
color: var(--accent-teal);
|
||||||
|
border: 1px solid rgba(45,212,191,.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Badge blue ─── */
|
||||||
|
.badge-blue {
|
||||||
|
background: rgba(88,166,255,.12);
|
||||||
|
color: var(--accent-blue);
|
||||||
|
border: 1px solid rgba(88,166,255,.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Tag teal ─── */
|
||||||
|
.tag-teal {
|
||||||
|
background: rgba(45,212,191,.12);
|
||||||
|
color: var(--accent-teal);
|
||||||
|
border: 1px solid rgba(45,212,191,.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Schéma des 3 zones de l'écran ─── */
|
||||||
|
.screen-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 2fr 1fr;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--bg-card);
|
||||||
|
}
|
||||||
|
|
||||||
|
.screen-zone {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 20px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-align: center;
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
transition: background .15s;
|
||||||
|
}
|
||||||
|
.screen-zone:last-child { border-right: none; }
|
||||||
|
.screen-zone i { font-size: 20px; }
|
||||||
|
|
||||||
|
.zone-left { color: var(--accent-indigo); }
|
||||||
|
.zone-left i { color: var(--accent-indigo); }
|
||||||
|
.zone-center {
|
||||||
|
background: rgba(45,212,191,.04);
|
||||||
|
color: var(--accent-teal);
|
||||||
|
}
|
||||||
|
.zone-center i { font-size: 26px; color: var(--accent-teal); }
|
||||||
|
.zone-right { color: var(--accent-orange); }
|
||||||
|
.zone-right i { color: var(--accent-orange); }
|
||||||
|
|
||||||
|
/* ─── Barre visuelle des seuils de centrage ─── */
|
||||||
|
.center-bar {
|
||||||
|
display: flex;
|
||||||
|
margin-top: 12px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
overflow: hidden;
|
||||||
|
height: 28px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.cb-seg {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.cb-red { background: rgba(248,81,73,.18); color: var(--accent-red); border: 1px solid rgba(248,81,73,.3); }
|
||||||
|
.cb-yellow { background: rgba(227,179,65,.18); color: var(--accent-yellow); border: 1px solid rgba(227,179,65,.3); }
|
||||||
|
.cb-green { background: rgba(63,185,80,.18); color: var(--accent-green); border: 1px solid rgba(63,185,80,.3); }
|
||||||
|
|
||||||
|
/* ─── Responsive ─── */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.screen-layout {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.screen-zone {
|
||||||
|
border-right: none;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
.screen-zone:last-child { border-bottom: none; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,521 @@
|
|||||||
|
/* ============================================================
|
||||||
|
doc_database.css
|
||||||
|
Feuille de style de la documentation base de données
|
||||||
|
Dépendances : w3.css, all.css (Font Awesome)
|
||||||
|
Fond sombre, style industriel/technique
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
/* ─── Variables globales ─── */
|
||||||
|
:root {
|
||||||
|
--bg-base: #0d1117;
|
||||||
|
--bg-surface: #161b22;
|
||||||
|
--bg-card: #1c2128;
|
||||||
|
--bg-card-hover: #21262d;
|
||||||
|
--border: #30363d;
|
||||||
|
--border-strong: #484f58;
|
||||||
|
|
||||||
|
--text-primary: #e6edf3;
|
||||||
|
--text-secondary: #8b949e;
|
||||||
|
--text-muted: #6e7681;
|
||||||
|
--text-code: #79c0ff;
|
||||||
|
|
||||||
|
--accent-blue: #58a6ff;
|
||||||
|
--accent-cyan: #39d353;
|
||||||
|
--accent-green: #3fb950;
|
||||||
|
--accent-teal: #2dd4bf;
|
||||||
|
--accent-orange: #f0883e;
|
||||||
|
--accent-yellow: #e3b341;
|
||||||
|
--accent-red: #f85149;
|
||||||
|
--accent-purple: #bc8cff;
|
||||||
|
--accent-indigo: #818cf8;
|
||||||
|
|
||||||
|
--nav-width: 240px;
|
||||||
|
--header-h: 90px;
|
||||||
|
--radius: 8px;
|
||||||
|
--radius-sm: 4px;
|
||||||
|
--shadow: 0 4px 16px rgba(0,0,0,.4);
|
||||||
|
--font-mono: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
|
||||||
|
--font-body: 'Segoe UI', system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Reset minimal ─── */
|
||||||
|
*, *::before, *::after { box-sizing: border-box; }
|
||||||
|
|
||||||
|
a { color: var(--accent-blue); text-decoration: none; }
|
||||||
|
/*a:hover { text-decoration: underline; }*/
|
||||||
|
|
||||||
|
ul { padding: 0; margin: 0; list-style: none; }
|
||||||
|
|
||||||
|
/* ─── Mise en page principale ─── */
|
||||||
|
.doc-wrapper {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: var(--nav-width) 1fr;
|
||||||
|
grid-template-rows: auto 1fr;
|
||||||
|
grid-template-areas:
|
||||||
|
"header header"
|
||||||
|
"sidenav content";
|
||||||
|
min-height: 100vh;
|
||||||
|
background: var(--bg-base);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── En-tête ─── */
|
||||||
|
.doc-header {
|
||||||
|
grid-area: header;
|
||||||
|
background: linear-gradient(135deg, #1a2236 0%, #0d1117 60%, #1a1a2e 100%);
|
||||||
|
border-bottom: 1px solid var(--border-strong);
|
||||||
|
padding: 20px 28px;
|
||||||
|
}
|
||||||
|
.doc-header-inner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 18px;
|
||||||
|
max-width: 1200px;
|
||||||
|
}
|
||||||
|
.doc-header-icon {
|
||||||
|
width: 52px; height: 52px;
|
||||||
|
background: linear-gradient(135deg, var(--accent-blue), var(--accent-indigo));
|
||||||
|
border-radius: var(--radius);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
font-size: 22px;
|
||||||
|
color: #fff;
|
||||||
|
flex-shrink: 0;
|
||||||
|
box-shadow: 0 4px 12px rgba(88,166,255,.3);
|
||||||
|
}
|
||||||
|
.doc-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.doc-subtitle {
|
||||||
|
margin: 2px 0 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Navigation latérale ─── */
|
||||||
|
.doc-sidenav {
|
||||||
|
grid-area: sidenav;
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
padding: 20px 0;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
height: 100vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.nav-section-label {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .08em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding: 0 16px 8px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.nav-link {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 7px 16px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
transition: all .15s;
|
||||||
|
border-left: 2px solid transparent;
|
||||||
|
}
|
||||||
|
.nav-link:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: var(--bg-card);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.nav-link.active {
|
||||||
|
color: var(--accent-blue);
|
||||||
|
border-left-color: var(--accent-blue);
|
||||||
|
background: rgba(88,166,255,.08);
|
||||||
|
}
|
||||||
|
.nav-link.nav-sub {
|
||||||
|
padding-left: 28px;
|
||||||
|
font-size: 12.5px;
|
||||||
|
}
|
||||||
|
.nav-link.nav-sub-2 {
|
||||||
|
padding-left: 42px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Contenu principal ─── */
|
||||||
|
.doc-content {
|
||||||
|
grid-area: content;
|
||||||
|
padding: 28px 32px;
|
||||||
|
max-width: 1100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Sections ─── */
|
||||||
|
.doc-section {
|
||||||
|
margin-bottom: 48px;
|
||||||
|
}
|
||||||
|
.subsection {
|
||||||
|
margin-top: 32px;
|
||||||
|
padding-top: 24px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.sub-subsection {
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.section-header h2 {
|
||||||
|
margin: 6px 0 6px;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.section-desc {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subsection-title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0 0 14px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.table-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0 0 12px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.table-desc {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 12.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Badges de section ─── */
|
||||||
|
.section-badge {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .06em;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 20px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.badge-time { background: rgba(57,211,83,.12); color: var(--accent-green); border: 1px solid rgba(57,211,83,.3); }
|
||||||
|
.badge-orange { background: rgba(240,136,62,.12); color: var(--accent-orange); border: 1px solid rgba(240,136,62,.3);}
|
||||||
|
|
||||||
|
/* ─── Ligne de cartes ─── */
|
||||||
|
.info-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 14px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Cartes d'information ─── */
|
||||||
|
.info-card {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 16px;
|
||||||
|
display: flex;
|
||||||
|
gap: 14px;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 220px;
|
||||||
|
transition: background .15s, box-shadow .15s;
|
||||||
|
border-left-width: 3px;
|
||||||
|
}
|
||||||
|
.info-card:hover {
|
||||||
|
background: var(--bg-card-hover);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
.info-card.full-width { flex: 1 1 100%; }
|
||||||
|
|
||||||
|
.card-icon {
|
||||||
|
width: 36px; height: 36px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
font-size: 16px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
opacity: .85;
|
||||||
|
}
|
||||||
|
.card-body { flex: 1; min-width: 0; }
|
||||||
|
.card-body h4 {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
font-size: 13.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.card-body p {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Accents colorés des cartes */
|
||||||
|
.card-accent-blue { border-left-color: var(--accent-blue); }
|
||||||
|
.card-accent-blue .card-icon { background: rgba(88,166,255,.12); color: var(--accent-blue); }
|
||||||
|
.card-accent-cyan { border-left-color: var(--accent-cyan); }
|
||||||
|
.card-accent-cyan .card-icon { background: rgba(57,211,83,.12); color: var(--accent-cyan); }
|
||||||
|
.card-accent-green { border-left-color: var(--accent-green); }
|
||||||
|
.card-accent-green .card-icon { background: rgba(63,185,80,.12); color: var(--accent-green); }
|
||||||
|
.card-accent-teal { border-left-color: var(--accent-teal); }
|
||||||
|
.card-accent-teal .card-icon { background: rgba(45,212,191,.12); color: var(--accent-teal); }
|
||||||
|
.card-accent-orange { border-left-color: var(--accent-orange); }
|
||||||
|
.card-accent-orange .card-icon { background: rgba(240,136,62,.12); color: var(--accent-orange); }
|
||||||
|
.card-accent-yellow { border-left-color: var(--accent-yellow); }
|
||||||
|
.card-accent-yellow .card-icon { background: rgba(227,179,65,.12); color: var(--accent-yellow); }
|
||||||
|
.card-accent-red { border-left-color: var(--accent-red); }
|
||||||
|
.card-accent-red .card-icon { background: rgba(248,81,73,.12); color: var(--accent-red); }
|
||||||
|
.card-accent-purple { border-left-color: var(--accent-purple); }
|
||||||
|
.card-accent-purple .card-icon { background: rgba(188,140,255,.12);color: var(--accent-purple); }
|
||||||
|
.card-accent-indigo { border-left-color: var(--accent-indigo); }
|
||||||
|
.card-accent-indigo .card-icon { background: rgba(129,140,248,.12);color: var(--accent-indigo); }
|
||||||
|
|
||||||
|
/* ─── Lien d'accès ─── */
|
||||||
|
.access-link {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: var(--accent-blue);
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
.access-link .fa-external-link-alt { font-size: 10px; margin-left: 4px; }
|
||||||
|
|
||||||
|
/* ─── Listes documentaires ─── */
|
||||||
|
.doc-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.doc-list li {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 4px 0;
|
||||||
|
border-bottom: 1px solid rgba(48,54,61,.5);
|
||||||
|
}
|
||||||
|
.doc-list li:last-child { border-bottom: none; }
|
||||||
|
.doc-list li i {
|
||||||
|
width: 16px;
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 2px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Tags ─── */
|
||||||
|
.tag-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
.tag-list.inline {
|
||||||
|
display: inline-flex;
|
||||||
|
margin-top: 0;
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
.tag {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.tag-blue { background: rgba(88,166,255,.12); color: var(--accent-blue); border: 1px solid rgba(88,166,255,.2); }
|
||||||
|
.tag-green { background: rgba(63,185,80,.12); color: var(--accent-green); border: 1px solid rgba(63,185,80,.2); }
|
||||||
|
.tag-orange { background: rgba(240,136,62,.12); color: var(--accent-orange); border: 1px solid rgba(240,136,62,.2);}
|
||||||
|
.tag-gray { background: rgba(110,118,129,.12); color: var(--text-secondary);border: 1px solid rgba(110,118,129,.2);}
|
||||||
|
|
||||||
|
/* ─── Grille de puits ─── */
|
||||||
|
.well-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Grille serpentin : 6 colonnes fixes, 4 lignes (D/C/B/A) */
|
||||||
|
.well-grid--serpentine {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(6, 1fr);
|
||||||
|
gap: 5px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.well-chip {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 5px 4px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
text-align: center;
|
||||||
|
letter-spacing: .03em;
|
||||||
|
/* couleur par défaut (teal) */
|
||||||
|
background: rgba(45,212,191,.1);
|
||||||
|
border: 1px solid rgba(45,212,191,.25);
|
||||||
|
color: var(--accent-teal);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Couleurs distinctes par ligne */
|
||||||
|
.wc-d {
|
||||||
|
background: rgba(248,81,73,.10);
|
||||||
|
border-color: rgba(248,81,73,.25);
|
||||||
|
color: var(--accent-red);
|
||||||
|
}
|
||||||
|
.wc-c {
|
||||||
|
background: rgba(240,136,62,.10);
|
||||||
|
border-color: rgba(240,136,62,.25);
|
||||||
|
color: var(--accent-orange);
|
||||||
|
}
|
||||||
|
.wc-b {
|
||||||
|
background: rgba(88,166,255,.10);
|
||||||
|
border-color: rgba(88,166,255,.25);
|
||||||
|
color: var(--accent-blue);
|
||||||
|
}
|
||||||
|
.wc-a {
|
||||||
|
background: rgba(63,185,80,.10);
|
||||||
|
border-color: rgba(63,185,80,.25);
|
||||||
|
color: var(--accent-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Grille de position multi-puits ─── */
|
||||||
|
.position-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 10px 0;
|
||||||
|
}
|
||||||
|
.pos-cell {
|
||||||
|
background: rgba(129,140,248,.08);
|
||||||
|
border: 1px solid rgba(129,140,248,.2);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 8px 10px;
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--accent-indigo);
|
||||||
|
transition: background .15s;
|
||||||
|
}
|
||||||
|
.pos-cell.active:hover {
|
||||||
|
background: rgba(129,140,248,.18);
|
||||||
|
}
|
||||||
|
.pos-cell small {
|
||||||
|
display: block;
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Badges inline ─── */
|
||||||
|
.badge-auto {
|
||||||
|
font-size: 10px;
|
||||||
|
background: rgba(63,185,80,.12);
|
||||||
|
color: var(--accent-green);
|
||||||
|
border: 1px solid rgba(63,185,80,.25);
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 20px;
|
||||||
|
margin-left: 4px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.badge-important {
|
||||||
|
font-size: 10px;
|
||||||
|
background: rgba(248,81,73,.15);
|
||||||
|
color: var(--accent-red);
|
||||||
|
border: 1px solid rgba(248,81,73,.3);
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 20px;
|
||||||
|
margin-left: 4px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Boîte d'alerte ─── */
|
||||||
|
.alert-box {
|
||||||
|
margin-top: 10px;
|
||||||
|
background: rgba(248,81,73,.08);
|
||||||
|
border: 1px solid rgba(248,81,73,.25);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 8px 12px;
|
||||||
|
color: var(--accent-red);
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Description de tâche ─── */
|
||||||
|
.task-desc {
|
||||||
|
margin: 3px 0 0;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Aide contextuelle ─── */
|
||||||
|
.hint {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Code inline ─── */
|
||||||
|
code {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: var(--text-code);
|
||||||
|
background: rgba(88,166,255,.07);
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: 3px;
|
||||||
|
border: 1px solid rgba(88,166,255,.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Couleurs utilitaires ─── */
|
||||||
|
.text-gold { color: #d4a017; }
|
||||||
|
.text-blue { color: var(--accent-blue); }
|
||||||
|
.text-gray { color: var(--text-muted); }
|
||||||
|
.text-green { color: var(--accent-green); }
|
||||||
|
.text-cyan { color: var(--accent-cyan); }
|
||||||
|
|
||||||
|
/* ─── Scrollbar personnalisée ─── */
|
||||||
|
::-webkit-scrollbar { width: 6px; }
|
||||||
|
::-webkit-scrollbar-track { background: var(--bg-base); }
|
||||||
|
::-webkit-scrollbar-thumb { background: var(--border-strong); border-radius: 3px; }
|
||||||
|
::-webkit-scrollbar-thumb:hover { background: var(--text-muted); }
|
||||||
|
|
||||||
|
/* ─── Responsive ─── */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.doc-wrapper {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
grid-template-areas:
|
||||||
|
"header"
|
||||||
|
"content";
|
||||||
|
}
|
||||||
|
.doc-sidenav { display: none; }
|
||||||
|
.doc-content { padding: 16px; }
|
||||||
|
.info-card { min-width: 100%; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
/* ============================================================
|
||||||
|
doc_experiments.css
|
||||||
|
Styles spécifiques à la page "Démarrer des expériences".
|
||||||
|
Dépend de doc_database.css + doc_calibration.css.
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
/* ─── Badges supplémentaires ─── */
|
||||||
|
.badge-green {
|
||||||
|
background: rgba(63,185,80,.12);
|
||||||
|
color: var(--accent-green);
|
||||||
|
border: 1px solid rgba(63,185,80,.3);
|
||||||
|
}
|
||||||
|
.badge-purple {
|
||||||
|
background: rgba(188,140,255,.12);
|
||||||
|
color: var(--accent-purple);
|
||||||
|
border: 1px solid rgba(188,140,255,.3);
|
||||||
|
}
|
||||||
|
.badge-orange {
|
||||||
|
background: rgba(240,136,62,.12);
|
||||||
|
color: var(--accent-orange);
|
||||||
|
border: 1px solid rgba(240,136,62,.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Tags supplémentaires ─── */
|
||||||
|
.tag-purple {
|
||||||
|
background: rgba(188,140,255,.12);
|
||||||
|
color: var(--accent-purple);
|
||||||
|
border: 1px solid rgba(188,140,255,.2);
|
||||||
|
}
|
||||||
|
.tag-yellow {
|
||||||
|
background: rgba(227,179,65,.12);
|
||||||
|
color: var(--accent-yellow);
|
||||||
|
border: 1px solid rgba(227,179,65,.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Étapes numérotées ─── */
|
||||||
|
ol.doc-steps {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 8px 0 0;
|
||||||
|
counter-reset: step-counter;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
ol.doc-steps li {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
counter-increment: step-counter;
|
||||||
|
padding: 6px 8px;
|
||||||
|
background: rgba(255,255,255,.02);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
transition: background .12s;
|
||||||
|
}
|
||||||
|
ol.doc-steps li:hover {
|
||||||
|
background: rgba(255,255,255,.05);
|
||||||
|
}
|
||||||
|
ol.doc-steps li::before {
|
||||||
|
content: counter(step-counter);
|
||||||
|
min-width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(88,166,255,.15);
|
||||||
|
color: var(--accent-blue);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: 1px;
|
||||||
|
}
|
||||||
|
ol.doc-steps li i {
|
||||||
|
width: 16px;
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 2px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Alert info (bleu, non critique) ─── */
|
||||||
|
.alert-box.alert-info {
|
||||||
|
background: rgba(88,166,255,.08);
|
||||||
|
border-color: rgba(88,166,255,.25);
|
||||||
|
color: var(--accent-blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Titre de groupe de métriques ─── */
|
||||||
|
.metrics-group-title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin: 16px 0 10px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .05em;
|
||||||
|
}
|
||||||
|
.metrics-group-title i {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Cartes métriques : padding réduit ─── */
|
||||||
|
.metrics-card {
|
||||||
|
min-width: 180px;
|
||||||
|
}
|
||||||
|
.metrics-card .card-body h4 {
|
||||||
|
font-size: 13px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Tags métriques : inline compact ─── */
|
||||||
|
.metric-tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 5px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
.metric-tags .tag {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
/* ============================================================
|
||||||
|
doc_media.css
|
||||||
|
Styles spécifiques à la page "Gestionnaire de médias".
|
||||||
|
Dépend de doc_database.css + doc_calibration.css
|
||||||
|
+ doc_experiments.css
|
||||||
|
+ doc_scanning.css
|
||||||
|
+ doc_results.css.
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
/* ─── Badge purple ─── */
|
||||||
|
.badge-purple {
|
||||||
|
background: rgba(188,140,255,.12);
|
||||||
|
color: var(--accent-purple);
|
||||||
|
border: 1px solid rgba(188,140,255,.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Prévisualisation grille ─── */
|
||||||
|
.grid-preview {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 14px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gp-col {
|
||||||
|
width: 28px; height: 28px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(188,140,255,.12);
|
||||||
|
border: 1px solid rgba(188,140,255,.25);
|
||||||
|
color: var(--accent-purple);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Chaque démo de grille */
|
||||||
|
.gp-demo {
|
||||||
|
display: grid;
|
||||||
|
gap: 3px;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 60px;
|
||||||
|
max-width: 120px;
|
||||||
|
}
|
||||||
|
.gp-demo span {
|
||||||
|
height: 22px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: rgba(188,140,255,.15);
|
||||||
|
border: 1px solid rgba(188,140,255,.2);
|
||||||
|
}
|
||||||
|
.gp-2 { grid-template-columns: repeat(2, 1fr); }
|
||||||
|
.gp-3 { grid-template-columns: repeat(3, 1fr); }
|
||||||
|
.gp-4 { grid-template-columns: repeat(4, 1fr); }
|
||||||
|
|
||||||
|
/* ─── Contrôles lecteur vidéo ─── */
|
||||||
|
.player-controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-btn {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 60px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
transition: background .15s;
|
||||||
|
}
|
||||||
|
.player-btn i { font-size: 16px; }
|
||||||
|
.player-btn span { color: var(--text-secondary); }
|
||||||
|
|
||||||
|
.btn-play {
|
||||||
|
background: rgba(63,185,80,.08);
|
||||||
|
border-color: rgba(63,185,80,.25);
|
||||||
|
color: var(--accent-green);
|
||||||
|
}
|
||||||
|
.btn-play:hover { background: rgba(63,185,80,.16); }
|
||||||
|
|
||||||
|
.btn-pause {
|
||||||
|
background: rgba(227,179,65,.08);
|
||||||
|
border-color: rgba(227,179,65,.25);
|
||||||
|
color: var(--accent-yellow);
|
||||||
|
}
|
||||||
|
.btn-pause:hover { background: rgba(227,179,65,.16); }
|
||||||
|
|
||||||
|
.btn-stop {
|
||||||
|
background: rgba(248,81,73,.08);
|
||||||
|
border-color: rgba(248,81,73,.25);
|
||||||
|
color: var(--accent-red);
|
||||||
|
}
|
||||||
|
.btn-stop:hover { background: rgba(248,81,73,.16); }
|
||||||
|
|
||||||
|
/* ─── Badge export différé ─── */
|
||||||
|
.deferred-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 10px;
|
||||||
|
padding: 5px 12px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
background: rgba(45,212,191,.08);
|
||||||
|
border: 1px solid rgba(45,212,191,.25);
|
||||||
|
color: var(--accent-teal);
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
/* ============================================================
|
||||||
|
doc_results.css
|
||||||
|
Styles spécifiques à la page "Exploiter les résultats".
|
||||||
|
Dépend de doc_database.css + doc_calibration.css
|
||||||
|
+ doc_experiments.css
|
||||||
|
+ doc_scanning.css.
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
/* ─── Bloc chemin serveur ─── */
|
||||||
|
.path-block {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 10px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: rgba(45,212,191,.06);
|
||||||
|
border: 1px solid rgba(45,212,191,.2);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--accent-teal);
|
||||||
|
}
|
||||||
|
.path-block i {
|
||||||
|
font-size: 16px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.path-block code {
|
||||||
|
color: var(--accent-teal);
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
font-size: 13px;
|
||||||
|
letter-spacing: .03em;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
/* ============================================================
|
||||||
|
doc_scanning.css
|
||||||
|
Styles spécifiques à la page "Balayage des puits".
|
||||||
|
Dépend de doc_database.css + doc_calibration.css
|
||||||
|
+ doc_experiments.css.
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
/* ─── Badge indigo ─── */
|
||||||
|
.badge-indigo {
|
||||||
|
background: rgba(129,140,248,.12);
|
||||||
|
color: var(--accent-indigo);
|
||||||
|
border: 1px solid rgba(129,140,248,.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Badge red ─── */
|
||||||
|
.badge-red {
|
||||||
|
background: rgba(248,81,73,.12);
|
||||||
|
color: var(--accent-red);
|
||||||
|
border: 1px solid rgba(248,81,73,.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Badge superadmin ─── */
|
||||||
|
.badge-superadmin {
|
||||||
|
font-size: 10px;
|
||||||
|
background: rgba(188,140,255,.15);
|
||||||
|
color: var(--accent-purple);
|
||||||
|
border: 1px solid rgba(188,140,255,.3);
|
||||||
|
padding: 1px 8px;
|
||||||
|
border-radius: 20px;
|
||||||
|
margin-left: 6px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Exemple UUID ─── */
|
||||||
|
.uuid-example {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 10px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: rgba(227,179,65,.06);
|
||||||
|
border: 1px dashed rgba(227,179,65,.25);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--accent-yellow);
|
||||||
|
font-size: 12.5px;
|
||||||
|
}
|
||||||
|
.uuid-example code {
|
||||||
|
color: var(--accent-yellow);
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
font-size: 12.5px;
|
||||||
|
letter-spacing: .04em;
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
/*
|
/*
|
||||||
** custom.js
|
** custom.js
|
||||||
** GNU GENERAL PUBLIC LICENSE: (c) DD miraceti.net return document.querySelectorAll(id);
|
** GNU GENERAL PUBLIC LICENSE: (c) DD miraceti.net
|
||||||
*/
|
*/
|
||||||
let s = function(sel) { return document.querySelector(sel); };
|
let s = function(sel) { return document.querySelector(sel); };
|
||||||
let ss = function(sel) { return document.querySelectorAll(sel); };
|
let ss = function(sel) { return document.querySelectorAll(sel); };
|
||||||
@@ -40,3 +40,13 @@ function timestampToLocalISOString(timestamp) {
|
|||||||
return toLocalISOString(date);
|
return toLocalISOString(date);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getCookie(name) {
|
||||||
|
const match = document.cookie.match('(?:^|; )' + name + '=([^;]*)');
|
||||||
|
return match ? decodeURIComponent(match[1]) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function csrfFetch(url, options = {}) {
|
||||||
|
options.headers = { ...(options.headers || {}), 'X-CSRFToken': getCookie('csrftoken') };
|
||||||
|
return fetch(url, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,473 @@
|
|||||||
|
{% extends 'scanner/base.html' %}
|
||||||
|
{% load i18n %}
|
||||||
|
{% block columns %}{% endblock %}
|
||||||
|
|
||||||
|
{% block styles %}
|
||||||
|
{{ block.super }}
|
||||||
|
<link rel="stylesheet" href="/static/css/doc_database.css">
|
||||||
|
<link rel="stylesheet" href="/static/css/doc_calibration.css">
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="doc-wrapper">
|
||||||
|
|
||||||
|
<!-- ─── En-tête ─── -->
|
||||||
|
<header class="doc-header">
|
||||||
|
<div class="doc-header-inner">
|
||||||
|
<div class="doc-header-icon" style="background: linear-gradient(135deg, var(--accent-teal), var(--accent-blue));">
|
||||||
|
<i class="fas fa-crosshairs"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 class="doc-title">{% trans "Calibration du scanner" %}</h1>
|
||||||
|
<p class="doc-subtitle">{% trans "Procédure, organisation de l'écran et commandes disponibles" %}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- ─── Navigation latérale ─── -->
|
||||||
|
<nav class="doc-sidenav" id="sidenav">
|
||||||
|
<p class="nav-section-label">{% trans "Navigation" %}</p>
|
||||||
|
<a href="#section-intro" class="nav-link active"><i class="fas fa-info-circle"></i> {% trans "Présentation" %}</a>
|
||||||
|
<a href="#section-screen" class="nav-link"> <i class="fas fa-desktop"></i> {% trans "Organisation écran" %}</a>
|
||||||
|
<a href="#section-left" class="nav-link nav-sub"><i class="fas fa-arrows-alt"></i> {% trans "Panneau gauche" %}</a>
|
||||||
|
<a href="#section-center" class="nav-link nav-sub"><i class="fas fa-video"></i> {% trans "Centre — vidéo live" %}</a>
|
||||||
|
<a href="#section-right" class="nav-link nav-sub"><i class="fas fa-sliders-h"></i> {% trans "Panneau droit" %}</a>
|
||||||
|
<a href="#section-centering" class="nav-link nav-sub-2"><i class="fas fa-dot-circle"></i> {% trans "Centrage" %}</a>
|
||||||
|
<a href="#section-base" class="nav-link nav-sub-2"><i class="fas fa-map-pin"></i> {% trans "Définir la base" %}</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- ─── Contenu principal ─── -->
|
||||||
|
<main class="doc-content">
|
||||||
|
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<!-- SECTION 1 : Présentation -->
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<section class="doc-section" id="section-intro">
|
||||||
|
<div class="section-header">
|
||||||
|
<span class="section-badge badge-teal">{% trans "Prérequis" %}</span>
|
||||||
|
<h2><i class="fas fa-crosshairs"></i> {% trans "Calibration" %}</h2>
|
||||||
|
<p class="section-desc">
|
||||||
|
{% trans "La calibration est indispensable au fonctionnement du scanner." %}<br>
|
||||||
|
<i class="fas fa-exclamation-triangle w3-text-red"></i> {% trans "Affichage des logs en bas de page" %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="info-row">
|
||||||
|
<!-- Pourquoi calibrer -->
|
||||||
|
<div class="info-card card-accent-teal">
|
||||||
|
<div class="card-icon"><i class="fas fa-bullseye"></i></div>
|
||||||
|
<div class="card-body">
|
||||||
|
<h4>{% trans "Objectifs" %}</h4>
|
||||||
|
<ul class="doc-list">
|
||||||
|
<li>
|
||||||
|
<i class="fas fa-camera text-blue"></i>
|
||||||
|
{% trans "Pointer l'axe de la caméra dans l'axe du tube." %}
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<i class="fas fa-crop-alt text-cyan"></i>
|
||||||
|
{% trans "Recadrer l'image pour porter l'origine du suivi des planaires au centre du tube." %}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Avantage recadrage -->
|
||||||
|
<div class="info-card card-accent-green">
|
||||||
|
<div class="card-icon"><i class="fas fa-compress-arrows-alt"></i></div>
|
||||||
|
<div class="card-body">
|
||||||
|
<h4>{% trans "Avantage du recadrage" %}</h4>
|
||||||
|
<ul class="doc-list">
|
||||||
|
<li>
|
||||||
|
<i class="fas fa-hdd text-green"></i>
|
||||||
|
{% trans "Réduit la taille des images stockées." %}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- IMPORTANT : base de départ -->
|
||||||
|
<div class="info-row">
|
||||||
|
<div class="info-card card-accent-red full-width">
|
||||||
|
<div class="card-icon"><i class="fas fa-exclamation-triangle"></i></div>
|
||||||
|
<div class="card-body">
|
||||||
|
<h4>{% trans "IMPORTANT — Avant toute manipulation" %} <span class="badge-important">{% trans "IMPORTANT" %}</span></h4>
|
||||||
|
<p>{% trans "Pour tout multi-puits, trouver la base de départ avant de commencer." %}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<!-- SECTION 2 : Organisation de l'écran -->
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<section class="doc-section" id="section-screen">
|
||||||
|
<div class="section-header">
|
||||||
|
<span class="section-badge badge-blue">{% trans "Interface" %}</span>
|
||||||
|
<h2><i class="fas fa-desktop"></i> {% trans "Organisation de l'écran" %}</h2>
|
||||||
|
<p class="section-desc">{% trans "L'écran de calibration est divisé en trois zones fonctionnelles." %}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Schéma des 3 zones -->
|
||||||
|
<div class="screen-layout">
|
||||||
|
<div class="screen-zone zone-left">
|
||||||
|
<i class="fas fa-arrows-alt"></i>
|
||||||
|
<span>{% trans "Déplacements" %}</span>
|
||||||
|
</div>
|
||||||
|
<div class="screen-zone zone-center">
|
||||||
|
<i class="fas fa-video"></i>
|
||||||
|
<span>{% trans "Vidéo live" %}</span>
|
||||||
|
</div>
|
||||||
|
<div class="screen-zone zone-right">
|
||||||
|
<i class="fas fa-sliders-h"></i>
|
||||||
|
<span>{% trans "Commandes" %}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ─── Panneau GAUCHE ─── -->
|
||||||
|
<div class="subsection" id="section-left">
|
||||||
|
<h3 class="subsection-title">
|
||||||
|
<i class="fas fa-arrow-left"></i> {% trans "Panneau gauche — Déplacements caméra" %}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div class="info-row">
|
||||||
|
<!-- Sélection & vitesse -->
|
||||||
|
<div class="info-card card-accent-indigo">
|
||||||
|
<div class="card-icon"><i class="fas fa-exchange-alt"></i></div>
|
||||||
|
<div class="card-body">
|
||||||
|
<h4>{% trans "Sélection & vitesse" %}</h4>
|
||||||
|
<ul class="doc-list">
|
||||||
|
<li><i class="fas fa-th"></i> {% trans "Changer de multi-puits" %}</li>
|
||||||
|
<li><i class="fas fa-tachometer-alt"></i> {% trans "Modifier la vitesse (mm/mn)" %}</li>
|
||||||
|
<li><i class="fas fa-ruler-horizontal"></i> {% trans "Modifier le pas de déplacement (mm)" %}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Manœuvre manuelle -->
|
||||||
|
<div class="info-card card-accent-blue">
|
||||||
|
<div class="card-icon"><i class="fas fa-gamepad"></i></div>
|
||||||
|
<div class="card-body">
|
||||||
|
<h4>{% trans "Manœuvre manuelle" %}</h4>
|
||||||
|
<ul class="doc-list">
|
||||||
|
<li>
|
||||||
|
<i class="fas fa-arrows-alt"></i>
|
||||||
|
{% trans "Déplacement" %}
|
||||||
|
<div class="tag-list inline">
|
||||||
|
<span class="tag tag-blue">±X</span>
|
||||||
|
<span class="tag tag-blue">±Y</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li><i class="fas fa-save"></i> {% trans "Sauver la position courante" %}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Déplacement puit à puit -->
|
||||||
|
<div class="info-card card-accent-cyan">
|
||||||
|
<div class="card-icon"><i class="fas fa-step-forward"></i></div>
|
||||||
|
<div class="card-body">
|
||||||
|
<h4>{% trans "Navigation puit à puit" %}</h4>
|
||||||
|
<ul class="doc-list">
|
||||||
|
<li><i class="fas fa-arrow-right text-cyan"></i> {% trans "Boutons de déplacement puit à puit" %}</li>
|
||||||
|
<li><i class="fas fa-compress-arrows-alt text-cyan"></i> {% trans "Bouton centrage manuel" %}</li>
|
||||||
|
<li><i class="fas fa-magic text-cyan"></i> {% trans "Bouton calibrage automatique" %}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sous-section : Définir la base -->
|
||||||
|
<div class="sub-subsection" id="section-base">
|
||||||
|
<h4 class="table-title">
|
||||||
|
<i class="fas fa-map-pin"></i>
|
||||||
|
{% trans "Définir la base" %}
|
||||||
|
<span class="badge-important">{% trans "IMPORTANT" %}</span>
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
<div class="info-row">
|
||||||
|
<!-- Création en BDD -->
|
||||||
|
<div class="info-card card-accent-orange">
|
||||||
|
<div class="card-icon"><i class="fas fa-database"></i></div>
|
||||||
|
<div class="card-body">
|
||||||
|
<h4>{% trans "À la création du multi-puits en base" %}</h4>
|
||||||
|
<ul class="doc-list">
|
||||||
|
<li>
|
||||||
|
<i class="fas fa-map-marker-alt text-orange"></i>
|
||||||
|
{% trans "Fixe la base par défaut en" %} <code>(50, 50)</code>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<i class="fas fa-calculator text-orange"></i>
|
||||||
|
{% trans "Calcule et enregistre toutes les positions des puits dans" %}
|
||||||
|
<code>scanner.wellposition</code>
|
||||||
|
<p class="task-desc">
|
||||||
|
<i class="fas fa-bolt text-green"></i>
|
||||||
|
{% trans "Permet de gagner du temps au centrage manuel ou automatique." %}
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Donnée positions générées -->
|
||||||
|
<div class="info-card card-accent-yellow">
|
||||||
|
<div class="card-icon"><i class="fas fa-sync-alt"></i></div>
|
||||||
|
<div class="card-body">
|
||||||
|
<h4>
|
||||||
|
{% trans "Positions générées" %} — <code>scanner.multiwell</code>
|
||||||
|
<span class="badge-important">{% trans "ATTENTION" %}</span>
|
||||||
|
</h4>
|
||||||
|
<ul class="doc-list">
|
||||||
|
<li>
|
||||||
|
<i class="fas fa-check-circle text-green"></i>
|
||||||
|
{% trans "À la création : donnée fixée à" %} <strong>{% trans "Oui" %}</strong>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<i class="fas fa-times-circle text-red"></i>
|
||||||
|
{% trans "Non → efface" %} <code>WellPosition</code>
|
||||||
|
{% trans "et recalcule toutes les positions" %}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bouton Définir la base -->
|
||||||
|
<div class="info-row">
|
||||||
|
<div class="info-card card-accent-blue full-width">
|
||||||
|
<div class="card-icon"><i class="fas fa-flag-checkered"></i></div>
|
||||||
|
<div class="card-body">
|
||||||
|
<h4>{% trans "Bouton « Définir la base »" %}</h4>
|
||||||
|
<ul class="doc-list">
|
||||||
|
<li>
|
||||||
|
<i class="fas fa-map-marker text-blue"></i>
|
||||||
|
{% trans "Fixe la base aux coordonnées" %} <code>(X, Y)</code> {% trans "courantes" %}
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<i class="fas fa-redo text-blue"></i>
|
||||||
|
{% trans "Recalcule toutes les positions des puits dans" %} <code>scanner.wellposition</code>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sous-section : Centrage -->
|
||||||
|
<div class="sub-subsection" id="section-centering">
|
||||||
|
<h4 class="table-title">
|
||||||
|
<i class="fas fa-dot-circle"></i>
|
||||||
|
{% trans "Centrage manuel / automatique" %}
|
||||||
|
<span class="badge-important">{% trans "ATTENTION" %}</span>
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
<div class="info-row">
|
||||||
|
<!-- Conditions -->
|
||||||
|
<div class="info-card card-accent-purple">
|
||||||
|
<div class="card-icon"><i class="fas fa-lightbulb"></i></div>
|
||||||
|
<div class="card-body">
|
||||||
|
<h4>{% trans "Conditions requises" %}</h4>
|
||||||
|
<ul class="doc-list">
|
||||||
|
<li>
|
||||||
|
<i class="fas fa-sun text-yellow"></i>
|
||||||
|
{% trans "Éclairage important des puits — éviter les ombres." %}
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<i class="fas fa-toggle-on text-blue"></i>
|
||||||
|
{% trans "Actif uniquement si Debug" %} <strong>{% trans "ET" %}</strong> {% trans "Recadrage activés (commandes à droite)." %}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Indicateurs visuels -->
|
||||||
|
<div class="info-card card-accent-indigo">
|
||||||
|
<div class="card-icon"><i class="fas fa-circle-notch"></i></div>
|
||||||
|
<div class="card-body">
|
||||||
|
<h4>{% trans "Indicateurs visuels de centrage" %}</h4>
|
||||||
|
<ul class="doc-list">
|
||||||
|
<li>
|
||||||
|
<i class="fas fa-circle" style="color:#f85149;"></i>
|
||||||
|
<strong class="text-red">{% trans "Cercle rouge" %}</strong>
|
||||||
|
— {% trans "déplacement centre" %} <code>< 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>< 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>< 20 px</span>
|
||||||
|
</div>
|
||||||
|
<div class="cb-seg cb-yellow">
|
||||||
|
<span>< 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> & <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> & <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'">×</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'">×</div>
|
||||||
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Executable
+102
@@ -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
|
||||||
|
|
||||||
@@ -21,11 +21,14 @@ import logging
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional, Callable, TYPE_CHECKING
|
from typing import Optional, Callable, TYPE_CHECKING
|
||||||
|
from asgiref.sync import async_to_sync
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from modules.planarian_tracker import PlanarianTracker
|
from modules.planarian_tracker import PlanarianTracker
|
||||||
|
from modules.planarian_metrics import ExperimentParams, EthoVisionMetrics
|
||||||
from modules.tube_aligner import TubeAligner
|
from modules.tube_aligner import TubeAligner
|
||||||
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from .circular_crop import CircularCrop # Evite l'import circulaire au runtime
|
from .circular_crop import CircularCrop # Evite l'import circulaire au runtime
|
||||||
|
|
||||||
@@ -49,16 +52,27 @@ class VideoCaptureInterface(abc.ABC):
|
|||||||
# Cadence par défaut en images par seconde
|
# Cadence par défaut en images par seconde
|
||||||
DEFAULT_FPS: float = 5.0
|
DEFAULT_FPS: float = 5.0
|
||||||
|
|
||||||
def __init__(self, fps: float = DEFAULT_FPS, use_tracking: bool = False, display=None, parent=None):
|
DEFAULT_TRACKER_CONFIG = dict(
|
||||||
|
tube_axis = settings.TRACKER_TUBE_AXIS,
|
||||||
|
min_area_px = settings.TRACKER_MIN_AREA,
|
||||||
|
max_area_ratio = settings.TRACKER_MAX_AREA_RATIO,
|
||||||
|
max_planarians = settings.TRACKER_MAX_PLANARIANS,
|
||||||
|
merge_kernel_size = settings.TRACKER_MERGE_KERNEL_SIZE,
|
||||||
|
min_contour_dist_px = settings.TRACKER_MIN_CONTOUR_DIST_PX,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def __init__(self, fps: float = DEFAULT_FPS, use_tracking: bool = False, display=None, parent=None, jpeg_quality=85):
|
||||||
"""
|
"""
|
||||||
Initialise l'interface de capture.
|
Initialise l'interface de capture.
|
||||||
|
|
||||||
:param fps: Cadence cible en images par seconde
|
:param fps: Cadence cible en images par seconde
|
||||||
"""
|
"""
|
||||||
self._fps: float = fps
|
self._fps: float = fps
|
||||||
self.use_tracking = use_tracking
|
|
||||||
self.display = display
|
self.display = display
|
||||||
self.parent = parent
|
self.parent = parent
|
||||||
|
self.use_tracking = use_tracking
|
||||||
|
self.jpeg_quality = jpeg_quality
|
||||||
self._interval: float = 1.0 / fps # Intervalle en secondes entre chaque capture
|
self._interval: float = 1.0 / fps # Intervalle en secondes entre chaque capture
|
||||||
self._running: bool = False # Indique si la capture est en cours
|
self._running: bool = False # Indique si la capture est en cours
|
||||||
self._thread: Optional[threading.Thread] = None
|
self._thread: Optional[threading.Thread] = None
|
||||||
@@ -67,27 +81,85 @@ class VideoCaptureInterface(abc.ABC):
|
|||||||
self._circular_crop: Optional["CircularCrop"] = None # Recadrage circulaire optionnel
|
self._circular_crop: Optional["CircularCrop"] = None # Recadrage circulaire optionnel
|
||||||
self._active_median = False
|
self._active_median = False
|
||||||
self._active_crop = False
|
self._active_crop = False
|
||||||
|
self._active_edge_enhance = False
|
||||||
self._error_occured = False
|
self._error_occured = False
|
||||||
|
|
||||||
self._tracker = PlanarianTracker(
|
self._tracker: PlanarianTracker | None = None
|
||||||
tube_axis = settings.TRACKER_TUBE_AXIS,
|
self._metrics: list[EthoVisionMetrics] | None = None
|
||||||
min_area_px = settings.TRACKER_MIN_AREA,
|
self._params: ExperimentParams | None = None
|
||||||
)
|
self._clientDB = self.parent.metricDB
|
||||||
|
|
||||||
|
# Tracker générique, pour simulation
|
||||||
|
self.on_test_well_change(**self.DEFAULT_TRACKER_CONFIG)
|
||||||
|
|
||||||
self._aligner = TubeAligner(
|
self._aligner = TubeAligner(
|
||||||
grbl_threshold_px = 20, # au-delà → correction GRBL
|
grbl_threshold_px = 20, # au-delà → correction GRBL
|
||||||
dead_zone_px = 5, # en-dessous → rien à faire
|
dead_zone_px = 5, # en-dessous → rien à faire
|
||||||
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
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -213,10 +285,30 @@ class VideoCaptureInterface(abc.ABC):
|
|||||||
msg= f"{self.__class__.__name__}: recadrage circulaire désactivé"
|
msg= f"{self.__class__.__name__}: recadrage circulaire désactivé"
|
||||||
|
|
||||||
logger.info(msg)
|
logger.info(msg)
|
||||||
self.display(state='circular_crop', msg=msg)
|
if self.display is not None:
|
||||||
|
self.display(state='circular_crop', msg=msg)
|
||||||
|
|
||||||
|
|
||||||
def process_frame(self, jpeg_bytes: bytes) -> bytes:
|
def set_edge_enhance(self, enabled: bool) -> None:
|
||||||
|
"""Active ou désactive le filtre de mise en évidence des contours (calibration)."""
|
||||||
|
self._active_edge_enhance = enabled
|
||||||
|
logger.info(f"{self.__class__.__name__}: edge_enhance={enabled}")
|
||||||
|
if self.display is not None:
|
||||||
|
self.display(state='edge_enhance', value=enabled, msg=f"Edge enhance: {enabled}")
|
||||||
|
|
||||||
|
def _apply_edge_enhance(self, frame: np.ndarray) -> np.ndarray:
|
||||||
|
"""Overlay Canny vert additif sur l'image originale.
|
||||||
|
Flou fort avant détection pour ne garder que les bords dominants (rebord du puit).
|
||||||
|
"""
|
||||||
|
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
|
||||||
|
blurred = cv2.GaussianBlur(gray, (9, 9), 2)
|
||||||
|
edges = cv2.Canny(blurred, 80, 200)
|
||||||
|
edges = cv2.dilate(edges, np.ones((3, 3), np.uint8), iterations=1)
|
||||||
|
overlay = np.zeros_like(frame)
|
||||||
|
overlay[edges > 0] = [0, 255, 0] # vert sur fond noir
|
||||||
|
return cv2.addWeighted(frame, 1.0, overlay, 1.0, 0) # additif : image inchangée hors bords
|
||||||
|
|
||||||
|
def process_frame(self, jpeg_bytes: bytes) -> tuple[bytes, dict]:
|
||||||
"""
|
"""
|
||||||
Applique le post-traitement configuré sur une image brute.
|
Applique le post-traitement configuré sur une image brute.
|
||||||
|
|
||||||
@@ -227,30 +319,47 @@ class VideoCaptureInterface(abc.ABC):
|
|||||||
:return: Image traitée (JPEG ou PNG selon la stratégie)
|
:return: Image traitée (JPEG ou PNG selon la stratégie)
|
||||||
"""
|
"""
|
||||||
metrics = {"detected": False}
|
metrics = {"detected": False}
|
||||||
|
|
||||||
if self._circular_crop is not None:
|
if self._circular_crop is not None:
|
||||||
jpeg = self._circular_crop.process(jpeg_bytes)
|
jpeg = self._circular_crop.process(jpeg_bytes)
|
||||||
nparr = np.frombuffer(jpeg, np.uint8)
|
nparr = np.frombuffer(jpeg, np.uint8)
|
||||||
frame = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
|
frame = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
|
||||||
if frame is None:
|
if frame is None:
|
||||||
|
return jpeg, metrics
|
||||||
|
try:
|
||||||
|
# Edge enhance sur la frame propre, avant les annotations
|
||||||
|
if self._active_edge_enhance:
|
||||||
|
frame = self._apply_edge_enhance(frame)
|
||||||
|
##
|
||||||
|
# Mode debug (annotations par-dessus)
|
||||||
|
if self._aligner.debug:
|
||||||
|
self.align_detection = self._aligner.detect_tube(frame)
|
||||||
|
annotated = self.align_detection.get('frame_annotated')
|
||||||
|
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
|
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')
|
# Pas de circular crop — appliquer edge enhance si actif
|
||||||
frame = annotated if annotated is not None else frame
|
if self._active_edge_enhance:
|
||||||
|
nparr = np.frombuffer(jpeg_bytes, np.uint8)
|
||||||
# mode racking
|
frame = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
|
||||||
if self.use_tracking:
|
if frame is not None:
|
||||||
ts = datetime.now(timezone.utc).timestamp()
|
frame = self._apply_edge_enhance(frame)
|
||||||
frame, metrics = self._tracker.process(frame, ts)
|
ok, buf = cv2.imencode(".jpg", frame, [cv2.IMWRITE_JPEG_QUALITY, self.jpeg_quality])
|
||||||
|
if ok:
|
||||||
ok, buf = cv2.imencode(".jpg", frame, [cv2.IMWRITE_JPEG_QUALITY, 85])
|
return buf.tobytes(), metrics
|
||||||
if ok:
|
|
||||||
jpeg = buf.tobytes()
|
|
||||||
|
|
||||||
return jpeg, metrics
|
|
||||||
|
|
||||||
return jpeg_bytes, metrics
|
return jpeg_bytes, metrics
|
||||||
|
|
||||||
def save_frame(self, jpeg_bytes: bytes, directory: str = ".", prefix: str = "frame") -> Path:
|
def save_frame(self, jpeg_bytes: bytes, directory: str = ".", prefix: str = "frame") -> Path:
|
||||||
@@ -325,16 +434,12 @@ class VideoCaptureInterface(abc.ABC):
|
|||||||
##
|
##
|
||||||
jpeg, metrics = self.process_frame(jpeg) # Recadrage circulaire si configuré
|
jpeg, metrics = self.process_frame(jpeg) # Recadrage circulaire si configuré
|
||||||
|
|
||||||
metrics.update({
|
|
||||||
"count": self._frame_count,
|
|
||||||
})
|
|
||||||
|
|
||||||
self._frame_count += 1
|
self._frame_count += 1
|
||||||
ts = datetime.now(timezone.utc)
|
ts = datetime.now(timezone.utc)
|
||||||
|
|
||||||
if self._on_frame:
|
if self._on_frame:
|
||||||
try:
|
try:
|
||||||
self._on_frame(jpeg, ts, metrics)
|
self._on_frame(jpeg, ts, metrics, self._frame_count)
|
||||||
except Exception as cb_err: # noqa: BLE001
|
except Exception as cb_err: # noqa: BLE001
|
||||||
logger.error("Erreur dans le callback image : %s", cb_err)
|
logger.error("Erreur dans le callback image : %s", cb_err)
|
||||||
|
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ class CircularCrop:
|
|||||||
|
|
||||||
# Cache du masque pour éviter de le recalculer à chaque frame
|
# Cache du masque pour éviter de le recalculer à chaque frame
|
||||||
self._mask_cache: Optional[np.ndarray] = None
|
self._mask_cache: Optional[np.ndarray] = None
|
||||||
self._mask_shape: Optional[tuple[int, int, int]] = None # (H, W, strategy)
|
self._mask_shape: Optional[tuple[int, ...]] = None # (H, W, cx, cy, radius)
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# API publique
|
# API publique
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import logging
|
|||||||
import serial
|
import serial
|
||||||
import time
|
import time
|
||||||
import threading
|
import threading
|
||||||
|
from typing import Callable, Any
|
||||||
|
|
||||||
|
|
||||||
logging.basicConfig(level=logging.INFO)
|
logging.basicConfig(level=logging.INFO)
|
||||||
@@ -41,11 +42,10 @@ class GRBLController:
|
|||||||
if y_max is not None:
|
if y_max is not None:
|
||||||
self.Y_MAX = y_max
|
self.Y_MAX = y_max
|
||||||
|
|
||||||
self._state = send_callback
|
self._state: Callable[..., Any] = send_callback if send_callback is not None else self._send_msg
|
||||||
if self._state is None:
|
|
||||||
self._state = self._send_msg
|
|
||||||
|
|
||||||
self.x, self.y = 0, 0
|
self.x: float | None = None
|
||||||
|
self.y: float | None = None
|
||||||
|
|
||||||
#self.start_connection()
|
#self.start_connection()
|
||||||
|
|
||||||
@@ -67,8 +67,8 @@ class GRBLController:
|
|||||||
try:
|
try:
|
||||||
self.ser = serial.Serial(self.port, self.baudrate, timeout=self.timeout, exclusive=True)
|
self.ser = serial.Serial(self.port, self.baudrate, timeout=self.timeout, exclusive=True)
|
||||||
# CRITIQUE :
|
# CRITIQUE :
|
||||||
self.ser.setDTR(False)
|
self.ser.setDTR(False) # type: ignore[attr-defined]
|
||||||
self.ser.setRTS(False)
|
self.ser.setRTS(False) # type: ignore[attr-defined]
|
||||||
self.clear_buffer()
|
self.clear_buffer()
|
||||||
|
|
||||||
self._wake_up()
|
self._wake_up()
|
||||||
@@ -192,7 +192,7 @@ class GRBLController:
|
|||||||
|
|
||||||
def move_relative(self, dx=0, dy=0, feed=1000):
|
def move_relative(self, dx=0, dy=0, feed=1000):
|
||||||
x, y = self.get_mpos() # Position actuelle
|
x, y = self.get_mpos() # Position actuelle
|
||||||
self.move_to(x + dx, y + dy, feed=feed)
|
self.move_to((x or 0) + dx, (y or 0) + dy, feed=feed)
|
||||||
|
|
||||||
def move_relative__(self, dx=0, dy=0, feed=1000):
|
def move_relative__(self, dx=0, dy=0, feed=1000):
|
||||||
self.send("G91") # Mode relatif
|
self.send("G91") # Mode relatif
|
||||||
|
|||||||
@@ -0,0 +1,300 @@
|
|||||||
|
'''
|
||||||
|
Simulateur GCode pour tester sans CNC physique.
|
||||||
|
|
||||||
|
GRBLController (simulé):
|
||||||
|
Reproduit fidèlement l'API de grbl.py
|
||||||
|
Simule les mouvements (X, Y) avec délai proportionnel au feed rate
|
||||||
|
Le mode absolu est retenu
|
||||||
|
Aucune dépendance à pyserial
|
||||||
|
|
||||||
|
Created on 07 mai 2026
|
||||||
|
|
||||||
|
@author: denis@miraceti.net
|
||||||
|
'''
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
import threading
|
||||||
|
import math
|
||||||
|
from typing import Callable, Any
|
||||||
|
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class GRBLController:
|
||||||
|
'''
|
||||||
|
Simulateur du contrôleur GRBL 1.1f (L2544 Laser Engraving Machine).
|
||||||
|
API 100% identique à grbl.py — interchangeable sans modifier le code appelant.
|
||||||
|
Les délais de déplacement sont calculés à partir du feed rate et de la distance.
|
||||||
|
'''
|
||||||
|
X_MAX = 350
|
||||||
|
Y_MAX = 250
|
||||||
|
X_MIN = 0
|
||||||
|
Y_MIN = 0
|
||||||
|
|
||||||
|
# Facteur de compression du temps simulé (1.0 = temps réel, 0.1 = 10x plus rapide)
|
||||||
|
TIME_SCALE = 0.1
|
||||||
|
|
||||||
|
def __init__(self, port='/dev/ttyUSB0', baudrate=115200, timeout=1, send_callback=None, x_max=None, y_max=None):
|
||||||
|
logger.info(f"GRBLController SIMULATOR::init begin {port} device port")
|
||||||
|
|
||||||
|
self.port = port
|
||||||
|
self.baudrate = baudrate
|
||||||
|
self.timeout = timeout
|
||||||
|
|
||||||
|
if x_max is not None:
|
||||||
|
self.X_MAX = x_max
|
||||||
|
if y_max is not None:
|
||||||
|
self.Y_MAX = y_max
|
||||||
|
|
||||||
|
self._state: Callable[..., Any] = send_callback if send_callback is not None else self._send_msg
|
||||||
|
|
||||||
|
# Position courante simulée
|
||||||
|
self.x: float | None = None
|
||||||
|
self.y: float | None = None
|
||||||
|
|
||||||
|
# État interne de la machine simulée
|
||||||
|
self._machine_state = 'Idle' # Idle | Run | Alarm
|
||||||
|
self._connected = False
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
# Méthodes utilitaires
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def wait_for(self, delay=1.0):
|
||||||
|
# Applique le facteur de compression temporelle
|
||||||
|
threading.Event().wait(delay * self.TIME_SCALE)
|
||||||
|
|
||||||
|
def _send_msg(self, **msg):
|
||||||
|
# Callback par défaut : simple affichage console
|
||||||
|
print(msg)
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
# Simulation de la couche série (pas de port réel)
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def clear_buffer(self):
|
||||||
|
# Rien à vider : pas de port série physique
|
||||||
|
logger.debug("SIMULATOR::clear_buffer (no-op)")
|
||||||
|
|
||||||
|
def start_connection(self):
|
||||||
|
'''Simule l'ouverture de la connexion série et l'initialisation GRBL.'''
|
||||||
|
logger.info(f"SIMULATOR::start_connection on {self.port} @ {self.baudrate} baud")
|
||||||
|
self._state(state='serial', msg="Grbl 1.1f ['$' for help]")
|
||||||
|
self._connected = True
|
||||||
|
self._wake_up()
|
||||||
|
self._init_machine()
|
||||||
|
logger.info("SIMULATOR::start_connection started")
|
||||||
|
|
||||||
|
def _init_machine(self):
|
||||||
|
# Envoie les commandes d'initialisation (simulées)
|
||||||
|
self.send("G21") # Unités en mm
|
||||||
|
self.send("G90") # Mode absolu
|
||||||
|
|
||||||
|
def _clamp(self, x, y):
|
||||||
|
self.clear_buffer()
|
||||||
|
x = max(self.X_MIN, min(self.X_MAX, x))
|
||||||
|
y = max(self.Y_MIN, min(self.Y_MAX, y))
|
||||||
|
return x, y
|
||||||
|
|
||||||
|
def _wake_up(self):
|
||||||
|
# Simule l'envoi des octets de réveil et la réponse GRBL
|
||||||
|
logger.debug("SIMULATOR::_wake_up")
|
||||||
|
self.wait_for(1)
|
||||||
|
self._state(state='serial', msg="") # ligne vide typique de GRBL au démarrage
|
||||||
|
self.clear_buffer()
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
# Envoi de commandes
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def send(self, cmd, wait_ok=True, timeout=5):
|
||||||
|
try:
|
||||||
|
return self._send(cmd, wait_ok, timeout)
|
||||||
|
except Exception as e:
|
||||||
|
self._state(state='error', msg=f"Error send {cmd} command: {e}")
|
||||||
|
self.close()
|
||||||
|
self.start_connection()
|
||||||
|
|
||||||
|
def recover(self):
|
||||||
|
self._state(state='recover', msg="Erreur, récupération de GRBL...")
|
||||||
|
self.wait_for(1)
|
||||||
|
self._wake_up()
|
||||||
|
|
||||||
|
def _send(self, cmd, wait_ok=True, timeout=5):
|
||||||
|
'''Simule l'envoi d'une commande GCode et retourne "ok".'''
|
||||||
|
self._state(state='send', msg=f">>> {cmd}")
|
||||||
|
logger.debug(f"SIMULATOR::_send {cmd}")
|
||||||
|
|
||||||
|
# Interprète les commandes de mouvement pour mettre à jour la position interne
|
||||||
|
self._interpret_gcode(cmd)
|
||||||
|
|
||||||
|
if not wait_ok:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Simule une réponse "ok" immédiate
|
||||||
|
return "ok"
|
||||||
|
|
||||||
|
def _interpret_gcode(self, cmd):
|
||||||
|
'''
|
||||||
|
Analyse le GCode pour mettre à jour x, y et simuler le délai de déplacement.
|
||||||
|
Gère : G0, G1, G53 G1, G92, G21, G90, G91, $X, $H.
|
||||||
|
'''
|
||||||
|
cmd_upper = cmd.strip().upper()
|
||||||
|
|
||||||
|
# --- Commandes sans mouvement ---
|
||||||
|
if cmd_upper in ("G21", "G90", "G91", "$X"):
|
||||||
|
return
|
||||||
|
if cmd_upper == "$H":
|
||||||
|
# Homing : retour à l'origine avec délai simulé
|
||||||
|
self._machine_state = 'Run'
|
||||||
|
self._state(state='send', msg="SIMULATOR: homing...")
|
||||||
|
distance = math.hypot(self.x or 0.0, self.y or 0.0)
|
||||||
|
self._simulate_move_delay(distance, feed=3000)
|
||||||
|
self.x, self.y = 0.0, 0.0
|
||||||
|
self._machine_state = 'Idle'
|
||||||
|
return
|
||||||
|
|
||||||
|
# --- Extraction des coordonnées X, Y et du feed F ---
|
||||||
|
tokens = cmd_upper.replace(',', ' ').split()
|
||||||
|
new_x: float = self.x or 0.0
|
||||||
|
new_y: float = self.y or 0.0
|
||||||
|
feed: float = 1000.0
|
||||||
|
|
||||||
|
for token in tokens:
|
||||||
|
if token.startswith('X'):
|
||||||
|
try:
|
||||||
|
new_x = float(token[1:])
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
elif token.startswith('Y'):
|
||||||
|
try:
|
||||||
|
new_y = float(token[1:])
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
elif token.startswith('F'):
|
||||||
|
try:
|
||||||
|
feed = float(token[1:])
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# --- G92 : redéfinit la position courante sans déplacement ---
|
||||||
|
if 'G92' in tokens:
|
||||||
|
self.x = new_x
|
||||||
|
self.y = new_y
|
||||||
|
logger.debug(f"SIMULATOR: G92 position set to ({self.x:.2f}, {self.y:.2f})")
|
||||||
|
return
|
||||||
|
|
||||||
|
# --- Mouvement effectif (G0, G1, G53 G1, etc.) ---
|
||||||
|
has_move = any(t in tokens for t in ('G0', 'G1', 'G53'))
|
||||||
|
cur_x = self.x or 0.0
|
||||||
|
cur_y = self.y or 0.0
|
||||||
|
if has_move and (new_x != cur_x or new_y != cur_y):
|
||||||
|
distance = math.hypot(new_x - cur_x, new_y - cur_y)
|
||||||
|
self._machine_state = 'Run'
|
||||||
|
self._simulate_move_delay(distance, feed)
|
||||||
|
self.x = new_x
|
||||||
|
self.y = new_y
|
||||||
|
self._machine_state = 'Idle'
|
||||||
|
logger.debug(f"SIMULATOR: moved to ({self.x:.2f}, {self.y:.2f})")
|
||||||
|
|
||||||
|
def _simulate_move_delay(self, distance_mm, feed):
|
||||||
|
'''Simule le temps de déplacement : distance / feed (mm/min) → secondes.'''
|
||||||
|
if feed <= 0:
|
||||||
|
return
|
||||||
|
duration = (distance_mm / feed) * 60.0 # feed est en mm/min
|
||||||
|
self.wait_for(duration)
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
# Status machine
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def get_status(self):
|
||||||
|
'''Retourne un status GRBL simulé au format <State|MPos:x,y,z>.'''
|
||||||
|
x = self.x or 0.0
|
||||||
|
y = self.y or 0.0
|
||||||
|
status = f"<{self._machine_state}|MPos:{x:.3f},{y:.3f},0.000|FS:0,0>"
|
||||||
|
logger.debug(f"SIMULATOR::get_status → {status}")
|
||||||
|
return status
|
||||||
|
|
||||||
|
def reset_grbl(self):
|
||||||
|
self.send("$X") # Réinitialise les alarmes
|
||||||
|
self.wait_idle()
|
||||||
|
self.send("$H") # Homing
|
||||||
|
self.wait_idle()
|
||||||
|
|
||||||
|
def _mpos(self, status):
|
||||||
|
if "MPos" in status:
|
||||||
|
mpos = status.split("MPos:")[1].split("|")[0]
|
||||||
|
x, y, *_ = mpos.split(",")
|
||||||
|
self._state(state='Mpos', msg=f"pos >>> ({x}, {y})")
|
||||||
|
return float(x), float(y)
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
def get_mpos(self):
|
||||||
|
return self._mpos(self.get_status())
|
||||||
|
|
||||||
|
def wait_idle(self, timeout=20):
|
||||||
|
'''Attend que la machine soit à l'état Idle (immédiat en simulation).'''
|
||||||
|
start = time.time()
|
||||||
|
while True:
|
||||||
|
if time.time() - start > timeout:
|
||||||
|
raise TimeoutError("Délai d'attente pour Idle dépassé")
|
||||||
|
status = self.get_status()
|
||||||
|
self.x, self.y = self._mpos(status)
|
||||||
|
self._state(xy=True, x=self.x, y=self.y)
|
||||||
|
if status and "Idle" in status:
|
||||||
|
break
|
||||||
|
self.wait_for(0.1)
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
# Commandes de haut niveau (identiques à grbl.py)
|
||||||
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def send_command(self, cmd):
|
||||||
|
self.send(cmd)
|
||||||
|
self.wait_idle()
|
||||||
|
|
||||||
|
def move_to(self, x, y, feed=1000):
|
||||||
|
x, y = self._clamp(x, y)
|
||||||
|
cmd = f"G53 G1 X{x:.2f} Y{y:.2f} F{feed}"
|
||||||
|
self.send_command(cmd)
|
||||||
|
|
||||||
|
def move_relative(self, dx=0, dy=0, feed=1000):
|
||||||
|
x, y = self.get_mpos() # Position actuelle
|
||||||
|
self.move_to((x or 0.0) + dx, (y or 0.0) + dy, feed=feed)
|
||||||
|
|
||||||
|
def move_relative__(self, dx=0, dy=0, feed=1000):
|
||||||
|
self.send("G91") # Mode relatif
|
||||||
|
cmd = f"G0 X{dx} Y{dy} F{feed}"
|
||||||
|
self.send(cmd)
|
||||||
|
self.send("G90") # Retour en mode absolu
|
||||||
|
self.wait_idle()
|
||||||
|
|
||||||
|
def go_origin(self, feed=1000):
|
||||||
|
self.move_to(0, 0, feed=feed)
|
||||||
|
self.wait_for(2.0)
|
||||||
|
|
||||||
|
def set_position(self, x, y):
|
||||||
|
x, y = self._clamp(x, y)
|
||||||
|
cmd = f"G92 X{x:.2f} Y{y:.2f}"
|
||||||
|
self.send(cmd)
|
||||||
|
self.wait_for(2.0)
|
||||||
|
|
||||||
|
def move_up(self, step=10, feed=1000):
|
||||||
|
self.move_relative(dy=step, feed=feed)
|
||||||
|
|
||||||
|
def move_down(self, step=10, feed=1000):
|
||||||
|
self.move_relative(dy=-step, feed=feed)
|
||||||
|
|
||||||
|
def move_left(self, step=10, feed=1000):
|
||||||
|
self.move_relative(dx=-step, feed=feed)
|
||||||
|
|
||||||
|
def move_right(self, step=10, feed=1000):
|
||||||
|
self.move_relative(dx=step, feed=feed)
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
# Simule la fermeture du port série
|
||||||
|
self._connected = False
|
||||||
|
logger.info("SIMULATOR::close — connexion simulée fermée")
|
||||||
@@ -59,7 +59,7 @@ class PiCamera2Capture(VideoCaptureInterface):
|
|||||||
:param use_video_config: True = VideoConfiguration (flux continu, basse latence)
|
:param use_video_config: True = VideoConfiguration (flux continu, basse latence)
|
||||||
False = StillConfiguration (haute résolution, plus lent)
|
False = StillConfiguration (haute résolution, plus lent)
|
||||||
"""
|
"""
|
||||||
super().__init__(fps=fps, use_tracking=use_tracking, display=display, parent=parent)
|
super().__init__(fps=fps, use_tracking=use_tracking, display=display, parent=parent, jpeg_quality=jpeg_quality)
|
||||||
self._width: int = width
|
self._width: int = width
|
||||||
self._height: int = height
|
self._height: int = height
|
||||||
self._jpeg_quality: int = jpeg_quality
|
self._jpeg_quality: int = jpeg_quality
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,36 +1,221 @@
|
|||||||
# modules/planarian_tracker.py
|
"""
|
||||||
'''
|
modules/planarian_tracker.py
|
||||||
Created on 16 avr. 2026
|
|
||||||
|
|
||||||
|
Détection et suivi multi-individus de planaires dans un tube.
|
||||||
|
Supporte de 1 à MAX_PLANARIANS planaires par tube.
|
||||||
|
|
||||||
|
Stratégie :
|
||||||
|
- Soustraction de fond MOG2 (léger sur Raspberry Pi 4)
|
||||||
|
- Détection de tous les contours valides (surface >= min_area_px)
|
||||||
|
- Association frame-à-frame par distance euclidienne minimale
|
||||||
|
via algorithme hongrois (scipy.optimize.linear_sum_assignment)
|
||||||
|
- Un état inter-frame indépendant par individu (PlanarianState)
|
||||||
|
- Retourne une liste de résultats, un par individu suivi
|
||||||
|
|
||||||
|
Created on 25 avr. 2026
|
||||||
@author: denis
|
@author: denis
|
||||||
'''
|
"""
|
||||||
|
|
||||||
import cv2
|
import cv2
|
||||||
import logging
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
|
from scipy.optimize import linear_sum_assignment
|
||||||
|
import logging
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Nombre maximum de planaires suivis simultanément par tube
|
||||||
|
MAX_PLANARIANS = 10
|
||||||
|
|
||||||
|
# Distance maximale en pixels entre deux positions consécutives
|
||||||
|
# pour qu'une association soit acceptée (évite les sauts aberrants)
|
||||||
|
MAX_ASSOC_DIST_PX = 80
|
||||||
|
|
||||||
|
# Couleurs d'annotation par individu (BGR)
|
||||||
|
# Cycle automatique si plus de planaires que de couleurs
|
||||||
|
INDIVIDUAL_COLORS = [
|
||||||
|
(255, 255, 0), # cyan
|
||||||
|
( 0, 165, 255), # orange
|
||||||
|
(255, 0, 255), # magenta
|
||||||
|
( 0, 255, 255), # jaune
|
||||||
|
(128, 0, 255), # violet
|
||||||
|
( 0, 255, 128), # vert clair
|
||||||
|
(255, 128, 0), # bleu clair
|
||||||
|
( 0, 128, 255), # orange foncé
|
||||||
|
(128, 255, 0), # vert-jaune
|
||||||
|
(255, 0, 128), # rose
|
||||||
|
]
|
||||||
|
|
||||||
|
# Couleur du contour principal (individu le plus grand)
|
||||||
|
COLOR_LARGEST = (255, 255, 0) # cyan
|
||||||
|
COLOR_OTHER = ( 0, 255, 0) # vert
|
||||||
|
COLOR_CENTER = ( 0, 0, 255) # rouge
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# État inter-frame d'un individu
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class PlanarianState:
|
||||||
|
"""
|
||||||
|
Mémorise la position et le timestamp de la dernière détection
|
||||||
|
pour un planaire individuel.
|
||||||
|
|
||||||
|
Un PlanarianState par slot (index 0 à max_planarians-1).
|
||||||
|
Quand un slot n'est pas associé à un contour sur plusieurs frames
|
||||||
|
consécutives, il est marqué comme perdu (lost).
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Nombre de frames sans détection avant de considérer l'individu perdu
|
||||||
|
MAX_LOST_FRAMES = 5
|
||||||
|
|
||||||
|
def __init__(self, idx: int):
|
||||||
|
"""
|
||||||
|
Args:
|
||||||
|
idx : index de l'individu (0-based)
|
||||||
|
"""
|
||||||
|
self.idx: int = idx
|
||||||
|
self.cx: int | None = None
|
||||||
|
self.cy: int | None = None
|
||||||
|
self.ts: float | None = None
|
||||||
|
self.lost: int = 0 # compteur de frames sans détection
|
||||||
|
self.active: bool = False # vrai si l'individu a été détecté au moins une fois
|
||||||
|
|
||||||
|
def update(self, cx: int, cy: int, ts: float):
|
||||||
|
"""
|
||||||
|
Met à jour la position suite à une association réussie.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
cx, cy : position du centre de masse en pixels
|
||||||
|
ts : timestamp de la frame
|
||||||
|
"""
|
||||||
|
self.cx = cx
|
||||||
|
self.cy = cy
|
||||||
|
self.ts = ts
|
||||||
|
self.lost = 0
|
||||||
|
self.active = True
|
||||||
|
|
||||||
|
def mark_lost(self):
|
||||||
|
"""Incrémente le compteur de perte — appelé quand aucun contour n'est associé."""
|
||||||
|
self.lost += 1
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_lost(self) -> bool:
|
||||||
|
"""Retourne True si l'individu est considéré perdu (trop de frames sans détection)."""
|
||||||
|
return self.lost >= self.MAX_LOST_FRAMES
|
||||||
|
|
||||||
|
def compute_speed(self, cx: int, cy: int, ts: float, tube_axis: str) -> tuple:
|
||||||
|
"""
|
||||||
|
Calcule la vitesse instantanée depuis la position précédente.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
cx, cy : position courante en pixels
|
||||||
|
ts : timestamp courant
|
||||||
|
tube_axis : "vertical" ou "horizontal"
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple (speed_px_s, axial_speed) ou (0.0, 0.0) si état vide
|
||||||
|
"""
|
||||||
|
if self.cx is None or self.cy is None or self.ts is None:
|
||||||
|
return 0.0, 0.0
|
||||||
|
|
||||||
|
dt = ts - self.ts
|
||||||
|
if dt <= 0:
|
||||||
|
return 0.0, 0.0
|
||||||
|
|
||||||
|
dx = cx - self.cx
|
||||||
|
dy = cy - self.cy
|
||||||
|
speed_px_s = float(np.sqrt(dx**2 + dy**2) / dt)
|
||||||
|
axial_speed = float((dy / dt) if tube_axis == "vertical" else (dx / dt))
|
||||||
|
|
||||||
|
return speed_px_s, axial_speed
|
||||||
|
|
||||||
|
def reset(self):
|
||||||
|
"""Réinitialise l'état de cet individu."""
|
||||||
|
self.cx = None
|
||||||
|
self.cy = None
|
||||||
|
self.ts = None
|
||||||
|
self.lost = 0
|
||||||
|
self.active = False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tracker multi-individus
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
class PlanarianTracker:
|
class PlanarianTracker:
|
||||||
"""
|
"""
|
||||||
Détection et suivi d'une planaire dans un tube.
|
Détection et suivi multi-individus de planaires dans un tube.
|
||||||
|
|
||||||
Instancié une fois par caméra active, réutilisé frame à frame.
|
Instancié une fois par caméra active, réutilisé frame à frame.
|
||||||
Utilise la soustraction de fond MOG2 — léger sur Raspberry Pi 4.
|
Utilise la soustraction de fond MOG2 — léger sur Raspberry Pi 4.
|
||||||
|
Association frame-à-frame par algorithme hongrois (distance euclidienne).
|
||||||
|
|
||||||
|
Usage :
|
||||||
|
tracker = PlanarianTracker(tube_axis="vertical", max_planarians=3)
|
||||||
|
while capturing:
|
||||||
|
frame_out, results = tracker.process(frame, ts)
|
||||||
|
# results : liste de dicts, un par individu détecté
|
||||||
|
for r in results:
|
||||||
|
metrics.update(r, planarian_id=r["planarian_id"])
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, tube_axis: str = "vertical", min_area_px: int = 20):
|
# Nombre de frames d'initialisation MOG2 ignorées (fond non appris)
|
||||||
# Axe du tube : "vertical" (cy) ou "horizontal" (cx)
|
WARMUP_FRAMES = 10
|
||||||
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
|
||||||
return {
|
# ------------------------------------------------------------------ #
|
||||||
"timestamp" : ts,
|
|
||||||
"detected" : False,
|
|
||||||
"cx" : 0,
|
|
||||||
"cy" : 0,
|
|
||||||
"area_px" : 0,
|
|
||||||
"speed_px_s" : 0.0,
|
|
||||||
"axial_speed": 0.0,
|
|
||||||
"axial_pos" : 0.0,
|
|
||||||
}
|
|
||||||
|
|
||||||
def _update_prev(self, cx, cy, ts):
|
def process(self, frame: np.ndarray, ts: float) -> tuple:
|
||||||
self._prev_cx = cx
|
"""
|
||||||
self._prev_cy = cy
|
Analyse une frame, associe les contours aux individus connus,
|
||||||
self._prev_ts = ts
|
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: dict[int, int | None] = {i: None for i in range(n_slots)}
|
||||||
|
|
||||||
|
assigned_dets = set()
|
||||||
|
for ri, ci in zip(row_ind, col_ind):
|
||||||
|
if cost[ri, ci] <= MAX_ASSOC_DIST_PX:
|
||||||
|
slot_idx = active_slots[ri].idx
|
||||||
|
assignment[slot_idx] = ci
|
||||||
|
assigned_dets.add(ci)
|
||||||
|
|
||||||
|
# --- Nouvelles détections non assignées → slots inactifs libres ---
|
||||||
|
free_slots = [s for s in self._states if not s.active or s.is_lost]
|
||||||
|
new_dets = [di for di in range(n_dets) if di not in assigned_dets]
|
||||||
|
|
||||||
|
for state, det_idx in zip(free_slots, new_dets):
|
||||||
|
assignment[state.idx] = det_idx
|
||||||
|
|
||||||
|
return assignment
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Dessin des annotations
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def _draw_center(
|
||||||
|
self,
|
||||||
|
frame: np.ndarray,
|
||||||
|
cx: int,
|
||||||
|
cy: int,
|
||||||
|
idx: int,
|
||||||
|
speed_px_s: float,
|
||||||
|
axial_pos: float,
|
||||||
|
color: tuple,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Dessine la croix, le cercle et le label de vitesse/position
|
||||||
|
pour un individu.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
frame : image à annoter (en place)
|
||||||
|
cx, cy : centre de masse en pixels
|
||||||
|
idx : index de l'individu
|
||||||
|
speed_px_s : vitesse en px/s
|
||||||
|
axial_pos : position axiale normalisée
|
||||||
|
color : couleur BGR de l'individu
|
||||||
|
"""
|
||||||
|
cross = 8
|
||||||
|
cv2.line(frame, (cx - cross, cy), (cx + cross, cy), COLOR_CENTER, 1)
|
||||||
|
cv2.line(frame, (cx, cy - cross), (cx, cy + cross), COLOR_CENTER, 1)
|
||||||
|
cv2.circle(frame, (cx, cy), 12, color, 1)
|
||||||
|
|
||||||
|
# Badge numéro individu
|
||||||
|
cv2.circle(frame, (cx + 14, cy - 14), 8, color, -1)
|
||||||
|
cv2.putText(
|
||||||
|
frame, str(idx),
|
||||||
|
(cx + 10, cy - 10),
|
||||||
|
cv2.FONT_HERSHEY_SIMPLEX, 0.35, (0, 0, 0), 1, cv2.LINE_AA,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Texte vitesse + position axiale
|
||||||
|
label = (
|
||||||
|
f"#{idx} v={speed_px_s:.1f}px/s ax={axial_pos:.2f}"
|
||||||
|
if speed_px_s > 0
|
||||||
|
else f"#{idx} ax={axial_pos:.2f}"
|
||||||
|
)
|
||||||
|
cv2.putText(
|
||||||
|
frame, label,
|
||||||
|
(max(cx - 60, 0), max(cy - 22, 12)),
|
||||||
|
cv2.FONT_HERSHEY_SIMPLEX, 0.35, (255, 255, 255), 1, cv2.LINE_AA,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Résultats vides / perdus
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def _lost_result(self, planarian_id: int, ts: float) -> dict:
|
||||||
|
"""
|
||||||
|
Retourne un résultat pour un individu temporairement non détecté.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
planarian_id : index de l'individu
|
||||||
|
ts : timestamp de la frame courante
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict avec detected=False et les dernières coordonnées connues
|
||||||
|
"""
|
||||||
|
state = self._states[planarian_id]
|
||||||
|
return {
|
||||||
|
"planarian_id": planarian_id,
|
||||||
|
"detected": False,
|
||||||
|
"cx": state.cx or 0,
|
||||||
|
"cy": state.cy or 0,
|
||||||
|
"area_px": 0,
|
||||||
|
"speed_px_s": 0.0,
|
||||||
|
"axial_speed": 0.0,
|
||||||
|
"axial_pos": 0.0,
|
||||||
|
"timestamp": ts,
|
||||||
|
}
|
||||||
|
|||||||
@@ -27,12 +27,11 @@ class ReductStoreBase(ABC):
|
|||||||
self.bucket: Bucket = asyncio.run(self.create_bucket())
|
self.bucket: Bucket = asyncio.run(self.create_bucket())
|
||||||
logger.info(f"==== {url} token:{api_token}")
|
logger.info(f"==== {url} token:{api_token}")
|
||||||
|
|
||||||
|
|
||||||
async def create_bucket(self):
|
async def create_bucket(self):
|
||||||
|
|
||||||
settings = BucketSettings(
|
settings = BucketSettings(
|
||||||
quota_type=self.quota_type,
|
quota_type=self.quota_type,
|
||||||
quota_size=self.quota_size,
|
quota_size=self.quota_size,
|
||||||
exist_ok=True,
|
|
||||||
)
|
)
|
||||||
return await self.client.create_bucket(self.bucket_name, settings, exist_ok=True)
|
return await self.client.create_bucket(self.bucket_name, settings, exist_ok=True)
|
||||||
|
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ def _update_cache():
|
|||||||
_timer.start()
|
_timer.start()
|
||||||
|
|
||||||
|
|
||||||
def start_background_updater(interval_seconds: int = None):
|
def start_background_updater(interval_seconds: int = 0):
|
||||||
global REFRESH_INTERVAL, _timer
|
global REFRESH_INTERVAL, _timer
|
||||||
if interval_seconds:
|
if interval_seconds:
|
||||||
REFRESH_INTERVAL = interval_seconds
|
REFRESH_INTERVAL = interval_seconds
|
||||||
|
|||||||
@@ -22,13 +22,24 @@ class TubeAligner:
|
|||||||
grbl_threshold_px : int = 20,
|
grbl_threshold_px : int = 20,
|
||||||
dead_zone_px : int = 5,
|
dead_zone_px : int = 5,
|
||||||
debug : bool = False, # ← activable depuis la vue
|
debug : bool = False, # ← activable depuis la vue
|
||||||
display = None, # display function
|
display = None, # display function
|
||||||
):
|
):
|
||||||
self.grbl_threshold_px = grbl_threshold_px
|
self.grbl_threshold_px = grbl_threshold_px
|
||||||
self.dead_zone_px = dead_zone_px
|
self.dead_zone_px = dead_zone_px
|
||||||
self.debug = debug
|
self.debug = debug
|
||||||
self.display = display
|
self.display = display
|
||||||
self.TUBE_DIAMETER_MM = 16.0
|
self.TUBE_DIAMETER_MM = 16.0
|
||||||
|
# Plage de recherche du rayon en fraction de min(w,h)
|
||||||
|
# Défaut : tube occupe ~30% du champ (camera).
|
||||||
|
# Mode vidéo : puit remplit le crop → ratio ~0.50 → appeler set_radius_range(0.35, 0.52)
|
||||||
|
self._min_radius_ratio = 0.26
|
||||||
|
self._max_radius_ratio = 0.37
|
||||||
|
self.draw_annotations = True # masquer l'overlay sans couper la détection
|
||||||
|
|
||||||
|
def set_radius_range(self, min_ratio: float, max_ratio: float) -> None:
|
||||||
|
"""Ajuste la plage de recherche HoughCircles. Appeler avant detect_tube()."""
|
||||||
|
self._min_radius_ratio = min_ratio
|
||||||
|
self._max_radius_ratio = max_ratio
|
||||||
|
|
||||||
|
|
||||||
def set_tube_diameter(self, tube_diameter: float = 16.0) -> None:
|
def set_tube_diameter(self, tube_diameter: float = 16.0) -> None:
|
||||||
@@ -64,14 +75,18 @@ class TubeAligner:
|
|||||||
frame_out = frame.copy()
|
frame_out = frame.copy()
|
||||||
|
|
||||||
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
|
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
|
||||||
|
# CLAHE pour renforcer le contraste local (paroi du puit sur fond gris uniforme)
|
||||||
|
clahe = cv2.createCLAHE(clipLimit=3.0, tileGridSize=(8, 8))
|
||||||
|
gray = clahe.apply(gray)
|
||||||
blurred = cv2.GaussianBlur(gray, (15, 15), 3)
|
blurred = cv2.GaussianBlur(gray, (15, 15), 3)
|
||||||
|
|
||||||
|
lo = self._min_radius_ratio
|
||||||
|
hi = self._max_radius_ratio
|
||||||
# 3 configurations légèrement différentes — vote majoritaire
|
# 3 configurations légèrement différentes — vote majoritaire
|
||||||
# Fonctionne sur fond sombre ET fond clair
|
|
||||||
configs = [
|
configs = [
|
||||||
dict(param1=50, param2=30, minRadius=int(min(w,h)*0.26), maxRadius=int(min(w,h)*0.36)),
|
dict(param1=50, param2=30, minRadius=int(min(w,h)*lo), maxRadius=int(min(w,h)*hi)),
|
||||||
dict(param1=60, param2=30, minRadius=int(min(w,h)*0.26), maxRadius=int(min(w,h)*0.37)),
|
dict(param1=60, param2=28, minRadius=int(min(w,h)*lo), maxRadius=int(min(w,h)*(hi+0.01))),
|
||||||
dict(param1=50, param2=28, minRadius=int(min(w,h)*0.25), maxRadius=int(min(w,h)*0.365)),
|
dict(param1=50, param2=26, minRadius=int(min(w,h)*(lo-0.01)), maxRadius=int(min(w,h)*(hi+0.005))),
|
||||||
]
|
]
|
||||||
|
|
||||||
all_cx, all_cy, all_r = [], [], []
|
all_cx, all_cy, all_r = [], [], []
|
||||||
@@ -93,7 +108,7 @@ class TubeAligner:
|
|||||||
if not all_cx:
|
if not all_cx:
|
||||||
msg = f"TubeAligner: aucun cercle détecté ({w}x{h})"
|
msg = f"TubeAligner: aucun cercle détecté ({w}x{h})"
|
||||||
result["msg"] =msg
|
result["msg"] =msg
|
||||||
if self.debug:
|
if self.debug and self.draw_annotations:
|
||||||
frame_out = self._draw_debug_no_detection(frame_out, cx_img, cy_img)
|
frame_out = self._draw_debug_no_detection(frame_out, cx_img, cy_img)
|
||||||
result["frame_annotated"] = frame_out
|
result["frame_annotated"] = frame_out
|
||||||
return result
|
return result
|
||||||
@@ -120,7 +135,7 @@ class TubeAligner:
|
|||||||
else:
|
else:
|
||||||
action = "grbl"
|
action = "grbl"
|
||||||
|
|
||||||
if self.debug:
|
if self.debug and self.draw_annotations:
|
||||||
frame_out = self._draw_debug(
|
frame_out = self._draw_debug(
|
||||||
frame_out, cx_img, cy_img,
|
frame_out, cx_img, cy_img,
|
||||||
tx, ty, tr,
|
tx, ty, tr,
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ def get_tmpfs_info(mount_point="/ramdisk"):
|
|||||||
return f"{n:.1f}PB"
|
return f"{n:.1f}PB"
|
||||||
|
|
||||||
usage = None
|
usage = None
|
||||||
|
part = None
|
||||||
for part in psutil.disk_partitions(all=True):
|
for part in psutil.disk_partitions(all=True):
|
||||||
if part.mountpoint == mount_point and part.fstype.lower() == "tmpfs":
|
if part.mountpoint == mount_point and part.fstype.lower() == "tmpfs":
|
||||||
usage = psutil.disk_usage(part.mountpoint)
|
usage = psutil.disk_usage(part.mountpoint)
|
||||||
@@ -81,15 +82,17 @@ def get_tmpfs_info(mount_point="/ramdisk"):
|
|||||||
print(f" Free: {usage.free} bytes ({sizeof(usage.free)})")
|
print(f" Free: {usage.free} bytes ({sizeof(usage.free)})")
|
||||||
print(f" Percent used: {usage.percent}%")
|
print(f" Percent used: {usage.percent}%")
|
||||||
break
|
break
|
||||||
return {
|
if usage and part:
|
||||||
"percent": usage.percent,
|
return {
|
||||||
"mount": part.mountpoint,
|
"percent": usage.percent,
|
||||||
"device": part.device,
|
"mount": part.mountpoint,
|
||||||
"fstype": part.fstype,
|
"device": part.device,
|
||||||
"total": usage.total,
|
"fstype": part.fstype,
|
||||||
"used": usage.used,
|
"total": usage.total,
|
||||||
"free": usage.free,
|
"used": usage.used,
|
||||||
}
|
"free": usage.free,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def get_cpu_info():
|
def get_cpu_info():
|
||||||
|
|||||||
@@ -33,12 +33,11 @@ class VideoFileCapture(VideoCaptureInterface):
|
|||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
video_file: str = None,
|
video_file: str | None = None,
|
||||||
fps: float = VideoCaptureInterface.DEFAULT_FPS,
|
fps: float = VideoCaptureInterface.DEFAULT_FPS,
|
||||||
jpeg_quality: int = 85,
|
jpeg_quality: int = 85,
|
||||||
width: Optional[int] = None,
|
width: Optional[int] = None,
|
||||||
height: Optional[int] = None,
|
height: Optional[int] = None,
|
||||||
video_lists = [],
|
|
||||||
use_tracking: bool = False,
|
use_tracking: bool = False,
|
||||||
display = None,
|
display = None,
|
||||||
parent = None,
|
parent = None,
|
||||||
@@ -50,33 +49,23 @@ class VideoFileCapture(VideoCaptureInterface):
|
|||||||
:param width: Largeur souhaitée (None = valeur par défaut du pilote)
|
:param width: Largeur souhaitée (None = valeur par défaut du pilote)
|
||||||
:param height: Hauteur souhaitée (None = valeur par défaut du pilote)
|
:param height: Hauteur souhaitée (None = valeur par défaut du pilote)
|
||||||
"""
|
"""
|
||||||
super().__init__(fps=fps, use_tracking=use_tracking, display=display, parent=parent)
|
super().__init__(fps=fps, use_tracking=use_tracking, display=display, parent=parent, jpeg_quality=jpeg_quality)
|
||||||
self._video_file: str = video_file
|
self._video_file: str | None = video_file
|
||||||
self._jpeg_quality: int = jpeg_quality
|
self._jpeg_quality: int = jpeg_quality
|
||||||
self._width: Optional[int] = width
|
self._width: Optional[int] = width
|
||||||
self._height: Optional[int] = height
|
self._height: Optional[int] = height
|
||||||
self._video_lists = video_lists
|
|
||||||
|
|
||||||
self.ptf = 0
|
|
||||||
self._cap = None # Instance cv2.VideoCapture
|
self._cap = None # Instance cv2.VideoCapture
|
||||||
|
|
||||||
def get_file(self):
|
def set_video_file(self, vf):
|
||||||
if self._video_lists:
|
self._video_file = vf
|
||||||
self._video_file = self._video_lists[self.ptf]
|
|
||||||
self.ptf += 1
|
|
||||||
if self.ptf >= len(self._video_lists):
|
|
||||||
self.ptf = 0
|
|
||||||
return self._video_file
|
return self._video_file
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Implémentation des méthodes abstraites
|
# Implémentation des méthodes abstraites
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
def open(self) -> None:
|
def open(self) -> None:
|
||||||
"""Ouvre le flux V4L2 via OpenCV et configure la résolution."""
|
"""Ouvre le flux V4L2 via OpenCV et configure la résolution."""
|
||||||
self.get_file()
|
|
||||||
|
|
||||||
self._cap = cv2.VideoCapture(self._video_file)
|
self._cap = cv2.VideoCapture(self._video_file)
|
||||||
|
|
||||||
if not self._cap.isOpened():
|
if not self._cap.isOpened():
|
||||||
@@ -145,8 +134,8 @@ class VideoFileCapture(VideoCaptureInterface):
|
|||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def video_file(self) -> int:
|
def video_file(self) -> str | None:
|
||||||
"""Index du périphérique V4L2."""
|
"""Fichier vidéo."""
|
||||||
return self._video_file
|
return self._video_file
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|||||||
@@ -0,0 +1,226 @@
|
|||||||
|
"""
|
||||||
|
VideoPlateCapture — capture par extraction de région dans une vidéo plaque entière.
|
||||||
|
|
||||||
|
La vidéo montre l'ensemble de la plaque multi-puits (vue de dessus).
|
||||||
|
La position GRBL (x, y en mm) détermine la région extraite via px_per_mm.
|
||||||
|
Le résultat est un carré centré sur le puits courant, compatible avec
|
||||||
|
le recadrage circulaire de process_frame() comme pour toute autre capture.
|
||||||
|
|
||||||
|
Flux : video frame → crop carré (GRBL pos) → CircularCrop → tracking/display
|
||||||
|
|
||||||
|
Hot swap : set_video_file(path) remplace la vidéo sans arrêter la capture.
|
||||||
|
Thread-safe via _cap_lock.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
os.environ['OPENCV_LOG_LEVEL'] = "0"
|
||||||
|
os.environ['OPENCV_FFMPEG_LOGLEVEL'] = "0"
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from modules.capture_interface import VideoCaptureInterface, CaptureError
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class VideoPlateCapture(VideoCaptureInterface):
|
||||||
|
"""
|
||||||
|
Lecture d'une vidéo de plaque complète avec crop dynamique à la position GRBL.
|
||||||
|
|
||||||
|
La position GRBL (x_mm, y_mm) est convertie en coordonnées pixel via px_per_mm.
|
||||||
|
Un carré de côté 2*crop_radius_px est extrait à cette position, puis
|
||||||
|
process_frame() applique le masque circulaire habituel.
|
||||||
|
|
||||||
|
La vidéo boucle automatiquement. La cadence est adaptée via frame_step
|
||||||
|
pour correspondre au fps cible.
|
||||||
|
|
||||||
|
Calibration : set_px_per_mm() met à jour le facteur de conversion à chaud.
|
||||||
|
Hot swap : set_video_file(path) change la vidéo sans interruption.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
video_dir,
|
||||||
|
fps: float = 5.0,
|
||||||
|
jpeg_quality: int = 90,
|
||||||
|
use_tracking: bool = False,
|
||||||
|
display=None,
|
||||||
|
parent=None,
|
||||||
|
crop_radius_px: int = 150,
|
||||||
|
px_per_mm: float = 15.0,
|
||||||
|
x_offset_mm: float = 0.0,
|
||||||
|
y_offset_mm: float = 0.0,
|
||||||
|
initial_video_path: str | None = None,
|
||||||
|
):
|
||||||
|
super().__init__(fps, use_tracking, display, parent, jpeg_quality)
|
||||||
|
self._video_dir = Path(video_dir)
|
||||||
|
self._cap: cv2.VideoCapture | None = None
|
||||||
|
self._cap_lock = threading.Lock()
|
||||||
|
self._video_path: Path | None = (
|
||||||
|
Path(initial_video_path) if initial_video_path else None
|
||||||
|
)
|
||||||
|
self._frame_w: int = 0
|
||||||
|
self._frame_h: int = 0
|
||||||
|
self._crop_radius_px: int = crop_radius_px
|
||||||
|
self._px_per_mm: float = px_per_mm
|
||||||
|
self._x_offset_mm: float = x_offset_mm
|
||||||
|
self._y_offset_mm: float = y_offset_mm
|
||||||
|
self._frame_step: int = 1
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# API publique
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def set_px_per_mm(self, px_per_mm: float) -> None:
|
||||||
|
"""Met à jour le facteur de conversion mm → pixel à chaud (depuis WellPosition)."""
|
||||||
|
self._px_per_mm = px_per_mm
|
||||||
|
logger.info(f"VideoPlateCapture: px_per_mm={px_per_mm:.3f}")
|
||||||
|
|
||||||
|
def set_crop_radius_px(self, r: int) -> None:
|
||||||
|
"""Met à jour le rayon de découpe en pixels à chaud (depuis MultiWell.crop_radius)."""
|
||||||
|
self._crop_radius_px = r
|
||||||
|
logger.info(f"VideoPlateCapture: crop_radius_px={r}")
|
||||||
|
|
||||||
|
def set_offset_mm(self, x_offset_mm: float, y_offset_mm: float) -> None:
|
||||||
|
"""Met à jour l'offset d'origine (xbase, ybase du MultiWell) à chaud."""
|
||||||
|
self._x_offset_mm = x_offset_mm
|
||||||
|
self._y_offset_mm = y_offset_mm
|
||||||
|
|
||||||
|
def set_video_file(self, path: str) -> None:
|
||||||
|
"""
|
||||||
|
Hot swap : remplace la vidéo courante sans arrêter la capture.
|
||||||
|
Thread-safe — peut être appelé depuis n'importe quel thread.
|
||||||
|
"""
|
||||||
|
new_cap = cv2.VideoCapture(path)
|
||||||
|
if not new_cap.isOpened():
|
||||||
|
logger.error(f"VideoPlateCapture hot swap: impossible d'ouvrir {path}")
|
||||||
|
return
|
||||||
|
|
||||||
|
new_w = int(new_cap.get(cv2.CAP_PROP_FRAME_WIDTH))
|
||||||
|
new_h = int(new_cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
||||||
|
nat_fps = new_cap.get(cv2.CAP_PROP_FPS) or self._fps
|
||||||
|
new_step = max(1, round(nat_fps / self._fps))
|
||||||
|
|
||||||
|
with self._cap_lock:
|
||||||
|
old_cap = self._cap
|
||||||
|
self._cap = new_cap
|
||||||
|
self._video_path = Path(path)
|
||||||
|
self._frame_w = new_w
|
||||||
|
self._frame_h = new_h
|
||||||
|
self._frame_step = new_step
|
||||||
|
|
||||||
|
if old_cap:
|
||||||
|
old_cap.release()
|
||||||
|
|
||||||
|
logger.info(f"VideoPlateCapture: hot swap → {Path(path).name} {new_w}×{new_h}")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def video_path(self) -> Path | None:
|
||||||
|
return self._video_path
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Implémentation VideoCaptureInterface
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def open(self) -> None:
|
||||||
|
path = self._video_path if (self._video_path and self._video_path.exists()) \
|
||||||
|
else self._find_video()
|
||||||
|
if path is None:
|
||||||
|
raise CaptureError(f"Aucune vidéo trouvée dans {self._video_dir}")
|
||||||
|
|
||||||
|
cap = cv2.VideoCapture(str(path))
|
||||||
|
if not cap.isOpened():
|
||||||
|
raise CaptureError(f"Impossible d'ouvrir {path.name}")
|
||||||
|
|
||||||
|
nat_fps = cap.get(cv2.CAP_PROP_FPS) or self._fps
|
||||||
|
|
||||||
|
with self._cap_lock:
|
||||||
|
self._cap = cap
|
||||||
|
self._video_path = path
|
||||||
|
self._frame_w = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
|
||||||
|
self._frame_h = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
||||||
|
self._frame_step = max(1, round(nat_fps / self._fps))
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"VideoPlateCapture: {path.name} {self._frame_w}×{self._frame_h} "
|
||||||
|
f"@ {nat_fps:.1f} fps → step={self._frame_step}, "
|
||||||
|
f"px_per_mm={self._px_per_mm:.2f}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
with self._cap_lock:
|
||||||
|
cap, self._cap = self._cap, None
|
||||||
|
if cap:
|
||||||
|
cap.release()
|
||||||
|
|
||||||
|
def is_available(self) -> bool:
|
||||||
|
with self._cap_lock:
|
||||||
|
return self._cap is not None and self._cap.isOpened()
|
||||||
|
|
||||||
|
def capture_frame(self) -> bytes:
|
||||||
|
with self._cap_lock:
|
||||||
|
if self._cap is None or not self._cap.isOpened():
|
||||||
|
raise CaptureError("Vidéo non disponible")
|
||||||
|
|
||||||
|
# Avancer dans la vidéo pour correspondre au fps cible
|
||||||
|
for _ in range(self._frame_step - 1):
|
||||||
|
self._cap.grab()
|
||||||
|
|
||||||
|
ret, frame = self._cap.read()
|
||||||
|
if not ret:
|
||||||
|
self._cap.set(cv2.CAP_PROP_POS_FRAMES, 0)
|
||||||
|
ret, frame = self._cap.read()
|
||||||
|
if not ret:
|
||||||
|
raise CaptureError("Impossible de relire la vidéo")
|
||||||
|
|
||||||
|
frame_w = self._frame_w
|
||||||
|
frame_h = self._frame_h
|
||||||
|
|
||||||
|
# Position GRBL (mm) → coordonnées pixel dans la vidéo plaque
|
||||||
|
grbl = getattr(self.parent, 'grbl', None) if self.parent else None
|
||||||
|
x_mm = float(getattr(grbl, 'x', None) or 0.0)
|
||||||
|
y_mm = float(getattr(grbl, 'y', None) or 0.0)
|
||||||
|
|
||||||
|
# À l'origine CNC (0, 0) : retourner la plaque entière à sa résolution native
|
||||||
|
if x_mm == 0.0 and y_mm == 0.0:
|
||||||
|
ok, buf = cv2.imencode('.jpg', frame, [cv2.IMWRITE_JPEG_QUALITY, self.jpeg_quality])
|
||||||
|
if not ok:
|
||||||
|
raise CaptureError("Encodage JPEG échoué")
|
||||||
|
return buf.tobytes()
|
||||||
|
|
||||||
|
cx = int((x_mm - self._x_offset_mm) * self._px_per_mm)
|
||||||
|
cy = int((y_mm - self._y_offset_mm) * self._px_per_mm)
|
||||||
|
r = self._crop_radius_px
|
||||||
|
|
||||||
|
# Extraction du carré centré sur le puits courant
|
||||||
|
x1 = max(0, cx - r)
|
||||||
|
y1 = max(0, cy - r)
|
||||||
|
x2 = min(frame_w, cx + r)
|
||||||
|
y2 = min(frame_h, cy + r)
|
||||||
|
crop = frame[y1:y2, x1:x2]
|
||||||
|
|
||||||
|
# Padding noir si le crop déborde du bord de la vidéo
|
||||||
|
if crop.shape[0] != 2 * r or crop.shape[1] != 2 * r:
|
||||||
|
padded = np.zeros((2 * r, 2 * r, 3), dtype=np.uint8)
|
||||||
|
padded[:crop.shape[0], :crop.shape[1]] = crop
|
||||||
|
crop = padded
|
||||||
|
|
||||||
|
ok, buf = cv2.imencode('.jpg', crop, [cv2.IMWRITE_JPEG_QUALITY, self.jpeg_quality])
|
||||||
|
if not ok:
|
||||||
|
raise CaptureError("Encodage JPEG échoué")
|
||||||
|
return buf.tobytes()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Privé
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _find_video(self) -> Path | None:
|
||||||
|
"""Retourne le premier fichier vidéo trouvé dans video_dir."""
|
||||||
|
self._video_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
for ext in ('*.mp4', '*.avi', '*.MP4', '*.AVI'):
|
||||||
|
files = sorted(self._video_dir.glob(ext))
|
||||||
|
if files:
|
||||||
|
return files[0]
|
||||||
|
return None
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
# planarian/forms.py
|
|
||||||
|
|
||||||
import csv
|
|
||||||
import io
|
|
||||||
|
|
||||||
from django import forms
|
|
||||||
from django.utils.translation import gettext_lazy as _
|
|
||||||
from .models import ExperimentConfig
|
|
||||||
|
|
||||||
|
|
||||||
class ExperimentConfigForm(forms.ModelForm):
|
|
||||||
"""Formulaire de saisie/modification d'un ExperimentConfig."""
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = ExperimentConfig
|
|
||||||
fields = "__all__"
|
|
||||||
widgets = {
|
|
||||||
"description": forms.Textarea(attrs={"rows": 3}),
|
|
||||||
}
|
|
||||||
|
|
||||||
def clean(self):
|
|
||||||
cleaned = super().clean()
|
|
||||||
if cleaned.get("thresh_immobile", 0) >= cleaned.get("thresh_mobile", 1):
|
|
||||||
raise forms.ValidationError(
|
|
||||||
_("Le seuil Immobile doit être inférieur au seuil Mobile.")
|
|
||||||
)
|
|
||||||
if cleaned.get("avoid_radius_mm", 0) >= cleaned.get("aggreg_radius_mm", 1):
|
|
||||||
raise forms.ValidationError(
|
|
||||||
_("Le rayon d'évitement doit être inférieur au rayon d'agrégation.")
|
|
||||||
)
|
|
||||||
return cleaned
|
|
||||||
|
|
||||||
|
|
||||||
class CsvImportForm(forms.Form):
|
|
||||||
"""Formulaire d'import de paramètres depuis un fichier CSV."""
|
|
||||||
|
|
||||||
csv_file = forms.FileField(
|
|
||||||
label=_("Fichier CSV"),
|
|
||||||
help_text=_(
|
|
||||||
"Colonnes obligatoires : experiment, well, px_per_mm, fps. "
|
|
||||||
"Toutes les autres colonnes sont optionnelles."
|
|
||||||
),
|
|
||||||
)
|
|
||||||
overwrite = forms.BooleanField(
|
|
||||||
required=False,
|
|
||||||
initial=False,
|
|
||||||
label=_("Écraser les configurations existantes"),
|
|
||||||
)
|
|
||||||
|
|
||||||
def clean_csv_file(self):
|
|
||||||
f = self.cleaned_data["csv_file"]
|
|
||||||
try:
|
|
||||||
content = f.read().decode("utf-8")
|
|
||||||
reader = csv.DictReader(io.StringIO(content))
|
|
||||||
rows = list(reader)
|
|
||||||
except Exception as e:
|
|
||||||
raise forms.ValidationError(_("Fichier CSV invalide : %(err)s") % {"err": e})
|
|
||||||
|
|
||||||
required = {"experiment", "well", "px_per_mm", "fps"}
|
|
||||||
if rows:
|
|
||||||
missing = required - set(rows[0].keys())
|
|
||||||
if missing:
|
|
||||||
raise forms.ValidationError(
|
|
||||||
_("Colonnes manquantes : %(cols)s") % {"cols": ", ".join(missing)}
|
|
||||||
)
|
|
||||||
self.csv_rows = rows
|
|
||||||
return f
|
|
||||||
|
|
||||||
|
|
||||||
class ExportCsvForm(forms.Form):
|
|
||||||
"""Formulaire de demande d'export CSV depuis ReductStore."""
|
|
||||||
|
|
||||||
experiment = forms.CharField(label=_("Expérience"), max_length=100)
|
|
||||||
well = forms.CharField(label=_("Puits"), max_length=20)
|
|
||||||
planarian = forms.IntegerField(label=_("Index planaire"), initial=0, min_value=0)
|
|
||||||
record_type = forms.ChoiceField(
|
|
||||||
label=_("Type d'enregistrement"),
|
|
||||||
choices=[("frame", _("Frame par frame")), ("summary", _("Résumé"))],
|
|
||||||
initial="frame",
|
|
||||||
)
|
|
||||||
start_dt = forms.DateTimeField(
|
|
||||||
label=_("Début (UTC)"),
|
|
||||||
required=False,
|
|
||||||
widget=forms.DateTimeInput(attrs={"type": "datetime-local"}),
|
|
||||||
)
|
|
||||||
stop_dt = forms.DateTimeField(
|
|
||||||
label=_("Fin (UTC)"),
|
|
||||||
required=False,
|
|
||||||
widget=forms.DateTimeInput(attrs={"type": "datetime-local"}),
|
|
||||||
)
|
|
||||||
|
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# Generated by Django 6.0.5 on 2026-05-31 07:42
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='ExperimentConfig',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('experiment', models.CharField(default='Identifier', max_length=128, null=True, verbose_name='Identifiant expérience')),
|
||||||
|
('well', models.CharField(choices=[], default='A1', help_text='Nom du puit', max_length=8, null=True, verbose_name='Puit')),
|
||||||
|
('description', models.TextField(blank=True, default='-', verbose_name='Description')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Créé le')),
|
||||||
|
('active', models.BooleanField(default=True, verbose_name='Active')),
|
||||||
|
('px_per_mm', models.FloatField(default=26.25, help_text='Facteur de calibration optique', verbose_name='Pixels par mm')),
|
||||||
|
('fps', models.FloatField(default=5.0, help_text='Image de capture en img/s', verbose_name='FPS de capture')),
|
||||||
|
('well_radius_mm', models.FloatField(default=8.0, help_text='En mm', verbose_name='Rayon du puits')),
|
||||||
|
('thresh_immobile', models.FloatField(default=0.2, verbose_name='Seuil Immobile (mm/s)')),
|
||||||
|
('thresh_mobile', models.FloatField(default=1.5, verbose_name='Seuil Mobile (mm/s)')),
|
||||||
|
('tube_axis', models.CharField(choices=[('vertical', 'Vertical'), ('horizontal', 'Horizontal')], default='vertical', max_length=10, verbose_name='Axe du tube')),
|
||||||
|
('min_area_px', models.IntegerField(default=20, verbose_name='Surface min détection (px²)')),
|
||||||
|
('max_area_ratio', models.FloatField(default=0.1, help_text='Ratio de la surface du puits, ex: 0.10 pour 10%', verbose_name='Surface max contour (fraction de la frame)')),
|
||||||
|
('planarian_count', models.IntegerField(default=1, verbose_name='Nombre de planaires')),
|
||||||
|
('merge_kernel_size', models.PositiveIntegerField(default=15, help_text='taille du kernel elliptique de fusion des fragments (px). Augmenter si fragments résiduels', verbose_name='Taille du kernel')),
|
||||||
|
('min_contour_dist_px', models.PositiveIntegerField(default=40, help_text='Distance min entre deux contours pour les considérer comme individus distincts. Défaut : 40px. Augmenter si IDs multiples persistent', verbose_name='Distance <contour>')),
|
||||||
|
('thigmotaxis_wall_dist_mm', models.FloatField(default=1.0, verbose_name='Distance paroi thigmotactisme (mm)')),
|
||||||
|
('photo_mode', models.CharField(choices=[('none', 'Désactivé'), ('fixed', 'Source fixe'), ('sine', 'Source sinusoïdale'), ('radial', 'Gradient radial')], default='none', max_length=10, verbose_name='Mode phototactisme')),
|
||||||
|
('photo_strength', models.FloatField(default=0.0, verbose_name='Intensité phototactisme')),
|
||||||
|
('photo_x', models.FloatField(default=0.5, verbose_name='Source lumière X (0-1)')),
|
||||||
|
('photo_y', models.FloatField(default=0.5, verbose_name='Source lumière Y (0-1)')),
|
||||||
|
('chemo_strength', models.FloatField(default=0.0, verbose_name='Intensité chimiotactisme')),
|
||||||
|
('chemo_x', models.FloatField(default=0.5, verbose_name='Nourriture X (0-1)')),
|
||||||
|
('chemo_y', models.FloatField(default=0.5, verbose_name='Nourriture Y (0-1)')),
|
||||||
|
('chemo_radius_mm', models.FloatField(default=2.0, verbose_name='Rayon nourriture (mm)')),
|
||||||
|
('avoid_radius_mm', models.FloatField(default=3.0, verbose_name='Rayon évitement (mm)')),
|
||||||
|
('aggreg_radius_mm', models.FloatField(default=6.0, verbose_name='Rayon agrégation (mm)')),
|
||||||
|
('author', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='Auteur')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': "Configuration d'une expérience",
|
||||||
|
'verbose_name_plural': 'Configurations des expériences',
|
||||||
|
'ordering': ['-created_at'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# Generated by Django 6.0.5 on 2026-05-31 07:42
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('planarian', '0001_initial'),
|
||||||
|
('scanner', '0001_initial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='experimentconfig',
|
||||||
|
name='experiment_key',
|
||||||
|
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='scanner.experiment', verbose_name='Expérience'),
|
||||||
|
),
|
||||||
|
migrations.AlterUniqueTogether(
|
||||||
|
name='experimentconfig',
|
||||||
|
unique_together={('experiment_key', 'well')},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -1,36 +1,33 @@
|
|||||||
# 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):
|
||||||
"""
|
"""
|
||||||
Paramètres d'une expérience PlanarianScanner.
|
Paramètres d'une expérience PlanarianScanner.
|
||||||
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,
|
||||||
@@ -142,24 +173,4 @@ class ExperimentConfig(models.Model):
|
|||||||
"avoid_radius_mm": self.avoid_radius_mm,
|
"avoid_radius_mm": self.avoid_radius_mm,
|
||||||
"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 ;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
# tasks.py
|
||||||
|
from celery import shared_task, group
|
||||||
|
from celery.utils.log import get_task_logger
|
||||||
|
|
||||||
|
from scanner.models import SessionExperiment, get_uuid_from_session, Experiment
|
||||||
|
from .models import ExperimentConfig
|
||||||
|
from .export_service import export_csv_file_sync
|
||||||
|
|
||||||
|
logger = get_task_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task(bind=True, autoretry_for=(Exception,), retry_backoff=True, retry_kwargs={"max_retries": 3},)
|
||||||
|
def export_single_metric(self, *, experiment, well, uuid, planarian, record_type,):
|
||||||
|
"""
|
||||||
|
Exporte UN fichier CSV.
|
||||||
|
"""
|
||||||
|
logger.info( f"Export start {experiment} {well} {planarian} {record_type}")
|
||||||
|
f, n = export_csv_file_sync(
|
||||||
|
experiment=experiment,
|
||||||
|
well=well,
|
||||||
|
uuid=uuid,
|
||||||
|
planarian=planarian,
|
||||||
|
record_type=record_type,
|
||||||
|
)
|
||||||
|
logger.info( f"Export done {f} ({n} rows)" )
|
||||||
|
return {"file": f, "rows": n, }
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task
|
||||||
|
def export_experiment_metrics_task(experiment_id):
|
||||||
|
experiment = Experiment.objects.filter(pk=experiment_id).first()
|
||||||
|
if not experiment:
|
||||||
|
return
|
||||||
|
|
||||||
|
jobs = []
|
||||||
|
configs = (ExperimentConfig.objects.filter(experiment_key_id=experiment.pk).order_by("well"))
|
||||||
|
for conf in configs:
|
||||||
|
well = conf.well
|
||||||
|
count = conf.planarian_count
|
||||||
|
session = conf.get_session()
|
||||||
|
uuid = get_uuid_from_session(session.id, conf.experiment_key.multiwell.position, well, )
|
||||||
|
|
||||||
|
for record_type in ["frame", "summary"]:
|
||||||
|
for planarian in range(count):
|
||||||
|
'''
|
||||||
|
jobs.append(
|
||||||
|
export_single_metric.s(
|
||||||
|
experiment=experiment.identifier,
|
||||||
|
well=well,
|
||||||
|
uuid=uuid,
|
||||||
|
planarian=planarian,
|
||||||
|
record_type=record_type,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
'''
|
||||||
|
try:
|
||||||
|
f, n = export_csv_file_sync(
|
||||||
|
experiment=experiment.identifier,
|
||||||
|
well=well,
|
||||||
|
uuid=uuid,
|
||||||
|
planarian=planarian,
|
||||||
|
record_type=record_type,
|
||||||
|
)
|
||||||
|
logger.info( f"Export done {f} ({n} rows)" )
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(e)
|
||||||
|
'''
|
||||||
|
result = group(jobs).apply_async()
|
||||||
|
logger.info(f"{len(jobs)} exports launched group_id={result.id}")
|
||||||
|
return {"group_id": result.id,"jobs": len(jobs), }'''
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task
|
||||||
|
def export_session_metrics_task(session_id):
|
||||||
|
experiments = SessionExperiment.experiment_by_session(session_id, active=False)
|
||||||
|
for experiment in experiments:
|
||||||
|
export_experiment_metrics_task.delay(experiment.id)
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
{% extends "scanner/base.html" %}
|
||||||
|
{% load i18n home_tags %}
|
||||||
|
{% block styles %}
|
||||||
|
{{ block.super }}
|
||||||
|
<link href="/static/planarian/css/planarian.css" rel="stylesheet">
|
||||||
|
{% endblock %}
|
||||||
|
{% block columns %}{% endblock %}
|
||||||
|
|
||||||
|
{% block sidebar_list %}
|
||||||
|
<a href="/" class="w3-bar-item w3-btn w3-hover-opacity"><i class="fa-solid fa-house w3-text-orange w3-xlarge"></i> {% trans "Retour accueil" %}</a>
|
||||||
|
<form method="post" class="w3-bar-item" action="">
|
||||||
|
{% csrf_token %}
|
||||||
|
<select id="_sid" name="_sid" class="w3-select w3-margin-bottom" onchange="this.form.submit()" title="{% trans "Ensemble d'expériences" %}">
|
||||||
|
<option value="0">---- {% trans "Session" %}</option>
|
||||||
|
{% for s in sessions %}
|
||||||
|
<option value="{{ s.id }}" {% if s.id == current_session.id %}selected{% endif %}>{{ s }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<div class="w3-margin-left w3-margin-bottom">
|
||||||
|
{% include "scanner/experiment-inc.html" %}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% block export_csv %}
|
||||||
|
{% if current_session and current_experiment %}
|
||||||
|
{% url "planarian:api-export-csv" as url_export %}
|
||||||
|
<a href="#" class="w3-bar-item w3-btn w3-hover-opacity" onclick="export_csv('{{ url_export }}', {{ current_experiment.id }}, 'experiment_csv')">
|
||||||
|
<i class="fa-solid fa-file-export w3-text-red w3-xlarge"></i> {% trans "Exporter les metrics de l'expérience" %}<br>
|
||||||
|
<span class="w3-margin-left-5"> $> {{ 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"> $> {{ export_csv_destination }}
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block js_footer %}
|
||||||
|
{{ block.super }}
|
||||||
|
<script>
|
||||||
|
const export_msg = "{% trans "Confirmer l'export des vidéos. ATTENTION le processus peut être long!" %}";
|
||||||
|
// Auto-dismiss messages after 5 seconds
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const messages = document.querySelectorAll('.alert');
|
||||||
|
messages.forEach(function(message) {
|
||||||
|
setTimeout(function() {
|
||||||
|
// Fade out effect
|
||||||
|
message.style.transition = 'opacity 0.5s ease-out';
|
||||||
|
message.style.opacity = '0';
|
||||||
|
|
||||||
|
// Remove from DOM after fade
|
||||||
|
setTimeout(function() {
|
||||||
|
message.remove();
|
||||||
|
}, 500);
|
||||||
|
}, 15000); // 15 seconds
|
||||||
|
});
|
||||||
|
});
|
||||||
|
function export_csv(url, pid, mode) {
|
||||||
|
csrfFetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
action: mode,
|
||||||
|
pid: pid,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(res => { alert(res.msg); })
|
||||||
|
.catch(error => { console.error('Erreur:', error); });
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -1,503 +0,0 @@
|
|||||||
{% extends "scanner/base.html" %}
|
|
||||||
{% load i18n %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
|
|
||||||
<div class="w3-container w3-padding-32" style="max-width:960px; margin:auto;">
|
|
||||||
|
|
||||||
<!-- En-tête de la page -->
|
|
||||||
<div class="w3-panel w3-teal w3-round-large w3-padding-16" style="margin-bottom:2rem;">
|
|
||||||
<div class="w3-row w3-bar-item">
|
|
||||||
<span class="w3-xlarge">
|
|
||||||
<i class="w3-margin-right">🔬</i>
|
|
||||||
{% if object %}
|
|
||||||
{% trans "Modifier la configuration" %} — {{ object }}
|
|
||||||
{% else %}
|
|
||||||
{% trans "Nouvelle configuration d'expérience" %}
|
|
||||||
{% endif %}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Messages Django -->
|
|
||||||
{% for message in messages %}
|
|
||||||
<div class="w3-panel w3-round
|
|
||||||
{% if message.tags == 'success' %}w3-green
|
|
||||||
{% elif message.tags == 'error' %}w3-red
|
|
||||||
{% elif message.tags == 'warning' %}w3-orange
|
|
||||||
{% else %}w3-blue{% endif %}">
|
|
||||||
<p>{{ message }}</p>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
|
|
||||||
<form method="post" novalidate>
|
|
||||||
{% csrf_token %}
|
|
||||||
|
|
||||||
<!-- Erreurs globales du formulaire -->
|
|
||||||
{% if form.non_field_errors %}
|
|
||||||
<div class="w3-panel w3-red w3-round">
|
|
||||||
{% for error in form.non_field_errors %}
|
|
||||||
<p>⚠ {{ error }}</p>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<!-- ============================================================
|
|
||||||
Section 1 : Identification
|
|
||||||
============================================================ -->
|
|
||||||
<div class="w3-card-4 w3-round-large w3-margin-bottom">
|
|
||||||
<header class="w3-container w3-teal w3-round-top-large">
|
|
||||||
<h3 class="w3-text-white">{% trans "Identification" %}</h3>
|
|
||||||
</header>
|
|
||||||
<div class="w3-container w3-padding-24">
|
|
||||||
<div class="w3-row-padding">
|
|
||||||
|
|
||||||
<!-- Experiment -->
|
|
||||||
<div class="w3-col m6 s12 w3-margin-bottom">
|
|
||||||
<label class="w3-text-teal"><b>{{ form.experiment.label }}</b></label>
|
|
||||||
{% if form.experiment.help_text %}
|
|
||||||
<p class="w3-small w3-text-grey" style="margin:0 0 4px;">{{ form.experiment.help_text }}</p>
|
|
||||||
{% endif %}
|
|
||||||
{{ form.experiment }}
|
|
||||||
{% if form.experiment.errors %}
|
|
||||||
<span class="w3-text-red w3-small">{{ form.experiment.errors|join:", " }}</span>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Well -->
|
|
||||||
<div class="w3-col m6 s12 w3-margin-bottom">
|
|
||||||
<label class="w3-text-teal"><b>{{ form.well.label }}</b></label>
|
|
||||||
{% if form.well.help_text %}
|
|
||||||
<p class="w3-small w3-text-grey" style="margin:0 0 4px;">{{ form.well.help_text }}</p>
|
|
||||||
{% endif %}
|
|
||||||
{{ form.well }}
|
|
||||||
{% if form.well.errors %}
|
|
||||||
<span class="w3-text-red w3-small">{{ form.well.errors|join:", " }}</span>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Description -->
|
|
||||||
<div class="w3-margin-bottom">
|
|
||||||
<label class="w3-text-teal"><b>{{ form.description.label }}</b></label>
|
|
||||||
{{ form.description }}
|
|
||||||
{% if form.description.errors %}
|
|
||||||
<span class="w3-text-red w3-small">{{ form.description.errors|join:", " }}</span>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ============================================================
|
|
||||||
Section 2 : Calibration optique
|
|
||||||
============================================================ -->
|
|
||||||
<div class="w3-card-4 w3-round-large w3-margin-bottom">
|
|
||||||
<header class="w3-container w3-blue-grey w3-round-top-large">
|
|
||||||
<h3 class="w3-text-white">{% trans "Calibration optique" %}</h3>
|
|
||||||
</header>
|
|
||||||
<div class="w3-container w3-padding-24">
|
|
||||||
<div class="w3-row-padding">
|
|
||||||
|
|
||||||
<div class="w3-col m4 s12 w3-margin-bottom">
|
|
||||||
<label class="w3-text-blue-grey"><b>{{ form.px_per_mm.label }}</b></label>
|
|
||||||
{% if form.px_per_mm.help_text %}
|
|
||||||
<p class="w3-small w3-text-grey" style="margin:0 0 4px;">{{ form.px_per_mm.help_text }}</p>
|
|
||||||
{% endif %}
|
|
||||||
{{ form.px_per_mm }}
|
|
||||||
{% if form.px_per_mm.errors %}
|
|
||||||
<span class="w3-text-red w3-small">{{ form.px_per_mm.errors|join:", " }}</span>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="w3-col m4 s12 w3-margin-bottom">
|
|
||||||
<label class="w3-text-blue-grey"><b>{{ form.fps.label }}</b></label>
|
|
||||||
{{ form.fps }}
|
|
||||||
{% if form.fps.errors %}
|
|
||||||
<span class="w3-text-red w3-small">{{ form.fps.errors|join:", " }}</span>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="w3-col m4 s12 w3-margin-bottom">
|
|
||||||
<label class="w3-text-blue-grey"><b>{{ form.well_radius_mm.label }}</b></label>
|
|
||||||
{{ form.well_radius_mm }}
|
|
||||||
{% if form.well_radius_mm.errors %}
|
|
||||||
<span class="w3-text-red w3-small">{{ form.well_radius_mm.errors|join:", " }}</span>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ============================================================
|
|
||||||
Section 3 : Seuils de mobilité EthoVision
|
|
||||||
============================================================ -->
|
|
||||||
<div class="w3-card-4 w3-round-large w3-margin-bottom">
|
|
||||||
<header class="w3-container w3-indigo w3-round-top-large">
|
|
||||||
<h3 class="w3-text-white">{% trans "Seuils de mobilité EthoVision XT" %}</h3>
|
|
||||||
</header>
|
|
||||||
<div class="w3-container w3-padding-24">
|
|
||||||
|
|
||||||
<!-- Visualisation des seuils -->
|
|
||||||
<div class="w3-light-grey w3-round w3-margin-bottom" style="height:32px; position:relative; overflow:hidden;">
|
|
||||||
<div style="position:absolute; left:0; width:13%; height:100%; background:#c0392b; display:flex; align-items:center; justify-content:center;">
|
|
||||||
<span class="w3-small w3-text-white"><b>{% trans "Immobile" %}</b></span>
|
|
||||||
</div>
|
|
||||||
<div style="position:absolute; left:13%; width:27%; height:100%; background:#e67e22; display:flex; align-items:center; justify-content:center;">
|
|
||||||
<span class="w3-small w3-text-white"><b>{% trans "Mobile" %}</b></span>
|
|
||||||
</div>
|
|
||||||
<div style="position:absolute; left:40%; right:0; height:100%; background:#27ae60; display:flex; align-items:center; justify-content:center;">
|
|
||||||
<span class="w3-small w3-text-white"><b>{% trans "Très mobile" %}</b></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p class="w3-small w3-text-grey" style="margin-top:-8px; margin-bottom:16px;">
|
|
||||||
{% trans "Représentation indicative des zones de mobilité (défauts EthoVision : 0.2 / 1.5 mm/s)" %}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="w3-row-padding">
|
|
||||||
<div class="w3-col m6 s12 w3-margin-bottom">
|
|
||||||
<label class="w3-text-indigo"><b>{{ form.thresh_immobile.label }}</b></label>
|
|
||||||
<p class="w3-small w3-text-grey" style="margin:0 0 4px;">{% trans "En-dessous : Immobile (mm/s)" %}</p>
|
|
||||||
{{ form.thresh_immobile }}
|
|
||||||
{% if form.thresh_immobile.errors %}
|
|
||||||
<span class="w3-text-red w3-small">{{ form.thresh_immobile.errors|join:", " }}</span>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
<div class="w3-col m6 s12 w3-margin-bottom">
|
|
||||||
<label class="w3-text-indigo"><b>{{ form.thresh_mobile.label }}</b></label>
|
|
||||||
<p class="w3-small w3-text-grey" style="margin:0 0 4px;">{% trans "En-dessous : Mobile, au-delà : Très mobile (mm/s)" %}</p>
|
|
||||||
{{ form.thresh_mobile }}
|
|
||||||
{% if form.thresh_mobile.errors %}
|
|
||||||
<span class="w3-text-red w3-small">{{ form.thresh_mobile.errors|join:", " }}</span>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ============================================================
|
|
||||||
Section 4 : Tracker
|
|
||||||
============================================================ -->
|
|
||||||
<div class="w3-card-4 w3-round-large w3-margin-bottom">
|
|
||||||
<header class="w3-container w3-dark-grey w3-round-top-large">
|
|
||||||
<h3 class="w3-text-white">{% trans "Tracker" %}</h3>
|
|
||||||
</header>
|
|
||||||
<div class="w3-container w3-padding-24">
|
|
||||||
<div class="w3-row-padding">
|
|
||||||
|
|
||||||
<div class="w3-col m4 s12 w3-margin-bottom">
|
|
||||||
<label class="w3-text-dark-grey"><b>{{ form.tube_axis.label }}</b></label>
|
|
||||||
{{ form.tube_axis }}
|
|
||||||
{% if form.tube_axis.errors %}
|
|
||||||
<span class="w3-text-red w3-small">{{ form.tube_axis.errors|join:", " }}</span>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="w3-col m4 s12 w3-margin-bottom">
|
|
||||||
<label class="w3-text-dark-grey"><b>{{ form.min_area_px.label }}</b></label>
|
|
||||||
{{ form.min_area_px }}
|
|
||||||
{% if form.min_area_px.errors %}
|
|
||||||
<span class="w3-text-red w3-small">{{ form.min_area_px.errors|join:", " }}</span>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="w3-col m4 s12 w3-margin-bottom">
|
|
||||||
<label class="w3-text-dark-grey"><b>{{ form.planarian_count.label }}</b></label>
|
|
||||||
{{ form.planarian_count }}
|
|
||||||
{% if form.planarian_count.errors %}
|
|
||||||
<span class="w3-text-red w3-small">{{ form.planarian_count.errors|join:", " }}</span>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ============================================================
|
|
||||||
Section 5 : Comportements (accordéon W3.CSS)
|
|
||||||
============================================================ -->
|
|
||||||
<div class="w3-card-4 w3-round-large w3-margin-bottom">
|
|
||||||
<header class="w3-container w3-teal w3-round-top-large">
|
|
||||||
<h3 class="w3-text-white">{% trans "Comportements" %}</h3>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<!-- --- Thigmotactisme --- -->
|
|
||||||
<div class="w3-container w3-padding-16 w3-border-bottom">
|
|
||||||
<button type="button" class="w3-button w3-block w3-left-align w3-hover-light-grey"
|
|
||||||
onclick="toggleSection('thigmo')">
|
|
||||||
<span class="w3-large">🫧</span>
|
|
||||||
<b class="w3-margin-left">{% trans "Thigmotactisme" %}</b>
|
|
||||||
<span class="w3-small w3-text-grey w3-margin-left">
|
|
||||||
{% trans "Attraction vers la paroi du puits" %}
|
|
||||||
</span>
|
|
||||||
<span id="thigmo-icon" class="w3-right">▼</span>
|
|
||||||
</button>
|
|
||||||
<div id="thigmo" class="w3-padding-top">
|
|
||||||
<div class="w3-row-padding">
|
|
||||||
<div class="w3-col m6 s12">
|
|
||||||
<label class="w3-text-teal"><b>{{ form.thigmotaxis_wall_dist_mm.label }}</b></label>
|
|
||||||
<p class="w3-small w3-text-grey" style="margin:0 0 4px;">
|
|
||||||
{% trans "Distance à la paroi considérée « près du bord » (mm)" %}
|
|
||||||
</p>
|
|
||||||
{{ form.thigmotaxis_wall_dist_mm }}
|
|
||||||
{% if form.thigmotaxis_wall_dist_mm.errors %}
|
|
||||||
<span class="w3-text-red w3-small">{{ form.thigmotaxis_wall_dist_mm.errors|join:", " }}</span>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- --- Phototactisme --- -->
|
|
||||||
<div class="w3-container w3-padding-16 w3-border-bottom">
|
|
||||||
<button type="button" class="w3-button w3-block w3-left-align w3-hover-light-grey"
|
|
||||||
onclick="toggleSection('photo')">
|
|
||||||
<span class="w3-large">💡</span>
|
|
||||||
<b class="w3-margin-left">{% trans "Phototactisme" %}</b>
|
|
||||||
<span class="w3-small w3-text-grey w3-margin-left">
|
|
||||||
{% trans "Fuite de la lumière" %}
|
|
||||||
</span>
|
|
||||||
<span id="photo-icon" class="w3-right">▼</span>
|
|
||||||
</button>
|
|
||||||
<div id="photo" class="w3-hide w3-padding-top">
|
|
||||||
<div class="w3-row-padding">
|
|
||||||
|
|
||||||
<div class="w3-col m6 s12 w3-margin-bottom">
|
|
||||||
<label class="w3-text-teal"><b>{{ form.photo_mode.label }}</b></label>
|
|
||||||
{{ form.photo_mode }}
|
|
||||||
{% if form.photo_mode.errors %}
|
|
||||||
<span class="w3-text-red w3-small">{{ form.photo_mode.errors|join:", " }}</span>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="w3-col m6 s12 w3-margin-bottom">
|
|
||||||
<label class="w3-text-teal"><b>{{ form.photo_strength.label }}</b></label>
|
|
||||||
<p class="w3-small w3-text-grey" style="margin:0 0 4px;">{% trans "0.0 = désactivé → 1.0 = fort" %}</p>
|
|
||||||
{{ form.photo_strength }}
|
|
||||||
{% if form.photo_strength.errors %}
|
|
||||||
<span class="w3-text-red w3-small">{{ form.photo_strength.errors|join:", " }}</span>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="w3-col m6 s12 w3-margin-bottom">
|
|
||||||
<label class="w3-text-teal"><b>{{ form.photo_x.label }}</b></label>
|
|
||||||
{{ form.photo_x }}
|
|
||||||
{% if form.photo_x.errors %}
|
|
||||||
<span class="w3-text-red w3-small">{{ form.photo_x.errors|join:", " }}</span>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="w3-col m6 s12 w3-margin-bottom">
|
|
||||||
<label class="w3-text-teal"><b>{{ form.photo_y.label }}</b></label>
|
|
||||||
{{ form.photo_y }}
|
|
||||||
{% if form.photo_y.errors %}
|
|
||||||
<span class="w3-text-red w3-small">{{ form.photo_y.errors|join:", " }}</span>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- --- Chimiotactisme --- -->
|
|
||||||
<div class="w3-container w3-padding-16 w3-border-bottom">
|
|
||||||
<button type="button" class="w3-button w3-block w3-left-align w3-hover-light-grey"
|
|
||||||
onclick="toggleSection('chemo')">
|
|
||||||
<span class="w3-large">🍖</span>
|
|
||||||
<b class="w3-margin-left">{% trans "Chimiotactisme" %}</b>
|
|
||||||
<span class="w3-small w3-text-grey w3-margin-left">
|
|
||||||
{% trans "Attraction vers une source de nourriture" %}
|
|
||||||
</span>
|
|
||||||
<span id="chemo-icon" class="w3-right">▼</span>
|
|
||||||
</button>
|
|
||||||
<div id="chemo" class="w3-hide w3-padding-top">
|
|
||||||
<div class="w3-row-padding">
|
|
||||||
|
|
||||||
<div class="w3-col m4 s12 w3-margin-bottom">
|
|
||||||
<label class="w3-text-teal"><b>{{ form.chemo_strength.label }}</b></label>
|
|
||||||
<p class="w3-small w3-text-grey" style="margin:0 0 4px;">{% trans "0.0 = désactivé → 1.0 = fort" %}</p>
|
|
||||||
{{ form.chemo_strength }}
|
|
||||||
{% if form.chemo_strength.errors %}
|
|
||||||
<span class="w3-text-red w3-small">{{ form.chemo_strength.errors|join:", " }}</span>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="w3-col m4 s12 w3-margin-bottom">
|
|
||||||
<label class="w3-text-teal"><b>{{ form.chemo_x.label }}</b></label>
|
|
||||||
{{ form.chemo_x }}
|
|
||||||
{% if form.chemo_x.errors %}
|
|
||||||
<span class="w3-text-red w3-small">{{ form.chemo_x.errors|join:", " }}</span>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="w3-col m4 s12 w3-margin-bottom">
|
|
||||||
<label class="w3-text-teal"><b>{{ form.chemo_y.label }}</b></label>
|
|
||||||
{{ form.chemo_y }}
|
|
||||||
{% if form.chemo_y.errors %}
|
|
||||||
<span class="w3-text-red w3-small">{{ form.chemo_y.errors|join:", " }}</span>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="w3-col m6 s12 w3-margin-bottom">
|
|
||||||
<label class="w3-text-teal"><b>{{ form.chemo_radius_mm.label }}</b></label>
|
|
||||||
{{ form.chemo_radius_mm }}
|
|
||||||
{% if form.chemo_radius_mm.errors %}
|
|
||||||
<span class="w3-text-red w3-small">{{ form.chemo_radius_mm.errors|join:", " }}</span>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- --- Interactions inter-individus --- -->
|
|
||||||
<div class="w3-container w3-padding-16">
|
|
||||||
<button type="button" class="w3-button w3-block w3-left-align w3-hover-light-grey"
|
|
||||||
onclick="toggleSection('social')">
|
|
||||||
<span class="w3-large">🔀</span>
|
|
||||||
<b class="w3-margin-left">{% trans "Interactions inter-individus" %}</b>
|
|
||||||
<span class="w3-small w3-text-grey w3-margin-left">
|
|
||||||
{% trans "Évitement et agrégation" %}
|
|
||||||
</span>
|
|
||||||
<span id="social-icon" class="w3-right">▼</span>
|
|
||||||
</button>
|
|
||||||
<div id="social" class="w3-hide w3-padding-top">
|
|
||||||
<div class="w3-row-padding">
|
|
||||||
|
|
||||||
<div class="w3-col m6 s12 w3-margin-bottom">
|
|
||||||
<label class="w3-text-teal"><b>{{ form.avoid_radius_mm.label }}</b></label>
|
|
||||||
<p class="w3-small w3-text-grey" style="margin:0 0 4px;">{% trans "Rayon de répulsion courte portée (mm)" %}</p>
|
|
||||||
{{ form.avoid_radius_mm }}
|
|
||||||
{% if form.avoid_radius_mm.errors %}
|
|
||||||
<span class="w3-text-red w3-small">{{ form.avoid_radius_mm.errors|join:", " }}</span>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="w3-col m6 s12 w3-margin-bottom">
|
|
||||||
<label class="w3-text-teal"><b>{{ form.aggreg_radius_mm.label }}</b></label>
|
|
||||||
<p class="w3-small w3-text-grey" style="margin:0 0 4px;">{% trans "Rayon d'attraction longue portée — doit être > rayon évitement (mm)" %}</p>
|
|
||||||
{{ form.aggreg_radius_mm }}
|
|
||||||
{% if form.aggreg_radius_mm.errors %}
|
|
||||||
<span class="w3-text-red w3-small">{{ form.aggreg_radius_mm.errors|join:", " }}</span>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div><!-- fin card comportements -->
|
|
||||||
|
|
||||||
<!-- ============================================================
|
|
||||||
Boutons d'action
|
|
||||||
============================================================ -->
|
|
||||||
<div class="w3-row-padding w3-margin-top">
|
|
||||||
|
|
||||||
<div class="w3-col m8 s12 w3-margin-bottom">
|
|
||||||
<button type="submit" class="w3-button w3-teal w3-round w3-large w3-padding-large">
|
|
||||||
{% if object %}
|
|
||||||
💾 {% trans "Enregistrer les modifications" %}
|
|
||||||
{% else %}
|
|
||||||
➕ {% trans "Créer la configuration" %}
|
|
||||||
{% endif %}
|
|
||||||
</button>
|
|
||||||
<a href="{% url 'planarian:experiment-list' %}"
|
|
||||||
class="w3-button w3-light-grey w3-round w3-large w3-padding-large w3-margin-left">
|
|
||||||
✖ {% trans "Annuler" %}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if object %}
|
|
||||||
<div class="w3-col m4 s12 w3-right-align w3-margin-bottom">
|
|
||||||
<a href="{% url 'planarian:export-csv' %}?experiment={{ object.experiment }}&well={{ object.well }}"
|
|
||||||
class="w3-button w3-blue w3-round w3-large w3-padding-large">
|
|
||||||
📥 {% trans "Exporter CSV" %}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</form><!-- fin form -->
|
|
||||||
|
|
||||||
</div><!-- fin container -->
|
|
||||||
|
|
||||||
<!-- ============================================================
|
|
||||||
Styles W3.CSS pour les champs de formulaire Django
|
|
||||||
(Django génère des <input> bruts sans classes — on les stylise ici)
|
|
||||||
============================================================ -->
|
|
||||||
<style>
|
|
||||||
/* Champs texte, number, select */
|
|
||||||
input[type="text"],
|
|
||||||
input[type="number"],
|
|
||||||
input[type="email"],
|
|
||||||
textarea,
|
|
||||||
select {
|
|
||||||
width: 100%;
|
|
||||||
padding: 8px 12px;
|
|
||||||
border: 1px solid #ccc;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 15px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
margin-top: 2px;
|
|
||||||
background-color: #fafafa;
|
|
||||||
transition: border-color 0.2s;
|
|
||||||
}
|
|
||||||
input[type="text"]:focus,
|
|
||||||
input[type="number"]:focus,
|
|
||||||
textarea:focus,
|
|
||||||
select:focus {
|
|
||||||
border-color: #009688; /* teal W3.CSS */
|
|
||||||
outline: none;
|
|
||||||
background-color: #fff;
|
|
||||||
}
|
|
||||||
/* Champ en erreur */
|
|
||||||
input.errorfield, select.errorfield, textarea.errorfield {
|
|
||||||
border-color: #f44336;
|
|
||||||
background-color: #fff8f8;
|
|
||||||
}
|
|
||||||
/* Label au-dessus du champ */
|
|
||||||
label {
|
|
||||||
display: block;
|
|
||||||
margin-bottom: 2px;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
/* Sections accordéon */
|
|
||||||
[id$="-icon"] {
|
|
||||||
transition: transform 0.2s;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
/**
|
|
||||||
* Bascule la visibilité d'une section accordéon.
|
|
||||||
* @param {string} id - Identifiant de la section (sans le préfixe)
|
|
||||||
*/
|
|
||||||
function toggleSection(id) {
|
|
||||||
const section = document.getElementById(id);
|
|
||||||
const icon = document.getElementById(id + '-icon');
|
|
||||||
if (section.classList.contains('w3-hide')) {
|
|
||||||
section.classList.remove('w3-hide');
|
|
||||||
if (icon) icon.textContent = '▲';
|
|
||||||
} else {
|
|
||||||
section.classList.add('w3-hide');
|
|
||||||
if (icon) icon.textContent = '▼';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Ouvrir automatiquement les sections qui contiennent des erreurs */
|
|
||||||
document.addEventListener('DOMContentLoaded', function () {
|
|
||||||
['thigmo', 'photo', 'chemo', 'social'].forEach(function (id) {
|
|
||||||
const section = document.getElementById(id);
|
|
||||||
if (section && section.querySelector('.w3-text-red')) {
|
|
||||||
section.classList.remove('w3-hide');
|
|
||||||
const icon = document.getElementById(id + '-icon');
|
|
||||||
if (icon) icon.textContent = '▲';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,334 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
{% load i18n %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
|
|
||||||
<div class="w3-container w3-padding-32" style="max-width:1200px; margin:auto;">
|
|
||||||
|
|
||||||
<!-- En-tête -->
|
|
||||||
<div class="w3-panel w3-teal w3-round-large w3-padding-16 w3-margin-bottom">
|
|
||||||
<div class="w3-row">
|
|
||||||
<div class="w3-col s12 m8 w3-bar-item">
|
|
||||||
<span class="w3-xlarge">
|
|
||||||
<i class="w3-margin-right">🔬</i>
|
|
||||||
{% trans "Configurations d'expériences" %}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="w3-col s12 m4 w3-bar-item w3-right-align" style="padding-top:6px;">
|
|
||||||
<a href="{% url 'planarian:experiment-new' %}"
|
|
||||||
class="w3-button w3-white w3-round w3-text-teal">
|
|
||||||
➕ {% trans "Nouvelle configuration" %}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Messages Django -->
|
|
||||||
{% for message in messages %}
|
|
||||||
<div class="w3-panel w3-round
|
|
||||||
{% if message.tags == 'success' %}w3-green
|
|
||||||
{% elif message.tags == 'error' %}w3-red
|
|
||||||
{% elif message.tags == 'warning' %}w3-orange
|
|
||||||
{% else %}w3-blue{% endif %}">
|
|
||||||
<p>{{ message }}</p>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
|
|
||||||
<!-- Barre d'outils : recherche + import -->
|
|
||||||
<div class="w3-row-padding w3-margin-bottom">
|
|
||||||
|
|
||||||
<!-- Recherche côté client -->
|
|
||||||
<div class="w3-col m7 s12 w3-margin-bottom">
|
|
||||||
<div class="w3-border w3-round" style="display:flex; align-items:center; background:#fafafa;">
|
|
||||||
<span style="padding:0 10px; font-size:18px; color:#888;">🔍</span>
|
|
||||||
<input type="text" id="search-input"
|
|
||||||
placeholder="{% trans 'Filtrer par expérience, puits…' %}"
|
|
||||||
class="w3-input w3-border-0"
|
|
||||||
style="background:transparent;"
|
|
||||||
oninput="filterTable(this.value)">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Boutons import / export -->
|
|
||||||
<div class="w3-col m5 s12 w3-right-align w3-margin-bottom">
|
|
||||||
<a href="{% url 'planarian:import-params' %}"
|
|
||||||
class="w3-button w3-blue-grey w3-round w3-margin-right">
|
|
||||||
📂 {% trans "Importer CSV" %}
|
|
||||||
</a>
|
|
||||||
<a href="{% url 'planarian:export-csv' %}"
|
|
||||||
class="w3-button w3-blue w3-round">
|
|
||||||
📥 {% trans "Exporter CSV" %}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ============================================================
|
|
||||||
Tableau des configurations
|
|
||||||
============================================================ -->
|
|
||||||
{% if configs %}
|
|
||||||
|
|
||||||
<!-- Compteur -->
|
|
||||||
<p class="w3-text-grey w3-small" id="row-count">
|
|
||||||
{{ configs|length }} {% trans "configuration(s)" %}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="w3-responsive w3-card-4 w3-round-large">
|
|
||||||
<table class="w3-table w3-striped w3-hoverable w3-bordered" id="config-table">
|
|
||||||
<thead>
|
|
||||||
<tr class="w3-teal">
|
|
||||||
<th onclick="sortTable(0)" class="sortable" title="{% trans 'Trier' %}">
|
|
||||||
{% trans "Expérience" %} <span class="sort-icon">↕</span>
|
|
||||||
</th>
|
|
||||||
<th onclick="sortTable(1)" class="sortable" title="{% trans 'Trier' %}">
|
|
||||||
{% trans "Puits" %} <span class="sort-icon">↕</span>
|
|
||||||
</th>
|
|
||||||
<th onclick="sortTable(2)" class="sortable w3-hide-small" title="{% trans 'Trier' %}">
|
|
||||||
{% trans "px/mm" %} <span class="sort-icon">↕</span>
|
|
||||||
</th>
|
|
||||||
<th class="w3-hide-small">{% trans "FPS" %}</th>
|
|
||||||
<th class="w3-hide-small">{% trans "Seuils (mm/s)" %}</th>
|
|
||||||
<th class="w3-hide-small">{% trans "Comportements" %}</th>
|
|
||||||
<th onclick="sortTable(6)" class="sortable w3-hide-small" title="{% trans 'Trier' %}">
|
|
||||||
{% trans "Créé le" %} <span class="sort-icon">↕</span>
|
|
||||||
</th>
|
|
||||||
<th>{% trans "Actions" %}</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody id="config-tbody">
|
|
||||||
{% for cfg in configs %}
|
|
||||||
<tr class="config-row">
|
|
||||||
|
|
||||||
<!-- Expérience -->
|
|
||||||
<td>
|
|
||||||
<b>{{ cfg.experiment }}</b>
|
|
||||||
{% if cfg.description %}
|
|
||||||
<br><span class="w3-small w3-text-grey">{{ cfg.description|truncatechars:40 }}</span>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
|
|
||||||
<!-- Puits -->
|
|
||||||
<td>
|
|
||||||
<span class="w3-tag w3-teal w3-round">{{ cfg.well }}</span>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
<!-- px/mm -->
|
|
||||||
<td class="w3-hide-small">{{ cfg.px_per_mm }}</td>
|
|
||||||
|
|
||||||
<!-- FPS -->
|
|
||||||
<td class="w3-hide-small">{{ cfg.fps }}</td>
|
|
||||||
|
|
||||||
<!-- Seuils de mobilité -->
|
|
||||||
<td class="w3-hide-small">
|
|
||||||
<span class="w3-tag w3-red w3-round w3-small"
|
|
||||||
title="{% trans 'Immobile' %}">
|
|
||||||
< {{ cfg.thresh_immobile }}
|
|
||||||
</span>
|
|
||||||
<span class="w3-tag w3-orange w3-round w3-small"
|
|
||||||
title="{% trans 'Mobile' %}">
|
|
||||||
< {{ 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 }}">
|
||||||
{% if form.non_field_errors %}
|
<input type="hidden" id="_expid" name="_expid" value="{{ current_experiment.id }}">
|
||||||
<div class="w3-panel w3-red w3-round">
|
|
||||||
{% for error in form.non_field_errors %}
|
|
||||||
<p>⚠ {{ error }}</p>
|
<div class="w3-card-4 w3-border w3-round w3-round-large">
|
||||||
{% endfor %}
|
<header class="w3-container w3-blue w3-round w3-round-top-large">
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<div class="w3-card-4 w3-round-large">
|
|
||||||
<header class="w3-container w3-blue 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,56 +1,26 @@
|
|||||||
{% 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 }}">
|
||||||
{% if form.non_field_errors %}
|
<input type="hidden" id="_expid" name="_expid" value="{{ current_experiment.id }}">
|
||||||
<div class="w3-panel w3-red w3-round">
|
|
||||||
{% for error in form.non_field_errors %}
|
<div class="w3-card-4 w3-margin-bottom w3-border w3-round w3-round-large">
|
||||||
<p>⚠ {{ error }}</p>
|
<header class="w3-container w3-blue-grey w3-round w3-round-top-large">
|
||||||
{% endfor %}
|
<h3 class="w3-text-white">{% trans "Fichier CSV à importer" %}: <span class="w3-large w3-text-lime">{{ current_experiment }}</span></h3>
|
||||||
</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"
|
||||||
class="w3-border w3-border-blue-grey w3-round-large w3-padding-32"
|
class="w3-border w3-border-blue-grey w3-round-large w3-padding-32"
|
||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -6,16 +6,12 @@ from planarian import views
|
|||||||
app_name = "planarian"
|
app_name = "planarian"
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
# Configurations expériences
|
# Import / export
|
||||||
path("experiments/", views.ExperimentConfigListView.as_view(), name="experiment-list"),
|
path("import/csv/", views.import_csv_view, name="import-params"),
|
||||||
path("experiments/new/", views.ExperimentConfigFormView.as_view(), name="experiment-new"),
|
path("export/csv/", views.export_csv_view, name="export-csv"),
|
||||||
path("experiments/<int:pk>/",views.ExperimentConfigFormView.as_view(), name="experiment-edit"),
|
path("api/export/csv/", views.export_metrics, name="api-export-csv"),
|
||||||
|
|
||||||
# Import / export
|
|
||||||
path("import/", views.ImportParamsView.as_view(), name="import-params"),
|
|
||||||
path("export/", views.ExportCsvView.as_view(), name="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"),
|
||||||
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -2,178 +2,233 @@
|
|||||||
|
|
||||||
#import asyncio
|
#import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
import json
|
||||||
from asgiref.sync import async_to_sync
|
from asgiref.sync import async_to_sync
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
from django.http import HttpResponse, JsonResponse
|
from django.http import JsonResponse, FileResponse
|
||||||
from django.shortcuts import get_object_or_404, redirect #, render
|
from django.shortcuts import redirect
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
from django.shortcuts import render #, redirect
|
||||||
|
from django.views.decorators.http import require_GET
|
||||||
|
from django.contrib.auth.decorators import login_required
|
||||||
from django.views import View
|
from django.views import View
|
||||||
from django.views.generic import FormView, ListView
|
|
||||||
|
|
||||||
|
|
||||||
from .forms import CsvImportForm, ExperimentConfigForm, ExportCsvForm
|
|
||||||
from .models import ExperimentConfig
|
|
||||||
from modules.planarian_metrics import ExperimentParams, ReductStoreClient
|
from modules.planarian_metrics import ExperimentParams, ReductStoreClient
|
||||||
|
from modules.system_stats import get_cached_stats, start_background_updater
|
||||||
|
from scanner.constants import ScannerConstants
|
||||||
|
|
||||||
|
from .tasks import export_experiment_metrics_task, export_session_metrics_task
|
||||||
|
from .export_service import export_csv_sync
|
||||||
|
from scanner import models, views as scanner_views
|
||||||
|
from .models import ExperimentConfig
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
start_background_updater()
|
||||||
|
|
||||||
|
|
||||||
|
def is_staff_or_admin(user):
|
||||||
|
return user.is_staff or user.is_superuser
|
||||||
|
|
||||||
|
|
||||||
|
@require_GET
|
||||||
|
def stats_view(request):
|
||||||
|
"""
|
||||||
|
Retourne tout le cache (shm, cpu_info, memory_info, disk_info, updated_at)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
data = get_cached_stats()
|
||||||
|
return JsonResponse(data, safe=False)
|
||||||
|
except Exception as e:
|
||||||
|
return JsonResponse({"error": str(e)}, status=500)
|
||||||
|
|
||||||
|
|
||||||
def _get_reduct_client() -> ReductStoreClient:
|
def _get_reduct_client() -> ReductStoreClient:
|
||||||
"""Instancie le client ReductStore depuis les settings Django."""
|
"""Instancie le client ReductStore depuis les settings Django."""
|
||||||
return ReductStoreClient(
|
return ReductStoreClient(url=settings.REDUCTSTORE_URL, token=settings.REDUCTSTORE_TOKEN)
|
||||||
url = getattr(settings, "REDUCTSTORE_URL", "http://localhost:8383"),
|
|
||||||
token = getattr(settings, "REDUCTSTORE_TOKEN", ""),
|
|
||||||
bucket = getattr(settings, "REDUCTSTORE_BUCKET", "planarian_metrics"),
|
def global_context(request, **ctx):
|
||||||
|
conf = ScannerConstants().get()
|
||||||
|
return dict(
|
||||||
|
app_title=settings.APP_TITLE,
|
||||||
|
app_sub_title=settings.APP_SUB_TITLE,
|
||||||
|
domain_server=settings.DOMAIN_SERVER,
|
||||||
|
local_ip_server=settings.LOCAL_IP_SERVER,
|
||||||
|
host_port=settings.SERVER_HOST_PORT,
|
||||||
|
export_csv_destination=settings.CSV_EXPORT_DIR,
|
||||||
|
conf=conf,
|
||||||
|
**ctx
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_active_session(request, session_id=None, experiment_id=None):
|
||||||
|
cursid = session_id or request.POST.get('_sid')
|
||||||
|
expid = experiment_id or request.POST.get('_expid')
|
||||||
|
|
||||||
|
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 : liste des configurations
|
# Vue :Export CSV depuis ReductStore
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
@login_required
|
||||||
class ExperimentConfigListView(ListView):
|
def export_metrics(request):
|
||||||
"""Liste toutes les configurations expériences."""
|
data = json.loads(request.body.decode() or "{}")
|
||||||
|
action = data.get("action")
|
||||||
model = ExperimentConfig
|
pid = data.get("pid")
|
||||||
template_name = "planarian/experiment_list.html"
|
if action=='experiment_csv':
|
||||||
context_object_name = "configs"
|
experiment = models.Experiment.objects.filter(pk=pid).first()
|
||||||
ordering = ["-created_at"]
|
if experiment:
|
||||||
|
export_experiment_metrics_task.delay(experiment.pk) # @UndefinedVariable
|
||||||
|
return JsonResponse({"success": True, "msg": str(_("Métrics en cours de téléchargement"))})
|
||||||
|
if action=='session_csv':
|
||||||
|
if pid:
|
||||||
|
export_session_metrics_task(pid) # @UndefinedVariable
|
||||||
|
return JsonResponse({"success": True, "msg": str(_("Métrics en cours de téléchargement"))})
|
||||||
|
return JsonResponse({"success": False, "msg": str(_("Erreur: les métrics non pas été téléchargés"))})
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
@login_required
|
||||||
# Vue : création / modification d'une configuration
|
def export_csv_view(request):
|
||||||
# ---------------------------------------------------------------------------
|
session_context = get_active_session(request)
|
||||||
|
ctx = {
|
||||||
|
'choice_title': _("Export vers un fichier CSV depuis ReductStore"),
|
||||||
|
'well': 'A1',
|
||||||
|
'planarian': "0",
|
||||||
|
'record_type': 'frame',
|
||||||
|
**session_context
|
||||||
|
}
|
||||||
|
if request.method == 'POST':
|
||||||
|
valid = request.POST.get('valid')
|
||||||
|
session = session_context['current_session']
|
||||||
|
experiment = session_context['current_experiment']
|
||||||
|
if valid == 'ok' and session and experiment:
|
||||||
|
well_name = request.POST.get('well')
|
||||||
|
uuid = models.get_uuid_from_session(session.pk, experiment.multiwell.position, well_name) # type: ignore[union-attr]
|
||||||
|
'''
|
||||||
|
csv_content, filename = export_csv(request, uuid)
|
||||||
|
if csv_content:
|
||||||
|
response = FileResponse(csv_content, content_type="text/csv; charset=utf-8")
|
||||||
|
response["Content-Disposition"] = f'attachment; filename="{filename}"'
|
||||||
|
return response'''
|
||||||
|
csv_content, n = export_csv_sync(
|
||||||
|
experiment=experiment.identifier, # type: ignore[union-attr]
|
||||||
|
well=well_name,
|
||||||
|
uuid=uuid,
|
||||||
|
planarian=request.POST.get("planarian"),
|
||||||
|
record_type=request.POST.get("record_type"),
|
||||||
|
start=request.POST.get("start_dt"),
|
||||||
|
stop=request.POST.get("stop_dt"),
|
||||||
|
)
|
||||||
|
if csv_content:
|
||||||
|
filename = (
|
||||||
|
f"{experiment.identifier}_{well_name}-{request.POST.get('planarian')}_" # type: ignore[union-attr]
|
||||||
|
f"{request.POST.get('record_type')}.csv"
|
||||||
|
)
|
||||||
|
logger.info(f"Export CSV: {n} lignes, content size={len(csv_content)}")
|
||||||
|
response = FileResponse(csv_content, content_type="text/csv; charset=utf-8")
|
||||||
|
response["Content-Disposition"] = (f'attachment; filename="{filename}"')
|
||||||
|
return response
|
||||||
|
|
||||||
class ExperimentConfigFormView(FormView):
|
messages.warning(request, _("Aucune donnée trouvée."))
|
||||||
"""Formulaire de saisie des paramètres d'une expérience."""
|
return render(request, "planarian/export_csv.html", context=global_context(request, **ctx))
|
||||||
|
|
||||||
template_name = "planarian/experiment_form.html"
|
|
||||||
form_class = ExperimentConfigForm
|
|
||||||
|
|
||||||
def get_form(self, form_class=None):
|
|
||||||
pk = self.kwargs.get("pk")
|
|
||||||
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)
|
||||||
template_name = "planarian/import_params.html"
|
if request.method == 'POST':
|
||||||
form_class = CsvImportForm
|
valid = request.POST.get('valid')
|
||||||
|
current_experiment = session_context.get('current_experiment')
|
||||||
def form_valid(self, form):
|
|
||||||
rows = form.csv_rows
|
if valid == 'ok' and current_experiment:
|
||||||
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:
|
||||||
obj, is_new = ExperimentConfig.objects.get_or_create(
|
content = f.read().decode("utf-8-sig")
|
||||||
experiment = d["experiment"],
|
reader = csv.DictReader(io.StringIO(content))
|
||||||
well = d["well"],
|
rows = list(reader)
|
||||||
)
|
except Exception as e:
|
||||||
|
msg = f'Fichier CSV invalide : {e}'
|
||||||
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
|
|
||||||
|
|
||||||
|
required = {"well", "px_per_mm", "fps"}
|
||||||
|
if rows:
|
||||||
|
missing = required - set(rows[0].keys())
|
||||||
|
if missing:
|
||||||
|
msg = _("Colonnes manquantes : %(cols)s") % {"cols": ", ".join(missing)}
|
||||||
|
raise Exception(msg)
|
||||||
|
|
||||||
|
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, e)
|
||||||
errors += 1
|
logger.error(e)
|
||||||
|
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 +257,13 @@ class TrackingDataView(View):
|
|||||||
planarian = planarian,
|
planarian = planarian,
|
||||||
record_type = record_type,
|
record_type = record_type,
|
||||||
)
|
)
|
||||||
finally:
|
except Exception as e:
|
||||||
await client.close()
|
logger.error(f"Erreur fetching tracking data: {e}")
|
||||||
|
|
||||||
records = _fetch()
|
records = _fetch()
|
||||||
return JsonResponse({"count": len(records), "records": records})
|
return JsonResponse({"count": len(records), "records": records})
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context = super().get_context_data(**kwargs) # type: ignore[attr-defined]
|
||||||
|
return global_context(self.request, choice_title=str(_("Métriques de tracking d'un planaire")), **context)
|
||||||
|
|
||||||
|
|||||||
Executable
+1335
File diff suppressed because it is too large
Load Diff
Executable
+5
@@ -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
|
||||||
@@ -1,33 +1,85 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
from django.utils.html import format_html
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from . import models
|
from . import models
|
||||||
|
|
||||||
|
|
||||||
class WellAdmin(admin.ModelAdmin):
|
class WellAdmin(admin.ModelAdmin):
|
||||||
model = models.Well
|
model = models.Well
|
||||||
list_display = ('name', 'author',)
|
list_display = ('name', 'author',)
|
||||||
|
|
||||||
class ConfigurationAdmin(admin.ModelAdmin):
|
class ConfigurationAdmin(admin.ModelAdmin):
|
||||||
list_display = ('name', 'author', 'capture_type', 'video_width_capture', 'video_height_capture', 'video_frame_rate', 'active',)
|
list_display = ('name', 'author', 'capture_type', 'video_width_capture', 'video_height_capture', 'video_frame_rate', 'active',)
|
||||||
|
|
||||||
|
|
||||||
|
fieldsets = (
|
||||||
|
(_("Identification"), {
|
||||||
|
"fields": ("name", "author", "active"),
|
||||||
|
}),
|
||||||
|
(_("Dashboard"), {
|
||||||
|
"fields": ("sidebar_width", "default_grid_columns",),"classes": ("collapse",),
|
||||||
|
}),
|
||||||
|
(_("opencv"), {
|
||||||
|
"fields": ("opencv_fourcc_format", "opencv_video_type"),"classes": ("collapse",),
|
||||||
|
}),
|
||||||
|
(_("Grbl"), {
|
||||||
|
"fields": ("grbl_xmax", "grbl_ymax"),
|
||||||
|
"classes": ("collapse",),
|
||||||
|
}),
|
||||||
|
(_("Camera"), {
|
||||||
|
"fields": ("scan_simulation", "capture_type", "webcam_device_index", "image_quality", "video_jpeg_quality", "video_frame_rate", "video_width_capture", "video_height_capture"),
|
||||||
|
"classes": ("collapse",),
|
||||||
|
}),
|
||||||
|
(_("Calibration / Balayage"), {
|
||||||
|
"fields": ("tube_axis", "calibration_crop_radius", "calibration_default_multiwell", "calibration_default_feed", "calibration_default_step", "calibration_default_duration"),
|
||||||
|
"classes": ("collapse",),
|
||||||
|
}),
|
||||||
|
(_("Tracking: valeurs par défaut"), {
|
||||||
|
"fields": ("tracking", "tracking_setting", "min_area_px", "max_area_ratio", "max_planarians", "merge_kernel_size", "min_contour_dist_px"),
|
||||||
|
"classes": ("collapse",),
|
||||||
|
}),
|
||||||
|
|
||||||
|
|
||||||
|
)
|
||||||
|
|
||||||
class MultiWellAdmin(admin.ModelAdmin):
|
class MultiWellAdmin(admin.ModelAdmin):
|
||||||
list_filter = ('author', )
|
list_filter = ('author', )
|
||||||
list_display = ('label', 'position', 'author', 'order', 'xbase', 'ybase', 'duration', 'feed', 'default', 'well_position', 'active',)
|
list_display = ('label', 'position', 'author', 'order', 'xbase', 'ybase', 'duration', 'feed', 'default', 'well_position', 'capture_video', 'active',)
|
||||||
|
ordering = ('label', 'order')
|
||||||
|
fieldsets = (
|
||||||
|
(_("Identification"), {
|
||||||
|
"fields": ("label", "author", "position", "default", "capture_video", "active"),
|
||||||
|
}),
|
||||||
|
(_("Géométrie"), {
|
||||||
|
"fields": ("cols", "rows", "diameter", "crop_radius", "row_def", "row_order"),"classes": ("collapse",),
|
||||||
|
}),
|
||||||
|
(_("Déplacement"), {
|
||||||
|
"fields": ("order", "duration", "xbase", "ybase", "dx", "dy", "feed"),"classes": ("collapse",),
|
||||||
|
}),
|
||||||
|
(_("Positions générées"), {
|
||||||
|
"fields": ("well_position",),
|
||||||
|
}),
|
||||||
|
|
||||||
|
)
|
||||||
|
|
||||||
class WellPositionAdmin(admin.ModelAdmin):
|
class WellPositionAdmin(admin.ModelAdmin):
|
||||||
list_filter = ('author', 'multiwell')
|
list_filter = ('author', 'multiwell')
|
||||||
list_display = ('multiwell__position', 'well__name', 'order', 'x', 'y', 'px_per_mm', 'author',)
|
list_display = ('multiwell__position', 'well__name', 'order', 'x', 'y', 'px_per_mm', 'author',)
|
||||||
|
|
||||||
|
|
||||||
|
class ExperimentWellInline(admin.TabularInline):
|
||||||
|
model = models.ExperimentWell
|
||||||
|
extra = 0
|
||||||
|
#ordering = ('experiment__multiwell__wellposition__order',)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
#class ExperimentConfigInline(admin.TabularInline):
|
|
||||||
# model = models.ExperimentConfig
|
|
||||||
# extra = 0
|
|
||||||
|
|
||||||
class ExperimentAdmin(admin.ModelAdmin):
|
class ExperimentAdmin(admin.ModelAdmin):
|
||||||
#inlines = (ExperimenConfigInline,)
|
inlines = (ExperimentWellInline, )
|
||||||
list_filter = ('session_experiments__session', 'author', )
|
list_filter = ('session_experiments__session', 'author', )
|
||||||
list_display = ('title', 'author', 'multiwell', 'created', 'started', 'finished')
|
list_display = ('title', 'author', 'identifier', 'duration', 'multiwell', 'created', 'started', 'finished')
|
||||||
readonly_fields = ('created', 'started', 'finished', )
|
readonly_fields = ('created', 'identifier', 'started', 'finished', )
|
||||||
|
|
||||||
|
|
||||||
class SessionExperimentInlineAdmin(admin.TabularInline):
|
class SessionExperimentInlineAdmin(admin.TabularInline):
|
||||||
model = models.SessionExperiment
|
model = models.SessionExperiment
|
||||||
@@ -51,7 +103,7 @@ class SessionExperimentInlineAdmin(admin.TabularInline):
|
|||||||
class SessionAdmin(admin.ModelAdmin):
|
class SessionAdmin(admin.ModelAdmin):
|
||||||
list_filter = ('author',)
|
list_filter = ('author',)
|
||||||
inlines = (SessionExperimentInlineAdmin, )
|
inlines = (SessionExperimentInlineAdmin, )
|
||||||
list_display = ('name', 'author', 'created', 'finished', 'active', 'expected_export', 'expected_scanning', )
|
list_display = ('name', 'id', 'author', 'created', 'finished', 'active', 'expected_export', 'expected_scanning', )
|
||||||
readonly_fields = (
|
readonly_fields = (
|
||||||
'created',
|
'created',
|
||||||
'finished',
|
'finished',
|
||||||
@@ -63,6 +115,107 @@ class SessionAdmin(admin.ModelAdmin):
|
|||||||
'scanning_finished_at'
|
'scanning_finished_at'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@admin.register(models.VideoPlate)
|
||||||
|
class VideoPlateAdmin(admin.ModelAdmin):
|
||||||
|
|
||||||
|
list_display = ['multiwell', 'label', 'video_filename', 'active',
|
||||||
|
'fps_display', 'duration_display', 'resolution_display', 'uploaded_at']
|
||||||
|
list_filter = ['multiwell', 'active']
|
||||||
|
list_editable = ['active']
|
||||||
|
readonly_fields = [
|
||||||
|
'native_fps', 'duration_s', 'frame_w', 'frame_h',
|
||||||
|
'uploaded_at', 'resolution_display', 'video_preview',
|
||||||
|
]
|
||||||
|
fields = [
|
||||||
|
'multiwell', 'label', 'video_file', 'active', 'px_per_mm',
|
||||||
|
'x_origin_mm', 'y_origin_mm',
|
||||||
|
'video_preview',
|
||||||
|
'native_fps', 'duration_s', 'frame_w', 'frame_h', 'uploaded_at',
|
||||||
|
]
|
||||||
|
|
||||||
|
class Media:
|
||||||
|
css = {'all': ('scanner/css/video_upload.css',)}
|
||||||
|
js = ('scanner/js/video_upload.js',)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Colonnes liste
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@admin.display(description=_("Fichier"), ordering='video_file')
|
||||||
|
def video_filename(self, obj):
|
||||||
|
return obj.video_filename
|
||||||
|
|
||||||
|
@admin.display(description=_("FPS"))
|
||||||
|
def fps_display(self, obj):
|
||||||
|
return f"{obj.native_fps:.2f}" if obj.native_fps else "—"
|
||||||
|
|
||||||
|
@admin.display(description=_("Durée"))
|
||||||
|
def duration_display(self, obj):
|
||||||
|
if not obj.duration_s:
|
||||||
|
return "—"
|
||||||
|
m, s = divmod(int(obj.duration_s), 60)
|
||||||
|
return f"{m}:{s:02d}"
|
||||||
|
|
||||||
|
@admin.display(description=_("Résolution"))
|
||||||
|
def resolution_display(self, obj):
|
||||||
|
return obj.resolution
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Aperçu vidéo (readonly field)
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@admin.display(description=_("Aperçu"))
|
||||||
|
def video_preview(self, obj):
|
||||||
|
if not obj.video_file:
|
||||||
|
return "—"
|
||||||
|
return format_html(
|
||||||
|
'<video src="{}" controls style="max-width:480px;max-height:320px;'
|
||||||
|
'border-radius:4px;"></video>',
|
||||||
|
obj.video_file.url,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Sauvegarde : extraction des métadonnées
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def save_model(self, request, obj, form, change):
|
||||||
|
super().save_model(request, obj, form, change)
|
||||||
|
if obj.video_file:
|
||||||
|
self._extract_metadata(obj)
|
||||||
|
|
||||||
|
def _extract_metadata(self, obj):
|
||||||
|
try:
|
||||||
|
import cv2
|
||||||
|
cap = cv2.VideoCapture(obj.video_file.path)
|
||||||
|
if cap.isOpened():
|
||||||
|
fps = cap.get(cv2.CAP_PROP_FPS)
|
||||||
|
fc = cap.get(cv2.CAP_PROP_FRAME_COUNT)
|
||||||
|
models.VideoPlate.objects.filter(pk=obj.pk).update(
|
||||||
|
native_fps = fps,
|
||||||
|
duration_s = (fc / fps) if fps else None,
|
||||||
|
frame_w = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)),
|
||||||
|
frame_h = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)),
|
||||||
|
)
|
||||||
|
cap.release()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Suppression : efface aussi le fichier physique
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def delete_model(self, request, obj):
|
||||||
|
if obj.video_file:
|
||||||
|
Path(obj.video_file.path).unlink(missing_ok=True)
|
||||||
|
super().delete_model(request, obj)
|
||||||
|
|
||||||
|
def delete_queryset(self, request, queryset):
|
||||||
|
for obj in queryset:
|
||||||
|
if obj.video_file:
|
||||||
|
Path(obj.video_file.path).unlink(missing_ok=True)
|
||||||
|
super().delete_queryset(request, queryset)
|
||||||
|
|
||||||
|
|
||||||
admin.site.register(models.Configuration, ConfigurationAdmin)
|
admin.site.register(models.Configuration, ConfigurationAdmin)
|
||||||
admin.site.register(models.Well, WellAdmin)
|
admin.site.register(models.Well, WellAdmin)
|
||||||
admin.site.register(models.MultiWell, MultiWellAdmin)
|
admin.site.register(models.MultiWell, MultiWellAdmin)
|
||||||
|
|||||||
@@ -19,17 +19,25 @@ class DefaultConfig:
|
|||||||
webcam_device_index: int = 2
|
webcam_device_index: int = 2
|
||||||
image_quality: int = 90
|
image_quality: int = 90
|
||||||
video_jpeg_quality: int = 90
|
video_jpeg_quality: int = 90
|
||||||
video_frame_rate: int = 5.0
|
video_frame_rate: float = 5.0
|
||||||
video_width_capture: int = 2028
|
video_width_capture: int = 2028
|
||||||
video_height_capture: int = 1520
|
video_height_capture: int = 1520
|
||||||
|
scan_simulation: bool = False
|
||||||
calibration_crop_radius: int = 500
|
calibration_crop_radius: int = 500
|
||||||
calibration_default_multiwell: str = 'HD'
|
calibration_default_multiwell: str = 'HD'
|
||||||
calibration_default_feed: int = 1000
|
calibration_default_feed: int = 1000
|
||||||
calibration_default_step: float = 1.0
|
calibration_default_step: float = 1.0
|
||||||
calibration_default_duration: float = 3.0
|
calibration_default_duration: float = 3.0
|
||||||
tracking: bool = False
|
tracking: bool = False
|
||||||
|
tracking_setting: bool = False
|
||||||
|
tube_axis: str = 'vertical'
|
||||||
|
min_area_px: int = 20
|
||||||
|
max_area_ratio: float = 0.10
|
||||||
|
max_planarians: int = 1
|
||||||
|
merge_kernel_size: int = 15
|
||||||
|
min_contour_dist_px: int = 40
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class ScannerConstants:
|
class ScannerConstants:
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
@@ -44,4 +52,12 @@ class ScannerConstants:
|
|||||||
def get(self):
|
def get(self):
|
||||||
return self.conf
|
return self.conf
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_config(cls):
|
||||||
|
return Configuration.objects.filter(active=True).first()
|
||||||
|
|
||||||
|
def save_config(self):
|
||||||
|
d = asdict(self.conf)
|
||||||
|
Configuration.objects.filter(active=True).update(**d)
|
||||||
|
|
||||||
|
|
||||||
@@ -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):
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import posix_ipc
|
|||||||
import mmap
|
import mmap
|
||||||
import cv2
|
import cv2
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from django.http import JsonResponse, HttpResponse
|
#from django.http import JsonResponse, HttpResponse
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from reduct.time import unix_timestamp_to_iso
|
from reduct.time import unix_timestamp_to_iso
|
||||||
|
|
||||||
@@ -34,24 +34,20 @@ def delete_file_later(path):
|
|||||||
|
|
||||||
async def remove_video_by_uuid(uuid, start_ts=None, end_ts=None, when=None):
|
async def remove_video_by_uuid(uuid, start_ts=None, end_ts=None, when=None):
|
||||||
record_manager = CameraRecordManager(cameraDB)
|
record_manager = CameraRecordManager(cameraDB)
|
||||||
await record_manager.remove(uuid, start_ts, end_ts)
|
await record_manager.remove_uuid(uuid, start_ts, end_ts)
|
||||||
|
|
||||||
|
|
||||||
async def remove_video(uuid, start_ts, end_ts, when=None):
|
async def remove_video(uuid, start_ts, end_ts, when=None):
|
||||||
try:
|
await remove_video_by_uuid(uuid, start_ts, end_ts, when=when)
|
||||||
await remove_video_by_uuid(uuid, start_ts, end_ts, when=when)
|
|
||||||
return JsonResponse({'state': 'ok'}, status=200)
|
|
||||||
except Exception as e:
|
|
||||||
return JsonResponse({'error': str(e)}, status=500)
|
|
||||||
|
|
||||||
|
|
||||||
async def shm_download_video(uuid, start_ts, end_ts, frame_rate=5, opencv_fourcc_format='mp4v', opencv_video_type='mp4'):
|
async def shm_download_video(uuid, start_ts, end_ts, frame_rate=5, opencv_fourcc_format='mp4v', opencv_video_type='mp4'):
|
||||||
try:
|
video_path = os.path.join(settings.MEDIA_ROOT, f"output.{opencv_video_type}")
|
||||||
|
try:
|
||||||
record_manager = CameraRecordManager(cameraDB)
|
record_manager = CameraRecordManager(cameraDB)
|
||||||
total_size = await record_manager.size(uuid, start_ts, end_ts)
|
total_size = await record_manager.size(uuid, start_ts, end_ts)
|
||||||
|
|
||||||
# segment de mémoire partagée pour stocker les frames
|
# segment de mémoire partagée pour stocker les frames
|
||||||
shm_size = int(total_size * 1.5)
|
shm_size = int((total_size or 0) * 1.5)
|
||||||
shm_name = f"/video_frames_{uuid}"
|
shm_name = f"/video_frames_{uuid}"
|
||||||
try:
|
try:
|
||||||
shm = posix_ipc.SharedMemory(shm_name, posix_ipc.O_CREAT | posix_ipc.O_EXCL, size=shm_size)
|
shm = posix_ipc.SharedMemory(shm_name, posix_ipc.O_CREAT | posix_ipc.O_EXCL, size=shm_size)
|
||||||
@@ -79,14 +75,16 @@ async def shm_download_video(uuid, start_ts, end_ts, frame_rate=5, opencv_fourcc
|
|||||||
total += 1
|
total += 1
|
||||||
|
|
||||||
if not frame_sizes:
|
if not frame_sizes:
|
||||||
return JsonResponse({'error': 'Aucune frame trouvée'}, status=404)
|
raise Exception("No frame found!")
|
||||||
|
#return JsonResponse({'error': 'Aucune frame trouvée'}, status=404)
|
||||||
|
|
||||||
video_path = os.path.join(settings.MEDIA_ROOT, f"output.{opencv_video_type}")
|
#video_path = os.path.join(settings.MEDIA_ROOT, f"output.{opencv_video_type}")
|
||||||
fourcc = cv2.VideoWriter_fourcc(* opencv_fourcc_format)
|
fourcc = cv2.VideoWriter_fourcc(*opencv_fourcc_format) # type: ignore[attr-defined]
|
||||||
|
|
||||||
# Lit les frames depuis la mémoire partagée
|
# Lit les frames depuis la mémoire partagée
|
||||||
current_offset = 0
|
current_offset = 0
|
||||||
i = 0
|
i = 0
|
||||||
|
video = None
|
||||||
for size in frame_sizes:
|
for size in frame_sizes:
|
||||||
frame_bytes = mm[current_offset:current_offset + size]
|
frame_bytes = mm[current_offset:current_offset + size]
|
||||||
nparr = np.frombuffer(frame_bytes, np.uint8)
|
nparr = np.frombuffer(frame_bytes, np.uint8)
|
||||||
@@ -97,36 +95,17 @@ async def shm_download_video(uuid, start_ts, end_ts, frame_rate=5, opencv_fourcc
|
|||||||
video.write(frame)
|
video.write(frame)
|
||||||
current_offset += size
|
current_offset += size
|
||||||
|
|
||||||
progress_bar(i + 1, total, prefix=f'Progression {uuid}:', suffix='Terminé', length=30)
|
progress_bar(i + 1, total, prefix=f'Progress {uuid}:', suffix='Ended', length=30)
|
||||||
i+=1
|
i+=1
|
||||||
|
if video:
|
||||||
video.release()
|
video.release()
|
||||||
|
|
||||||
# Nettoie la mémoire partagée
|
# Nettoie la mémoire partagée
|
||||||
shm.unlink()
|
shm.unlink()
|
||||||
|
|
||||||
# Vérifier que le fichier existe
|
return { "status": 404, "success": True, "video_path": video_path}
|
||||||
if not os.path.exists(video_path):
|
|
||||||
logger.error(f"Fichier non créé: {video_path}")
|
|
||||||
return JsonResponse({'error': 'Erreur création vidéo'}, status=500)
|
|
||||||
|
|
||||||
# Lit le fichier vidéo généré
|
|
||||||
with open(video_path, 'rb') as f:
|
|
||||||
video_bytes = f.read()
|
|
||||||
|
|
||||||
# Retourne la vidéo en réponse
|
|
||||||
response = HttpResponse(video_bytes, content_type='video/mp4')
|
|
||||||
response['Content-Disposition'] = f'attachment; filename="{video_path}"'
|
|
||||||
response['Content-Length'] = os.path.getsize(video_path)
|
|
||||||
|
|
||||||
|
|
||||||
# Supprime le fichier temporaire
|
|
||||||
os.remove(video_path)
|
|
||||||
return response
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"shm_download_video: {e}")
|
return {"status": 500, "success": False, "video_path": video_path, "error": str(e)}
|
||||||
return JsonResponse({'error': str(e)}, status=500)
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────
|
# ─────────────────────────────────────────────
|
||||||
##
|
##
|
||||||
@@ -144,6 +123,10 @@ def _copy_to_destinations(source_path: str, filename: str) -> dict:
|
|||||||
Retourne un dict avec les chemins effectivement écrits.
|
Retourne un dict avec les chemins effectivement écrits.
|
||||||
"""
|
"""
|
||||||
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:
|
||||||
|
|
||||||
@@ -151,10 +134,10 @@ def _copy_to_destinations(source_path: str, filename: str) -> dict:
|
|||||||
# Déjà sur place, rien à copier
|
# Déjà sur place, rien à copier
|
||||||
results["local"] = source_path
|
results["local"] = source_path
|
||||||
|
|
||||||
elif dest == "remote":
|
elif dest == "remote":
|
||||||
remote_path = os.path.join(settings.EXPORT_REMOTE_DIR, filename)
|
remote_path = os.path.join(settings.EXPORT_REMOTE_PATH, filename)
|
||||||
try:
|
try:
|
||||||
if not remote_mount_available(settings.EXPORT_REMOTE_DIR):
|
if not remote_mount_available(settings.EXPORT_REMOTE_PATH):
|
||||||
logger.warning("Partage Samba non disponible, copie ignorée")
|
logger.warning("Partage Samba non disponible, copie ignorée")
|
||||||
results["remote_error"] = "Montage indisponible"
|
results["remote_error"] = "Montage indisponible"
|
||||||
continue
|
continue
|
||||||
@@ -203,7 +186,7 @@ async def export_images_zip(
|
|||||||
# --- Chargement des frames en mémoire partagée ---
|
# --- Chargement des frames en mémoire partagée ---
|
||||||
record_manager = CameraRecordManager(cameraDB)
|
record_manager = CameraRecordManager(cameraDB)
|
||||||
total_size = await record_manager.size(uuid, start_ts, end_ts)
|
total_size = await record_manager.size(uuid, start_ts, end_ts)
|
||||||
shm_size = int(total_size * 1.5)
|
shm_size = int((total_size or 0) * 1.5)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
shm = posix_ipc.SharedMemory(
|
shm = posix_ipc.SharedMemory(
|
||||||
@@ -243,11 +226,12 @@ async def export_images_zip(
|
|||||||
total += 1
|
total += 1
|
||||||
|
|
||||||
if not frame_sizes:
|
if not frame_sizes:
|
||||||
return {"status": "error", "message": "Aucune frame trouvée"}
|
return {"status": "error", "message": "No frame found!..."}
|
||||||
|
|
||||||
# --- Génération du ZIP ---
|
# --- Génération du ZIP ---
|
||||||
max_zip_bytes = max_zip_size_mb * 1024 * 1024 if max_zip_size_mb > 0 else 0
|
max_zip_bytes = max_zip_size_mb * 1024 * 1024 if max_zip_size_mb > 0 else 0
|
||||||
ts_s = unix_timestamp_to_iso(start_ts)
|
ts_s = unix_timestamp_to_iso(start_ts)
|
||||||
|
|
||||||
zip_filename = f"{uuid}_{ts_s}.zip"
|
zip_filename = f"{uuid}_{ts_s}.zip"
|
||||||
zip_path = os.path.join(settings.EXPORTS_LOCAL_PATH, 'images', zip_filename)
|
zip_path = os.path.join(settings.EXPORTS_LOCAL_PATH, 'images', zip_filename)
|
||||||
os.makedirs(os.path.dirname(zip_path), exist_ok=True)
|
os.makedirs(os.path.dirname(zip_path), exist_ok=True)
|
||||||
@@ -292,19 +276,21 @@ async def export_images_zip(
|
|||||||
zf.writestr(f"{uuid}_{ts_iso}.jpg", buf.tobytes())
|
zf.writestr(f"{uuid}_{ts_iso}.jpg", buf.tobytes())
|
||||||
written += 1
|
written += 1
|
||||||
|
|
||||||
progress_bar(i + 1, total, prefix=f'Progression {uuid}:', suffix='Terminé', length=30)
|
progress_bar(i + 1, total, prefix=f'Progress {uuid}:', suffix='Ended', length=30)
|
||||||
i+=1
|
i+=1
|
||||||
current_offset += size
|
current_offset += size
|
||||||
|
|
||||||
## Copie vers les destinations (local + Samba)
|
## Copie vers les destinations (local + Samba)
|
||||||
#destinations = _copy_to_destinations(zip_path, zip_filename)
|
filename = os.path.join('images', zip_filename)
|
||||||
|
|
||||||
|
destinations = _copy_to_destinations(zip_path, filename)
|
||||||
return {
|
return {
|
||||||
"status": "success",
|
"status": "success",
|
||||||
"zip_path": zip_path,
|
"zip_path": zip_path,
|
||||||
"frames_written": written,
|
"frames_written": written,
|
||||||
"frames_skipped": skipped,
|
"frames_skipped": skipped,
|
||||||
"jpeg_quality": jpeg_quality,
|
"jpeg_quality": jpeg_quality,
|
||||||
#"destinations": destinations,
|
"destinations": destinations,
|
||||||
}
|
}
|
||||||
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -353,11 +339,11 @@ async def export_video_mp4(
|
|||||||
video = None
|
video = None
|
||||||
shm_name = f"/vid_frames_{uuid}"
|
shm_name = f"/vid_frames_{uuid}"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# --- Chargement des frames en mémoire partagée ---
|
# --- Chargement des frames en mémoire partagée ---
|
||||||
record_manager = CameraRecordManager(cameraDB)
|
record_manager = CameraRecordManager(cameraDB)
|
||||||
total_size = await record_manager.size(uuid, start_ts, end_ts)
|
total_size = await record_manager.size(uuid, start_ts, end_ts)
|
||||||
shm_size = int(total_size * 1.5)
|
shm_size = int((total_size or 0) * 1.5)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
shm = posix_ipc.SharedMemory(
|
shm = posix_ipc.SharedMemory(
|
||||||
@@ -395,7 +381,7 @@ async def export_video_mp4(
|
|||||||
total +=1
|
total +=1
|
||||||
|
|
||||||
if not frame_sizes:
|
if not frame_sizes:
|
||||||
return {"status": "error", "message": "Aucune frame trouvée"}
|
return {"status": "error", "message": "No frame found!..."}
|
||||||
|
|
||||||
|
|
||||||
# --- Génération du MP4 ---
|
# --- Génération du MP4 ---
|
||||||
@@ -407,7 +393,7 @@ async def export_video_mp4(
|
|||||||
)
|
)
|
||||||
os.makedirs(os.path.dirname(video_path), exist_ok=True)
|
os.makedirs(os.path.dirname(video_path), exist_ok=True)
|
||||||
|
|
||||||
fourcc = cv2.VideoWriter_fourcc(*opencv_fourcc_format)
|
fourcc = cv2.VideoWriter_fourcc(*opencv_fourcc_format) # type: ignore[attr-defined]
|
||||||
skipped = 0
|
skipped = 0
|
||||||
written = 0
|
written = 0
|
||||||
current_offset = 0
|
current_offset = 0
|
||||||
@@ -451,18 +437,20 @@ async def export_video_mp4(
|
|||||||
written += 1
|
written += 1
|
||||||
current_offset += size
|
current_offset += size
|
||||||
|
|
||||||
progress_bar(i + 1, total, prefix=f'Progression {uuid}:', suffix='Terminé', length=30)
|
progress_bar(i + 1, total, prefix=f'Progress {uuid}:', suffix='Ended', length=30)
|
||||||
i+=1
|
i+=1
|
||||||
|
|
||||||
if video:
|
if video:
|
||||||
video.release()
|
video.release()
|
||||||
|
|
||||||
if not os.path.exists(video_path):
|
if not os.path.exists(video_path):
|
||||||
return {"status": "error", "message": f"Fichier {opencv_video_type} non créé"}
|
return {"status": "error", "message": f"File {opencv_video_type} not created!..."}
|
||||||
|
|
||||||
## Copie vers les destinations (local + Samba)
|
## Copie vers les destinations (local + Samba)
|
||||||
#filename = os.path.basename(video_path)
|
filename = os.path.basename(video_path)
|
||||||
#destinations = _copy_to_destinations(video_path, filename)
|
filename = os.path.join('videos', filename)
|
||||||
|
|
||||||
|
destinations = _copy_to_destinations(video_path, filename)
|
||||||
return {
|
return {
|
||||||
"status": "success",
|
"status": "success",
|
||||||
"video_path": video_path,
|
"video_path": video_path,
|
||||||
@@ -470,7 +458,7 @@ async def export_video_mp4(
|
|||||||
"frames_skipped": skipped,
|
"frames_skipped": skipped,
|
||||||
"frame_rate": frame_rate,
|
"frame_rate": frame_rate,
|
||||||
"file_size_mb": round(os.path.getsize(video_path) / 1024 / 1024, 2),
|
"file_size_mb": round(os.path.getsize(video_path) / 1024 / 1024, 2),
|
||||||
#"destinations": destinations,
|
"destinations": destinations,
|
||||||
}
|
}
|
||||||
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
|||||||
@@ -0,0 +1,197 @@
|
|||||||
|
# Generated by Django 6.0.5 on 2026-05-31 07:42
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
import django.utils.timezone
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('django_celery_beat', '0019_alter_periodictasks_options'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Configuration',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('name', models.CharField(default='Configuration par défaut', help_text='Nom de la configuration', max_length=100, null=True, verbose_name='Nom de la Configuration')),
|
||||||
|
('sidebar_width', models.CharField(default='350px', help_text='Largeur barre latérale (css)', max_length=32, null=True, verbose_name='Barre latérale')),
|
||||||
|
('default_grid_columns', models.PositiveSmallIntegerField(default=3, help_text='Nombre de colonnes de la grille par défaut', verbose_name='Colonnes de la grille par défaut')),
|
||||||
|
('opencv_fourcc_format', models.CharField(choices=[('mp4v', 'MP4'), ('XVID', 'XVID')], default='mp4v', help_text='Opencv fourcc format', max_length=8, null=True, verbose_name='Fourcc')),
|
||||||
|
('opencv_video_type', models.CharField(choices=[('mp4', 'MP4'), ('avi', 'AVI')], default='mp4', help_text='Opencv video type', max_length=8, null=True, verbose_name='Video type')),
|
||||||
|
('grbl_xmax', models.FloatField(default=350.0, help_text='CNC Grbl Xmax en mm', verbose_name='Grbl Xmax')),
|
||||||
|
('grbl_ymax', models.FloatField(default=250.0, help_text='CNC Grbl Ymax en mm', verbose_name='Grbl Ymax')),
|
||||||
|
('capture_type', models.CharField(choices=[('rpi', 'Arducam'), ('webcam', 'Webcam'), ('file', 'Simulation Fichier vidéo (mp4, avi)'), ('video', 'Fichier vidéo (mp4, avi)')], default='rpi', help_text='Type de capture. Nécessite un redémarrage en cas de modification à chaud!', max_length=8, null=True, verbose_name='Capture')),
|
||||||
|
('webcam_device_index', models.PositiveSmallIntegerField(default=2, help_text='Index de la webcam (0, 1, ...) si présente', verbose_name='Index de la webcam')),
|
||||||
|
('image_quality', models.PositiveSmallIntegerField(default=90, help_text='Qualité JPEG (1-100) pour les images exportées', verbose_name='Qualité JPEG')),
|
||||||
|
('video_jpeg_quality', models.PositiveSmallIntegerField(default=90, help_text='Qualité JPEG (1-100) pour les images extraites des vidéos', verbose_name='Qualité JPEG pour les vidéos')),
|
||||||
|
('video_frame_rate', models.FloatField(default=5.0, help_text="Fréquence d'extraction des images des vidéos (images par seconde)", verbose_name='Fréquence vidéos (fps)')),
|
||||||
|
('video_width_capture', models.PositiveSmallIntegerField(default=1280, help_text='Largeur de capture vidéo en pixels', verbose_name='Largeur de capture vidéo')),
|
||||||
|
('video_height_capture', models.PositiveSmallIntegerField(default=720, help_text='Hauteur de capture vidéo en pixels', verbose_name='Hauteur de capture vidéo')),
|
||||||
|
('scan_simulation', models.BooleanField(default=False, help_text='Autorise la simulation du balayage', verbose_name='Simuler balayage')),
|
||||||
|
('calibration_crop_radius', models.PositiveSmallIntegerField(default=150, help_text='Rayon en pixels pour découper les images de calibration en px', verbose_name='Rayon de découpe pour la calibration')),
|
||||||
|
('calibration_default_multiwell', models.CharField(choices=[('HG', 'HG-Haut gauche'), ('HD', 'HD-Haut droit'), ('BG', 'BG-Bas gauche'), ('BD', 'BD-Bas droit')], default='HG', help_text='Position du multi-puits de calibration par défaut', max_length=8, verbose_name='Multi-puits de calibration par défaut')),
|
||||||
|
('calibration_default_feed', models.PositiveIntegerField(default=1000, help_text='Vitesse de déplacement pour la calibration en mm/mn', verbose_name='Vitesse de calibration')),
|
||||||
|
('calibration_default_step', models.FloatField(default=1.0, help_text='Pas de déplacement pour la calibration en mm', verbose_name='Pas de calibration')),
|
||||||
|
('calibration_default_duration', models.FloatField(default=3.0, help_text='Durée de pose entre chaque puits en s', verbose_name='Duruée calibration')),
|
||||||
|
('tracking', models.BooleanField(default=False, help_text='Suivi et analyse des planaires', verbose_name='Suivi')),
|
||||||
|
('tracking_setting', models.BooleanField(default=False, help_text='Autorise le réglage des valeurs par défaut dans la calibration', verbose_name='Réglage dans calibration')),
|
||||||
|
('tube_axis', models.CharField(choices=[('vertical', 'Vertical'), ('horizontal', 'Horizontal')], default='vertical', help_text='Axe du tube', max_length=16, null=True, verbose_name='Axe du puit')),
|
||||||
|
('min_area_px', models.PositiveIntegerField(default=20, help_text="surface minimale d'un contour pour être considéré valide (px²)", verbose_name='Surface minimale')),
|
||||||
|
('max_area_ratio', models.FloatField(default=0.1, help_text="surface maximale d'un contour en fraction de la frame (défaut 10%)", verbose_name='surface maximale ')),
|
||||||
|
('max_planarians', models.PositiveIntegerField(default=1, help_text='nombre maximum de planaires à suivre simultanément (1-10)', verbose_name='Max planaire')),
|
||||||
|
('merge_kernel_size', models.PositiveIntegerField(default=15, help_text='taille du kernel elliptique de fusion des fragments (px). Augmenter si fragments résiduels', verbose_name='Taille du kernel')),
|
||||||
|
('min_contour_dist_px', models.PositiveIntegerField(default=40, help_text='Distance min entre deux contours pour les considérer comme individus distincts. Défaut : 40px. Augmenter si IDs multiples persistent', verbose_name='Distance <contour>')),
|
||||||
|
('active', models.BooleanField(default=False, verbose_name='Actif')),
|
||||||
|
('author', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='Auteur')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Configuration',
|
||||||
|
'verbose_name_plural': 'Configuration',
|
||||||
|
'ordering': ['id'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='MultiWell',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('label', models.CharField(blank=True, help_text='Label du multi-puit', max_length=100, null=True, verbose_name='Label')),
|
||||||
|
('position', models.CharField(choices=[('HG', 'HG-Haut gauche'), ('HD', 'HD-Haut droit'), ('BG', 'BG-Bas gauche'), ('BD', 'BD-Bas droit')], help_text='Position du multi-puits sur la table', max_length=8, null=True, unique=True, verbose_name='Position')),
|
||||||
|
('default', models.BooleanField(default=False, help_text='Multi-puit par défaut', verbose_name='Par défaut')),
|
||||||
|
('cols', models.PositiveSmallIntegerField(default=6, help_text='Nombre de colonnes', verbose_name='Colonnes')),
|
||||||
|
('rows', models.PositiveSmallIntegerField(default=4, help_text='Nombre de lignes', verbose_name='Lignes')),
|
||||||
|
('diameter', models.FloatField(default=16.0, help_text='Diamètre des tubes en mm', verbose_name='Diamètre')),
|
||||||
|
('row_def', models.CharField(default='A,B,C,D', help_text='Définition des lignes', max_length=16, null=True, verbose_name='Définition')),
|
||||||
|
('row_order', models.CharField(default='D,C,B,A', help_text='Ordre ligne de puit. Lecture en serpentin dans le sens des +- X', max_length=16, null=True, verbose_name='Ordre ligne')),
|
||||||
|
('crop_radius', models.PositiveSmallIntegerField(default=500, help_text='Rayon en pixels pour recadrer les images en px', verbose_name='Rayon de découpe recadrage')),
|
||||||
|
('order', models.PositiveSmallIntegerField(default=0, help_text='Ordre de lecture du multi-puit', verbose_name='Ordre')),
|
||||||
|
('duration', models.PositiveIntegerField(default=10, help_text='Durée de capture en secondes pour la calibration', verbose_name='Durée')),
|
||||||
|
('xbase', models.FloatField(default=50.0, help_text='Base origine X en mm', verbose_name='Origine X')),
|
||||||
|
('ybase', models.FloatField(default=50.0, help_text='Base origine Y en mm', verbose_name='Origine Y')),
|
||||||
|
('dx', models.FloatField(default=19.5, help_text='Pas ou interval sur X en mm', verbose_name='Pas X')),
|
||||||
|
('dy', models.FloatField(default=19.5, help_text='Pas ou interval sur Y en mm', verbose_name='Pas Y')),
|
||||||
|
('feed', models.PositiveIntegerField(default=1000, help_text='Vitesse déplacement en mm/mn ', verbose_name='Vitesse')),
|
||||||
|
('well_position', models.BooleanField(default=False, help_text='Positions des puits générées ?. Non => efface WellPosition et recalcule les positions', verbose_name='Positions')),
|
||||||
|
('active', models.BooleanField(default=True, verbose_name='Active')),
|
||||||
|
('author', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='Auteur')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Multi-puits',
|
||||||
|
'verbose_name_plural': 'Multi-puits',
|
||||||
|
'ordering': ['order'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Experiment',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('title', models.CharField(max_length=100, null=True, verbose_name="Titre de l'expérience")),
|
||||||
|
('comment', models.TextField(blank=True, help_text="Descriptions de l'expérience", null=True, verbose_name='Commentaires')),
|
||||||
|
('identifier', models.CharField(max_length=100, null=True, unique=True, verbose_name="Identifiant d'expérience")),
|
||||||
|
('duration', models.PositiveIntegerField(default=120, help_text='Durée de la prise de vue en secondes', verbose_name='Durée')),
|
||||||
|
('created', models.DateTimeField(default=django.utils.timezone.now, verbose_name='Date de création')),
|
||||||
|
('started', models.DateTimeField(blank=True, null=True, verbose_name='Date de début')),
|
||||||
|
('finished', models.DateTimeField(blank=True, null=True, verbose_name='Date de fin')),
|
||||||
|
('author', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='Auteur')),
|
||||||
|
('multiwell', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='scanner.multiwell', verbose_name='Multi-puits')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Expérience',
|
||||||
|
'verbose_name_plural': 'Expériences',
|
||||||
|
'ordering': ['-created'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Session',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('name', models.CharField(help_text="Session d'expérience. 4 Multi-puits maximum", max_length=100, null=True, verbose_name='Nom de la session')),
|
||||||
|
('active', models.BooleanField(default=True, verbose_name='Active')),
|
||||||
|
('expected_export', models.DateTimeField(blank=True, help_text="Date d'exportation prévue", null=True, verbose_name='Exportation auto')),
|
||||||
|
('expected_scanning', models.DateTimeField(blank=True, help_text='Date du balayage prévue', null=True, verbose_name='Bbalayage auto')),
|
||||||
|
('created', models.DateTimeField(default=django.utils.timezone.now, verbose_name='Date de création')),
|
||||||
|
('finished', models.DateTimeField(blank=True, null=True, verbose_name='Date de fin')),
|
||||||
|
('export_status', models.CharField(choices=[('pending', 'En attente'), ('running', 'En cours'), ('done', 'Terminé'), ('error', 'Erreur')], default='pending', max_length=16, verbose_name='Status exportation')),
|
||||||
|
('export_exported_at', models.DateTimeField(blank=True, null=True, verbose_name='Exportation terminée à')),
|
||||||
|
('scanning_status', models.CharField(choices=[('pending', 'En attente'), ('running', 'En cours'), ('done', 'Terminé'), ('error', 'Erreur')], default='pending', max_length=16, verbose_name='Status scanning')),
|
||||||
|
('scanning_finished_at', models.DateTimeField(blank=True, null=True, verbose_name='Balayage terminé à')),
|
||||||
|
('author', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='Auteur')),
|
||||||
|
('export_task', models.OneToOneField(blank=True, help_text="Programmation de l'exportation des vidéos et images", null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='export_session', to='django_celery_beat.periodictask', verbose_name='Export médias')),
|
||||||
|
('scanning_task', models.OneToOneField(blank=True, help_text='Programmation du lancement du balayage', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='scanning_session', to='django_celery_beat.periodictask', verbose_name='Lancer le balayage')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Session',
|
||||||
|
'verbose_name_plural': 'Sessions',
|
||||||
|
'ordering': ['-created'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Well',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('name', models.CharField(blank=True, help_text='Nom du puit: Ai..Di', max_length=4, null=True, unique=True, verbose_name='Nom')),
|
||||||
|
('author', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='Auteur')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Puit',
|
||||||
|
'verbose_name_plural': 'Puits',
|
||||||
|
'ordering': ['name'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='SessionExperiment',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('author', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='Auteur')),
|
||||||
|
('experiment', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='session_experiments', to='scanner.experiment', verbose_name='Expérience')),
|
||||||
|
('session', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='scanner.session', verbose_name='Session')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': "Expérience d'une session",
|
||||||
|
'verbose_name_plural': "Expériences d'une session",
|
||||||
|
'ordering': ['session'],
|
||||||
|
'unique_together': {('session', 'experiment')},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='ExperimentWell',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('active', models.BooleanField(default=True, verbose_name='Active')),
|
||||||
|
('author', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='Auteur')),
|
||||||
|
('experiment', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='experimentwell', to='scanner.experiment', verbose_name='Expérience')),
|
||||||
|
('well', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='wellexperiment', to='scanner.well', verbose_name='Puit')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Expérience puit',
|
||||||
|
'verbose_name_plural': 'Expériences puits',
|
||||||
|
'ordering': ['experiment', 'well'],
|
||||||
|
'unique_together': {('experiment', 'well')},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='WellPosition',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('order', models.PositiveSmallIntegerField(default=0, help_text='Ordre de lecture du puit', verbose_name='Ordre')),
|
||||||
|
('x', models.FloatField(default=10.0, help_text='Axe X en mm', verbose_name='X')),
|
||||||
|
('y', models.FloatField(default=10.0, help_text='Axe Y en mm', verbose_name='Y')),
|
||||||
|
('px_per_mm', models.FloatField(default=50.0, help_text='Facteur de calibration optique', verbose_name='Pixels par mm')),
|
||||||
|
('author', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='Auteur')),
|
||||||
|
('multiwell', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='scanner.multiwell', verbose_name='Multi-puits')),
|
||||||
|
('well', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='scanner.well', verbose_name='Puit')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Position du puit',
|
||||||
|
'verbose_name_plural': 'Position des puits',
|
||||||
|
'ordering': ['order'],
|
||||||
|
'unique_together': {('multiwell', 'well')},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Generated by Django 6.0.5 on 2026-05-31 07:43
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('scanner', '0001_initial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='multiwell',
|
||||||
|
name='crop_radius',
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 6.0.5 on 2026-05-31 07:43
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('scanner', '0002_remove_multiwell_crop_radius'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='multiwell',
|
||||||
|
name='crop_radius',
|
||||||
|
field=models.PositiveSmallIntegerField(default=500, help_text='Rayon en pixels pour recadrer les images en px', verbose_name='Rayon de découpe recadrage'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# Generated by Django 6.0.5 on 2026-05-31 11:01
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('scanner', '0003_multiwell_crop_radius'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='configuration',
|
||||||
|
name='calibration_default_multiwell',
|
||||||
|
field=models.CharField(choices=[('HG', 'MP 6x24: HG-Haut gauche'), ('HD', 'MP 6x24: HD-Haut droit'), ('BG', 'MP 6x24: BG-Bas gauche'), ('BD', 'MP 6x24: BD-Bas droit'), ('HG_6', 'MP 2x3: HG-Haut gauche'), ('HD_6', 'MP 2x3: HD-Haut droit'), ('BG_6', 'MP 2x3: BG-Bas gauche'), ('BD_6', 'MP 2x3: BD-Bas droit'), ('HG_12', 'MP 3x4: HG-Haut gauche'), ('HD_12', 'MP 3x4: HD-Haut droit'), ('BG_12', 'MP 3x4: BG-Bas gauche'), ('BD_12', 'MP 3x4: BD-Bas droit'), ('HG_48', 'MP 6x8: HG-Haut gauche'), ('HD_48', 'MP 6x8: HD-Haut droit'), ('BG_48', 'MP 6x8: BG-Bas gauche'), ('BD_48', 'MP 6x8: BD-Bas droit')], default='HG', help_text='Position du multi-puits de calibration par défaut', max_length=8, verbose_name='Multi-puits de calibration par défaut'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='multiwell',
|
||||||
|
name='position',
|
||||||
|
field=models.CharField(choices=[('HG', 'MP 6x24: HG-Haut gauche'), ('HD', 'MP 6x24: HD-Haut droit'), ('BG', 'MP 6x24: BG-Bas gauche'), ('BD', 'MP 6x24: BD-Bas droit'), ('HG_6', 'MP 2x3: HG-Haut gauche'), ('HD_6', 'MP 2x3: HD-Haut droit'), ('BG_6', 'MP 2x3: BG-Bas gauche'), ('BD_6', 'MP 2x3: BD-Bas droit'), ('HG_12', 'MP 3x4: HG-Haut gauche'), ('HD_12', 'MP 3x4: HD-Haut droit'), ('BG_12', 'MP 3x4: BG-Bas gauche'), ('BD_12', 'MP 3x4: BD-Bas droit'), ('HG_48', 'MP 6x8: HG-Haut gauche'), ('HD_48', 'MP 6x8: HD-Haut droit'), ('BG_48', 'MP 6x8: BG-Bas gauche'), ('BD_48', 'MP 6x8: BD-Bas droit')], help_text='Position du multi-puits sur la table', max_length=8, null=True, unique=True, verbose_name='Position'),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='VideoPlate',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('label', models.CharField(blank=True, max_length=200, verbose_name='Label')),
|
||||||
|
('video_file', models.FileField(blank=True, null=True, upload_to='videos/', verbose_name='Fichier vidéo')),
|
||||||
|
('active', models.BooleanField(default=True, verbose_name='Active')),
|
||||||
|
('uploaded_at', models.DateTimeField(auto_now_add=True, verbose_name='Déposé le')),
|
||||||
|
('native_fps', models.FloatField(blank=True, null=True, verbose_name='FPS natif')),
|
||||||
|
('duration_s', models.FloatField(blank=True, null=True, verbose_name='Durée (s)')),
|
||||||
|
('frame_w', models.PositiveIntegerField(blank=True, null=True, verbose_name='Largeur (px)')),
|
||||||
|
('frame_h', models.PositiveIntegerField(blank=True, null=True, verbose_name='Hauteur (px)')),
|
||||||
|
('multiwell', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='video_plates', to='scanner.multiwell', verbose_name='Multi-puits')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Vidéo plaque',
|
||||||
|
'verbose_name_plural': 'Vidéos plaque',
|
||||||
|
'ordering': ['multiwell__order', '-uploaded_at'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
+22
@@ -0,0 +1,22 @@
|
|||||||
|
# Generated by Django 6.0.5 on 2026-06-02 08:25
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('scanner', '0004_add_videoplate'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name='multiwell',
|
||||||
|
options={'ordering': ['label', 'order'], 'verbose_name': 'Multi-puits', 'verbose_name_plural': 'Multi-puits'},
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='multiwell',
|
||||||
|
name='capture_video',
|
||||||
|
field=models.BooleanField(default=False, help_text='Ce multi-puit servira pour la capture vidéo', verbose_name='Capture vidéo'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Generated by Django 6.0.5 on 2026-06-02 08:28
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('scanner', '0005_alter_multiwell_options_multiwell_capture_video'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='multiwell',
|
||||||
|
name='capture_video',
|
||||||
|
field=models.BooleanField(default=False, help_text='Ce multi-puit servira pour la capture vidéo', verbose_name='Vidéo'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='multiwell',
|
||||||
|
name='default',
|
||||||
|
field=models.BooleanField(default=False, help_text='Multi-puit par défaut', verbose_name='Défaut'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 6.0.5 on 2026-06-02 09:51
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('scanner', '0006_alter_multiwell_capture_video_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='videoplate',
|
||||||
|
name='px_per_mm',
|
||||||
|
field=models.FloatField(default=15.0, help_text='Facteur pixels/mm dans la vidéo plaque. À calibrer selon la résolution de la caméra plaque.', verbose_name='Pixels par mm (vidéo plaque)'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('scanner', '0007_add_videoplate_px_per_mm'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='videoplate',
|
||||||
|
name='x_origin_mm',
|
||||||
|
field=models.FloatField(
|
||||||
|
default=0.0,
|
||||||
|
verbose_name='Origine X (mm)',
|
||||||
|
help_text='Position CNC X correspondant au pixel 0 de la vidéo plaque (mm). Défaut 0.',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='videoplate',
|
||||||
|
name='y_origin_mm',
|
||||||
|
field=models.FloatField(
|
||||||
|
default=0.0,
|
||||||
|
verbose_name='Origine Y (mm)',
|
||||||
|
help_text='Position CNC Y correspondant au pixel 0 de la vidéo plaque (mm). Défaut 0.',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Generated by Django 6.0.5 on 2026-06-03 09:13
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('scanner', '0008_add_videoplate_origin'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='videoplate',
|
||||||
|
name='x_origin_mm',
|
||||||
|
field=models.FloatField(default=0.0, help_text='Position CNC X correspondant au bord gauche de la vidéo plaque (mm). Défaut 0.', verbose_name='Origine X (mm)'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='videoplate',
|
||||||
|
name='y_origin_mm',
|
||||||
|
field=models.FloatField(default=0.0, help_text='Position CNC Y correspondant au bord haut de la vidéo plaque (mm). Défaut 0.', verbose_name='Origine Y (mm)'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -4,22 +4,36 @@
|
|||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
import uuid
|
import uuid
|
||||||
import json
|
import json
|
||||||
|
from pathlib import Path
|
||||||
from django_celery_beat.models import PeriodicTask, ClockedSchedule
|
from django_celery_beat.models import PeriodicTask, ClockedSchedule
|
||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
from django.db.models.signals import post_save, post_delete
|
from django.db.models.signals import post_save, post_delete
|
||||||
|
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
|
|
||||||
|
|
||||||
MULTIWELL_POSITION = [
|
MULTIWELL_POSITION = [
|
||||||
('HG', _("HG-Haut gauche")),
|
('HG', _("MP 4x6: HG-Haut gauche")),
|
||||||
('HD', _("HD-Haut droit")),
|
('HD', _("MP 4x6: HD-Haut droit")),
|
||||||
('BG', _("BG-Bas gauche")),
|
('BG', _("MP 4x6: BG-Bas gauche")),
|
||||||
('BD', _("BD-Bas droit")),
|
('BD', _("MP 4x6: BD-Bas droit")),
|
||||||
('BM', _("BM-Bas milieu")),
|
('HG_6', _("MP 2x3: HG-Haut gauche")),
|
||||||
('HM', _("HM-Haut milieu")),
|
('HD_6', _("MP 2x3: HD-Haut droit")),
|
||||||
|
('BG_6', _("MP 2x3: BG-Bas gauche")),
|
||||||
|
('BD_6', _("MP 2x3: BD-Bas droit")),
|
||||||
|
('HG_12', _("MP 3x4: HG-Haut gauche")),
|
||||||
|
('HD_12', _("MP 3x4: HD-Haut droit")),
|
||||||
|
('BG_12', _("MP 3x4: BG-Bas gauche")),
|
||||||
|
('BD_12', _("MP 3x4: BD-Bas droit")),
|
||||||
|
('HG_48', _("MP 6x8: HG-Haut gauche")),
|
||||||
|
('HD_48', _("MP 6x8: HD-Haut droit")),
|
||||||
|
('BG_48', _("MP 6x8: BG-Bas gauche")),
|
||||||
|
('BD_48', _("MP 6x8: BD-Bas droit")),
|
||||||
|
('HG_96', _("MP 8x12: HG-Haut gauche")),
|
||||||
|
('HD_96', _("MP 8x12: HD-Haut droit")),
|
||||||
|
('BG_96', _("MP 8x12: BG-Bas gauche")),
|
||||||
|
('BD_96', _("MP 8x12: BD-Bas droit")),
|
||||||
]
|
]
|
||||||
|
|
||||||
FOURCC_FORMAT = [
|
FOURCC_FORMAT = [
|
||||||
@@ -35,12 +49,18 @@ VIDEO_TYPE = [
|
|||||||
CAPTURE_TYPE = [
|
CAPTURE_TYPE = [
|
||||||
('rpi', _("Arducam")),
|
('rpi', _("Arducam")),
|
||||||
('webcam', _("Webcam")),
|
('webcam', _("Webcam")),
|
||||||
('file', _("mp4")),
|
('file', _("Simulation Fichier vidéo (mp4, avi)")),
|
||||||
|
('video', _("Fichier vidéo (mp4, avi)")),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
TUBE_AXIS_TYPE = [
|
||||||
|
('vertical', _("Vertical")),
|
||||||
|
('horizontal', _("Horizontal")),
|
||||||
|
]
|
||||||
|
|
||||||
class Configuration(models.Model):
|
class Configuration(models.Model):
|
||||||
name = models.CharField(_("Nom de la Configuration"), help_text=_("Nom de la configuration"), max_length=100, null=True, blank=False, default=_("Configuration par défaut"))
|
name = models.CharField(_("Nom de la Configuration"), help_text=_("Nom de la configuration"), max_length=100, null=True, blank=False, default=_("Configuration par défaut"))
|
||||||
author = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name="Auteur", null=True, blank=True)
|
author = models.ForeignKey(User, on_delete=models.SET_NULL, verbose_name="Auteur", null=True, blank=True)
|
||||||
# Dashboard configuration
|
# Dashboard configuration
|
||||||
sidebar_width = models.CharField(_("Barre latérale"), help_text=_("Largeur barre latérale (css)"), max_length=32, null=True, blank=False, default="350px")
|
sidebar_width = models.CharField(_("Barre latérale"), help_text=_("Largeur barre latérale (css)"), max_length=32, null=True, blank=False, default="350px")
|
||||||
default_grid_columns = models.PositiveSmallIntegerField(_("Colonnes de la grille par défaut"), help_text=_("Nombre de colonnes de la grille par défaut"), blank=False, default=3)
|
default_grid_columns = models.PositiveSmallIntegerField(_("Colonnes de la grille par défaut"), help_text=_("Nombre de colonnes de la grille par défaut"), blank=False, default=3)
|
||||||
@@ -51,7 +71,8 @@ class Configuration(models.Model):
|
|||||||
grbl_xmax = models.FloatField(_("Grbl Xmax"), help_text=_("CNC Grbl Xmax en mm"), blank=False, default=350.0)
|
grbl_xmax = models.FloatField(_("Grbl Xmax"), help_text=_("CNC Grbl Xmax en mm"), blank=False, default=350.0)
|
||||||
grbl_ymax = models.FloatField(_("Grbl Ymax"), help_text=_("CNC Grbl Ymax en mm"), blank=False, default=250.0)
|
grbl_ymax = models.FloatField(_("Grbl Ymax"), help_text=_("CNC Grbl Ymax en mm"), blank=False, default=250.0)
|
||||||
# camera configuration
|
# camera configuration
|
||||||
capture_type = models.CharField(_("Capture"), help_text=_("Type de capture"), default='rpi', max_length=8, choices=CAPTURE_TYPE, null=True, blank=False)
|
capture_type = models.CharField(_("Capture"), help_text=_("Type de capture. Nécessite un redémarrage en cas de modification à chaud!"), default='rpi', max_length=8, choices=CAPTURE_TYPE, null=True, blank=False)
|
||||||
|
|
||||||
webcam_device_index = models.PositiveSmallIntegerField(_("Index de la webcam"), help_text=_("Index de la webcam (0, 1, ...) si présente"), default=2)
|
webcam_device_index = models.PositiveSmallIntegerField(_("Index de la webcam"), help_text=_("Index de la webcam (0, 1, ...) si présente"), default=2)
|
||||||
image_quality = models.PositiveSmallIntegerField(_("Qualité JPEG"), help_text=_("Qualité JPEG (1-100) pour les images exportées"), default=90)
|
image_quality = models.PositiveSmallIntegerField(_("Qualité JPEG"), help_text=_("Qualité JPEG (1-100) pour les images exportées"), default=90)
|
||||||
video_jpeg_quality = models.PositiveSmallIntegerField(_("Qualité JPEG pour les vidéos"), help_text=_("Qualité JPEG (1-100) pour les images extraites des vidéos"), default=90)
|
video_jpeg_quality = models.PositiveSmallIntegerField(_("Qualité JPEG pour les vidéos"), help_text=_("Qualité JPEG (1-100) pour les images extraites des vidéos"), default=90)
|
||||||
@@ -59,6 +80,7 @@ class Configuration(models.Model):
|
|||||||
video_width_capture = models.PositiveSmallIntegerField(_("Largeur de capture vidéo"), help_text=_("Largeur de capture vidéo en pixels"), default=1280)
|
video_width_capture = models.PositiveSmallIntegerField(_("Largeur de capture vidéo"), help_text=_("Largeur de capture vidéo en pixels"), default=1280)
|
||||||
video_height_capture = models.PositiveSmallIntegerField(_("Hauteur de capture vidéo"), help_text=_("Hauteur de capture vidéo en pixels"), default=720)
|
video_height_capture = models.PositiveSmallIntegerField(_("Hauteur de capture vidéo"), help_text=_("Hauteur de capture vidéo en pixels"), default=720)
|
||||||
# Calibration
|
# Calibration
|
||||||
|
scan_simulation = models.BooleanField(_("Simuler balayage"), help_text=_("Autorise la simulation du balayage"), default=False)
|
||||||
calibration_crop_radius = models.PositiveSmallIntegerField(_("Rayon de découpe pour la calibration"), help_text=_("Rayon en pixels pour découper les images de calibration en px"), default=150)
|
calibration_crop_radius = models.PositiveSmallIntegerField(_("Rayon de découpe pour la calibration"), help_text=_("Rayon en pixels pour découper les images de calibration en px"), default=150)
|
||||||
calibration_default_multiwell = models.CharField(_("Multi-puits de calibration par défaut"), help_text=_("Position du multi-puits de calibration par défaut"), max_length=8, choices=MULTIWELL_POSITION, default='HG')
|
calibration_default_multiwell = models.CharField(_("Multi-puits de calibration par défaut"), help_text=_("Position du multi-puits de calibration par défaut"), max_length=8, choices=MULTIWELL_POSITION, default='HG')
|
||||||
calibration_default_feed = models.PositiveIntegerField(_("Vitesse de calibration"), help_text=_("Vitesse de déplacement pour la calibration en mm/mn"), default=1000)
|
calibration_default_feed = models.PositiveIntegerField(_("Vitesse de calibration"), help_text=_("Vitesse de déplacement pour la calibration en mm/mn"), default=1000)
|
||||||
@@ -66,10 +88,17 @@ class Configuration(models.Model):
|
|||||||
calibration_default_duration = models.FloatField(_("Duruée calibration"), help_text=_("Durée de pose entre chaque puits en s"), default=3.0)
|
calibration_default_duration = models.FloatField(_("Duruée calibration"), help_text=_("Durée de pose entre chaque puits en s"), default=3.0)
|
||||||
# tracking
|
# tracking
|
||||||
tracking = models.BooleanField(_("Suivi"), help_text=_("Suivi et analyse des planaires"), default=False)
|
tracking = models.BooleanField(_("Suivi"), help_text=_("Suivi et analyse des planaires"), default=False)
|
||||||
|
tracking_setting = models.BooleanField(_("Réglage dans calibration"), help_text=_("Autorise le réglage des valeurs par défaut dans la calibration"), default=False)
|
||||||
|
|
||||||
|
tube_axis = models.CharField(_("Axe du puit"), help_text=_("Axe du tube"), default='vertical', max_length=16, choices=TUBE_AXIS_TYPE, null=True, blank=False)
|
||||||
|
min_area_px = models.PositiveIntegerField(_("Surface minimale"), help_text=_("surface minimale d'un contour pour être considéré valide (px²)"), default=20)
|
||||||
|
max_area_ratio = models.FloatField(_("surface maximale "), help_text=_("surface maximale d'un contour en fraction de la frame (défaut 10%)"), default=0.10)
|
||||||
|
max_planarians = models.PositiveIntegerField(_("Max planaire"), help_text=_("nombre maximum de planaires à suivre simultanément (1-10)"), default=1)
|
||||||
|
merge_kernel_size = models.PositiveIntegerField(_("Taille du kernel"), help_text=_("taille du kernel elliptique de fusion des fragments (px). Augmenter si fragments résiduels"), default=15)
|
||||||
|
min_contour_dist_px = models.PositiveIntegerField(_("Distance <contour>"), help_text=_("Distance min entre deux contours pour les considérer comme individus distincts. Défaut : 40px. Augmenter si IDs multiples persistent"), default=40)
|
||||||
#
|
#
|
||||||
active = models.BooleanField(_("Actif"), default=False)
|
active = models.BooleanField(_("Actif"), default=False)
|
||||||
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def active_config(cls):
|
def active_config(cls):
|
||||||
return Configuration.objects.filter(active=True).first()
|
return Configuration.objects.filter(active=True).first()
|
||||||
@@ -83,45 +112,46 @@ 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:
|
||||||
ordering = ['name', ]
|
ordering = ['name', ]
|
||||||
verbose_name = _("Puit")
|
verbose_name = _("Puit")
|
||||||
verbose_name_plural = _("Puits")
|
verbose_name_plural = _("Puits")
|
||||||
|
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f'{self.name}'
|
return f'{self.name}'
|
||||||
|
|
||||||
|
|
||||||
class MultiWell(models.Model):
|
class MultiWell(models.Model):
|
||||||
|
# Identification
|
||||||
label = models.CharField(_("Label"), help_text=_("Label du multi-puit"), max_length=100, null=True, blank=True)
|
label = models.CharField(_("Label"), help_text=_("Label du multi-puit"), max_length=100, null=True, blank=True)
|
||||||
author = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name="Auteur", null=True, blank=True)
|
author = models.ForeignKey(User, on_delete=models.SET_NULL, verbose_name="Auteur", null=True, blank=True)
|
||||||
position = models.CharField(_("Position"), help_text=_('Position du multi-puits sur la table'), unique=True, max_length=8, choices=MULTIWELL_POSITION, null=True, blank=False)
|
position = models.CharField(_("Position"), help_text=_('Position du multi-puits sur la table'), unique=True, max_length=8, choices=MULTIWELL_POSITION, null=True, blank=False)
|
||||||
default = models.BooleanField(_("Par défaut"), help_text=_('Multi-puit par défaut'), default=False)
|
default = models.BooleanField(_("Défaut"), help_text=_('Multi-puit par défaut'), default=False)
|
||||||
|
# Configuration
|
||||||
cols = models.PositiveSmallIntegerField(_("Colonnes"), help_text=_('Nombre de colonnes'), blank=False, default=6)
|
cols = models.PositiveSmallIntegerField(_("Colonnes"), help_text=_('Nombre de colonnes'), blank=False, default=6)
|
||||||
rows = models.PositiveSmallIntegerField(_("Lignes"), help_text=_('Nombre de lignes'), blank=False, default=4)
|
rows = models.PositiveSmallIntegerField(_("Lignes"), help_text=_('Nombre de lignes'), blank=False, default=4)
|
||||||
diameter = models.FloatField(_("Diamètre"), help_text=_('Diamètre des tubes en mm'), blank=False, default=16.0)
|
diameter = models.FloatField(_("Diamètre"), help_text=_('Diamètre des tubes en mm'), blank=False, default=16.0)
|
||||||
|
|
||||||
row_def = models.CharField(_("Définition"), help_text=_('Définition des lignes'), max_length=16, null=True, blank=False, default="A,B,C,D")
|
row_def = models.CharField(_("Définition"), help_text=_('Définition des lignes'), max_length=16, null=True, blank=False, default="A,B,C,D")
|
||||||
row_order = models.CharField(_("Ordre ligne"), help_text=_('Ordre ligne de puit. Lecture en serpentin dans le sens des +- X'), max_length=16, null=True, blank=False, default="D,C,B,A")
|
row_order = models.CharField(_("Ordre ligne"), help_text=_('Ordre ligne de puit. Lecture en serpentin dans le sens des +- X'), max_length=16, null=True, blank=False, default="D,C,B,A")
|
||||||
|
crop_radius = models.PositiveSmallIntegerField(_("Rayon de découpe recadrage"), help_text=_("Rayon en pixels pour recadrer les images en px"), blank=False, default=500)
|
||||||
|
|
||||||
|
# Balayage
|
||||||
order = models.PositiveSmallIntegerField(_("Ordre"), help_text=_('Ordre de lecture du multi-puit'), blank=False, default=0)
|
order = models.PositiveSmallIntegerField(_("Ordre"), help_text=_('Ordre de lecture du multi-puit'), blank=False, default=0)
|
||||||
duration = models.PositiveIntegerField(_("Durée"), help_text=_('Durée du film en secondes'), blank=False, default=120)
|
duration = models.PositiveIntegerField(_("Durée"), help_text=_('Durée de capture en secondes pour la calibration'), blank=False, default=10)
|
||||||
xbase = models.FloatField(_("Origine X"), help_text=_('Base origine X en mm'), blank=False, default=50.0)
|
xbase = models.FloatField(_("Origine X"), help_text=_('Base origine X en mm'), blank=False, default=50.0)
|
||||||
ybase = models.FloatField(_("Origine Y"), help_text=_('Base origine Y en mm'), blank=False, default=50.0)
|
ybase = models.FloatField(_("Origine Y"), help_text=_('Base origine Y en mm'), blank=False, default=50.0)
|
||||||
|
|
||||||
dx = models.FloatField(_("Pas X"), help_text=_('Pas ou interval sur X en mm'), blank=False, default=19.5)
|
dx = models.FloatField(_("Pas X"), help_text=_('Pas ou interval sur X en mm'), blank=False, default=19.5)
|
||||||
dy = models.FloatField(_("Pas Y"), help_text=_('Pas ou interval sur Y en mm'), blank=False, default=19.5)
|
dy = models.FloatField(_("Pas Y"), help_text=_('Pas ou interval sur Y en mm'), blank=False, default=19.5)
|
||||||
feed = models.PositiveIntegerField(_("Vitesse"), help_text=_('Vitesse déplacement en mm/mn '), blank=False, default=1000)
|
feed = models.PositiveIntegerField(_("Vitesse"), help_text=_('Vitesse déplacement en mm/mn '), blank=False, default=1000)
|
||||||
|
|
||||||
well_position = models.BooleanField(_("Positions"), help_text=_('Positions des puits générées ?. Non => efface WellPosition et recalcule les positions'), default=False)
|
well_position = models.BooleanField(_("Positions"), help_text=_('Positions des puits générées ?. Non => efface WellPosition et recalcule les positions'), default=False)
|
||||||
|
capture_video = models.BooleanField(_("Vidéo"), help_text=_('Ce multi-puit servira pour la capture vidéo'), default=False)
|
||||||
active = models.BooleanField(_("Active"), default=True)
|
active = models.BooleanField(_("Active"), default=True)
|
||||||
|
|
||||||
|
|
||||||
def config(self):
|
def config(self):
|
||||||
return dict(
|
return dict(
|
||||||
position=self.position,
|
position=self.position,
|
||||||
@@ -154,7 +184,7 @@ class MultiWell(models.Model):
|
|||||||
return MultiWell.objects.filter(active=True).all()
|
return MultiWell.objects.filter(active=True).all()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ['order', ]
|
ordering = ['label', 'order', ]
|
||||||
verbose_name = _("Multi-puits")
|
verbose_name = _("Multi-puits")
|
||||||
verbose_name_plural = _("Multi-puits")
|
verbose_name_plural = _("Multi-puits")
|
||||||
|
|
||||||
@@ -164,7 +194,7 @@ class MultiWell(models.Model):
|
|||||||
|
|
||||||
|
|
||||||
class WellPosition(models.Model):
|
class WellPosition(models.Model):
|
||||||
author = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name="Auteur", null=True, blank=True)
|
author = models.ForeignKey(User, on_delete=models.SET_NULL, verbose_name="Auteur", null=True, blank=True)
|
||||||
well = models.ForeignKey(Well, verbose_name=_("Puit"), on_delete=models.SET_NULL, null=True, blank=True)
|
well = models.ForeignKey(Well, verbose_name=_("Puit"), on_delete=models.SET_NULL, null=True, blank=True)
|
||||||
multiwell = models.ForeignKey(MultiWell, verbose_name=_("Multi-puits"), on_delete=models.SET_NULL, null=True, blank=True)
|
multiwell = models.ForeignKey(MultiWell, verbose_name=_("Multi-puits"), on_delete=models.SET_NULL, null=True, blank=True)
|
||||||
|
|
||||||
@@ -175,13 +205,17 @@ class WellPosition(models.Model):
|
|||||||
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def active_well(cls, multiwel, well):
|
def active_well(cls, multiwell, well):
|
||||||
return WellPosition.objects.filter(multiwel_id=multiwel.id, well_id=well.id).first()
|
return WellPosition.objects.filter(multiwell_id=multiwell.id, well_id=well.id).first()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def well_by_multiwell(cls, multiwell):
|
||||||
|
return WellPosition.objects.filter(multiwell_id=multiwell.id).all()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ['order']
|
ordering = ['order']
|
||||||
unique_together = ["multiwell", "well"]
|
unique_together = ["multiwell", "well"]
|
||||||
verbose_name = _("Position d'un puit")
|
verbose_name = _("Position du puit")
|
||||||
verbose_name_plural = _("Position des puits")
|
verbose_name_plural = _("Position des puits")
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
@@ -190,8 +224,8 @@ class WellPosition(models.Model):
|
|||||||
|
|
||||||
@receiver(post_save, sender=MultiWell)
|
@receiver(post_save, sender=MultiWell)
|
||||||
def create_well_position(sender, instance, created, **kwargs):
|
def create_well_position(sender, instance, created, **kwargs):
|
||||||
if created:
|
#if created:
|
||||||
pass
|
# pass
|
||||||
if not instance.well_position:
|
if not instance.well_position:
|
||||||
row_order = instance.row_order.split(',')
|
row_order = instance.row_order.split(',')
|
||||||
n = 0
|
n = 0
|
||||||
@@ -222,11 +256,25 @@ 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 )
|
||||||
multiwell = models.ForeignKey(MultiWell, verbose_name=_("Multi-puits"), on_delete=models.SET_NULL, null=True, blank=True)
|
|
||||||
|
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)
|
||||||
|
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', ]
|
||||||
@@ -234,7 +282,7 @@ class Experiment(models.Model):
|
|||||||
verbose_name_plural = _("Expériences")
|
verbose_name_plural = _("Expériences")
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f'{self.title}: {self.created} {self.multiwell.order}'
|
return f'{self.identifier} [ {self.title} ]'
|
||||||
|
|
||||||
|
|
||||||
class Session(models.Model):
|
class Session(models.Model):
|
||||||
@@ -246,10 +294,10 @@ class Session(models.Model):
|
|||||||
ERROR = "error", _("Erreur")
|
ERROR = "error", _("Erreur")
|
||||||
|
|
||||||
name = models.CharField(_("Nom de la session"), help_text=_("Session d'expérience. 4 Multi-puits maximum"), max_length=100, null=True, blank=False)
|
name = models.CharField(_("Nom de la session"), help_text=_("Session d'expérience. 4 Multi-puits maximum"), max_length=100, null=True, blank=False)
|
||||||
author = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name="Auteur", null=True, blank=True)
|
author = models.ForeignKey(User, on_delete=models.SET_NULL, verbose_name="Auteur", null=True, blank=True)
|
||||||
active = models.BooleanField(_("Active"), default=True)
|
active = models.BooleanField(_("Active"), default=True)
|
||||||
expected_export = models.DateTimeField(_("Date d'exportation"), help_text=_("Date d'exportation prévue"), null=True, blank=True)
|
expected_export = models.DateTimeField(_("Exportation auto"), help_text=_("Date d'exportation prévue"), null=True, blank=True)
|
||||||
expected_scanning = models.DateTimeField(_("Date du balayage"), help_text=_("Date du balayage prévue"), null=True, blank=True)
|
expected_scanning = models.DateTimeField(_("Bbalayage auto"), help_text=_("Date du balayage prévue"), null=True, blank=True)
|
||||||
|
|
||||||
created = models.DateTimeField(_("Date de création"), default=timezone.now)
|
created = models.DateTimeField(_("Date de création"), default=timezone.now)
|
||||||
finished = models.DateTimeField (_("Date de fin"), null=True, blank=True)
|
finished = models.DateTimeField (_("Date de fin"), null=True, blank=True)
|
||||||
@@ -270,15 +318,20 @@ class Session(models.Model):
|
|||||||
null=True, blank=True, on_delete=models.SET_NULL, related_name="scanning_session")
|
null=True, blank=True, on_delete=models.SET_NULL, related_name="scanning_session")
|
||||||
scanning_finished_at = models.DateTimeField(_("Balayage terminé à"), null=True, blank=True)
|
scanning_finished_at = models.DateTimeField(_("Balayage terminé à"), null=True, blank=True)
|
||||||
|
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_session(cls, sid):
|
||||||
|
return Session.objects.filter(pk=sid).first()
|
||||||
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ['-created', ]
|
ordering = ['-created', ]
|
||||||
verbose_name = _("Session d'expérience")
|
verbose_name = _("Session")
|
||||||
verbose_name_plural = _("Sessions d'expériences")
|
verbose_name_plural = _("Sessions")
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
state = _("Terminée") if not self.active else _("Active")
|
state = _("Terminée") if not self.active else _("Active")
|
||||||
return f'{self.name}: {state}'
|
return f'[ {self.pk} ] {self.name} ({state})'
|
||||||
|
|
||||||
|
|
||||||
@receiver(post_save, sender=Session)
|
@receiver(post_save, sender=Session)
|
||||||
@@ -305,8 +358,8 @@ def create_periodic_task(sender, instance, created, **kwargs):
|
|||||||
)
|
)
|
||||||
# Sauvegarde sans re-déclencher le signal
|
# Sauvegarde sans re-déclencher le signal
|
||||||
Session.objects.filter(pk=instance.pk).update(export_task=export_task)
|
Session.objects.filter(pk=instance.pk).update(export_task=export_task)
|
||||||
except:
|
except Exception as e:
|
||||||
pass
|
print("create_periodic_task error", e)
|
||||||
|
|
||||||
if instance.expected_scanning:
|
if instance.expected_scanning:
|
||||||
try:
|
try:
|
||||||
@@ -340,15 +393,21 @@ def delete_periodic_task(sender, instance, **kwargs):
|
|||||||
instance.scanning_task.delete()
|
instance.scanning_task.delete()
|
||||||
|
|
||||||
|
|
||||||
|
def get_uuid_from_session(session_id, multiwel_position, well_name):
|
||||||
|
return f'{session_id}-{multiwel_position}-{well_name}'
|
||||||
|
|
||||||
|
|
||||||
class SessionExperiment(models.Model):
|
class SessionExperiment(models.Model):
|
||||||
author = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name="Auteur", null=True, blank=True)
|
author = models.ForeignKey(User, on_delete=models.SET_NULL, verbose_name="Auteur", null=True, blank=True)
|
||||||
session = models.ForeignKey(Session, verbose_name=_("Session"), on_delete=models.SET_NULL, null=True, blank=True)
|
session = models.ForeignKey(Session, verbose_name=_("Session"), on_delete=models.CASCADE, null=True, blank=True)
|
||||||
experiment = models.ForeignKey(Experiment, verbose_name=_("Expérience"), on_delete=models.SET_NULL, null=True, blank=True, related_name="session_experiments")
|
experiment = models.ForeignKey(Experiment, verbose_name=_("Expérience"), on_delete=models.CASCADE, null=True, blank=True, related_name="session_experiments")
|
||||||
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def experiment_by_session(cls, session_id, active=True):
|
def experiment_by_session(cls, session_id, active=True):
|
||||||
return [ ss.experiment for ss in SessionExperiment.objects.filter(session__id=session_id, session__active=active).order_by('experiment__multiwell__order') ]
|
return [ ss.experiment for ss in SessionExperiment.objects.filter(session__id=session_id, session__active=active).order_by('experiment__multiwell__order') ]
|
||||||
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def uuid_from_session(cls, sid):
|
def uuid_from_session(cls, sid):
|
||||||
experiments = [ss.experiment for ss in SessionExperiment.objects.filter(session__id=sid, session__active=False)]
|
experiments = [ss.experiment for ss in SessionExperiment.objects.filter(session__id=sid, session__active=False)]
|
||||||
@@ -361,13 +420,166 @@ class SessionExperiment(models.Model):
|
|||||||
uuid_list.append(uuid)
|
uuid_list.append(uuid)
|
||||||
return uuid_list
|
return uuid_list
|
||||||
|
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_uuid(cls, session_id, experiment_id, well_name):
|
||||||
|
ss = SessionExperiment.objects.filter(session__id=session_id, experiment_id=experiment_id).first()
|
||||||
|
if ss:
|
||||||
|
return get_uuid_from_session(session_id, ss.experiment.multiwel.position, well_name)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ['session',]
|
ordering = ['session',]
|
||||||
unique_together = ["session", "experiment"]
|
unique_together = ["session", "experiment"]
|
||||||
verbose_name = _("Session expérience")
|
verbose_name = _("Expérience d'une session")
|
||||||
verbose_name_plural = _("Sessions expériences")
|
verbose_name_plural = _("Expériences d'une session")
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f'{self.session.name}'
|
return f'{self.session.id}: {self.experiment.title}'
|
||||||
|
|
||||||
|
|
||||||
|
class ExperimentWell(models.Model):
|
||||||
|
author = models.ForeignKey(User, on_delete=models.SET_NULL, verbose_name="Auteur", null=True, blank=True)
|
||||||
|
experiment = models.ForeignKey(Experiment, verbose_name=_("Expérience"), on_delete=models.CASCADE, null=True, blank=True, related_name="experimentwell")
|
||||||
|
well = models.ForeignKey(Well, verbose_name=_("Puit"), on_delete=models.SET_NULL, null=True, blank=True, related_name="wellexperiment")
|
||||||
|
active = models.BooleanField(_("Active"), default=True)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def well_by_experiment(cls, experiment_id):
|
||||||
|
return ExperimentWell.objects.filter(experiment__id=experiment_id, active=True).order_by('well__name')
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def wellname_by_experiment(cls, experiment_id):
|
||||||
|
return [ ew.well.name for ew in ExperimentWell.objects.filter(experiment__id=experiment_id, active=True).order_by('well__name') ]
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['experiment', 'well']
|
||||||
|
unique_together = ["experiment", "well", ]
|
||||||
|
verbose_name = _("Expérience puit")
|
||||||
|
verbose_name_plural = _("Expériences puits")
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f'{self.experiment.title}'
|
||||||
|
|
||||||
|
class VideoPlate(models.Model):
|
||||||
|
"""Vidéo d'une plaque multi-puits entière, utilisée en mode capture_type='video'."""
|
||||||
|
multiwell = models.ForeignKey(
|
||||||
|
MultiWell,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
verbose_name=_("Multi-puits"),
|
||||||
|
related_name='video_plates',
|
||||||
|
)
|
||||||
|
label = models.CharField(_("Label"), max_length=200, blank=True)
|
||||||
|
video_file = models.FileField(
|
||||||
|
_("Fichier vidéo"),
|
||||||
|
upload_to='videos/',
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
)
|
||||||
|
active = models.BooleanField(_("Active"), default=True)
|
||||||
|
uploaded_at = models.DateTimeField(_("Déposé le"), auto_now_add=True)
|
||||||
|
# Calibration vidéo plaque : pixels par mm dans la vidéo (≠ calibration caméra individuelle)
|
||||||
|
px_per_mm = models.FloatField(
|
||||||
|
_("Pixels par mm (vidéo plaque)"),
|
||||||
|
default=15.0,
|
||||||
|
help_text=_("Facteur pixels/mm dans la vidéo plaque. À calibrer selon la résolution de la caméra plaque."),
|
||||||
|
)
|
||||||
|
# Origine : position CNC (mm) correspondant au pixel (0, 0) de la vidéo.
|
||||||
|
# Indépendant de MultiWell.xbase — ne change pas à la recalibration des puits.
|
||||||
|
x_origin_mm = models.FloatField(
|
||||||
|
_("Origine X (mm)"),
|
||||||
|
default=0.0,
|
||||||
|
help_text=_("Position CNC X correspondant au bord gauche de la vidéo plaque (mm). Défaut 0."),
|
||||||
|
)
|
||||||
|
y_origin_mm = models.FloatField(
|
||||||
|
_("Origine Y (mm)"),
|
||||||
|
default=0.0,
|
||||||
|
help_text=_("Position CNC Y correspondant au bord haut de la vidéo plaque (mm). Défaut 0."),
|
||||||
|
)
|
||||||
|
# Métadonnées extraites automatiquement à l'upload
|
||||||
|
native_fps = models.FloatField(_("FPS natif"), null=True, blank=True)
|
||||||
|
duration_s = models.FloatField(_("Durée (s)"), null=True, blank=True)
|
||||||
|
frame_w = models.PositiveIntegerField(_("Largeur (px)"), null=True, blank=True)
|
||||||
|
frame_h = models.PositiveIntegerField(_("Hauteur (px)"), null=True, blank=True)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def active_for(cls, multiwell_position: str) -> "VideoPlate | None":
|
||||||
|
return cls.objects.filter(multiwell__position=multiwell_position, active=True).first()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def active_video(cls) -> "VideoPlate | None":
|
||||||
|
return cls.objects.filter(active=True).first()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def video_filename(self) -> str:
|
||||||
|
return Path(self.video_file.name).name if self.video_file else "—"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def resolution(self) -> str:
|
||||||
|
if self.frame_w and self.frame_h:
|
||||||
|
return f"{self.frame_w}×{self.frame_h}"
|
||||||
|
return "—"
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['multiwell__order', '-uploaded_at']
|
||||||
|
verbose_name = _("Vidéo plaque")
|
||||||
|
verbose_name_plural = _("Vidéos plaque")
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f"{self.multiwell.position} — {self.label or self.video_filename}"
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(post_save, sender=VideoPlate)
|
||||||
|
def notify_video_plate_change(sender, instance, **kwargs):
|
||||||
|
"""Hot swap : publie sur Redis quand une vidéo active est enregistrée."""
|
||||||
|
if not instance.active or not instance.video_file:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
from redis import Redis
|
||||||
|
from django.conf import settings as django_settings
|
||||||
|
r = Redis(
|
||||||
|
host=django_settings.REDIS_HOST,
|
||||||
|
port=django_settings.REDIS_PORT,
|
||||||
|
db=0,
|
||||||
|
decode_responses=True,
|
||||||
|
)
|
||||||
|
r.publish('scanner_proc', json.dumps({
|
||||||
|
'type': 'scanner',
|
||||||
|
'topic': 'video_plate',
|
||||||
|
'multiwell': instance.multiwell.position,
|
||||||
|
'path': instance.video_file.path,
|
||||||
|
}))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(post_delete, sender=VideoPlate)
|
||||||
|
def delete_video_file(sender, instance, **kwargs):
|
||||||
|
"""Supprime le fichier physique quand l'enregistrement est effacé."""
|
||||||
|
if instance.video_file:
|
||||||
|
path = Path(instance.video_file.path)
|
||||||
|
path.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(post_save, sender=Experiment)
|
||||||
|
def create_experiment_well(sender, instance, created, **kwargs):
|
||||||
|
from planarian.models import ExperimentConfig
|
||||||
|
from .constants import ScannerConstants
|
||||||
|
|
||||||
|
wellposition = WellPosition.well_by_multiwell(instance.multiwell)
|
||||||
|
for wp in wellposition:
|
||||||
|
ExperimentWell.objects.get_or_create(experiment=instance, well=wp.well, author=instance.author, defaults={'active':True})
|
||||||
|
|
||||||
|
ExperimentConfig.objects.get_or_create(
|
||||||
|
experiment_key=instance,
|
||||||
|
well=wp.well.name,
|
||||||
|
author=instance.author,
|
||||||
|
experiment=instance.identifier,
|
||||||
|
defaults={
|
||||||
|
'px_per_mm': wp.px_per_mm,
|
||||||
|
'fps': ScannerConstants().get().video_frame_rate,
|
||||||
|
'well_radius_mm': instance.multiwell.diameter / 2,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -7,20 +7,21 @@ Created on 20 avr. 2026
|
|||||||
|
|
||||||
@author: denis
|
@author: denis
|
||||||
'''
|
'''
|
||||||
|
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
from threading import Thread, Event
|
from threading import Thread, Event
|
||||||
#from django.utils.translation import gettext_lazy as _
|
#from django.utils.translation import gettext_lazy as _
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.utils.html import mark_safe
|
from django.utils.safestring import mark_safe
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from planarian.models import ExperimentConfig
|
||||||
from . import models
|
from . import models
|
||||||
|
|
||||||
|
|
||||||
logging.basicConfig(level=logging.INFO)
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class WellIterator:
|
class WellIterator:
|
||||||
"""Itérateur personnalisé pour naviguer dans les Wells"""
|
"""Itérateur personnalisé pour naviguer dans les Wells"""
|
||||||
|
|
||||||
@@ -75,8 +76,11 @@ class WellIterator:
|
|||||||
class MultiWellManager:
|
class MultiWellManager:
|
||||||
|
|
||||||
def __init__(self, process):
|
def __init__(self, process):
|
||||||
|
|
||||||
self.process = process
|
self.process = process
|
||||||
self.cnc_controller = process.grbl
|
self.cnc_controller = process.grbl
|
||||||
|
logger.info(f"MultiWellManager initialized with CNC controller: {self.cnc_controller}")
|
||||||
|
|
||||||
self.stop_playing = Event()
|
self.stop_playing = Event()
|
||||||
self.well_iterator = None
|
self.well_iterator = None
|
||||||
self.multiwel = None
|
self.multiwel = None
|
||||||
@@ -84,6 +88,26 @@ class MultiWellManager:
|
|||||||
self.set_multiwell()
|
self.set_multiwell()
|
||||||
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
|
||||||
@@ -91,146 +115,273 @@ class MultiWellManager:
|
|||||||
self._duration = duration or self.process.conf.calibration_default_duration
|
self._duration = duration or self.process.conf.calibration_default_duration
|
||||||
self.px_per_mm = 50.0
|
self.px_per_mm = 50.0
|
||||||
|
|
||||||
|
def init_manager_values(self):
|
||||||
|
wells = models.WellPosition.objects.filter(multiwell_id=self.multiwell.pk).order_by('order').all() # type: ignore[union-attr]
|
||||||
|
self.well_iterator = WellIterator(wells)
|
||||||
|
self.position = self.multiwell.position
|
||||||
|
self._xbase = self.multiwell.xbase
|
||||||
|
self._ybase = self.multiwell.ybase
|
||||||
|
self._dx = self.multiwell.dx
|
||||||
|
self._dy = self.multiwell.dy
|
||||||
|
|
||||||
def set_multiwell(self, position=None):
|
def set_multiwell(self, position=None):
|
||||||
if position is None:
|
if position is None:
|
||||||
self.multiwell = models.MultiWell.objects.filter(default=True).first()
|
self.multiwell = models.MultiWell.objects.filter(default=True).first()
|
||||||
else:
|
else:
|
||||||
self.multiwell = models.MultiWell.by_position(position)
|
self.multiwell = models.MultiWell.by_position(position)
|
||||||
|
self.init_manager_values()
|
||||||
wells = models.WellPosition.objects.filter(multiwell_id=self.multiwell.id).order_by('order').all()
|
|
||||||
self.well_iterator = WellIterator(wells)
|
|
||||||
|
|
||||||
self.position = self.multiwell.position
|
|
||||||
self._xbase = self.multiwell.xbase
|
|
||||||
self._ybase = self.multiwell.ybase
|
|
||||||
self._dx = self.multiwell.dx
|
|
||||||
self._dy = self.multiwell.dy
|
|
||||||
|
|
||||||
return self.multiwell.config()
|
return self.multiwell.config()
|
||||||
|
|
||||||
|
def set_first_multiwell_from_session(self, sid):
|
||||||
def multiwell_buttons(self):
|
experiments = models.SessionExperiment.experiment_by_session(sid)
|
||||||
|
if experiments:
|
||||||
|
self.multiwell = experiments[0].multiwell
|
||||||
|
self.init_manager_values()
|
||||||
|
|
||||||
|
def multiwell_buttons(self, btn_class="w3-button", onclick=''' onclick="goto_well(this)"'''):
|
||||||
multiwells = []
|
multiwells = []
|
||||||
multiwells.append('''<div class="w3-border well-btn">''')
|
multiwells.append('''<div class="w3-border well-btn">''')
|
||||||
for wl in self.well_iterator:
|
for wl in self.well_iterator:
|
||||||
multiwells.append(f"""<button class="w3-button well" value="{wl.order}" onclick="goto_well(this)">{wl.well.name}</button>""")
|
multiwells.append(f"""<button class="{btn_class} well" value="{wl.order}"{onclick}>{wl.well.name}</button>""")
|
||||||
multiwells.append('''</div>''')
|
multiwells.append('''</div>''')
|
||||||
self.well_iterator.reset()
|
self.well_iterator.reset()
|
||||||
return mark_safe("\n".join(multiwells))
|
return mark_safe("\n".join(multiwells))
|
||||||
|
|
||||||
|
def set_circular_crop(self, crop_radius):
|
||||||
def _grid_scanning_capture(self, uuid, duration):
|
crop = self.process.set_crop_radius(crop_radius)
|
||||||
self.process.data.uuid = uuid
|
self.process.cam.set_circular_crop(crop)
|
||||||
self.process.data.record = True
|
|
||||||
|
|
||||||
start = time.monotonic()
|
def update_crop_radius(self, value):
|
||||||
while not self.stop_playing.is_set():
|
self.multiwell.crop_radius = value
|
||||||
if time.monotonic() - start > duration:
|
self.multiwell.save()
|
||||||
break
|
|
||||||
self.cnc_controller.wait_for(1.0)
|
|
||||||
|
|
||||||
logger.info(f"Arrêter l'enregistrement {uuid}")
|
def _grid_scanning_capture(self, experiment, well_position, simulate=False):
|
||||||
self.process.data.record = False
|
uuid = None
|
||||||
self.process.data.uuid = None
|
try:
|
||||||
|
well = well_position.well
|
||||||
|
multiwell = experiment.multiwell
|
||||||
def _grid_scanning(self, experiment, xnext=0, ynext=0):
|
|
||||||
multiwell = experiment.multiwell
|
# En mode video le crop_radius est piloté par _capture_video_simulation
|
||||||
wells = models.WellPosition.objects.filter(multiwell_id=multiwell.id).order_by('order').all()
|
if self.process.conf.capture_type != 'video':
|
||||||
|
self.set_circular_crop(multiwell.crop_radius)
|
||||||
|
|
||||||
|
## create uuid for this capture
|
||||||
|
uuid = f'{self.process.data.session}-{multiwell.position}-{well.name}'
|
||||||
|
|
||||||
|
if self.process.use_tracking:
|
||||||
|
cfg = ExperimentConfig.objects.filter(experiment_key_id=experiment.id, well=well.name).first()
|
||||||
|
if not cfg:
|
||||||
|
raise Exception(f"Configuration d'expérience introuvable pour {experiment} / {well}")
|
||||||
|
# reset PlanarianTracker => on_well_change
|
||||||
|
self.process.cam.on_well_change(cfg, uuid=uuid, draw_contours=False)
|
||||||
|
|
||||||
|
## start recording
|
||||||
|
self.process.data.uuid = uuid
|
||||||
|
if not simulate:
|
||||||
|
self.process.data.record = True
|
||||||
|
self.process._send(current=well_position.order)
|
||||||
|
|
||||||
|
msg = f"Starting capture for {uuid} ordre: {well_position.order}"
|
||||||
|
logger.info(msg)
|
||||||
|
self.process._send(well_state=msg)
|
||||||
|
|
||||||
|
start = time.monotonic()
|
||||||
|
while not self.stop_playing.is_set():
|
||||||
|
## stop after duration in experiemnt now
|
||||||
|
if time.monotonic() - start > experiment.duration:
|
||||||
|
break
|
||||||
|
self.cnc_controller.wait_for(0.1)
|
||||||
|
|
||||||
|
self.process.cam._flush_current_well(uuid)
|
||||||
|
|
||||||
|
self.process.data.record = False
|
||||||
|
self.process.data.uuid = None
|
||||||
|
|
||||||
|
msg = f"{uuid}: capture done..."
|
||||||
|
except Exception as e:
|
||||||
|
msg = f"error during capture - {e}"
|
||||||
|
logger.error(msg)
|
||||||
|
finally:
|
||||||
|
self.process.cam._flush_current_well(uuid)
|
||||||
|
|
||||||
|
logger.info(msg)
|
||||||
|
self.process._send(scan_state=msg)
|
||||||
|
|
||||||
|
|
||||||
|
def _capture_file_simulation(self, name):
|
||||||
|
vf = settings.MEDIA_ROOT / 'simulation' / f'{name}.mp4'
|
||||||
|
if vf.exists():
|
||||||
|
self.process.cam._video_file = str(vf)
|
||||||
|
self.process.cam._error_occured = True
|
||||||
|
logger.info(f"Simulating capture with file {vf}")
|
||||||
|
|
||||||
|
def _capture_video_simulation(self, well_position):
|
||||||
|
"""Met à jour VideoPlateCapture : crop_radius depuis MultiWell.crop_radius."""
|
||||||
cam = self.process.cam
|
cam = self.process.cam
|
||||||
cam._aligner.set_tube_diameter(multiwell.diameter)
|
r = well_position.multiwell.crop_radius
|
||||||
|
if hasattr(cam, 'set_crop_radius_px'):
|
||||||
|
cam.set_crop_radius_px(r)
|
||||||
|
self.set_circular_crop(r)
|
||||||
|
logger.info(f"video_simulation: {well_position.well.name} crop_r={r}px")
|
||||||
|
|
||||||
|
|
||||||
|
def _is_well_valid(self, welposition, experiment):
|
||||||
|
names = models.ExperimentWell.wellname_by_experiment(experiment.id)
|
||||||
|
if welposition.well.name not in names:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _grid_scanning(self, experiment, xnext=0, ynext=0, simulate=False):
|
||||||
|
try:
|
||||||
|
multiwell = experiment.multiwell
|
||||||
|
wellpositions = models.WellPosition.objects.filter(multiwell_id=multiwell.id).order_by('order').all()
|
||||||
|
|
||||||
self.stop_playing = Event()
|
cam = self.process.cam
|
||||||
for wl in wells:
|
cam._aligner.set_tube_diameter(multiwell.diameter)
|
||||||
if self.stop_playing.is_set():
|
|
||||||
break
|
for wl in wellpositions:
|
||||||
self.cnc_controller.move_to(wl.x, wl.y, feed=wl.multiwell.feed)
|
if self.stop_playing.is_set():
|
||||||
|
break
|
||||||
uuid = f'{self.process.data.session}-{multiwell.position}-{wl.well.name}'
|
if not self._is_well_valid(wl, experiment):
|
||||||
self._grid_scanning_capture(uuid, multiwell.duration)
|
continue
|
||||||
|
|
||||||
## change file
|
self.cnc_controller.move_to(wl.x, wl.y, feed=wl.multiwell.feed)
|
||||||
if self.process.conf.capture_type == 'file':
|
if self.process.conf.capture_type == 'file':
|
||||||
self.process.cam._error_occured = True
|
self._capture_file_simulation(wl.well.name)
|
||||||
|
elif self.process.conf.capture_type == 'video':
|
||||||
self.process._send(scan_state=f"{uuid}: capture")
|
self._capture_video_simulation(wl)
|
||||||
|
|
||||||
logger.info(f"Scan terminé — retour à l'origine (X={xnext:.1f} Y={ynext:.1f})")
|
self._grid_scanning_capture(experiment, wl, simulate=simulate)
|
||||||
self.cnc_controller.move_to(xnext, ynext, feed=multiwell.feed*2)
|
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=self.feed*2)
|
||||||
|
|
||||||
|
|
||||||
def _start_scanning(self, session, experiments):
|
def _start_scanning(self, session, experiments, simulate=False):
|
||||||
self.process.cam._aligner.debug = False
|
result = False
|
||||||
xynext = []
|
try:
|
||||||
for obs in experiments:
|
self.process.get_config() # get video configuration if updated
|
||||||
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()
|
self.process.cam._aligner.debug = False
|
||||||
if self.stop_playing.is_set():
|
self.stop_playing.clear()
|
||||||
msg = f"Session {session.name} abandonnée à {session.finished} après {session.finished - started} secondes."
|
|
||||||
else:
|
xynext = []
|
||||||
session.active = False
|
for obs in experiments:
|
||||||
if session.scanning_task:
|
xynext.append((obs.multiwell.xbase, obs.multiwell.ybase))
|
||||||
session.scanning_task.enabled = False
|
xynext.append((0, 0))
|
||||||
session.save()
|
|
||||||
msg = f"Session {session.name} terminée à {session.finished} après {session.finished - started} secondes."
|
pos = 1
|
||||||
logger.info(msg)
|
self.process.data.session = session.id
|
||||||
self.process._send(scan_state=msg)
|
started = timezone.now()
|
||||||
self.scan_thread = None
|
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):
|
||||||
self.process.data.record = False
|
self.process.data.record = False
|
||||||
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)
|
||||||
|
elif self.process.conf.capture_type == 'video':
|
||||||
|
self._capture_video_simulation(wl)
|
||||||
self.cnc_controller.move_to(wl.x, wl.y, feed=wl.multiwell.feed)
|
self.cnc_controller.move_to(wl.x, wl.y, feed=wl.multiwell.feed)
|
||||||
return {"state": "previous", "msg": f">>> ({wl.x}, {wl.y})"}
|
return {"state": "previous", "msg": f">>> {wl.well.name}: ({wl.x}, {wl.y})"}
|
||||||
|
|
||||||
|
|
||||||
def next_well(self):
|
def next_well(self):
|
||||||
wl = self.well_iterator.next()
|
wl = self.well_iterator.next()
|
||||||
|
if self.process.conf.capture_type == 'file':
|
||||||
|
self._capture_file_simulation(wl.well.name)
|
||||||
|
elif self.process.conf.capture_type == 'video':
|
||||||
|
self._capture_video_simulation(wl)
|
||||||
self.cnc_controller.move_to(wl.x, wl.y, feed=wl.multiwell.feed)
|
self.cnc_controller.move_to(wl.x, wl.y, feed=wl.multiwell.feed)
|
||||||
return {"state": "next", "msg": f">>> ({wl.x}, {wl.y})"}
|
return {"state": "next", "msg": f">>> {wl.well.name}: ({wl.x}, {wl.y})"}
|
||||||
|
|
||||||
|
|
||||||
def goto_well(self, numwell):
|
def goto_well(self, numwell):
|
||||||
wl = self.well_iterator.seek(numwell)
|
wl = self.well_iterator.seek(numwell)
|
||||||
|
if self.process.conf.capture_type == 'file':
|
||||||
|
self._capture_file_simulation(wl.well.name)
|
||||||
|
elif self.process.conf.capture_type == 'video':
|
||||||
|
self._capture_video_simulation(wl)
|
||||||
self.cnc_controller.move_to(wl.x, wl.y, feed=wl.multiwell.feed)
|
self.cnc_controller.move_to(wl.x, wl.y, feed=wl.multiwell.feed)
|
||||||
return {"state": "goto", "msg": f">>> ({wl.x}, {wl.y})"}
|
return {"state": "goto", "msg": f">>> {wl.well.name}: ({wl.x}, {wl.y})"}
|
||||||
|
|
||||||
|
def goto_xy(self):
|
||||||
|
wl = self.well_iterator.seek(0)
|
||||||
|
if self.process.conf.capture_type == 'file':
|
||||||
|
self._capture_file_simulation(wl.well.name)
|
||||||
|
elif self.process.conf.capture_type == 'video':
|
||||||
|
self._capture_video_simulation(wl)
|
||||||
|
self.cnc_controller.move_to(self.xbase, self.ybase, feed=self.feed)
|
||||||
|
|
||||||
|
def goto_0(self):
|
||||||
|
self.well_iterator.reset()
|
||||||
|
if self.process.conf.capture_type == 'file':
|
||||||
|
self._capture_file_simulation('zero')
|
||||||
|
elif self.process.conf.capture_type == 'video':
|
||||||
|
# Plein cadre : désactiver le masque circulaire pour ne pas rogner la vue d'ensemble
|
||||||
|
self.process.cam.set_circular_crop(None)
|
||||||
|
self.cnc_controller.move_to(0, 0, feed=self.feed*2)
|
||||||
|
|
||||||
def set_well_position(self):
|
def set_well_position(self):
|
||||||
wl = self.well_iterator.get_current()
|
wl = self.well_iterator.get_current()
|
||||||
if wl:
|
if wl:
|
||||||
@@ -243,17 +394,22 @@ class MultiWellManager:
|
|||||||
|
|
||||||
|
|
||||||
def _scanning_test(self, auto=False):
|
def _scanning_test(self, auto=False):
|
||||||
self.stop_playing = Event()
|
self.stop_playing.clear()
|
||||||
cam = self.process.cam
|
cam = self.process.cam
|
||||||
cam._aligner.set_tube_diameter(self.multiwell.diameter)
|
cam._aligner.set_tube_diameter(self.multiwell.diameter)
|
||||||
duration = self.duration if not auto else settings.CALIBRATION_AUTO_DURATION
|
duration = self.duration if not auto else settings.CALIBRATION_AUTO_DURATION
|
||||||
|
start_test = time.monotonic()
|
||||||
try:
|
try:
|
||||||
start_test = time.monotonic()
|
|
||||||
for wl in self.well_iterator:
|
for wl in self.well_iterator:
|
||||||
if self.stop_playing.is_set():
|
if self.stop_playing.is_set():
|
||||||
break
|
break
|
||||||
|
|
||||||
self.cnc_controller.wait_for(2.0)
|
self.cnc_controller.wait_for(2.0)
|
||||||
self.cnc_controller.move_to(wl.x, wl.y, feed=wl.multiwell.feed)
|
self.cnc_controller.move_to(wl.x, wl.y, feed=wl.multiwell.feed)
|
||||||
|
if self.process.conf.capture_type == 'file':
|
||||||
|
self._capture_file_simulation(wl.well.name)
|
||||||
|
elif self.process.conf.capture_type == 'video':
|
||||||
|
self._capture_video_simulation(wl)
|
||||||
self.process._send(current=wl.order)
|
self.process._send(current=wl.order)
|
||||||
|
|
||||||
start = time.monotonic()
|
start = time.monotonic()
|
||||||
@@ -287,23 +443,24 @@ class MultiWellManager:
|
|||||||
self.process._send(state='center', msg=msg)
|
self.process._send(state='center', msg=msg)
|
||||||
|
|
||||||
self.cnc_controller.wait_for(0.1)
|
self.cnc_controller.wait_for(0.1)
|
||||||
logger.info("Fin du centrage")
|
logger.info("Fin du test")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(e)
|
logger.error("Error during scanning test", e)
|
||||||
|
finally:
|
||||||
|
self.test_thread = None
|
||||||
|
|
||||||
self.well_iterator.reset()
|
self.well_iterator.reset()
|
||||||
self.process.cam._aligner.debug = False
|
self.process.cam._aligner.debug = False
|
||||||
|
|
||||||
logger.info(f"Scan terminé — retour à l'origine (X=0, Y=0) en {int(time.monotonic()-start_test)} s")
|
logger.info(f"Scan terminé — retour à l'origine (X=0, Y=0) en {int(time.monotonic()-start_test)} s")
|
||||||
self.cnc_controller.move_to(0, 0, feed=self.multiwell.feed*2)
|
self.goto_0()
|
||||||
self.test_thread = None
|
self.process._send(current=self.get_well_order())
|
||||||
|
|
||||||
|
|
||||||
def scan_test(self, auto=False):
|
def scan_test(self, auto=False):
|
||||||
if self.test_thread:
|
if self.test_thread:
|
||||||
return
|
return
|
||||||
self.test_thread = Thread(target=self._scanning_test, args=(auto, ), daemon=True).start()
|
self.test_thread = Thread(target=self._scanning_test, args=(auto, ), daemon=True)
|
||||||
|
self.test_thread.start()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def position(self):
|
def position(self):
|
||||||
@@ -369,14 +526,12 @@ class MultiWellManager:
|
|||||||
def dy(self, value):
|
def dy(self, value):
|
||||||
self._dy = value
|
self._dy = value
|
||||||
|
|
||||||
|
|
||||||
def get_well_order(self):
|
def get_well_order(self):
|
||||||
wl = self.well_iterator.get_current()
|
wl = self.well_iterator.get_current()
|
||||||
if wl:
|
if wl:
|
||||||
return wl.order
|
return wl.order
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def set_position(self):
|
def set_position(self):
|
||||||
x, y = self.cnc_controller.get_mpos()
|
x, y = self.cnc_controller.get_mpos()
|
||||||
self.cnc_controller.wait_for(2.0)
|
self.cnc_controller.wait_for(2.0)
|
||||||
@@ -386,18 +541,17 @@ class MultiWellManager:
|
|||||||
wl.x, wl.y = x, y
|
wl.x, wl.y = x, y
|
||||||
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}"}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -21,20 +21,25 @@ from celery.exceptions import Ignore
|
|||||||
from celery.utils.log import get_task_logger
|
from celery.utils.log import get_task_logger
|
||||||
from redis import Redis
|
from redis import Redis
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from modules import reductstore, grbl, utils
|
from modules import reductstore, utils, planarian_metrics
|
||||||
|
|
||||||
## camera devices
|
## camera devices
|
||||||
from modules.circular_crop import CircularCrop, CropStrategy
|
from modules.circular_crop import CircularCrop, CropStrategy
|
||||||
from .multiwell import MultiWellManager
|
from .multiwell import MultiWellManager
|
||||||
from .constants import ScannerConstants
|
from .constants import ScannerConstants
|
||||||
from . import models
|
from .models import MultiWell
|
||||||
|
|
||||||
|
# CNC
|
||||||
|
if not settings.GRBL_SIMULATION:
|
||||||
|
from modules.grbl import GRBLController # @UnusedImport
|
||||||
|
else:
|
||||||
|
from modules.grbl_simulator import GRBLController # @Reimport
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class ProcessData:
|
class ProcessData:
|
||||||
play: bool = True
|
play: bool = True
|
||||||
record: bool = False
|
record: bool = False
|
||||||
uuid: str = None
|
uuid: str | None = None
|
||||||
session: int = 0
|
session: int = 0
|
||||||
tube_diameter: float = 16.0
|
tube_diameter: float = 16.0
|
||||||
|
|
||||||
@@ -42,6 +47,8 @@ class ProcessData:
|
|||||||
logger = get_task_logger(__name__)
|
logger = get_task_logger(__name__)
|
||||||
redisDB = Redis(host=settings.REDIS_HOST, port=settings.REDIS_PORT, db=0, decode_responses=True)
|
redisDB = Redis(host=settings.REDIS_HOST, port=settings.REDIS_PORT, db=0, decode_responses=True)
|
||||||
cameraDB = reductstore.ReductStore(name='camera')
|
cameraDB = reductstore.ReductStore(name='camera')
|
||||||
|
planarianDB = planarian_metrics.ReductStoreClient(url=settings.REDUCTSTORE_URL, token=settings.REDUCTSTORE_TOKEN)
|
||||||
|
async_to_sync(planarianDB.connect)()
|
||||||
|
|
||||||
|
|
||||||
class CameraRecordManager():
|
class CameraRecordManager():
|
||||||
@@ -139,11 +146,12 @@ class CameraRecordManager():
|
|||||||
|
|
||||||
|
|
||||||
class ScannerProcess(Task):
|
class ScannerProcess(Task):
|
||||||
|
name='scanner.scanner_process'
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
|
|
||||||
self.channel_layer = get_channel_layer()
|
self.channel_layer = get_channel_layer()
|
||||||
self.group = f'scanner_proc'
|
self.group = 'scanner_proc'
|
||||||
self.stop_event = Event()
|
self.stop_event = Event()
|
||||||
self.cam = None
|
self.cam = None
|
||||||
self.grbl = None
|
self.grbl = None
|
||||||
@@ -153,42 +161,51 @@ 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)
|
||||||
|
|
||||||
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.constants = ScannerConstants()
|
||||||
|
self.conf = self.constants.get()
|
||||||
|
self.use_tracking = self.conf.tracking
|
||||||
|
|
||||||
|
self.video_quality = self.conf.video_jpeg_quality
|
||||||
|
self.image_quality = self.conf.image_quality
|
||||||
|
self.video_fps = self.conf.video_frame_rate
|
||||||
|
self.video_width = self.conf.video_width_capture
|
||||||
|
self.video_height = self.conf.video_height_capture
|
||||||
|
|
||||||
|
self.crop_radius = self.conf.calibration_crop_radius
|
||||||
|
|
||||||
|
self.video_jpg_quality = [int(cv2.IMWRITE_JPEG_QUALITY), self.video_quality]
|
||||||
|
self.image_jpg_quality = [int(cv2.IMWRITE_JPEG_QUALITY), self.image_quality]
|
||||||
|
return self.conf
|
||||||
|
|
||||||
|
def save_config(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def start(self, *args, **kwargs):
|
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
|
||||||
|
|
||||||
self.crop = self.set_crop_radius(self.crop_radius)
|
self.crop = self.set_crop_radius(self.crop_radius)
|
||||||
|
|
||||||
capture_type = self.conf.capture_type
|
capture_type = self.conf.capture_type
|
||||||
if capture_type == 'file':
|
if capture_type == 'file':
|
||||||
video_lists = []
|
|
||||||
wells = models.Well.objects.all()
|
|
||||||
for wl in wells:
|
|
||||||
video_lists.append(str( settings.MEDIA_ROOT / 'simulation' / f'{wl.name}.mp4') )
|
|
||||||
|
|
||||||
from modules.videofile_capture import VideoFileCapture
|
from modules.videofile_capture import VideoFileCapture
|
||||||
self.cam = VideoFileCapture(
|
self.cam = VideoFileCapture(
|
||||||
video_file=settings.MEDIA_ROOT / 'simulation' / 'A1.mp4',
|
video_file=settings.MEDIA_ROOT / 'simulation' / 'default_simulation.mp4',
|
||||||
fps=self.video_fps,
|
fps=self.video_fps,
|
||||||
width=self.video_width,
|
width=self.video_width,
|
||||||
height=self.video_height,
|
height=self.video_height,
|
||||||
@@ -196,8 +213,30 @@ class ScannerProcess(Task):
|
|||||||
use_tracking=self.use_tracking,
|
use_tracking=self.use_tracking,
|
||||||
display=self._display,
|
display=self._display,
|
||||||
parent=self,
|
parent=self,
|
||||||
video_lists=video_lists,
|
)
|
||||||
)
|
elif capture_type == 'video':
|
||||||
|
from modules.videoplate_capture import VideoPlateCapture
|
||||||
|
from .models import VideoPlate
|
||||||
|
|
||||||
|
vp = VideoPlate.active_video()
|
||||||
|
if not vp:
|
||||||
|
raise Exception("Aucun VideoPlate actif trouvé — créer un enregistrement dans l'admin.")
|
||||||
|
initial_path = vp.video_file.path if vp.video_file else None
|
||||||
|
mw = vp.multiwell
|
||||||
|
|
||||||
|
self.cam = VideoPlateCapture(
|
||||||
|
video_dir=settings.MEDIA_ROOT / 'videos',
|
||||||
|
fps=self.video_fps,
|
||||||
|
jpeg_quality=self.video_quality,
|
||||||
|
use_tracking=self.use_tracking,
|
||||||
|
display=self._display,
|
||||||
|
parent=self,
|
||||||
|
crop_radius_px=mw.crop_radius,
|
||||||
|
px_per_mm=vp.px_per_mm,
|
||||||
|
x_offset_mm=vp.x_origin_mm,
|
||||||
|
y_offset_mm=vp.y_origin_mm,
|
||||||
|
initial_video_path=initial_path,
|
||||||
|
)
|
||||||
elif capture_type == 'webcam':
|
elif capture_type == 'webcam':
|
||||||
from modules.webcam_capture import WebcamCapture
|
from modules.webcam_capture import WebcamCapture
|
||||||
self.cam = WebcamCapture(
|
self.cam = WebcamCapture(
|
||||||
@@ -226,14 +265,21 @@ class ScannerProcess(Task):
|
|||||||
self.cam._active_median = False
|
self.cam._active_median = False
|
||||||
self.cam.set_circular_crop(None)
|
self.cam.set_circular_crop(None)
|
||||||
|
|
||||||
self.grbl = grbl.GRBLController(
|
# Mode vidéo : toujours simuler le GRBL (pas de CNC physique)
|
||||||
send_callback=self._display,
|
if capture_type == 'video':
|
||||||
x_max=self.conf.grbl_xmax,
|
from modules.grbl_simulator import GRBLController as _GRBLCtrl
|
||||||
|
else:
|
||||||
|
_GRBLCtrl = GRBLController
|
||||||
|
self.grbl = _GRBLCtrl(
|
||||||
|
send_callback=self._display,
|
||||||
|
x_max=self.conf.grbl_xmax,
|
||||||
y_max=self.conf.grbl_ymax
|
y_max=self.conf.grbl_ymax
|
||||||
)
|
)
|
||||||
|
|
||||||
self.stop_event.clear()
|
self.stop_event.clear()
|
||||||
self.start_services()
|
self.start_services()
|
||||||
|
|
||||||
|
return self.name
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Scanner started error: {e}")
|
logger.error(f"Scanner started error: {e}")
|
||||||
raise Ignore()
|
raise Ignore()
|
||||||
@@ -250,11 +296,14 @@ class ScannerProcess(Task):
|
|||||||
self.stop_event.set()
|
self.stop_event.set()
|
||||||
|
|
||||||
def start_services(self):
|
def start_services(self):
|
||||||
Thread(target=self._listen_to_redis, daemon=True).start()
|
Thread(target=self._init_manager, daemon=True).start()
|
||||||
Thread(target=self._recording, daemon=True).start()
|
Thread(target=self._recording, daemon=True).start()
|
||||||
Thread(target=self._init_grbl, daemon=True).start()
|
Thread(target=self._init_grbl, daemon=True).start()
|
||||||
|
Thread(target=self._listen_to_redis, daemon=True).start()
|
||||||
self.cam.start()
|
self.cam.start()
|
||||||
|
|
||||||
|
logger.warning(f"Scanner services started ...")
|
||||||
|
|
||||||
def _send(self, **payload):
|
def _send(self, **payload):
|
||||||
async_to_sync(self.channel_layer.group_send)(
|
async_to_sync(self.channel_layer.group_send)(
|
||||||
self.group, {
|
self.group, {
|
||||||
@@ -266,31 +315,54 @@ class ScannerProcess(Task):
|
|||||||
def _display(self, **msg):
|
def _display(self, **msg):
|
||||||
if self.grbl:
|
if self.grbl:
|
||||||
self._send(**msg)
|
self._send(**msg)
|
||||||
|
|
||||||
|
|
||||||
|
def _store_metric(self, uuid, metrics, ts):
|
||||||
|
|
||||||
|
if not isinstance(metrics, list):
|
||||||
|
return
|
||||||
|
for r in metrics:
|
||||||
|
pid = r["planarian_id"]
|
||||||
|
record = self.cam._metrics[pid].update(r, well_radius_mm=self.cam._params.well_radius_mm)
|
||||||
|
|
||||||
def _on_frame(self, jpeg_bytes: bytes, ts: datetime, metrics: dict) -> None:
|
async_to_sync(planarianDB.store_metric)(
|
||||||
if self.data.record:
|
record,
|
||||||
self.record_queue.put((self.data.uuid, ts, jpeg_bytes, metrics))
|
self.cam._params.experiment,
|
||||||
if self.data.play:
|
self.cam._params.well,
|
||||||
self._send(ts=ts.timestamp(), jpeg=base64.b64encode(jpeg_bytes).decode(), **metrics)
|
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())
|
||||||
|
|
||||||
|
self._store_frame(uuid, frame, ts, frame_count)
|
||||||
|
|
||||||
if metrics.get("detected"):
|
|
||||||
labels.update({
|
|
||||||
"cx" : str(metrics["cx"]),
|
|
||||||
"cy" : str(metrics["cy"]),
|
|
||||||
"area_px" : str(metrics["area_px"]),
|
|
||||||
"speed_px_s" : str(metrics["speed_px_s"]),
|
|
||||||
"axial_pos" : str(metrics["axial_pos"]),
|
|
||||||
"axial_speed" : str(metrics["axial_speed"]),
|
|
||||||
})
|
|
||||||
|
|
||||||
self.recordDB.write(uuid, frame, labels, ts=ts)
|
|
||||||
self.record_queue.task_done()
|
self.record_queue.task_done()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f'recorder: {e}')
|
logger.error(f'recorder: {e}')
|
||||||
@@ -302,18 +374,23 @@ class ScannerProcess(Task):
|
|||||||
self._send(state='serial', msg=f"Connected {self.grbl.port}")
|
self._send(state='serial', msg=f"Connected {self.grbl.port}")
|
||||||
|
|
||||||
self.grbl.go_origin(feed=feed)
|
self.grbl.go_origin(feed=feed)
|
||||||
|
if self.conf.capture_type == 'file':
|
||||||
|
self.manager._capture_file_simulation('zero')
|
||||||
self.grbl.wait_for(2.0)
|
self.grbl.wait_for(2.0)
|
||||||
|
|
||||||
|
|
||||||
|
def _init_manager(self):
|
||||||
|
logger.info(f"==== Start MultiWellManager!")
|
||||||
|
self.manager = MultiWellManager(process=self)
|
||||||
|
self.manager.set_calib_debug(False)
|
||||||
|
Event().wait(2.0)
|
||||||
|
|
||||||
|
|
||||||
def _listen_to_redis(self):
|
def _listen_to_redis(self):
|
||||||
|
logger.info(f"==== Scanner {self.group}: listen via redisDB")
|
||||||
|
pubsub = redisDB.pubsub()
|
||||||
|
pubsub.subscribe(self.group)
|
||||||
try:
|
try:
|
||||||
logger.info(f"==== Scanner {self.group}: listen via redisDB")
|
|
||||||
pubsub = redisDB.pubsub()
|
|
||||||
pubsub.subscribe(self.group)
|
|
||||||
#self._init_grbl()
|
|
||||||
self.manager = MultiWellManager(process=self)
|
|
||||||
self.manager.set_calib_debug(False)
|
|
||||||
|
|
||||||
for message in pubsub.listen():
|
for message in pubsub.listen():
|
||||||
try:
|
try:
|
||||||
#logger.info(f"{message}")
|
#logger.info(f"{message}")
|
||||||
@@ -326,22 +403,59 @@ class ScannerProcess(Task):
|
|||||||
if not isinstance(cmd, dict):
|
if not isinstance(cmd, dict):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
self._send(state=cmd["type"], msg=f"Cmd: {cmd.get('topic')} {cmd.get('value', '')}")
|
self._send(state=cmd["type"], msg=f"Cmd: {cmd.get('topic')} {cmd.get('value', '')}")
|
||||||
|
ctx = {}
|
||||||
if cmd["type"]=="scanner":
|
if cmd["type"]=="scanner":
|
||||||
topic = cmd.get("topic")
|
topic = cmd.get("topic")
|
||||||
if topic == 'init':
|
if topic == 'init':
|
||||||
self.cam.set_circular_crop(self.crop)
|
sid = cmd.get("sid")
|
||||||
|
self.manager.set_first_multiwell_from_session(sid)
|
||||||
self.cam._active_median = False
|
self.cam._active_median = False
|
||||||
|
self.cam.set_edge_enhance(False)
|
||||||
|
if self.conf.capture_type == 'video':
|
||||||
|
|
||||||
|
self.cam._active_crop = False
|
||||||
|
|
||||||
|
|
||||||
|
self.cam.set_circular_crop(None)
|
||||||
|
ctx = dict(state="crop", value=self.cam._active_crop)
|
||||||
|
else:
|
||||||
|
self.cam.set_circular_crop(self.crop)
|
||||||
self.grbl.go_origin(feed=self.manager.feed)
|
self.grbl.go_origin(feed=self.manager.feed)
|
||||||
|
self.cam.set_draw_contours(False)
|
||||||
|
|
||||||
|
elif topic == 'video_plate':
|
||||||
|
new_path = cmd.get('path')
|
||||||
|
if new_path and hasattr(self.cam, 'set_video_file'):
|
||||||
|
self.cam.set_video_file(new_path)
|
||||||
|
self._send(state='video_plate', msg=f"Vidéo: {new_path}")
|
||||||
|
continue
|
||||||
|
|
||||||
elif topic == 'scan':
|
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
|
||||||
|
self.cam.set_edge_enhance(False)
|
||||||
|
simulate = (topic=='simulate')
|
||||||
|
self.manager.scan_process(sid, simulate)
|
||||||
|
|
||||||
|
self._send(state=topic, msg=str(_('Balayage démarré...')))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Scan error: {e}")
|
||||||
|
self._send(state='error', msg=str(_('Erreur lors du démarrage du balayage...')))
|
||||||
|
continue
|
||||||
|
|
||||||
|
self._send(
|
||||||
|
buttons=self.manager.multiwell_buttons(btn_class="w3-btn well", onclick=""),
|
||||||
|
columns=self.manager.multiwell.cols,
|
||||||
|
current=self.manager.get_well_order(),
|
||||||
|
**ctx
|
||||||
|
)
|
||||||
elif cmd["type"]=="calibrate":
|
elif cmd["type"]=="calibrate":
|
||||||
topic = cmd.get("topic")
|
topic = cmd.get("topic")
|
||||||
value = cmd.get("value")
|
value = cmd.get("value")
|
||||||
@@ -355,7 +469,9 @@ class ScannerProcess(Task):
|
|||||||
position = cmd.get("position")
|
position = cmd.get("position")
|
||||||
self.manager.set_multiwell(position)
|
self.manager.set_multiwell(position)
|
||||||
self.cam.set_circular_crop(None)
|
self.cam.set_circular_crop(None)
|
||||||
self.cam._active_median = False
|
self.cam._active_median = False
|
||||||
|
self.cam.set_edge_enhance(False)
|
||||||
|
self.cam.set_draw_contours(False)
|
||||||
buttons = self.manager.multiwell_buttons()
|
buttons = self.manager.multiwell_buttons()
|
||||||
|
|
||||||
elif topic == 'up':
|
elif topic == 'up':
|
||||||
@@ -372,18 +488,34 @@ class ScannerProcess(Task):
|
|||||||
|
|
||||||
elif topic == 'median':
|
elif topic == 'median':
|
||||||
self.cam._active_median = not self.cam._active_median
|
self.cam._active_median = not self.cam._active_median
|
||||||
|
self._send(state="median", value=self.cam._active_median, msg=f"Median: {self.cam._active_median}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
elif topic == 'edge_enhance':
|
||||||
|
self.cam.set_edge_enhance(not self.cam._active_edge_enhance)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
elif topic == 'crop':
|
elif topic == 'crop':
|
||||||
self.cam._active_crop = not self.cam._active_crop
|
self.cam._active_crop = not self.cam._active_crop
|
||||||
self.cam.set_circular_crop(self.crop) if self.cam._active_crop else self.cam.set_circular_crop(None)
|
if self.cam._active_crop:
|
||||||
|
self.cam.set_circular_crop(self.crop)
|
||||||
|
# En mode vidéo, naviguer vers le premier puit (Base)
|
||||||
|
# pour que le crop circulaire soit aligné sur un puit réel
|
||||||
|
if self.conf.capture_type == 'video':
|
||||||
|
self.manager.goto_xy()
|
||||||
|
else:
|
||||||
|
self.cam.set_circular_crop(None)
|
||||||
|
self._send(state="crop", value=self.cam._active_crop, msg=f"Crop: {self.cam._active_crop}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
elif topic == 'crop_radius':
|
elif topic == 'crop_radius':
|
||||||
self.conf.calibration_crop_radius=int(value)
|
self.conf.calibration_crop_radius=int(value)
|
||||||
self.crop = self.set_crop_radius(self.conf.calibration_crop_radius)
|
self.crop = self.set_crop_radius(self.conf.calibration_crop_radius)
|
||||||
self.conf.save()
|
self.constants.save_config() # type: ignore[attr-defined]
|
||||||
|
|
||||||
self.cam.set_circular_crop(self.crop)
|
self.cam.set_circular_crop(self.crop)
|
||||||
|
|
||||||
|
self.manager.update_crop_radius(int(value))
|
||||||
continue
|
continue
|
||||||
|
|
||||||
elif topic == 'position':
|
elif topic == 'position':
|
||||||
@@ -400,13 +532,11 @@ class ScannerProcess(Task):
|
|||||||
self.manager.duration = float(value)
|
self.manager.duration = float(value)
|
||||||
|
|
||||||
elif topic == 'goto_0':
|
elif topic == 'goto_0':
|
||||||
self.grbl.go_origin(feed=self.manager.feed)
|
self.manager.goto_0()
|
||||||
self.manager.well_iterator.reset()
|
|
||||||
|
|
||||||
elif topic == 'goto_xy':
|
elif topic == 'goto_xy':
|
||||||
self.grbl.move_to(self.manager.xbase, self.manager.ybase, feed=self.manager.feed)
|
self.manager.goto_xy()
|
||||||
self.manager.well_iterator.seek(0)
|
|
||||||
|
|
||||||
elif topic == 'xy_base':
|
elif topic == 'xy_base':
|
||||||
self.manager.set_position()
|
self.manager.set_position()
|
||||||
|
|
||||||
@@ -417,6 +547,12 @@ class ScannerProcess(Task):
|
|||||||
elif topic == 'auto':
|
elif topic == 'auto':
|
||||||
self.manager.set_calib_debug(True)
|
self.manager.set_calib_debug(True)
|
||||||
self.cam.set_circular_crop(self.crop)
|
self.cam.set_circular_crop(self.crop)
|
||||||
|
# En mode vidéo le puit remplit le crop (ratio ~0.50) ;
|
||||||
|
# en mode caméra le tube occupe ~30% du champ.
|
||||||
|
if self.conf.capture_type == 'video':
|
||||||
|
self.cam._aligner.set_radius_range(0.38, 0.47)
|
||||||
|
else:
|
||||||
|
self.cam._aligner.set_radius_range(0.26, 0.37)
|
||||||
self.manager.scan_test(auto=True)
|
self.manager.scan_test(auto=True)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -431,10 +567,20 @@ class ScannerProcess(Task):
|
|||||||
elif topic == 'halt':
|
elif topic == 'halt':
|
||||||
self.manager.halt_scanning()
|
self.manager.halt_scanning()
|
||||||
|
|
||||||
|
|
||||||
elif topic == 'calib_debug':
|
elif topic == 'calib_debug':
|
||||||
|
if self.conf.capture_type == 'video':
|
||||||
|
self.cam._aligner.set_radius_range(0.38, 0.47)
|
||||||
|
else:
|
||||||
|
self.cam._aligner.set_radius_range(0.26, 0.37)
|
||||||
msg = self.manager.calib_toggle_debug()
|
msg = self.manager.calib_toggle_debug()
|
||||||
self._send(**msg)
|
self._send(**msg)
|
||||||
|
continue
|
||||||
|
|
||||||
|
elif topic == 'draw_debug':
|
||||||
|
a = self.cam._aligner
|
||||||
|
a.draw_annotations = not a.draw_annotations
|
||||||
|
self._send(state='draw_debug', value=a.draw_annotations,
|
||||||
|
msg=f"Draw debug: {a.draw_annotations}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
elif topic == 'previous':
|
elif topic == 'previous':
|
||||||
@@ -452,7 +598,18 @@ class ScannerProcess(Task):
|
|||||||
elif topic == 'set_well':
|
elif topic == 'set_well':
|
||||||
msg = self.manager.set_well_position()
|
msg = self.manager.set_well_position()
|
||||||
self._send(**msg)
|
self._send(**msg)
|
||||||
|
|
||||||
|
elif topic == 'draw':
|
||||||
|
draw = (value=="1")
|
||||||
|
self.cam.set_draw_contours(draw)
|
||||||
|
self._send(state=topic, msg=f"Tracking contour: {draw}")
|
||||||
|
|
||||||
|
elif topic in ['min_area_px', 'max_area_ratio', 'max_planarians', 'merge_kernel_size', 'min_contour_dist_px']:
|
||||||
|
value = int(value) if topic in ['min_area_px', 'max_planarians', 'merge_kernel_size', 'min_contour_dist_px'] else float(value)
|
||||||
|
self.manager.tracker_config[topic] = value
|
||||||
|
self.cam.on_test_well_change(**self.manager.tracker_config)
|
||||||
|
self._send(state=topic, msg=f"Value changed {value}")
|
||||||
|
|
||||||
self._send(
|
self._send(
|
||||||
xbase=self.manager.xbase,
|
xbase=self.manager.xbase,
|
||||||
ybase=self.manager.ybase,
|
ybase=self.manager.ybase,
|
||||||
@@ -463,6 +620,7 @@ class ScannerProcess(Task):
|
|||||||
dx=self.manager.dx,
|
dx=self.manager.dx,
|
||||||
dy=self.manager.dy,
|
dy=self.manager.dy,
|
||||||
buttons=buttons,
|
buttons=buttons,
|
||||||
|
columns=self.manager.multiwell.cols,
|
||||||
current=self.manager.get_well_order(),
|
current=self.manager.get_well_order(),
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -624,12 +782,11 @@ class ReplayProcess(Task):
|
|||||||
logger.info(f"==== ReplayProcess stopped.")
|
logger.info(f"==== ReplayProcess stopped.")
|
||||||
|
|
||||||
def _listen_to_redis(self):
|
def _listen_to_redis(self):
|
||||||
|
loop = None
|
||||||
|
logger.info(f"==== ReplayProcess {self.group}: listen via redisDB")
|
||||||
|
pubsub = redisDB.pubsub()
|
||||||
|
pubsub.subscribe(self.group)
|
||||||
try:
|
try:
|
||||||
loop = None
|
|
||||||
logger.info(f"==== ReplayProcess {self.group}: listen via redisDB")
|
|
||||||
pubsub = redisDB.pubsub()
|
|
||||||
pubsub.subscribe(self.group)
|
|
||||||
|
|
||||||
for message in pubsub.listen():
|
for message in pubsub.listen():
|
||||||
try:
|
try:
|
||||||
if self.stop_event.is_set():
|
if self.stop_event.is_set():
|
||||||
@@ -681,7 +838,8 @@ class ReplayProcess(Task):
|
|||||||
logger.error(f'ReplayProcess::listen_to_redis: {e}')
|
logger.error(f'ReplayProcess::listen_to_redis: {e}')
|
||||||
finally:
|
finally:
|
||||||
self.running.set()
|
self.running.set()
|
||||||
utils.stop_async(loop)
|
if loop:
|
||||||
|
utils.stop_async(loop)
|
||||||
pubsub.unsubscribe()
|
pubsub.unsubscribe()
|
||||||
pubsub.close()
|
pubsub.close()
|
||||||
|
|
||||||
|
|||||||
@@ -5,10 +5,8 @@
|
|||||||
|
|
||||||
.well-btn {
|
.well-btn {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(6, 1fr);
|
grid-template-columns: repeat(var(--well-columns, 6), 1fr);
|
||||||
justify-items: center;
|
justify-items: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
|
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
|
||||||
|
.well {
|
||||||
|
padding: 0.2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.well-btn {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(var(--well-columns, 6), 1fr);
|
||||||
|
justify-items: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
.video-drop-zone {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 80px;
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 12px 20px;
|
||||||
|
border: 2px dashed #aaa;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #f9f9f9;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.2s, background 0.2s;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-drop-zone:hover,
|
||||||
|
.video-drop-zone.drag-over {
|
||||||
|
border-color: #417690;
|
||||||
|
background: #e8f3fb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-drop-zone.has-file {
|
||||||
|
border-style: solid;
|
||||||
|
border-color: #28a745;
|
||||||
|
background: #f0fff4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-drop-hint {
|
||||||
|
font-size: 0.9em;
|
||||||
|
color: #555;
|
||||||
|
pointer-events: none;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-drop-zone.has-file .video-drop-hint {
|
||||||
|
color: #28a745;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Progress bar ---- */
|
||||||
|
|
||||||
|
.video-progress-wrap {
|
||||||
|
display: none;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 360px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-progress-bar-track {
|
||||||
|
width: 100%;
|
||||||
|
height: 10px;
|
||||||
|
background: #ddd;
|
||||||
|
border-radius: 5px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-progress-bar {
|
||||||
|
height: 10px;
|
||||||
|
width: 0%;
|
||||||
|
background: #417690;
|
||||||
|
border-radius: 5px;
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-progress-label {
|
||||||
|
display: block;
|
||||||
|
text-align: right;
|
||||||
|
font-size: 0.8em;
|
||||||
|
color: #417690;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
@@ -32,19 +32,30 @@ class ScannerManager {
|
|||||||
this.step = options.step;
|
this.step = options.step;
|
||||||
this.well = options.well;
|
this.well = options.well;
|
||||||
this.debug = options.debug;
|
this.debug = options.debug;
|
||||||
this.calib_debug = options.calib_debug;
|
this.calib_debug = options.calib_debug;
|
||||||
this.calib_center= options.calib_center;
|
this.draw_debug = options.draw_debug;
|
||||||
|
this.calib_center= options.calib_center;
|
||||||
this.previous = options.previous;
|
this.previous = options.previous;
|
||||||
this.next = options.next;
|
this.next = options.next;
|
||||||
this.set_well = options.set_well;
|
this.set_well = options.set_well;
|
||||||
this.well_btn = options.well_btn;
|
this.well_btn = options.well_btn;
|
||||||
this.median = options.median;
|
this.median = options.median;
|
||||||
this.crop = options.crop;
|
this.edge_enhance = options.edge_enhance;
|
||||||
|
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() {
|
||||||
this.up.addEventListener('mousedown', (e) => { this._send({ type: 'calibrate', topic: "up" }); });
|
this.up.addEventListener('mousedown', (e) => { this._send({ type: 'calibrate', topic: "up" }); });
|
||||||
this.down.addEventListener('mousedown', (e) => { this._send({ type: 'calibrate', topic: "down" }); });
|
this.down.addEventListener('mousedown', (e) => { this._send({ type: 'calibrate', topic: "down" }); });
|
||||||
this.left.addEventListener('mousedown', (e) => { this._send({ type: 'calibrate', topic: "left" }); });
|
this.left.addEventListener('mousedown', (e) => { this._send({ type: 'calibrate', topic: "left" }); });
|
||||||
@@ -53,24 +64,35 @@ class ScannerManager {
|
|||||||
this.goto_0.addEventListener('click', (e) => { this.clear_buttons(); this._send({ type: 'calibrate', topic: "goto_0" }); });
|
this.goto_0.addEventListener('click', (e) => { this.clear_buttons(); this._send({ type: 'calibrate', topic: "goto_0" }); });
|
||||||
this.goto_xy.addEventListener('click', (e) => { this.clear_buttons(); this._send({ type: 'calibrate', topic: "goto_xy" }); });
|
this.goto_xy.addEventListener('click', (e) => { this.clear_buttons(); this._send({ type: 'calibrate', topic: "goto_xy" }); });
|
||||||
this.xy_base.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "xy_base" }); });
|
this.xy_base.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "xy_base" }); });
|
||||||
|
|
||||||
this.calib_debug.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "calib_debug" }); });
|
|
||||||
this.previous.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "previous" }); });
|
|
||||||
this.next.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "next" }); });
|
|
||||||
this.set_well.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "set_well" }); });
|
this.set_well.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "set_well" }); });
|
||||||
|
this.median.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "median" }); });
|
||||||
this.median.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "median" }); });
|
this.edge_enhance.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "edge_enhance" }); });
|
||||||
this.crop.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "crop" }); });
|
this.crop.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "crop" }); });
|
||||||
this.crop_radius.addEventListener('change',(e) => { this._send({ type: 'calibrate', topic: "crop_radius", value: this.crop_radius.value }); });
|
this.crop_radius.addEventListener('change',(e) => { this._send({ type: 'calibrate', topic: "crop_radius", value: this.crop_radius.value }); });
|
||||||
|
|
||||||
this.well.addEventListener("change", (e) => { this._send({ type: 'calibrate', topic: "position", value: e.target.value }); });
|
this.well.addEventListener("change", (e) => { this._send({ type: 'calibrate', topic: "position", value: e.target.value }); });
|
||||||
this.step.addEventListener("change", (e) => { this._send({ type: 'calibrate', topic: "step", value: e.target.value }); });
|
this.step.addEventListener("change", (e) => { this._send({ type: 'calibrate', topic: "step", value: e.target.value }); });
|
||||||
this.feed.addEventListener("change", (e) => { this._send({ type: 'calibrate', topic: "feed", value: e.target.value }); });
|
this.feed.addEventListener("change", (e) => { this._send({ type: 'calibrate', topic: "feed", value: e.target.value }); });
|
||||||
this.duration.addEventListener("change", (e) => { this._send({ type: 'calibrate', topic: "duration", value: e.target.value }); });
|
this.duration.addEventListener("change", (e) => { this._send({ type: 'calibrate', topic: "duration", value: e.target.value }); });
|
||||||
|
|
||||||
this.test.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "test" }); });
|
this.test.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "test" }); });
|
||||||
this.calib_center.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "center" }); });
|
|
||||||
this.calib_auto.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "auto" }); });
|
|
||||||
this.halt.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "halt" }); });
|
this.halt.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "halt" }); });
|
||||||
|
this.calib_debug.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "calib_debug" }); });
|
||||||
|
this.draw_debug.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "draw_debug" }); });
|
||||||
|
try {
|
||||||
|
|
||||||
|
this.previous.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "previous" }); });
|
||||||
|
this.next.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "next" }); });
|
||||||
|
this.calib_center.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "center" }); });
|
||||||
|
this.calib_auto.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "auto" }); });
|
||||||
|
this.min_area_px.addEventListener('change', (e) => { this._send({ type: 'calibrate', topic: "min_area_px", value: e.target.value }); });
|
||||||
|
this.max_area_ratio.addEventListener('change', (e) => { this._send({ type: 'calibrate', topic: "max_area_ratio", value: e.target.value }); });
|
||||||
|
this.max_planarians.addEventListener('change', (e) => { this._send({ type: 'calibrate', topic: "max_planarians", value: e.target.value}); });
|
||||||
|
this.merge_kernel_size.addEventListener('change', (e) => { this._send({ type: 'calibrate', topic: "merge_kernel_size", value: e.target.value}); });
|
||||||
|
this.min_contour_dist_px.addEventListener('change', (e) => { this._send({ type: 'calibrate', topic: "min_contour_dist_px", value: e.target.value }); });
|
||||||
|
this.draw.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "draw", value: e.target.value }); });
|
||||||
|
} catch(e) {console.log(e);}
|
||||||
}
|
}
|
||||||
|
|
||||||
registerSocket(socket) {
|
registerSocket(socket) {
|
||||||
@@ -83,25 +105,32 @@ class ScannerManager {
|
|||||||
if (payload.jpeg) { this.container.src = `data:image/jpeg;base64,${payload.jpeg}`; }
|
if (payload.jpeg) { this.container.src = `data:image/jpeg;base64,${payload.jpeg}`; }
|
||||||
if (payload.xbase) { this.xbase.textContent = payload.xbase; this.ybase.textContent = payload.ybase; }
|
if (payload.xbase) { this.xbase.textContent = payload.xbase; this.ybase.textContent = payload.ybase; }
|
||||||
if (payload.xy) { this.x.textContent=payload.x.toFixed(2); this.y.textContent=payload.y.toFixed(2); }
|
if (payload.xy) { this.x.textContent=payload.x.toFixed(2); this.y.textContent=payload.y.toFixed(2); }
|
||||||
if (payload.state) { this.debug.insertAdjacentHTML('afterbegin', `<li>[ ${++this.debug_count} - ${payload.state} ]: ${payload.msg}</li>`); }
|
if (payload.state) {
|
||||||
if (payload.ts) { this.ts.textContent = timestampToLocalISOString(payload.ts); }
|
if (payload.state == 'debug') {
|
||||||
|
const span = this.calib_debug.querySelector("span.debug"); span.style.color = payload.value ? '#f0f': '#fff';
|
||||||
if (payload.detected && use_tracking) {
|
} else if (payload.state == 'draw_debug') {
|
||||||
this.cx.textContent = payload.cx; this.cy.textContent = payload.cy;
|
const span = this.draw_debug.querySelector("span.draw_debug"); span.style.color = payload.value ? '#0ff' : '#888';
|
||||||
this.speed_px_s.textContent = payload.speed_px_s;
|
} else if (payload.state == 'median') {
|
||||||
this.axial_speed.textContent = payload.axial_speed;
|
const span = this.median.querySelector("span.median"); span.style.color = payload.value ? '#f0f' : '#fff';
|
||||||
this.axial_pos.textContent = payload.axial_pos;
|
} else if (payload.state == 'edge_enhance') {
|
||||||
this.area_px.textContent = payload.area_px;
|
const span = this.edge_enhance.querySelector("span.edge_enhance"); span.style.color = payload.value ? '#0ff' : '#fff';
|
||||||
this.frame_count.textContent = payload.count;
|
} 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.buttons) {
|
||||||
|
this.well_btn.innerHTML = payload.buttons;
|
||||||
|
document.documentElement.style.setProperty('--well-columns', payload.columns);
|
||||||
}
|
}
|
||||||
if (payload.buttons) { this.well_btn.innerHTML = payload.buttons; }
|
|
||||||
if (payload.current >= 0) {
|
if (payload.current >= 0) {
|
||||||
document.querySelectorAll('button.w3-button.well').forEach(btn => {
|
document.querySelectorAll('button.w3-button.well').forEach(btn => {
|
||||||
if (btn.value==payload.current) { btn.classList.add('w3-green'); return; }
|
if (btn.value==payload.current) { btn.classList.add('w3-green'); return; }
|
||||||
btn.classList.remove('w3-green');
|
btn.classList.remove('w3-green');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch(e) { console.log(e); }
|
} catch(e) { console.log(e); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -171,7 +171,7 @@ class ReplayManager {
|
|||||||
const ok = confirm(`Télécharger le fichier ?\n\n${filename}`);
|
const ok = confirm(`Télécharger le fichier ?\n\n${filename}`);
|
||||||
if (!ok) return false;
|
if (!ok) return false;
|
||||||
|
|
||||||
fetch(this.video_endpoint, {
|
csrfFetch(this.video_endpoint, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user