Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 88cdc69f7d | |||
| c0a57a8920 | |||
| 299bfad872 | |||
| c6145f250b | |||
| 90d271dc2b | |||
| 695e59174a | |||
| e4bd67b686 | |||
| 9bb8fc1bce |
+66
-4
@@ -80,9 +80,10 @@ Modèles principaux :
|
|||||||
| Modèle | Rôle |
|
| Modèle | Rôle |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `Configuration` | Config globale active (caméra, GRBL, tracking, calibration) |
|
| `Configuration` | Config globale active (caméra, GRBL, tracking, calibration) |
|
||||||
| `MultiWell` | Plaque multi-puits (position HG/HD/BG/BD, grille 6×4, pas XY) |
|
| `MultiWell` | Plaque multi-puits (position HG/HD/BG/BD, grille 6×4, pas XY, crop_radius) |
|
||||||
| `Well` | Puit individuel (nom Ai..Di) |
|
| `Well` | Puit individuel (nom Ai..Di) |
|
||||||
| `WellPosition` | Position XY mm d'un puit dans un MultiWell + px_per_mm |
|
| `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) |
|
| `Experiment` | Session de capture sur un MultiWell (durée, début/fin) |
|
||||||
| `Session` | Groupe d'expériences avec planification (ClockedSchedule one-shot) |
|
| `Session` | Groupe d'expériences avec planification (ClockedSchedule one-shot) |
|
||||||
| `SessionExperiment` | Liaison Session ↔ Experiment |
|
| `SessionExperiment` | Liaison Session ↔ Experiment |
|
||||||
@@ -109,13 +110,14 @@ Modèle `ExperimentConfig` : paramètres de tracking par puit (px_per_mm, fps, s
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `grbl.py` | Pilotage CNC via port série (G-code, homing, déplacement XY) |
|
| `grbl.py` | Pilotage CNC via port série (G-code, homing, déplacement XY) |
|
||||||
| `grbl_simulator.py` | Simulateur GRBL pour dev sans matériel |
|
| `grbl_simulator.py` | Simulateur GRBL pour dev sans matériel |
|
||||||
| `capture_interface.py` | Interface abstraite de capture |
|
| `capture_interface.py` | Interface abstraite de capture — crop circulaire, edge enhance, debug overlay, tracking |
|
||||||
| `picamera2_capture.py` | Capture ArduCam via Picamera2 |
|
| `picamera2_capture.py` | Capture ArduCam via Picamera2 |
|
||||||
| `webcam_capture.py` | Capture webcam via OpenCV |
|
| `webcam_capture.py` | Capture webcam via OpenCV |
|
||||||
| `videofile_capture.py` | Lecture fichier vidéo (test/sim) |
|
| `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_tracker.py` | Tracking multi-individus : MOG2 + algorithme hongrois (`scipy`) |
|
||||||
| `planarian_metrics.py` | Métriques par frame et summary (mobilité, thigmo, photo, chemo, social) |
|
| `planarian_metrics.py` | Métriques par frame et summary (mobilité, thigmo, photo, chemo, social) |
|
||||||
| `tube_aligner.py` | Alignement et calibration optique du tube |
|
| `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 |
|
| `circular_crop.py` | Découpe circulaire des images de puit |
|
||||||
| `reductstore.py` | Interface ReductStore (stockage/lecture frames time série) |
|
| `reductstore.py` | Interface ReductStore (stockage/lecture frames time série) |
|
||||||
| `system_stats.py` | Stats système (CPU, RAM, disque — affichage dashboard) |
|
| `system_stats.py` | Stats système (CPU, RAM, disque — affichage dashboard) |
|
||||||
@@ -190,6 +192,66 @@ python3 planarian_sim.py --count 5 --photo-mode fixed --photo-x 0.2 --photo-y 0.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 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
|
## Licence
|
||||||
|
|
||||||
GPL-3.0
|
GPL-3.0
|
||||||
|
|
||||||
|
|||||||
@@ -52,7 +52,14 @@ d'analyse distantes.
|
|||||||
|
|
||||||
- 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) :
|
||||||
@@ -238,15 +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:
|
|
||||||
si 10.8.0.100 est l'ip locale du raspberry pi4 le serveur
|
|
||||||
|
|
||||||
10.8.0.100 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
|
||||||
@@ -364,14 +362,28 @@ 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
|
|
||||||
|
|
||||||
2. Activer recadrage pour isoler le tube
|
### 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
|
||||||
|
|
||||||
 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)
