export video/image

This commit is contained in:
2026-05-15 16:14:26 +02:00
parent 4fb7fa8fd3
commit 7760d7ae7c
13 changed files with 183 additions and 149 deletions
@@ -8,7 +8,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-05-14 17:29+0200\n" "POT-Creation-Date: 2026-05-15 15:08+0200\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n" "Language-Team: LANGUAGE <LL@li.org>\n"
@@ -80,7 +80,7 @@ msgstr "Prerequisite"
#: home/templates/docs/doc_database.html:363 #: home/templates/docs/doc_database.html:363
#: home/templates/docs/doc_database.html:366 #: home/templates/docs/doc_database.html:366
#: home/templates/docs/doc_database.html:367 #: home/templates/docs/doc_database.html:367
#: scanner/templates/scanner/base.html:132 scanner/views.py:131 #: scanner/templates/scanner/base.html:132 scanner/views.py:133
msgid "Calibration" msgid "Calibration"
msgstr "Calibration" msgstr "Calibration"
@@ -409,8 +409,7 @@ msgid "Tester le circuit"
msgstr "Test circuit" msgstr "Test circuit"
#: home/templates/docs/doc_calibration.html:420 #: home/templates/docs/doc_calibration.html:420
msgid "" msgid "Vérifier les déplacements en parcourant automatiquement tous les puits."
"Vérifier les déplacements en parcourant automatiquement tous les puits."
msgstr "Check movements by automatically browsing all wells." msgstr "Check movements by automatically browsing all wells."
#: home/templates/docs/doc_calibration.html:431 #: home/templates/docs/doc_calibration.html:431
@@ -803,8 +802,8 @@ msgid ""
"Sessions, expériences, configurations et import CSV — interface " "Sessions, expériences, configurations et import CSV — interface "
"Administration Django" "Administration Django"
msgstr "" msgstr ""
"Sessions, experiments, configurations and import CSV — Django Administration" "Sessions, experiments, configurations and import CSV — Django Administration "
" interface" "interface"
#: home/templates/docs/doc_experiment.html:32 #: home/templates/docs/doc_experiment.html:32
#: home/templates/docs/doc_experiment.html:86 #: home/templates/docs/doc_experiment.html:86
@@ -847,8 +846,7 @@ msgstr "CSV import"
#: home/templates/docs/doc_experiment.html:55 #: home/templates/docs/doc_experiment.html:55
msgid "" msgid ""
"C'est l'interface Administration Django qui est chargée de créer les " "C'est l'interface Administration Django qui est chargée de créer les données."
"données."
msgstr "" msgstr ""
"The Django Administration interface is responsible for creating the data." "The Django Administration interface is responsible for creating the data."
@@ -947,8 +945,8 @@ msgstr "Date of export"
#: home/templates/docs/doc_experiment.html:169 #: home/templates/docs/doc_experiment.html:169
msgid "" msgid ""
"Crée une tâche différée pour exporter automatiquement les images et vidéos à" "Crée une tâche différée pour exporter automatiquement les images et vidéos à "
" la date choisie." "la date choisie."
msgstr "" msgstr ""
"Creates a delayed task to automatically export images and videos to the " "Creates a delayed task to automatically export images and videos to the "
"selected date." "selected date."
@@ -996,8 +994,7 @@ msgid "Par défaut, tous les puits sont actifs."
msgstr "By default, all wells are active." msgstr "By default, all wells are active."
#: home/templates/docs/doc_experiment.html:255 #: home/templates/docs/doc_experiment.html:255
msgid "" msgid "Il est possible d'ignorer le balayage d'un ou plusieurs puits au choix."
"Il est possible d'ignorer le balayage d'un ou plusieurs puits au choix."
msgstr "It is possible to ignore the scanning of one or more wells of choice." msgstr "It is possible to ignore the scanning of one or more wells of choice."
#: home/templates/docs/doc_experiment.html:265 #: home/templates/docs/doc_experiment.html:265
@@ -1630,7 +1627,7 @@ msgstr "Back home"
msgid "Ensemble d'expériences" msgid "Ensemble d'expériences"
msgstr "Set of experiments" msgstr "Set of experiments"
#: planarian/templates/planarian/base.html:30 #: planarian/templates/planarian/base.html:27
msgid "Exporter les metrics de l'expérience" msgid "Exporter les metrics de l'expérience"
msgstr "Export the metrics of the experience" msgstr "Export the metrics of the experience"
@@ -2450,6 +2447,14 @@ msgstr "Scanning started..."
msgid "Erreur lors du démarrage du balayage..." msgid "Erreur lors du démarrage du balayage..."
msgstr "Error starting scanning..." msgstr "Error starting scanning..."
#: scanner/tasks.py:21
msgid "Balayage démarré."
msgstr "Scanning started..."
#: scanner/tasks.py:27
msgid "Replay démarré."
msgstr "Replay started..."
#: scanner/templates/scanner/base.html:5 #: scanner/templates/scanner/base.html:5
msgid "Barre de côté" msgid "Barre de côté"
msgstr "Side bar" msgstr "Side bar"
@@ -2548,7 +2553,7 @@ msgstr "Creation of experience sessions"
msgid "Gestions des expériences" msgid "Gestions des expériences"
msgstr "Experience management" msgstr "Experience management"
#: scanner/templates/scanner/base.html:137 scanner/views.py:121 #: scanner/templates/scanner/base.html:137 scanner/views.py:123
msgid "Balayage multi-puits" msgid "Balayage multi-puits"
msgstr "Multi-well sweeping" msgstr "Multi-well sweeping"
@@ -2560,7 +2565,7 @@ msgstr "Results"
msgid "Export CSV des expériences" msgid "Export CSV des expériences"
msgstr "CSV export of experiments" msgstr "CSV export of experiments"
#: scanner/templates/scanner/base.html:146 scanner/views.py:185 #: scanner/templates/scanner/base.html:146 scanner/views.py:187
msgid "Gestionnaire d'images" msgid "Gestionnaire d'images"
msgstr "Image Manager" msgstr "Image Manager"
@@ -2719,22 +2724,34 @@ msgid "Tester le cirduit"
msgstr "Test for circumference" msgstr "Test for circumference"
#: scanner/templates/scanner/calibration.html:207 #: scanner/templates/scanner/calibration.html:207
#: scanner/templates/scanner/scanning.html:58 #: scanner/templates/scanner/scanning.html:59
msgid "ARRET" msgid "ARRET"
msgstr "JUDGMENT" msgstr "JUDGMENT"
#: scanner/templates/scanner/images.html:30 #: scanner/templates/scanner/images.html:27
msgid "Exporter l'ensemble des images" msgid "Exporter l'ensemble des images de la session"
msgstr "Export All Images" msgstr "Export All Images for that session"
#: scanner/templates/scanner/images.html:48 #: scanner/templates/scanner/images.html:45
msgid "Télécharger" msgid "Télécharger"
msgstr "Download" msgstr "Download"
#: scanner/templates/scanner/replay.html:32 #: scanner/templates/scanner/images.html:65
msgid "Confirmer l'export des images."
msgstr "Export must be confirmed."
#: scanner/templates/scanner/images.html:66
msgid "ATTENTION le processus peut être long!"
msgstr "BEWARE The procedure can be lengthy."
#: scanner/templates/scanner/replay.html:29
msgid "Exporter l'ensemble des vidéos" msgid "Exporter l'ensemble des vidéos"
msgstr "Export all videos" msgstr "Export all videos"
#: scanner/templates/scanner/replay.html:78
msgid "Confirmer l'export des vidéos. ATTENTION le processus peut être long!."
msgstr "Confirm videos exportation. BEWARE the procedure can be lengthy."
#: scanner/templates/scanner/scanning.html:14 #: scanner/templates/scanner/scanning.html:14
msgid "Choix de la session" msgid "Choix de la session"
msgstr "Selection of session" msgstr "Selection of session"
@@ -2756,26 +2773,26 @@ msgstr "scanning"
msgid "Simuler" msgid "Simuler"
msgstr "Simulate" msgstr "Simulate"
#: scanner/views.py:109 #: scanner/views.py:111
msgid "Documentation" msgid "Documentation"
msgstr "Documentation" msgstr "Documentation"
#: scanner/views.py:230 #: scanner/views.py:222 scanner/views.py:273
msgid "Erreur d'exportation"
msgstr "Export error"
#: scanner/views.py:246
msgid "Gestionnaire de vidéos" msgid "Gestionnaire de vidéos"
msgstr "Video Manager" msgstr "Video Manager"
#: scanner/views.py:252 #: scanner/views.py:268
msgid "Images téléchargées" msgid "Images téléchargées"
msgstr "Downloaded images" msgstr "Downloaded images"
#: scanner/views.py:255 #: scanner/views.py:271
msgid "Vidéos téléchargées" msgid "Vidéos téléchargées"
msgstr "Videos downloaded" msgstr "Videos downloaded"
#: scanner/views.py:257
msgid "Erreur d'exportation"
msgstr "Export error"
#: templates/admin/base_site.html:4 #: templates/admin/base_site.html:4
msgid "Django site admin" msgid "Django site admin"
msgstr "Django website admin" msgstr "Django website admin"
+2 -2
View File
@@ -9,11 +9,11 @@ from .models import ExperimentConfig
class ExperimentConfigAdmin(admin.ModelAdmin): class ExperimentConfigAdmin(admin.ModelAdmin):
"""Admin Django pour les configurations d'expérience.""" """Admin Django pour les configurations d'expérience."""
readonly_fields = ('experiment', 'px_per_mm', 'fps', 'well_radius_mm',) readonly_fields = ('experiment', 'px_per_mm', 'fps', 'well_radius_mm',)
list_display = ("experiment_key", "well", "active", "px_per_mm", "fps", list_display = ("experiment_key", "well", "active", "planarian_count", "px_per_mm", "fps",
"thresh_immobile", "thresh_mobile", "thresh_immobile", "thresh_mobile",
"photo_mode", "chemo_strength", "created_at", ) "photo_mode", "chemo_strength", "created_at", )
list_filter = ("experiment_key", "photo_mode", "tube_axis") list_filter = ("experiment_key__session_experiments__session", "experiment_key", "photo_mode", "thigmotaxis_wall_dist_mm", "planarian_count")
search_fields = ("experiment_key", "well_name", "description") search_fields = ("experiment_key", "well_name", "description")
ordering = ("-created_at",) ordering = ("-created_at",)
@@ -17,10 +17,7 @@
{% endfor %} {% endfor %}
</select> </select>
<div class="w3-margin-left w3-margin-bottom"> <div class="w3-margin-left w3-margin-bottom">
{% for ss in experiments %} {% include "scanner/experiment-inc.html" %}
<input class="" type="radio" name="_expid" value="{{ ss.id }}" {% if ss.id == current_experiment.id %}checked{% endif %} onchange="this.form.submit()" >
<label>{{ ss }}</label>
{% endfor %}
</div> </div>
</form> </form>
{% block export_csv %} {% block export_csv %}
+15 -39
View File
@@ -7,7 +7,7 @@ import posix_ipc
import mmap import mmap
import cv2 import cv2
import numpy as np import numpy as np
from django.http import JsonResponse, HttpResponse #from django.http import JsonResponse, HttpResponse
from django.conf import settings from django.conf import settings
from reduct.time import unix_timestamp_to_iso from reduct.time import unix_timestamp_to_iso
@@ -38,15 +38,10 @@ async def remove_video_by_uuid(uuid, start_ts=None, end_ts=None, when=None):
async def remove_video(uuid, start_ts, end_ts, when=None): async def remove_video(uuid, start_ts, end_ts, when=None):
try: await remove_video_by_uuid(uuid, start_ts, end_ts, when=when)
await remove_video_by_uuid(uuid, start_ts, end_ts, when=when)
return JsonResponse({'state': 'ok'}, status=200)
except Exception as e:
return JsonResponse({'error': str(e)}, status=500)
async def shm_download_video(uuid, start_ts, end_ts, frame_rate=5, opencv_fourcc_format='mp4v', opencv_video_type='mp4'): async def shm_download_video(uuid, start_ts, end_ts, frame_rate=5, opencv_fourcc_format='mp4v', opencv_video_type='mp4'):
try: try:
record_manager = CameraRecordManager(cameraDB) record_manager = CameraRecordManager(cameraDB)
total_size = await record_manager.size(uuid, start_ts, end_ts) total_size = await record_manager.size(uuid, start_ts, end_ts)
@@ -79,7 +74,8 @@ async def shm_download_video(uuid, start_ts, end_ts, frame_rate=5, opencv_fourcc
total += 1 total += 1
if not frame_sizes: if not frame_sizes:
return JsonResponse({'error': 'Aucune frame trouvée'}, status=404) raise Exception("No frame found!")
#return JsonResponse({'error': 'Aucune frame trouvée'}, status=404)
video_path = os.path.join(settings.MEDIA_ROOT, f"output.{opencv_video_type}") video_path = os.path.join(settings.MEDIA_ROOT, f"output.{opencv_video_type}")
fourcc = cv2.VideoWriter_fourcc(* opencv_fourcc_format) fourcc = cv2.VideoWriter_fourcc(* opencv_fourcc_format)
@@ -97,36 +93,16 @@ async def shm_download_video(uuid, start_ts, end_ts, frame_rate=5, opencv_fourcc
video.write(frame) video.write(frame)
current_offset += size current_offset += size
progress_bar(i + 1, total, prefix=f'Progression {uuid}:', suffix='Terminé', length=30) progress_bar(i + 1, total, prefix=f'Progress {uuid}:', suffix='Ended', length=30)
i+=1 i+=1
video.release() video.release()
# Nettoie la mémoire partagée # Nettoie la mémoire partagée
shm.unlink() shm.unlink()
# Vérifier que le fichier existe return { "status": 404, "success": True, "video_path": video_path}
if not os.path.exists(video_path):
logger.error(f"Fichier non créé: {video_path}")
return JsonResponse({'error': 'Erreur création vidéo'}, status=500)
# Lit le fichier vidéo généré
with open(video_path, 'rb') as f:
video_bytes = f.read()
# Retourne la vidéo en réponse
response = HttpResponse(video_bytes, content_type='video/mp4')
response['Content-Disposition'] = f'attachment; filename="{video_path}"'
response['Content-Length'] = os.path.getsize(video_path)
# Supprime le fichier temporaire
os.remove(video_path)
return response
except Exception as e: except Exception as e:
logger.error(f"shm_download_video: {e}") return {"status": 500, "success": False, "video_path": video_path, "error": str(e)}
return JsonResponse({'error': str(e)}, status=500)
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
## ##
@@ -247,7 +223,7 @@ async def export_images_zip(
total += 1 total += 1
if not frame_sizes: if not frame_sizes:
return {"status": "error", "message": "Aucune frame trouvée"} return {"status": "error", "message": "No frame found!..."}
# --- Génération du ZIP --- # --- Génération du ZIP ---
max_zip_bytes = max_zip_size_mb * 1024 * 1024 if max_zip_size_mb > 0 else 0 max_zip_bytes = max_zip_size_mb * 1024 * 1024 if max_zip_size_mb > 0 else 0
@@ -297,7 +273,7 @@ async def export_images_zip(
zf.writestr(f"{uuid}_{ts_iso}.jpg", buf.tobytes()) zf.writestr(f"{uuid}_{ts_iso}.jpg", buf.tobytes())
written += 1 written += 1
progress_bar(i + 1, total, prefix=f'Progression {uuid}:', suffix='Terminé', length=30) progress_bar(i + 1, total, prefix=f'Progress {uuid}:', suffix='Ended', length=30)
i+=1 i+=1
current_offset += size current_offset += size
@@ -402,7 +378,7 @@ async def export_video_mp4(
total +=1 total +=1
if not frame_sizes: if not frame_sizes:
return {"status": "error", "message": "Aucune frame trouvée"} return {"status": "error", "message": "No frame found!..."}
# --- Génération du MP4 --- # --- Génération du MP4 ---
@@ -458,14 +434,14 @@ async def export_video_mp4(
written += 1 written += 1
current_offset += size current_offset += size
progress_bar(i + 1, total, prefix=f'Progression {uuid}:', suffix='Terminé', length=30) progress_bar(i + 1, total, prefix=f'Progress {uuid}:', suffix='Ended', length=30)
i+=1 i+=1
if video: if video:
video.release() video.release()
if not os.path.exists(video_path): if not os.path.exists(video_path):
return {"status": "error", "message": f"Fichier {opencv_video_type} non créé"} return {"status": "error", "message": f"File {opencv_video_type} not created!..."}
## Copie vers les destinations (local + Samba) ## Copie vers les destinations (local + Samba)
filename = os.path.basename(video_path) filename = os.path.basename(video_path)
+14 -14
View File
@@ -42,7 +42,7 @@ TUBE_AXIS_TYPE = [
class Configuration(models.Model): class Configuration(models.Model):
name = models.CharField(_("Nom de la Configuration"), help_text=_("Nom de la configuration"), max_length=100, null=True, blank=False, default=_("Configuration par défaut")) name = models.CharField(_("Nom de la Configuration"), help_text=_("Nom de la configuration"), max_length=100, null=True, blank=False, default=_("Configuration par défaut"))
author = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name="Auteur", null=True, blank=True) author = models.ForeignKey(User, on_delete=models.SET_NULL, verbose_name="Auteur", null=True, blank=True)
# Dashboard configuration # Dashboard configuration
sidebar_width = models.CharField(_("Barre latérale"), help_text=_("Largeur barre latérale (css)"), max_length=32, null=True, blank=False, default="350px") sidebar_width = models.CharField(_("Barre latérale"), help_text=_("Largeur barre latérale (css)"), max_length=32, null=True, blank=False, default="350px")
default_grid_columns = models.PositiveSmallIntegerField(_("Colonnes de la grille par défaut"), help_text=_("Nombre de colonnes de la grille par défaut"), blank=False, default=3) default_grid_columns = models.PositiveSmallIntegerField(_("Colonnes de la grille par défaut"), help_text=_("Nombre de colonnes de la grille par défaut"), blank=False, default=3)
@@ -94,7 +94,7 @@ class Configuration(models.Model):
return f'{self.name}' return f'{self.name}'
class Well(models.Model): class Well(models.Model):
author = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name="Auteur", null=True, blank=True) author = models.ForeignKey(User, on_delete=models.SET_NULL, verbose_name="Auteur", null=True, blank=True)
name = models.CharField(_("Nom"), help_text=_("Nom du puit: Ai..Di"), unique=True, max_length=4, null=True, blank=True) name = models.CharField(_("Nom"), help_text=_("Nom du puit: Ai..Di"), unique=True, max_length=4, null=True, blank=True)
class Meta: class Meta:
@@ -110,7 +110,7 @@ class Well(models.Model):
class MultiWell(models.Model): class MultiWell(models.Model):
# Identification # Identification
label = models.CharField(_("Label"), help_text=_("Label du multi-puit"), max_length=100, null=True, blank=True) label = models.CharField(_("Label"), help_text=_("Label du multi-puit"), max_length=100, null=True, blank=True)
author = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name="Auteur", null=True, blank=True) author = models.ForeignKey(User, on_delete=models.SET_NULL, verbose_name="Auteur", null=True, blank=True)
position = models.CharField(_("Position"), help_text=_('Position du multi-puits sur la table'), unique=True, max_length=8, choices=MULTIWELL_POSITION, null=True, blank=False) position = models.CharField(_("Position"), help_text=_('Position du multi-puits sur la table'), unique=True, max_length=8, choices=MULTIWELL_POSITION, null=True, blank=False)
default = models.BooleanField(_("Par défaut"), help_text=_('Multi-puit par défaut'), default=False) default = models.BooleanField(_("Par défaut"), help_text=_('Multi-puit par défaut'), default=False)
# Configuration # Configuration
@@ -175,7 +175,7 @@ class MultiWell(models.Model):
class WellPosition(models.Model): class WellPosition(models.Model):
author = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name="Auteur", null=True, blank=True) author = models.ForeignKey(User, on_delete=models.SET_NULL, verbose_name="Auteur", null=True, blank=True)
well = models.ForeignKey(Well, verbose_name=_("Puit"), on_delete=models.SET_NULL, null=True, blank=True) well = models.ForeignKey(Well, verbose_name=_("Puit"), on_delete=models.SET_NULL, null=True, blank=True)
multiwell = models.ForeignKey(MultiWell, verbose_name=_("Multi-puits"), on_delete=models.SET_NULL, null=True, blank=True) multiwell = models.ForeignKey(MultiWell, verbose_name=_("Multi-puits"), on_delete=models.SET_NULL, null=True, blank=True)
@@ -239,7 +239,7 @@ class Experiment(models.Model):
comment = models.TextField(_("Commentaires"), help_text=_("Descriptions de l'expérience"), null=True, blank=True) comment = models.TextField(_("Commentaires"), help_text=_("Descriptions de l'expérience"), null=True, blank=True)
identifier = models.CharField(_("Identifiant d'expérience"), unique=True, max_length=100, null=True, blank=False ) identifier = models.CharField(_("Identifiant d'expérience"), unique=True, max_length=100, null=True, blank=False )
author = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name="Auteur", null=True, blank=True) author = models.ForeignKey(User, on_delete=models.SET_NULL, verbose_name="Auteur", null=True, blank=True)
multiwell = models.ForeignKey(MultiWell, verbose_name=_("Multi-puits"), on_delete=models.SET_NULL, null=True, blank=True) multiwell = models.ForeignKey(MultiWell, verbose_name=_("Multi-puits"), on_delete=models.SET_NULL, null=True, blank=True)
duration = models.PositiveIntegerField(_("Durée"), help_text=_('Durée de la prise de vue en secondes'), blank=False, default=120) duration = models.PositiveIntegerField(_("Durée"), help_text=_('Durée de la prise de vue en secondes'), blank=False, default=120)
@@ -249,7 +249,7 @@ class Experiment(models.Model):
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
self.identifier = f'{self.id}-{self.multiwell.position}_{self.created.isoformat()[:19]}' self.identifier = f'{self.multiwell.position}_{self.created.isoformat()[:19]}'
super().save(*args, **kwargs) super().save(*args, **kwargs)
@@ -264,7 +264,7 @@ class Experiment(models.Model):
verbose_name_plural = _("Expériences") verbose_name_plural = _("Expériences")
def __str__(self): def __str__(self):
return f'{self.identifier}' return f'{self.identifier} [ {self.title} ]'
class Session(models.Model): class Session(models.Model):
@@ -276,7 +276,7 @@ class Session(models.Model):
ERROR = "error", _("Erreur") ERROR = "error", _("Erreur")
name = models.CharField(_("Nom de la session"), help_text=_("Session d'expérience. 4 Multi-puits maximum"), max_length=100, null=True, blank=False) name = models.CharField(_("Nom de la session"), help_text=_("Session d'expérience. 4 Multi-puits maximum"), max_length=100, null=True, blank=False)
author = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name="Auteur", null=True, blank=True) author = models.ForeignKey(User, on_delete=models.SET_NULL, verbose_name="Auteur", null=True, blank=True)
active = models.BooleanField(_("Active"), default=True) active = models.BooleanField(_("Active"), default=True)
expected_export = models.DateTimeField(_("Date d'exportation"), help_text=_("Date d'exportation prévue"), null=True, blank=True) expected_export = models.DateTimeField(_("Date d'exportation"), help_text=_("Date d'exportation prévue"), null=True, blank=True)
expected_scanning = models.DateTimeField(_("Date du balayage"), help_text=_("Date du balayage prévue"), null=True, blank=True) expected_scanning = models.DateTimeField(_("Date du balayage"), help_text=_("Date du balayage prévue"), null=True, blank=True)
@@ -313,7 +313,7 @@ class Session(models.Model):
def __str__(self): def __str__(self):
state = _("Terminée") if not self.active else _("Active") state = _("Terminée") if not self.active else _("Active")
return f'{self.id}: {self.name} {state}' return f'[ {self.id} ] {self.name} ({state})'
@receiver(post_save, sender=Session) @receiver(post_save, sender=Session)
@@ -376,9 +376,9 @@ def delete_periodic_task(sender, instance, **kwargs):
class SessionExperiment(models.Model): class SessionExperiment(models.Model):
author = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name="Auteur", null=True, blank=True) author = models.ForeignKey(User, on_delete=models.SET_NULL, verbose_name="Auteur", null=True, blank=True)
session = models.ForeignKey(Session, verbose_name=_("Session"), on_delete=models.SET_NULL, null=True, blank=True) session = models.ForeignKey(Session, verbose_name=_("Session"), on_delete=models.CASCADE, null=True, blank=True)
experiment = models.ForeignKey(Experiment, verbose_name=_("Expérience"), on_delete=models.SET_NULL, null=True, blank=True, related_name="session_experiments") experiment = models.ForeignKey(Experiment, verbose_name=_("Expérience"), on_delete=models.CASCADE, null=True, blank=True, related_name="session_experiments")
@classmethod @classmethod
def experiment_by_session(cls, session_id, active=True): def experiment_by_session(cls, session_id, active=True):
@@ -407,8 +407,8 @@ class SessionExperiment(models.Model):
class ExperimentWell(models.Model): class ExperimentWell(models.Model):
author = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name="Auteur", null=True, blank=True) author = models.ForeignKey(User, on_delete=models.SET_NULL, verbose_name="Auteur", null=True, blank=True)
experiment = models.ForeignKey(Experiment, verbose_name=_("Expérience"), on_delete=models.SET_NULL, null=True, blank=True, related_name="experimentwell") experiment = models.ForeignKey(Experiment, verbose_name=_("Expérience"), on_delete=models.CASCADE, null=True, blank=True, related_name="experimentwell")
well = models.ForeignKey(Well, verbose_name=_("Puit"), on_delete=models.SET_NULL, null=True, blank=True, related_name="wellexperiment") well = models.ForeignKey(Well, verbose_name=_("Puit"), on_delete=models.SET_NULL, null=True, blank=True, related_name="wellexperiment")
active = models.BooleanField(_("Active"), default=True) active = models.BooleanField(_("Active"), default=True)
+8 -4
View File
@@ -151,7 +151,7 @@ class MultiWellManager:
multiwell = experiment.multiwell multiwell = experiment.multiwell
if self.process.use_tracking: if self.process.use_tracking:
cfg = ExperimentConfig.objects.filter(experiment_id=experiment.id, well_id=well.id).first() cfg = ExperimentConfig.objects.filter(experiment_key_id=experiment.id, well=well.name).first()
if not cfg: if not cfg:
raise Exception(f"Configuration d'expérience introuvable pour {experiment} / {well}") raise Exception(f"Configuration d'expérience introuvable pour {experiment} / {well}")
# reset PlanarianTracker => on_well_change # reset PlanarianTracker => on_well_change
@@ -159,14 +159,16 @@ class MultiWellManager:
## create uuid for this capture ## create uuid for this capture
uuid = f'{self.process.data.session}-{multiwell.position}-{well.name}' uuid = f'{self.process.data.session}-{multiwell.position}-{well.name}'
## start recording ## start recording
self.process.data.uuid = uuid self.process.data.uuid = uuid
if not simulate: if not simulate:
self.process.data.record = True self.process.data.record = True
self.process._send(current=well_position.order) self.process._send(current=well_position.order)
logger.info(f"Starting capture for {uuid} ordre: {well_position.order}") msg = f"Starting capture for {uuid} ordre: {well_position.order}"
logger.info(msg)
self.process._send(well_state=msg)
start = time.monotonic() start = time.monotonic()
while not self.stop_playing.is_set(): while not self.stop_playing.is_set():
@@ -249,7 +251,9 @@ class MultiWellManager:
started = timezone.now() started = timezone.now()
for obs in experiments: for obs in experiments:
logger.warning(f"Starting scan for {obs} (well {pos}/{len(experiments)})") msg = f"Starting scan for {obs} (well {pos}/{len(experiments)})"
logger.warning(msg)
self.process._send(well_state=msg)
if self.stop_playing.is_set(): if self.stop_playing.is_set():
break break
@@ -25,6 +25,7 @@ class ScannerManager {
this.scan_state = options.scan_state; this.scan_state = options.scan_state;
this.sim_bt = options.simulate || null; this.sim_bt = options.simulate || null;
this.well_btn = options.well_btn; this.well_btn = options.well_btn;
this.well_state = options.well_state;
} }
init_controls() { init_controls() {
@@ -56,7 +57,8 @@ class ScannerManager {
} }
if (payload.ts) { this.ts.textContent = timestampToLocalISOString(payload.ts); } if (payload.ts) { this.ts.textContent = timestampToLocalISOString(payload.ts); }
if (payload.scan_state) { this.scan_state.textContent=payload.scan_state;} if (payload.scan_state) { this.scan_state.textContent=payload.scan_state;}
if (payload.well_state) { this.well_state.textContent=payload.well_state;}
if (payload.buttons) { if (payload.buttons) {
this.well_btn.innerHTML = payload.buttons; this.well_btn.innerHTML = payload.buttons;
const span = this.crop.querySelector("span.crop"); span.style.color = '#f0f'; const span = this.crop.querySelector("span.crop"); span.style.color = '#f0f';
@@ -66,9 +68,7 @@ class ScannerManager {
if (btn.value==payload.current) { btn.classList.add('w3-green'); return; } if (btn.value==payload.current) { btn.classList.add('w3-green'); return; }
btn.classList.remove('w3-green'); btn.classList.remove('w3-green');
}); });
} }
} catch(e) { console.log(e); } } catch(e) { console.log(e); }
} }
+29 -15
View File
@@ -5,7 +5,7 @@ import requests
from celery import shared_task, group, chord, chain from celery import shared_task, group, chord, chain
from celery.utils.log import get_task_logger from celery.utils.log import get_task_logger
from django.utils import timezone from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from .process import ScannerProcess, ReplayProcess, redisDB from .process import ScannerProcess, ReplayProcess, redisDB
from .export_tasks import shm_download_video, export_images_zip, export_video_mp4 from .export_tasks import shm_download_video, export_images_zip, export_video_mp4
from .constants import ScannerConstants from .constants import ScannerConstants
@@ -18,21 +18,35 @@ logger = get_task_logger(__name__)
def scanner_start(): def scanner_start():
scanner = ScannerProcess() scanner = ScannerProcess()
scanner.start() scanner.start()
return f"Scanner démarré." return str(_("Scanner démarré."))
@shared_task @shared_task
def replay_start(): def replay_start():
replay = ReplayProcess(latency=5.0) replay = ReplayProcess(latency=5.0)
replay.start() replay.start()
return f"Replay démarré." return str(_("Replay démarré."))
@shared_task
def download_video(uuid, start_ts, end_ts, frame_rate=10, opencv_fourcc_format='mp4v', opencv_video_type='mp4'): @shared_task(bind=True)
try: def download_video(self, uuid, start_ts, end_ts, frame_rate=5, opencv_fourcc_format='mp4v', opencv_video_type='mp4'):
return asyncio.run(shm_download_video(uuid, start_ts, end_ts, frame_rate, opencv_fourcc_format, opencv_video_type)) """
Exécuter directement la fonction async DANS la tâche Celery.
Sans passer par une wrapper.
"""
try:
logger.info(f"Task {self.request.id}: Processing {uuid} from {start_ts} to {end_ts}")
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
result = loop.run_until_complete(
shm_download_video(uuid, start_ts, end_ts, frame_rate, opencv_fourcc_format, opencv_video_type) )
return result
finally:
loop.close()
except Exception as e: except Exception as e:
logger.error(f"download_video: {e}") logger.error(f"shm_download_video_task error: {e}", exc_info=True)
return { 'status': 'error', 'error': str(e), 'uuid': uuid}
@shared_task @shared_task
def export_images( def export_images(
uuid: str, uuid: str,
@@ -46,7 +60,8 @@ def export_images(
return asyncio.run(export_images_zip(uuid, start_ts, end_ts, jpeg_quality, max_zip_size_mb, max_image_width, max_image_height)) return asyncio.run(export_images_zip(uuid, start_ts, end_ts, jpeg_quality, max_zip_size_mb, max_image_width, max_image_height))
except Exception as e: except Exception as e:
logger.error(f"export_images: {e}") logger.error(f"export_images: {e}")
@shared_task @shared_task
def export_all_images(session_id=None): def export_all_images(session_id=None):
try: try:
@@ -72,13 +87,11 @@ def export_all_images(session_id=None):
return job_zip return job_zip
except Exception as e: except Exception as e:
logger.error(f"export_images: {e}") logger.error(f"export_images: {e}")
@shared_task @shared_task
def export_videos(uuid: str, def export_videos(uuid: str, start_ts: float | None = None, end_ts: float | None = None, frame_rate: int = 5,
start_ts: float | None = None,
end_ts: float | None = None,
frame_rate: int = 5,
opencv_fourcc_format='mp4v', opencv_fourcc_format='mp4v',
opencv_video_type='mp4', opencv_video_type='mp4',
max_video_size_mb: int = 0, max_video_size_mb: int = 0,
@@ -88,6 +101,7 @@ def export_videos(uuid: str,
return asyncio.run(export_video_mp4(uuid, start_ts, end_ts, frame_rate, opencv_fourcc_format, opencv_video_type, max_video_size_mb, max_width, max_height)) return asyncio.run(export_video_mp4(uuid, start_ts, end_ts, frame_rate, opencv_fourcc_format, opencv_video_type, max_video_size_mb, max_width, max_height))
except Exception as e: except Exception as e:
logger.error(f"export_videos: {e}") logger.error(f"export_videos: {e}")
return {"success": False, "error": str(e)}
@shared_task @shared_task
@@ -0,0 +1,6 @@
{% for ss in experiments %}
<div>
<input class="" type="radio" name="_expid" value="{{ ss.id }}" {% if ss.id == current_experiment.id %}checked{% endif %} onchange="this.form.submit()" >
<label>{{ ss }}</label>
</div>
{% endfor %}
@@ -16,18 +16,15 @@
{% endfor %} {% endfor %}
</select> </select>
<div class="w3-margin-left w3-margin-bottom"> <div class="w3-margin-left w3-margin-bottom">
{% for ss in experiments %} {% include "scanner/experiment-inc.html" %}
<input class="" type="radio" name="_expid" value="{{ ss.id }}" {% if ss.id == current_experiment.id %}checked{% endif %} onchange="this.form.submit()" >
<label>{{ ss.multiwell }}</label>
{% endfor %}
</div> </div>
{% if current_session.id and current_experiment %} {% if current_session.id and current_experiment %}
{% multiwell_cards current_session.id current_experiment %} {% multiwell_cards current_session.id current_experiment %}
{% endif %} {% endif %}
</form> </form>
{% if current_session.id %} {% if current_session.id %}
<a href="#" class="w3-bar-item w3-btn w3-hover-opacity" onclick="download_all_images()"> <a href="#" class="w3-bar-item w3-btn w3-hover-opacity" onclick="download_all_images({{ current_session.id }})">
<i class="fa-solid fa-file-export w3-text-red w3-xlarge"></i> {% trans "Exporter l'ensemble des images" %}<br> <i class="fa-solid fa-file-export w3-text-red w3-xlarge"></i> {% trans "Exporter l'ensemble des images de la session" %}<br>
<span class="w3-margin-left">{{ export_destination }} <span class="w3-margin-left">{{ export_destination }}
</a> </a>
{% endif %} {% endif %}
@@ -65,7 +62,8 @@
const duration = parseInt("{{ duration }}"); const duration = parseInt("{{ duration }}");
const uuid_btn = sId("btn-" + "{{ uuid }}"); const uuid_btn = sId("btn-" + "{{ uuid }}");
const image_endpoint = "{% url 'scanner:export_api' %}"; const image_endpoint = "{% url 'scanner:export_api' %}";
const sid = parseInt("{{ cursid }}"); const export_msg = "{% trans "Confirmer l'export des images." %}";
const export_msg_long = "{% trans "ATTENTION le processus peut être long!" %}";
function setGridColumns(col) { container.style.setProperty("--grid-columns", col); updateActiveButton(col); } function setGridColumns(col) { container.style.setProperty("--grid-columns", col); updateActiveButton(col); }
function handleMultiwell(b) { b.classList.add('w3-green'); } function handleMultiwell(b) { b.classList.add('w3-green'); }
@@ -78,7 +76,7 @@
} }
function image_save(name, img) { function image_save(name, img) {
const ok = confirm(`Télécharger le fichier ?\n\n${name}`); const ok = confirm(export_msg);
if (!ok) return false; if (!ok) return false;
fetch(img) fetch(img)
.then(res => res.blob()) .then(res => res.blob())
@@ -94,7 +92,9 @@
}); });
} }
function download_all_images() { function download_all_images(sid) {
const ok = confirm(export_msg +' '+ export_msg_long);
if (!ok) return false;
fetch(image_endpoint, { fetch(image_endpoint, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@@ -109,7 +109,8 @@
}) })
.catch(error => { .catch(error => {
console.error('Erreur:', error); console.error('Erreur:', error);
}); });
} }
setGridColumns(columns); setGridColumns(columns);
if (uuid_btn) if (uuid_btn)
@@ -17,10 +17,7 @@
{% endfor %} {% endfor %}
</select> </select>
<div class="w3-margin-left w3-margin-bottom"> <div class="w3-margin-left w3-margin-bottom">
{% for ss in experiments %} {% include "scanner/experiment-inc.html" %}
<input class="" type="radio" name="_expid" value="{{ ss.id }}" {% if ss.id == current_experiment.id %}checked{% endif %} onchange="this.form.submit()" >
<label>{{ ss.multiwell }}</label>
{% endfor %}
</div> </div>
{% if current_session.id and current_experiment %} {% if current_session.id and current_experiment %}
@@ -28,7 +25,7 @@
{% endif %} {% endif %}
</form> </form>
{% if current_session.id %} {% if current_session.id %}
<a href="#" class="w3-bar-item w3-btn w3-hover-opacity" onclick="download_all_videos()"> <a href="#" class="w3-bar-item w3-btn w3-hover-opacity" onclick="download_all_videos({{ current_session.id }})">
<i class="fa-solid fa-file-export w3-text-red w3-xlarge"></i> {% trans "Exporter l'ensemble des vidéos" %}<br> <i class="fa-solid fa-file-export w3-text-red w3-xlarge"></i> {% trans "Exporter l'ensemble des vidéos" %}<br>
<span class="w3-margin-left">{{ export_destination }}</span> <span class="w3-margin-left">{{ export_destination }}</span>
</a> </a>
@@ -78,9 +75,12 @@
const ws_route = "{{ ws_route }}"; const ws_route = "{{ ws_route }}";
const uuid_btn = sId("btn-" + "{{ uuid }}"); const uuid_btn = sId("btn-" + "{{ uuid }}");
const image_endpoint = "{% url 'scanner:export_api' %}"; const image_endpoint = "{% url 'scanner:export_api' %}";
const sid = parseInt("{{ cursid }}"); const export_msg = "{% trans "Confirmer l'export des vidéos. ATTENTION le processus peut être long!" %}";
function download_all_videos() {
function download_all_videos(sid) {
const ok = confirm(export_msg);
if (!ok) return false;
fetch(image_endpoint, { fetch(image_endpoint, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@@ -51,7 +51,8 @@
</div> </div>
{% endif %} {% endif %}
<div class="w3-col"> <div class="w3-col">
<div id="_well_btn" class="w3-margin-top"></div> <div id="_well_state" class="w3-margin-top w3-small w3-text-amber w3-justify"></div>
<div id="_well_btn"></div>
</div> </div>
<div class="w3-col"> <div class="w3-col">
<button id="_halt" class="w3-button w3-red w3-round-large w3-margin-top w3-block"> <button id="_halt" class="w3-button w3-red w3-round-large w3-margin-top w3-block">
@@ -63,8 +64,8 @@
<div class="w3-margin-top w3-padding-small w3-small"> <div class="w3-margin-top w3-padding-small w3-small">
<span id="_ts"></span> <span id="_ts"></span>
</div> </div>
<div class="w3-margin-top w3-small w3-center w3-justify"> <div class="w3-margin-top w3-small">
<span id="_scan_state" class="w3-text-amber w3-padding"></span> <span id="_scan_state" class="w3-text-amber w3-padding w3-justify"></span>
</div> </div>
</div> </div>
</div> </div>
@@ -100,7 +101,9 @@
frame_count : sId("_count"), frame_count : sId("_count"),
scan_state : sId("_scan_state"), scan_state : sId("_scan_state"),
simulate : sId("_simulate"), simulate : sId("_simulate"),
well_btn : sId("_well_btn") well_btn : sId("_well_btn"),
well_state : sId("_well_state")
}; };
</script> </script>
<script src="/static/scanner/js/scanning.js"></script> <script src="/static/scanner/js/scanning.js"></script>
+33 -17
View File
@@ -1,13 +1,15 @@
# #
import os
from asgiref.sync import async_to_sync from asgiref.sync import async_to_sync
import base64, json import base64, json
from django.shortcuts import render #, redirect from django.shortcuts import render #, redirect
from django.http import JsonResponse from django.http import JsonResponse, FileResponse
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.views.decorators.http import require_GET, require_POST from django.views.decorators.http import require_GET, require_POST
from django.views.decorators.csrf import csrf_exempt from django.views.decorators.csrf import csrf_exempt
from django.contrib.auth.decorators import login_required, user_passes_test from django.contrib.auth.decorators import login_required, user_passes_test
from django.conf import settings from django.conf import settings
from reduct.time import unix_timestamp_to_iso from reduct.time import unix_timestamp_to_iso
from modules.system_stats import get_cached_stats, start_background_updater from modules.system_stats import get_cached_stats, start_background_updater
from modules import reductstore from modules import reductstore
@@ -198,13 +200,28 @@ def images_view(request):
@csrf_exempt @csrf_exempt
def download_api(request): def download_api(request):
data = json.loads(request.body.decode() or "{}") data = json.loads(request.body.decode() or "{}")
action = data.get("action") try:
if action=='download': action = data.get("action")
uuid, dt_start, dt_stop, frame_rate = data.get("uuid"), data.get("dt_start"), data.get("dt_stop"), data.get("fps") if action=='download':
return download_video.delay(uuid, dt_start, dt_stop, frame_rate=frame_rate) # @UndefinedVariable uuid, dt_start, dt_stop, frame_rate = data.get("uuid"), data.get("dt_start"), data.get("dt_stop"), data.get("fps")
else: task = download_video.delay(uuid, dt_start, dt_stop, frame_rate=frame_rate) # @UndefinedVariable
return JsonResponse({"state": False}) result = task.get(timeout=300)
if result.get('success'):
video_path = result.get('video_path')
if not os.path.exists(video_path):
raise Exception(f"File not created: {video_path}")
response = FileResponse(open(video_path, 'rb'), content_type='video/mp4')
response['Content-Disposition'] = f'attachment; filename="{video_path}"'
response['Content-Length'] = os.path.getsize(video_path)
os.remove(video_path)
return response
return JsonResponse(result)
except Exception as e:
print("download_api error", e)
return JsonResponse({"state": False, "success": False, "msg": str(_("Erreur d'exportation")), })
def get_video(uuid): def get_video(uuid):
oldest, latest = async_to_sync(reductstore.old_last_dates)(cameraDB, entry_name=uuid) oldest, latest = async_to_sync(reductstore.old_last_dates)(cameraDB, entry_name=uuid)
@@ -246,14 +263,13 @@ def export_api(request):
data = json.loads(request.body.decode() or "{}") data = json.loads(request.body.decode() or "{}")
session_id = data.get("sid") session_id = data.get("sid")
action = data.get("action") action = data.get("action")
try:
if action == 'export_images': if action == 'export_images':
export_all_images(session_id) export_all_images(session_id)
return JsonResponse({"success": True, "msg": str(_("Images téléchargées"))}) return JsonResponse({"success": True, "msg": str(_("Images téléchargées"))})
elif action == 'export_videos': elif action == 'export_videos':
export_all_videos(session_id) export_all_videos(session_id)
return JsonResponse({"success": True, "msg": str(_("Vidéos téléchargées"))}) return JsonResponse({"success": True, "msg": str(_("Vidéos téléchargées"))})
else: except:
return JsonResponse({"success": False, "msg": str(_("Erreur d'exportation"))}) return JsonResponse({"state": False, "success": False, "msg": str(_("Erreur d'exportation")), })