second commit

This commit is contained in:
2026-04-13 23:14:05 +02:00
parent 1c031ebf63
commit e08597fc72
125 changed files with 26221 additions and 0 deletions
+64
View File
@@ -0,0 +1,64 @@
from django.contrib import admin
from django.db.models import Q
from . import models
class WellAdmin(admin.ModelAdmin):
model = models.Well
list_display = ('name', 'author',)
class ConfigurationAdmin(admin.ModelAdmin):
list_display = ('name', 'author', 'use_rpicam', 'video_width_capture', 'video_height_capture', 'video_frame_rate', 'active',)
class MultiWellAdmin(admin.ModelAdmin):
list_filter = ('author',)
list_display = ('label', 'position', 'author', 'order', 'xbase', 'ybase', 'duration', 'feed', 'active',)
class ObservationMultiWellDetailInline(admin.TabularInline):
model = models.ObservationMultiWellDetail
extra = 0
class ObservationAdmin(admin.ModelAdmin):
inlines = (ObservationMultiWellDetailInline,)
list_filter = ('sessionobservation__session', 'author', )
list_display = ('title', 'author', 'multiwell', 'created', 'started', 'finished')
readonly_fields = ('created', 'started', 'finished', )
class SessionObservationInlineAdmin(admin.TabularInline):
model = models.SessionObservation
fk_name = 'session'
extra = 0
def formfield_for_foreignkey(self, db_field, request, **kwargs):
if db_field.name == "observation":
obj_id = request.resolver_match.kwargs.get("object_id")
qs = models.Observation.objects.filter(sessionobservation__isnull=True)
if obj_id:
qs = models.Observation.objects.filter(
Q(sessionobservation__isnull=True) |
Q(sessionobservation__session_id=obj_id)
)
kwargs["queryset"] = qs.distinct()
return super().formfield_for_foreignkey(db_field, request, **kwargs)
class SessionAdmin(admin.ModelAdmin):
list_filter = ('author',)
inlines = (SessionObservationInlineAdmin, )
list_display = ('name', 'author', 'created', 'finished', 'active', 'expected_export', 'expected_scanning', )
readonly_fields = (
'created',
'finished',
'export_status',
'export_task',
'export_exported_at',
'scanning_status',
'scanning_task',
'scanning_finished_at'
)
admin.site.register(models.Configuration, ConfigurationAdmin)
admin.site.register(models.Well, WellAdmin)
admin.site.register(models.MultiWell, MultiWellAdmin)
admin.site.register(models.Observation, ObservationAdmin)
admin.site.register(models.Session, SessionAdmin)
+8
View File
@@ -0,0 +1,8 @@
from django.apps import AppConfig
class ScannerConfig(AppConfig):
name = 'scanner'
def ready(self):
import scanner.models # noqa — active les signaux post_save/post_delete
+62
View File
@@ -0,0 +1,62 @@
#
#from django.utils.translation import gettext_lazy as _
#from channels.layers import get_channel_layer
#from django.conf import settings
#import asyncio
#from asgiref.sync import sync_to_async
import json, logging
from channels.generic.websocket import AsyncWebsocketConsumer
from .process import redisDB
logger = logging.getLogger(__name__)
class ScannerConsumer(AsyncWebsocketConsumer):
async def connect(self):
self.this_group = f"scanner_proc"
await self.channel_layer.group_add(self.this_group, self.channel_name)
await self.accept()
logger.info(f"==== connected to {self.this_group}")
async def disconnect(self, close_code):
await self.channel_layer.group_discard(self.this_group, self.channel_name)
logger.info( f"==== Disconnect from {self.this_group}")
async def scanner_message(self, event):
await self.send(text_data=json.dumps(event["text"]))
## Receive message from WebSocket
async def receive(self, text_data):
data = json.loads(text_data)
msg_type = data.get("type")
if msg_type in ["scanner", "calibrate"]:
redisDB.publish(self.this_group, json.dumps(data))
async def replay_message(self, event):
await self.send(text_data=json.dumps(event["text"]))
class ReplayConsumer(AsyncWebsocketConsumer):
async def connect(self):
self.this_group = f"replay_proc"
await self.channel_layer.group_add(self.this_group, self.channel_name)
await self.accept()
logger.info(f"==== connected to {self.this_group}")
async def disconnect(self, close_code):
await self.channel_layer.group_discard(self.this_group, self.channel_name)
logger.info( f"==== Disconnect from {self.this_group}")
async def replay_message(self, event):
await self.send(text_data=json.dumps(event["text"]))
## Receive message from WebSocket
async def receive(self, text_data):
data = json.loads(text_data)
msg_type = data.get("type")
if msg_type == "replay":
redisDB.publish(self.this_group, json.dumps(data))
+604
View File
@@ -0,0 +1,604 @@
# Tâches d'exportation pour les vidéos de caméra
import zipfile
from celery.utils.log import get_task_logger
import shutil
import os, sys
import posix_ipc
import mmap
import cv2
import numpy as np
from django.http import JsonResponse, HttpResponse
from django.conf import settings
from reduct.time import unix_timestamp_to_iso
from .process import CameraRecordManager, cameraDB
logger = get_task_logger(__name__)
def progress_bar(iteration, total, prefix='', suffix='', length=30, fill=''):
percent = ("{0:.1f}").format(100 * (iteration / float(total)))
filled_length = int(length * iteration // total)
bar = fill * filled_length + '-' * (length - filled_length)
sys.stdout.write(f'\r{prefix} |{bar}| {percent}% {suffix}')
sys.stdout.flush()
def delete_file_later(path):
try:
if os.path.exists(path):
os.remove(path)
except Exception as e:
logger.error(f"[cleanup] error deleting {path}: {e}")
raise
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)
async def remove_video(uuid, start_ts, end_ts, when=None):
try:
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'):
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_name = f"/video_frames_{uuid}"
try:
shm = posix_ipc.SharedMemory(shm_name, posix_ipc.O_CREAT | posix_ipc.O_EXCL, size=shm_size)
except posix_ipc.ExistentialError:
existing = posix_ipc.SharedMemory(shm_name, flags=0)
existing.close_fd()
try:
existing.unlink()
except posix_ipc.ExistentialError:
pass
shm = posix_ipc.SharedMemory(shm_name, posix_ipc.O_CREAT | posix_ipc.O_EXCL, size=shm_size)
mm = mmap.mmap(shm.fd, shm_size)
queries = record_manager.query(uuid, start_ts, end_ts)
offset = 0
frame_sizes = []
total = 0
async for record in queries:
frame_bytes = await record.read_all()
frame_size = len(frame_bytes)
mm[offset:offset + frame_size] = frame_bytes
frame_sizes.append(frame_size)
offset += frame_size
total += 1
if not frame_sizes:
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)
# Lit les frames depuis la mémoire partagée
current_offset = 0
i = 0
for size in frame_sizes:
frame_bytes = mm[current_offset:current_offset + size]
nparr = np.frombuffer(frame_bytes, np.uint8)
frame = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
if 'video' not in locals():
height, width, _ = frame.shape
video = cv2.VideoWriter(video_path, fourcc, frame_rate, (width, height))
video.write(frame)
current_offset += size
progress_bar(i + 1, total, prefix=f'Progression {uuid}:', suffix='Terminé', length=30)
i+=1
video.release()
# Nettoie la mémoire partagée
shm.unlink()
# Vérifier que le fichier existe
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:
logger.error(f"shm_download_video: {e}")
return JsonResponse({'error': str(e)}, status=500)
# ─────────────────────────────────────────────
##
# ─────────────────────────────────────────────
def remote_mount_available(mount_point: str = "/mnt/exports_cam") -> bool:
"""
Vérifie que le point de montage Samba est actif et accessible en écriture.
"""
return os.path.ismount(mount_point) and os.access(mount_point, os.W_OK)
def _copy_to_destinations(source_path: str, filename: str) -> dict:
"""
Copie le fichier exporté vers les destinations configurées.
Retourne un dict avec les chemins effectivement écrits.
"""
results = {"local": None, "remote": None}
for dest in settings.EXPORT_DESTINATIONS:
if dest == "local":
# Déjà sur place, rien à copier
results["local"] = source_path
elif dest == "remote":
remote_path = os.path.join(settings.EXPORT_REMOTE_DIR, filename)
try:
if not remote_mount_available(settings.EXPORT_REMOTE_DIR):
logger.warning("Partage Samba non disponible, copie ignorée")
results["remote_error"] = "Montage indisponible"
continue
# Copie locale vers le point de montage Samba
shutil.copy2(source_path, remote_path)
results["remote"] = remote_path
logger.info("Copie distante OK : %s", remote_path)
except OSError as exc:
# Le partage est peut-être absent (machine Windows éteinte)
logger.error("Copie distante échouée [%s] : %s", remote_path, exc)
results["remote_error"] = str(exc)
return results
# ─────────────────────────────────────────────
# Tâche 1 : Export des frames en ZIP d'images
# ─────────────────────────────────────────────
async def export_images_zip(
uuid: str,
start_ts: float | None = None,
end_ts: float | None = None,
jpeg_quality: int = 85,
max_zip_size_mb: int = 0,
max_image_width: int = 0,
max_image_height: int = 0,
):
"""
Exporte les frames d'une caméra sous forme d'archive ZIP contenant des JPEG.
:param uuid: Identifiant de la caméra
:param start_ts: Timestamp de début (epoch secondes)
:param end_ts: Timestamp de fin (epoch secondes)
:param max_zip_size_mb: Taille maximale du ZIP en Mo (0 = illimité)
:param jpeg_quality: Qualité JPEG 1-100 (défaut 85)
:param max_image_width: Redimensionnement max largeur en px (0 = non redimensionné)
:param max_image_height: Redimensionnement max hauteur en px (0 = non redimensionné)
:return: Chemin du fichier ZIP généré + rapport JSON
"""
shm = None
mm = None
shm_name = f"/img_frames_{uuid}"
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)
try:
shm = posix_ipc.SharedMemory(
shm_name, posix_ipc.O_CREAT | posix_ipc.O_EXCL, size=shm_size
)
except posix_ipc.ExistentialError:
existing = posix_ipc.SharedMemory(shm_name, flags=0)
existing.close_fd()
try:
existing.unlink()
except posix_ipc.ExistentialError:
pass
shm = posix_ipc.SharedMemory(
shm_name, posix_ipc.O_CREAT | posix_ipc.O_EXCL, size=shm_size
)
mm = mmap.mmap(shm.fd, shm_size)
queries = record_manager.query(uuid, start_ts, end_ts)
if not start_ts:
start_ts = record_manager.oldest_ts
if not end_ts:
end_ts = record_manager.latest_ts
offset = 0
frame_sizes = []
ts = []
total = 0
async for record in queries:
ts.append(record.timestamp)
frame_bytes = await record.read_all()
frame_size = len(frame_bytes)
mm[offset:offset + frame_size] = frame_bytes
frame_sizes.append(frame_size)
offset += frame_size
total += 1
if not frame_sizes:
return {"status": "error", "message": "Aucune frame trouvée"}
# --- Génération du ZIP ---
max_zip_bytes = max_zip_size_mb * 1024 * 1024 if max_zip_size_mb > 0 else 0
ts_s = unix_timestamp_to_iso(start_ts)
zip_filename = f"{uuid}_{ts_s}.zip"
zip_path = os.path.join(settings.EXPORTS_LOCAL_PATH, 'images', zip_filename)
os.makedirs(os.path.dirname(zip_path), exist_ok=True)
skipped = 0
written = 0
current_offset = 0
encode_params = [cv2.IMWRITE_JPEG_QUALITY, jpeg_quality]
i = 0
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
for idx, size in enumerate(frame_sizes):
# Vérification de la taille du ZIP
if max_zip_bytes and os.path.getsize(zip_path) >= max_zip_bytes:
skipped += len(frame_sizes) - idx
logger.warning(
"export_images_zip: limite %d Mo atteinte, %d frames ignorées",
max_zip_size_mb,
len(frame_sizes) - idx,
)
break
frame_bytes = mm[current_offset:current_offset + size]
nparr = np.frombuffer(frame_bytes, np.uint8)
frame = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
if frame is None:
current_offset += size
skipped += 1
continue
# Redimensionnement optionnel
if max_image_width > 0 or max_image_height > 0:
frame = _resize_frame(frame, max_image_width, max_image_height)
# Encodage JPEG en mémoire puis ajout au ZIP
ok, buf = cv2.imencode(".jpg", frame, encode_params)
if ok:
#zf.writestr(f"frame_{idx:06d}.jpg", buf.tobytes())
ts_iso = unix_timestamp_to_iso(ts[idx])
#logger.info(f"export_images_zip: adding frame {idx} with timestamp {ts_iso} to ZIP")
zf.writestr(f"{uuid}_{ts_iso}.jpg", buf.tobytes())
written += 1
progress_bar(i + 1, total, prefix=f'Progression {uuid}:', suffix='Terminé', length=30)
i+=1
current_offset += size
## Copie vers les destinations (local + Samba)
#destinations = _copy_to_destinations(zip_path, zip_filename)
return {
"status": "success",
"zip_path": zip_path,
"frames_written": written,
"frames_skipped": skipped,
"jpeg_quality": jpeg_quality,
#"destinations": destinations,
}
except Exception as exc:
logger.error("export_images_zip [%s]: %s", uuid, exc, exc_info=True)
return {"status": "error", "message": str(exc)}
finally:
if mm:
mm.close()
if shm:
try:
shm.unlink()
except posix_ipc.ExistentialError:
pass
# ─────────────────────────────────────────────
# Tâche 2 : Export des frames en vidéo MP4
# ─────────────────────────────────────────────
#@shared_task(bind=True)
async def export_video_mp4(
uuid: str,
start_ts: float | None = None,
end_ts: float | None = None,
frame_rate: int = 5,
opencv_fourcc_format='mp4v',
opencv_video_type='mp4',
max_video_size_mb: int = 0,
max_width: int = 0,
max_height: int = 0,
):
"""
Exporte les frames d'une caméra en fichier MP4 via OpenCV.
:param uuid: Identifiant de la caméra
:param start_ts: Timestamp de début (epoch secondes)
:param end_ts: Timestamp de fin (epoch secondes)
:param frame_rate: Images par seconde (défaut 5)
:param max_video_size_mb: Taille maximale du MP4 en Mo (0 = illimité)
:param max_width: Redimensionnement max largeur en px (0 = non redimensionné)
:param max_height: Redimensionnement max hauteur en px (0 = non redimensionné)
:return: Chemin du fichier MP4 généré + rapport JSON
"""
shm = None
mm = None
video = None
shm_name = f"/vid_frames_{uuid}"
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)
try:
shm = posix_ipc.SharedMemory(
shm_name, posix_ipc.O_CREAT | posix_ipc.O_EXCL, size=shm_size
)
except posix_ipc.ExistentialError:
existing = posix_ipc.SharedMemory(shm_name, flags=0)
existing.close_fd()
try:
existing.unlink()
except posix_ipc.ExistentialError:
pass
shm = posix_ipc.SharedMemory(
shm_name, posix_ipc.O_CREAT | posix_ipc.O_EXCL, size=shm_size
)
mm = mmap.mmap(shm.fd, shm_size)
queries = record_manager.query(uuid, start_ts, end_ts)
if not start_ts:
start_ts = record_manager.oldest_ts
if not end_ts:
end_ts = record_manager.latest_ts
offset = 0
frame_sizes = []
total = 0
async for record in queries:
frame_bytes = await record.read_all()
frame_size = len(frame_bytes)
mm[offset:offset + frame_size] = frame_bytes
frame_sizes.append(frame_size)
offset += frame_size
total +=1
if not frame_sizes:
return {"status": "error", "message": "Aucune frame trouvée"}
# --- Génération du MP4 ---
max_video_bytes = max_video_size_mb * 1024 * 1024 if max_video_size_mb > 0 else 0
ts_s = unix_timestamp_to_iso(start_ts)
video_path = os.path.join(
settings.EXPORTS_LOCAL_PATH, 'videos', f"{uuid}_{ts_s}.{opencv_video_type}"
)
os.makedirs(os.path.dirname(video_path), exist_ok=True)
fourcc = cv2.VideoWriter_fourcc(*opencv_fourcc_format)
skipped = 0
written = 0
current_offset = 0
i=0
for idx, size in enumerate(frame_sizes):
# Vérification de la taille du MP4 en cours
if (
max_video_bytes
and video is not None
and os.path.exists(video_path)
and os.path.getsize(video_path) >= max_video_bytes
):
skipped += len(frame_sizes) - idx
logger.warning(
"export_video_mp4: limite %d Mo atteinte, %d frames ignorées",
max_video_size_mb,
len(frame_sizes) - idx,
)
break
frame_bytes = mm[current_offset:current_offset + size]
nparr = np.frombuffer(frame_bytes, np.uint8)
frame = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
if frame is None:
current_offset += size
skipped += 1
continue
# Redimensionnement optionnel
if max_width > 0 or max_height > 0:
frame = _resize_frame(frame, max_width, max_height)
# Initialisation du VideoWriter sur la première frame valide
if video is None:
h, w, _ = frame.shape
video = cv2.VideoWriter(video_path, fourcc, frame_rate, (w, h))
video.write(frame)
written += 1
current_offset += size
progress_bar(i + 1, total, prefix=f'Progression {uuid}:', suffix='Terminé', length=30)
i+=1
if video:
video.release()
if not os.path.exists(video_path):
return {"status": "error", "message": f"Fichier {opencv_video_type} non créé"}
## Copie vers les destinations (local + Samba)
#filename = os.path.basename(video_path)
#destinations = _copy_to_destinations(video_path, filename)
return {
"status": "success",
"video_path": video_path,
"frames_written": written,
"frames_skipped": skipped,
"frame_rate": frame_rate,
"file_size_mb": round(os.path.getsize(video_path) / 1024 / 1024, 2),
#"destinations": destinations,
}
except Exception as exc:
logger.error("export_video_mp4 [%s]: %s", uuid, exc, exc_info=True)
return {"status": "error", "message": str(exc)}
finally:
if mm:
mm.close()
if shm:
try:
shm.unlink()
except posix_ipc.ExistentialError:
pass
# ─────────────────────────────────────────────
# Utilitaire commun
# ─────────────────────────────────────────────
def _resize_frame(
frame: np.ndarray, max_width: int, max_height: int
) -> np.ndarray:
"""
Redimensionne une frame en conservant le ratio si un max est dépassé.
max_width ou max_height à 0 signifie sans contrainte sur cet axe.
"""
h, w = frame.shape[:2]
scale = 1.0
if max_width > 0 and w > max_width:
scale = min(scale, max_width / w)
if max_height > 0 and h > max_height:
scale = min(scale, max_height / h)
if scale < 1.0:
new_w = int(w * scale)
new_h = int(h * scale)
frame = cv2.resize(frame, (new_w, new_h), interpolation=cv2.INTER_AREA)
return frame
# tasks/export_tasks.py
from celery import shared_task, group
from django.utils import timezone
import logging
logger = logging.getLogger(__name__)
@shared_task(bind=True)
def run_session_exports(self, session_id: str):
"""
Point d'entrée déclenché par django_celery_beat.
Lance en parallèle l'export images et l'export vidéo de la session.
"""
from cameras.models import ExportSession
try:
session = ExportSession.objects.get(session_id=session_id)
except ExportSession.DoesNotExist:
logger.error("run_session_exports: session %s introuvable", session_id)
return {"status": "error", "message": "Session introuvable"}
session.status = ExportSession.Status.RUNNING
session.save(update_fields=["status"])
try:
# Lancement en parallèle avec group Celery
job = group(
export_all_images.s(session_id),
export_all_videos.s(session_id),
).apply_async()
results = job.get(timeout=7200) # 2h max pour les deux
session.status = ExportSession.Status.DONE
session.exported_at = timezone.now()
session.save(update_fields=["status", "exported_at"])
return {"status": "success", "results": results}
except Exception as exc:
session.status = ExportSession.Status.ERROR
session.save(update_fields=["status"])
logger.error("run_session_exports [%s]: %s", session_id, exc, exc_info=True)
return {"status": "error", "message": str(exc)}
@shared_task(bind=True)
def export_all_images(self, session_id: str):
"""
Export ZIP de toutes les images de la session.
"""
from cameras.models import ExportSession
session = ExportSession.objects.get(session_id=session_id)
return export_images_zip(
session.camera_uuid,
session.start_ts,
session.end_ts,
max_zip_size_mb = session.max_zip_size_mb,
jpeg_quality = session.jpeg_quality,
max_image_width = session.max_image_width,
max_image_height = session.max_image_height,
)
@shared_task(bind=True)
def export_all_videos(self, session_id: str):
"""
Export MP4 de toutes les vidéos de la session.
"""
from cameras.models import ExportSession
session = ExportSession.objects.get(session_id=session_id)
return export_video_mp4(
session.camera_uuid,
session.start_ts,
session.end_ts,
frame_rate = session.frame_rate,
max_video_size_mb = session.max_video_size_mb,
max_width = session.max_width,
max_height = session.max_height,
)
+308
View File
@@ -0,0 +1,308 @@
# Django models for test tube scanner application
# Created on 10/04/2024
# denis@linuxtarn.org
from django.utils.translation import gettext_lazy as _
import uuid
import json
from django_celery_beat.models import PeriodicTask, ClockedSchedule
from django.dispatch import receiver
from django.db.models.signals import post_save, post_delete
from django.utils import timezone
from django.db import models
from django.contrib.auth.models import User
# Multi-well positions on the table for calibration and observation
MULTIWELL_POSITION = [
('HG', _("Haut gauche")),
('HD', _("Haut droit")),
('BG', _("Bas gauche")),
('BD', _("Bas droit")),
('BM', _("Bas milieu")),
('HM', _("Haut milieu")),
]
FOURCC_FORMAT = [
('mp4v', _("MP4")),
('XVID', _("XVID")),
]
VIDEO_TYPE = [
('mp4', _("MP4")),
('avi', _("AVI")),
]
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"))
author = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name="Auteur", null=True, blank=True)
# 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")
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)
# opencv
opencv_fourcc_format = models.CharField(_("Fourcc"), help_text=_('Opencv fourcc format'), max_length=8, choices=FOURCC_FORMAT, null=True, blank=False, default='mp4v')
opencv_video_type = models.CharField(_("Video type"), help_text=_('Opencv video type'), max_length=8, choices=VIDEO_TYPE, null=True, blank=False, default='mp4')
# Grbl configuration
grbl_xmax = models.FloatField(_("Grbl Xmax"), help_text=_("CNC Grbl Xmax en mm"), blank=False, default=350.0)
grbl_ymax = models.FloatField(_("Grbl Ymax"), help_text=_("CNC Grbl Ymax en mm"), blank=False, default=250.0)
# camera configuration
use_rpicam = models.BooleanField(_("Utiliser rpicam"), help_text=_("Par défaaut. Sinon USB webcam"), default=True)
webcam_device_index = models.PositiveSmallIntegerField(_("Index de la webcam"), help_text=_("Index de la webcam (0, 1, ...) si présente"), default=2)
image_quality = models.PositiveSmallIntegerField(_("Qualité JPEG"), help_text=_("Qualité JPEG (1-100) pour les images exportées"), default=90)
video_jpeg_quality = models.PositiveSmallIntegerField(_("Qualité JPEG pour les vidéos"), help_text=_("Qualité JPEG (1-100) pour les images extraites des vidéos"), default=90)
video_frame_rate = models.FloatField(_("Fréquence vidéos (fps)"), help_text=_("Fréquence d'extraction des images des vidéos (images par seconde)"), default=5.0)
video_width_capture = models.PositiveSmallIntegerField(_("Largeur de capture vidéo"), help_text=_("Largeur de capture vidéo en pixels"), default=1280)
video_height_capture = models.PositiveSmallIntegerField(_("Hauteur de capture vidéo"), help_text=_("Hauteur de capture vidéo en pixels"), default=720)
# Calibration
calibration_crop_radius = models.PositiveSmallIntegerField(_("Rayon de découpe pour la calibration"), help_text=_("Rayon en pixels pour découper les images de calibration en px"), default=150)
calibration_default_multiwell = models.CharField(_("Multi-puits de calibration par défaut"), help_text=_("Position du multi-puits de calibration par défaut"), max_length=8, choices=MULTIWELL_POSITION, default='HG')
calibration_default_feed = models.PositiveIntegerField(_("Vitesse de calibration"), help_text=_("Vitesse de déplacement pour la calibration en mm/mn"), default=1000)
calibration_default_step = models.FloatField(_("Pas de calibration"), help_text=_("Pas de déplacement pour la calibration en mm"), default=1.0)
active = models.BooleanField(_("Actif"), default=False)
class Meta:
ordering = ['id', ]
verbose_name = _("Configuration")
verbose_name_plural = verbose_name
def __str__(self):
return f'{self.name}'
class Well(models.Model):
author = models.ForeignKey(User, on_delete=models.CASCADE, 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)
class Meta:
ordering = ['name', ]
verbose_name = _("Puit")
verbose_name_plural = _("Puits")
def __str__(self):
return f'{self.name}'
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.CASCADE, 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)
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)
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"), help_text=_('Ordre de lecture en serpentin'), max_length=16, null=True, blank=False, default="D,C,B,A")
order = models.PositiveSmallIntegerField(_("Ordre"), help_text=_('Ordre de lecture du multi-puit'), blank=False, default=0)
duration = models.PositiveIntegerField(_("Durée"), help_text=_('Durée du film en secondes'), blank=False, default=120)
xbase = models.FloatField(_("Origine X"), help_text=_('Base origine X en mm'), blank=False, default=50.0)
ybase = models.FloatField(_("Origine Y"), help_text=_('Base origine Y en mm'), blank=False, default=50.0)
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)
active = models.BooleanField(_("Active"), default=True)
def config(self):
return dict(
cols=self.cols,
rows=self.rows,
row_def=self.row_def,
row_order=self.row_order,
dx=self.dx,
dy=self.dy,
duration=self.duration,
feed=self.feed,
xbase=self.xbase,
ybase=self.ybase,
)
return {}
@classmethod
def config_by_position(cls, position):
qs = MultiWell.objects.filter(position__exact=position).values()
if qs:
return dict(qs[0])
return {}
@classmethod
def by_position(cls, position):
return MultiWell.objects.filter(position__exact=position).first()
@classmethod
def all(cls):
return MultiWell.objects.filter(active=True).all()
class Meta:
ordering = ['order', ]
verbose_name = _("Multi-puits")
verbose_name_plural = _("Multi-puits")
def __str__(self):
return f'{self.position}: {self.label}'
class Observation(models.Model):
title = models.CharField(_("Titre de l'observation"), max_length=100, null=True, blank=False)
comment = models.TextField(_("Commentaires"), help_text=_("Descriptions de l'observations"), null=True, blank=True)
author = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name="Auteur", null=True, blank=True)
multiwell = models.ForeignKey(MultiWell, verbose_name=_("Multi-puits"), on_delete=models.SET_NULL, null=True, blank=True)
created = models.DateTimeField(_("Date de création"), default=timezone.now)
started = models.DateTimeField (_("Date de début"), null=True, blank=True)
finished = models.DateTimeField (_("Date de fin"), null=True, blank=True)
class Meta:
ordering = ['-created', ]
verbose_name = _("Observation")
verbose_name_plural = _("Observations")
def __str__(self):
return f'{self.title}: {self.created} {self.multiwell.order}'
class ObservationMultiWellDetail(models.Model):
author = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name="Auteur", null=True, blank=True)
observation = models.ForeignKey(Observation, on_delete=models.CASCADE, related_name="multiwell_details" , null=True, blank=True)
well = models.ForeignKey(Well, verbose_name="Puit", on_delete=models.CASCADE, related_name="observation_details", null=True, blank=True )
detail = models.CharField("Détail", max_length=255)
comment = models.TextField("Commentaire", blank=True)
class Meta:
ordering = ['observation', 'well__name']
unique_together = ["observation", "well"]
verbose_name = _("Observation multi-puits détail")
verbose_name_plural = _("Observations multi-puits détails")
def __str__(self):
return f"{self.observation.title} - {self.well} - {self.detail}"
class Session(models.Model):
class Status(models.TextChoices):
PENDING = "pending", _("En attente")
RUNNING = "running", _("En cours")
DONE = "done", _("Terminé")
ERROR = "error", _("Erreur")
name = models.CharField(_("Nom de la session"), help_text=_("Session d'observations. 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)
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_scanning = models.DateTimeField(_("Date du balayage"), help_text=_("Date du balayage prévue"), null=True, blank=True)
created = models.DateTimeField(_("Date de création"), default=timezone.now)
finished = models.DateTimeField (_("Date de fin"), null=True, blank=True)
export_status = models.CharField(_("Status exportation"), max_length=16, choices=Status.choices, default=Status.PENDING)
export_task = models.OneToOneField(
"django_celery_beat.PeriodicTask",
verbose_name=_("Export médias"),
help_text=_("Programmation de l'exportation des vidéos et images"),
null=True, blank=True, on_delete=models.SET_NULL, related_name="export_session")
export_exported_at = models.DateTimeField(_("Exportation terminée à"), null=True, blank=True)
scanning_status = models.CharField(_("Status scanning"), max_length=16, choices=Status.choices, default=Status.PENDING)
scanning_task = models.OneToOneField(
"django_celery_beat.PeriodicTask",
verbose_name=_("Lancer le balayage"),
help_text=_("Programmation du lancement du balayage"),
null=True, blank=True, on_delete=models.SET_NULL, related_name="scanning_session")
scanning_finished_at = models.DateTimeField(_("Balayage terminé à"), null=True, blank=True)
class Meta:
ordering = ['-created', ]
verbose_name = _("Session d'observation")
verbose_name_plural = _("Sessions d'observation")
def __str__(self):
state = _("Terminée") if not self.active else _("Active")
return f'{self.name}: {state}'
@receiver(post_save, sender=Session)
def create_periodic_task(sender, instance, created, **kwargs):
"""
Crée automatiquement une PeriodicTask à la création d'une session.
La tâche est one-shot : elle se désactive après exécution (one_off=True).
"""
if instance.expected_export:
try:
clocked, _ = ClockedSchedule.objects.get_or_create(clocked_time=instance.expected_export)
export_task = PeriodicTask.objects.create(
name = f"export_session_{instance.id}",
task = "scanner.tasks.run_session_exports",
clocked = clocked,
one_off = True, # se désactive après la première exécution
enabled = True,
last_run_at = None, # force Celery Beat à ne pas la considérer déjà exécutée
start_time = None, # pas de contrainte de démarrage
kwargs = json.dumps({ # paramètres passés à la tâche
"session_id": str(instance.id),
}),
description = f"Export expected at {instance.expected_export}{instance.name}",
)
# Sauvegarde sans re-déclencher le signal
Session.objects.filter(pk=instance.pk).update(export_task=export_task)
except:
pass
if instance.expected_export:
try:
clocked, _ = ClockedSchedule.objects.get_or_create(clocked_time=instance.expected_scanning)
scanning_task = PeriodicTask.objects.create(
name = f"scanning_session_{instance.id}",
task = "scanner.tasks.run_scanning",
clocked = clocked,
one_off = True, # se désactive après la première exécution
enabled = True,
last_run_at = None, # force Celery Beat à ne pas la considérer déjà exécutée
start_time = None, # pas de contrainte de démarrage
kwargs = json.dumps({ # paramètres passés à la tâche
"session_id": str(instance.id),
}),
description = f"Scanning expected at {instance.expected_scanning}{instance.name}",
)
# Sauvegarde sans re-déclencher le signal
Session.objects.filter(pk=instance.pk).update(scanning_task=scanning_task)
except:
pass
@receiver(post_delete, sender=Session)
def delete_periodic_task(sender, instance, **kwargs):
"""
Supprime la PeriodicTask associée quand la session est supprimée.
"""
if instance.export_task:
instance.export_task.delete()
if instance.scanning_task:
instance.scanning_task.delete()
class SessionObservation(models.Model):
author = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name="Auteur", null=True, blank=True)
session = models.ForeignKey(Session, verbose_name=_("Session"), on_delete=models.SET_NULL, null=True, blank=True)
observation = models.ForeignKey(Observation, verbose_name=_("Observation"), on_delete=models.SET_NULL, null=True, blank=True)
@classmethod
def observation_by_session(cls, session_id, active=True):
return [ ss.observation for ss in SessionObservation.objects.filter(session__id=session_id, session__active=active).order_by('observation__multiwell__order') ]
@classmethod
def uuid_from_session(cls, sid):
observations = [ss.observation for ss in SessionObservation.objects.filter(session__id=sid, session__active=False)]
uuid_list = []
for obs in observations:
row_def = obs.multiwell.row_def.split(',')
for row in range(obs.multiwell.rows):
for col in range(obs.multiwell.cols):
uuid = f'{sid}-{obs.multiwell.position}-{row_def[row]}{col+1}'
uuid_list.append(uuid)
return uuid_list
class Meta:
ordering = ['session',]
unique_together = ["session", "observation"]
verbose_name = _("Session observations")
verbose_name_plural = _("Sessions observations")
def __str__(self):
return f'{self.session.name}'
+812
View File
@@ -0,0 +1,812 @@
#
# process.py
import os
os.environ['OPENCV_LOG_LEVEL']="0"
os.environ['OPENCV_FFMPEG_LOGLEVEL']="0"
import cv2
from django.utils.translation import gettext_lazy as _
from datetime import datetime
import time, asyncio, bisect
import json, base64
from threading import Thread, Event, Lock
from queue import Queue
from asgiref.sync import async_to_sync #, sync_to_async
from channels.layers import get_channel_layer
from django.utils import timezone
from django.conf import settings
from celery import Task
from celery.exceptions import Ignore
from celery.utils.log import get_task_logger
from redis import Redis
from dataclasses import dataclass
from modules import reductstore, grbl, utils
## camera devices
from modules.circular_crop import CircularCrop, CropStrategy
from . import models
@dataclass
class ProcTag:
play: bool = True
record: bool = False
uuid: str = None
session: int = 0
logger = get_task_logger(__name__)
redisDB = Redis(host=settings.REDIS_HOST, port=settings.REDIS_PORT, db=0, decode_responses=True)
cameraDB = reductstore.ReductStore(name='camera')
class CameraRecordManager():
def __init__(self, clienDB):
self.clienDB = clienDB
self.is_image = False
self.oldest_ts = None
self.latest_ts = None
async def size(self, uuid, start_ts, end_ts):
try:
queries = self.query(uuid, start_ts, end_ts)
total_size = 0
record_number = 0
latest = None
async for record in queries:
if not record_number:
self.oldest_ts = record.timestamp
frame_bytes = await record.read_all()
total_size += len(frame_bytes)
record_number += 1
latest = record.timestamp
self.latest_ts = latest
return total_size
except:
return None
def black_jpg(self):
frame = cv2.imread(settings.MEDIA_ROOT / 'images' / 'black-screen.jpg', cv2.IMREAD_UNCHANGED)
_, frame = cv2.imencode('.jpg', frame)
black_jpg = frame.tobytes()
return f'data:image/jpeg;base64,{base64.b64encode(black_jpg).decode()}'
def set_filters(self, session=None, test=None):
filters = []
if session:
filters.append({"&session": { "$eq": session} })
if test==True:
filters.append({"&test": { "$contains": "True"} })
when = {"$and": filters}
return when
async def record_content(self, query):
record = await anext(query)
content = await record.read_all()
return record, content
def query(self, uuid, start=None, stop=None, filters=None):
try:
return self.clienDB.query(uuid, start, stop, when=filters, ttl=3600)
except Exception as e:
logger.error(f"CameraRecordManager query: {e}")
def first_image(self, uuid, start=None, stop=None, filters=None):
try:
query = self.query(uuid, start, stop, filters=filters)
record, content = async_to_sync(self.record_content)(query)
self.is_image = True
return f'data:image/jpeg;base64,{base64.b64encode(content).decode()}', record.timestamp
except Exception as e: # @UnusedVariable
pass
#logger.error(f"CameraRecordManager first_image: {e}")
self.is_image = False
return self.black_jpg(), start
def write(self, uuid, frame, labels, ts=None):
try:
if ts is None:
ts = timezone.now()
async_to_sync(self.clienDB.write)(
uuid,
frame,
timestamp=ts,
labels=labels,
content_type='application/octet-stream',
)
except Exception as e:
logger.error(f"CameraRecordManager write: {e}")
async def remove_uuid(self, uuid, start=None, stop=None, when=None):
try:
await self.clienDB.remove_query(uuid, start, stop, when=when)
except Exception as e:
logger.error(f"CameraRecordManager remove: {e}")
def remove(self, uuid, start=None, stop=None, when=None):
asyncio.run(self.remove_uuid(uuid, start, stop, when=when))
class MultiWellManager:
def __init__(self, position, feed=None, step=None, proc=None):
self.set_multiwell(position)
self._feed = feed
self._step = step
self.proc = proc
self.scanner = None
def set_multiwell(self, position):
self._position = position
self.well = models.MultiWell.by_position(position)
self._xbase = self.well.xbase
self._ybase = self.well.ybase
self._dx = self.well.dx
self._dy = self.well.dy
def _start_test(self):
self.scanner.start()
def _start(self, machine, session, observations):
xynext = []
for obs in observations:
xynext.append((obs.multiwell.xbase, obs.multiwell.ybase))
xynext.append((0, 0))
pos = 1
self.proc.session = session.id
started = timezone.now()
for obs in observations:
conf = obs.multiwell.config()
self.scanner = grbl.GridScanner(machine, proc=self.proc, **conf)
obs.started = timezone.now()
obs.save()
xnext, ynext = xynext[pos]
pos +=1
self.scanner.start(xnext=xnext, ynext=ynext, position=obs.multiwell.position)
obs.finished = timezone.now()
obs.save()
session.finished = timezone.now()
session.active = False
session.save()
logger.info(f"==== Session {session.name} terminée à {session.finished} après {session.finished - started} secondes.")
def scan_test(self, machine, duration=5.0):
conf = self.well.config()
conf['duration'] = duration
conf['feed'] = self.feed
conf['xnext'] = self._xbase
conf['ynext'] = self._ybase
self.proc.session = 0
self.scanner = grbl.GridScanner(machine, proc=self.proc, **conf)
Thread(target=self._start_test, daemon=True).start()
def scan(self, machine, sid):
try:
session = models.Session.objects.get(pk=sid)
observations = models.SessionObservation.observation_by_session(sid)
Thread(target=self._start, args=(machine, session, observations, ), daemon=True).start()
except Exception as e:
print("MultiWellManager::scan error", e)
def halt(self):
if self.scanner:
self.scanner.halt()
@property
def position(self):
return self._position
@position.setter
def position(self, value):
self._position = value
@property
def step(self):
return self._step
@step.setter
def step(self, value):
self._step = value
@property
def feed(self):
return self._feed
@feed.setter
def feed(self, value):
self._feed = value
@property
def xbase(self):
return self._xbase
@xbase.setter
def xbase(self, value):
self._xbase = value
@property
def ybase(self):
return self._ybase
@ybase.setter
def ybase(self, value):
self._ybase = value
@property
def dx(self):
return self._dx
@dx.setter
def dx(self, value):
self._dx = value
@property
def dy(self):
return self._dy
@dy.setter
def dy(self, value):
self._dy = value
def set_xy_step(self):
models.MultiWell.objects.filter(position__exact=self.position).update(dx=self.dx, dy=self.dy)
def set_position(self, machine):
x, y = machine.get_mpos()
machine.wait_for(2.0)
models.MultiWell.objects.filter(position__exact=self.position).update(xbase=x, ybase=y)
self._xbase, self._ybase = x, y
class ScannerProcess(Task):
'''
video_quality = settings.VIDEO_JPG_QUALITY
image_quality = settings.IMAGE_JPG_QUALITY
video_fps = settings.VIDEO_FPS
video_width = settings.VIDEO_WIDTH
video_height = settings.VIDEO_HEIGHT
crop_radius = settings.CALIBRATION_CROP_RADIUS
default_multiwell = settings.CALIBRATION_DEFAULT_MULTIWELL
default_feed = settings.CALIBRATION_DEFAULT_FEED
default_step = settings.CALIBRATION_DEFAULT_STEP'''
def __init__(self):
super().__init__()
self.channel_layer = get_channel_layer()
self.group = f'scanner_proc'
self.stop_event = Event()
self.cam = None
self.grbl = None
self.crop = None
self.multiwel = None
self.conf = None
self.record_queue = Queue()
self.proc = ProcTag()
self.manager = None
self.recordDB = CameraRecordManager(cameraDB)
def __call__(self, *args, **kwargs):
return self.start(*args, **kwargs)
def set_crop_radius(self, radius):
return CircularCrop(radius=radius, strategy=CropStrategy.CROP_JPEG, jpeg_quality=self.image_quality)
def start(self, *args, **kwargs):
try:
self.conf = models.Configuration.objects.filter(active=True).first()
self.video_quality = self.conf.video_jpeg_quality
self.image_quality = self.conf.image_quality
self.video_fps = self.conf.video_frame_rate
self.video_width = self.conf.video_width_capture
self.video_height = self.conf.video_height_capture
self.crop_radius = self.conf.calibration_crop_radius
self.default_multiwell = self.conf.calibration_default_multiwell
self.default_feed = self.conf.calibration_default_feed
self.default_step = self.conf.calibration_default_step
self.video_jpg_quality = [int(cv2.IMWRITE_JPEG_QUALITY), self.video_quality]
self.image_jpg_quality = [int(cv2.IMWRITE_JPEG_QUALITY), self.image_quality]
self.grbl_xmax = self.conf.grbl_xmax
self.grbl_ymax = self.conf.grbl_ymax
#self.crop = CircularCrop(radius=self.crop_radius, strategy=CropStrategy.CROP_JPEG, jpeg_quality=self.image_quality)
self.crop = self.set_crop_radius(self.crop_radius)
if not self.conf.use_rpicam:
from modules.webcam_capture import WebcamCapture
self.cam = WebcamCapture(
device_index=self.conf.webcam_device_index,
fps=self.video_fps,
width=self.video_width,
height=self.video_height,
jpeg_quality=self.video_quality,
)
else:
from modules.picamera2_capture import PiCamera2Capture
self.cam = PiCamera2Capture(
fps=self.video_fps,
width=self.video_width,
height=self.video_height,
jpeg_quality=self.video_quality,
)
self.cam.set_frame_callback(self._on_frame)
self.cam.set_median(False)
self.cam.set_circular_crop(None)
self.stop_event.clear()
self.start_services()
except Exception as e:
logger.error(f"Scanner started error: {e}")
raise Ignore()
def stop(self):
try:
info = 'Scanner stopped'
self._send(state='stop', msg=info)
self.stop_event.set()
self.cam.stop()
logger.info(info)
Event().wait(1.0)
finally:
self.stop_event.set()
def start_services(self):
Thread(target=self._listen_to_redis, daemon=True).start()
Thread(target=self._recording, daemon=True).start()
self.cam.start()
def _send(self, **payload):
async_to_sync(self.channel_layer.group_send)(
self.group, {
"type": 'scanner.message',
"text": payload
}
)
def _display(self, **msg):
if self.grbl:
self._send(**msg)
def _on_frame(self, jpeg_bytes: bytes, ts: datetime) -> None:
if self.proc.record:
# record images
self.record_queue.put((self.proc.uuid, ts, jpeg_bytes))
if self.proc.play:
# play image
self._send(ts=ts.timestamp(), jpeg=base64.b64encode(jpeg_bytes).decode(), )
def _recording(self):
logger.info(f"Scanner {self.group}: start recorder")
while not self.stop_event.is_set():
try:
(uuid, ts, frame) = self.record_queue.get()
labels = dict(fps=self.video_fps, session=self.proc.session)
self.recordDB.write(uuid, frame, labels, ts=ts)
self.record_queue.task_done()
except Exception as e:
logger.error(f'recorder: {e}')
def _init_grbl(self, feed=1000):
self.grbl = grbl.GRBLController(
send_callback=self._display,
x_max=self.conf.grbl_xmax,
y_max=self.conf.grbl_ymax
)
self.grbl.go_origin(feed=feed)
self.grbl.wait_for(2.0)
def _listen_to_redis(self):
try:
logger.info(f"==== Scanner {self.group}: listen via redisDB")
pubsub = redisDB.pubsub()
pubsub.subscribe(self.group)
self._init_grbl()
self.manager = MultiWellManager(
self.default_multiwell,
feed=self.default_feed,
step=self.default_step,
proc=self.proc
)
for message in pubsub.listen():
try:
#logger.info(f"{message}")
if self.stop_event.is_set():
break
cmd = json.loads(str(message.get('data')))
logger.info(f"{cmd}")
if not isinstance(cmd, dict):
continue
self._send(state=cmd["type"], msg=f"Cmd: {cmd.get('topic')} {cmd.get('value', '')}")
if cmd["type"]=="scanner":
topic = cmd.get("topic")
if topic == 'init':
self.cam.set_circular_crop(self.crop)
self.cam.set_median(is_median=False)
self.grbl.go_origin(feed=self.manager.feed)
elif topic == 'scan':
sid = cmd.get("session", '0')
if sid == "0":
self._send(state='error', msg=str(_('La session est nulle!...')))
else:
self.cam.set_median(is_median=False)
self.manager.scan(self.grbl, sid)
elif cmd["type"]=="calibrate":
topic = cmd.get("topic")
value = cmd.get("value")
if topic == 'init':
self.manager.feed = int(cmd.get("feed", self.default_feed))
self.manager.step = float(cmd.get("step", self.default_step))
position = cmd.get("position", self.default_multiwell)
if self.manager.position != position:
self.manager.set_multiwell(position)
self.cam.set_circular_crop(None)
self.cam.set_median(is_median=False)
elif topic == 'up':
self.grbl.move_relative(dy=self.manager.step, feed=self.manager.feed)
elif topic == 'down':
self.grbl.move_relative(dy=-self.manager.step, feed=self.manager.feed)
elif topic == 'right':
self.grbl.move_relative(dx=self.manager.step, feed=self.manager.feed)
elif topic == 'left':
self.grbl.move_relative(dx=-self.manager.step, feed=self.manager.feed)
elif topic == 'median':
self.cam.set_median(is_median=value)
elif topic == 'crop':
self.cam.set_circular_crop(self.crop) if value else self.cam.set_circular_crop(None)
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.cam.set_circular_crop(self.crop)
continue
elif topic == 'position':
self.manager.set_multiwell(value)
elif topic == 'step':
self.manager.step = float(value)
elif topic == 'feed':
self.manager.feed = int(value)
elif topic == 'goto_0':
self.grbl.go_origin(feed=self.manager.feed)
elif topic == 'goto_xy':
self.grbl.move_to(self.manager.xbase, self.manager.ybase, feed=self.manager.feed)
elif topic == 'xy_base':
self.manager.set_position(self.grbl)
elif topic == 'dx':
self.manager.dx = float(value)
elif topic == 'dy':
self.manager.dy = float(value)
elif topic == 'xy_step':
self.manager.set_xy_step()
elif topic == 'test':
self.manager.scan_test(self.grbl)
continue
elif topic == 'halt':
self.manager.halt()
continue
self._send(
xbase=self.manager.xbase,
ybase=self.manager.ybase,
x=self.grbl.x,
y=self.grbl.y,
xy=True,
dxy=True,
dx=self.manager.dx,
dy=self.manager.dy
)
except Exception as e:
logger.error(f'scanner listen_to_redis: {e}')
finally:
pubsub.unsubscribe()
pubsub.close()
#=================================================================
#
# REPLAY Buffer glissant
#
# temps réel replay →
# |---- préchargé ----|---- en lecture ----|---- à venir ----|
# -2s t +3s
# max_seconds:
# 3s → faible latence, faible RAM
# 10s → seek ultra fluide
#=================================================================
class ReplayBuffer:
def __init__(self, max_seconds=5.0):
self.max_seconds = max_seconds
self.frames = {} # ts → bytes
self.timestamps = [] # triée
self.lock = Lock()
def push(self, ts, frame):
with self.lock:
if ts in self.frames:
return
bisect.insort(self.timestamps, ts)
self.frames[ts] = frame
self._cleanup(ts)
def get_nearest(self, ts_us: int):
try:
with self.lock:
if not self.timestamps:
return None
idx = bisect.bisect_right(self.timestamps, ts_us) - 1
if idx < 0:
idx = 0
nearest_ts = self.timestamps[idx]
return nearest_ts, self.frames[nearest_ts]
except Exception as e: # @UnusedVariable
pass
#logger.error(f"{e}")
return None
def clear(self):
with self.lock:
self.frames.clear()
self.timestamps.clear()
def _cleanup(self, current_ts):
# supprime les frames trop anciennes
limit = current_ts - self.max_seconds
while self.timestamps and self.timestamps[0] < limit:
ts = self.timestamps.pop(0)
del self.frames[ts]
class ReplayClock:
def __init__(self, uuid, start_ts, stop_ts, fps=5.0, speed=1.0):
self.uuid = uuid
self.start_ts = start_ts
self.stop_ts = stop_ts
self.ts = start_ts
self.fps = fps
self.speed = speed
self.paused = False
self._seek_ts = None
self._last_tick = time.monotonic()
self.lock = Lock()
self.delta = self.stop_ts - self.start_ts
def tick(self):
with self.lock:
now = time.monotonic()
dt_sec = now - self._last_tick
self._last_tick = now
delta_us = int(dt_sec * 1_000_000 * self.speed)
self.ts += delta_us
if self.ts >= self.stop_ts:
self.paused = True
return None
return self.ts
def sleep_duration(self) -> float:
with self.lock:
frame_us = int(1_000_000 / self.fps)
return max((frame_us / self.speed) / 1_000_000, 0.001)
def play(self):
with self.lock:
self.paused = False
def pause(self):
with self.lock:
self.paused = True
def stop(self):
with self.lock:
self.paused = True
self.ts = self.start_ts
return self.ts
def set_speed(self, speed):
with self.lock:
self.speed = max(0.1, speed)
def seek(self, k):
with self.lock:
self._seek_ts = int( self.start_ts + (k * self.delta) )
def consume_seek(self):
with self.lock:
ts = self._seek_ts
self._seek_ts = None
return ts
return None
def progress(self, ts: int) -> float:
ptx = (ts - self.start_ts) / self.delta
return round( max(0.0, min(1.0, ptx)), 6)
class ReplayProcess(Task):
def __init__(self, latency=5.0):
super().__init__()
self.channel_layer = get_channel_layer()
self.latency = latency
self.group = f'replay_proc'
self.recordDB = cameraDB
self.stop_event = Event()
self.clock = None
self.query = None
self.running = asyncio.Event()
def __call__(self, uuid, *args, **kwargs):
return self.start(*args, **kwargs)
def start(self, *args, **kwargs):
try:
self.stop_event.clear()
Thread(target=self._listen_to_redis, daemon=True).start()
except Exception as e:
logger.error(f"Replay error: {e}")
raise Ignore()
def stop(self):
self.stop_event.set()
logger.info(f"==== ReplayProcess stopped.")
def _listen_to_redis(self):
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():
break
cmd = json.loads(str(message.get('data')))
logger.info(f"{cmd}")
if not isinstance(cmd, dict):
continue
if cmd["type"] == "replay":
action = cmd["action"]
if action in ["init", "play"]:
uuid = cmd.get("uuid")
start_ts, stop_ts = int(cmd.get('dt_start')), int(cmd.get('dt_stop'))
fps, speed = float(cmd.get('fps', 5.0)), int(cmd.get('speed'))
self.clock = ReplayClock(uuid, start_ts, stop_ts, fps, speed)
if action == "init":
if loop:
utils.stop_async(loop)
loop = None
elif action == "play":
if not loop:
loop = utils.start_async()
utils.submit_async(loop, self._replay())
self.clock.play()
elif action == 'stop':
self.running.set()
if loop:
utils.stop_async(loop)
loop = None
ts = self.clock.stop()
async_to_sync(self._send_message)('video-reset', dt_start=ts, percent=0.0)
elif action == 'pause':
self.clock.pause()
elif action == 'speed':
self.clock.set_speed(cmd.get("value"))
elif action == 'seek':
k = float(cmd.get("value"))
self.clock.seek(k)
except Exception as e:
logger.error(f'ReplayProcess::listen_to_redis: {e}')
finally:
self.running.set()
utils.stop_async(loop)
pubsub.unsubscribe()
pubsub.close()
async def _send(self, payload):
await self.channel_layer.group_send(self.group, {"type": 'replay.message', "text": payload })
async def _send_message(self, motif, **msg):
payload = {
'uuid': self.clock.uuid,
'motif': motif,
**msg
}
await self._send(payload)
async def _send_frame(self, ts, jpg_bytes):
payload = {
'uuid': self.clock.uuid,
"ts": ts,
"progress": self.clock.progress(ts),
"jpeg": base64.b64encode(jpg_bytes).decode()
}
await self._send(payload)
def _create_query(self, clock):
return self.recordDB.query(clock.uuid, start=clock.ts, stop=clock.stop_ts )
async def _replay(self):
try:
self.running.clear()
query = self._create_query(self.clock)
self.buffer = ReplayBuffer(max_seconds=self.latency)
while not self.running.is_set():
try:
# ---- seek ? ----
seek_ts = self.clock.consume_seek()
if seek_ts is not None:
self.clock.ts = seek_ts
query = self._create_query(self.clock)
await asyncio.sleep(0.01)
continue
# ---- pause ----
if self.clock.paused:
await asyncio.sleep(0.5)
continue
# ---- frame ----
record = await anext(query)
frame = await record.read_all()
self.buffer.push(record.timestamp, frame)
if record.timestamp < self.clock.ts + self.buffer.max_seconds:
continue
# ---- get frame ----
nearest = self.buffer.get_nearest(self.clock.ts)
if nearest is None:
await asyncio.sleep(0.01)
continue
# ---- emit jpg ----
frame_ts, jpg = nearest
await self._send_frame(frame_ts, jpg)
# ---- avance temps ----
self.clock.tick()
await asyncio.sleep(self.clock.sleep_duration())
except StopAsyncIteration:
self.clock.pause()
self.buffer.clear()
except Exception as e:
logger.error(f'_replay loop: {e}')
await asyncio.sleep(0.5)
except Exception as e:
logger.error(f"_replay: {e}")
+13
View File
@@ -0,0 +1,13 @@
#
# routing.py
from django.urls import re_path
from django.conf import settings
from . import consumers
urla = settings.SCANNER_WEBSOCKET_ROUTE
urlb = settings.REPLAY_WEBSOCKET_ROUTE
websocket_urlpatterns = [
re_path(urla, consumers.ScannerConsumer.as_asgi()),
re_path(urlb, consumers.ReplayConsumer.as_asgi()),
]
@@ -0,0 +1,34 @@
.container {
height: 100%;
padding: 0.25em;
display: grid;
grid-template-columns: 180px 1fr 150px;
grid-template-rows: 64px 1fr;
gap: 1em 1em;
grid-auto-flow: row;
grid-template-areas:
"header header header"
"move scanner scan"
}
.header {
align-self: stretch;
grid-area: header;
}
.scanner {
justify-self: center;
align-self: center;
grid-area: scanner;
}
.scan {
padding: 0 0.25em;
align-self: start;
grid-area: scan;
}
.move {
align-self: start;
grid-area: move;
}
@@ -0,0 +1,28 @@
.multiwell_cards {
display: grid;
/*grid-auto-columns: 1fr;
grid-auto-rows: 1fr;*/
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr 1fr 1fr;
gap: 0.1em;
grid-template-areas:
". ."
". ."
". .";
}
button.multiwell {
padding: 0.15em ;
}
#image-grid {
display: grid;
grid-template-columns: repeat(var(--grid-columns, 4), 1fr);
gap: 0.5em;
width: 100%;
height: 100%;
padding: 0.5em;
justify-items: center;
align-items: center;
}
@@ -0,0 +1,30 @@
.container {
height: 100%;
padding: 0.25em;
display: grid;
grid-template-columns: 180px 1fr;
grid-template-rows: 64px 0.5fr;
gap: 1em 1em;
grid-auto-flow: row;
grid-template-areas:
"header header"
"scan scanner"
}
.header {
align-self: stretch;
grid-area: header;
}
.scanner {
justify-self: center;
align-self: center;
grid-area: scanner;
}
.scan {
align-self: start;
grid-area: scan;
}
@@ -0,0 +1,21 @@
:root{
--track-height:10px;
--track-color:#ddd;
--range-color:#4aa3ff;
--thumb-size:18px;
--thumb-color:#fff;
--thumb-border:#4aa3ff;
}
.slider-container{ width:100%; max-width:640px; margin:32px auto; }
.labels{ display:flex; justify-content:space-between; margin-bottom:12px; font-size:14px; }
.range-wrap{ position:relative; height:var(--thumb-size); user-select:none; touch-action:none; }
.track{ position:absolute; left:0; right:0; top:50%; transform:translateY(-50%); height:var(--track-height); background:var(--track-color); border-radius:999px; }
.range-highlight{ position:absolute; top:50%; transform:translateY(-50%); height:var(--track-height); background:var(--range-color); border-radius:999px; }
input[type=range]{ -webkit-appearance:none; appearance:none; position:absolute; left:0; right:0; top:0; width:100%; height:100%; background:transparent; pointer-events:none; }
input[type=range]::-webkit-slider-thumb{ -webkit-appearance:none; appearance:none; pointer-events:auto; width:var(--thumb-size); height:var(--thumb-size); border-radius:50%; background:var(--thumb-color); border:2px solid var(--thumb-border); box-shadow:0 1px 3px rgba(0,0,0,0.15); cursor:pointer; margin-top: calc((var(--track-height) - var(--thumb-size)) / 2); }
input[type=range]::-moz-range-thumb{ pointer-events:auto; width:var(--thumb-size); height:var(--thumb-size); border-radius:50%; background:var(--thumb-color); border:2px solid var(--thumb-border); box-shadow:0 1px 3px rgba(0,0,0,0.15); cursor:pointer; }
input[type=range]::-moz-range-track{ background:transparent; border:0; }
@media (max-width:520px){ .values{ flex-direction:column; align-items:flex-start; } .readable{ min-width:unset; width:100%; } }
@@ -0,0 +1,39 @@
.multiwell_cards {
display: grid;
/*grid-auto-columns: 1fr;
grid-auto-rows: 1fr;*/
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr 1fr 1fr;
gap: 0.1em;
grid-template-areas:
". ."
". ."
". .";
}
button.multiwell {
padding: 0.15em ;
}
#replay-grid {
display: grid;
grid-template-columns: repeat(var(--grid-columns, 1), 1fr);
gap: 0.5em;
width: 100%;
height: 100%;
padding: 0.5em;
justify-items: center;
align-items: center;
}
.replay-slider, .replay-sub-slide, .replay_ts_cursor{
width: 100%;
flex: 0 0 auto;
}
.replay-timeline {
width: 100%;
background: transparent;
display: block;
}
@@ -0,0 +1,31 @@
.container {
padding: 0.25em;
display: grid;
grid-template-columns: 0.25fr 1.5fr;
grid-template-rows: 0.5fr 3fr 0.5fr;
grid-auto-columns: 1fr;
gap: 1em 1em;
grid-auto-flow: row;
grid-template-areas:
"header header"
"move scanner"
"command command";
}
.header {
align-self: stretch;
grid-area: header;
}
.command { grid-area: command; }
.scanner {
justify-self: center;
align-self: center;
grid-area: scanner;
}
.move {
align-self: stretch;
grid-area: move;
}
@@ -0,0 +1,138 @@
class ScannerManager {
constructor(container) {
this.container = container;
this.socket = null;
this.axes = 0;
this.cropping = 0;
this.debug_count = 0
}
toggle_median() { this.axes = !this.axes; return this.axes; }
toggle_crop() { this.croping = !this.croping; return this.croping; }
init_controls() {
this.ts = sId("_ts");
const goto_0 = sId("_goto-0");
const goto_xy = sId("_goto-xy");
const xy_base = sId("_xy-base");
const xy_step = sId("_xy-step");
const up = sId("_up");
const down = sId("_down");
const left = sId("_left");
const right = sId("_right");
this.feed = sId("_feed");
this.step = sId("_step");
this.well = sId("_well");
this.x = sId("_x");
this.y = sId("_y");
this.dx = sId("_dx");
this.dy = sId("_dy");
this.xbase = sId("_xbase");
this.ybase = sId("_ybase");
this.debug = sId("_debug");
const test = sId("_test");
const halt = sId("_halt");
const median = sId("_median");
const crop = sId("_crop");
const crop_radius = sId("_crop_radius");
up.addEventListener('mousedown', (e) => { this._send({ type: 'calibrate', topic: "up" }); });
down.addEventListener('mousedown', (e) => { this._send({ type: 'calibrate', topic: "down" }); });
left.addEventListener('mousedown', (e) => { this._send({ type: 'calibrate', topic: "left" }); });
right.addEventListener('mousedown', (e) => { this._send({ type: 'calibrate', topic: "right" }); });
goto_0.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "goto_0" }); });
goto_xy.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "goto_xy" }); });
xy_base.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "xy_base" }); });
xy_step.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "xy_step" }); });
median.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "median", value: this.toggle_median() }); });
crop.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "crop", value: this.toggle_crop() }); });
crop_radius.addEventListener('change',(e) => { this._send({ type: 'calibrate', topic: "crop_radius", value: 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.dx.addEventListener("change", (e) => { this._send({ type: 'calibrate', topic: "dx", value: e.target.value }); });
this.dy.addEventListener("change", (e) => { this._send({ type: 'calibrate', topic: "dy", value: e.target.value }); });
test.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "test" }); });
halt.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "halt" }); });
}
registerSocket(socket) {
this.socket = socket;
this.init_controls();
}
update(payload) {
try {
if (payload.jpeg) { this.container.src = `data:image/jpeg;base64,${payload.jpeg}`; }
if (payload.xbase) { this.xbase.textContent = payload.xbase; this.ybase.textContent = payload.ybase; }
if (payload.dxy) { this.dy.value=payload.dy; this.dx.value=payload.dx; }
if (payload.xy) { this.x.textContent=payload.x.toFixed(2); this.y.textContent=payload.y.toFixed(2); }
if (payload.state) { this.debug.insertAdjacentHTML('afterbegin', `<li>[ ${++this.debug_count} - ${payload.state} ]: ${payload.msg}</li>`); }
if (payload.ts) { this.ts.textContent = timestampToLocalISOString(payload.ts); }
} catch(e) { console.log(e); }
}
init() {
this.axes = 0;
this.cropping = 0;
this._send({
type: 'calibrate',
topic: "init",
feed: this.feed.value,
step: this.step.value,
position: this.well.value
});
}
start() { this._send({ type: 'scanner', topic: "start"}); }
halt() { this._send({ type: 'scanner', topic: "halt" }); }
_send(message) { this.socket.send(message); }
}
class MetadataSocket {
constructor(url) {
this.url = url;
this.ws = null;
this.manager = null;
this.reconnectDelay = 1000;
this.shouldReconnect = true;
this.reconnect = false;
}
setManager(manager) { this.manager = manager; }
connect() {
this.ws = new WebSocket(this.url);
this.ws.onmessage = (event) => {
const data = JSON.parse(event.data);
this.manager.update(data);
};
this.ws.onopen = (event) => {
if (this.manager && !this.reconnect)
this.manager['init']();
this.reconnect = false;
};
this.ws.onclose = () => {
console.warn(`WebSocket closed...`);
if (this.shouldReconnect) {
this.reconnect = true;
setTimeout(() => {
console.log("Reconnect WebSocket...");
this.connect();
}, this.reconnectDelay);
}
};
}
send(obj) { if (this.ws?.readyState === WebSocket.OPEN) { this.ws.send(JSON.stringify(obj)); } }
}
@@ -0,0 +1,92 @@
class ScannerManager {
constructor(container) {
this.container = container;
this.socket = null;
this.axes = 0;
this.cropping = 1;
this.debug_count = 0
}
toggle_median() { this.axes = !this.axes; return this.axes; }
toggle_crop() { this.croping = !this.croping; return this.croping; }
init_controls() {
this.session= sId("_session");
this.ts = sId("_ts");
this.x = sId("_x");
this.y = sId("_y");
this.debug = sId("_debug");
const scan = sId("_scan");
const halt = sId("_halt");
const median = sId("_median");
const crop = sId("_crop");
median.addEventListener('click',(e) => { this._send({ type: 'calibrate', topic: "median", value: this.toggle_median() }); });
crop.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "crop", value: this.toggle_crop() }); });
scan.addEventListener('click', (e) => { this.scan(); });
halt.addEventListener('click', (e) => { this.halt(); });
}
registerSocket(socket) {
this.socket = socket;
this.init_controls();
}
update(payload) {
try {
if (payload.jpeg) { this.container.src = `data:image/jpeg;base64,${payload.jpeg}`; }
if (payload.xy) { this.x.textContent=payload.x.toFixed(2); this.y.textContent=payload.y.toFixed(2); }
if (payload.state) { this.debug.insertAdjacentHTML('afterbegin', `<li>[ ${++this.debug_count} - ${payload.state} ]: ${payload.msg}</li>`); }
if (payload.ts) { this.ts.textContent = timestampToLocalISOString(payload.ts); }
} catch(e) { console.log(e); }
}
init() { this.axes = 0; this.cropping = 1; this._send({ type: 'scanner', topic: "init", }); }
scan() { this._send({ type: 'scanner', topic: "scan", session: this.session.value ? this.session.value: "0" }); }
halt() { this._send({ type: 'calibrate', topic: "halt" }); }
_send(message) { this.socket.send(message); }
}
class MetadataSocket {
constructor(url) {
this.url = url;
this.ws = null;
this.manager = null;
this.reconnectDelay = 1000;
this.shouldReconnect = true;
this.reconnect = false;
}
setManager(manager) { this.manager = manager; }
connect() {
this.ws = new WebSocket(this.url);
this.ws.onmessage = (event) => {
const data = JSON.parse(event.data);
this.manager.update(data);
};
this.ws.onopen = (event) => {
if (this.manager && !this.reconnect)
this.manager['init']();
this.reconnect = false;
};
this.ws.onclose = () => {
console.warn(`WebSocket closed...`);
if (this.shouldReconnect) {
this.reconnect = true;
setTimeout(() => {
console.log("Reconnect WebSocket...");
this.connect();
}, this.reconnectDelay);
}
};
}
send(obj) { if (this.ws?.readyState === WebSocket.OPEN) { this.ws.send(JSON.stringify(obj)); } }
}
@@ -0,0 +1,220 @@
class DualRangeSlider {
constructor(root, options = {}) {
// root: element containing the slider UI
this.root = (typeof root === 'string') ? document.querySelector(root) : root;
if (!this.root) throw new Error('Root element not found');
// elements
this.slider = this.root.querySelector('.slider');
this.minRange = this.root.querySelector('.min-range');
this.maxRange = this.root.querySelector('.max-range');
this.minNumber = this.root.querySelector('.min-number');
this.maxNumber = this.root.querySelector('.max-number');
this.displayRange = this.root.querySelector('.display-range');
this.highlight = this.root.querySelector('.range-highlight');
// config
this.min = Number(this.slider.dataset.min ?? this.minRange.min ?? 0);
this.max = Number(this.slider.dataset.max ?? this.minRange.max ?? 100);
this.gap = Number(options.gap ?? 0);
this._bindEvents();
this.updateFromInputs();
}
// calc & repaint
updateHighlight() {
const a = Number(this.minRange.value);
const b = Number(this.maxRange.value);
const pctA = (a - this.min) / (this.max - this.min) * 100;
const pctB = (b - this.min) / (this.max - this.min) * 100;
this.highlight.style.left = pctA + '%';
this.highlight.style.width = (pctB - pctA) + '%';
}
// keep values coherent and sync UI
updateFromInputs(triggerSource) {
let a = Number(this.minRange.value);
let b = Number(this.maxRange.value);
if (a > b - this.gap) {
if (triggerSource === 'min') a = b - this.gap;
else if (triggerSource === 'max') b = a + this.gap;
else a = Math.min(a, b - this.gap);
}
a = Math.max(this.min, Math.min(a, this.max));
b = Math.max(this.min, Math.min(b, this.max));
this.minRange.value = a;
this.maxRange.value = b;
this.minNumber.value = a;
this.maxNumber.value = b;
//if (this.displayRange) this.displayRange.textContent = a + ' — ' + b;
if (this.displayRange)
this.displayRange.innerHTML = `<div class="w3-row"><div class="w3-half">${a}</div><div class="w3-half w3-right-align">${b}</div></div>`;
this.updateHighlight();
}
updateFromNumbers() {
let a = Number(this.minNumber.value) || this.min;
let b = Number(this.maxNumber.value) || this.max;
if (a > b - this.gap) a = b - this.gap;
a = Math.max(this.min, Math.min(a, this.max));
b = Math.max(this.min, Math.min(b, this.max));
this.minRange.value = a;
this.maxRange.value = b;
this.minNumber.value = a;
this.maxNumber.value = b;
//if (this.displayRange) this.displayRange.textContent = a + ' — ' + b;
if (this.displayRange)
this.displayRange.innerHTML = `<div class="w3-row"><div class="w3-half">${a}</div><div class="w3-half w3-right-align">${b}</div></div>`;
this.updateHighlight();
}
_bindEvents() {
this.minRange.addEventListener('input', () => this.updateFromInputs('min'));
this.maxRange.addEventListener('input', () => this.updateFromInputs('max'));
this.minNumber.addEventListener('change', () => this.updateFromNumbers());
this.maxNumber.addEventListener('change', () => this.updateFromNumbers());
// click on track reposition nearest thumb
this.slider.addEventListener('click', (e) => {
if (e.target.tagName.toLowerCase() === 'input') return;
const rect = this.slider.getBoundingClientRect();
const clickX = e.clientX - rect.left;
const pct = clickX / rect.width;
const value = Math.round(pct * (this.max - this.min) + this.min);
const distMin = Math.abs(value - Number(this.minRange.value));
const distMax = Math.abs(value - Number(this.maxRange.value));
if (distMin <= distMax) {
this.minRange.value = Math.min(value, Number(this.maxRange.value));
} else {
this.maxRange.value = Math.max(value, Number(this.minRange.value));
}
this.updateFromInputs();
});
}
// public API: get current values
getValues() {
return { min: Number(this.minRange.value), max: Number(this.maxRange.value) };
}
// public API: set values programmatically
setValues(a, b) {
this.minRange.value = a;
this.maxRange.value = b;
this.updateFromInputs();
}
}
/*
// Exemple d'usage
document.addEventListener('DOMContentLoaded', () => {
// initialisation simple
const slider1 = new DualRangeSlider('#myRangeSlider', { gap: 0 });
// accéder aux valeurs
// console.log(slider1.getValues());
// modifier les valeurs depuis le code
// slider1.setValues(10, 50);
});
*/
/**
* Crée le DOM du slider et instancie DualRangeSlider à partir d'options.
* Retourne { instance, root } ou null si erreur.
*
* options = {
* container: selector|Element, // obligatoire
* min: number, max: number, // obligatoire (timestamps)
* valueMin: number, valueMax: number, // optionnel
* ms: boolean, // unité interne en ms
* gap: number, // minimal gap (same unit as min/max)
* classes: { root, slider, ... } // opcional CSS classes overrides
* labels: { left, right } // opcional text
* }
*/
function createAndInitDualRange(options = {}) {
if (!options.container) throw new Error('options.container required');
const parent = (typeof options.container === 'string') ? document.querySelector(options.container) : options.container;
if (!parent) throw new Error('Container element not found');
if (typeof options.min === 'undefined' || typeof options.max === 'undefined') {
throw new Error('options.min and options.max required');
}
const cls = Object.assign({
root: 'slider-container',
display: 'display-range',
rangeWrap: 'range-wrap slider',
sliderSel: 'slider',
track: 'track',
highlight: 'range-highlight',
minRange: 'min-range',
maxRange: 'max-range',
values: 'values',
minNumber: 'min-number',
maxNumber: 'max-number',
}, options.classes || {});
// build root
const root = document.createElement('div');
root.className = cls.root;
// labels
const labels = document.createElement('div');
labels.className = cls.display;
root.appendChild(labels);
// range wrap / slider
const rangeWrap = document.createElement('div');
rangeWrap.className = cls.rangeWrap;
// set data-min/data-max as provided
rangeWrap.dataset.min = String(options.min);
rangeWrap.dataset.max = String(options.max);
const track = document.createElement('div'); track.className = cls.track;
const highlight = document.createElement('div'); highlight.className = cls.highlight;
rangeWrap.appendChild(track);
rangeWrap.appendChild(highlight);
// inputs range
const minR = document.createElement('input'); minR.type = 'range'; minR.className = cls.minRange;
const maxR = document.createElement('input'); maxR.type = 'range'; maxR.className = cls.maxRange;
minR.min = String(options.min); minR.max = String(options.max);
maxR.min = String(options.min); maxR.max = String(options.max);
minR.step = options.step ?? '1'; maxR.step = options.step ?? '1';
minR.value = String(options.valueMin ?? options.min);
maxR.value = String(options.valueMax ?? options.max);
rangeWrap.appendChild(minR); rangeWrap.appendChild(maxR);
root.appendChild(rangeWrap);
// values area
const values = document.createElement('div'); values.className = "w3-row";
const minLabel = document.createElement('div'); minLabel.className = "w3-half";
const minStrong = document.createElement('span'); minStrong.textContent = 'Min)';
const minNum = document.createElement('input'); minNum.type = 'number'; minNum.className = cls.minNumber;
minNum.min = String(options.min); minNum.max = String(options.max); minNum.value = String(options.valueMin ?? options.min); minNum.step = options.step ?? '1';
minLabel.appendChild(minStrong); minLabel.appendChild(minNum);
const maxLabel = document.createElement('div'); maxLabel.className = "w3-half w3-right-align";
const maxStrong = document.createElement('span'); maxStrong.textContent = 'Max';
const maxNum = document.createElement('input'); maxNum.type = 'number'; maxNum.className = cls.maxNumber;
maxNum.min = String(options.min); maxNum.max = String(options.max); maxNum.value = String(options.valueMax ?? options.max);maxNum.step = options.step ?? '1';
maxLabel.appendChild(maxStrong); maxLabel.appendChild(maxNum);
values.appendChild(minLabel); values.appendChild(maxLabel);
root.appendChild(values);
// append to parent
parent.appendChild(root);
const instOptions = { gap: options.gap ?? 0, ms: Boolean(options.ms), callback: options.callback ?? null };
const instance = new DualRangeSlider(root, instOptions);
return instance;
}
@@ -0,0 +1,265 @@
class DualRangeSlider {
constructor(root, options = {}) {
this.root = (typeof root === 'string') ? document.querySelector(root) : root;
if (!this.root) throw new Error('Root element not found');
this.slider = this.root.querySelector('.slider');
this.minRange = this.root.querySelector('.min-range');
this.maxRange = this.root.querySelector('.max-range');
this.minNumber = this.root.querySelector('.min-number');
this.maxNumber = this.root.querySelector('.max-number');
this.displayRange = this.root.querySelector('.display-range');
this.highlight = this.root.querySelector('.range-highlight');
this.useMilliseconds = Boolean(options.ms);
this.callback = options.callback || null;
// lire min/max depuis data- ou attributs, puis normaliser en unité interne
const rawMin = Number(this.slider.dataset.min ?? this.minRange.min ?? 0);
const rawMax = Number(this.slider.dataset.max ?? this.minRange.max ?? 100);
// unité interne = ms si useMilliseconds true, sinon seconds
this.min = this.useMilliseconds ? rawMin : rawMin;
this.max = this.useMilliseconds ? rawMax : rawMax;
// Si ms=true mais HTML inputs fournis en secondes, détecter et convertir automatiquement
// Détection simple : si ms=true et raw values semblent petits (<1e12), on multiplie par 1000
if (this.useMilliseconds) {
const needsConversion = (n) => n && n < 1e12; // timestamp en s
if (needsConversion(this.min)) this.min *= 1000;
if (needsConversion(this.max)) this.max *= 1000;
} else {
// si ms=false mais raw semblent en ms (>1e12), convertir en s
const needsConversion = (n) => n && n > 1e12;
if (needsConversion(this.min)) this.min = Math.floor(this.min / 1000);
if (needsConversion(this.max)) this.max = Math.floor(this.max / 1000);
}
// normaliser les inputs HTML pour qu'ils correspondent à l'unité interne
const setInputRangeAttrs = (el, val) => {
if (!el) return;
if (this.useMilliseconds) el.min = String(this.min);
else el.min = String(this.min);
if (this.useMilliseconds) el.max = String(this.max);
else el.max = String(this.max);
// si value absent, initialiser
if (!el.value) el.value = el.min;
};
setInputRangeAttrs(this.minRange);
setInputRangeAttrs(this.maxRange);
if (this.minNumber) { this.minNumber.min = String(this.min); this.minNumber.max = String(this.max); }
if (this.maxNumber) { this.maxNumber.min = String(this.min); this.maxNumber.max = String(this.max); }
this.gap = Number(options.gap ?? 0) * (this.useMilliseconds ? 1000 : 1);
// if inputs have values, ensure they are in internal unit; convert if necessary
const normalizeInputValue = (el) => {
if (!el) return;
let v = Number(el.value || el.getAttribute('value') || this.min);
if (this.useMilliseconds && v < 1e12) v = v * 1000;
if (!this.useMilliseconds && v > 1e12) v = Math.floor(v / 1000);
el.value = String(Math.max(this.min, Math.min(v, this.max)));
};
normalizeInputValue(this.minRange);
normalizeInputValue(this.maxRange);
normalizeInputValue(this.minNumber);
normalizeInputValue(this.maxNumber);
this._bindEvents();
this.updateFromInputs();
}
_toDate(ts) {
// ts is in internal unit: ms if useMilliseconds else seconds
return this.useMilliseconds ? new Date(Number(ts)) : new Date(Number(ts) * 1000);
}
_formatDate(ts) {
const d = this._toDate(ts);
const pad = (n) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
}
updateHighlight() {
const a = Number(this.minRange.value);
const b = Number(this.maxRange.value);
if (this.callback) this.callback(a, b);
const pctA = (a - this.min) / (this.max - this.min) * 100;
const pctB = (b - this.min) / (this.max - this.min) * 100;
this.highlight.style.left = pctA + '%';
this.highlight.style.width = (pctB - pctA) + '%';
}
updateFromInputs(triggerSource) {
let a = Number(this.minRange.value);
let b = Number(this.maxRange.value);
if (a > b - this.gap) {
if (triggerSource === 'min') a = b - this.gap;
else if (triggerSource === 'max') b = a + this.gap;
else a = Math.min(a, b - this.gap);
}
a = Math.max(this.min, Math.min(a, this.max));
b = Math.max(this.min, Math.min(b, this.max));
this.minRange.value = String(a);
this.maxRange.value = String(b);
if (this.minNumber) this.minNumber.value = String(a);
if (this.maxNumber) this.maxNumber.value = String(b);
//if (this.displayRange) this.displayRange.textContent = `${this._formatDate(a)} — ${this._formatDate(b)}`;
if (this.displayRange)
this.displayRange.innerHTML = `<div class="w3-row"><div class="w3-half">${this._formatDate(a)}</div><div class="w3-half w3-right-align">${this._formatDate(b)}</div></div>`;
this.updateHighlight();
}
updateFromNumbers() {
let a = Number(this.minNumber.value) || this.min;
let b = Number(this.maxNumber.value) || this.max;
if (a > b - this.gap) a = b - this.gap;
a = Math.max(this.min, Math.min(a, this.max));
b = Math.max(this.min, Math.min(b, this.max));
this.minRange.value = String(a);
this.maxRange.value = String(b);
this.minNumber.value = String(a);
this.maxNumber.value = String(b);
//if (this.displayRange) this.displayRange.textContent = `${this._formatDate(a)} — ${this._formatDate(b)}`;
if (this.displayRange)
this.displayRange.innerHTML = `<div class="w3-row"><div class="w3-half">${this._formatDate(a)}</div><div class="w3-half w3-right-align">${this._formatDate(b)}</div></div>`;
this.updateHighlight();
}
_bindEvents() {
this.minRange.addEventListener('input', () => this.updateFromInputs('min'));
this.maxRange.addEventListener('input', () => this.updateFromInputs('max'));
if (this.minNumber) this.minNumber.addEventListener('change', () => this.updateFromNumbers());
if (this.maxNumber) this.maxNumber.addEventListener('change', () => this.updateFromNumbers());
this.slider.addEventListener('click', (e) => {
if (e.target.tagName.toLowerCase() === 'input') return;
const rect = this.slider.getBoundingClientRect();
const clickX = e.clientX - rect.left;
const pct = clickX / rect.width;
const value = Math.round(pct * (this.max - this.min) + this.min);
const distMin = Math.abs(value - Number(this.minRange.value));
const distMax = Math.abs(value - Number(this.maxRange.value));
if (distMin <= distMax) {
this.minRange.value = String(Math.min(value, Number(this.maxRange.value)));
} else {
this.maxRange.value = String(Math.max(value, Number(this.minRange.value)));
}
this.updateFromInputs();
});
}
getValues() {
return { min: Number(this.minRange.value), max: Number(this.maxRange.value) };
}
setValues(a, b) {
// a,b given in same unit as useMilliseconds setting
this.minRange.value = String(a);
this.maxRange.value = String(b);
this.updateFromInputs();
}
}
/**
* Crée le DOM du slider et instancie DualRangeSlider à partir d'options.
* Retourne { instance, root } ou null si erreur.
*
* options = {
* container: selector|Element, // obligatoire
* min: number, max: number, // obligatoire (timestamps)
* valueMin: number, valueMax: number, // optionnel
* ms: boolean, // unité interne en ms
* gap: number, // minimal gap (same unit as min/max)
* classes: { root, slider, ... } // opcional CSS classes overrides
* labels: { left, right } // opcional text
* }
*/
function createAndInitDualRange(options = {}) {
if (!options.container) throw new Error('options.container required');
const parent = (typeof options.container === 'string') ? document.querySelector(options.container) : options.container;
if (!parent) throw new Error('Container element not found');
if (typeof options.min === 'undefined' || typeof options.max === 'undefined') {
throw new Error('options.min and options.max required');
}
const cls = Object.assign({
root: 'slider-container',
display: 'display-range',
rangeWrap: 'range-wrap slider',
sliderSel: 'slider',
track: 'track',
highlight: 'range-highlight',
minRange: 'min-range',
maxRange: 'max-range',
values: 'values',
minNumber: 'min-number',
maxNumber: 'max-number',
}, options.classes || {});
// build root
const root = document.createElement('div');
root.className = cls.root;
// labels
const labels = document.createElement('div');
labels.className = cls.display;
root.appendChild(labels);
// range wrap / slider
const rangeWrap = document.createElement('div');
rangeWrap.className = cls.rangeWrap;
// set data-min/data-max as provided
rangeWrap.dataset.min = String(options.min);
rangeWrap.dataset.max = String(options.max);
const track = document.createElement('div'); track.className = cls.track;
const highlight = document.createElement('div'); highlight.className = cls.highlight;
rangeWrap.appendChild(track);
rangeWrap.appendChild(highlight);
// inputs range
const minR = document.createElement('input'); minR.type = 'range'; minR.className = cls.minRange;
const maxR = document.createElement('input'); maxR.type = 'range'; maxR.className = cls.maxRange;
minR.min = String(options.min); minR.max = String(options.max);
maxR.min = String(options.min); maxR.max = String(options.max);
minR.step = options.step ?? '1'; maxR.step = options.step ?? '1';
minR.value = String(options.valueMin ?? options.min);
maxR.value = String(options.valueMax ?? options.max);
rangeWrap.appendChild(minR); rangeWrap.appendChild(maxR);
root.appendChild(rangeWrap);
// values area
const values = document.createElement('div'); values.className = "w3-row";
const minLabel = document.createElement('div'); minLabel.className = "w3-half";
const minStrong = document.createElement('span'); minStrong.textContent = 'Min (ms)';
const minNum = document.createElement('input'); minNum.type = 'number'; minNum.className = cls.minNumber;
minNum.min = String(options.min); minNum.max = String(options.max); minNum.value = String(options.valueMin ?? options.min); minNum.step = options.step ?? '1';
minLabel.appendChild(minStrong); minLabel.appendChild(minNum);
const maxLabel = document.createElement('div'); maxLabel.className = "w3-half w3-right-align";
const maxStrong = document.createElement('span'); maxStrong.textContent = 'Max (ms)';
const maxNum = document.createElement('input'); maxNum.type = 'number'; maxNum.className = cls.maxNumber;
maxNum.min = String(options.min); maxNum.max = String(options.max); maxNum.value = String(options.valueMax ?? options.max);maxNum.step = options.step ?? '1';
maxLabel.appendChild(maxStrong); maxLabel.appendChild(maxNum);
values.appendChild(minLabel); values.appendChild(maxLabel);
root.appendChild(values);
// append to parent
parent.appendChild(root);
const instOptions = { gap: options.gap ?? 0, ms: Boolean(options.ms), callback: options.callback ?? null };
const instance = new DualRangeSlider(root, instOptions);
return instance;
}
@@ -0,0 +1,260 @@
class ReplayProgressBar {
constructor(parent, input) {
this.input = input;
this.parent = parent;
this.isUserSeeking = false;
input.addEventListener("input", () => {
this.isUserSeeking = true;
const percent = input.value / 1000;
this.parent.seek(percent);
});
input.addEventListener("change", () => {
this.isUserSeeking = false;
});
}
update(progress) {
if (this.isUserSeeking) return;
this.input.value = progress * 1000;
}
}
class ReplaySpeedControl {
constructor(parent, initialSpeed = 1) {
this.parent = parent;
this.SPEED_STEPS = [0.25, 0.5, 0.75, 1, 2, 4, 8, 16, 32];
const initialIndex = this.SPEED_STEPS.indexOf(initialSpeed) !== -1
? this.SPEED_STEPS.indexOf(initialSpeed)
: this.SPEED_STEPS.indexOf(1);
this.parent.speed_control.addEventListener("input", () => {
this.parent.speed_label.textContent = this.SPEED_STEPS[this.parent.speed_control.value];
});
this.parent.speed_control.addEventListener("change", () => {
const speed = this.getSpeed();
this.parent.setSpeed(speed);
});
}
getSpeed() {
return this.SPEED_STEPS[this.parent.speed_control.value];
}
updateSpeed(speed) {
this.parent.speed_control.value = this.SPEED_STEPS.indexOf(speed);
this.parent.speed_label.textContent = this.SPEED_STEPS[this.parent.speed_control.value];
}
}
class ReplayManager {
constructor(options = {}) {
this.img = options.img;
this.btplay = options.play;
this.btpause= options.pause;
this.btstop = options.stop;
this.ts = options.ts;
this.btsnapshot = options.snapshot;
this.btvideosnap = options.videosnap;
this.speed_label = options.speed_label;
this.speed_control = options.speed_control;
this.ts_iso = options.ts_iso;
this.percent = options.percent;
this.dt_left = options.dt_left;
this.dt_right = options.dt_right;
this.timeline = options.timeline;
this.uuid = options.uuid;
this.fps = options.fps;
this.video_endpoint = options.video_endpoint;
this.dt_start = options.dt_start;
this.dt_stop = options.dt_stop;
this.video_type = options.video_type || 'mp4';
this.state = null;
this.socket = null;
this.cursor = null;
}
registerSocket(socket) {
this.socket = socket;
}
async start() {
if (!this.uuid) return;
this.btplay.addEventListener('click', (e) => { this.play(); });
this.btpause.addEventListener('click', (e) => { this.pause(); });
this.btstop.addEventListener('click', (e) => { this.stop(); });
this.btsnapshot.addEventListener('click', (e) => { this.snapshot(); });
this.btvideosnap.addEventListener('click', (e) => { this.videosnap(); });
this.speedControl = new ReplaySpeedControl(this);
this.progressBar = new ReplayProgressBar(this, this.timeline);
this._update_container(this.dt_start);
this.updateState("stopped");
}
updateProgessBar(progress) {
if (this.progressBar) {
this.progressBar.update(progress);
}
}
updateState(state) {
const rules = {
stopped: { play: true, pause: false, stop: false, range: true, snapshot: true, videosnap: true },
playing: { play: false, pause: true, stop: true, range: false , snapshot: false, videosnap: false },
paused: { play: true, pause: false, stop: true, range: true , snapshot: true, videosnap: true }
};
const rule = rules[state];
this.btplay.disabled = !rule.play;
this.btpause.disabled = !rule.pause;
this.btstop.disabled = !rule.stop;
this.btsnapshot.disabled = !rule.snapshot;
this.btvideosnap.disabled = !rule.videosnap;
this.timeline.disabled = rule.range;
}
_setState(state) {
this.state = state;
this.updateState(state);
}
_update_container(cursor) {
this.cursor = cursor;
this.filter = {
uuid: this.uuid,
dt_start: cursor,
dt_stop: this.dt_stop,
fps: this.fps,
speed: this.speedControl.getSpeed()
}
this.percent.textContent = '';
this.updateProgessBar(0.0);
const dt = cursor / 1_000;
this.ts_iso.textContent = toLocalISOString(new Date(dt));
this.dt_left.textContent = timestampToLocalISOString(dt/1_000);
this.dt_right.textContent = timestampToLocalISOString(this.dt_stop/1_000_000);
this._setState("stopped");
}
_update_content_slider(ts, progress) {
this.cursor = ts;
this.ts_iso.textContent = toLocalISOString(new Date(ts/1000));
const percent = Math.ceil(progress * 1000000) / 10000;
this.percent.textContent = percent.toFixed(3) +' %';
this.updateProgessBar(progress);
}
update(payload) {
try {
if (payload.jpeg) { this.img.src = `data:image/jpeg;base64,${payload.jpeg}`; }
if (payload.ts) { this._update_content_slider(payload.ts, payload.progress); }
if (payload.motif === "video-reset") { this._update_container(payload.dt_start); }
} catch(e) { console.log(e); }
}
init() { this._send({ type: 'replay', action: "init", }); }
play() { if (this.state === "playing") return; this._setState("playing"); this._send({ type: 'replay', action: "play", }); }
pause() { if (this.state !== "playing") return; this._setState("paused"); this._send({ type: 'replay', action: "pause", }); }
stop() { this._setState("stopped"); this._send({ type: 'replay', action: "stop", }); }
setSpeed(speed) { this._send({ type: 'replay', action: "speed", value: speed }); }
seek(percent) { this._send({ type: 'replay', action: "seek", value: percent }); }
videosnap() {
let filename = `${this.uuid}-${this.ts_iso.textContent}.${this.video_type}`;
filename = filename.replace(/ /g, '_');
const ok = confirm(`Télécharger le fichier ?\n\n${filename}`);
if (!ok) return false;
fetch(this.video_endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: 'download',
uuid: this.uuid,
dt_start: this.cursor,
dt_stop: this.dt_stop,
fps: this.fps
})
}).then(res => {
if (!res.ok) throw new Error(res.status);
return res.blob();
}).then(blob => {
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
}).catch(error => {
console.error('Erreur:', error);
});
}
snapshot() {
let filename = `${this.uuid}-${this.ts_iso.textContent}.jpg`;
filename = filename.replace(/ /g, '_');
const ok = confirm(`Télécharger le fichier ?\n\n${filename}`);
if (!ok) return false;
const a = document.createElement('a');
a.href = this.img.src;
a.download = filename || 'image';
document.body.appendChild(a);
a.click();
a.remove();
}
_send(message) {
if (!this.uuid) return;
this.socket.send({ ...message, ...this.filter });
}
}
class MetadataSocket {
constructor(url) {
this.url = url;
this.ws = null;
this.manager = null;
this.reconnectDelay = 1000;
this.shouldReconnect = true;
this.reconnect = false;
}
setManager(manager) { this.manager = manager; }
connect() {
this.ws = new WebSocket(this.url);
this.ws.onmessage = (event) => {
const data = JSON.parse(event.data);
this.manager.update(data);
};
this.ws.onopen = (event) => {
if (this.manager && !this.reconnect)
this.manager['init']();
this.reconnect = false;
};
this.ws.onclose = () => {
console.warn(`WebSocket closed...`);
if (this.shouldReconnect) {
this.reconnect = true;
setTimeout(() => {
console.log("Reconnect WebSocket...");
this.connect();
}, this.reconnectDelay);
}
};
}
send(obj) { if (this.ws?.readyState === WebSocket.OPEN) { this.ws.send(JSON.stringify(obj)); } }
}
@@ -0,0 +1,132 @@
class ScannerManager {
constructor(container, multiwells=null) {
this.container = container;
this.socket = null;
this.multiweels = multiwells;
this.axes = 0;
this.cropping = 0;
}
toggle_median() { this.axes = !this.axes; return this.axes; }
toggle_crop() { this.croping = !this.croping; return this.croping; }
init_controls() {
const goto_0 = sId("_goto-0");
const goto_xy = sId("_goto-xy");
const xy_base = sId("_xy-base");
const xy_step = sId("_xy-step");
const up = sId("_up");
const down = sId("_down");
const left = sId("_left");
const right = sId("_right");
this.feed = sId("_feed");
this.step = sId("_step");
this.well = sId("_well");
this.x = sId("_x");
this.y = sId("_y");
this.dx = sId("_dx");
this.dy = sId("_dy");
this.xbase = sId("_xbase");
this.ybase = sId("_ybase");
const test = sId("_test");
const halt = sId("_halt");
const median = sId("_median");
const crop = sId("_crop");
up.addEventListener('mousedown', (e) => { this._send({ type: 'calibrate', topic: "up" }); });
down.addEventListener('mousedown', (e) => { this._send({ type: 'calibrate', topic: "down" }); });
left.addEventListener('mousedown', (e) => { this._send({ type: 'calibrate', topic: "left" }); });
right.addEventListener('mousedown', (e) => { this._send({ type: 'calibrate', topic: "right" }); });
goto_0.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "goto_0" }); });
goto_xy.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "goto_xy" }); });
xy_base.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "xy_base" }); });
xy_step.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "xy_step" }); });
median.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "median", value: this.toggle_median() }); });
crop.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "crop", value: this.toggle_crop() }); });
this.well.addEventListener("change", (e) => { this._send({ type: 'calibrate', topic: "well", 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.dx.addEventListener("change", (e) => { this._send({ type: 'calibrate', topic: "dx", value: e.target.value }); });
this.dy.addEventListener("change", (e) => { this._send({ type: 'calibrate', topic: "dy", value: e.target.value }); });
test.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "test" }); });
halt.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "halt" }); });
}
registerSocket(socket) {
this.socket = socket;
this.init_controls();
}
update(payload) {
try {
if (payload.jpeg) { this.container.src = `data:image/jpeg;base64,${payload.jpeg}`; }
if (payload.xbase) { this.xbase.textContent = payload.xbase; this.ybase.textContent = payload.ybase; }
if (payload.dxy) { this.dy.value=payload.dy; this.dx.value=payload.dx; }
if (payload.xy) { this.x.textContent=payload.x; this.y.textContent=payload.y; }
//if (payload.ts) { console.log(payload.ts); }
} catch(e) { console.log(e); }
}
init() {
this._send({
type: 'scanner',
topic: "init",
feed: this.feed.value,
step: this.step.value,
well: this.well.value
});
}
start() { this._send({ type: 'scanner', topic: "start"}); }
halt() { this._send({ type: 'scanner', topic: "halt" }); }
_send(message) { this.socket.send(message); }
}
class MetadataSocket {
constructor(url) {
this.url = url;
this.ws = null;
this.manager = null;
this.reconnectDelay = 1000;
this.shouldReconnect = true;
this.reconnect = false;
}
setManager(manager) { this.manager = manager; }
connect() {
this.ws = new WebSocket(this.url);
this.ws.onmessage = (event) => {
const data = JSON.parse(event.data);
this.manager.update(data);
};
this.ws.onopen = (event) => {
if (this.manager && !this.reconnect)
this.manager['init']();
this.reconnect = false;
};
this.ws.onclose = () => {
console.warn(`WebSocket closed...`);
if (this.shouldReconnect) {
this.reconnect = true;
setTimeout(() => {
console.log("Reconnect WebSocket...");
this.connect();
}, this.reconnectDelay);
}
};
}
send(obj) { if (this.ws?.readyState === WebSocket.OPEN) { this.ws.send(JSON.stringify(obj)); } }
}
@@ -0,0 +1,31 @@
const cpu_used = sId('cpu-used');
const shm_used = sId('shm-used');
const mem_used = sId('mem-used');
const disk_used = sId('disk-used');
const ramdisk_used = sId('ramdisk-used');
let autoTimer = null;
async function fetchStats() {
try {
const r = await fetch(stats_endpoint, { credentials: 'same-origin' });
if (!r.ok) throw new Error('HTTP ' + r.status);
const j = await r.json();
//console.log(j);
const cpu_percent = j.cpu_info.cpu_percent+'%'; cpu_used.style.setProperty("--cpu-used", cpu_percent); cpu_used.title=`Cpu: ${cpu_percent}`;
const shm_length = j.shm.length; shm_used.style.setProperty("--shm_used", shm_length); shm_used.title= `Shm: ${shm_length}`;
const virtual_memory = j.memory_info.virtual_memory.percent+'%'; mem_used.style.setProperty("--mem-used", virtual_memory); mem_used.title=`Mem: ${virtual_memory}`;
const root_percent = j.disk_info.root.percent+'%'; disk_used.style.setProperty("--disk-used", root_percent); disk_used.title=`Disk: ${root_percent}`;
let ramdisk_percent = "0%";
if (! j.ramdisk_info) ramdisk_percent = j.ramdisk_info.percent+'%';
ramdisk_used.style.setProperty("--ramdisk-used", ramdisk_percent); ramdisk_used.title=`Ramdisk: ${ramdisk_percent}`;
} catch (e) {
console.log('Error: ' + e.message);
}
}
function auto_fetch_start() { fetchStats(); autoTimer = setInterval(fetchStats, 5000);}
function auto_fetch_stop() { clearInterval(autoTimer); autoTimer = null; }
auto_fetch_start();
+243
View File
@@ -0,0 +1,243 @@
# tasks.py
import asyncio
from celery import shared_task, group, chord, chain
from celery.utils.log import get_task_logger
from django.utils import timezone
from .process import ScannerProcess, ReplayProcess
from .export_tasks import shm_download_video, export_images_zip, export_video_mp4
from . import models
logger = get_task_logger(__name__)
class ScannerTaskManager:
def __init__(self):
self.scanner = None
self.replay = None
def start_scanner(self):
if self.scanner is None:
self.scanner = ScannerProcess()
self.scanner.start()
def stop_scanner(self):
if self.scanner:
self.scanner.stop()
def start_replay(self, latency=5.0):
if self.replay is None:
self.replay = ReplayProcess(latency=latency)
self.replay.start()
def stop_replay(self):
if self.replay:
self.replay.stop()
task_manager = ScannerTaskManager()
@shared_task(bind=True)
def scanner_start(self):
task_manager.start_scanner()
return f"Scanner démarré."
#@shared_task(bind=True)
#def scanner_stop(self):
# task_manager.stop_scanner()
# return f"Scanner arrêté."
@shared_task(bind=True)
def replay_start(self):
task_manager.start_replay()
return f"Replay démarré."
#@shared_task(bind=True)
#def replay_stop(self):
# task_manager.stop_replay()
# return f"Replay arrêté."
@shared_task
def download_video(uuid, start_ts, end_ts, frame_rate=10, opencv_fourcc_format='mp4v', opencv_video_type='mp4'):
try:
return asyncio.run(shm_download_video(uuid, start_ts, end_ts, frame_rate, opencv_fourcc_format, opencv_video_type))
except Exception as e:
logger.error(f"download_video: {e}")
@shared_task
def export_images(
uuid: str,
start_ts: float | None = None,
end_ts: float | None = None,
jpeg_quality: int = 90,
max_zip_size_mb: int = 0,
max_image_width: int = 0,
max_image_height: int = 0 ):
try:
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:
logger.error(f"export_images: {e}")
@shared_task
def export_all_images(session_id=None):
try:
conf = models.Configuration.objects.filter(active=True).first()
if session_id is None:
sessions = [s.id for s in models.Session.objects.filter(active=False)]
else:
sessions = [session_id]
for session_id in sessions:
uuid_list = models.SessionObservation.uuid_from_session(session_id)
job_zip = []
for uuid in uuid_list:
job = export_images.delay( # @UndefinedVariable
uuid,
start_ts=None,
end_ts=None,
jpeg_quality=conf.video_jpeg_quality, # Qualité JPEG
)
job_zip.append(job.id)
return job_zip
except Exception as e:
logger.error(f"export_images: {e}")
@shared_task
def export_videos(uuid: str,
start_ts: float | None = None,
end_ts: float | None = None,
frame_rate: int = 5,
opencv_fourcc_format='mp4v',
opencv_video_type='mp4',
max_video_size_mb: int = 0,
max_width: int = 0,
max_height: int = 0, ):
try:
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:
logger.error(f"export_videos: {e}")
@shared_task
def export_all_videos(session_id=None):
try:
conf = models.Configuration.objects.filter(active=True).first()
if session_id is None:
sessions = [s.id for s in models.Session.objects.filter(active=False)]
else:
sessions = [session_id]
for session_id in sessions:
uuid_list = models.SessionObservation.uuid_from_session(session_id)
job_mp4 = []
for uuid in uuid_list:
job = export_videos.delay( # @UndefinedVariable
uuid,
start_ts=None,
end_ts=None,
frame_rate=conf.video_frame_rate, # Frame rate de la vidéo exportée
opencv_fourcc_format=conf.opencv_fourcc_format, # Format de compression vidéo (ex: 'mp4v' pour MP4),
opencv_video_type=conf.opencv_video_type, # Type de vidéo exportée (ex: 'mp4', 'avi', 'mkv'),
)
job_mp4.append(job.id)
return job_mp4
except Exception as e:
logger.error(f"export_all_videos: {e}")
@shared_task(bind=True)
def run_session_exports(self, session_id: str):
"""
Orchestre l'export images + vidéo en parallèle via chord.
Lance en parallèle l'export images et l'export vidéo de la session.
Le callback on_exports_done est appelé quand les 2 sont terminés.
"""
try:
session = models.Session.objects.get(pk=session_id)
except models.Session.DoesNotExist:
logger.error("run_session_exports: session %s introuvable", session_id)
return {"status": "error", "message": "Session introuvable"}
session.status = models.Session.Status.RUNNING
session.save(update_fields=["export_status"])
chord(
group(
export_all_images.s(session_id),
export_all_videos.s(session_id),
),
on_exports_done.s(session_id=session_id)
).apply_async()
@shared_task
def on_exports_done(results: list, session_id: str):
"""
Callback appelé par chord quand export_all_images ET export_all_videos
sont tous les deux terminés.
results = [résultat_images, résultat_vidéo]
"""
session = models.Session.objects.get(pk=session_id)
errors = [r for r in results if isinstance(r, dict) and r.get("export_status") == "error"]
if errors:
session.export_status = models.Session.Status.ERROR
logger.error(f"on_exports_done [{session_id}]: échec avec {len(errors)} erreurs")
else:
session.export_status = models.Session.Status.DONE
logger.info(f"on_exports_done [{session_id}]: succès export terminé")
session.export_exported_at = timezone.now()
session.save(update_fields=["export_status", "export_exported_at"])
return {"status": session.export_status}
@shared_task
def scanning(session_id: str):
try:
scanner = task_manager.scanner
scanner.cam.set_median(is_median=False)
scanner.cam.set_circular_crop(scanner.crop)
scanner.grbl.go_origin(feed=scanner.manager.feed)
scanner.manager.scan(scanner.grbl, session_id)
except Exception as e:
logger.error(f"scanning session: {session_id} error {e}")
return {"status": "error", "message": str(e)}
@shared_task(bind=True)
def run_scanning(self, session_id: str):
try:
session = models.Session.objects.get(pk=session_id)
if not session.active:
raise Exception("La session n'est plus active")
except models.Session.DoesNotExist:
logger.error("run_session_exports: session %s introuvable", session_id)
return {"status": "error", "message": "Session introuvable"}
session.scanning_status = models.Session.Status.RUNNING
session.save(update_fields=["scanning_status"])
chain(
scanning.s(session_id),
on_scanning_done.s(session_id=session_id),
).apply_async()
@shared_task
def on_scanning_done(result: dict, session_id: str):
"""
Callback appelé automatiquement à la fin de scanning().
Met à jour scanning_status en base — détectable par polling.
"""
try:
session = models.Session.objects.get(pk=session_id)
except models.Session.DoesNotExist:
logger.error("on_scanning_done: session %s introuvable", session_id)
return
session.scanning_status = models.Session.Status.ERROR if result.get("status") == "error" else models.Session.Status.DONE
session.scanning_finished_at = timezone.now()
session.save(update_fields=["scanning_status", "scanning_finished_at"])
@@ -0,0 +1,128 @@
{% extends 'base.html' %}
{% load i18n home_tags %}
{% block header %}
<div class="w3-bar w3-dark-light">
<a href="#" class="w3-bar-item w3-mobile w3-btn w3-hover-opacity w3-xlarge w3-text-yellow" onclick="toggleSidebar()" title="{% trans 'Barre de côté' %}">
<i class="fa-solid fa-right-left"></i>
</a>
<div class="w3-bar-item w3-btn w3-hover-opacity w3-xlarge">
{{ choice_title }}
</div>
<a href="#" class="w3-bar-item w3-mobile w3-btn w3-hover-opacity w3-right w3-xlarge w3-text-green" onclick="goFullscreen()" title="{% trans 'Ecran plein' %}">
<i class="fa-solid fa-maximize"></i>
</a>
</div>
{% endblock %}
{% block sidebar %}
<div class="w3-dark-xlight w3-border-bottom">
<div class="w3-large w3-bold">
<a href="/" class="w3-btn w3-hover-opacity w100 w3-left-align">{{ app_title }}<br>
<span class="w3-tiny">{{ app_sub_title }}</span>
</a>
</div>
<form method="post" action="{% url 'logout' %}" class="w3-padding-small">
{% csrf_token %}
<button class="w3-bar-item w3-btn w3-hover-opacity" type="submit" title="{% trans 'Déconnexion' %}">
{{ request.user.username }}<i class="fa-solid fa-right-from-bracket w3-right"></i>
</button>
</form>
</div>
{% block system %}
<div class="w3-light-blue w3-padding-large">
<div id="shm-used" class="w3-green w3-round" style="height:0.5em; width: var(--shm-used, 0%);" title="{% trans 'Mémoire partagée' %}"></div>
<div id="ramdisk-used" class="w3-purple w3-round" style="height:0.5em;width: var(--ramdisk-used, 0%); margin: 0.35em 0" title="{% trans 'Disque RAM' %}"></div>
<div id="mem-used" class="w3-deep-orange w3-round" style="height:0.5em;width: var(--mem-used, 0%); margin: 0.35em 0" title="{% trans 'Mémoire RAM' %}"></div>
<div id="cpu-used" class="w3-indigo w3-round" style="height:0.5em;width: var(--cpu-used, 0%); margin: 0.35em 0" title="{% trans 'Charge CPU' %}"></div>
<div id="disk-used" class="w3-amber w3-round" style="height:0.5em;width: var(--disk-used, 0%); margin: 0.35em 0" title="{% trans 'Disque /' %}"></div>
</div>
{% endblock %}
{% block columns %}
<div class="w3-dark-light">
<div class=" w3-padding w3-border-bottom">
<div>{% trans "Grille" %}</div>
<div style="width:100%">
{% x_range 1 13 as cols %}
{% for c in cols %}
<button id="swap-{{ c }}" class="w3-btn w3-badge w3-padding-small w3-sand w3-tiny" onclick="setGridColumns({{ c }})">{{ c }}</button>
{% endfor %}
</div>
</div>
</div>
<script>
function updateActiveButton(n) {
document.querySelectorAll('[id^="swap-"]').forEach(btn => {
btn.classList.remove('w3-blue', 'w3-sand'); btn.classList.add('w3-sand');
});
const active = sId('swap-'+ n);
if (active) { active.classList.remove('w3-sand'); active.classList.add('w3-blue');
}
}
</script>
{% endblock %}
{% block sidebar_list %}
<div class="w3-dark-light w3-padding-small">
{% block sidebar_command %}{% endblock %}
{% if request.user.is_superuser %}
<div>
<div class="w3-dark-light w3-margin-top-6">
<a href="#" class="w3-btn w3-padding-small" onclick="toggleDisplay(sId('_admin'))"><span class="w3-text-orange w3-xlarge">&#x26F6;</span>
{% trans "Administration" %}
</a>
</div>
<div id="_admin" style="display:none">
<a href="{% url 'scanner:admin' %}" class="w3-bar-item w3-btn w3-hover-opacity w3-margin-left">
<i class="fa-solid fa-gear w3-text-pink"></i> {% trans "Administration base de données" %}
</a>
<a href="{% url 'scanner:reductstore' %}" class="w3-bar-item w3-btn w3-hover-opacity w3-margin-left">
<img class="w3-no-padding" src="/static/img/reductstore.png" style="width: 24px"> {% trans "Base de données Reductstore" %}
</a>
<!--a href="{% url 'scanner:adminer' %}" class="w3-bar-item w3-btn w3-hover-opacity w3-margin-left">
<img class="w3-no-padding" src="/static/img/adminer.png" style="width: 28px"> {% trans "Base de données mariadb" %}
</a-->
<!--a href="{% url 'scanner:portainer' %}" class="w3-bar-item w3-btn w3-hover-opacity" w3-margin-left>
<img class="w3-no-padding" src="/static/img/portainer.png" style="width: 28px"> {% trans "Portainer" %}
</a-->
<a href="{% url 'scanner:supervisor' %}" class="w3-bar-item w3-btn w3-hover-opacity w3-margin-left">
<i class="fa-solid fa-gears w3-text-purple"></i> {% trans "Supervisor" %}
</a>
<a href="{% url 'scanner:logs_scheduler' %}" class="w3-bar-item w3-btn w3-hover-opacity w3-margin-left">
<i class="fa-regular fa-file-lines w3-text-orange"></i> {% trans "Logs des planifications" %}
</a>
<a href="{% url 'scanner:logs_worker' %}" class="w3-bar-item w3-btn w3-hover-opacity w3-margin-left">
<i class="fa-regular fa-file-lines w3-text-amber"></i> {% trans "Logs des workers" %}
</a>
<a href="{% url 'scanner:calibration' %}" class="w3-bar-item w3-btn w3-hover-opacity w3-margin-left">
<i class="fa-solid fa-wrench w3-text-red"></i> {% trans "Calibration" %}
</a>
</div>
</div>
{% endif %}
{% block sidebar_smenu %}
<a href="{% url 'scanner:main' %}" class="w3-bar-item w3-btn w3-hover-opacity">
<i class="fa-solid fa-film w3-text-green"></i> {% trans "Balayage multi-puits" %}
</a>
<a href="{% url 'scanner:images' %}" class="w3-bar-item w3-btn w3-hover-opacity">
<i class="fa-regular fa-images w3-text-cyan"></i> {% trans "Gestionnaire d'images" %}
</a>
<a href="{% url 'scanner:replay' %}" class="w3-bar-item w3-btn w3-hover-opacity">
<i class="fa-solid fa-podcast w3-text-amber"></i> {% trans "Redifusion vidéos" %}
</a>
{% endblock %}
</div>
{% endblock %}
{% endblock %}
{% block content %}{% endblock %}
{% block js_footer %}
{{ block.super }}
<script>
const stats_endpoint = "{% url 'scanner:api_stats' %}";
</script>
<script src="/static/scanner/js/stats.js"></script>
{% endblock %}
@@ -0,0 +1,122 @@
{% extends 'scanner/base.html' %}
{% load i18n home_tags %}
{% block styles %}
{{ block.super }}
<link href="/static/scanner/css/calibration.css" rel="stylesheet">
{% endblock %}
{% block columns %}{% endblock %}
{% block content %}
<div class="container w3-black">
<div class="header">
<div class="w3-row w3-row-padding">
<div class="w3-col" style="width:30%">
{% trans 'Position multi-puit' %}<br>
<select id="_well" class="w3-select">
{% for w in wells %}
<option value="{{ w.position }}" {% if w.position == 'HG' %}selected{% endif %}>{{ w }}</option>
{% endfor %}
</select>
</div>
<div class="w3-col" style="width:20%">
<div>{% trans 'Vitesse' %}</div>
<select id="_feed" class="w3-select">
<option value="500">500 mm/mn</option>
<option value="750">750 mm/mn</option>
<option value="1000" selected>1000 mm/mn</option>
<option value="1200">1200 mm/mn</option>
<option value="1500">1500 mm/mn</option>
<option value="2000">2000 mm/mn</option>
<option value="3000">3000 mm/mn</option>
</select>
</div>
<div class="w3-col" style="width:20%">
<div>{% trans 'Pas' %}</div>
<select id="_step" class="w3-select">
<option value="0.1">0.1 mm</option>
<option value="0.25">0.25 mm</option>
<option value="0.5">0.5 mm</option>
<option value="1.0" selected>1.0 mm</option>
<option value="1.5">1.5 mm</option>
<option value="2.0">2.0 mm</option>
<option value="2.5">2.5 mm</option>
<option value="5.0">5.0 mm</option>
<option value="10.0">10.0 mm</option>
<option value="20.0">20.0 mm</option>
<option value="30.0">30.0 mm</option>
<option value="40.0">40.0 mm</option>
<option value="50.0">50.0 mm</option>
</select>
</div>
<div class="w3-col" style="width:30%">
<div class="w3-margin-top w3-padding w3-right">
<span id="_ts"></span><br>
</div>
</div>
</div>
</div>
<div class="move w3-padding-small w3-center">
<div class="w3-row">
<div class="w3-half">{% trans 'dx' %}<br><input id="_dx" type="number" min="15.0" max="25.0" step="0.01" value=""/></div>
<div class="w3-half">{% trans 'dy' %}<br><input id="_dy" type="number" min="15.0" max="25.0" step="0.01" value=""/></div>
</div>
<button id="_xy-step" class="w3-button w3-warning w3-round-large w3-block w3-large">
<i class="fa-solid fa-left-right"></i> {% trans 'Définir (dx, dy)' %}
</button>
<hr>
<div><button id="_up" class="w3-button w3-dark-xlight w3-round-large w3-margin-small w3-block">&#x2191; {% trans 'En haut' %}</button></div>
<div><button id="_left" class="w3-button w3-dark-xlight w3-round-large w3-margin-small w3-block">&#x2190; {% trans 'A gauches' %}</button></div>
<div><button id="_right" class="w3-button w3-dark-xlight w3-round-large w3-margin-small w3-block">&#x2192; {% trans 'A droite' %}</button></div>
<div><button id="_down" class="w3-button w3-dark-xlight w3-round-large w3-margin-small w3-block">&#x2193; {% trans 'En Bas' %}</button></div>
<hr>
<button id="_xy-base" class="w3-button w3-warning w3-round-large w3-margin-small w3-block">{% trans 'Définir base ' %}</button>
</div>
<div class="scan w3-center">
<div class="w3-row">
<div class="w3-half">X<br><span id="_x"></span></div>
<div class="w3-half">Y<br><span id="_y"></span></div>
</div>
<button id="_goto-0" class="w3-button w3-light-blue w3-round w3-round-large w3-margin-small w3-block">Origine (0, 0)</button>
<button id="_goto-xy" class="w3-button w3-light-blue w3-round-large w3-margin-small w3-block w3-margin-bottom">
{% trans 'Aller à la base' %}<br>(<span id="_xbase"></span>, <span id="_ybase"></span>)
</button>
<hr>
<button id="_median" class="w3-button w3-teal w3-round-large w3-margin-small w3-block"><i class="fa-solid fa-crosshairs"></i> {% trans 'Axes' %}</button>
<button id="_crop" class="w3-button w3-teal w3-round-large w3-margin-small w3-block w3-margin-bottom"><i class="fa-solid fa-crop"></i> {% trans 'Recadrer' %}</button>
<span>
{% trans 'Rayon' %}: <input id="_crop_radius" type="number" min="100" max="1200" step="1" value="{{ conf.calibration_crop_radius }}" title="{% trans 'Rayon de cadrage' %}"/>
</span>
<hr>
<button id="_test" class="w3-button w3-warning w3-round-large w3-margin-small w3-block">{% trans 'Tester le cirduit' %}</button>
<button id="_halt" class="w3-button w3-red w3-round-large w3-margin-small w3-block"><i class="fa-solid fa-hand"></i> {% trans 'ARRET' %}</button>
</div>
<div class="scanner"><img id="scan-img" class="w3-image"></div>
</div>
<ul id="_debug" class="w3-scroll-y" style="height: 30vh"></ul>
{% endblock %}
{% block js_footer %}
{{ block.super }}
<script src="/static/scanner/js/calibration.js"></script>
<script>
const container = sId("scan-img");
const ws_route = "{{ ws_route }}";
// ---- Point d'entrée ----
(async () => {
const manager = new ScannerManager(container);
const protocol = location.protocol === "https:" ? "wss" : "ws";
const wsUrl = `${protocol}://${location.host}/${ws_route}`;
const socket = new MetadataSocket(wsUrl);
socket.setManager(manager);
socket.connect();
manager.registerSocket(socket);
})();
</script>
{% endblock %}
@@ -0,0 +1,6 @@
{% extends 'scanner/base.html' %}
{% block columns %}{% endblock %}
{% block content %}
<iframe width="100%" src="{{ link }}" style="border:none; height:90vh;display:block;"></iframe>
{% endblock %}
@@ -0,0 +1,14 @@
<div class="w3-modal" id="imagemodal" style="z-index: 200;">
<div class="w3-modal-content w3-border w3-dark-medium" style="max-width: 640px">
<button onclick="sId('imagemodal').style.display='none'" class="w3-button w3-round w3-round-xxlarge w3-text-red w3-display-topright w3-xlarge">&#x2716;</button>
<div class="w3-card-4">
<div class="w3-bar w3-dark-xlight" style="padding-right: 4em">
<span class="w3-bar-item" id="imagepreview-uuid"></span>
<span class="w3-bar-item" id="imagepreview-ts"></span>
<span class="w3-bar-item w3-right" id="imagepreview-index"></span>
</div>
<img id="imagepreview" style="width: 100%">
</div>
</div>
</div>
@@ -0,0 +1,114 @@
{% extends 'scanner/base.html' %}
{% load i18n home_tags scanner_tags %}
{% block styles %}
{{ block.super }}
<link href="/static/scanner/css/images.css" rel="stylesheet">
{% endblock %}
{% block sidebar_list %}
<a href="/" class="w3-bar-item w3-btn w3-hover-opacity"><i class="fa-solid fa-house w3-text-orange"></i> {% trans "Retour accueil" %}</a>
<form method="post" class="w3-bar-item" action="">
{% csrf_token %}
<select id="_sid" name="_sid" class="w3-select w3-margin-bottom" onchange="this.form.submit()">
<option value="0">---- {% trans "Session d'observations" %}</option>
{% for s in sessions %}
<option value="{{ s.id }}" {% if s.id == cursid %}selected{% endif %}>{{ s.name }}</option>
{% endfor %}
</select>
{% if cursid %}
<div class="multiwell_cards">
{% multiwell_cards cursid observations %}
</div>
{% endif %}
</form>
{% if cursid %}
<a href="#" class="w3-bar-item w3-btn w3-hover-opacity" onclick="download_all_images()">
<i class="fa-solid fa-file-export w3-text-red"></i> {% trans "Exporter l'ensemble des images" %}
</a>
{% endif %}
{% endblock %}
{% block content %}
<div id="image-grid">
{% if images %}
{% for img in images %}
<div class="w3-card-4 w3-border">
<div class="w3-padding-small w3-dark-xlight w3-tiny">
<span>{{ img.uuid }}</span>
<span class="w3-right">{{ img.index }}</span>
</div>
<img class="w3-image" src="{{ img.content }}" onclick="image_preview(this, '{{ img.uuid }}', '{{ img.index }}', '{{ img.ts }}')">
<div class="w3-container w3-dark-xlight w3-tiny">
<button class="w3-button w3-round w3-warning w3-padding-1"
onclick="image_save('{{ img.uuid }}.jpg', '{{ img.content }}')" title="{% trans 'Télécharger' %}">&nbsp;&#x2B07&nbsp;
</button>
<span class="w3-right">{{ img.ts }}</span>
</div>
</div>
{% endfor %}
{% endif %}
</div>
{% endblock %}
{% block modal %}
{% include "scanner/image-preview.html" %}
{% endblock %}
{% block js_footer %}
{{ block.super }}
<script>
const container= sId("image-grid");
const columns = "{{ conf.default_grid_columns }}";
const duration = parseInt("{{ duration }}");
const uuid_btn = sId("btn-" + "{{ uuid }}");
const image_endpoint = "{% url 'scanner:export_api' %}";
const sid = parseInt("{{ cursid }}");
function setGridColumns(col) { container.style.setProperty("--grid-columns", col); updateActiveButton(col); }
function handleMultiwell(b) { b.classList.add('w3-green'); }
function image_preview(img, uuid, index, ts) {
imagemodal.style.display='block';
sId('imagepreview').src = img.src;
sId('imagepreview-uuid').textContent = uuid;
sId('imagepreview-index').textContent = index;
sId('imagepreview-ts').textContent = ts;
}
function image_save(name, img) {
const ok = confirm(`Télécharger le fichier ?\n\n${name}`);
if (!ok) return false;
fetch(img)
.then(res => res.blob())
.then(blob => {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = name;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
});
}
function download_all_images() {
fetch(image_endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: 'export_images',
sid: sid,
})
}).then(res => {
console.log(res);
}).catch(error => {
console.error('Erreur:', error);
});
}
setGridColumns(columns);
if (uuid_btn)
handleMultiwell(uuid_btn);
</script>
{% endblock %}
@@ -0,0 +1,63 @@
{% extends 'scanner/base.html' %}
{% load i18n home_tags %}
{% block styles %}
{{ block.super }}
<link href="/static/scanner/css/main.css" rel="stylesheet">
{% endblock %}
{% block columns %}{% endblock %}
{% block content %}
<div class="container w3-black">
<div class="header">
<div class="w3-row w3-row-padding">
<div class="w3-half">
<div>{% trans "Choix de la session" %}</div>
<select id="_session" class="w3-select">
{% for s in sessions %}
<option value="{{ s.id }}">{{ s.name }}</option>
{% endfor %}
</select>
</div>
<div class="w3-half">
<div class="w3-margin-top w3-padding w3-right">
<span id="_ts"></span><br>
(<span id="_x"></span>, <span id="_y"></span>)
</div>
</div>
</div>
</div>
<div class="scan">
<button id="_median" class="w3-button w3-teal w3-round-large w3-margin-small w3-block"><i class="fa-solid fa-crosshairs"></i> {% trans 'Axes' %}</button>
<button id="_crop" class="w3-button w3-teal w3-round-large w3-margin-small w3-block w3-margin-bottom"><i class="fa-solid fa-crop"></i> {% trans 'Recadrer' %}</button>
<hr>
<button id="_scan" class="w3-button w3-warning w3-round-large w3-large w3-block">
<i class="fa-solid fa-camera"></i><br>{% trans 'Lancer le balayage' %}
</button>
<button id="_halt" class="w3-button w3-red w3-round-large w3-padding-16 w3-block w3-margin-top"><i class="fa-solid fa-hand"></i><br>{% trans 'ARRET' %}</button>
</div>
<div class="scanner"><img id="scan-img" class="w3-image"></div>
</div>
<ul id="_debug" class="w3-scroll-y" style="height: 50vh"></ul>
{% endblock %}
{% block js_footer %}
{{ block.super }}
<script src="/static/scanner/js/main.js"></script>
<script>
const container = sId("scan-img");
const ws_route = "{{ ws_route }}";
// ---- Point d'entrée ----
(async () => {
const manager = new ScannerManager(container);
const protocol = location.protocol === "https:" ? "wss" : "ws";
const wsUrl = `${protocol}://${location.host}/${ws_route}`;
const socket = new MetadataSocket(wsUrl);
socket.setManager(manager);
socket.connect();
manager.registerSocket(socket);
})();
</script>
{% endblock %}
@@ -0,0 +1,22 @@
<div class="slider-container" id="scannerSlider">
<div class="display-range w3-small w3-margin-bottom"></div>
<div class="range-wrap slider" data-min="1680307200" data-max="1680998400">
<div class="track"></div>
<div class="range-highlight"></div>
<input class="min-range" type="range" min="1680307200" max="1680998400" value="1680307200" step="100" aria-label="valeur minimale">
<input class="max-range" type="range" min="1680307200" max="1680998400" value="1680998400" step="100" aria-label="valeur maximale">
</div>
<div class="w3-row w3-margin-top">
<div class="w3-half">
<span>Min (ms)</span>
<input class="min-number" type="number" min="1680307200" max="1680998400" value="1680307200" step="100">
</div>
<div class="w3-half w3-right-align">
<span>Max (ms)</span>
<input class="max-number" type="number" min="1680307200" max="1680998400" value="1680998400" step="100">
</div>
</div>
</div>
@@ -0,0 +1,8 @@
<!DOCTYPE html>
<html>
<head>
<title></title>
<meta http-equiv="refresh" content="0; url={{ link }}" />
</head>
<body></body>
</html>
@@ -0,0 +1,134 @@
{% extends 'scanner/base.html' %}
{% load i18n home_tags scanner_tags %}
{% block styles %}
{{ block.super }}
<link href="/static/scanner/css/replay.css" rel="stylesheet">
{% endblock %}
{% block columns %}{% endblock %}
{% block sidebar_list %}
<a href="/" class="w3-bar-item w3-btn w3-hover-opacity"><i class="fa-solid fa-house w3-text-orange"></i> {% trans "Retour accueil" %}</a>
<form method="post" class="w3-bar-item" action="">
{% csrf_token %}
<select id="_sid" name="_sid" class="w3-select w3-margin-bottom" onchange="this.form.submit()">
<option value="0">---- {% trans "Session d'observations" %}</option>
{% for s in sessions %}
<option value="{{ s.id }}" {% if s.id == cursid %}selected{% endif %}>{{ s.name }}</option>
{% endfor %}
</select>
{% if cursid %}
<div class="multiwell_cards">
{% multiwell_cards cursid observations %}
</div>
{% endif %}
</form>
{% if cursid %}
<a href="#" class="w3-bar-item w3-btn w3-hover-opacity" onclick="download_all_videos()">
<i class="fa-solid fa-file-export w3-text-red"></i> {% trans "Exporter l'ensemble des vidéos" %}
</a>
{% endif %}
{% endblock %}
{% block content %}
<div id="replay-grid" class="w3-black">
{% if image %}
<div class="w3-card-4 w3-border" style="min-width: 640px">
<img id="_replay_img" src="{{ image }}" style="width: 100%">
<div class="w3-bar w3-padding-small w3-light-grey">
<span class="w3-bar-item w3-marging-right w3-dark-xlight" >{{ uuid }}</span>
<button id="_replay_play" class="w3-bar-item w3-btn"><i class="fa-solid fa-play"></i></button>
<button id="_replay_pause" class="w3-bar-item w3-btn"><i class="fa-solid fa-pause"></i></button>
<button id="_replay_stop" class="w3-bar-item w3-btn"><i class="fa-solid fa-stop"></i></button>
<button id="_replay_snapshot" class="w3-bar-item w3-btn"><i class="fa-solid fa-image"></i></button>
<button id="_replay_videosnap" class="w3-bar-item w3-btn"><i class="fa-solid fa-video"></i></button>
<div class="replay-speed-control w3-bar-item w3-right w3-padding">
<span>x <span id="_speed_label">1</span></span>
<input id="_speed_control" type="range" min="0" max="8" step="1" class="w3-range">
</div>
</div>
<div class="w3-dark-light w3-nbsp w3-small w3-padding-small w3-margint-top w3-margin-bottom">
<div class="replay-ts-cursor ">
<span id="_replay_ts_iso" class="w3-left">2026-04-05T08:13:18.067</span>
<span id="_replay_percent" class="w3-right"></span>
</div>
<div class="replay-slider">
<input id="_replay_timeline" type="range" min="0" max="1000" step="0.1" class="replay-timeline" disabled="">
</div>
<div class="replay-sub-slider">
<span id="_replay_dt_left" class="w3-left">2026-04-05T08:13:18.067</span>
<span id="_replay_dt_right" class="w3-right">2026-02-20T17:18:44.331</span>
</div>
</div>
</div>
{% endif %}
</div>
{% endblock %}
{% block js_footer %}
{{ block.super }}
<script src="/static/scanner/js/replay.js"></script>
<script>
const ws_route = "{{ ws_route }}";
const uuid_btn = sId("btn-" + "{{ uuid }}");
const image_endpoint = "{% url 'scanner:export_api' %}";
const sid = parseInt("{{ cursid }}");
function download_all_videos() {
fetch(image_endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: 'export_videos',
sid: sid,
})
}).then(res => {
console.log(res);
}).catch(error => {
console.error('Erreur:', error);
});
}
function handleMultiwell(b) { b.classList.add('w3-green'); }
// ---- Point d'entrée ----
(async () => {
if (!uuid_btn) return;
const manager = new ReplayManager({
uuid: "{{ uuid }}",
dt_start: parseInt("{{ oldest }}"),
dt_stop: parseInt("{{ latest }}"),
fps: parseFloat("{{ conf.video_frame_rate }}"),
video_type: "{{ conf.opencv_video_type }}",
video_endpoint: "{% url 'scanner:download_api' %}",
img: sId("_replay_img"),
play: sId("_replay_play"),
pause: sId("_replay_pause"),
stop: sId("_replay_stop"),
ts: sId("_replay_ts"),
snapshot: sId("_replay_snapshot"),
videosnap: sId("_replay_videosnap"),
speed_control: sId("_speed_control"),
speed_label: sId("_speed_label"),
timeline: sId("_replay_timeline"),
ts_iso: sId("_replay_ts_iso"),
percent: sId("_replay_percent"),
dt_left: sId("_replay_dt_left"),
dt_right: sId("_replay_dt_right")
});
await manager.start();
const protocol = location.protocol === "https:" ? "wss" : "ws";
const wsUrl = `${protocol}://${location.host}/${ws_route}`;
const socket = new MetadataSocket(wsUrl);
socket.setManager(manager);
socket.connect();
manager.registerSocket(socket);
if (uuid_btn)
handleMultiwell(uuid_btn);
})();
</script>
{% endblock %}
@@ -0,0 +1,27 @@
# encoding: utf-8
from django import template
from django.utils.html import mark_safe
register = template.Library()
@register.simple_tag
def multiwell_cards(sid, observations):
multiwells = []
for obs in observations:
row_def = obs.multiwell.row_def.split(',')
multiwells.append(
f'''
<div class="w3-border w3-small">
<div class="w3-center w3-sand">{obs.title}</div>
''')
for row in range(obs.multiwell.rows):
multiwells.append('''<div class="w3-padding-small">''')
for col in range(obs.multiwell.cols):
btn = f'{row_def[row]}{col+1}'
uuid = f'{sid}-{obs.multiwell.position}-{btn}'
multiwells.append(f"""<button id="btn-{uuid}" name="_multiwell" class="multiwell w3-button" value="{uuid}" onclick="this.form.submit()">{btn}</button>""")
multiwells.append('''</div>''')
multiwells.append('''</div>''')
return mark_safe("\n".join(multiwells))
+3
View File
@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.
+23
View File
@@ -0,0 +1,23 @@
from django.urls import path
from . import views
app_name = "scanner"
urlpatterns = [
path('scanner/admin/', views.admin_view, name='admin'),
path('scanner/reductstore/', views.reductstore_view, name='reductstore'),
path('scanner/adminer/', views.adminer_view, name='adminer'),
path('scanner/portainer/', views.portainer_view, name='portainer'),
path('scanner/calibration/', views.calibration_view, name='calibration'),
path('scanner/supervisor/', views.supervisor_view, name='supervisor'),
path('scanner/logs/worker', views.supervisor_worker, name='logs_worker'),
path('scanner/logs/scheduler', views.supervisor_scheduler, name='logs_scheduler'),
path('main/', views.main_view, name='main'),
path('scanner/images/', views.images_view, name='images'),
path('scanner/replay/', views.replay_view, name='replay'),
path('api/stats/', views.stats_view, name='api_stats'),
path('api/video/', views.download_api, name='download_api'),
path('api/export/', views.export_api, name='export_api'),
]
+199
View File
@@ -0,0 +1,199 @@
#
from asgiref.sync import async_to_sync
import base64, json
from django.shortcuts import render
from django.http import JsonResponse
from django.utils.translation import gettext_lazy as _
from django.views.decorators.http import require_GET, require_POST
from django.views.decorators.csrf import csrf_exempt
from django.contrib.auth.decorators import login_required
from django.conf import settings
from reduct.time import unix_timestamp_to_iso
from modules.system_stats import get_cached_stats, start_background_updater
from modules import reductstore
from .tasks import download_video, export_images, export_videos, export_all_images, export_all_videos
from .process import CameraRecordManager, cameraDB
from . import models
record_manager = CameraRecordManager(cameraDB)
start_background_updater()
@require_GET
def stats_view(request):
"""
Retourne tout le cache (shm, cpu_info, memory_info, disk_info, updated_at)
"""
try:
data = get_cached_stats()
return JsonResponse(data, safe=False)
except Exception as e:
return JsonResponse({"error": str(e)}, status=500)
def global_context(request, **ctx):
return dict(
app_title=settings.APP_TITLE,
app_sub_title=settings.APP_SUB_TITLE,
domain_server=settings.DOMAIN_SERVER,
conf=models.Configuration.objects.filter(active=True).first(),
**ctx
)
@login_required
def admin_view(request):
return render(request, "scanner/iframe.html", context=global_context(request, link='/admin/'))
@login_required
def reductstore_view(request):
return render(request, "scanner/iframe.html", context=global_context(request, link=f'http://{settings.DOMAIN_SERVER}:8383/'))
@login_required
def adminer_view(request):
return render(request, "scanner/iframe.html", context=global_context(request, link=f'http://{settings.DOMAIN_SERVER}:8181/'))
@login_required
def portainer_view(request):
return render(request, "scanner/redirection.html", context=global_context(request, link=f'http://{settings.DOMAIN_SERVER}:9000/'))
@login_required
def supervisor_view(request):
return render(request, "scanner/iframe.html", context=global_context(request, link=f'http://{settings.DOMAIN_SERVER}:9001/'))
@login_required
def supervisor_worker(request):
return render(request, "scanner/iframe.html", context=global_context(request, link=f'http://{settings.DOMAIN_SERVER}:9001/logtail/test_tube:workers'))
@login_required
def supervisor_scheduler(request):
return render(request, "scanner/iframe.html", context=global_context(request, link=f'http://{settings.DOMAIN_SERVER}:9001/logtail/test_tube:beat'))
## Mainboard
@login_required
def main_view(request):
ctx = dict(
ws_route=settings.SCANNER_WEBSOCKET_ROUTE,
columns=1,
sessions=models.Session.objects.filter(active=True).all(),
choice_title=_("Balayage multi-puits")
)
return render(request, "scanner/main.html", context=global_context(request, **ctx))
## Calibration
@login_required
def calibration_view(request):
ctx = dict(
ws_route=settings.SCANNER_WEBSOCKET_ROUTE,
columns=1,
choice_title=_("Calibration"),
wells = models.MultiWell.objects.all(),
)
return render(request, "scanner/calibration.html", context=global_context(request, **ctx))
## images
def get_images(uuid):
oldest, latest, n, images = 0, 0, 0, []
filters = record_manager.set_filters(test=False)
queries = record_manager.query(uuid, filters=filters)
while True:
try:
record, content = async_to_sync(record_manager.record_content)(queries)
if n<1:
oldest = record.timestamp
latest = record.timestamp
msg = {
"uuid": uuid,
"ts": unix_timestamp_to_iso(latest),
"content": f'data:image/jpeg;base64,{base64.b64encode(content).decode()}',
"index": n,
}
images.append(msg)
n+=1
except:
break
return int((latest-oldest)/1_000_000), images
@login_required
def images_view(request):
cursid, duration, uuid, images = 0, 0, "", []
if request.method == 'POST':
cursid = request.POST.get('_sid')
uuid = request.POST.get('_multiwell')
duration, images = get_images(uuid)
ctx = dict(
choice_title=_("Gestionnaire d'images"),
sessions=models.Session.objects.filter(active=False).all(),
observations=models.SessionObservation.observation_by_session(cursid, active=False),
cursid=int(cursid),
images=images,
uuid=uuid,
duration=duration,
)
return render(request, "scanner/images.html", context=global_context(request, **ctx))
## replay
@require_POST
@csrf_exempt
def download_api(request):
data = json.loads(request.body.decode() or "{}")
action = data.get("action")
if action=='download':
uuid, dt_start, dt_stop, frame_rate = data.get("uuid"), data.get("dt_start"), data.get("dt_stop"), data.get("fps")
return download_video.delay(uuid, dt_start, dt_stop, frame_rate=frame_rate) # @UndefinedVariable
else:
return JsonResponse({"state": False})
def get_video(uuid):
oldest, latest = async_to_sync(reductstore.old_last_dates)(cameraDB, entry_name=uuid)
filters = record_manager.set_filters(test=False)
image, _ = record_manager.first_image(uuid, filters=filters)
return oldest, latest, image
@login_required
def replay_view(request):
cursid, oldest, latest, uuid, image = 0, 0, 0, "", ""
if request.method == 'POST':
cursid = request.POST.get('_sid')
uuid = request.POST.get('_multiwell')
if uuid:
oldest, latest, image = get_video(uuid)
ctx = dict(
choice_title=_("Gestionnaire de vidéos"),
ws_route=settings.REPLAY_WEBSOCKET_ROUTE,
sessions=models.Session.objects.filter(active=False).all(),
observations=models.SessionObservation.observation_by_session(cursid, active=False),
cursid=int(cursid),
image=image,
uuid=uuid,
oldest=oldest,
latest=latest,
)
return render(request, "scanner/replay.html", context=global_context(request, **ctx))
@require_POST
@csrf_exempt
def export_api(request):
data = json.loads(request.body.decode() or "{}")
session_id = data.get("sid")
action = data.get("action")
if action == 'export_images':
job_zip = export_all_images(session_id)
return JsonResponse({"state": True, "tasks": job_zip})
elif action == 'export_videos':
job_mp4 = export_all_videos(session_id)
return JsonResponse({"state": True, "tasks": job_mp4})
else:
return JsonResponse({"state": False})