Video plate capture: calibration, edge enhance, auto-detect well borders

This commit is contained in:
2026-06-03 17:56:23 +02:00
parent 4b42c03756
commit 9bb8fc1bce
58 changed files with 1699 additions and 274 deletions
+107 -4
View File
@@ -1,4 +1,6 @@
from pathlib import Path
from django.utils.translation import gettext_lazy as _
from django.utils.html import format_html
from django.contrib import admin
from django.db.models import Q
from . import models
@@ -43,14 +45,14 @@ class ConfigurationAdmin(admin.ModelAdmin):
class MultiWellAdmin(admin.ModelAdmin):
list_filter = ('author', )
list_display = ('label', 'position', 'author', 'order', 'xbase', 'ybase', 'duration', 'feed', 'default', 'well_position', 'active',)
list_display = ('label', 'position', 'author', 'order', 'xbase', 'ybase', 'duration', 'feed', 'default', 'well_position', 'capture_video', 'active',)
ordering = ('label', 'order')
fieldsets = (
(_("Identification"), {
"fields": ("label", "author", "position", "default", "active"),
"fields": ("label", "author", "position", "default", "capture_video", "active"),
}),
(_("Géométrie"), {
"fields": ("cols", "rows", "diameter", "row_def", "row_order"),"classes": ("collapse",),
"fields": ("cols", "rows", "diameter", "crop_radius", "row_def", "row_order"),"classes": ("collapse",),
}),
(_("Déplacement"), {
"fields": ("order", "duration", "xbase", "ybase", "dx", "dy", "feed"),"classes": ("collapse",),
@@ -113,6 +115,107 @@ class SessionAdmin(admin.ModelAdmin):
'scanning_finished_at'
)
@admin.register(models.VideoPlate)
class VideoPlateAdmin(admin.ModelAdmin):
list_display = ['multiwell', 'label', 'video_filename', 'active',
'fps_display', 'duration_display', 'resolution_display', 'uploaded_at']
list_filter = ['multiwell', 'active']
list_editable = ['active']
readonly_fields = [
'native_fps', 'duration_s', 'frame_w', 'frame_h',
'uploaded_at', 'resolution_display', 'video_preview',
]
fields = [
'multiwell', 'label', 'video_file', 'active', 'px_per_mm',
'x_origin_mm', 'y_origin_mm',
'video_preview',
'native_fps', 'duration_s', 'frame_w', 'frame_h', 'uploaded_at',
]
class Media:
css = {'all': ('scanner/css/video_upload.css',)}
js = ('scanner/js/video_upload.js',)
# ------------------------------------------------------------------
# Colonnes liste
# ------------------------------------------------------------------
@admin.display(description=_("Fichier"), ordering='video_file')
def video_filename(self, obj):
return obj.video_filename
@admin.display(description=_("FPS"))
def fps_display(self, obj):
return f"{obj.native_fps:.2f}" if obj.native_fps else ""
@admin.display(description=_("Durée"))
def duration_display(self, obj):
if not obj.duration_s:
return ""
m, s = divmod(int(obj.duration_s), 60)
return f"{m}:{s:02d}"
@admin.display(description=_("Résolution"))
def resolution_display(self, obj):
return obj.resolution
# ------------------------------------------------------------------
# Aperçu vidéo (readonly field)
# ------------------------------------------------------------------
@admin.display(description=_("Aperçu"))
def video_preview(self, obj):
if not obj.video_file:
return ""
return format_html(
'<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.Well, WellAdmin)
admin.site.register(models.MultiWell, MultiWellAdmin)
+9 -1
View File
@@ -19,7 +19,7 @@ class DefaultConfig:
webcam_device_index: int = 2
image_quality: int = 90
video_jpeg_quality: int = 90
video_frame_rate: int = 5.0
video_frame_rate: float = 5.0
video_width_capture: int = 2028
video_height_capture: int = 1520
scan_simulation: bool = False
@@ -52,4 +52,12 @@ class ScannerConstants:
def get(self):
return self.conf
@classmethod
def get_config(cls):
return Configuration.objects.filter(active=True).first()
def save_config(self):
d = asdict(self.conf)
Configuration.objects.filter(active=True).update(**d)
+13 -10
View File
@@ -34,19 +34,20 @@ def delete_file_later(path):
async def remove_video_by_uuid(uuid, start_ts=None, end_ts=None, when=None):
record_manager = CameraRecordManager(cameraDB)
await record_manager.remove(uuid, start_ts, end_ts)
await record_manager.remove_uuid(uuid, start_ts, end_ts)
async def remove_video(uuid, start_ts, end_ts, when=None):
await remove_video_by_uuid(uuid, start_ts, end_ts, when=when)
async def shm_download_video(uuid, start_ts, end_ts, frame_rate=5, opencv_fourcc_format='mp4v', opencv_video_type='mp4'):
video_path = os.path.join(settings.MEDIA_ROOT, f"output.{opencv_video_type}")
try:
record_manager = CameraRecordManager(cameraDB)
total_size = await record_manager.size(uuid, start_ts, end_ts)
# segment de mémoire partagée pour stocker les frames
shm_size = int(total_size * 1.5)
shm_size = int((total_size or 0) * 1.5)
shm_name = f"/video_frames_{uuid}"
try:
shm = posix_ipc.SharedMemory(shm_name, posix_ipc.O_CREAT | posix_ipc.O_EXCL, size=shm_size)
@@ -77,12 +78,13 @@ async def shm_download_video(uuid, start_ts, end_ts, frame_rate=5, opencv_fourcc
raise Exception("No frame found!")
#return JsonResponse({'error': 'Aucune frame trouvée'}, status=404)
video_path = os.path.join(settings.MEDIA_ROOT, f"output.{opencv_video_type}")
fourcc = cv2.VideoWriter_fourcc(* opencv_fourcc_format)
#video_path = os.path.join(settings.MEDIA_ROOT, f"output.{opencv_video_type}")
fourcc = cv2.VideoWriter_fourcc(*opencv_fourcc_format) # type: ignore[attr-defined]
# Lit les frames depuis la mémoire partagée
current_offset = 0
i = 0
video = None
for size in frame_sizes:
frame_bytes = mm[current_offset:current_offset + size]
nparr = np.frombuffer(frame_bytes, np.uint8)
@@ -94,8 +96,9 @@ async def shm_download_video(uuid, start_ts, end_ts, frame_rate=5, opencv_fourcc
current_offset += size
progress_bar(i + 1, total, prefix=f'Progress {uuid}:', suffix='Ended', length=30)
i+=1
video.release()
i+=1
if video:
video.release()
# Nettoie la mémoire partagée
shm.unlink()
@@ -183,7 +186,7 @@ async def export_images_zip(
# --- Chargement des frames en mémoire partagée ---
record_manager = CameraRecordManager(cameraDB)
total_size = await record_manager.size(uuid, start_ts, end_ts)
shm_size = int(total_size * 1.5)
shm_size = int((total_size or 0) * 1.5)
try:
shm = posix_ipc.SharedMemory(
@@ -336,11 +339,11 @@ async def export_video_mp4(
video = None
shm_name = f"/vid_frames_{uuid}"
try:
try:
# --- Chargement des frames en mémoire partagée ---
record_manager = CameraRecordManager(cameraDB)
total_size = await record_manager.size(uuid, start_ts, end_ts)
shm_size = int(total_size * 1.5)
shm_size = int((total_size or 0) * 1.5)
try:
shm = posix_ipc.SharedMemory(
@@ -390,7 +393,7 @@ async def export_video_mp4(
)
os.makedirs(os.path.dirname(video_path), exist_ok=True)
fourcc = cv2.VideoWriter_fourcc(*opencv_fourcc_format)
fourcc = cv2.VideoWriter_fourcc(*opencv_fourcc_format) # type: ignore[attr-defined]
skipped = 0
written = 0
current_offset = 0
@@ -0,0 +1,197 @@
# Generated by Django 6.0.5 on 2026-05-31 07:42
import django.db.models.deletion
import django.utils.timezone
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('django_celery_beat', '0019_alter_periodictasks_options'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Configuration',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(default='Configuration par défaut', help_text='Nom de la configuration', max_length=100, null=True, verbose_name='Nom de la Configuration')),
('sidebar_width', models.CharField(default='350px', help_text='Largeur barre latérale (css)', max_length=32, null=True, verbose_name='Barre latérale')),
('default_grid_columns', models.PositiveSmallIntegerField(default=3, help_text='Nombre de colonnes de la grille par défaut', verbose_name='Colonnes de la grille par défaut')),
('opencv_fourcc_format', models.CharField(choices=[('mp4v', 'MP4'), ('XVID', 'XVID')], default='mp4v', help_text='Opencv fourcc format', max_length=8, null=True, verbose_name='Fourcc')),
('opencv_video_type', models.CharField(choices=[('mp4', 'MP4'), ('avi', 'AVI')], default='mp4', help_text='Opencv video type', max_length=8, null=True, verbose_name='Video type')),
('grbl_xmax', models.FloatField(default=350.0, help_text='CNC Grbl Xmax en mm', verbose_name='Grbl Xmax')),
('grbl_ymax', models.FloatField(default=250.0, help_text='CNC Grbl Ymax en mm', verbose_name='Grbl Ymax')),
('capture_type', models.CharField(choices=[('rpi', 'Arducam'), ('webcam', 'Webcam'), ('file', 'Simulation Fichier vidéo (mp4, avi)'), ('video', 'Fichier vidéo (mp4, avi)')], default='rpi', help_text='Type de capture. Nécessite un redémarrage en cas de modification à chaud!', max_length=8, null=True, verbose_name='Capture')),
('webcam_device_index', models.PositiveSmallIntegerField(default=2, help_text='Index de la webcam (0, 1, ...) si présente', verbose_name='Index de la webcam')),
('image_quality', models.PositiveSmallIntegerField(default=90, help_text='Qualité JPEG (1-100) pour les images exportées', verbose_name='Qualité JPEG')),
('video_jpeg_quality', models.PositiveSmallIntegerField(default=90, help_text='Qualité JPEG (1-100) pour les images extraites des vidéos', verbose_name='Qualité JPEG pour les vidéos')),
('video_frame_rate', models.FloatField(default=5.0, help_text="Fréquence d'extraction des images des vidéos (images par seconde)", verbose_name='Fréquence vidéos (fps)')),
('video_width_capture', models.PositiveSmallIntegerField(default=1280, help_text='Largeur de capture vidéo en pixels', verbose_name='Largeur de capture vidéo')),
('video_height_capture', models.PositiveSmallIntegerField(default=720, help_text='Hauteur de capture vidéo en pixels', verbose_name='Hauteur de capture vidéo')),
('scan_simulation', models.BooleanField(default=False, help_text='Autorise la simulation du balayage', verbose_name='Simuler balayage')),
('calibration_crop_radius', models.PositiveSmallIntegerField(default=150, help_text='Rayon en pixels pour découper les images de calibration en px', verbose_name='Rayon de découpe pour la calibration')),
('calibration_default_multiwell', models.CharField(choices=[('HG', 'HG-Haut gauche'), ('HD', 'HD-Haut droit'), ('BG', 'BG-Bas gauche'), ('BD', 'BD-Bas droit')], default='HG', help_text='Position du multi-puits de calibration par défaut', max_length=8, verbose_name='Multi-puits de calibration par défaut')),
('calibration_default_feed', models.PositiveIntegerField(default=1000, help_text='Vitesse de déplacement pour la calibration en mm/mn', verbose_name='Vitesse de calibration')),
('calibration_default_step', models.FloatField(default=1.0, help_text='Pas de déplacement pour la calibration en mm', verbose_name='Pas de calibration')),
('calibration_default_duration', models.FloatField(default=3.0, help_text='Durée de pose entre chaque puits en s', verbose_name='Duruée calibration')),
('tracking', models.BooleanField(default=False, help_text='Suivi et analyse des planaires', verbose_name='Suivi')),
('tracking_setting', models.BooleanField(default=False, help_text='Autorise le réglage des valeurs par défaut dans la calibration', verbose_name='Réglage dans calibration')),
('tube_axis', models.CharField(choices=[('vertical', 'Vertical'), ('horizontal', 'Horizontal')], default='vertical', help_text='Axe du tube', max_length=16, null=True, verbose_name='Axe du puit')),
('min_area_px', models.PositiveIntegerField(default=20, help_text="surface minimale d'un contour pour être considéré valide (px²)", verbose_name='Surface minimale')),
('max_area_ratio', models.FloatField(default=0.1, help_text="surface maximale d'un contour en fraction de la frame (défaut 10%)", verbose_name='surface maximale ')),
('max_planarians', models.PositiveIntegerField(default=1, help_text='nombre maximum de planaires à suivre simultanément (1-10)', verbose_name='Max planaire')),
('merge_kernel_size', models.PositiveIntegerField(default=15, help_text='taille du kernel elliptique de fusion des fragments (px). Augmenter si fragments résiduels', verbose_name='Taille du kernel')),
('min_contour_dist_px', models.PositiveIntegerField(default=40, help_text='Distance min entre deux contours pour les considérer comme individus distincts. Défaut : 40px. Augmenter si IDs multiples persistent', verbose_name='Distance <contour>')),
('active', models.BooleanField(default=False, verbose_name='Actif')),
('author', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='Auteur')),
],
options={
'verbose_name': 'Configuration',
'verbose_name_plural': 'Configuration',
'ordering': ['id'],
},
),
migrations.CreateModel(
name='MultiWell',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('label', models.CharField(blank=True, help_text='Label du multi-puit', max_length=100, null=True, verbose_name='Label')),
('position', models.CharField(choices=[('HG', 'HG-Haut gauche'), ('HD', 'HD-Haut droit'), ('BG', 'BG-Bas gauche'), ('BD', 'BD-Bas droit')], help_text='Position du multi-puits sur la table', max_length=8, null=True, unique=True, verbose_name='Position')),
('default', models.BooleanField(default=False, help_text='Multi-puit par défaut', verbose_name='Par défaut')),
('cols', models.PositiveSmallIntegerField(default=6, help_text='Nombre de colonnes', verbose_name='Colonnes')),
('rows', models.PositiveSmallIntegerField(default=4, help_text='Nombre de lignes', verbose_name='Lignes')),
('diameter', models.FloatField(default=16.0, help_text='Diamètre des tubes en mm', verbose_name='Diamètre')),
('row_def', models.CharField(default='A,B,C,D', help_text='Définition des lignes', max_length=16, null=True, verbose_name='Définition')),
('row_order', models.CharField(default='D,C,B,A', help_text='Ordre ligne de puit. Lecture en serpentin dans le sens des +- X', max_length=16, null=True, verbose_name='Ordre ligne')),
('crop_radius', models.PositiveSmallIntegerField(default=500, help_text='Rayon en pixels pour recadrer les images en px', verbose_name='Rayon de découpe recadrage')),
('order', models.PositiveSmallIntegerField(default=0, help_text='Ordre de lecture du multi-puit', verbose_name='Ordre')),
('duration', models.PositiveIntegerField(default=10, help_text='Durée de capture en secondes pour la calibration', verbose_name='Durée')),
('xbase', models.FloatField(default=50.0, help_text='Base origine X en mm', verbose_name='Origine X')),
('ybase', models.FloatField(default=50.0, help_text='Base origine Y en mm', verbose_name='Origine Y')),
('dx', models.FloatField(default=19.5, help_text='Pas ou interval sur X en mm', verbose_name='Pas X')),
('dy', models.FloatField(default=19.5, help_text='Pas ou interval sur Y en mm', verbose_name='Pas Y')),
('feed', models.PositiveIntegerField(default=1000, help_text='Vitesse déplacement en mm/mn ', verbose_name='Vitesse')),
('well_position', models.BooleanField(default=False, help_text='Positions des puits générées ?. Non => efface WellPosition et recalcule les positions', verbose_name='Positions')),
('active', models.BooleanField(default=True, verbose_name='Active')),
('author', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='Auteur')),
],
options={
'verbose_name': 'Multi-puits',
'verbose_name_plural': 'Multi-puits',
'ordering': ['order'],
},
),
migrations.CreateModel(
name='Experiment',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=100, null=True, verbose_name="Titre de l'expérience")),
('comment', models.TextField(blank=True, help_text="Descriptions de l'expérience", null=True, verbose_name='Commentaires')),
('identifier', models.CharField(max_length=100, null=True, unique=True, verbose_name="Identifiant d'expérience")),
('duration', models.PositiveIntegerField(default=120, help_text='Durée de la prise de vue en secondes', verbose_name='Durée')),
('created', models.DateTimeField(default=django.utils.timezone.now, verbose_name='Date de création')),
('started', models.DateTimeField(blank=True, null=True, verbose_name='Date de début')),
('finished', models.DateTimeField(blank=True, null=True, verbose_name='Date de fin')),
('author', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='Auteur')),
('multiwell', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='scanner.multiwell', verbose_name='Multi-puits')),
],
options={
'verbose_name': 'Expérience',
'verbose_name_plural': 'Expériences',
'ordering': ['-created'],
},
),
migrations.CreateModel(
name='Session',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(help_text="Session d'expérience. 4 Multi-puits maximum", max_length=100, null=True, verbose_name='Nom de la session')),
('active', models.BooleanField(default=True, verbose_name='Active')),
('expected_export', models.DateTimeField(blank=True, help_text="Date d'exportation prévue", null=True, verbose_name='Exportation auto')),
('expected_scanning', models.DateTimeField(blank=True, help_text='Date du balayage prévue', null=True, verbose_name='Bbalayage auto')),
('created', models.DateTimeField(default=django.utils.timezone.now, verbose_name='Date de création')),
('finished', models.DateTimeField(blank=True, null=True, verbose_name='Date de fin')),
('export_status', models.CharField(choices=[('pending', 'En attente'), ('running', 'En cours'), ('done', 'Terminé'), ('error', 'Erreur')], default='pending', max_length=16, verbose_name='Status exportation')),
('export_exported_at', models.DateTimeField(blank=True, null=True, verbose_name='Exportation terminée à')),
('scanning_status', models.CharField(choices=[('pending', 'En attente'), ('running', 'En cours'), ('done', 'Terminé'), ('error', 'Erreur')], default='pending', max_length=16, verbose_name='Status scanning')),
('scanning_finished_at', models.DateTimeField(blank=True, null=True, verbose_name='Balayage terminé à')),
('author', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='Auteur')),
('export_task', models.OneToOneField(blank=True, help_text="Programmation de l'exportation des vidéos et images", null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='export_session', to='django_celery_beat.periodictask', verbose_name='Export médias')),
('scanning_task', models.OneToOneField(blank=True, help_text='Programmation du lancement du balayage', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='scanning_session', to='django_celery_beat.periodictask', verbose_name='Lancer le balayage')),
],
options={
'verbose_name': 'Session',
'verbose_name_plural': 'Sessions',
'ordering': ['-created'],
},
),
migrations.CreateModel(
name='Well',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(blank=True, help_text='Nom du puit: Ai..Di', max_length=4, null=True, unique=True, verbose_name='Nom')),
('author', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='Auteur')),
],
options={
'verbose_name': 'Puit',
'verbose_name_plural': 'Puits',
'ordering': ['name'],
},
),
migrations.CreateModel(
name='SessionExperiment',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('author', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='Auteur')),
('experiment', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='session_experiments', to='scanner.experiment', verbose_name='Expérience')),
('session', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='scanner.session', verbose_name='Session')),
],
options={
'verbose_name': "Expérience d'une session",
'verbose_name_plural': "Expériences d'une session",
'ordering': ['session'],
'unique_together': {('session', 'experiment')},
},
),
migrations.CreateModel(
name='ExperimentWell',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('active', models.BooleanField(default=True, verbose_name='Active')),
('author', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='Auteur')),
('experiment', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='experimentwell', to='scanner.experiment', verbose_name='Expérience')),
('well', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='wellexperiment', to='scanner.well', verbose_name='Puit')),
],
options={
'verbose_name': 'Expérience puit',
'verbose_name_plural': 'Expériences puits',
'ordering': ['experiment', 'well'],
'unique_together': {('experiment', 'well')},
},
),
migrations.CreateModel(
name='WellPosition',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('order', models.PositiveSmallIntegerField(default=0, help_text='Ordre de lecture du puit', verbose_name='Ordre')),
('x', models.FloatField(default=10.0, help_text='Axe X en mm', verbose_name='X')),
('y', models.FloatField(default=10.0, help_text='Axe Y en mm', verbose_name='Y')),
('px_per_mm', models.FloatField(default=50.0, help_text='Facteur de calibration optique', verbose_name='Pixels par mm')),
('author', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='Auteur')),
('multiwell', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='scanner.multiwell', verbose_name='Multi-puits')),
('well', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='scanner.well', verbose_name='Puit')),
],
options={
'verbose_name': 'Position du puit',
'verbose_name_plural': 'Position des puits',
'ordering': ['order'],
'unique_together': {('multiwell', 'well')},
},
),
]
@@ -0,0 +1,17 @@
# Generated by Django 6.0.5 on 2026-05-31 07:43
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('scanner', '0001_initial'),
]
operations = [
migrations.RemoveField(
model_name='multiwell',
name='crop_radius',
),
]
@@ -0,0 +1,18 @@
# Generated by Django 6.0.5 on 2026-05-31 07:43
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('scanner', '0002_remove_multiwell_crop_radius'),
]
operations = [
migrations.AddField(
model_name='multiwell',
name='crop_radius',
field=models.PositiveSmallIntegerField(default=500, help_text='Rayon en pixels pour recadrer les images en px', verbose_name='Rayon de découpe recadrage'),
),
]
@@ -0,0 +1,44 @@
# Generated by Django 6.0.5 on 2026-05-31 11:01
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('scanner', '0003_multiwell_crop_radius'),
]
operations = [
migrations.AlterField(
model_name='configuration',
name='calibration_default_multiwell',
field=models.CharField(choices=[('HG', 'MP 6x24: HG-Haut gauche'), ('HD', 'MP 6x24: HD-Haut droit'), ('BG', 'MP 6x24: BG-Bas gauche'), ('BD', 'MP 6x24: BD-Bas droit'), ('HG_6', 'MP 2x3: HG-Haut gauche'), ('HD_6', 'MP 2x3: HD-Haut droit'), ('BG_6', 'MP 2x3: BG-Bas gauche'), ('BD_6', 'MP 2x3: BD-Bas droit'), ('HG_12', 'MP 3x4: HG-Haut gauche'), ('HD_12', 'MP 3x4: HD-Haut droit'), ('BG_12', 'MP 3x4: BG-Bas gauche'), ('BD_12', 'MP 3x4: BD-Bas droit'), ('HG_48', 'MP 6x8: HG-Haut gauche'), ('HD_48', 'MP 6x8: HD-Haut droit'), ('BG_48', 'MP 6x8: BG-Bas gauche'), ('BD_48', 'MP 6x8: BD-Bas droit')], default='HG', help_text='Position du multi-puits de calibration par défaut', max_length=8, verbose_name='Multi-puits de calibration par défaut'),
),
migrations.AlterField(
model_name='multiwell',
name='position',
field=models.CharField(choices=[('HG', 'MP 6x24: HG-Haut gauche'), ('HD', 'MP 6x24: HD-Haut droit'), ('BG', 'MP 6x24: BG-Bas gauche'), ('BD', 'MP 6x24: BD-Bas droit'), ('HG_6', 'MP 2x3: HG-Haut gauche'), ('HD_6', 'MP 2x3: HD-Haut droit'), ('BG_6', 'MP 2x3: BG-Bas gauche'), ('BD_6', 'MP 2x3: BD-Bas droit'), ('HG_12', 'MP 3x4: HG-Haut gauche'), ('HD_12', 'MP 3x4: HD-Haut droit'), ('BG_12', 'MP 3x4: BG-Bas gauche'), ('BD_12', 'MP 3x4: BD-Bas droit'), ('HG_48', 'MP 6x8: HG-Haut gauche'), ('HD_48', 'MP 6x8: HD-Haut droit'), ('BG_48', 'MP 6x8: BG-Bas gauche'), ('BD_48', 'MP 6x8: BD-Bas droit')], help_text='Position du multi-puits sur la table', max_length=8, null=True, unique=True, verbose_name='Position'),
),
migrations.CreateModel(
name='VideoPlate',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('label', models.CharField(blank=True, max_length=200, verbose_name='Label')),
('video_file', models.FileField(blank=True, null=True, upload_to='videos/', verbose_name='Fichier vidéo')),
('active', models.BooleanField(default=True, verbose_name='Active')),
('uploaded_at', models.DateTimeField(auto_now_add=True, verbose_name='Déposé le')),
('native_fps', models.FloatField(blank=True, null=True, verbose_name='FPS natif')),
('duration_s', models.FloatField(blank=True, null=True, verbose_name='Durée (s)')),
('frame_w', models.PositiveIntegerField(blank=True, null=True, verbose_name='Largeur (px)')),
('frame_h', models.PositiveIntegerField(blank=True, null=True, verbose_name='Hauteur (px)')),
('multiwell', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='video_plates', to='scanner.multiwell', verbose_name='Multi-puits')),
],
options={
'verbose_name': 'Vidéo plaque',
'verbose_name_plural': 'Vidéos plaque',
'ordering': ['multiwell__order', '-uploaded_at'],
},
),
]
@@ -0,0 +1,22 @@
# Generated by Django 6.0.5 on 2026-06-02 08:25
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('scanner', '0004_add_videoplate'),
]
operations = [
migrations.AlterModelOptions(
name='multiwell',
options={'ordering': ['label', 'order'], 'verbose_name': 'Multi-puits', 'verbose_name_plural': 'Multi-puits'},
),
migrations.AddField(
model_name='multiwell',
name='capture_video',
field=models.BooleanField(default=False, help_text='Ce multi-puit servira pour la capture vidéo', verbose_name='Capture vidéo'),
),
]
@@ -0,0 +1,23 @@
# Generated by Django 6.0.5 on 2026-06-02 08:28
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('scanner', '0005_alter_multiwell_options_multiwell_capture_video'),
]
operations = [
migrations.AlterField(
model_name='multiwell',
name='capture_video',
field=models.BooleanField(default=False, help_text='Ce multi-puit servira pour la capture vidéo', verbose_name='Vidéo'),
),
migrations.AlterField(
model_name='multiwell',
name='default',
field=models.BooleanField(default=False, help_text='Multi-puit par défaut', verbose_name='Défaut'),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 6.0.5 on 2026-06-02 09:51
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('scanner', '0006_alter_multiwell_capture_video_and_more'),
]
operations = [
migrations.AddField(
model_name='videoplate',
name='px_per_mm',
field=models.FloatField(default=15.0, help_text='Facteur pixels/mm dans la vidéo plaque. À calibrer selon la résolution de la caméra plaque.', verbose_name='Pixels par mm (vidéo plaque)'),
),
]
@@ -0,0 +1,29 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('scanner', '0007_add_videoplate_px_per_mm'),
]
operations = [
migrations.AddField(
model_name='videoplate',
name='x_origin_mm',
field=models.FloatField(
default=0.0,
verbose_name='Origine X (mm)',
help_text='Position CNC X correspondant au pixel 0 de la vidéo plaque (mm). Défaut 0.',
),
),
migrations.AddField(
model_name='videoplate',
name='y_origin_mm',
field=models.FloatField(
default=0.0,
verbose_name='Origine Y (mm)',
help_text='Position CNC Y correspondant au pixel 0 de la vidéo plaque (mm). Défaut 0.',
),
),
]
@@ -0,0 +1,23 @@
# Generated by Django 6.0.5 on 2026-06-03 09:13
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('scanner', '0008_add_videoplate_origin'),
]
operations = [
migrations.AlterField(
model_name='videoplate',
name='x_origin_mm',
field=models.FloatField(default=0.0, help_text='Position CNC X correspondant au bord gauche de la vidéo plaque (mm). Défaut 0.', verbose_name='Origine X (mm)'),
),
migrations.AlterField(
model_name='videoplate',
name='y_origin_mm',
field=models.FloatField(default=0.0, help_text='Position CNC Y correspondant au bord haut de la vidéo plaque (mm). Défaut 0.', verbose_name='Origine Y (mm)'),
),
]
+129 -15
View File
@@ -4,6 +4,7 @@
from django.utils.translation import gettext_lazy as _
import uuid
import json
from pathlib import Path
from django_celery_beat.models import PeriodicTask, ClockedSchedule
from django.dispatch import receiver
from django.db.models.signals import post_save, post_delete
@@ -13,10 +14,22 @@ from django.contrib.auth.models import User
MULTIWELL_POSITION = [
('HG', _("HG-Haut gauche")),
('HD', _("HD-Haut droit")),
('BG', _("BG-Bas gauche")),
('BD', _("BD-Bas droit")),
('HG', _("MP 6x24: HG-Haut gauche")),
('HD', _("MP 6x24: HD-Haut droit")),
('BG', _("MP 6x24: BG-Bas gauche")),
('BD', _("MP 6x24: BD-Bas droit")),
('HG_6', _("MP 2x3: HG-Haut gauche")),
('HD_6', _("MP 2x3: HD-Haut droit")),
('BG_6', _("MP 2x3: BG-Bas gauche")),
('BD_6', _("MP 2x3: BD-Bas droit")),
('HG_12', _("MP 3x4: HG-Haut gauche")),
('HD_12', _("MP 3x4: HD-Haut droit")),
('BG_12', _("MP 3x4: BG-Bas gauche")),
('BD_12', _("MP 3x4: BD-Bas droit")),
('HG_48', _("MP 6x8: HG-Haut gauche")),
('HD_48', _("MP 6x8: HD-Haut droit")),
('BG_48', _("MP 6x8: BG-Bas gauche")),
('BD_48', _("MP 6x8: BD-Bas droit")),
]
FOURCC_FORMAT = [
@@ -32,7 +45,8 @@ VIDEO_TYPE = [
CAPTURE_TYPE = [
('rpi', _("Arducam")),
('webcam', _("Webcam")),
('file', _("Fichier vidéo (mp4, avi)")),
('file', _("Simulation Fichier vidéo (mp4, avi)")),
('video', _("Fichier vidéo (mp4, avi)")),
]
TUBE_AXIS_TYPE = [
@@ -81,7 +95,6 @@ class Configuration(models.Model):
#
active = models.BooleanField(_("Actif"), default=False)
@classmethod
def active_config(cls):
return Configuration.objects.filter(active=True).first()
@@ -102,7 +115,6 @@ class Well(models.Model):
ordering = ['name', ]
verbose_name = _("Puit")
verbose_name_plural = _("Puits")
def __str__(self):
return f'{self.name}'
@@ -113,13 +125,15 @@ class MultiWell(models.Model):
label = models.CharField(_("Label"), help_text=_("Label du multi-puit"), max_length=100, null=True, blank=True)
author = models.ForeignKey(User, on_delete=models.SET_NULL, verbose_name="Auteur", null=True, blank=True)
position = models.CharField(_("Position"), help_text=_('Position du multi-puits sur la table'), unique=True, max_length=8, choices=MULTIWELL_POSITION, null=True, blank=False)
default = models.BooleanField(_("Par défaut"), help_text=_('Multi-puit par défaut'), default=False)
default = models.BooleanField(_("Défaut"), help_text=_('Multi-puit par défaut'), default=False)
# Configuration
cols = models.PositiveSmallIntegerField(_("Colonnes"), help_text=_('Nombre de colonnes'), blank=False, default=6)
rows = models.PositiveSmallIntegerField(_("Lignes"), help_text=_('Nombre de lignes'), blank=False, default=4)
diameter = models.FloatField(_("Diamètre"), help_text=_('Diamètre des tubes en mm'), blank=False, default=16.0)
row_def = models.CharField(_("Définition"), help_text=_('Définition des lignes'), max_length=16, null=True, blank=False, default="A,B,C,D")
row_order = models.CharField(_("Ordre ligne"), help_text=_('Ordre ligne de puit. Lecture en serpentin dans le sens des +- X'), max_length=16, null=True, blank=False, default="D,C,B,A")
crop_radius = models.PositiveSmallIntegerField(_("Rayon de découpe recadrage"), help_text=_("Rayon en pixels pour recadrer les images en px"), blank=False, default=500)
# Balayage
order = models.PositiveSmallIntegerField(_("Ordre"), help_text=_('Ordre de lecture du multi-puit'), blank=False, default=0)
duration = models.PositiveIntegerField(_("Durée"), help_text=_('Durée de capture en secondes pour la calibration'), blank=False, default=10)
@@ -129,11 +143,11 @@ class MultiWell(models.Model):
dx = models.FloatField(_("Pas X"), help_text=_('Pas ou interval sur X en mm'), blank=False, default=19.5)
dy = models.FloatField(_("Pas Y"), help_text=_('Pas ou interval sur Y en mm'), blank=False, default=19.5)
feed = models.PositiveIntegerField(_("Vitesse"), help_text=_('Vitesse déplacement en mm/mn '), blank=False, default=1000)
well_position = models.BooleanField(_("Positions"), help_text=_('Positions des puits générées ?. Non => efface WellPosition et recalcule les positions'), default=False)
capture_video = models.BooleanField(_("Vidéo"), help_text=_('Ce multi-puit servira pour la capture vidéo'), default=False)
active = models.BooleanField(_("Active"), default=True)
def config(self):
return dict(
position=self.position,
@@ -166,7 +180,7 @@ class MultiWell(models.Model):
return MultiWell.objects.filter(active=True).all()
class Meta:
ordering = ['order', ]
ordering = ['label', 'order', ]
verbose_name = _("Multi-puits")
verbose_name_plural = _("Multi-puits")
@@ -206,8 +220,8 @@ class WellPosition(models.Model):
@receiver(post_save, sender=MultiWell)
def create_well_position(sender, instance, created, **kwargs):
if created:
pass
#if created:
# pass
if not instance.well_position:
row_order = instance.row_order.split(',')
n = 0
@@ -302,7 +316,7 @@ class Session(models.Model):
@classmethod
def get_session(self, sid):
def get_session(cls, sid):
return Session.objects.filter(pk=sid).first()
@@ -313,7 +327,7 @@ class Session(models.Model):
def __str__(self):
state = _("Terminée") if not self.active else _("Active")
return f'[ {self.id} ] {self.name} ({state})'
return f'[ {self.pk} ] {self.name} ({state})'
@receiver(post_save, sender=Session)
@@ -444,6 +458,106 @@ class ExperimentWell(models.Model):
def __str__(self):
return f'{self.experiment.title}'
class VideoPlate(models.Model):
"""Vidéo d'une plaque multi-puits entière, utilisée en mode capture_type='video'."""
multiwell = models.ForeignKey(
MultiWell,
on_delete=models.CASCADE,
verbose_name=_("Multi-puits"),
related_name='video_plates',
)
label = models.CharField(_("Label"), max_length=200, blank=True)
video_file = models.FileField(
_("Fichier vidéo"),
upload_to='videos/',
null=True,
blank=True,
)
active = models.BooleanField(_("Active"), default=True)
uploaded_at = models.DateTimeField(_("Déposé le"), auto_now_add=True)
# Calibration vidéo plaque : pixels par mm dans la vidéo (≠ calibration caméra individuelle)
px_per_mm = models.FloatField(
_("Pixels par mm (vidéo plaque)"),
default=15.0,
help_text=_("Facteur pixels/mm dans la vidéo plaque. À calibrer selon la résolution de la caméra plaque."),
)
# Origine : position CNC (mm) correspondant au pixel (0, 0) de la vidéo.
# Indépendant de MultiWell.xbase — ne change pas à la recalibration des puits.
x_origin_mm = models.FloatField(
_("Origine X (mm)"),
default=0.0,
help_text=_("Position CNC X correspondant au bord gauche de la vidéo plaque (mm). Défaut 0."),
)
y_origin_mm = models.FloatField(
_("Origine Y (mm)"),
default=0.0,
help_text=_("Position CNC Y correspondant au bord haut de la vidéo plaque (mm). Défaut 0."),
)
# Métadonnées extraites automatiquement à l'upload
native_fps = models.FloatField(_("FPS natif"), null=True, blank=True)
duration_s = models.FloatField(_("Durée (s)"), null=True, blank=True)
frame_w = models.PositiveIntegerField(_("Largeur (px)"), null=True, blank=True)
frame_h = models.PositiveIntegerField(_("Hauteur (px)"), null=True, blank=True)
@classmethod
def active_for(cls, multiwell_position: str) -> "VideoPlate | None":
return cls.objects.filter(multiwell__position=multiwell_position, active=True).first()
@classmethod
def active_video(cls) -> "VideoPlate | None":
return cls.objects.filter(active=True).first()
@property
def video_filename(self) -> str:
return Path(self.video_file.name).name if self.video_file else ""
@property
def resolution(self) -> str:
if self.frame_w and self.frame_h:
return f"{self.frame_w}×{self.frame_h}"
return ""
class Meta:
ordering = ['multiwell__order', '-uploaded_at']
verbose_name = _("Vidéo plaque")
verbose_name_plural = _("Vidéos plaque")
def __str__(self) -> str:
return f"{self.multiwell.position}{self.label or self.video_filename}"
@receiver(post_save, sender=VideoPlate)
def notify_video_plate_change(sender, instance, **kwargs):
"""Hot swap : publie sur Redis quand une vidéo active est enregistrée."""
if not instance.active or not instance.video_file:
return
try:
from redis import Redis
from django.conf import settings as django_settings
r = Redis(
host=django_settings.REDIS_HOST,
port=django_settings.REDIS_PORT,
db=0,
decode_responses=True,
)
r.publish('scanner_proc', json.dumps({
'type': 'scanner',
'topic': 'video_plate',
'multiwell': instance.multiwell.position,
'path': instance.video_file.path,
}))
except Exception:
pass
@receiver(post_delete, sender=VideoPlate)
def delete_video_file(sender, instance, **kwargs):
"""Supprime le fichier physique quand l'enregistrement est effacé."""
if instance.video_file:
path = Path(instance.video_file.path)
path.unlink(missing_ok=True)
@receiver(post_save, sender=Experiment)
def create_experiment_well(sender, instance, created, **kwargs):
from planarian.models import ExperimentConfig
+75 -41
View File
@@ -14,7 +14,7 @@ import time
from threading import Thread, Event
#from django.utils.translation import gettext_lazy as _
from django.utils import timezone
from django.utils.html import mark_safe
from django.utils.safestring import mark_safe
from django.conf import settings
from planarian.models import ExperimentConfig
from . import models
@@ -115,42 +115,56 @@ class MultiWellManager:
self._duration = duration or self.process.conf.calibration_default_duration
self.px_per_mm = 50.0
def init_manager_values(self):
wells = models.WellPosition.objects.filter(multiwell_id=self.multiwell.pk).order_by('order').all() # type: ignore[union-attr]
self.well_iterator = WellIterator(wells)
self.position = self.multiwell.position
self._xbase = self.multiwell.xbase
self._ybase = self.multiwell.ybase
self._dx = self.multiwell.dx
self._dy = self.multiwell.dy
def set_multiwell(self, position=None):
if position is None:
self.multiwell = models.MultiWell.objects.filter(default=True).first()
else:
self.multiwell = models.MultiWell.by_position(position)
wells = models.WellPosition.objects.filter(multiwell_id=self.multiwell.id).order_by('order').all()
self.well_iterator = WellIterator(wells)
self.position = self.multiwell.position
self._xbase = self.multiwell.xbase
self._ybase = self.multiwell.ybase
self._dx = self.multiwell.dx
self._dy = self.multiwell.dy
self.init_manager_values()
return self.multiwell.config()
def set_first_multiwell_from_session(self, sid):
experiments = models.SessionExperiment.experiment_by_session(sid)
if experiments:
self.multiwell = experiments[0].multiwell
self.init_manager_values()
def multiwell_buttons(self, btn_class="w3-button", onclick=''' onclick="goto_well(this)"'''):
multiwells = []
multiwells.append('''<div class="w3-border well-btn">''')
for wl in self.well_iterator:
for wl in self.well_iterator:
multiwells.append(f"""<button class="{btn_class} well" value="{wl.order}"{onclick}>{wl.well.name}</button>""")
multiwells.append('''</div>''')
self.well_iterator.reset()
return mark_safe("\n".join(multiwells))
#def _grid_scanning_capture(self, uuid, duration):
def set_circular_crop(self, crop_radius):
crop = self.process.set_crop_radius(crop_radius)
self.process.cam.set_circular_crop(crop)
def update_crop_radius(self, value):
self.multiwell.crop_radius = value
self.multiwell.save()
def _grid_scanning_capture(self, experiment, well_position, simulate=False):
uuid = None
try:
well = well_position.well
multiwell = experiment.multiwell
# En mode video le crop_radius est piloté par _capture_video_simulation
if self.process.conf.capture_type != 'video':
self.set_circular_crop(multiwell.crop_radius)
## create uuid for this capture
uuid = f'{self.process.data.session}-{multiwell.position}-{well.name}'
@@ -198,8 +212,17 @@ class MultiWellManager:
vf = settings.MEDIA_ROOT / 'simulation' / f'{name}.mp4'
if vf.exists():
self.process.cam._video_file = str(vf)
self.process.cam._error_occured = True
logger.info(f"Simulating capture with file {vf}")
self.process.cam._error_occured = True
logger.info(f"Simulating capture with file {vf}")
def _capture_video_simulation(self, well_position):
"""Met à jour VideoPlateCapture : crop_radius depuis MultiWell.crop_radius."""
cam = self.process.cam
r = well_position.multiwell.crop_radius
if hasattr(cam, 'set_crop_radius_px'):
cam.set_crop_radius_px(r)
self.set_circular_crop(r)
logger.info(f"video_simulation: {well_position.well.name} crop_r={r}px")
def _is_well_valid(self, welposition, experiment):
@@ -222,10 +245,12 @@ class MultiWellManager:
if not self._is_well_valid(wl, experiment):
continue
self.cnc_controller.move_to(wl.x, wl.y, feed=wl.multiwell.feed)
self.cnc_controller.move_to(wl.x, wl.y, feed=wl.multiwell.feed)
if self.process.conf.capture_type == 'file':
self._capture_file_simulation(wl.well.name)
self._capture_file_simulation(wl.well.name)
elif self.process.conf.capture_type == 'video':
self._capture_video_simulation(wl)
self._grid_scanning_capture(experiment, wl, simulate=simulate)
msg =f"Scan terminé — retour à l'origine (X={xnext:.1f} Y={ynext:.1f})"
logger.info(msg)
@@ -237,7 +262,7 @@ class MultiWellManager:
self.process._send(state='error', msg=msg)
return False
finally:
self.cnc_controller.move_to(xnext, ynext, feed=multiwell.feed*2)
self.cnc_controller.move_to(xnext, ynext, feed=self.feed*2)
def _start_scanning(self, session, experiments, simulate=False):
@@ -315,38 +340,46 @@ class MultiWellManager:
wl = self.well_iterator.previous()
if self.process.conf.capture_type == 'file':
self._capture_file_simulation(wl.well.name)
elif self.process.conf.capture_type == 'video':
self._capture_video_simulation(wl)
self.cnc_controller.move_to(wl.x, wl.y, feed=wl.multiwell.feed)
return {"state": "previous", "msg": f">>> {wl.well.name}: ({wl.x}, {wl.y})"}
return {"state": "previous", "msg": f">>> {wl.well.name}: ({wl.x}, {wl.y})"}
def next_well(self):
wl = self.well_iterator.next()
if self.process.conf.capture_type == 'file':
self._capture_file_simulation(wl.well.name)
elif self.process.conf.capture_type == 'video':
self._capture_video_simulation(wl)
self.cnc_controller.move_to(wl.x, wl.y, feed=wl.multiwell.feed)
return {"state": "next", "msg": f">>> {wl.well.name}: ({wl.x}, {wl.y})"}
return {"state": "next", "msg": f">>> {wl.well.name}: ({wl.x}, {wl.y})"}
def goto_well(self, numwell):
wl = self.well_iterator.seek(numwell)
if self.process.conf.capture_type == 'file':
self._capture_file_simulation(wl.well.name)
self._capture_file_simulation(wl.well.name)
elif self.process.conf.capture_type == 'video':
self._capture_video_simulation(wl)
self.cnc_controller.move_to(wl.x, wl.y, feed=wl.multiwell.feed)
return {"state": "goto", "msg": f">>> {wl.well.name}: ({wl.x}, {wl.y})"}
def goto_xy(self):
wl = self.well_iterator.seek(0)
if self.process.conf.capture_type == 'file':
self._capture_file_simulation(wl.well.name)
self._capture_file_simulation(wl.well.name)
elif self.process.conf.capture_type == 'video':
self._capture_video_simulation(wl)
self.cnc_controller.move_to(self.xbase, self.ybase, feed=self.feed)
def goto_0(self):
self.well_iterator.reset()
if self.process.conf.capture_type == 'file':
self._capture_file_simulation('zero')
self._capture_file_simulation('zero')
elif self.process.conf.capture_type == 'video':
# Plein cadre : désactiver le masque circulaire pour ne pas rogner la vue d'ensemble
self.process.cam.set_circular_crop(None)
self.cnc_controller.move_to(0, 0, feed=self.feed*2)
def set_well_position(self):
@@ -364,18 +397,19 @@ class MultiWellManager:
self.stop_playing.clear()
cam = self.process.cam
cam._aligner.set_tube_diameter(self.multiwell.diameter)
duration = self.duration if not auto else settings.CALIBRATION_AUTO_DURATION
duration = self.duration if not auto else settings.CALIBRATION_AUTO_DURATION
start_test = time.monotonic()
try:
start_test = time.monotonic()
for wl in self.well_iterator:
if self.stop_playing.is_set():
break
self.cnc_controller.wait_for(2.0)
self.cnc_controller.move_to(wl.x, wl.y, feed=wl.multiwell.feed)
## change file
if self.process.conf.capture_type == 'file':
self._capture_file_simulation(wl.well.name)
self._capture_file_simulation(wl.well.name)
elif self.process.conf.capture_type == 'video':
self._capture_video_simulation(wl)
self.process._send(current=wl.order)
start = time.monotonic()
@@ -497,7 +531,7 @@ class MultiWellManager:
if wl:
return wl.order
return None
def set_position(self):
x, y = self.cnc_controller.get_mpos()
self.cnc_controller.wait_for(2.0)
+113 -31
View File
@@ -27,6 +27,7 @@ from modules import reductstore, utils, planarian_metrics
from modules.circular_crop import CircularCrop, CropStrategy
from .multiwell import MultiWellManager
from .constants import ScannerConstants
from .models import MultiWell
# CNC
if not settings.GRBL_SIMULATION:
@@ -38,7 +39,7 @@ else:
class ProcessData:
play: bool = True
record: bool = False
uuid: str = None
uuid: str | None = None
session: int = 0
tube_diameter: float = 16.0
@@ -173,27 +174,31 @@ class ScannerProcess(Task):
Constants:
reset si besoin les constantes vidéo
'''
self.conf = ScannerConstants().get()
self.constants = ScannerConstants()
self.conf = self.constants.get()
self.use_tracking = self.conf.tracking
self.video_quality = self.conf.video_jpeg_quality
self.image_quality = self.conf.image_quality
self.video_fps = self.conf.video_frame_rate
self.video_width = self.conf.video_width_capture
self.video_height = self.conf.video_height_capture
self.video_height = self.conf.video_height_capture
self.crop_radius = self.conf.calibration_crop_radius
self.video_jpg_quality = [int(cv2.IMWRITE_JPEG_QUALITY), self.video_quality]
self.image_jpg_quality = [int(cv2.IMWRITE_JPEG_QUALITY), self.image_quality]
return self.conf
def save_config(self):
pass
def start(self, *args, **kwargs):
try:
self.get_config()
self.grbl_xmax = self.conf.grbl_xmax
self.grbl_ymax = self.conf.grbl_ymax
self.grbl_ymax = self.conf.grbl_ymax
self.crop = self.set_crop_radius(self.crop_radius)
capture_type = self.conf.capture_type
@@ -208,7 +213,30 @@ class ScannerProcess(Task):
use_tracking=self.use_tracking,
display=self._display,
parent=self,
)
)
elif capture_type == 'video':
from modules.videoplate_capture import VideoPlateCapture
from .models import VideoPlate
vp = VideoPlate.active_video()
if not vp:
raise Exception("Aucun VideoPlate actif trouvé — créer un enregistrement dans l'admin.")
initial_path = vp.video_file.path if vp.video_file else None
mw = vp.multiwell
self.cam = VideoPlateCapture(
video_dir=settings.MEDIA_ROOT / 'videos',
fps=self.video_fps,
jpeg_quality=self.video_quality,
use_tracking=self.use_tracking,
display=self._display,
parent=self,
crop_radius_px=mw.crop_radius,
px_per_mm=vp.px_per_mm,
x_offset_mm=vp.x_origin_mm,
y_offset_mm=vp.y_origin_mm,
initial_video_path=initial_path,
)
elif capture_type == 'webcam':
from modules.webcam_capture import WebcamCapture
self.cam = WebcamCapture(
@@ -237,9 +265,14 @@ class ScannerProcess(Task):
self.cam._active_median = False
self.cam.set_circular_crop(None)
self.grbl = GRBLController(
send_callback=self._display,
x_max=self.conf.grbl_xmax,
# Mode vidéo : toujours simuler le GRBL (pas de CNC physique)
if capture_type == 'video':
from modules.grbl_simulator import GRBLController as _GRBLCtrl
else:
_GRBLCtrl = GRBLController
self.grbl = _GRBLCtrl(
send_callback=self._display,
x_max=self.conf.grbl_xmax,
y_max=self.conf.grbl_ymax
)
@@ -354,11 +387,10 @@ class ScannerProcess(Task):
def _listen_to_redis(self):
logger.info(f"==== Scanner {self.group}: listen via redisDB")
pubsub = redisDB.pubsub()
pubsub.subscribe(self.group)
try:
logger.info(f"==== Scanner {self.group}: listen via redisDB")
pubsub = redisDB.pubsub()
pubsub.subscribe(self.group)
for message in pubsub.listen():
try:
#logger.info(f"{message}")
@@ -371,18 +403,32 @@ class ScannerProcess(Task):
if not isinstance(cmd, dict):
continue
self._send(state=cmd["type"], msg=f"Cmd: {cmd.get('topic')} {cmd.get('value', '')}")
self._send(state=cmd["type"], msg=f"Cmd: {cmd.get('topic')} {cmd.get('value', '')}")
ctx = {}
if cmd["type"]=="scanner":
topic = cmd.get("topic")
if topic == 'init':
self.cam.set_circular_crop(self.crop)
sid = cmd.get("sid")
self.manager.set_first_multiwell_from_session(sid)
self.cam._active_median = False
self.cam.set_edge_enhance(False)
if self.conf.capture_type == 'video':
self.cam._active_crop = False
self.cam.set_circular_crop(None)
ctx = dict(state="crop", value=self.cam._active_crop)
else:
self.cam.set_circular_crop(self.crop)
self.grbl.go_origin(feed=self.manager.feed)
self.cam.set_draw_contours(False)
buttons = self.manager.multiwell_buttons(btn_class="w3-btn well", onclick="")
self._send(buttons=buttons)
elif topic == 'scan' or topic == 'simulate':
elif topic == 'video_plate':
new_path = cmd.get('path')
if new_path and hasattr(self.cam, 'set_video_file'):
self.cam.set_video_file(new_path)
self._send(state='video_plate', msg=f"Vidéo: {new_path}")
continue
elif topic == 'scan' or topic == 'simulate':
logger.info(f"==== Scan {cmd}")
sid = cmd.get("session", '0')
@@ -391,6 +437,7 @@ class ScannerProcess(Task):
else:
try:
self.cam._active_median = False
self.cam.set_edge_enhance(False)
simulate = (topic=='simulate')
self.manager.scan_process(sid, simulate)
@@ -401,8 +448,10 @@ class ScannerProcess(Task):
continue
self._send(
buttons=buttons,
buttons=self.manager.multiwell_buttons(btn_class="w3-btn well", onclick=""),
columns=self.manager.multiwell.cols,
current=self.manager.get_well_order(),
**ctx
)
elif cmd["type"]=="calibrate":
topic = cmd.get("topic")
@@ -417,8 +466,9 @@ class ScannerProcess(Task):
position = cmd.get("position")
self.manager.set_multiwell(position)
self.cam.set_circular_crop(None)
self.cam._active_median = False
self.cam.set_draw_contours(False)
self.cam._active_median = False
self.cam.set_edge_enhance(False)
self.cam.set_draw_contours(False)
buttons = self.manager.multiwell_buttons()
elif topic == 'up':
@@ -437,18 +487,32 @@ class ScannerProcess(Task):
self.cam._active_median = not self.cam._active_median
self._send(state="median", value=self.cam._active_median, msg=f"Median: {self.cam._active_median}")
continue
elif topic == 'edge_enhance':
self.cam.set_edge_enhance(not self.cam._active_edge_enhance)
continue
elif topic == 'crop':
self.cam._active_crop = not self.cam._active_crop
self.cam.set_circular_crop(self.crop) if self.cam._active_crop else self.cam.set_circular_crop(None)
if self.cam._active_crop:
self.cam.set_circular_crop(self.crop)
# En mode vidéo, naviguer vers le premier puit (Base)
# pour que le crop circulaire soit aligné sur un puit réel
if self.conf.capture_type == 'video':
self.manager.goto_xy()
else:
self.cam.set_circular_crop(None)
self._send(state="crop", value=self.cam._active_crop, msg=f"Crop: {self.cam._active_crop}")
continue
elif topic == 'crop_radius':
self.conf.calibration_crop_radius=int(value)
self.crop = self.set_crop_radius(self.conf.calibration_crop_radius)
self.conf.save()
self.constants.save_config() # type: ignore[attr-defined]
self.cam.set_circular_crop(self.crop)
self.manager.update_crop_radius(int(value))
continue
elif topic == 'position':
@@ -480,6 +544,12 @@ class ScannerProcess(Task):
elif topic == 'auto':
self.manager.set_calib_debug(True)
self.cam.set_circular_crop(self.crop)
# En mode vidéo le puit remplit le crop (ratio ~0.50) ;
# en mode caméra le tube occupe ~30% du champ.
if self.conf.capture_type == 'video':
self.cam._aligner.set_radius_range(0.38, 0.47)
else:
self.cam._aligner.set_radius_range(0.26, 0.37)
self.manager.scan_test(auto=True)
continue
@@ -495,8 +565,19 @@ class ScannerProcess(Task):
self.manager.halt_scanning()
elif topic == 'calib_debug':
if self.conf.capture_type == 'video':
self.cam._aligner.set_radius_range(0.38, 0.47)
else:
self.cam._aligner.set_radius_range(0.26, 0.37)
msg = self.manager.calib_toggle_debug()
self._send(**msg)
self._send(**msg)
continue
elif topic == 'draw_debug':
a = self.cam._aligner
a.draw_annotations = not a.draw_annotations
self._send(state='draw_debug', value=a.draw_annotations,
msg=f"Draw debug: {a.draw_annotations}")
continue
elif topic == 'previous':
@@ -536,6 +617,7 @@ class ScannerProcess(Task):
dx=self.manager.dx,
dy=self.manager.dy,
buttons=buttons,
columns=self.manager.multiwell.cols,
current=self.manager.get_well_order(),
)
except Exception as e:
@@ -697,12 +779,11 @@ class ReplayProcess(Task):
logger.info(f"==== ReplayProcess stopped.")
def _listen_to_redis(self):
loop = None
logger.info(f"==== ReplayProcess {self.group}: listen via redisDB")
pubsub = redisDB.pubsub()
pubsub.subscribe(self.group)
try:
loop = None
logger.info(f"==== ReplayProcess {self.group}: listen via redisDB")
pubsub = redisDB.pubsub()
pubsub.subscribe(self.group)
for message in pubsub.listen():
try:
if self.stop_event.is_set():
@@ -754,7 +835,8 @@ class ReplayProcess(Task):
logger.error(f'ReplayProcess::listen_to_redis: {e}')
finally:
self.running.set()
utils.stop_async(loop)
if loop:
utils.stop_async(loop)
pubsub.unsubscribe()
pubsub.close()
@@ -5,7 +5,7 @@
.well-btn {
display: grid;
grid-template-columns: repeat(6, 1fr);
grid-template-columns: repeat(var(--well-columns, 6), 1fr);
justify-items: center;
align-items: center;
}
@@ -5,7 +5,7 @@
.well-btn {
display: grid;
grid-template-columns: repeat(6, 1fr);
grid-template-columns: repeat(var(--well-columns, 6), 1fr);
justify-items: center;
align-items: center;
}
@@ -0,0 +1,72 @@
.video-drop-zone {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 80px;
margin-top: 8px;
padding: 12px 20px;
border: 2px dashed #aaa;
border-radius: 6px;
background: #f9f9f9;
cursor: pointer;
transition: border-color 0.2s, background 0.2s;
gap: 8px;
}
.video-drop-zone:hover,
.video-drop-zone.drag-over {
border-color: #417690;
background: #e8f3fb;
}
.video-drop-zone.has-file {
border-style: solid;
border-color: #28a745;
background: #f0fff4;
}
.video-drop-hint {
font-size: 0.9em;
color: #555;
pointer-events: none;
text-align: center;
}
.video-drop-zone.has-file .video-drop-hint {
color: #28a745;
font-weight: 600;
}
/* ---- Progress bar ---- */
.video-progress-wrap {
display: none;
width: 100%;
max-width: 360px;
}
.video-progress-bar-track {
width: 100%;
height: 10px;
background: #ddd;
border-radius: 5px;
overflow: hidden;
margin-bottom: 2px;
}
.video-progress-bar {
height: 10px;
width: 0%;
background: #417690;
border-radius: 5px;
transition: width 0.3s ease;
}
.video-progress-label {
display: block;
text-align: right;
font-size: 0.8em;
color: #417690;
font-weight: 600;
}
@@ -32,14 +32,16 @@ class ScannerManager {
this.step = options.step;
this.well = options.well;
this.debug = options.debug;
this.calib_debug = options.calib_debug;
this.calib_center= options.calib_center;
this.calib_debug = options.calib_debug;
this.draw_debug = options.draw_debug;
this.calib_center= options.calib_center;
this.previous = options.previous;
this.next = options.next;
this.set_well = options.set_well;
this.well_btn = options.well_btn;
this.median = options.median;
this.crop = options.crop;
this.median = options.median;
this.edge_enhance = options.edge_enhance;
this.crop = options.crop;
this.crop_radius = options.crop_radius;
this.calib_auto = options.calib_auto;
@@ -53,7 +55,7 @@ class ScannerManager {
} catch(e) {}
}
init_controls() {
init_controls() {
this.up.addEventListener('mousedown', (e) => { this._send({ type: 'calibrate', topic: "up" }); });
this.down.addEventListener('mousedown', (e) => { this._send({ type: 'calibrate', topic: "down" }); });
this.left.addEventListener('mousedown', (e) => { this._send({ type: 'calibrate', topic: "left" }); });
@@ -62,33 +64,35 @@ class ScannerManager {
this.goto_0.addEventListener('click', (e) => { this.clear_buttons(); this._send({ type: 'calibrate', topic: "goto_0" }); });
this.goto_xy.addEventListener('click', (e) => { this.clear_buttons(); this._send({ type: 'calibrate', topic: "goto_xy" }); });
this.xy_base.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "xy_base" }); });
this.calib_debug.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "calib_debug" }); });
this.previous.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "previous" }); });
this.next.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "next" }); });
this.set_well.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "set_well" }); });
this.median.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "median" }); });
this.crop.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "crop" }); });
this.median.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "median" }); });
this.edge_enhance.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "edge_enhance" }); });
this.crop.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "crop" }); });
this.crop_radius.addEventListener('change',(e) => { this._send({ type: 'calibrate', topic: "crop_radius", value: this.crop_radius.value }); });
this.well.addEventListener("change", (e) => { this._send({ type: 'calibrate', topic: "position", value: e.target.value }); });
this.step.addEventListener("change", (e) => { this._send({ type: 'calibrate', topic: "step", value: e.target.value }); });
this.feed.addEventListener("change", (e) => { this._send({ type: 'calibrate', topic: "feed", value: e.target.value }); });
this.duration.addEventListener("change", (e) => { this._send({ type: 'calibrate', topic: "duration", value: e.target.value }); });
this.test.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "test" }); });
this.calib_center.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "center" }); });
this.calib_auto.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "auto" }); });
this.halt.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "halt" }); });
this.calib_debug.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "calib_debug" }); });
this.draw_debug.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "draw_debug" }); });
try {
this.previous.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "previous" }); });
this.next.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "next" }); });
this.calib_center.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "center" }); });
this.calib_auto.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "auto" }); });
this.min_area_px.addEventListener('change', (e) => { this._send({ type: 'calibrate', topic: "min_area_px", value: e.target.value }); });
this.max_area_ratio.addEventListener('change', (e) => { this._send({ type: 'calibrate', topic: "max_area_ratio", value: e.target.value }); });
this.max_planarians.addEventListener('change', (e) => { this._send({ type: 'calibrate', topic: "max_planarians", value: e.target.value}); });
this.merge_kernel_size.addEventListener('change', (e) => { this._send({ type: 'calibrate', topic: "merge_kernel_size", value: e.target.value}); });
this.min_contour_dist_px.addEventListener('change', (e) => { this._send({ type: 'calibrate', topic: "min_contour_dist_px", value: e.target.value }); });
this.draw.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "draw", value: e.target.value }); });
} catch(e) {}
} catch(e) {console.log(e);}
}
registerSocket(socket) {
@@ -104,8 +108,12 @@ class ScannerManager {
if (payload.state) {
if (payload.state == 'debug') {
const span = this.calib_debug.querySelector("span.debug"); span.style.color = payload.value ? '#f0f': '#fff';
} else if (payload.state == 'draw_debug') {
const span = this.draw_debug.querySelector("span.draw_debug"); span.style.color = payload.value ? '#0ff' : '#888';
} else if (payload.state == 'median') {
const span = this.median.querySelector("span.median"); span.style.color = payload.value ? '#f0f' : '#fff';
} else if (payload.state == 'edge_enhance') {
const span = this.edge_enhance.querySelector("span.edge_enhance"); span.style.color = payload.value ? '#0ff' : '#fff';
} else if (payload.state == 'crop') {
const span = this.crop.querySelector("span.crop"); span.style.color = payload.value ? '#f0f' : '#fff';
}
@@ -113,7 +121,10 @@ class ScannerManager {
}
if (payload.ts) { this.ts.textContent = timestampToLocalISOString(payload.ts); }
if (payload.buttons) { this.well_btn.innerHTML = payload.buttons; }
if (payload.buttons) {
this.well_btn.innerHTML = payload.buttons;
document.documentElement.style.setProperty('--well-columns', payload.columns);
}
if (payload.current >= 0) {
document.querySelectorAll('button.w3-button.well').forEach(btn => {
if (btn.value==payload.current) { btn.classList.add('w3-green'); return; }
@@ -62,6 +62,7 @@ class ScannerManager {
if (payload.buttons) {
this.well_btn.innerHTML = payload.buttons;
const span = this.crop.querySelector("span.crop"); span.style.color = '#f0f';
document.documentElement.style.setProperty('--well-columns', payload.columns);
}
if (payload.current >= 0) {
document.querySelectorAll('button.w3-btn.well').forEach(btn => {
@@ -72,7 +73,7 @@ class ScannerManager {
} catch(e) { console.log(e); }
}
init() { this.axes = 0; this.cropping = 1; this._send({ type: 'scanner', topic: "init", }); }
init() { this.axes = 0; this.cropping = 1; this._send({ type: 'scanner', topic: "init", sid: this.session.value }); }
scan() { this._send({ type: 'scanner', topic: "scan", session: this.session.value ? this.session.value: "0" }); }
simulate() { this._send({ type: 'scanner', topic: "simulate", session: this.session.value ? this.session.value: "0" }); }
halt() { this._send({ type: 'calibrate', topic: "halt" }); }
@@ -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);
}
});
}());
+7 -10
View File
@@ -68,15 +68,13 @@ def export_all_images(session_id=None):
conf = ScannerConstants().get()
if session_id is None:
sessions = [s.id for s in models.Session.objects.filter(active=False)]
sessions = [s.pk for s in models.Session.objects.filter(active=False)]
else:
sessions = [session_id]
for session_id in sessions:
job_zip = []
for session_id in sessions:
uuid_list = models.SessionExperiment.uuid_from_session(session_id)
job_zip = []
for uuid in uuid_list:
job = export_images.delay( # @UndefinedVariable
uuid,
start_ts=None,
@@ -109,13 +107,12 @@ def export_all_videos(session_id=None):
try:
conf = ScannerConstants().get()
if session_id is None:
sessions = [s.id for s in models.Session.objects.filter(active=False)]
sessions = [s.pk for s in models.Session.objects.filter(active=False)]
else:
sessions = [session_id]
job_mp4 = []
for session_id in sessions:
uuid_list = models.SessionExperiment.uuid_from_session(session_id)
job_mp4 = []
uuid_list = models.SessionExperiment.uuid_from_session(session_id)
for uuid in uuid_list:
job = export_videos.delay( # @UndefinedVariable
@@ -146,7 +143,7 @@ def run_session_exports(self, session_id: str):
logger.error("run_session_exports: session %s introuvable", session_id)
return {"status": "error", "message": "Session introuvable"}
session.status = models.Session.Status.RUNNING
session.export_status = models.Session.Status.RUNNING
session.save(update_fields=["export_status"])
logger.info(f"run_session_exports [{session_id}]: export démarré")
chord(
@@ -133,10 +133,10 @@
</a>
<hr class="w3-bar-item divider w3-grey w3-margin-bottom">
{% 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" %}
</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 %}
<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" %}
@@ -1,8 +1,14 @@
{% extends 'scanner/base.html' %}
{% load i18n home_tags scanner_tags %}
{% load i18n home_tags %}
{% block styles %}
{{ block.super }}
<style>
:root {
--well-columns: 3;
}
</style>
<link href="/static/scanner/css/calibration.css" rel="stylesheet">
{% endblock %}
{% block columns %}{% endblock %}
@@ -84,34 +90,36 @@
</button>
</div>
</div>
<div class="w3-row w3-row-padding">
<div id="_well_btn" class="w3-col w3-margin-bottom"></div>
<div class="w3-third">
<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>
</button>
<div {% if capture_type == "file" %}class="w3-hide"{% endif %}>
<div class="w3-third">
<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>
</button>
</div>
<div class="w3-third">
<button id="_calib_center" class="w3-button w3-dark-xlight w3-round-large w3-padding-small" title="{% trans 'Centrer manuellement' %}">
<span class="w3-small">{% trans 'Centrer' %}</span><br><i class="fa-regular fa-circle w3-xlarge w3-text-orange"></i>
</button>
</div>
<div class="w3-third">
<button id="_next" class="w3-button w3-dark-xlight w3-round-large w3-padding-small">
<span class="w3-small">{% trans 'Suivant' %}</span><br><i class="fa-solid fa-circle-right w3-xlarge"></i>
</button>
</div>
<div class="w3-col">
<button id="_calib_auto" class="w3-button w3-dark-xlight w3-round-large w3-margin-top w3-block"
title="{% trans 'Centrage auto et sauvegarde des positions' %}">
{% trans 'Calibrage auto' %}<br><i class="fa-solid fa-robot w3-text-orange w3-xlarge"></i>
</button>
</div>
</div>
<div class="w3-third">
<button id="_calib_center" class="w3-button w3-dark-xlight w3-round-large w3-padding-small" title="{% trans 'Centrer manuellement' %}">
<span class="w3-small">{% trans 'Centrer' %}</span><br><i class="fa-regular fa-circle w3-xlarge w3-text-orange"></i>
</button>
</div>
<div class="w3-third">
<button id="_next" class="w3-button w3-dark-xlight w3-round-large w3-padding-small">
<span class="w3-small">{% trans 'Suivant' %}</span><br><i class="fa-solid fa-circle-right w3-xlarge"></i>
</button>
</div>
<div class="w3-col">
<button id="_calib_auto" class="w3-button w3-dark-xlight w3-round-large w3-margin-top w3-block"
title="{% trans 'Centrage auto et sauvegarde des positions' %}">
{% trans 'Calibrage auto' %}<br><i class="fa-solid fa-robot w3-text-orange w3-xlarge"></i>
</button>
</div>
</div>
</div>
<div class="w3-col" style="width: 50%">
{% 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 class="w3-col w3-center" style="width: 25%">
<div class="w3-row w3-row-padding">
@@ -136,17 +144,27 @@
<div class="w3-half">
<button id="_calib_debug" class="w3-button w3-dark-xlight w3-round-large w3-padding-1 w3-block" title="{% trans 'Ce mode permet les réglages graphiques' %}">
<span class="debug">{% trans 'Debug' %}</span><br><i class="fa-solid fa-triangle-exclamation w3-text-amber w3-xlarge"></i>
</button>
</div>
</button>
</div>
<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>
</button>
</button>
</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">
<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>
</button>
</button>
</div>
</div>
{% if conf.tracking and conf.tracking_setting %}
@@ -248,14 +266,16 @@
well : sId("_well"),
debug : sId("_debug"),
calib_debug : sId("_calib_debug"),
draw_debug : sId("_draw_debug"),
calib_auto : sId("_calib_auto"),
calib_center: sId("_calib_center"),
previous : sId("_previous"),
next : sId("_next"),
set_well : sId("_set_well"),
well_btn : sId("_well_btn"),
median : sId("_median"),
crop : sId("_crop"),
median : sId("_median"),
edge_enhance : sId("_edge_enhance"),
crop : sId("_crop"),
crop_radius : sId("_crop_radius"),
min_area_px : sId("_min_area_px"),
max_area_ratio : sId("_max_area_ratio"),
@@ -1,5 +1,5 @@
{% extends 'scanner/base.html' %}
{% load i18n home_tags scanner_tags %}
{% load i18n home_tags %}
{% block styles %}
{{ block.super }}
@@ -1,5 +1,5 @@
{% extends 'scanner/base.html' %}
{% load i18n home_tags scanner_tags %}
{% load i18n home_tags %}
{% block styles %}
{{ block.super }}
@@ -12,7 +12,7 @@
<div>Ax pos: <span id="_axial_pos"></span></div>
</div-->
{#% endif %#}
<div class="w3-rest">
<img id="scan-img" class="w3-image">
<div class="w3-col">
<img id="scan-img" class="w3-image w3-margin-auto">
</div>
</div>
@@ -3,6 +3,11 @@
{% block styles %}
{{ block.super }}
<style>
:root {
--well-columns: 6;
}
</style>
<link href="/static/scanner/css/scanning.css" rel="stylesheet">
{% endblock %}
{% block columns %}{% endblock %}
@@ -12,11 +17,15 @@
<div class="w3-col w3-center" style="width: 30%">
<div class="w3-col">
<div>{% trans "Choix de la session" %}</div>
<select id="_session" class="w3-select">
{% for s in sessions %}
<option value="{{ s.id }}">{{ s }}</option>
{% endfor %}
</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 %}
<option value="{{ s.id }}" {% if s.id == cursid %}selected{% endif %}>{{ s }}</option>
{% endfor %}
</select>
</form>
</div>
<div class="w3-col">
<div class="w3-row w3-bold">
@@ -1,13 +1,13 @@
# encoding: utf-8
from django import template
from django.utils.html import mark_safe
from django.utils.safestring import mark_safe
register = template.Library()
@register.simple_tag
def multiwell_cards(sid, experiment):
multiwells = []
row_def = experiment.multiwell.row_def.split(',')
row_def = experiment.multiwell.row_def.split(',')
multiwells.append(
f'''
<div class="w3-padding-small w3-sand">{experiment.title}</div>
+27 -11
View File
@@ -55,7 +55,6 @@ def global_context(request, **ctx):
**ctx
)
@login_required
@user_passes_test(is_staff_or_admin)
def admin_view(request):
@@ -126,9 +125,16 @@ def documentation(request, template=None):
## Mainboard
@login_required
def scanning_view(request):
cursid = 0
if request.method == 'POST':
cursid = request.POST.get('_session', 0)
if not cursid:
current_session = models.Session.objects.filter(active=True).first()
cursid = current_session.pk
ctx = dict(
ws_route=settings.SCANNER_WEBSOCKET_ROUTE,
columns=1,
cursid=int(cursid),
sessions=models.Session.objects.filter(active=True).all(),
choice_title=_("Balayage multi-puits")
)
@@ -137,26 +143,36 @@ def scanning_view(request):
## Calibration
@login_required
def calibration_view(request):
context = global_context(request)
wells = models.MultiWell.objects.filter(capture_video=False).all()
capture_type = context['conf'].capture_type
config = ScannerConstants.get_config()
if capture_type == 'video':
mw = models.MultiWell.objects.filter(default=True, capture_video=True).first()
if mw:
context['conf'].default_position = mw.position
wells = models.MultiWell.objects.filter(capture_video=True).all()
ctx = dict(
ws_route=settings.SCANNER_WEBSOCKET_ROUTE,
columns=1,
choice_title=_("Calibration"),
wells = models.MultiWell.objects.all(),
choice_title=f'{str(_("Calibration"))} {config.get_capture_type_display()}',
wells = wells,
capture_type=capture_type,
)
return render(request, "scanner/calibration.html", context=global_context(request, **ctx))
context.update(ctx)
return render(request, "scanner/calibration.html", context=context)
def get_not_active_experiments(session, expid=None):
def get_not_active_experiments(session, expid=None) -> tuple[list, "models.Experiment | None"]:
if session:
experiments = models.SessionExperiment.experiment_by_session(session.id, active=False) or []
experiments = models.SessionExperiment.experiment_by_session(session.pk, active=False) or []
if experiments and not expid:
return experiments, experiments[0]
for e in experiments:
if expid == str(e.id):
if expid == str(e.pk):
return experiments, e
return [], None
## images
def get_images(uuid):
oldest, latest, n, images = 0, 0, 0, []