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
@@ -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)'),
),
]