tracking
This commit is contained in:
@@ -19,6 +19,7 @@ APP_DATAS=test_tube_scanner
|
|||||||
EXPORTS_LOCAL_PATH=/home/rpi4/exports
|
EXPORTS_LOCAL_PATH=/home/rpi4/exports
|
||||||
EXPORT_REMOTE_PATH=/mnt/exports
|
EXPORT_REMOTE_PATH=/mnt/exports
|
||||||
REDUCTSTORE_PATH=/home/rpi4/medias
|
REDUCTSTORE_PATH=/home/rpi4/medias
|
||||||
|
CSV_EXPORT_DIR=/home/rpi4/exports/csv
|
||||||
|
|
||||||
####
|
####
|
||||||
# django email
|
# django email
|
||||||
|
|||||||
@@ -393,6 +393,8 @@ EXPORT_REMOTE_PATH = config("EXPORT_REMOTE_PATH")
|
|||||||
EXPORT_DESTINATIONS = ["local", "remote"]
|
EXPORT_DESTINATIONS = ["local", "remote"]
|
||||||
#EXPORT_DESTINATIONS = ["remote"] # only remote
|
#EXPORT_DESTINATIONS = ["remote"] # only remote
|
||||||
|
|
||||||
|
CSV_EXPORT_DIR = config("CSV_EXPORT_DIR") # ou None pour mémoire uniquement
|
||||||
|
|
||||||
## tracker default parameters
|
## tracker default parameters
|
||||||
#
|
#
|
||||||
TRACKER_TUBE_AXIS = "vertical"
|
TRACKER_TUBE_AXIS = "vertical"
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ Métriques résumé (summary) :
|
|||||||
Created on 25 avr. 2026
|
Created on 25 avr. 2026
|
||||||
@author: denis
|
@author: denis
|
||||||
"""
|
"""
|
||||||
import asyncio
|
#import asyncio
|
||||||
import csv
|
import csv
|
||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
@@ -42,8 +42,8 @@ import logging
|
|||||||
import math
|
import math
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -667,14 +667,14 @@ class ReductStoreClient:
|
|||||||
self.bucket_name = bucket
|
self.bucket_name = bucket
|
||||||
self.quota_type = quota_type
|
self.quota_type = quota_type
|
||||||
self.quota_size = quota_size
|
self.quota_size = quota_size
|
||||||
self._client = None
|
self.entry_name = "metrics"
|
||||||
self._bucket = asyncio.run(self.create_bucket())
|
self._client = None
|
||||||
|
self._bucket = None
|
||||||
|
|
||||||
|
|
||||||
async def create_bucket(self):
|
async def _create_bucket(self):
|
||||||
from reduct import Client, BucketSettings
|
from reduct import Client, BucketSettings
|
||||||
self._client = Client(self.url, api_token=self.token)
|
self._client = Client(self.url, api_token=self.token)
|
||||||
|
|
||||||
settings = BucketSettings(
|
settings = BucketSettings(
|
||||||
quota_type=self.quota_type,
|
quota_type=self.quota_type,
|
||||||
quota_size=self.quota_size,
|
quota_size=self.quota_size,
|
||||||
@@ -682,43 +682,45 @@ class ReductStoreClient:
|
|||||||
)
|
)
|
||||||
return await self._client.create_bucket(self.bucket_name, settings, exist_ok=True)
|
return await self._client.create_bucket(self.bucket_name, settings, exist_ok=True)
|
||||||
|
|
||||||
'''
|
|
||||||
async def connect(self):
|
async def connect(self):
|
||||||
"""Initialise la connexion et crée le bucket si nécessaire."""
|
"""Initialise la connexion et crée le bucket si nécessaire."""
|
||||||
from reduct import Client, BucketSettings, QuotaType
|
self._bucket = await self._create_bucket()
|
||||||
self._client = Client(self.url, api_token=self.token)
|
|
||||||
self._bucket = await self._client.create_bucket(
|
|
||||||
self.bucket_name,
|
|
||||||
BucketSettings(quota_type=QuotaType.NONE),
|
|
||||||
exist_ok=True,
|
|
||||||
)
|
|
||||||
logger.info(f"ReductStore connecté : {self.url} / {self.bucket_name}")
|
logger.info(f"ReductStore connecté : {self.url} / {self.bucket_name}")
|
||||||
|
|
||||||
'''
|
|
||||||
|
|
||||||
async def store_metric(
|
async def store_metric(
|
||||||
self,
|
self,
|
||||||
record: dict,
|
record: dict,
|
||||||
experiment: str,
|
experiment: str,
|
||||||
well: str,
|
well: str,
|
||||||
entry_name: str = "metrics",
|
|
||||||
planarian: int = 0,
|
planarian: int = 0,
|
||||||
record_type: str = "metrics",
|
record_type: str = "frame",
|
||||||
|
uuid: str = "",
|
||||||
ts_us: Optional[int] = None,
|
ts_us: Optional[int] = None,
|
||||||
):
|
):
|
||||||
"""Stocke un enregistrement dans ReductStore."""
|
"""
|
||||||
|
Stocke un enregistrement dans ReductStore.
|
||||||
|
|
||||||
|
Le timestamp est rendu unique par planaire en ajoutant l'index
|
||||||
|
du planaire comme offset sub-microseconde — évite le 409 Conflict
|
||||||
|
quand plusieurs planaires du même puits écrivent dans la même frame.
|
||||||
|
"""
|
||||||
if self._bucket is None:
|
if self._bucket is None:
|
||||||
await self.connect()
|
await self.connect()
|
||||||
ts_us = ts_us or int(time.time() * 1_000_000)
|
# ts_us de base + offset planaire (0, 1, 2…) pour unicité garantie
|
||||||
|
base_ts = ts_us or int(time.time() * 1_000_000)
|
||||||
|
unique_ts = base_ts + planarian
|
||||||
|
|
||||||
await self._bucket.write(
|
await self._bucket.write(
|
||||||
entry_name = entry_name,
|
entry_name = "metrics",
|
||||||
data = json.dumps(record).encode("utf-8"),
|
data = json.dumps(record).encode("utf-8"),
|
||||||
timestamp = ts_us,
|
timestamp = unique_ts,
|
||||||
labels = {
|
labels = {
|
||||||
"experiment": experiment,
|
"experiment": experiment,
|
||||||
"well": well,
|
"well": well,
|
||||||
"planarian": str(planarian),
|
"planarian": str(planarian),
|
||||||
"record_type": record_type,
|
"record_type": record_type,
|
||||||
|
"uuid": uuid,
|
||||||
},
|
},
|
||||||
content_type = "application/json",
|
content_type = "application/json",
|
||||||
)
|
)
|
||||||
@@ -733,7 +735,7 @@ class ReductStoreClient:
|
|||||||
experiment: str,
|
experiment: str,
|
||||||
well: str,
|
well: str,
|
||||||
planarian: int = 0,
|
planarian: int = 0,
|
||||||
record_type: str = "frame",
|
record_type: str = "metrics",
|
||||||
start: Optional[datetime] = None,
|
start: Optional[datetime] = None,
|
||||||
stop: Optional[datetime] = None,
|
stop: Optional[datetime] = None,
|
||||||
) -> list:
|
) -> list:
|
||||||
@@ -756,45 +758,121 @@ class ReductStoreClient:
|
|||||||
logger.warning(f"Entrée illisible ignorée : {e}")
|
logger.warning(f"Entrée illisible ignorée : {e}")
|
||||||
return records
|
return records
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _convert_timestamps(records: list) -> list:
|
||||||
|
"""
|
||||||
|
Convertit le champ 'timestamp' (epoch float secondes) en ISO 8601 UTC
|
||||||
|
dans chaque enregistrement.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
records : liste de dicts issus de ReductStore
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
nouvelle liste avec timestamp converti (originaux non modifiés)
|
||||||
|
"""
|
||||||
|
converted = []
|
||||||
|
for r in records:
|
||||||
|
row = dict(r)
|
||||||
|
ts = row.get("timestamp")
|
||||||
|
if ts is not None:
|
||||||
|
try:
|
||||||
|
row["timestamp"] = (
|
||||||
|
datetime.fromtimestamp(float(ts), tz=timezone.utc)
|
||||||
|
.strftime("%Y-%m-%dT%H:%M:%S.%f") + "Z"
|
||||||
|
)
|
||||||
|
except (ValueError, TypeError, OSError):
|
||||||
|
pass
|
||||||
|
converted.append(row)
|
||||||
|
return converted
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _build_filepath(output_dir: str, experiment: str,
|
||||||
|
well: str, planarian: int, record_type: str) -> str:
|
||||||
|
"""
|
||||||
|
Construit le chemin du fichier CSV de sortie.
|
||||||
|
Nom : <experiment>_<well>_planaire<NN>_<record_type>.csv
|
||||||
|
|
||||||
|
Args:
|
||||||
|
output_dir : répertoire de sortie (créé si absent)
|
||||||
|
experiment : identifiant de l'expérience
|
||||||
|
well : identifiant du puits
|
||||||
|
planarian : index du planaire
|
||||||
|
record_type : "frame" ou "summary"
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
chemin absolu du fichier CSV
|
||||||
|
"""
|
||||||
|
dirpath = os.path.abspath(output_dir)
|
||||||
|
os.makedirs(dirpath, exist_ok=True)
|
||||||
|
filename = f"{experiment}_{well}_planaire{planarian:02d}_{record_type}.csv"
|
||||||
|
return os.path.join(dirpath, filename)
|
||||||
|
|
||||||
async def export_csv(
|
async def export_csv(
|
||||||
self,
|
self,
|
||||||
filepath: str,
|
|
||||||
experiment: str,
|
experiment: str,
|
||||||
well: str,
|
well: str,
|
||||||
planarian: int = 0,
|
planarian: int = 0,
|
||||||
record_type: str = "frame",
|
record_type: str = "metrics",
|
||||||
|
output_dir: str = ".",
|
||||||
start: Optional[datetime] = None,
|
start: Optional[datetime] = None,
|
||||||
stop: Optional[datetime] = None,
|
stop: Optional[datetime] = None,
|
||||||
) -> int:
|
) -> tuple:
|
||||||
"""Exporte les données vers un fichier CSV."""
|
"""
|
||||||
|
Exporte les données depuis ReductStore vers un fichier CSV.
|
||||||
|
Le répertoire de sortie est choisi via output_dir.
|
||||||
|
Le champ timestamp est converti en ISO 8601 UTC.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
experiment : identifiant de l'expérience
|
||||||
|
well : identifiant du puits
|
||||||
|
planarian : index du planaire
|
||||||
|
record_type : "frame" | "summary"
|
||||||
|
output_dir : répertoire de sortie (défaut : répertoire courant)
|
||||||
|
start, stop : plage temporelle (datetime UTC, optionnel)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple (filepath, nb_lignes)
|
||||||
|
"""
|
||||||
records = await self.get_tracking_data(
|
records = await self.get_tracking_data(
|
||||||
experiment, well, planarian, record_type, start, stop)
|
experiment, well, planarian, record_type, start, stop)
|
||||||
if not records:
|
if not records:
|
||||||
logger.warning(f"Aucune donnée pour {experiment}/{well}/{planarian}")
|
logger.warning(f"Aucune donnée pour {experiment}/{well}/{planarian}")
|
||||||
return 0
|
return "", 0
|
||||||
os.makedirs(os.path.dirname(os.path.abspath(filepath)), exist_ok=True)
|
|
||||||
|
records = self._convert_timestamps(records)
|
||||||
|
filepath = self._build_filepath(output_dir, experiment, well,
|
||||||
|
planarian, record_type)
|
||||||
fieldnames = list(dict.fromkeys(k for r in records for k in r.keys()))
|
fieldnames = list(dict.fromkeys(k for r in records for k in r.keys()))
|
||||||
|
|
||||||
with open(filepath, "w", newline="", encoding="utf-8") as f:
|
with open(filepath, "w", newline="", encoding="utf-8") as f:
|
||||||
writer = csv.DictWriter(f, fieldnames=fieldnames, extrasaction="ignore")
|
writer = csv.DictWriter(f, fieldnames=fieldnames, extrasaction="ignore")
|
||||||
writer.writeheader()
|
writer.writeheader()
|
||||||
writer.writerows(records)
|
writer.writerows(records)
|
||||||
|
|
||||||
logger.info(f"Export CSV : {len(records)} lignes → {filepath}")
|
logger.info(f"Export CSV : {len(records)} lignes → {filepath}")
|
||||||
return len(records)
|
return filepath, len(records)
|
||||||
|
|
||||||
async def export_csv_response(
|
async def export_csv_response(
|
||||||
self,
|
self,
|
||||||
experiment: str,
|
experiment: str,
|
||||||
well: str,
|
well: str,
|
||||||
planarian: int = 0,
|
planarian: int = 0,
|
||||||
record_type: str = "frame",
|
record_type: str = "metrics",
|
||||||
start: Optional[datetime] = None,
|
start: Optional[datetime] = None,
|
||||||
stop: Optional[datetime] = None,
|
stop: Optional[datetime] = None,
|
||||||
) -> tuple:
|
) -> tuple:
|
||||||
"""Génère le contenu CSV en mémoire (pour réponse HTTP Django)."""
|
"""
|
||||||
|
Génère le contenu CSV en mémoire (pour réponse HTTP Django).
|
||||||
|
Le champ timestamp est converti en ISO 8601 UTC.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple (contenu_csv_str, nb_lignes)
|
||||||
|
"""
|
||||||
records = await self.get_tracking_data(
|
records = await self.get_tracking_data(
|
||||||
experiment, well, planarian, record_type, start, stop)
|
experiment, well, planarian, record_type, start, stop)
|
||||||
if not records:
|
if not records:
|
||||||
return "", 0
|
return "", 0
|
||||||
|
records = self._convert_timestamps(records)
|
||||||
fieldnames = list(dict.fromkeys(k for r in records for k in r.keys()))
|
fieldnames = list(dict.fromkeys(k for r in records for k in r.keys()))
|
||||||
out = io.StringIO()
|
out = io.StringIO()
|
||||||
writer = csv.DictWriter(out, fieldnames=fieldnames, extrasaction="ignore")
|
writer = csv.DictWriter(out, fieldnames=fieldnames, extrasaction="ignore")
|
||||||
@@ -803,7 +881,12 @@ class ReductStoreClient:
|
|||||||
return out.getvalue(), len(records)
|
return out.getvalue(), len(records)
|
||||||
|
|
||||||
async def close(self):
|
async def close(self):
|
||||||
"""Ferme la connexion ReductStore."""
|
"""
|
||||||
if self._client:
|
Ferme la connexion ReductStore.
|
||||||
await self._client.close()
|
Note : reduct-py >= 1.x ne nécessite pas de fermeture explicite —
|
||||||
logger.info("ReductStore déconnecté")
|
la méthode est conservée pour compatibilité d'interface.
|
||||||
|
"""
|
||||||
|
self._client = None
|
||||||
|
self._bucket = None
|
||||||
|
logger.info("ReductStore déconnecté")
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from modules.planarian_metrics import ExperimentParams, ReductStoreClient
|
|||||||
from modules.system_stats import get_cached_stats, start_background_updater
|
from modules.system_stats import get_cached_stats, start_background_updater
|
||||||
from scanner.constants import ScannerConstants
|
from scanner.constants import ScannerConstants
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
start_background_updater()
|
start_background_updater()
|
||||||
@@ -49,11 +50,7 @@ def global_context(request, **ctx):
|
|||||||
|
|
||||||
def _get_reduct_client() -> ReductStoreClient:
|
def _get_reduct_client() -> ReductStoreClient:
|
||||||
"""Instancie le client ReductStore depuis les settings Django."""
|
"""Instancie le client ReductStore depuis les settings Django."""
|
||||||
return ReductStoreClient(
|
return ReductStoreClient(url=settings.REDUCTSTORE_URL, token=settings.REDUCTSTORE_TOKEN)
|
||||||
url = getattr(settings, "REDUCTSTORE_URL", "http://localhost:8383"),
|
|
||||||
token = getattr(settings, "REDUCTSTORE_TOKEN", ""),
|
|
||||||
bucket = getattr(settings, "REDUCTSTORE_BUCKET", "planarian_metrics"),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -212,12 +209,17 @@ class ExportCsvView(FormView):
|
|||||||
start = d.get("start_dt"),
|
start = d.get("start_dt"),
|
||||||
stop = d.get("stop_dt"),
|
stop = d.get("stop_dt"),
|
||||||
)
|
)
|
||||||
finally:
|
print(f"Export CSV: export_csv_response done, {n} lignes, content size={len(csv_content)}, {csv_content}")
|
||||||
await client.close()
|
except Exception as e:
|
||||||
|
logger.error(f"Erreur export CSV: {e}")
|
||||||
|
messages.error(self.request, _("Erreur lors de l'export CSV: %(error)s") % {"error": str(e)})
|
||||||
|
return None, 0
|
||||||
|
|
||||||
return csv_content, n
|
return csv_content, n
|
||||||
|
|
||||||
csv_content, n = _do_export()
|
csv_content, n = _do_export()
|
||||||
|
|
||||||
|
print(f"Export CSV: {n} lignes, content size={len(csv_content)}")
|
||||||
if not csv_content:
|
if not csv_content:
|
||||||
messages.warning(self.request, _("Aucune donnée trouvée."))
|
messages.warning(self.request, _("Aucune donnée trouvée."))
|
||||||
return self.form_invalid(form)
|
return self.form_invalid(form)
|
||||||
@@ -268,8 +270,8 @@ class TrackingDataView(View):
|
|||||||
planarian = planarian,
|
planarian = planarian,
|
||||||
record_type = record_type,
|
record_type = record_type,
|
||||||
)
|
)
|
||||||
finally:
|
except Exception as e:
|
||||||
await client.close()
|
logger.error(f"Erreur fetching tracking data: {e}")
|
||||||
|
|
||||||
records = _fetch()
|
records = _fetch()
|
||||||
return JsonResponse({"count": len(records), "records": records})
|
return JsonResponse({"count": len(records), "records": records})
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ logger = get_task_logger(__name__)
|
|||||||
redisDB = Redis(host=settings.REDIS_HOST, port=settings.REDIS_PORT, db=0, decode_responses=True)
|
redisDB = Redis(host=settings.REDIS_HOST, port=settings.REDIS_PORT, db=0, decode_responses=True)
|
||||||
cameraDB = reductstore.ReductStore(name='camera')
|
cameraDB = reductstore.ReductStore(name='camera')
|
||||||
planarianDB = planarian_metrics.ReductStoreClient(url=settings.REDUCTSTORE_URL, token=settings.REDUCTSTORE_TOKEN)
|
planarianDB = planarian_metrics.ReductStoreClient(url=settings.REDUCTSTORE_URL, token=settings.REDUCTSTORE_TOKEN)
|
||||||
|
async_to_sync(planarianDB.connect)()
|
||||||
|
|
||||||
|
|
||||||
class CameraRecordManager():
|
class CameraRecordManager():
|
||||||
@@ -281,9 +282,10 @@ class ScannerProcess(Task):
|
|||||||
record,
|
record,
|
||||||
self.cam._params.experiment,
|
self.cam._params.experiment,
|
||||||
self.cam._params.well,
|
self.cam._params.well,
|
||||||
entry_name=uuid,
|
uuid=uuid,
|
||||||
planarian=pid,
|
planarian=pid,
|
||||||
record_type='metrics',
|
record_type='metrics',
|
||||||
|
ts_us=ts,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _store_frame(self, uuid, frame, ts, frame_count):
|
def _store_frame(self, uuid, frame, ts, frame_count):
|
||||||
@@ -311,7 +313,7 @@ class ScannerProcess(Task):
|
|||||||
try:
|
try:
|
||||||
(uuid, ts, frame, metrics, frame_count) = self.record_queue.get()
|
(uuid, ts, frame, metrics, frame_count) = self.record_queue.get()
|
||||||
if self.cam.use_tracking:
|
if self.cam.use_tracking:
|
||||||
self._store_metric(uuid, metrics, ts)
|
self._store_metric(uuid, metrics, ts.timestamp())
|
||||||
|
|
||||||
self._store_frame(uuid, frame, ts, frame_count)
|
self._store_frame(uuid, frame, ts, frame_count)
|
||||||
|
|
||||||
|
|||||||
@@ -58,8 +58,8 @@
|
|||||||
<div class="w3-margin-top w3-padding w3-small">
|
<div class="w3-margin-top w3-padding w3-small">
|
||||||
<span id="_ts"></span>
|
<span id="_ts"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="w3-margin-top w3-padding w3-small">
|
<div class="w3-margin-top w3-small">
|
||||||
<span id="_scan_state" class="w3-text-amber w3-border"></span>
|
<span id="_scan_state" class="w3-text-amber w3-padding"></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -19,6 +19,5 @@ urlpatterns = [
|
|||||||
path('replay/', views.replay_view, name='replay'),
|
path('replay/', views.replay_view, name='replay'),
|
||||||
path('api/stats/', views.stats_view, name='api_stats'),
|
path('api/stats/', views.stats_view, name='api_stats'),
|
||||||
path('api/video/', views.download_api, name='download_api'),
|
path('api/video/', views.download_api, name='download_api'),
|
||||||
path('api/restart/', views.restart_all, name='restart_all'),
|
|
||||||
path('api/export/', views.export_api, name='export_api'),
|
path('api/export/', views.export_api, name='export_api'),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -156,18 +156,6 @@ def images_view(request):
|
|||||||
return render(request, "scanner/images.html", context=global_context(request, **ctx))
|
return render(request, "scanner/images.html", context=global_context(request, **ctx))
|
||||||
|
|
||||||
|
|
||||||
## replay
|
|
||||||
@require_GET
|
|
||||||
@csrf_exempt
|
|
||||||
def restart_all(request):
|
|
||||||
#http://scanner.local:9001/index.html?processname=test_tube%3Aservices&action=restart
|
|
||||||
#http://scanner.local:9001/index.html?processname=test_tube%3AwebUI&action=start
|
|
||||||
#http://scanner.local:9001/index.html?action=restartall
|
|
||||||
#supervisor_restart_service('index.html?action=restartall')
|
|
||||||
supervisor_restart_service('index.html?processname=test_tube%3Aservices&action=restart')
|
|
||||||
return JsonResponse({"state": True})
|
|
||||||
|
|
||||||
|
|
||||||
@require_POST
|
@require_POST
|
||||||
@csrf_exempt
|
@csrf_exempt
|
||||||
def download_api(request):
|
def download_api(request):
|
||||||
|
|||||||
Reference in New Issue
Block a user