|
[🎬 Vidéo Calibration auto](https://youtu.be/6RueJ3onUoY)
|
||||||
|
|
||||||
|
|||||||
+32
-12
@@ -50,7 +50,14 @@ high-performance storage of captures, and export to remote analysis machines.
|
|||||||
|
|
||||||
- CNC arm control through GRBL — automatic well-by-well movement
|
- CNC arm control through GRBL — automatic well-by-well movement
|
||||||
- Multi-well calibration with database synchronization
|
- Multi-well calibration with database synchronization
|
||||||
- High-definition image acquisition via ArduCam (OpenCV + Picamera2)
|
- 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
|
- Frame storage in ReductStore time-series database
|
||||||
- Configurable scan sessions (full grid or selected wells)
|
- Configurable scan sessions (full grid or selected wells)
|
||||||
- Asynchronous export (Celery):
|
- Asynchronous export (Celery):
|
||||||
@@ -227,14 +234,6 @@ or
|
|||||||
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:*
|
||||||
|
|
||||||
Add scanner.local to the hosts file on web clients:
|
|
||||||
if 10.8.0.100 is the Raspberry Pi 4 local IP address of the server
|
|
||||||
|
|
||||||
10.8.0.100 scanner.local
|
|
||||||
|
|
||||||
- linux : /etc/hosts
|
|
||||||
- windows: C:\Windows\System32\drivers\etc\hosts
|
|
||||||
- mac : /private/etc/hosts
|
|
||||||
Repository Organization
|
Repository Organization
|
||||||
PlanarianScanner/
|
PlanarianScanner/
|
||||||
├── assets
|
├── assets
|
||||||
@@ -343,9 +342,30 @@ PlanarianScanner/
|
|||||||
│ └── webfonts
|
│ └── webfonts
|
||||||
└── templates
|
└── templates
|
||||||
└── admin
|
└── admin
|
||||||
4-Step Calibration Procedure
|
## Calibration Procedure
|
||||||
Enable "Detection Debug" → display the circle and zones on the stream
|
|
||||||
Enable cropping to isolate the tube
|
### 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
|
## Status
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -20,4 +21,4 @@ mysqlclient
|
|||||||
psycopg2
|
psycopg2
|
||||||
pyserial
|
pyserial
|
||||||
scipy
|
scipy
|
||||||
|
django-stubs[compatible-mypy]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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,7 +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/
|
||||||
@@ -35,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'
|
||||||
@@ -237,7 +237,7 @@ 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
|
IS_LOGGING = False
|
||||||
|
|
||||||
@@ -365,9 +365,6 @@ CELERY_BEAT_SCHEDULE = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## reductstore
|
## reductstore
|
||||||
#
|
#
|
||||||
REDUCTSTORE_TOKEN = config('REDUCTSTORE_TOKEN')
|
REDUCTSTORE_TOKEN = config('REDUCTSTORE_TOKEN')
|
||||||
@@ -404,7 +401,7 @@ DATETIME_FORMAT = '%d-%m-%Y-%m %H:%M:%S'
|
|||||||
# rpicam 4056x3040 2028x1080 2028x1520
|
# rpicam 4056x3040 2028x1080 2028x1520
|
||||||
#===========================
|
#===========================
|
||||||
|
|
||||||
GRBL_SIMULATION = True
|
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")
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ from asgiref.sync import async_to_sync
|
|||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from modules.planarian_tracker import PlanarianTracker
|
from modules.planarian_tracker import PlanarianTracker
|
||||||
from modules.planarian_metrics import ExperimentParams
|
from modules.planarian_metrics import ExperimentParams, EthoVisionMetrics
|
||||||
from modules.tube_aligner import TubeAligner
|
from modules.tube_aligner import TubeAligner
|
||||||
|
|
||||||
|
|
||||||
@@ -81,11 +81,12 @@ 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 = None
|
self._tracker: PlanarianTracker | None = None
|
||||||
self._metrics = None
|
self._metrics: list[EthoVisionMetrics] | None = None
|
||||||
self._params = None
|
self._params: ExperimentParams | None = None
|
||||||
self._clientDB = self.parent.metricDB
|
self._clientDB = self.parent.metricDB
|
||||||
|
|
||||||
# Tracker générique, pour simulation
|
# Tracker générique, pour simulation
|
||||||
@@ -284,10 +285,30 @@ class VideoCaptureInterface(abc.ABC):
|
|||||||
msg= f"{self.__class__.__name__}: recadrage circulaire désactivé"
|
msg= f"{self.__class__.__name__}: recadrage circulaire désactivé"
|
||||||
|
|
||||||
logger.info(msg)
|
logger.info(msg)
|
||||||
|
if self.display is not None:
|
||||||
self.display(state='circular_crop', msg=msg)
|
self.display(state='circular_crop', msg=msg)
|
||||||
|
|
||||||
|
|
||||||
def process_frame(self, jpeg_bytes: bytes) -> bytes:
|
def set_edge_enhance(self, enabled: bool) -> None:
|
||||||
|
"""Active ou désactive le filtre de mise en évidence des contours (calibration)."""
|
||||||
|
self._active_edge_enhance = enabled
|
||||||
|
logger.info(f"{self.__class__.__name__}: edge_enhance={enabled}")
|
||||||
|
if self.display is not None:
|
||||||
|
self.display(state='edge_enhance', value=enabled, msg=f"Edge enhance: {enabled}")
|
||||||
|
|
||||||
|
def _apply_edge_enhance(self, frame: np.ndarray) -> np.ndarray:
|
||||||
|
"""Overlay Canny vert additif sur l'image originale.
|
||||||
|
Flou fort avant détection pour ne garder que les bords dominants (rebord du puit).
|
||||||
|
"""
|
||||||
|
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
|
||||||
|
blurred = cv2.GaussianBlur(gray, (9, 9), 2)
|
||||||
|
edges = cv2.Canny(blurred, 80, 200)
|
||||||
|
edges = cv2.dilate(edges, np.ones((3, 3), np.uint8), iterations=1)
|
||||||
|
overlay = np.zeros_like(frame)
|
||||||
|
overlay[edges > 0] = [0, 255, 0] # vert sur fond noir
|
||||||
|
return cv2.addWeighted(frame, 1.0, overlay, 1.0, 0) # additif : image inchangée hors bords
|
||||||
|
|
||||||
|
def process_frame(self, jpeg_bytes: bytes) -> tuple[bytes, dict]:
|
||||||
"""
|
"""
|
||||||
Applique le post-traitement configuré sur une image brute.
|
Applique le post-traitement configuré sur une image brute.
|
||||||
|
|
||||||
@@ -306,7 +327,11 @@ class VideoCaptureInterface(abc.ABC):
|
|||||||
if frame is None:
|
if frame is None:
|
||||||
return jpeg, metrics
|
return jpeg, metrics
|
||||||
try:
|
try:
|
||||||
# Mode debug
|
# Edge enhance sur la frame propre, avant les annotations
|
||||||
|
if self._active_edge_enhance:
|
||||||
|
frame = self._apply_edge_enhance(frame)
|
||||||
|
##
|
||||||
|
# Mode debug (annotations par-dessus)
|
||||||
if self._aligner.debug:
|
if self._aligner.debug:
|
||||||
self.align_detection = self._aligner.detect_tube(frame)
|
self.align_detection = self._aligner.detect_tube(frame)
|
||||||
annotated = self.align_detection.get('frame_annotated')
|
annotated = self.align_detection.get('frame_annotated')
|
||||||
@@ -325,6 +350,16 @@ class VideoCaptureInterface(abc.ABC):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(e)
|
logger.error(e)
|
||||||
|
|
||||||
|
# Pas de circular crop — appliquer edge enhance si actif
|
||||||
|
if self._active_edge_enhance:
|
||||||
|
nparr = np.frombuffer(jpeg_bytes, np.uint8)
|
||||||
|
frame = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
|
||||||
|
if frame is not None:
|
||||||
|
frame = self._apply_edge_enhance(frame)
|
||||||
|
ok, buf = cv2.imencode(".jpg", frame, [cv2.IMWRITE_JPEG_QUALITY, self.jpeg_quality])
|
||||||
|
if ok:
|
||||||
|
return buf.tobytes(), metrics
|
||||||
|
|
||||||
return jpeg_bytes, metrics
|
return jpeg_bytes, metrics
|
||||||
|
|
||||||
def save_frame(self, jpeg_bytes: bytes, directory: str = ".", prefix: str = "frame") -> Path:
|
def save_frame(self, jpeg_bytes: bytes, directory: str = ".", prefix: str = "frame") -> Path:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import logging
|
|||||||
import time
|
import time
|
||||||
import threading
|
import threading
|
||||||
import math
|
import math
|
||||||
|
from typing import Callable, Any
|
||||||
|
|
||||||
|
|
||||||
logging.basicConfig(level=logging.INFO)
|
logging.basicConfig(level=logging.INFO)
|
||||||
@@ -47,12 +48,11 @@ 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
|
|
||||||
|
|
||||||
# Position courante simulée
|
# Position courante simulée
|
||||||
self.x, self.y = 0.0, 0.0
|
self.x: float | None = None
|
||||||
|
self.y: float | None = None
|
||||||
|
|
||||||
# État interne de la machine simulée
|
# État interne de la machine simulée
|
||||||
self._machine_state = 'Idle' # Idle | Run | Alarm
|
self._machine_state = 'Idle' # Idle | Run | Alarm
|
||||||
@@ -150,7 +150,7 @@ class GRBLController:
|
|||||||
# Homing : retour à l'origine avec délai simulé
|
# Homing : retour à l'origine avec délai simulé
|
||||||
self._machine_state = 'Run'
|
self._machine_state = 'Run'
|
||||||
self._state(state='send', msg="SIMULATOR: homing...")
|
self._state(state='send', msg="SIMULATOR: homing...")
|
||||||
distance = math.hypot(self.x, self.y)
|
distance = math.hypot(self.x or 0.0, self.y or 0.0)
|
||||||
self._simulate_move_delay(distance, feed=3000)
|
self._simulate_move_delay(distance, feed=3000)
|
||||||
self.x, self.y = 0.0, 0.0
|
self.x, self.y = 0.0, 0.0
|
||||||
self._machine_state = 'Idle'
|
self._machine_state = 'Idle'
|
||||||
@@ -158,7 +158,9 @@ class GRBLController:
|
|||||||
|
|
||||||
# --- Extraction des coordonnées X, Y et du feed F ---
|
# --- Extraction des coordonnées X, Y et du feed F ---
|
||||||
tokens = cmd_upper.replace(',', ' ').split()
|
tokens = cmd_upper.replace(',', ' ').split()
|
||||||
new_x, new_y, feed = self.x, self.y, 1000.0
|
new_x: float = self.x or 0.0
|
||||||
|
new_y: float = self.y or 0.0
|
||||||
|
feed: float = 1000.0
|
||||||
|
|
||||||
for token in tokens:
|
for token in tokens:
|
||||||
if token.startswith('X'):
|
if token.startswith('X'):
|
||||||
@@ -186,8 +188,10 @@ class GRBLController:
|
|||||||
|
|
||||||
# --- Mouvement effectif (G0, G1, G53 G1, etc.) ---
|
# --- Mouvement effectif (G0, G1, G53 G1, etc.) ---
|
||||||
has_move = any(t in tokens for t in ('G0', 'G1', 'G53'))
|
has_move = any(t in tokens for t in ('G0', 'G1', 'G53'))
|
||||||
if has_move and (new_x != self.x or new_y != self.y):
|
cur_x = self.x or 0.0
|
||||||
distance = math.hypot(new_x - self.x, new_y - self.y)
|
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._machine_state = 'Run'
|
||||||
self._simulate_move_delay(distance, feed)
|
self._simulate_move_delay(distance, feed)
|
||||||
self.x = new_x
|
self.x = new_x
|
||||||
@@ -208,7 +212,9 @@ class GRBLController:
|
|||||||
|
|
||||||
def get_status(self):
|
def get_status(self):
|
||||||
'''Retourne un status GRBL simulé au format <State|MPos:x,y,z>.'''
|
'''Retourne un status GRBL simulé au format <State|MPos:x,y,z>.'''
|
||||||
status = f"<{self._machine_state}|MPos:{self.x:.3f},{self.y:.3f},0.000|FS:0,0>"
|
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}")
|
logger.debug(f"SIMULATOR::get_status → {status}")
|
||||||
return status
|
return status
|
||||||
|
|
||||||
@@ -257,7 +263,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.0) + dx, (y or 0.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
|
||||||
|
|||||||
@@ -282,7 +282,7 @@ class EthoVisionMetrics:
|
|||||||
self.total_distance_mm += dist_mm
|
self.total_distance_mm += dist_mm
|
||||||
|
|
||||||
# Vecteur de déplacement (pour calculs d'angle)
|
# Vecteur de déplacement (pour calculs d'angle)
|
||||||
if self._prev_cx_mm is not None:
|
if self._prev_cx_mm is not None and self._prev_cy_mm is not None:
|
||||||
move_dx = cx_mm - self._prev_cx_mm
|
move_dx = cx_mm - self._prev_cx_mm
|
||||||
move_dy = cy_mm - self._prev_cy_mm
|
move_dy = cy_mm - self._prev_cy_mm
|
||||||
else:
|
else:
|
||||||
@@ -570,6 +570,36 @@ class ExperimentParams:
|
|||||||
**BEHAVIOUR_DEFAULTS,
|
**BEHAVIOUR_DEFAULTS,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Attributs issus de REQUIRED
|
||||||
|
experiment: str
|
||||||
|
well: str
|
||||||
|
px_per_mm: float
|
||||||
|
fps: float
|
||||||
|
# Attributs issus de DEFAULTS
|
||||||
|
well_radius_mm: float
|
||||||
|
thresh_immobile: float
|
||||||
|
thresh_mobile: float
|
||||||
|
planarian_count: int
|
||||||
|
tube_axis: str
|
||||||
|
min_area_px: int
|
||||||
|
max_area_ratio: float
|
||||||
|
merge_kernel_size: int
|
||||||
|
min_contour_dist_px: int
|
||||||
|
# Attributs issus de BEHAVIOUR_DEFAULTS
|
||||||
|
thigmotaxis_wall_dist_mm: float
|
||||||
|
photo_mode: str
|
||||||
|
photo_strength: float
|
||||||
|
photo_x: float
|
||||||
|
photo_y: float
|
||||||
|
photo_flee_angle_deg: float
|
||||||
|
chemo_strength: float
|
||||||
|
chemo_x: float
|
||||||
|
chemo_y: float
|
||||||
|
chemo_radius_mm: float
|
||||||
|
chemo_approach_angle_deg: float
|
||||||
|
avoid_radius_mm: float
|
||||||
|
aggreg_radius_mm: float
|
||||||
|
|
||||||
def __init__(self, data: dict):
|
def __init__(self, data: dict):
|
||||||
missing = self.REQUIRED - set(data.keys())
|
missing = self.REQUIRED - set(data.keys())
|
||||||
if missing:
|
if missing:
|
||||||
|
|||||||
@@ -75,12 +75,12 @@ class PlanarianState:
|
|||||||
Args:
|
Args:
|
||||||
idx : index de l'individu (0-based)
|
idx : index de l'individu (0-based)
|
||||||
"""
|
"""
|
||||||
self.idx = idx
|
self.idx: int = idx
|
||||||
self.cx = None
|
self.cx: int | None = None
|
||||||
self.cy = None
|
self.cy: int | None = None
|
||||||
self.ts = None
|
self.ts: float | None = None
|
||||||
self.lost = 0 # compteur de frames sans détection
|
self.lost: int = 0 # compteur de frames sans détection
|
||||||
self.active = False # vrai si l'individu a été détecté au moins une fois
|
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):
|
def update(self, cx: int, cy: int, ts: float):
|
||||||
"""
|
"""
|
||||||
@@ -117,7 +117,7 @@ class PlanarianState:
|
|||||||
Returns:
|
Returns:
|
||||||
tuple (speed_px_s, axial_speed) ou (0.0, 0.0) si état vide
|
tuple (speed_px_s, axial_speed) ou (0.0, 0.0) si état vide
|
||||||
"""
|
"""
|
||||||
if self.cx is None or self.ts is None:
|
if self.cx is None or self.cy is None or self.ts is None:
|
||||||
return 0.0, 0.0
|
return 0.0, 0.0
|
||||||
|
|
||||||
dt = ts - self.ts
|
dt = ts - self.ts
|
||||||
@@ -445,7 +445,7 @@ class PlanarianTracker:
|
|||||||
row_ind, col_ind = linear_sum_assignment(cost)
|
row_ind, col_ind = linear_sum_assignment(cost)
|
||||||
|
|
||||||
# Construire le dict d'association
|
# Construire le dict d'association
|
||||||
assignment = {i: None for i in range(n_slots)}
|
assignment: dict[int, int | None] = {i: None for i in range(n_slots)}
|
||||||
|
|
||||||
assigned_dets = set()
|
assigned_dets = set()
|
||||||
for ri, ci in zip(row_ind, col_ind):
|
for ri, ci in zip(row_ind, col_ind):
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ class ReductStoreBase(ABC):
|
|||||||
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
|
||||||
|
|||||||
@@ -29,6 +29,17 @@ class TubeAligner:
|
|||||||
self.debug = debug
|
self.debug = debug
|
||||||
self.display = display
|
self.display = display
|
||||||
self.TUBE_DIAMETER_MM = 16.0
|
self.TUBE_DIAMETER_MM = 16.0
|
||||||
|
# Plage de recherche du rayon en fraction de min(w,h)
|
||||||
|
# Défaut : tube occupe ~30% du champ (camera).
|
||||||
|
# Mode vidéo : puit remplit le crop → ratio ~0.50 → appeler set_radius_range(0.35, 0.52)
|
||||||
|
self._min_radius_ratio = 0.26
|
||||||
|
self._max_radius_ratio = 0.37
|
||||||
|
self.draw_annotations = True # masquer l'overlay sans couper la détection
|
||||||
|
|
||||||
|
def set_radius_range(self, min_ratio: float, max_ratio: float) -> None:
|
||||||
|
"""Ajuste la plage de recherche HoughCircles. Appeler avant detect_tube()."""
|
||||||
|
self._min_radius_ratio = min_ratio
|
||||||
|
self._max_radius_ratio = max_ratio
|
||||||
|
|
||||||
|
|
||||||
def set_tube_diameter(self, tube_diameter: float = 16.0) -> None:
|
def set_tube_diameter(self, tube_diameter: float = 16.0) -> None:
|
||||||
@@ -64,14 +75,18 @@ class TubeAligner:
|
|||||||
frame_out = frame.copy()
|
frame_out = frame.copy()
|
||||||
|
|
||||||
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
|
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
|
||||||
|
# CLAHE pour renforcer le contraste local (paroi du puit sur fond gris uniforme)
|
||||||
|
clahe = cv2.createCLAHE(clipLimit=3.0, tileGridSize=(8, 8))
|
||||||
|
gray = clahe.apply(gray)
|
||||||
blurred = cv2.GaussianBlur(gray, (15, 15), 3)
|
blurred = cv2.GaussianBlur(gray, (15, 15), 3)
|
||||||
|
|
||||||
|
lo = self._min_radius_ratio
|
||||||
|
hi = self._max_radius_ratio
|
||||||
# 3 configurations légèrement différentes — vote majoritaire
|
# 3 configurations légèrement différentes — vote majoritaire
|
||||||
# Fonctionne sur fond sombre ET fond clair
|
|
||||||
configs = [
|
configs = [
|
||||||
dict(param1=50, param2=30, minRadius=int(min(w,h)*0.26), maxRadius=int(min(w,h)*0.36)),
|
dict(param1=50, param2=30, minRadius=int(min(w,h)*lo), maxRadius=int(min(w,h)*hi)),
|
||||||
dict(param1=60, param2=30, minRadius=int(min(w,h)*0.26), maxRadius=int(min(w,h)*0.37)),
|
dict(param1=60, param2=28, minRadius=int(min(w,h)*lo), maxRadius=int(min(w,h)*(hi+0.01))),
|
||||||
dict(param1=50, param2=28, minRadius=int(min(w,h)*0.25), maxRadius=int(min(w,h)*0.365)),
|
dict(param1=50, param2=26, minRadius=int(min(w,h)*(lo-0.01)), maxRadius=int(min(w,h)*(hi+0.005))),
|
||||||
]
|
]
|
||||||
|
|
||||||
all_cx, all_cy, all_r = [], [], []
|
all_cx, all_cy, all_r = [], [], []
|
||||||
@@ -93,7 +108,7 @@ class TubeAligner:
|
|||||||
if not all_cx:
|
if not all_cx:
|
||||||
msg = f"TubeAligner: aucun cercle détecté ({w}x{h})"
|
msg = f"TubeAligner: aucun cercle détecté ({w}x{h})"
|
||||||
result["msg"] =msg
|
result["msg"] =msg
|
||||||
if self.debug:
|
if self.debug and self.draw_annotations:
|
||||||
frame_out = self._draw_debug_no_detection(frame_out, cx_img, cy_img)
|
frame_out = self._draw_debug_no_detection(frame_out, cx_img, cy_img)
|
||||||
result["frame_annotated"] = frame_out
|
result["frame_annotated"] = frame_out
|
||||||
return result
|
return result
|
||||||
@@ -120,7 +135,7 @@ class TubeAligner:
|
|||||||
else:
|
else:
|
||||||
action = "grbl"
|
action = "grbl"
|
||||||
|
|
||||||
if self.debug:
|
if self.debug and self.draw_annotations:
|
||||||
frame_out = self._draw_debug(
|
frame_out = self._draw_debug(
|
||||||
frame_out, cx_img, cy_img,
|
frame_out, cx_img, cy_img,
|
||||||
tx, ty, tr,
|
tx, ty, tr,
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ def get_tmpfs_info(mount_point="/ramdisk"):
|
|||||||
return f"{n:.1f}PB"
|
return f"{n:.1f}PB"
|
||||||
|
|
||||||
usage = None
|
usage = None
|
||||||
|
part = None
|
||||||
for part in psutil.disk_partitions(all=True):
|
for part in psutil.disk_partitions(all=True):
|
||||||
if part.mountpoint == mount_point and part.fstype.lower() == "tmpfs":
|
if part.mountpoint == mount_point and part.fstype.lower() == "tmpfs":
|
||||||
usage = psutil.disk_usage(part.mountpoint)
|
usage = psutil.disk_usage(part.mountpoint)
|
||||||
@@ -81,6 +82,7 @@ def get_tmpfs_info(mount_point="/ramdisk"):
|
|||||||
print(f" Free: {usage.free} bytes ({sizeof(usage.free)})")
|
print(f" Free: {usage.free} bytes ({sizeof(usage.free)})")
|
||||||
print(f" Percent used: {usage.percent}%")
|
print(f" Percent used: {usage.percent}%")
|
||||||
break
|
break
|
||||||
|
if usage and part:
|
||||||
return {
|
return {
|
||||||
"percent": usage.percent,
|
"percent": usage.percent,
|
||||||
"mount": part.mountpoint,
|
"mount": part.mountpoint,
|
||||||
@@ -92,6 +94,7 @@ def get_tmpfs_info(mount_point="/ramdisk"):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def get_cpu_info():
|
def get_cpu_info():
|
||||||
# cpu percent par coeur et moyennes load
|
# cpu percent par coeur et moyennes load
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ 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,
|
||||||
@@ -50,7 +50,7 @@ class VideoFileCapture(VideoCaptureInterface):
|
|||||||
: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, jpeg_quality=jpeg_quality)
|
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
|
||||||
@@ -134,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
|
||||||
@@ -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')},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -33,7 +33,7 @@ def export_experiment_metrics_task(experiment_id):
|
|||||||
return
|
return
|
||||||
|
|
||||||
jobs = []
|
jobs = []
|
||||||
configs = (ExperimentConfig.objects.filter(experiment_key_id=experiment.id).order_by("well"))
|
configs = (ExperimentConfig.objects.filter(experiment_key_id=experiment.pk).order_by("well"))
|
||||||
for conf in configs:
|
for conf in configs:
|
||||||
well = conf.well
|
well = conf.well
|
||||||
count = conf.planarian_count
|
count = conf.planarian_count
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{% extends "scanner/base.html" %}
|
{% extends "scanner/base.html" %}
|
||||||
{% load i18n home_tags scanner_tags %}
|
{% load i18n home_tags %}
|
||||||
{% block styles %}
|
{% block styles %}
|
||||||
{{ block.super }}
|
{{ block.super }}
|
||||||
<link href="/static/planarian/css/planarian.css" rel="stylesheet">
|
<link href="/static/planarian/css/planarian.css" rel="stylesheet">
|
||||||
@@ -58,7 +58,7 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
function export_csv(url, pid, mode) {
|
function export_csv(url, pid, mode) {
|
||||||
fetch(url, {
|
csrfFetch(url, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ from django.contrib import messages
|
|||||||
from django.http import JsonResponse, FileResponse
|
from django.http import JsonResponse, FileResponse
|
||||||
from django.shortcuts import redirect
|
from django.shortcuts import redirect
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django.views.decorators.csrf import csrf_exempt
|
|
||||||
from django.shortcuts import render #, redirect
|
from django.shortcuts import render #, redirect
|
||||||
from django.views.decorators.http import require_GET
|
from django.views.decorators.http import require_GET
|
||||||
from django.contrib.auth.decorators import login_required
|
from django.contrib.auth.decorators import login_required
|
||||||
@@ -85,7 +84,6 @@ def get_active_session(request, session_id=None, experiment_id=None):
|
|||||||
# Vue :Export CSV depuis ReductStore
|
# Vue :Export CSV depuis ReductStore
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@login_required
|
@login_required
|
||||||
@csrf_exempt
|
|
||||||
def export_metrics(request):
|
def export_metrics(request):
|
||||||
data = json.loads(request.body.decode() or "{}")
|
data = json.loads(request.body.decode() or "{}")
|
||||||
action = data.get("action")
|
action = data.get("action")
|
||||||
@@ -93,7 +91,7 @@ def export_metrics(request):
|
|||||||
if action=='experiment_csv':
|
if action=='experiment_csv':
|
||||||
experiment = models.Experiment.objects.filter(pk=pid).first()
|
experiment = models.Experiment.objects.filter(pk=pid).first()
|
||||||
if experiment:
|
if experiment:
|
||||||
export_experiment_metrics_task.delay(experiment.id) # @UndefinedVariable
|
export_experiment_metrics_task.delay(experiment.pk) # @UndefinedVariable
|
||||||
return JsonResponse({"success": True, "msg": str(_("Métrics en cours de téléchargement"))})
|
return JsonResponse({"success": True, "msg": str(_("Métrics en cours de téléchargement"))})
|
||||||
if action=='session_csv':
|
if action=='session_csv':
|
||||||
if pid:
|
if pid:
|
||||||
@@ -118,7 +116,7 @@ def export_csv_view(request):
|
|||||||
experiment = session_context['current_experiment']
|
experiment = session_context['current_experiment']
|
||||||
if valid == 'ok' and session and experiment:
|
if valid == 'ok' and session and experiment:
|
||||||
well_name = request.POST.get('well')
|
well_name = request.POST.get('well')
|
||||||
uuid = models.get_uuid_from_session(session.id, experiment.multiwell.position, well_name)
|
uuid = models.get_uuid_from_session(session.pk, experiment.multiwell.position, well_name) # type: ignore[union-attr]
|
||||||
'''
|
'''
|
||||||
csv_content, filename = export_csv(request, uuid)
|
csv_content, filename = export_csv(request, uuid)
|
||||||
if csv_content:
|
if csv_content:
|
||||||
@@ -126,7 +124,7 @@ def export_csv_view(request):
|
|||||||
response["Content-Disposition"] = f'attachment; filename="{filename}"'
|
response["Content-Disposition"] = f'attachment; filename="{filename}"'
|
||||||
return response'''
|
return response'''
|
||||||
csv_content, n = export_csv_sync(
|
csv_content, n = export_csv_sync(
|
||||||
experiment=experiment.identifier,
|
experiment=experiment.identifier, # type: ignore[union-attr]
|
||||||
well=well_name,
|
well=well_name,
|
||||||
uuid=uuid,
|
uuid=uuid,
|
||||||
planarian=request.POST.get("planarian"),
|
planarian=request.POST.get("planarian"),
|
||||||
@@ -136,7 +134,7 @@ def export_csv_view(request):
|
|||||||
)
|
)
|
||||||
if csv_content:
|
if csv_content:
|
||||||
filename = (
|
filename = (
|
||||||
f"{experiment.identifier}_{well_name}-{request.POST.get('planarian')}_"
|
f"{experiment.identifier}_{well_name}-{request.POST.get('planarian')}_" # type: ignore[union-attr]
|
||||||
f"{request.POST.get('record_type')}.csv"
|
f"{request.POST.get('record_type')}.csv"
|
||||||
)
|
)
|
||||||
logger.info(f"Export CSV: {n} lignes, content size={len(csv_content)}")
|
logger.info(f"Export CSV: {n} lignes, content size={len(csv_content)}")
|
||||||
@@ -223,8 +221,8 @@ def import_csv_view(request):
|
|||||||
|
|
||||||
return import_csv(request, current_experiment, rows, overwrite)
|
return import_csv(request, current_experiment, rows, overwrite)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
messages.error(request, msg)
|
messages.error(request, e)
|
||||||
logger.error(msg)
|
logger.error(e)
|
||||||
ctx = { 'choice_title': _("Importer des configurations depuis un fichier CSV"), **session_context }
|
ctx = { 'choice_title': _("Importer des configurations depuis un fichier CSV"), **session_context }
|
||||||
return render(request, "planarian/import_params.html", context=global_context(request, **ctx))
|
return render(request, "planarian/import_params.html", context=global_context(request, **ctx))
|
||||||
|
|
||||||
@@ -266,6 +264,6 @@ class TrackingDataView(View):
|
|||||||
return JsonResponse({"count": len(records), "records": records})
|
return JsonResponse({"count": len(records), "records": records})
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
context = super().get_context_data(**kwargs)
|
context = super().get_context_data(**kwargs) # type: ignore[attr-defined]
|
||||||
return global_context(self.request, choice_title=str(_("Métriques de tracking d'un planaire")), **context)
|
return global_context(self.request, choice_title=str(_("Métriques de tracking d'un planaire")), **context)
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ try:
|
|||||||
from planarian_metrics import EthoVisionMetrics
|
from planarian_metrics import EthoVisionMetrics
|
||||||
HAS_METRICS = True
|
HAS_METRICS = True
|
||||||
except ImportError:
|
except ImportError:
|
||||||
|
EthoVisionMetrics = None # type: ignore[assignment]
|
||||||
HAS_METRICS = False
|
HAS_METRICS = False
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import math
|
import math
|
||||||
@@ -1007,8 +1008,8 @@ def draw_arena(frame, cfg, width, height, arena_center, arena_radius_px, mm_to_p
|
|||||||
cv2.circle(overlay, arena_center, arena_radius_px + r_off, (245, 243, 238), 3)
|
cv2.circle(overlay, arena_center, arena_radius_px + r_off, (245, 243, 238), 3)
|
||||||
cv2.addWeighted(overlay, alpha / 255.0, frame, 1 - alpha / 255.0, 0, frame)
|
cv2.addWeighted(overlay, alpha / 255.0, frame, 1 - alpha / 255.0, 0, frame)
|
||||||
|
|
||||||
cv2.circle(frame, arena_center, arena_radius_px, arena_border, 2)
|
cv2.circle(frame, arena_center, arena_radius_px, arena_border, 4)
|
||||||
cv2.circle(frame, arena_center, arena_radius_px + 4, (200, 198, 192), 1)
|
cv2.circle(frame, arena_center, arena_radius_px + 8, (200, 198, 192), 1)
|
||||||
|
|
||||||
bar_len = int(mm_to_px)
|
bar_len = int(mm_to_px)
|
||||||
bx, by = width - 40, height - 25
|
bx, by = width - 40, height - 25
|
||||||
@@ -1191,14 +1192,18 @@ def main():
|
|||||||
}
|
}
|
||||||
metrics_list = []
|
metrics_list = []
|
||||||
if HAS_METRICS:
|
if HAS_METRICS:
|
||||||
|
assert EthoVisionMetrics is not None
|
||||||
for _ in planaires:
|
for _ in planaires:
|
||||||
metrics_list.append(EthoVisionMetrics(
|
metric = EthoVisionMetrics(
|
||||||
px_per_mm = MM_TO_PX,
|
px_per_mm = MM_TO_PX,
|
||||||
fps = args.fps,
|
fps = args.fps,
|
||||||
thresh_immobile = args.thresh_immobile,
|
thresh_immobile = args.thresh_immobile,
|
||||||
thresh_mobile = args.thresh_mobile,
|
thresh_mobile = args.thresh_mobile,
|
||||||
behaviour = behaviour,
|
behaviour = behaviour,
|
||||||
))
|
)
|
||||||
|
if metric:
|
||||||
|
metrics_list.append(metric)
|
||||||
|
|
||||||
|
|
||||||
# --- Arène de base ---
|
# --- Arène de base ---
|
||||||
arena_base = np.zeros((HEIGHT, WIDTH, 3), dtype=np.uint8)
|
arena_base = np.zeros((HEIGHT, WIDTH, 3), dtype=np.uint8)
|
||||||
@@ -1208,7 +1213,7 @@ def main():
|
|||||||
output_path = args.output
|
output_path = args.output
|
||||||
if not output_path.endswith(".mp4"):
|
if not output_path.endswith(".mp4"):
|
||||||
output_path += ".mp4"
|
output_path += ".mp4"
|
||||||
fourcc = cv2.VideoWriter_fourcc(*'mp4v')
|
fourcc = cv2.VideoWriter_fourcc(*'mp4v') # type: ignore[attr-defined]
|
||||||
out = cv2.VideoWriter(output_path, fourcc, args.fps, (WIDTH, HEIGHT))
|
out = cv2.VideoWriter(output_path, fourcc, args.fps, (WIDTH, HEIGHT))
|
||||||
|
|
||||||
print(f"Simulation : {args.count} planaire(s), {TOTAL_FRAMES} frames ({args.duration}s à {args.fps} fps)")
|
print(f"Simulation : {args.count} planaire(s), {TOTAL_FRAMES} frames ({args.duration}s à {args.fps} fps)")
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
from pathlib import Path
|
||||||
from django.utils.translation import gettext_lazy as _
|
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
|
||||||
@@ -43,14 +45,14 @@ class ConfigurationAdmin(admin.ModelAdmin):
|
|||||||
|
|
||||||
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 = (
|
fieldsets = (
|
||||||
(_("Identification"), {
|
(_("Identification"), {
|
||||||
"fields": ("label", "author", "position", "default", "active"),
|
"fields": ("label", "author", "position", "default", "capture_video", "active"),
|
||||||
}),
|
}),
|
||||||
(_("Géométrie"), {
|
(_("Géométrie"), {
|
||||||
"fields": ("cols", "rows", "diameter", "row_def", "row_order"),"classes": ("collapse",),
|
"fields": ("cols", "rows", "diameter", "crop_radius", "row_def", "row_order"),"classes": ("collapse",),
|
||||||
}),
|
}),
|
||||||
(_("Déplacement"), {
|
(_("Déplacement"), {
|
||||||
"fields": ("order", "duration", "xbase", "ybase", "dx", "dy", "feed"),"classes": ("collapse",),
|
"fields": ("order", "duration", "xbase", "ybase", "dx", "dy", "feed"),"classes": ("collapse",),
|
||||||
@@ -113,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,7 +19,7 @@ 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
|
scan_simulation: bool = False
|
||||||
@@ -52,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)
|
||||||
|
|
||||||
|
|
||||||
@@ -34,19 +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):
|
||||||
await remove_video_by_uuid(uuid, start_ts, end_ts, when=when)
|
await remove_video_by_uuid(uuid, start_ts, end_ts, when=when)
|
||||||
|
|
||||||
async def shm_download_video(uuid, start_ts, end_ts, frame_rate=5, opencv_fourcc_format='mp4v', opencv_video_type='mp4'):
|
async def shm_download_video(uuid, start_ts, end_ts, frame_rate=5, opencv_fourcc_format='mp4v', opencv_video_type='mp4'):
|
||||||
|
video_path = os.path.join(settings.MEDIA_ROOT, f"output.{opencv_video_type}")
|
||||||
try:
|
try:
|
||||||
record_manager = CameraRecordManager(cameraDB)
|
record_manager = CameraRecordManager(cameraDB)
|
||||||
total_size = await record_manager.size(uuid, start_ts, end_ts)
|
total_size = await record_manager.size(uuid, start_ts, end_ts)
|
||||||
|
|
||||||
# segment de mémoire partagée pour stocker les frames
|
# segment de mémoire partagée pour stocker les frames
|
||||||
shm_size = int(total_size * 1.5)
|
shm_size = int((total_size or 0) * 1.5)
|
||||||
shm_name = f"/video_frames_{uuid}"
|
shm_name = f"/video_frames_{uuid}"
|
||||||
try:
|
try:
|
||||||
shm = posix_ipc.SharedMemory(shm_name, posix_ipc.O_CREAT | posix_ipc.O_EXCL, size=shm_size)
|
shm = posix_ipc.SharedMemory(shm_name, posix_ipc.O_CREAT | posix_ipc.O_EXCL, size=shm_size)
|
||||||
@@ -77,12 +78,13 @@ async def shm_download_video(uuid, start_ts, end_ts, frame_rate=5, opencv_fourcc
|
|||||||
raise Exception("No frame found!")
|
raise Exception("No frame found!")
|
||||||
#return JsonResponse({'error': 'Aucune frame trouvée'}, status=404)
|
#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)
|
||||||
@@ -95,6 +97,7 @@ async def shm_download_video(uuid, start_ts, end_ts, frame_rate=5, opencv_fourcc
|
|||||||
|
|
||||||
progress_bar(i + 1, total, prefix=f'Progress {uuid}:', suffix='Ended', 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
|
||||||
@@ -183,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(
|
||||||
@@ -340,7 +343,7 @@ async def export_video_mp4(
|
|||||||
# --- Chargement des frames en mémoire partagée ---
|
# --- Chargement des frames en mémoire partagée ---
|
||||||
record_manager = CameraRecordManager(cameraDB)
|
record_manager = CameraRecordManager(cameraDB)
|
||||||
total_size = await record_manager.size(uuid, start_ts, end_ts)
|
total_size = await record_manager.size(uuid, start_ts, end_ts)
|
||||||
shm_size = int(total_size * 1.5)
|
shm_size = int((total_size or 0) * 1.5)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
shm = posix_ipc.SharedMemory(
|
shm = posix_ipc.SharedMemory(
|
||||||
@@ -390,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
|
||||||
|
|||||||
@@ -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,6 +4,7 @@
|
|||||||
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
|
||||||
@@ -13,10 +14,26 @@ 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")),
|
||||||
|
('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")),
|
||||||
|
('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 = [
|
||||||
@@ -32,7 +49,8 @@ VIDEO_TYPE = [
|
|||||||
CAPTURE_TYPE = [
|
CAPTURE_TYPE = [
|
||||||
('rpi', _("Arducam")),
|
('rpi', _("Arducam")),
|
||||||
('webcam', _("Webcam")),
|
('webcam', _("Webcam")),
|
||||||
('file', _("Fichier vidéo (mp4, avi)")),
|
('file', _("Simulation Fichier vidéo (mp4, avi)")),
|
||||||
|
('video', _("Fichier vidéo (mp4, avi)")),
|
||||||
]
|
]
|
||||||
|
|
||||||
TUBE_AXIS_TYPE = [
|
TUBE_AXIS_TYPE = [
|
||||||
@@ -81,7 +99,6 @@ class Configuration(models.Model):
|
|||||||
#
|
#
|
||||||
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()
|
||||||
@@ -103,7 +120,6 @@ class Well(models.Model):
|
|||||||
verbose_name = _("Puit")
|
verbose_name = _("Puit")
|
||||||
verbose_name_plural = _("Puits")
|
verbose_name_plural = _("Puits")
|
||||||
|
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f'{self.name}'
|
return f'{self.name}'
|
||||||
|
|
||||||
@@ -113,13 +129,15 @@ class MultiWell(models.Model):
|
|||||||
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.SET_NULL, 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
|
# 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
|
# 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 de capture en secondes pour la calibration'), blank=False, default=10)
|
duration = models.PositiveIntegerField(_("Durée"), help_text=_('Durée de capture en secondes pour la calibration'), blank=False, default=10)
|
||||||
@@ -131,9 +149,9 @@ class MultiWell(models.Model):
|
|||||||
feed = models.PositiveIntegerField(_("Vitesse"), help_text=_('Vitesse déplacement en mm/mn '), blank=False, default=1000)
|
feed = models.PositiveIntegerField(_("Vitesse"), help_text=_('Vitesse déplacement en mm/mn '), blank=False, default=1000)
|
||||||
|
|
||||||
well_position = models.BooleanField(_("Positions"), help_text=_('Positions des puits générées ?. Non => efface WellPosition et recalcule les positions'), default=False)
|
well_position = models.BooleanField(_("Positions"), help_text=_('Positions des puits générées ?. Non => efface WellPosition et recalcule les positions'), default=False)
|
||||||
|
capture_video = models.BooleanField(_("Vidéo"), help_text=_('Ce multi-puit servira pour la capture vidéo'), default=False)
|
||||||
active = models.BooleanField(_("Active"), default=True)
|
active = models.BooleanField(_("Active"), default=True)
|
||||||
|
|
||||||
|
|
||||||
def config(self):
|
def config(self):
|
||||||
return dict(
|
return dict(
|
||||||
position=self.position,
|
position=self.position,
|
||||||
@@ -166,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")
|
||||||
|
|
||||||
@@ -206,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
|
||||||
@@ -302,7 +320,7 @@ class Session(models.Model):
|
|||||||
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_session(self, sid):
|
def get_session(cls, sid):
|
||||||
return Session.objects.filter(pk=sid).first()
|
return Session.objects.filter(pk=sid).first()
|
||||||
|
|
||||||
|
|
||||||
@@ -313,7 +331,7 @@ class Session(models.Model):
|
|||||||
|
|
||||||
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.id} ] {self.name} ({state})'
|
return f'[ {self.pk} ] {self.name} ({state})'
|
||||||
|
|
||||||
|
|
||||||
@receiver(post_save, sender=Session)
|
@receiver(post_save, sender=Session)
|
||||||
@@ -444,6 +462,106 @@ class ExperimentWell(models.Model):
|
|||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f'{self.experiment.title}'
|
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)
|
@receiver(post_save, sender=Experiment)
|
||||||
def create_experiment_well(sender, instance, created, **kwargs):
|
def create_experiment_well(sender, instance, created, **kwargs):
|
||||||
from planarian.models import ExperimentConfig
|
from planarian.models import ExperimentConfig
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ 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 planarian.models import ExperimentConfig
|
||||||
from . import models
|
from . import models
|
||||||
@@ -115,24 +115,28 @@ 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):
|
||||||
def set_multiwell(self, position=None):
|
wells = models.WellPosition.objects.filter(multiwell_id=self.multiwell.pk).order_by('order').all() # type: ignore[union-attr]
|
||||||
if position is None:
|
|
||||||
self.multiwell = models.MultiWell.objects.filter(default=True).first()
|
|
||||||
else:
|
|
||||||
self.multiwell = models.MultiWell.by_position(position)
|
|
||||||
|
|
||||||
wells = models.WellPosition.objects.filter(multiwell_id=self.multiwell.id).order_by('order').all()
|
|
||||||
self.well_iterator = WellIterator(wells)
|
self.well_iterator = WellIterator(wells)
|
||||||
|
|
||||||
self.position = self.multiwell.position
|
self.position = self.multiwell.position
|
||||||
self._xbase = self.multiwell.xbase
|
self._xbase = self.multiwell.xbase
|
||||||
self._ybase = self.multiwell.ybase
|
self._ybase = self.multiwell.ybase
|
||||||
self._dx = self.multiwell.dx
|
self._dx = self.multiwell.dx
|
||||||
self._dy = self.multiwell.dy
|
self._dy = self.multiwell.dy
|
||||||
|
|
||||||
|
def set_multiwell(self, position=None):
|
||||||
|
if position is None:
|
||||||
|
self.multiwell = models.MultiWell.objects.filter(default=True).first()
|
||||||
|
else:
|
||||||
|
self.multiwell = models.MultiWell.by_position(position)
|
||||||
|
self.init_manager_values()
|
||||||
return self.multiwell.config()
|
return self.multiwell.config()
|
||||||
|
|
||||||
|
def set_first_multiwell_from_session(self, sid):
|
||||||
|
experiments = models.SessionExperiment.experiment_by_session(sid)
|
||||||
|
if experiments:
|
||||||
|
self.multiwell = experiments[0].multiwell
|
||||||
|
self.init_manager_values()
|
||||||
|
|
||||||
def multiwell_buttons(self, btn_class="w3-button", onclick=''' onclick="goto_well(this)"'''):
|
def multiwell_buttons(self, btn_class="w3-button", onclick=''' onclick="goto_well(this)"'''):
|
||||||
multiwells = []
|
multiwells = []
|
||||||
@@ -143,14 +147,24 @@ class MultiWellManager:
|
|||||||
self.well_iterator.reset()
|
self.well_iterator.reset()
|
||||||
return mark_safe("\n".join(multiwells))
|
return mark_safe("\n".join(multiwells))
|
||||||
|
|
||||||
|
def set_circular_crop(self, crop_radius):
|
||||||
|
crop = self.process.set_crop_radius(crop_radius)
|
||||||
|
self.process.cam.set_circular_crop(crop)
|
||||||
|
|
||||||
|
def update_crop_radius(self, value):
|
||||||
|
self.multiwell.crop_radius = value
|
||||||
|
self.multiwell.save()
|
||||||
|
|
||||||
#def _grid_scanning_capture(self, uuid, duration):
|
|
||||||
def _grid_scanning_capture(self, experiment, well_position, simulate=False):
|
def _grid_scanning_capture(self, experiment, well_position, simulate=False):
|
||||||
uuid = None
|
uuid = None
|
||||||
try:
|
try:
|
||||||
well = well_position.well
|
well = well_position.well
|
||||||
multiwell = experiment.multiwell
|
multiwell = experiment.multiwell
|
||||||
|
|
||||||
|
# En mode video le crop_radius est piloté par _capture_video_simulation
|
||||||
|
if self.process.conf.capture_type != 'video':
|
||||||
|
self.set_circular_crop(multiwell.crop_radius)
|
||||||
|
|
||||||
## create uuid for this capture
|
## create uuid for this capture
|
||||||
uuid = f'{self.process.data.session}-{multiwell.position}-{well.name}'
|
uuid = f'{self.process.data.session}-{multiwell.position}-{well.name}'
|
||||||
|
|
||||||
@@ -201,6 +215,15 @@ class MultiWellManager:
|
|||||||
self.process.cam._error_occured = True
|
self.process.cam._error_occured = True
|
||||||
logger.info(f"Simulating capture with file {vf}")
|
logger.info(f"Simulating capture with file {vf}")
|
||||||
|
|
||||||
|
def _capture_video_simulation(self, well_position):
|
||||||
|
"""Met à jour VideoPlateCapture : crop_radius depuis MultiWell.crop_radius."""
|
||||||
|
cam = self.process.cam
|
||||||
|
r = well_position.multiwell.crop_radius
|
||||||
|
if hasattr(cam, 'set_crop_radius_px'):
|
||||||
|
cam.set_crop_radius_px(r)
|
||||||
|
self.set_circular_crop(r)
|
||||||
|
logger.info(f"video_simulation: {well_position.well.name} crop_r={r}px")
|
||||||
|
|
||||||
|
|
||||||
def _is_well_valid(self, welposition, experiment):
|
def _is_well_valid(self, welposition, experiment):
|
||||||
names = models.ExperimentWell.wellname_by_experiment(experiment.id)
|
names = models.ExperimentWell.wellname_by_experiment(experiment.id)
|
||||||
@@ -225,6 +248,8 @@ class MultiWellManager:
|
|||||||
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':
|
if self.process.conf.capture_type == 'file':
|
||||||
self._capture_file_simulation(wl.well.name)
|
self._capture_file_simulation(wl.well.name)
|
||||||
|
elif self.process.conf.capture_type == 'video':
|
||||||
|
self._capture_video_simulation(wl)
|
||||||
|
|
||||||
self._grid_scanning_capture(experiment, wl, simulate=simulate)
|
self._grid_scanning_capture(experiment, wl, simulate=simulate)
|
||||||
msg =f"Scan terminé — retour à l'origine (X={xnext:.1f} Y={ynext:.1f})"
|
msg =f"Scan terminé — retour à l'origine (X={xnext:.1f} Y={ynext:.1f})"
|
||||||
@@ -237,7 +262,7 @@ class MultiWellManager:
|
|||||||
self.process._send(state='error', msg=msg)
|
self.process._send(state='error', msg=msg)
|
||||||
return False
|
return False
|
||||||
finally:
|
finally:
|
||||||
self.cnc_controller.move_to(xnext, ynext, feed=multiwell.feed*2)
|
self.cnc_controller.move_to(xnext, ynext, feed=self.feed*2)
|
||||||
|
|
||||||
|
|
||||||
def _start_scanning(self, session, experiments, simulate=False):
|
def _start_scanning(self, session, experiments, simulate=False):
|
||||||
@@ -315,6 +340,8 @@ class MultiWellManager:
|
|||||||
wl = self.well_iterator.previous()
|
wl = self.well_iterator.previous()
|
||||||
if self.process.conf.capture_type == 'file':
|
if self.process.conf.capture_type == 'file':
|
||||||
self._capture_file_simulation(wl.well.name)
|
self._capture_file_simulation(wl.well.name)
|
||||||
|
elif self.process.conf.capture_type == 'video':
|
||||||
|
self._capture_video_simulation(wl)
|
||||||
self.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.well.name}: ({wl.x}, {wl.y})"}
|
return {"state": "previous", "msg": f">>> {wl.well.name}: ({wl.x}, {wl.y})"}
|
||||||
|
|
||||||
@@ -323,7 +350,8 @@ class MultiWellManager:
|
|||||||
wl = self.well_iterator.next()
|
wl = self.well_iterator.next()
|
||||||
if self.process.conf.capture_type == 'file':
|
if self.process.conf.capture_type == 'file':
|
||||||
self._capture_file_simulation(wl.well.name)
|
self._capture_file_simulation(wl.well.name)
|
||||||
|
elif self.process.conf.capture_type == 'video':
|
||||||
|
self._capture_video_simulation(wl)
|
||||||
self.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.well.name}: ({wl.x}, {wl.y})"}
|
return {"state": "next", "msg": f">>> {wl.well.name}: ({wl.x}, {wl.y})"}
|
||||||
|
|
||||||
@@ -332,21 +360,26 @@ class MultiWellManager:
|
|||||||
wl = self.well_iterator.seek(numwell)
|
wl = self.well_iterator.seek(numwell)
|
||||||
if self.process.conf.capture_type == 'file':
|
if self.process.conf.capture_type == 'file':
|
||||||
self._capture_file_simulation(wl.well.name)
|
self._capture_file_simulation(wl.well.name)
|
||||||
|
elif self.process.conf.capture_type == 'video':
|
||||||
|
self._capture_video_simulation(wl)
|
||||||
self.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.well.name}: ({wl.x}, {wl.y})"}
|
return {"state": "goto", "msg": f">>> {wl.well.name}: ({wl.x}, {wl.y})"}
|
||||||
|
|
||||||
|
|
||||||
def goto_xy(self):
|
def goto_xy(self):
|
||||||
wl = self.well_iterator.seek(0)
|
wl = self.well_iterator.seek(0)
|
||||||
if self.process.conf.capture_type == 'file':
|
if self.process.conf.capture_type == 'file':
|
||||||
self._capture_file_simulation(wl.well.name)
|
self._capture_file_simulation(wl.well.name)
|
||||||
|
elif self.process.conf.capture_type == 'video':
|
||||||
|
self._capture_video_simulation(wl)
|
||||||
self.cnc_controller.move_to(self.xbase, self.ybase, feed=self.feed)
|
self.cnc_controller.move_to(self.xbase, self.ybase, feed=self.feed)
|
||||||
|
|
||||||
def goto_0(self):
|
def goto_0(self):
|
||||||
self.well_iterator.reset()
|
self.well_iterator.reset()
|
||||||
if self.process.conf.capture_type == 'file':
|
if self.process.conf.capture_type == 'file':
|
||||||
self._capture_file_simulation('zero')
|
self._capture_file_simulation('zero')
|
||||||
|
elif self.process.conf.capture_type == 'video':
|
||||||
|
# Plein cadre : désactiver le masque circulaire pour ne pas rogner la vue d'ensemble
|
||||||
|
self.process.cam.set_circular_crop(None)
|
||||||
self.cnc_controller.move_to(0, 0, feed=self.feed*2)
|
self.cnc_controller.move_to(0, 0, feed=self.feed*2)
|
||||||
|
|
||||||
def set_well_position(self):
|
def set_well_position(self):
|
||||||
@@ -365,17 +398,18 @@ class MultiWellManager:
|
|||||||
cam = self.process.cam
|
cam = self.process.cam
|
||||||
cam._aligner.set_tube_diameter(self.multiwell.diameter)
|
cam._aligner.set_tube_diameter(self.multiwell.diameter)
|
||||||
duration = self.duration if not auto else settings.CALIBRATION_AUTO_DURATION
|
duration = self.duration if not auto else settings.CALIBRATION_AUTO_DURATION
|
||||||
try:
|
|
||||||
start_test = time.monotonic()
|
start_test = time.monotonic()
|
||||||
|
try:
|
||||||
for wl in self.well_iterator:
|
for wl in self.well_iterator:
|
||||||
if self.stop_playing.is_set():
|
if self.stop_playing.is_set():
|
||||||
break
|
break
|
||||||
|
|
||||||
self.cnc_controller.wait_for(2.0)
|
self.cnc_controller.wait_for(2.0)
|
||||||
self.cnc_controller.move_to(wl.x, wl.y, feed=wl.multiwell.feed)
|
self.cnc_controller.move_to(wl.x, wl.y, feed=wl.multiwell.feed)
|
||||||
## change file
|
|
||||||
if self.process.conf.capture_type == 'file':
|
if self.process.conf.capture_type == 'file':
|
||||||
self._capture_file_simulation(wl.well.name)
|
self._capture_file_simulation(wl.well.name)
|
||||||
|
elif self.process.conf.capture_type == 'video':
|
||||||
|
self._capture_video_simulation(wl)
|
||||||
self.process._send(current=wl.order)
|
self.process._send(current=wl.order)
|
||||||
|
|
||||||
start = time.monotonic()
|
start = time.monotonic()
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ from modules import reductstore, utils, planarian_metrics
|
|||||||
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 .models import MultiWell
|
||||||
|
|
||||||
# CNC
|
# CNC
|
||||||
if not settings.GRBL_SIMULATION:
|
if not settings.GRBL_SIMULATION:
|
||||||
@@ -38,7 +39,7 @@ else:
|
|||||||
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
|
||||||
|
|
||||||
@@ -173,7 +174,8 @@ class ScannerProcess(Task):
|
|||||||
Constants:
|
Constants:
|
||||||
reset si besoin les constantes vidéo
|
reset si besoin les constantes vidéo
|
||||||
'''
|
'''
|
||||||
self.conf = ScannerConstants().get()
|
self.constants = ScannerConstants()
|
||||||
|
self.conf = self.constants.get()
|
||||||
self.use_tracking = self.conf.tracking
|
self.use_tracking = self.conf.tracking
|
||||||
|
|
||||||
self.video_quality = self.conf.video_jpeg_quality
|
self.video_quality = self.conf.video_jpeg_quality
|
||||||
@@ -181,19 +183,22 @@ class ScannerProcess(Task):
|
|||||||
self.video_fps = self.conf.video_frame_rate
|
self.video_fps = self.conf.video_frame_rate
|
||||||
self.video_width = self.conf.video_width_capture
|
self.video_width = self.conf.video_width_capture
|
||||||
self.video_height = self.conf.video_height_capture
|
self.video_height = self.conf.video_height_capture
|
||||||
|
|
||||||
self.crop_radius = self.conf.calibration_crop_radius
|
self.crop_radius = self.conf.calibration_crop_radius
|
||||||
|
|
||||||
self.video_jpg_quality = [int(cv2.IMWRITE_JPEG_QUALITY), self.video_quality]
|
self.video_jpg_quality = [int(cv2.IMWRITE_JPEG_QUALITY), self.video_quality]
|
||||||
self.image_jpg_quality = [int(cv2.IMWRITE_JPEG_QUALITY), self.image_quality]
|
self.image_jpg_quality = [int(cv2.IMWRITE_JPEG_QUALITY), self.image_quality]
|
||||||
return self.conf
|
return self.conf
|
||||||
|
|
||||||
|
def save_config(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def start(self, *args, **kwargs):
|
def start(self, *args, **kwargs):
|
||||||
try:
|
try:
|
||||||
self.get_config()
|
self.get_config()
|
||||||
self.grbl_xmax = self.conf.grbl_xmax
|
self.grbl_xmax = self.conf.grbl_xmax
|
||||||
self.grbl_ymax = self.conf.grbl_ymax
|
self.grbl_ymax = self.conf.grbl_ymax
|
||||||
|
|
||||||
self.crop = self.set_crop_radius(self.crop_radius)
|
self.crop = self.set_crop_radius(self.crop_radius)
|
||||||
|
|
||||||
capture_type = self.conf.capture_type
|
capture_type = self.conf.capture_type
|
||||||
@@ -209,6 +214,29 @@ class ScannerProcess(Task):
|
|||||||
display=self._display,
|
display=self._display,
|
||||||
parent=self,
|
parent=self,
|
||||||
)
|
)
|
||||||
|
elif capture_type == 'video':
|
||||||
|
from modules.videoplate_capture import VideoPlateCapture
|
||||||
|
from .models import VideoPlate
|
||||||
|
|
||||||
|
vp = VideoPlate.active_video()
|
||||||
|
if not vp:
|
||||||
|
raise Exception("Aucun VideoPlate actif trouvé — créer un enregistrement dans l'admin.")
|
||||||
|
initial_path = vp.video_file.path if vp.video_file else None
|
||||||
|
mw = vp.multiwell
|
||||||
|
|
||||||
|
self.cam = VideoPlateCapture(
|
||||||
|
video_dir=settings.MEDIA_ROOT / 'videos',
|
||||||
|
fps=self.video_fps,
|
||||||
|
jpeg_quality=self.video_quality,
|
||||||
|
use_tracking=self.use_tracking,
|
||||||
|
display=self._display,
|
||||||
|
parent=self,
|
||||||
|
crop_radius_px=mw.crop_radius,
|
||||||
|
px_per_mm=vp.px_per_mm,
|
||||||
|
x_offset_mm=vp.x_origin_mm,
|
||||||
|
y_offset_mm=vp.y_origin_mm,
|
||||||
|
initial_video_path=initial_path,
|
||||||
|
)
|
||||||
elif capture_type == 'webcam':
|
elif capture_type == 'webcam':
|
||||||
from modules.webcam_capture import WebcamCapture
|
from modules.webcam_capture import WebcamCapture
|
||||||
self.cam = WebcamCapture(
|
self.cam = WebcamCapture(
|
||||||
@@ -237,7 +265,12 @@ class ScannerProcess(Task):
|
|||||||
self.cam._active_median = False
|
self.cam._active_median = False
|
||||||
self.cam.set_circular_crop(None)
|
self.cam.set_circular_crop(None)
|
||||||
|
|
||||||
self.grbl = GRBLController(
|
# Mode vidéo : toujours simuler le GRBL (pas de CNC physique)
|
||||||
|
if capture_type == 'video':
|
||||||
|
from modules.grbl_simulator import GRBLController as _GRBLCtrl
|
||||||
|
else:
|
||||||
|
_GRBLCtrl = GRBLController
|
||||||
|
self.grbl = _GRBLCtrl(
|
||||||
send_callback=self._display,
|
send_callback=self._display,
|
||||||
x_max=self.conf.grbl_xmax,
|
x_max=self.conf.grbl_xmax,
|
||||||
y_max=self.conf.grbl_ymax
|
y_max=self.conf.grbl_ymax
|
||||||
@@ -354,11 +387,10 @@ class ScannerProcess(Task):
|
|||||||
|
|
||||||
|
|
||||||
def _listen_to_redis(self):
|
def _listen_to_redis(self):
|
||||||
try:
|
|
||||||
logger.info(f"==== Scanner {self.group}: listen via redisDB")
|
logger.info(f"==== Scanner {self.group}: listen via redisDB")
|
||||||
pubsub = redisDB.pubsub()
|
pubsub = redisDB.pubsub()
|
||||||
pubsub.subscribe(self.group)
|
pubsub.subscribe(self.group)
|
||||||
|
try:
|
||||||
for message in pubsub.listen():
|
for message in pubsub.listen():
|
||||||
try:
|
try:
|
||||||
#logger.info(f"{message}")
|
#logger.info(f"{message}")
|
||||||
@@ -372,15 +404,32 @@ class ScannerProcess(Task):
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
self._send(state=cmd["type"], msg=f"Cmd: {cmd.get('topic')} {cmd.get('value', '')}")
|
self._send(state=cmd["type"], msg=f"Cmd: {cmd.get('topic')} {cmd.get('value', '')}")
|
||||||
|
ctx = {}
|
||||||
if cmd["type"]=="scanner":
|
if cmd["type"]=="scanner":
|
||||||
topic = cmd.get("topic")
|
topic = cmd.get("topic")
|
||||||
if topic == 'init':
|
if topic == 'init':
|
||||||
self.cam.set_circular_crop(self.crop)
|
sid = cmd.get("sid")
|
||||||
|
self.manager.set_first_multiwell_from_session(sid)
|
||||||
self.cam._active_median = False
|
self.cam._active_median = False
|
||||||
|
self.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)
|
self.cam.set_draw_contours(False)
|
||||||
buttons = self.manager.multiwell_buttons(btn_class="w3-btn well", onclick="")
|
|
||||||
self._send(buttons=buttons)
|
elif topic == 'video_plate':
|
||||||
|
new_path = cmd.get('path')
|
||||||
|
if new_path and hasattr(self.cam, 'set_video_file'):
|
||||||
|
self.cam.set_video_file(new_path)
|
||||||
|
self._send(state='video_plate', msg=f"Vidéo: {new_path}")
|
||||||
|
continue
|
||||||
|
|
||||||
elif topic == 'scan' or topic == 'simulate':
|
elif topic == 'scan' or topic == 'simulate':
|
||||||
logger.info(f"==== Scan {cmd}")
|
logger.info(f"==== Scan {cmd}")
|
||||||
@@ -391,6 +440,7 @@ class ScannerProcess(Task):
|
|||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
self.cam._active_median = False
|
self.cam._active_median = False
|
||||||
|
self.cam.set_edge_enhance(False)
|
||||||
simulate = (topic=='simulate')
|
simulate = (topic=='simulate')
|
||||||
self.manager.scan_process(sid, simulate)
|
self.manager.scan_process(sid, simulate)
|
||||||
|
|
||||||
@@ -401,8 +451,10 @@ class ScannerProcess(Task):
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
self._send(
|
self._send(
|
||||||
buttons=buttons,
|
buttons=self.manager.multiwell_buttons(btn_class="w3-btn well", onclick=""),
|
||||||
|
columns=self.manager.multiwell.cols,
|
||||||
current=self.manager.get_well_order(),
|
current=self.manager.get_well_order(),
|
||||||
|
**ctx
|
||||||
)
|
)
|
||||||
elif cmd["type"]=="calibrate":
|
elif cmd["type"]=="calibrate":
|
||||||
topic = cmd.get("topic")
|
topic = cmd.get("topic")
|
||||||
@@ -418,6 +470,7 @@ class ScannerProcess(Task):
|
|||||||
self.manager.set_multiwell(position)
|
self.manager.set_multiwell(position)
|
||||||
self.cam.set_circular_crop(None)
|
self.cam.set_circular_crop(None)
|
||||||
self.cam._active_median = False
|
self.cam._active_median = False
|
||||||
|
self.cam.set_edge_enhance(False)
|
||||||
self.cam.set_draw_contours(False)
|
self.cam.set_draw_contours(False)
|
||||||
buttons = self.manager.multiwell_buttons()
|
buttons = self.manager.multiwell_buttons()
|
||||||
|
|
||||||
@@ -438,17 +491,31 @@ class ScannerProcess(Task):
|
|||||||
self._send(state="median", value=self.cam._active_median, msg=f"Median: {self.cam._active_median}")
|
self._send(state="median", value=self.cam._active_median, msg=f"Median: {self.cam._active_median}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
elif topic == 'edge_enhance':
|
||||||
|
self.cam.set_edge_enhance(not self.cam._active_edge_enhance)
|
||||||
|
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}")
|
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':
|
||||||
@@ -480,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
|
||||||
|
|
||||||
@@ -495,10 +568,21 @@ class ScannerProcess(Task):
|
|||||||
self.manager.halt_scanning()
|
self.manager.halt_scanning()
|
||||||
|
|
||||||
elif topic == 'calib_debug':
|
elif topic == 'calib_debug':
|
||||||
|
if self.conf.capture_type == 'video':
|
||||||
|
self.cam._aligner.set_radius_range(0.38, 0.47)
|
||||||
|
else:
|
||||||
|
self.cam._aligner.set_radius_range(0.26, 0.37)
|
||||||
msg = self.manager.calib_toggle_debug()
|
msg = self.manager.calib_toggle_debug()
|
||||||
self._send(**msg)
|
self._send(**msg)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
elif topic == 'draw_debug':
|
||||||
|
a = self.cam._aligner
|
||||||
|
a.draw_annotations = not a.draw_annotations
|
||||||
|
self._send(state='draw_debug', value=a.draw_annotations,
|
||||||
|
msg=f"Draw debug: {a.draw_annotations}")
|
||||||
|
continue
|
||||||
|
|
||||||
elif topic == 'previous':
|
elif topic == 'previous':
|
||||||
msg = self.manager.previous_well()
|
msg = self.manager.previous_well()
|
||||||
self._send(**msg)
|
self._send(**msg)
|
||||||
@@ -536,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:
|
||||||
@@ -697,12 +782,11 @@ class ReplayProcess(Task):
|
|||||||
logger.info(f"==== ReplayProcess stopped.")
|
logger.info(f"==== ReplayProcess stopped.")
|
||||||
|
|
||||||
def _listen_to_redis(self):
|
def _listen_to_redis(self):
|
||||||
try:
|
|
||||||
loop = None
|
loop = None
|
||||||
logger.info(f"==== ReplayProcess {self.group}: listen via redisDB")
|
logger.info(f"==== ReplayProcess {self.group}: listen via redisDB")
|
||||||
pubsub = redisDB.pubsub()
|
pubsub = redisDB.pubsub()
|
||||||
pubsub.subscribe(self.group)
|
pubsub.subscribe(self.group)
|
||||||
|
try:
|
||||||
for message in pubsub.listen():
|
for message in pubsub.listen():
|
||||||
try:
|
try:
|
||||||
if self.stop_event.is_set():
|
if self.stop_event.is_set():
|
||||||
@@ -754,6 +838,7 @@ class ReplayProcess(Task):
|
|||||||
logger.error(f'ReplayProcess::listen_to_redis: {e}')
|
logger.error(f'ReplayProcess::listen_to_redis: {e}')
|
||||||
finally:
|
finally:
|
||||||
self.running.set()
|
self.running.set()
|
||||||
|
if loop:
|
||||||
utils.stop_async(loop)
|
utils.stop_async(loop)
|
||||||
pubsub.unsubscribe()
|
pubsub.unsubscribe()
|
||||||
pubsub.close()
|
pubsub.close()
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
.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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
.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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
.video-drop-zone {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 80px;
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 12px 20px;
|
||||||
|
border: 2px dashed #aaa;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #f9f9f9;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.2s, background 0.2s;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-drop-zone:hover,
|
||||||
|
.video-drop-zone.drag-over {
|
||||||
|
border-color: #417690;
|
||||||
|
background: #e8f3fb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-drop-zone.has-file {
|
||||||
|
border-style: solid;
|
||||||
|
border-color: #28a745;
|
||||||
|
background: #f0fff4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-drop-hint {
|
||||||
|
font-size: 0.9em;
|
||||||
|
color: #555;
|
||||||
|
pointer-events: none;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-drop-zone.has-file .video-drop-hint {
|
||||||
|
color: #28a745;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Progress bar ---- */
|
||||||
|
|
||||||
|
.video-progress-wrap {
|
||||||
|
display: none;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 360px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-progress-bar-track {
|
||||||
|
width: 100%;
|
||||||
|
height: 10px;
|
||||||
|
background: #ddd;
|
||||||
|
border-radius: 5px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-progress-bar {
|
||||||
|
height: 10px;
|
||||||
|
width: 0%;
|
||||||
|
background: #417690;
|
||||||
|
border-radius: 5px;
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-progress-label {
|
||||||
|
display: block;
|
||||||
|
text-align: right;
|
||||||
|
font-size: 0.8em;
|
||||||
|
color: #417690;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
@@ -33,12 +33,14 @@ class ScannerManager {
|
|||||||
this.well = options.well;
|
this.well = options.well;
|
||||||
this.debug = options.debug;
|
this.debug = options.debug;
|
||||||
this.calib_debug = options.calib_debug;
|
this.calib_debug = options.calib_debug;
|
||||||
|
this.draw_debug = options.draw_debug;
|
||||||
this.calib_center= options.calib_center;
|
this.calib_center= options.calib_center;
|
||||||
this.previous = options.previous;
|
this.previous = options.previous;
|
||||||
this.next = options.next;
|
this.next = options.next;
|
||||||
this.set_well = options.set_well;
|
this.set_well = options.set_well;
|
||||||
this.well_btn = options.well_btn;
|
this.well_btn = options.well_btn;
|
||||||
this.median = options.median;
|
this.median = options.median;
|
||||||
|
this.edge_enhance = options.edge_enhance;
|
||||||
this.crop = options.crop;
|
this.crop = options.crop;
|
||||||
this.crop_radius = options.crop_radius;
|
this.crop_radius = options.crop_radius;
|
||||||
this.calib_auto = options.calib_auto;
|
this.calib_auto = options.calib_auto;
|
||||||
@@ -62,33 +64,35 @@ class ScannerManager {
|
|||||||
this.goto_0.addEventListener('click', (e) => { this.clear_buttons(); this._send({ type: 'calibrate', topic: "goto_0" }); });
|
this.goto_0.addEventListener('click', (e) => { this.clear_buttons(); this._send({ type: 'calibrate', topic: "goto_0" }); });
|
||||||
this.goto_xy.addEventListener('click', (e) => { this.clear_buttons(); this._send({ type: 'calibrate', topic: "goto_xy" }); });
|
this.goto_xy.addEventListener('click', (e) => { this.clear_buttons(); this._send({ type: 'calibrate', topic: "goto_xy" }); });
|
||||||
this.xy_base.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "xy_base" }); });
|
this.xy_base.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "xy_base" }); });
|
||||||
|
|
||||||
this.calib_debug.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "calib_debug" }); });
|
|
||||||
this.previous.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "previous" }); });
|
|
||||||
this.next.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "next" }); });
|
|
||||||
this.set_well.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "set_well" }); });
|
this.set_well.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "set_well" }); });
|
||||||
|
|
||||||
this.median.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "median" }); });
|
this.median.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "median" }); });
|
||||||
|
this.edge_enhance.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "edge_enhance" }); });
|
||||||
this.crop.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "crop" }); });
|
this.crop.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "crop" }); });
|
||||||
this.crop_radius.addEventListener('change',(e) => { this._send({ type: 'calibrate', topic: "crop_radius", value: this.crop_radius.value }); });
|
this.crop_radius.addEventListener('change',(e) => { this._send({ type: 'calibrate', topic: "crop_radius", value: this.crop_radius.value }); });
|
||||||
|
|
||||||
this.well.addEventListener("change", (e) => { this._send({ type: 'calibrate', topic: "position", value: e.target.value }); });
|
this.well.addEventListener("change", (e) => { this._send({ type: 'calibrate', topic: "position", value: e.target.value }); });
|
||||||
this.step.addEventListener("change", (e) => { this._send({ type: 'calibrate', topic: "step", value: e.target.value }); });
|
this.step.addEventListener("change", (e) => { this._send({ type: 'calibrate', topic: "step", value: e.target.value }); });
|
||||||
this.feed.addEventListener("change", (e) => { this._send({ type: 'calibrate', topic: "feed", value: e.target.value }); });
|
this.feed.addEventListener("change", (e) => { this._send({ type: 'calibrate', topic: "feed", value: e.target.value }); });
|
||||||
this.duration.addEventListener("change", (e) => { this._send({ type: 'calibrate', topic: "duration", value: e.target.value }); });
|
this.duration.addEventListener("change", (e) => { this._send({ type: 'calibrate', topic: "duration", value: e.target.value }); });
|
||||||
|
|
||||||
this.test.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "test" }); });
|
this.test.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "test" }); });
|
||||||
|
|
||||||
|
this.halt.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "halt" }); });
|
||||||
|
this.calib_debug.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "calib_debug" }); });
|
||||||
|
this.draw_debug.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "draw_debug" }); });
|
||||||
|
try {
|
||||||
|
|
||||||
|
this.previous.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "previous" }); });
|
||||||
|
this.next.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "next" }); });
|
||||||
this.calib_center.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "center" }); });
|
this.calib_center.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "center" }); });
|
||||||
this.calib_auto.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "auto" }); });
|
this.calib_auto.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "auto" }); });
|
||||||
this.halt.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "halt" }); });
|
|
||||||
|
|
||||||
try {
|
|
||||||
this.min_area_px.addEventListener('change', (e) => { this._send({ type: 'calibrate', topic: "min_area_px", value: e.target.value }); });
|
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_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.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.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.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 }); });
|
this.draw.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "draw", value: e.target.value }); });
|
||||||
} catch(e) {}
|
} catch(e) {console.log(e);}
|
||||||
}
|
}
|
||||||
|
|
||||||
registerSocket(socket) {
|
registerSocket(socket) {
|
||||||
@@ -104,8 +108,12 @@ class ScannerManager {
|
|||||||
if (payload.state) {
|
if (payload.state) {
|
||||||
if (payload.state == 'debug') {
|
if (payload.state == 'debug') {
|
||||||
const span = this.calib_debug.querySelector("span.debug"); span.style.color = payload.value ? '#f0f': '#fff';
|
const span = this.calib_debug.querySelector("span.debug"); span.style.color = payload.value ? '#f0f': '#fff';
|
||||||
|
} else if (payload.state == 'draw_debug') {
|
||||||
|
const span = this.draw_debug.querySelector("span.draw_debug"); span.style.color = payload.value ? '#0ff' : '#888';
|
||||||
} else if (payload.state == 'median') {
|
} else if (payload.state == 'median') {
|
||||||
const span = this.median.querySelector("span.median"); span.style.color = payload.value ? '#f0f' : '#fff';
|
const span = this.median.querySelector("span.median"); span.style.color = payload.value ? '#f0f' : '#fff';
|
||||||
|
} else if (payload.state == 'edge_enhance') {
|
||||||
|
const span = this.edge_enhance.querySelector("span.edge_enhance"); span.style.color = payload.value ? '#0ff' : '#fff';
|
||||||
} else if (payload.state == 'crop') {
|
} else if (payload.state == 'crop') {
|
||||||
const span = this.crop.querySelector("span.crop"); span.style.color = payload.value ? '#f0f' : '#fff';
|
const span = this.crop.querySelector("span.crop"); span.style.color = payload.value ? '#f0f' : '#fff';
|
||||||
}
|
}
|
||||||
@@ -113,7 +121,10 @@ class ScannerManager {
|
|||||||
}
|
}
|
||||||
if (payload.ts) { this.ts.textContent = timestampToLocalISOString(payload.ts); }
|
if (payload.ts) { this.ts.textContent = timestampToLocalISOString(payload.ts); }
|
||||||
|
|
||||||
if (payload.buttons) { this.well_btn.innerHTML = payload.buttons; }
|
if (payload.buttons) {
|
||||||
|
this.well_btn.innerHTML = payload.buttons;
|
||||||
|
document.documentElement.style.setProperty('--well-columns', payload.columns);
|
||||||
|
}
|
||||||
if (payload.current >= 0) {
|
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; }
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ class ScannerManager {
|
|||||||
if (payload.buttons) {
|
if (payload.buttons) {
|
||||||
this.well_btn.innerHTML = payload.buttons;
|
this.well_btn.innerHTML = payload.buttons;
|
||||||
const span = this.crop.querySelector("span.crop"); span.style.color = '#f0f';
|
const span = this.crop.querySelector("span.crop"); span.style.color = '#f0f';
|
||||||
|
document.documentElement.style.setProperty('--well-columns', payload.columns);
|
||||||
}
|
}
|
||||||
if (payload.current >= 0) {
|
if (payload.current >= 0) {
|
||||||
document.querySelectorAll('button.w3-btn.well').forEach(btn => {
|
document.querySelectorAll('button.w3-btn.well').forEach(btn => {
|
||||||
@@ -72,7 +73,7 @@ class ScannerManager {
|
|||||||
} catch(e) { console.log(e); }
|
} catch(e) { console.log(e); }
|
||||||
}
|
}
|
||||||
|
|
||||||
init() { this.axes = 0; this.cropping = 1; this._send({ type: 'scanner', topic: "init", }); }
|
init() { this.axes = 0; this.cropping = 1; this._send({ type: 'scanner', topic: "init", sid: this.session.value }); }
|
||||||
scan() { this._send({ type: 'scanner', topic: "scan", session: this.session.value ? this.session.value: "0" }); }
|
scan() { this._send({ type: 'scanner', topic: "scan", session: this.session.value ? this.session.value: "0" }); }
|
||||||
simulate() { this._send({ type: 'scanner', topic: "simulate", session: this.session.value ? this.session.value: "0" }); }
|
simulate() { this._send({ type: 'scanner', topic: "simulate", session: this.session.value ? this.session.value: "0" }); }
|
||||||
halt() { this._send({ type: 'calibrate', topic: "halt" }); }
|
halt() { this._send({ type: 'calibrate', topic: "halt" }); }
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
(function () {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
function initDropZone(input) {
|
||||||
|
var zone = document.createElement("div");
|
||||||
|
zone.className = "video-drop-zone";
|
||||||
|
|
||||||
|
var hint = document.createElement("span");
|
||||||
|
hint.className = "video-drop-hint";
|
||||||
|
hint.textContent = "⬇ Glisser une vidéo ici ou cliquer pour parcourir";
|
||||||
|
zone.appendChild(hint);
|
||||||
|
|
||||||
|
// Progress bar (hidden by default)
|
||||||
|
var progressWrap = document.createElement("div");
|
||||||
|
progressWrap.className = "video-progress-wrap";
|
||||||
|
var progressTrack = document.createElement("div");
|
||||||
|
progressTrack.className = "video-progress-bar-track";
|
||||||
|
var progressBar = document.createElement("div");
|
||||||
|
progressBar.className = "video-progress-bar";
|
||||||
|
progressTrack.appendChild(progressBar);
|
||||||
|
progressWrap.appendChild(progressTrack);
|
||||||
|
var progressLabel = document.createElement("span");
|
||||||
|
progressLabel.className = "video-progress-label";
|
||||||
|
progressWrap.appendChild(progressLabel);
|
||||||
|
zone.appendChild(progressWrap);
|
||||||
|
|
||||||
|
input.parentNode.insertBefore(zone, input.nextSibling);
|
||||||
|
|
||||||
|
function setFilename(name) {
|
||||||
|
hint.textContent = "✓ " + name;
|
||||||
|
zone.classList.add("has-file");
|
||||||
|
}
|
||||||
|
|
||||||
|
function showProgress(pct) {
|
||||||
|
progressWrap.style.display = "block";
|
||||||
|
progressBar.style.width = pct + "%";
|
||||||
|
progressLabel.textContent = Math.round(pct) + " %";
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideProgress() {
|
||||||
|
progressWrap.style.display = "none";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drag events
|
||||||
|
zone.addEventListener("dragenter", function (e) { e.preventDefault(); zone.classList.add("drag-over"); });
|
||||||
|
zone.addEventListener("dragover", function (e) { e.preventDefault(); zone.classList.add("drag-over"); });
|
||||||
|
zone.addEventListener("dragleave", function () { zone.classList.remove("drag-over"); });
|
||||||
|
zone.addEventListener("dragend", function () { zone.classList.remove("drag-over"); });
|
||||||
|
|
||||||
|
zone.addEventListener("drop", function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
zone.classList.remove("drag-over");
|
||||||
|
var files = e.dataTransfer.files;
|
||||||
|
if (!files.length) return;
|
||||||
|
var file = files[0];
|
||||||
|
try {
|
||||||
|
var dt = new DataTransfer();
|
||||||
|
dt.items.add(file);
|
||||||
|
input.files = dt.files;
|
||||||
|
input.dispatchEvent(new Event("change", { bubbles: true }));
|
||||||
|
} catch (_) {}
|
||||||
|
setFilename(file.name);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Click on zone → open file picker
|
||||||
|
zone.addEventListener("click", function () { input.click(); });
|
||||||
|
|
||||||
|
// Sync when user picks via dialog
|
||||||
|
input.addEventListener("change", function () {
|
||||||
|
if (input.files && input.files.length > 0) {
|
||||||
|
setFilename(input.files[0].name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Pre-fill if a file is already set (edit form)
|
||||||
|
if (input.value) {
|
||||||
|
var parts = input.value.split(/[\\/]/);
|
||||||
|
setFilename(parts[parts.length - 1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { showProgress: showProgress, hideProgress: hideProgress, input: input };
|
||||||
|
}
|
||||||
|
|
||||||
|
function interceptFormSubmit(form, dropZones) {
|
||||||
|
form.addEventListener("submit", function (e) {
|
||||||
|
// Only intercept if at least one file input has a file selected
|
||||||
|
var hasFile = dropZones.some(function (dz) {
|
||||||
|
return dz.input.files && dz.input.files.length > 0;
|
||||||
|
});
|
||||||
|
if (!hasFile) return; // normal submit, no big file
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
var data = new FormData(form);
|
||||||
|
var xhr = new XMLHttpRequest();
|
||||||
|
|
||||||
|
// Show progress on the first drop zone that has a file
|
||||||
|
var active = dropZones.find(function (dz) {
|
||||||
|
return dz.input.files && dz.input.files.length > 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (active) active.showProgress(0);
|
||||||
|
|
||||||
|
xhr.upload.addEventListener("progress", function (ev) {
|
||||||
|
if (ev.lengthComputable && active) {
|
||||||
|
active.showProgress((ev.loaded / ev.total) * 100);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
xhr.addEventListener("load", function () {
|
||||||
|
if (active) active.hideProgress();
|
||||||
|
// Django admin redirects after save — follow the final URL
|
||||||
|
var finalUrl = xhr.responseURL || form.action;
|
||||||
|
// The response itself is the resulting admin page; navigate to it
|
||||||
|
window.location.href = finalUrl;
|
||||||
|
});
|
||||||
|
|
||||||
|
xhr.addEventListener("error", function () {
|
||||||
|
if (active) {
|
||||||
|
active.hideProgress();
|
||||||
|
active.showProgress(0); // reset bar
|
||||||
|
}
|
||||||
|
alert("Erreur lors de l'envoi du fichier.");
|
||||||
|
});
|
||||||
|
|
||||||
|
xhr.open(form.method || "POST", form.action || window.location.href, true);
|
||||||
|
xhr.send(data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("DOMContentLoaded", function () {
|
||||||
|
var inputs = Array.from(document.querySelectorAll('input[type="file"]'));
|
||||||
|
var dropZones = inputs.map(initDropZone);
|
||||||
|
|
||||||
|
// Hook into the closest form that contains these inputs
|
||||||
|
var form = inputs.length && inputs[0].closest("form");
|
||||||
|
if (form && dropZones.length) {
|
||||||
|
interceptFormSubmit(form, dropZones);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}());
|
||||||
@@ -68,15 +68,13 @@ def export_all_images(session_id=None):
|
|||||||
conf = ScannerConstants().get()
|
conf = ScannerConstants().get()
|
||||||
|
|
||||||
if session_id is None:
|
if session_id is None:
|
||||||
sessions = [s.id for s in models.Session.objects.filter(active=False)]
|
sessions = [s.pk for s in models.Session.objects.filter(active=False)]
|
||||||
else:
|
else:
|
||||||
sessions = [session_id]
|
sessions = [session_id]
|
||||||
|
job_zip = []
|
||||||
for session_id in sessions:
|
for session_id in sessions:
|
||||||
uuid_list = models.SessionExperiment.uuid_from_session(session_id)
|
uuid_list = models.SessionExperiment.uuid_from_session(session_id)
|
||||||
job_zip = []
|
|
||||||
for uuid in uuid_list:
|
for uuid in uuid_list:
|
||||||
|
|
||||||
job = export_images.delay( # @UndefinedVariable
|
job = export_images.delay( # @UndefinedVariable
|
||||||
uuid,
|
uuid,
|
||||||
start_ts=None,
|
start_ts=None,
|
||||||
@@ -109,13 +107,12 @@ def export_all_videos(session_id=None):
|
|||||||
try:
|
try:
|
||||||
conf = ScannerConstants().get()
|
conf = ScannerConstants().get()
|
||||||
if session_id is None:
|
if session_id is None:
|
||||||
sessions = [s.id for s in models.Session.objects.filter(active=False)]
|
sessions = [s.pk for s in models.Session.objects.filter(active=False)]
|
||||||
else:
|
else:
|
||||||
sessions = [session_id]
|
sessions = [session_id]
|
||||||
|
job_mp4 = []
|
||||||
for session_id in sessions:
|
for session_id in sessions:
|
||||||
uuid_list = models.SessionExperiment.uuid_from_session(session_id)
|
uuid_list = models.SessionExperiment.uuid_from_session(session_id)
|
||||||
job_mp4 = []
|
|
||||||
for uuid in uuid_list:
|
for uuid in uuid_list:
|
||||||
|
|
||||||
job = export_videos.delay( # @UndefinedVariable
|
job = export_videos.delay( # @UndefinedVariable
|
||||||
@@ -146,7 +143,7 @@ def run_session_exports(self, session_id: str):
|
|||||||
logger.error("run_session_exports: session %s introuvable", session_id)
|
logger.error("run_session_exports: session %s introuvable", session_id)
|
||||||
return {"status": "error", "message": "Session introuvable"}
|
return {"status": "error", "message": "Session introuvable"}
|
||||||
|
|
||||||
session.status = models.Session.Status.RUNNING
|
session.export_status = models.Session.Status.RUNNING
|
||||||
session.save(update_fields=["export_status"])
|
session.save(update_fields=["export_status"])
|
||||||
logger.info(f"run_session_exports [{session_id}]: export démarré")
|
logger.info(f"run_session_exports [{session_id}]: export démarré")
|
||||||
chord(
|
chord(
|
||||||
|
|||||||
@@ -133,10 +133,10 @@
|
|||||||
</a>
|
</a>
|
||||||
<hr class="w3-bar-item divider w3-grey w3-margin-bottom">
|
<hr class="w3-bar-item divider w3-grey w3-margin-bottom">
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<a href="{% url 'scanner:scanning' %}" class="w3-bar-item w3-btn w3-hover-opacity">
|
<a href="{% url 'scanner:scanning' %}" class="w3-bar-item w3-btn w3-hover-opacity w3-margin-bottom">
|
||||||
<i class="fa-solid fa-film w3-text-green w3-xlarge""></i> {% trans "Balayage multi-puits" %}
|
<i class="fa-solid fa-film w3-text-green w3-xlarge""></i> {% trans "Balayage multi-puits" %}
|
||||||
</a>
|
</a>
|
||||||
<div class="w3-bar-item w3-blue-grey"><i class="fa-solid fa-square-poll-vertical w3-text-amber"></i> {% trans "Résultats " %}</div>
|
<div class="w3-bar-item w3-border-top w3-center"><i class="fa-solid fa-square-poll-vertical w3-text-amber"></i> {% trans "Résultats " %}</div>
|
||||||
{% if conf.tracking %}
|
{% if conf.tracking %}
|
||||||
<a href="{% url 'planarian:export-csv' %}" class="w3-bar-item w3-btn w3-hover-opacity">
|
<a href="{% url 'planarian:export-csv' %}" class="w3-bar-item w3-btn w3-hover-opacity">
|
||||||
<i class="fa-solid fa-file-export w3-text-lime w3-xlarge"></i> {% trans "Export CSV des expériences" %}
|
<i class="fa-solid fa-file-export w3-text-lime w3-xlarge"></i> {% trans "Export CSV des expériences" %}
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
{% extends 'scanner/base.html' %}
|
{% extends 'scanner/base.html' %}
|
||||||
{% load i18n home_tags scanner_tags %}
|
{% load i18n home_tags %}
|
||||||
|
|
||||||
{% block styles %}
|
{% block styles %}
|
||||||
{{ block.super }}
|
{{ block.super }}
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--well-columns: 3;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
<link href="/static/scanner/css/calibration.css" rel="stylesheet">
|
<link href="/static/scanner/css/calibration.css" rel="stylesheet">
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
{% block columns %}{% endblock %}
|
{% block columns %}{% endblock %}
|
||||||
@@ -84,9 +90,10 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="w3-row w3-row-padding">
|
<div class="w3-row w3-row-padding">
|
||||||
<div id="_well_btn" class="w3-col w3-margin-bottom"></div>
|
<div id="_well_btn" class="w3-col w3-margin-bottom"></div>
|
||||||
|
<div {% if capture_type == "file" %}class="w3-hide"{% endif %}>
|
||||||
<div class="w3-third">
|
<div class="w3-third">
|
||||||
<button id="_previous" class="w3-button w3-dark-xlight w3-round-large w3-padding-small">
|
<button id="_previous" class="w3-button w3-dark-xlight w3-round-large w3-padding-small">
|
||||||
<span class="w3-small">{% trans 'Précéd.' %}</span><br><i class="fa-solid fa-circle-left w3-xlarge"></i>
|
<span class="w3-small">{% trans 'Précéd.' %}</span><br><i class="fa-solid fa-circle-left w3-xlarge"></i>
|
||||||
@@ -110,8 +117,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="w3-col" style="width: 50%">
|
</div>
|
||||||
{% include 'scanner/scan-image.html' %}
|
<div class="w3-col" style="width: 50%; display: flex; align-items: center; justify-content: center;">
|
||||||
|
<img id="scan-img" class="w3-image">
|
||||||
</div>
|
</div>
|
||||||
<div class="w3-col w3-center" style="width: 25%">
|
<div class="w3-col w3-center" style="width: 25%">
|
||||||
<div class="w3-row w3-row-padding">
|
<div class="w3-row w3-row-padding">
|
||||||
@@ -139,10 +147,20 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="w3-half">
|
<div class="w3-half">
|
||||||
<button id="_median" class="w3-button w3-dark-xlight w3-round-large w3-padding-1 w3-block">
|
<button id="_draw_debug" class="w3-button w3-dark-xlight w3-round-large w3-padding-1 w3-block" title="{% trans 'Afficher / masquer les annotations de détection' %}">
|
||||||
|
<span class="draw_debug" style="color:#0ff">{% trans 'Overlay' %}</span><br><i class="fa-solid fa-chart-simple w3-text-amber w3-xlarge"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="w3-half">
|
||||||
|
<button id="_median" class="w3-button w3-dark-xlight w3-round-large w3-padding-1 w3-margin-top-1 w3-block">
|
||||||
<span class="median">{% trans 'Axes' %}</span><br><i class="fa-solid fa-crosshairs w3-text-amber w3-xlarge"></i>
|
<span class="median">{% trans 'Axes' %}</span><br><i class="fa-solid fa-crosshairs w3-text-amber w3-xlarge"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="w3-half">
|
||||||
|
<button id="_edge_enhance" class="w3-button w3-dark-xlight w3-round-large w3-padding-1 w3-margin-top-1 w3-block" title="{% trans 'Mettre en évidence les bords du puit' %}">
|
||||||
|
<span class="edge_enhance">{% trans 'Contours' %}</span><br><i class="fa-solid fa-circle-dot w3-text-amber w3-xlarge"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div class="w3-col">
|
<div class="w3-col">
|
||||||
<button id="_crop" class="w3-button w3-dark-xlight w3-round-large w3-padding-small w3-margin-top w3-block" title="{% trans 'Ce mode actionne le pointage graphique' %}">
|
<button id="_crop" class="w3-button w3-dark-xlight w3-round-large w3-padding-small w3-margin-top w3-block" title="{% trans 'Ce mode actionne le pointage graphique' %}">
|
||||||
<span class="crop">{% trans 'Recadrer pour calibrer / suivre' %}</span><br><i class="fa-solid fa-crop w3-text-amber w3-xlarge"></i>
|
<span class="crop">{% trans 'Recadrer pour calibrer / suivre' %}</span><br><i class="fa-solid fa-crop w3-text-amber w3-xlarge"></i>
|
||||||
@@ -248,6 +266,7 @@
|
|||||||
well : sId("_well"),
|
well : sId("_well"),
|
||||||
debug : sId("_debug"),
|
debug : sId("_debug"),
|
||||||
calib_debug : sId("_calib_debug"),
|
calib_debug : sId("_calib_debug"),
|
||||||
|
draw_debug : sId("_draw_debug"),
|
||||||
calib_auto : sId("_calib_auto"),
|
calib_auto : sId("_calib_auto"),
|
||||||
calib_center: sId("_calib_center"),
|
calib_center: sId("_calib_center"),
|
||||||
previous : sId("_previous"),
|
previous : sId("_previous"),
|
||||||
@@ -255,6 +274,7 @@
|
|||||||
set_well : sId("_set_well"),
|
set_well : sId("_set_well"),
|
||||||
well_btn : sId("_well_btn"),
|
well_btn : sId("_well_btn"),
|
||||||
median : sId("_median"),
|
median : sId("_median"),
|
||||||
|
edge_enhance : sId("_edge_enhance"),
|
||||||
crop : sId("_crop"),
|
crop : sId("_crop"),
|
||||||
crop_radius : sId("_crop_radius"),
|
crop_radius : sId("_crop_radius"),
|
||||||
min_area_px : sId("_min_area_px"),
|
min_area_px : sId("_min_area_px"),
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{% extends 'scanner/base.html' %}
|
{% extends 'scanner/base.html' %}
|
||||||
{% load i18n home_tags scanner_tags %}
|
{% load i18n home_tags %}
|
||||||
|
|
||||||
{% block styles %}
|
{% block styles %}
|
||||||
{{ block.super }}
|
{{ block.super }}
|
||||||
@@ -101,7 +101,7 @@
|
|||||||
function download_all_images(sid) {
|
function download_all_images(sid) {
|
||||||
const ok = confirm(export_msg +' '+ export_msg_long);
|
const ok = confirm(export_msg +' '+ export_msg_long);
|
||||||
if (!ok) return false;
|
if (!ok) return false;
|
||||||
fetch(image_endpoint, {
|
csrfFetch(image_endpoint, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{% extends 'scanner/base.html' %}
|
{% extends 'scanner/base.html' %}
|
||||||
{% load i18n home_tags scanner_tags %}
|
{% load i18n home_tags %}
|
||||||
|
|
||||||
{% block styles %}
|
{% block styles %}
|
||||||
{{ block.super }}
|
{{ block.super }}
|
||||||
@@ -87,7 +87,7 @@
|
|||||||
function download_all_videos(sid) {
|
function download_all_videos(sid) {
|
||||||
const ok = confirm(export_msg);
|
const ok = confirm(export_msg);
|
||||||
if (!ok) return false;
|
if (!ok) return false;
|
||||||
fetch(image_endpoint, {
|
csrfFetch(image_endpoint, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
<div>Ax pos: <span id="_axial_pos"></span></div>
|
<div>Ax pos: <span id="_axial_pos"></span></div>
|
||||||
</div-->
|
</div-->
|
||||||
{#% endif %#}
|
{#% endif %#}
|
||||||
<div class="w3-rest">
|
<div class="w3-col">
|
||||||
<img id="scan-img" class="w3-image">
|
<img id="scan-img" class="w3-image w3-margin-auto">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -3,6 +3,11 @@
|
|||||||
|
|
||||||
{% block styles %}
|
{% block styles %}
|
||||||
{{ block.super }}
|
{{ block.super }}
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--well-columns: 6;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
<link href="/static/scanner/css/scanning.css" rel="stylesheet">
|
<link href="/static/scanner/css/scanning.css" rel="stylesheet">
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
{% block columns %}{% endblock %}
|
{% block columns %}{% endblock %}
|
||||||
@@ -12,11 +17,15 @@
|
|||||||
<div class="w3-col w3-center" style="width: 30%">
|
<div class="w3-col w3-center" style="width: 30%">
|
||||||
<div class="w3-col">
|
<div class="w3-col">
|
||||||
<div>{% trans "Choix de la session" %}</div>
|
<div>{% trans "Choix de la session" %}</div>
|
||||||
<select id="_session" class="w3-select">
|
<form method="post" class="w3-bar-item" action="">
|
||||||
|
{% csrf_token %}
|
||||||
|
<select id="_session" name="_session" class="w3-select" onchange="this.form.submit()">
|
||||||
{% for s in sessions %}
|
{% for s in sessions %}
|
||||||
<option value="{{ s.id }}">{{ s }}</option>
|
<option value="{{ s.id }}" {% if s.id == cursid %}selected{% endif %}>{{ s }}</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
|
</form>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<div class="w3-col">
|
<div class="w3-col">
|
||||||
<div class="w3-row w3-bold">
|
<div class="w3-row w3-bold">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# encoding: utf-8
|
# encoding: utf-8
|
||||||
from django import template
|
from django import template
|
||||||
from django.utils.html import mark_safe
|
from django.utils.safestring import mark_safe
|
||||||
|
|
||||||
register = template.Library()
|
register = template.Library()
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ from django.shortcuts import render #, redirect
|
|||||||
from django.http import JsonResponse, FileResponse
|
from django.http import JsonResponse, FileResponse
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django.views.decorators.http import require_GET, require_POST
|
from django.views.decorators.http import require_GET, require_POST
|
||||||
from django.views.decorators.csrf import csrf_exempt
|
|
||||||
from django.contrib.auth.decorators import login_required, user_passes_test
|
from django.contrib.auth.decorators import login_required, user_passes_test
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
@@ -55,7 +54,6 @@ def global_context(request, **ctx):
|
|||||||
**ctx
|
**ctx
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
@user_passes_test(is_staff_or_admin)
|
@user_passes_test(is_staff_or_admin)
|
||||||
def admin_view(request):
|
def admin_view(request):
|
||||||
@@ -84,36 +82,36 @@ def admin_periodictask_view(request):
|
|||||||
@login_required
|
@login_required
|
||||||
@user_passes_test(is_staff_or_admin)
|
@user_passes_test(is_staff_or_admin)
|
||||||
def reductstore_view(request):
|
def reductstore_view(request):
|
||||||
return render(request, "scanner/redirection.html", context=global_context(request, link=f'http://{settings.LOCAL_IP_SERVER}:8383/'))
|
return render(request, "scanner/redirection.html", context=global_context(request, link=f'http://{settings.DOMAIN_SERVER}:8383/'))
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
@user_passes_test(is_staff_or_admin)
|
@user_passes_test(is_staff_or_admin)
|
||||||
def adminer_view(request):
|
def adminer_view(request):
|
||||||
return render(request, "scanner/redirection.html", context=global_context(request, link=f'http://{settings.LOCAL_IP_SERVER}/adminer/'))
|
return render(request, "scanner/redirection.html", context=global_context(request, link=f'http://{settings.DOMAIN_SERVER}/adminer/'))
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
@user_passes_test(is_staff_or_admin)
|
@user_passes_test(is_staff_or_admin)
|
||||||
def portainer_view(request):
|
def portainer_view(request):
|
||||||
return render(request, "scanner/redirection.html", context=global_context(request, link=f'http://{settings.LOCAL_IP_SERVER}:9000/'))
|
return render(request, "scanner/redirection.html", context=global_context(request, link=f'http://{settings.DOMAIN_SERVER}:9000/'))
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
@user_passes_test(is_staff_or_admin)
|
@user_passes_test(is_staff_or_admin)
|
||||||
def supervisor_view(request):
|
def supervisor_view(request):
|
||||||
return render(request, "scanner/redirection.html", context=global_context(request, link=f'http://{settings.LOCAL_IP_SERVER}:9001/'))
|
return render(request, "scanner/redirection.html", context=global_context(request, link=f'http://{settings.DOMAIN_SERVER}:9001/'))
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
@user_passes_test(is_staff_or_admin)
|
@user_passes_test(is_staff_or_admin)
|
||||||
def supervisor_worker(request):
|
def supervisor_worker(request):
|
||||||
return render(request, "scanner/redirection.html", context=global_context(request, link=f'http://{settings.LOCAL_IP_SERVER}:9001/logtail/test_tube:services'))
|
return render(request, "scanner/redirection.html", context=global_context(request, link=f'http://{settings.DOMAIN_SERVER}:9001/logtail/test_tube:services'))
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
@user_passes_test(is_staff_or_admin)
|
@user_passes_test(is_staff_or_admin)
|
||||||
def supervisor_scheduler(request):
|
def supervisor_scheduler(request):
|
||||||
return render(request, "scanner/redirection.html", context=global_context(request, link=f'http://{settings.LOCAL_IP_SERVER}:9001/logtail/test_tube:planification'))
|
return render(request, "scanner/redirection.html", context=global_context(request, link=f'http://{settings.DOMAIN_SERVER}:9001/logtail/test_tube:planification'))
|
||||||
|
|
||||||
|
|
||||||
def documentation(request, template=None):
|
def documentation(request, template=None):
|
||||||
@@ -126,9 +124,16 @@ def documentation(request, template=None):
|
|||||||
## Mainboard
|
## Mainboard
|
||||||
@login_required
|
@login_required
|
||||||
def scanning_view(request):
|
def scanning_view(request):
|
||||||
|
cursid = 0
|
||||||
|
if request.method == 'POST':
|
||||||
|
cursid = request.POST.get('_session', 0)
|
||||||
|
if not cursid:
|
||||||
|
current_session = models.Session.objects.filter(active=True).first()
|
||||||
|
cursid = current_session.pk
|
||||||
|
|
||||||
ctx = dict(
|
ctx = dict(
|
||||||
ws_route=settings.SCANNER_WEBSOCKET_ROUTE,
|
ws_route=settings.SCANNER_WEBSOCKET_ROUTE,
|
||||||
columns=1,
|
cursid=int(cursid),
|
||||||
sessions=models.Session.objects.filter(active=True).all(),
|
sessions=models.Session.objects.filter(active=True).all(),
|
||||||
choice_title=_("Balayage multi-puits")
|
choice_title=_("Balayage multi-puits")
|
||||||
)
|
)
|
||||||
@@ -137,26 +142,36 @@ def scanning_view(request):
|
|||||||
## Calibration
|
## Calibration
|
||||||
@login_required
|
@login_required
|
||||||
def calibration_view(request):
|
def calibration_view(request):
|
||||||
|
context = global_context(request)
|
||||||
|
wells = models.MultiWell.objects.filter(capture_video=False).all()
|
||||||
|
capture_type = context['conf'].capture_type
|
||||||
|
config = ScannerConstants.get_config()
|
||||||
|
|
||||||
|
if capture_type == 'video':
|
||||||
|
mw = models.MultiWell.objects.filter(default=True, capture_video=True).first()
|
||||||
|
if mw:
|
||||||
|
context['conf'].default_position = mw.position
|
||||||
|
wells = models.MultiWell.objects.filter(capture_video=True).all()
|
||||||
|
|
||||||
ctx = dict(
|
ctx = dict(
|
||||||
ws_route=settings.SCANNER_WEBSOCKET_ROUTE,
|
ws_route=settings.SCANNER_WEBSOCKET_ROUTE,
|
||||||
columns=1,
|
choice_title=f'{str(_("Calibration"))} {config.get_capture_type_display()}',
|
||||||
choice_title=_("Calibration"),
|
wells = wells,
|
||||||
wells = models.MultiWell.objects.all(),
|
capture_type=capture_type,
|
||||||
)
|
)
|
||||||
return render(request, "scanner/calibration.html", context=global_context(request, **ctx))
|
context.update(ctx)
|
||||||
|
return render(request, "scanner/calibration.html", context=context)
|
||||||
|
|
||||||
|
def get_not_active_experiments(session, expid=None) -> tuple[list, "models.Experiment | None"]:
|
||||||
def get_not_active_experiments(session, expid=None):
|
|
||||||
if session:
|
if session:
|
||||||
experiments = models.SessionExperiment.experiment_by_session(session.id, active=False) or []
|
experiments = models.SessionExperiment.experiment_by_session(session.pk, active=False) or []
|
||||||
if experiments and not expid:
|
if experiments and not expid:
|
||||||
return experiments, experiments[0]
|
return experiments, experiments[0]
|
||||||
for e in experiments:
|
for e in experiments:
|
||||||
if expid == str(e.id):
|
if expid == str(e.pk):
|
||||||
return experiments, e
|
return experiments, e
|
||||||
return [], None
|
return [], None
|
||||||
|
|
||||||
|
|
||||||
## images
|
## images
|
||||||
def get_images(uuid):
|
def get_images(uuid):
|
||||||
oldest, latest, n, images = 0, 0, 0, []
|
oldest, latest, n, images = 0, 0, 0, []
|
||||||
@@ -230,13 +245,12 @@ def export_medias(request):
|
|||||||
choice_title=_("Exporter les fichiers médias"),
|
choice_title=_("Exporter les fichiers médias"),
|
||||||
sessions=models.Session.objects.filter(active=False).all(),
|
sessions=models.Session.objects.filter(active=False).all(),
|
||||||
current_session=current_session,
|
current_session=current_session,
|
||||||
url=f'http://{settings.LOCAL_IP_SERVER}:9001/logtail/test_tube:services',
|
url=f'http://{settings.DOMAIN_SERVER}:9001/logtail/test_tube:services',
|
||||||
)
|
)
|
||||||
return render(request, "scanner/export_medias.html", context=global_context(request, **ctx))
|
return render(request, "scanner/export_medias.html", context=global_context(request, **ctx))
|
||||||
|
|
||||||
|
|
||||||
@require_POST
|
@require_POST
|
||||||
@csrf_exempt
|
|
||||||
def download_api(request):
|
def download_api(request):
|
||||||
data = json.loads(request.body.decode() or "{}")
|
data = json.loads(request.body.decode() or "{}")
|
||||||
try:
|
try:
|
||||||
@@ -298,7 +312,6 @@ def replay_view(request):
|
|||||||
|
|
||||||
|
|
||||||
@require_POST
|
@require_POST
|
||||||
@csrf_exempt
|
|
||||||
def export_api(request):
|
def export_api(request):
|
||||||
data = json.loads(request.body.decode() or "{}")
|
data = json.loads(request.body.decode() or "{}")
|
||||||
session_id = data.get("sid")
|
session_id = data.get("sid")
|
||||||
|
|||||||
Reference in New Issue
Block a user