From ce32bee3e812083756446b0d234bd7a3c245fbc2 Mon Sep 17 00:00:00 2001 From: denis defolie Date: Sun, 3 May 2026 13:24:25 +0200 Subject: [PATCH] calibration --- test_tube_scanner/home/settings.py | 8 +- .../modules/capture_interface.py | 51 ++++++++---- .../modules/planarian_tracker.py | 83 ++++++++++++++----- test_tube_scanner/planarian/models.py | 6 +- test_tube_scanner/scanner/admin.py | 50 ++++++++++- test_tube_scanner/scanner/constants.py | 7 +- test_tube_scanner/scanner/models.py | 13 ++- test_tube_scanner/scanner/multiwell.py | 10 +++ test_tube_scanner/scanner/process.py | 8 +- .../static/scanner/css/calibration.css | 2 - .../scanner/static/scanner/js/calibration.js | 14 ++++ .../templates/scanner/calibration.html | 38 ++++++++- 12 files changed, 235 insertions(+), 55 deletions(-) diff --git a/test_tube_scanner/home/settings.py b/test_tube_scanner/home/settings.py index 894e8ea..af79300 100644 --- a/test_tube_scanner/home/settings.py +++ b/test_tube_scanner/home/settings.py @@ -396,9 +396,11 @@ EXPORT_DESTINATIONS = ["local", "remote"] TRACKER_TUBE_AXIS = "vertical" -TRACKER_MIN_AREA = 8 # surface min planaire px x px -TRACKER_MAX_AREA_RATIO = 0.1 # 5% de la frame = surface max acceptable -TRACKER_MAX_PLANARIANS = 3 +TRACKER_MIN_AREA = 20 # surface min planaire px x px +TRACKER_MAX_AREA_RATIO = 0.1 # 5% de la frame = surface max acceptable +TRACKER_MAX_PLANARIANS = 1 +TRACKER_MERGE_KERNEL_SIZE = 15 # augmenter si fragments résiduels +TRACKER_MIN_CONTOUR_DIST_PX = 40 # augmenter si IDs multiples persistent CALIBRATION_AUTO_DURATION = 45.0 diff --git a/test_tube_scanner/modules/capture_interface.py b/test_tube_scanner/modules/capture_interface.py index 8fc78dc..bf93fba 100644 --- a/test_tube_scanner/modules/capture_interface.py +++ b/test_tube_scanner/modules/capture_interface.py @@ -51,6 +51,16 @@ class VideoCaptureInterface(abc.ABC): # Cadence par défaut en images par seconde DEFAULT_FPS: float = 5.0 + DEFAULT_TRACKER_CONFIG = dict( + tube_axis = settings.TRACKER_TUBE_AXIS, + min_area_px = settings.TRACKER_MIN_AREA, + max_area_ratio = settings.TRACKER_MAX_AREA_RATIO, + max_planarians = settings.TRACKER_MAX_PLANARIANS, + merge_kernel_size = settings.TRACKER_MERGE_KERNEL_SIZE, + min_contour_dist_px = settings.TRACKER_MIN_CONTOUR_DIST_PX, + ) + + def __init__(self, fps: float = DEFAULT_FPS, use_tracking: bool = False, display=None, parent=None, jpeg_quality=85): """ Initialise l'interface de capture. @@ -75,13 +85,9 @@ class VideoCaptureInterface(abc.ABC): self._tracker = None self._metrics = None self._paramss = None - if use_tracking: - self._tracker = PlanarianTracker( - tube_axis = settings.TRACKER_TUBE_AXIS, - min_area_px = settings.TRACKER_MIN_AREA, - max_area_ratio = settings.TRACKER_MAX_AREA_RATIO, - max_planarians = settings.TRACKER_MAX_PLANARIANS, - ) + + # Tracker générique, pour simulation + self.on_test_well_change(**self.DEFAULT_TRACKER_CONFIG) self._aligner = TubeAligner( grbl_threshold_px = 20, # au-delà → correction GRBL @@ -91,22 +97,31 @@ class VideoCaptureInterface(abc.ABC): self.align_detection = None # résultat du test + def on_test_well_change(self, **cfg): + if self.use_tracking and cfg: + logger.warning(f"tracking conf: {cfg}") + self._tracker = PlanarianTracker(**cfg) + + def on_well_change(self, cfg): """ Appelé par le CNC lors du changement de puits. Réinitialise le fond appris et l'état inter-frame du tracker. Construit les métriques aussi """ - if self.use_tracking and self._tracker: - self._tracker.reset() - self._params = ExperimentParams(cfg.to_params_dict()) - self._metrics = self._params.build_metrics() - self._tracker = PlanarianTracker( - tube_axis = self._params.tube_axis, - min_area_px = self._params.min_area_px, - max_area_ratio = self._params.max_area_ratio, - max_planarians = self._params.planarian_count, - ) + if not self.use_tracking: + return + + self._params = ExperimentParams(cfg.to_params_dict()) + self._metrics = self._params.build_metrics() + self._tracker = PlanarianTracker( + tube_axis = self._params.tube_axis, + min_area_px = self._params.min_area_px, + max_area_ratio = self._params.max_area_ratio, + max_planarians = self._params.planarian_count, + merge_kernel_size = settings.TRACKER_MERGE_KERNEL_SIZE, + min_contour_dist_px = settings.TRACKER_MIN_CONTOUR_DIST_PX, + ) # ------------------------------------------------------------------ @@ -262,7 +277,7 @@ class VideoCaptureInterface(abc.ABC): frame = annotated if annotated is not None else frame ## # mode racking - if self._tracker is not None: + if self.use_tracking: ts = datetime.now(timezone.utc).timestamp() frame, metrics = self._tracker.process(frame, ts) ## diff --git a/test_tube_scanner/modules/planarian_tracker.py b/test_tube_scanner/modules/planarian_tracker.py index 2927bc3..c818a50 100644 --- a/test_tube_scanner/modules/planarian_tracker.py +++ b/test_tube_scanner/modules/planarian_tracker.py @@ -4,22 +4,13 @@ modules/planarian_tracker.py Détection et suivi multi-individus de planaires dans un tube. Supporte de 1 à MAX_PLANARIANS planaires par tube. -Etat inter-frame indépendant par individu : position, timestamp, compteur de perte (lost), flag active. -Quand un individu n'est pas détecté pendant MAX_LOST_FRAMES (5) frames consécutives, il est marqué perdu et son slot se libère. - -Algorithme hongrois (scipy.optimize.linear_sum_assignment) dans _hungarian_assign() - — construit une matrice de coût distance euclidienne entre les slots actifs et les nouvelles détections, puis trouve l'association de coût minimal. -Une association est rejetée si la distance dépasse MAX_ASSOC_DIST_PX (80px) - — évite les sauts aberrants entre planaires proches. - - Stratégie : - Soustraction de fond MOG2 (léger sur Raspberry Pi 4) - Détection de tous les contours valides (surface >= min_area_px) - Association frame-à-frame par distance euclidienne minimale via algorithme hongrois (scipy.optimize.linear_sum_assignment) - Un état inter-frame indépendant par individu (PlanarianState) - - Retourne une liste de résultats, un par individu suivi: champ planarian_id (index 0-based). + - Retourne une liste de résultats, un par individu suivi Created on 25 avr. 2026 @author: denis @@ -171,10 +162,12 @@ class PlanarianTracker: def __init__( self, - tube_axis: str = "vertical", - min_area_px: int = 20, - max_area_ratio: float = 0.10, - max_planarians: int = 1, + tube_axis: str = "vertical", + min_area_px: int = 20, + max_area_ratio: float = 0.10, + max_planarians: int = 1, + merge_kernel_size: int = 15, + min_contour_dist_px:int = 40, ): """ Args: @@ -182,7 +175,11 @@ class PlanarianTracker: min_area_px : surface minimale d'un contour pour être considéré valide (px²) max_area_ratio : surface maximale d'un contour en fraction de la frame (défaut 10%) filtre les faux positifs du fond non encore appris par MOG2 - max_planarians : nombre maximum de planaires à suivre simultanément (1-10) + max_planarians : nombre maximum de planaires à suivre simultanément (1-10) + merge_kernel_size : taille du kernel elliptique de fusion des fragments (px). + Régler ≈ largeur du planaire en pixels. Défaut : 15. + min_contour_dist_px : distance min entre deux contours pour les considérer + comme individus distincts. Défaut : 40px. """ self.tube_axis = tube_axis self.min_area_px = min_area_px @@ -192,6 +189,16 @@ class PlanarianTracker: # Un état inter-frame par slot individu self._states = [PlanarianState(i) for i in range(self.max_planarians)] + # Taille du kernel de fusion morphologique (pixels) — + # doit être proche de la largeur du planaire en pixels. + # Trop petit : fragments non fusionnés → IDs multiples. + # Trop grand : deux planaires proches fusionnés en un seul. + self.merge_kernel_size = merge_kernel_size + + # Distance minimale en pixels entre deux contours distincts. + # En-dessous : le plus petit est considéré comme fragment du plus grand. + self.min_contour_dist_px = min_contour_dist_px + # Soustracteur de fond adaptatif MOG2 self._bg_sub = self._make_bg_sub() @@ -217,6 +224,8 @@ class PlanarianTracker: s.reset() self._bg_sub = self._make_bg_sub() self._warmup_count = 0 + # Les paramètres morphologiques (merge_kernel_size, min_contour_dist_px) + # sont conservés — ils ne dépendent pas du puits # ------------------------------------------------------------------ # # Interface principale @@ -253,9 +262,19 @@ class PlanarianTracker: gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) fg_mask = self._bg_sub.apply(gray) - kernel = np.ones((3, 3), np.uint8) - fg_mask = cv2.morphologyEx(fg_mask, cv2.MORPH_OPEN, kernel) - fg_mask = cv2.morphologyEx(fg_mask, cv2.MORPH_CLOSE, kernel) + # --- Morphologie : fusion des fragments du corps --- + # Un planaire ondulant est souvent segmenté en plusieurs contours + # (tête, milieu, queue). La dilatation fusionne les fragments proches + # avant la détection des contours. + # Kernel 3×3 : supprime le bruit fin (OPEN) + # Kernel merge_kernel : fusionne les fragments du corps (CLOSE + DILATE) + noise_kernel = np.ones((3, 3), np.uint8) + merge_kernel = cv2.getStructuringElement( + cv2.MORPH_ELLIPSE, (self.merge_kernel_size, self.merge_kernel_size) + ) + fg_mask = cv2.morphologyEx(fg_mask, cv2.MORPH_OPEN, noise_kernel) + fg_mask = cv2.morphologyEx(fg_mask, cv2.MORPH_CLOSE, merge_kernel) + fg_mask = cv2.dilate(fg_mask, merge_kernel, iterations=1) # Warmup MOG2 : les premières WARMUP_FRAMES frames retournent du bruit # (fond non encore appris) — on les alimente mais on ne détecte rien @@ -279,8 +298,33 @@ class PlanarianTracker: reverse=True, ) + # --- Suppression des fragments résiduels trop proches --- + # Si plusieurs contours valides ont leur centre à moins de + # min_contour_dist_px les uns des autres, seul le plus grand est conservé. + # Évite les cas où la fusion morphologique est incomplète. + filtered = [] + for c in valid: + M = cv2.moments(c) + if M["m00"] == 0: + continue + cx_c = int(M["m10"] / M["m00"]) + cy_c = int(M["m01"] / M["m00"]) + too_close = False + for kept in filtered: + Mk = cv2.moments(kept) + if Mk["m00"] == 0: + continue + cx_k = int(Mk["m10"] / Mk["m00"]) + cy_k = int(Mk["m01"] / Mk["m00"]) + dist = np.sqrt((cx_c - cx_k)**2 + (cy_c - cy_k)**2) + if dist < self.min_contour_dist_px: + too_close = True + break + if not too_close: + filtered.append(c) + # Limiter au nombre maximum de planaires attendus - valid = valid[:self.max_planarians] + valid = filtered[:self.max_planarians] # --- Calcul des centres de masse des contours détectés --- detections = [] # liste de (cx, cy, area, contour) @@ -489,4 +533,3 @@ class PlanarianTracker: "axial_pos": 0.0, "timestamp": ts, } - \ No newline at end of file diff --git a/test_tube_scanner/planarian/models.py b/test_tube_scanner/planarian/models.py index 7b12773..0e88347 100644 --- a/test_tube_scanner/planarian/models.py +++ b/test_tube_scanner/planarian/models.py @@ -65,8 +65,9 @@ class ExperimentConfig(models.Model): ) max_area_ratio = models.FloatField( - default=0.05, - verbose_name=_("Filtre surface max acceptable"), + default=0.10, + verbose_name=_("Surface max contour (fraction de la frame)"), + help_text=_("Ratio de la surface du puits, ex: 0.10 pour 10%"), ) planarian_count = models.IntegerField( @@ -131,6 +132,7 @@ class ExperimentConfig(models.Model): "thresh_mobile": self.thresh_mobile, "tube_axis": self.tube_axis, "min_area_px": self.min_area_px, + "max_area_ratio": self.max_area_ratio, "planarian_count": self.planarian_count, "thigmotaxis_wall_dist_mm": self.thigmotaxis_wall_dist_mm, "photo_mode": self.photo_mode, diff --git a/test_tube_scanner/scanner/admin.py b/test_tube_scanner/scanner/admin.py index e9b4b77..eacd3d7 100644 --- a/test_tube_scanner/scanner/admin.py +++ b/test_tube_scanner/scanner/admin.py @@ -1,18 +1,65 @@ +from django.utils.translation import gettext_lazy as _ 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', 'capture_type', 'video_width_capture', 'video_height_capture', 'video_frame_rate', 'active',) + + + fieldsets = ( + (_("Identification"), { + "fields": ("name", "author", "active"), + }), + (_("Dashboard"), { + "fields": ("sidebar_width", "default_grid_columns",),"classes": ("collapse",), + }), + (_("opencv"), { + "fields": ("opencv_fourcc_format", "opencv_video_type"),"classes": ("collapse",), + }), + (_("Grbl"), { + "fields": ("grbl_xmax", "grbl_ymax"), + "classes": ("collapse",), + }), + (_("Camera"), { + "fields": ("capture_type", "webcam_device_index", "image_quality", "video_jpeg_quality", "video_frame_rate", "video_width_capture", "video_height_capture"), + "classes": ("collapse",), + }), + (_("Calibration"), { + "fields": ("calibration_crop_radius", "calibration_default_multiwell", "calibration_default_feed", "calibration_default_step", "calibration_default_duration"), + "classes": ("collapse",), + }), + (_("Tracking"), { + "fields": ("tracking", "min_area_px", "max_area_ratio", "max_planarians", "merge_kernel_size", "min_contour_dist_px"), + "classes": ("collapse",), + }), + + + ) class MultiWellAdmin(admin.ModelAdmin): list_filter = ('author', ) list_display = ('label', 'position', 'author', 'order', 'xbase', 'ybase', 'duration', 'feed', 'default', 'well_position', 'active',) + + fieldsets = ( + (_("Identification"), { + "fields": ("label", "author", "position", "default", "active"), + }), + (_("Géométrie"), { + "fields": ("cols", "rows", "diameter", "row_def", "row_order"),"classes": ("collapse",), + }), + (_("Déplacement"), { + "fields": ("order", "duration", "xbase", "ybase", "dx", "dy", "feed"),"classes": ("collapse",), + }), + (_("Positions générées"), { + "fields": ("well_position",), + }), + + ) class WellPositionAdmin(admin.ModelAdmin): list_filter = ('author', 'multiwell') @@ -28,6 +75,7 @@ class ExperimentAdmin(admin.ModelAdmin): list_filter = ('session_experiments__session', 'author', ) list_display = ('title', 'author', 'multiwell', 'created', 'started', 'finished') readonly_fields = ('created', 'started', 'finished', ) + class SessionExperimentInlineAdmin(admin.TabularInline): model = models.SessionExperiment diff --git a/test_tube_scanner/scanner/constants.py b/test_tube_scanner/scanner/constants.py index 163a6cd..1c3bb6a 100644 --- a/test_tube_scanner/scanner/constants.py +++ b/test_tube_scanner/scanner/constants.py @@ -28,8 +28,13 @@ class DefaultConfig: calibration_default_step: float = 1.0 calibration_default_duration: float = 3.0 tracking: bool = False + min_area_px: int = 20 + max_area_ratio: float = 0.10 + max_planarians: int = 1 + merge_kernel_size: int = 15 + min_contour_dist_px: int = 40 + - class ScannerConstants: def __init__(self): diff --git a/test_tube_scanner/scanner/models.py b/test_tube_scanner/scanner/models.py index 8cca1c2..e6980fd 100644 --- a/test_tube_scanner/scanner/models.py +++ b/test_tube_scanner/scanner/models.py @@ -66,6 +66,13 @@ class Configuration(models.Model): calibration_default_duration = models.FloatField(_("Duruée calibration"), help_text=_("Durée de pose entre chaque puits en s"), default=3.0) # tracking tracking = models.BooleanField(_("Suivi"), help_text=_("Suivi et analyse des planaires"), default=False) + + min_area_px = models.PositiveIntegerField(_("Surface minimale"), help_text=_("surface minimale d'un contour pour être considéré valide (px²)"), default=20) + max_area_ratio = models.FloatField(_("surface maximale "), help_text=_("surface maximale d'un contour en fraction de la frame (défaut 10%)"), default=0.10) + max_planarians = models.PositiveIntegerField(_("Max planaire"), help_text=_("nombre maximum de planaires à suivre simultanément (1-10)"), default=1) + merge_kernel_size = models.PositiveIntegerField(_("Taille du kernel"), help_text=_("taille du kernel elliptique de fusion des fragments (px)"), default=15) + min_contour_dist_px = models.PositiveIntegerField(_("Distance "), help_text=_("Distance min entre deux contours pour les considérer comme individus distincts. Défaut : 40px."), default=40) + # active = models.BooleanField(_("Actif"), default=False) @@ -97,18 +104,18 @@ class Well(models.Model): class MultiWell(models.Model): + # Identification 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) default = models.BooleanField(_("Par défaut"), help_text=_('Multi-puit par défaut'), default=False) - + # Configuration cols = models.PositiveSmallIntegerField(_("Colonnes"), help_text=_('Nombre de colonnes'), blank=False, default=6) rows = models.PositiveSmallIntegerField(_("Lignes"), help_text=_('Nombre de lignes'), blank=False, default=4) diameter = models.FloatField(_("Diamètre"), help_text=_('Diamètre des tubes en mm'), blank=False, default=16.0) - row_def = models.CharField(_("Définition"), help_text=_('Définition des lignes'), max_length=16, null=True, blank=False, default="A,B,C,D") row_order = models.CharField(_("Ordre ligne"), help_text=_('Ordre ligne de puit. Lecture en serpentin dans le sens des +- X'), max_length=16, null=True, blank=False, default="D,C,B,A") - + # Balayage order = models.PositiveSmallIntegerField(_("Ordre"), help_text=_('Ordre de lecture du multi-puit'), blank=False, default=0) duration = models.PositiveIntegerField(_("Durée"), help_text=_('Durée 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) diff --git a/test_tube_scanner/scanner/multiwell.py b/test_tube_scanner/scanner/multiwell.py index ba714ad..2e06696 100644 --- a/test_tube_scanner/scanner/multiwell.py +++ b/test_tube_scanner/scanner/multiwell.py @@ -78,6 +78,7 @@ class MultiWellManager: def __init__(self, process): self.process = process self.cnc_controller = process.grbl + self.stop_playing = Event() self.well_iterator = None self.multiwel = None @@ -85,6 +86,15 @@ class MultiWellManager: self.set_multiwell() self.scan_thread = None self.test_thread = None + self.tracker_config = dict( + tube_axis = settings.TRACKER_TUBE_AXIS, + min_area_px = self.process.conf.min_area_px, + max_area_ratio = self.process.conf.max_area_ratio, + max_planarians = self.process.conf.max_planarians, + merge_kernel_size = self.process.conf.merge_kernel_size, + min_contour_dist_px = self.process.conf.min_contour_dist_px, + ) + def set_default_values(self, feed=None, step=None, duration=None): self._feed = feed or self.process.conf.calibration_default_feed diff --git a/test_tube_scanner/scanner/process.py b/test_tube_scanner/scanner/process.py index 14af4ab..3a68d96 100644 --- a/test_tube_scanner/scanner/process.py +++ b/test_tube_scanner/scanner/process.py @@ -467,7 +467,13 @@ class ScannerProcess(Task): elif topic == 'set_well': msg = self.manager.set_well_position() self._send(**msg) - + + elif topic in ['min_area_px', 'max_area_ratio', 'max_planarians', 'merge_kernel_size', 'min_contour_dist_px']: + value = int(value) if topic in ['min_area_px', 'max_planarians', 'merge_kernel_size', 'min_contour_dist_px'] else float(value) + self.manager.tracker_config[topic] = value + self.cam.on_test_well_change(**self.manager.tracker_config) + self._send(state=topic, msg=f"Value changed {value}") + self._send( xbase=self.manager.xbase, ybase=self.manager.ybase, diff --git a/test_tube_scanner/scanner/static/scanner/css/calibration.css b/test_tube_scanner/scanner/static/scanner/css/calibration.css index 2e7f187..56def16 100644 --- a/test_tube_scanner/scanner/static/scanner/css/calibration.css +++ b/test_tube_scanner/scanner/static/scanner/css/calibration.css @@ -10,5 +10,3 @@ align-items: center; } - - diff --git a/test_tube_scanner/scanner/static/scanner/js/calibration.js b/test_tube_scanner/scanner/static/scanner/js/calibration.js index 2d811c3..b893042 100644 --- a/test_tube_scanner/scanner/static/scanner/js/calibration.js +++ b/test_tube_scanner/scanner/static/scanner/js/calibration.js @@ -42,6 +42,13 @@ class ScannerManager { this.crop = options.crop; this.crop_radius = options.crop_radius; this.calib_auto = options.calib_auto; + + this.min_area_px = options.min_area_px; + this.max_area_ratio = options.max_area_ratio; + this.max_planarians = options.max_planarians; + this.merge_kernel_size = options.merge_kernel_size; + this.min_contour_dist_px = options.min_contour_dist_px; + } init_controls() { @@ -71,6 +78,13 @@ class ScannerManager { this.calib_center.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "center" }); }); this.calib_auto.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "auto" }); }); this.halt.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "halt" }); }); + + this.min_area_px.addEventListener('change', (e) => { this._send({ type: 'calibrate', topic: "min_area_px", value: e.target.value }); }); + this.max_area_ratio.addEventListener('change', (e) => { this._send({ type: 'calibrate', topic: "max_area_ratio", value: e.target.value }); }); + this.max_planarians.addEventListener('change', (e) => { this._send({ type: 'calibrate', topic: "max_planarians", value: e.target.value}); }); + this.merge_kernel_size.addEventListener('change', (e) => { this._send({ type: 'calibrate', topic: "merge_kernel_size", value: e.target.value}); }); + this.min_contour_dist_px.addEventListener('change', (e) => { this._send({ type: 'calibrate', topic: "min_contour_dist_px", value: e.target.value }); }); + } registerSocket(socket) { diff --git a/test_tube_scanner/scanner/templates/scanner/calibration.html b/test_tube_scanner/scanner/templates/scanner/calibration.html index b6995c3..5835f16 100644 --- a/test_tube_scanner/scanner/templates/scanner/calibration.html +++ b/test_tube_scanner/scanner/templates/scanner/calibration.html @@ -133,12 +133,12 @@
-
-
@@ -148,7 +148,32 @@
-
+ {% if conf.tracking %} +
+
{% trans 'Configuration du tracking' %}
+
+
{% trans 'Min contour' %} +
+
+
{% trans 'Max contour' %} +
+
+
{% trans 'Taille kernel' %} +
+
+
{% trans 'Distance min' %} +
+
+
{% trans 'Planaires' %} +
+
+ {% endif %} +
{% trans 'Rayon' %} px