tube aligner
This commit is contained in:
@@ -0,0 +1,230 @@
|
||||
'''
|
||||
Created on 17 avr. 2026
|
||||
|
||||
@author: denis
|
||||
'''
|
||||
# modules/tube_aligner.py
|
||||
|
||||
import cv2
|
||||
import logging
|
||||
import numpy as np
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TubeAligner:
|
||||
"""
|
||||
Détecte le cercle du tube à essai dans une frame (vue par dessous,
|
||||
éclairage par dessus → cercle clair sur fond sombre).
|
||||
Calcule le décalage entre le centre du tube et le centre de l'image.
|
||||
Décide d'une correction GRBL (grand écart) ou d'un recadrage (petit écart).
|
||||
"""
|
||||
|
||||
# Seuil en pixels : au-delà → correction GRBL, en-dessous → recadrage
|
||||
GRBL_THRESHOLD_PX = 20
|
||||
# Tolérance : en-dessous → pas de correction nécessaire
|
||||
DEAD_ZONE_PX = 5
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
px_per_mm: float = 10.0, # facteur d'échelle calibration (px/mm)
|
||||
grbl_threshold_px: int = 20,
|
||||
dead_zone_px: int = 5,
|
||||
debug: bool = False, # ← activable depuis la vue
|
||||
):
|
||||
self.px_per_mm = px_per_mm
|
||||
self.grbl_threshold_px = grbl_threshold_px
|
||||
self.dead_zone_px = dead_zone_px
|
||||
|
||||
def detect_tube(self, frame: np.ndarray) -> dict:
|
||||
"""
|
||||
Détecte le cercle du tube et calcule le décalage par rapport au centre image.
|
||||
|
||||
:param frame: Frame BGR (numpy array)
|
||||
:return: dict avec cercle détecté, décalage px et mm, action recommandée
|
||||
"""
|
||||
h, w = frame.shape[:2]
|
||||
cx_img = w // 2
|
||||
cy_img = h // 2
|
||||
|
||||
result = {
|
||||
"detected" : False,
|
||||
"tube_cx" : None,
|
||||
"tube_cy" : None,
|
||||
"tube_radius" : None,
|
||||
"offset_x_px" : None,
|
||||
"offset_y_px" : None,
|
||||
"offset_x_mm" : None,
|
||||
"offset_y_mm" : None,
|
||||
"action" : "none", # "none" | "crop" | "grbl"
|
||||
"grbl_gcode" : None,
|
||||
"frame_annotated": None,
|
||||
}
|
||||
|
||||
# Prétraitement : niveaux de gris + flou
|
||||
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
|
||||
#blurred = cv2.GaussianBlur(gray, (9, 9), 2)
|
||||
blurred = cv2.GaussianBlur(gray, (15, 15), 3)
|
||||
|
||||
# param1 : seuil Canny haut, param2 : seuil accumulation (plus bas = plus permissif)
|
||||
min_radius = int(min(w, h) * 0.26) # ~260px sur 1000px
|
||||
max_radius = int(min(w, h) * 0.36) # ~360px sur 1000px — bord intérieur du verre
|
||||
|
||||
circles = cv2.HoughCircles(
|
||||
blurred,
|
||||
cv2.HOUGH_GRADIENT,
|
||||
dp = 1.2,
|
||||
minDist = min(w, h) // 2, # un seul tube attendu
|
||||
param1 = 50,
|
||||
param2 = 30,
|
||||
minRadius = min_radius,
|
||||
maxRadius = max_radius,
|
||||
)
|
||||
|
||||
if circles is None:
|
||||
logger.warning("TubeAligner: aucun cercle détecté")
|
||||
result["frame_annotated"] = self._annotate(frame.copy(), cx_img, cy_img, None)
|
||||
return result
|
||||
|
||||
circles = np.round(circles[0, :]).astype(int)
|
||||
|
||||
# Prend le cercle le plus proche du centre image
|
||||
best = min(
|
||||
circles,
|
||||
key=lambda c: np.sqrt((c[0] - cx_img)**2 + (c[1] - cy_img)**2)
|
||||
)
|
||||
tx, ty, tr = int(best[0]), int(best[1]), int(best[2])
|
||||
|
||||
# Décalage : positif = tube à droite/bas du centre image
|
||||
offset_x_px = tx - cx_img
|
||||
offset_y_px = ty - cy_img
|
||||
offset_x_mm = offset_x_px / self.px_per_mm
|
||||
offset_y_mm = offset_y_px / self.px_per_mm
|
||||
|
||||
dist_px = np.sqrt(offset_x_px**2 + offset_y_px**2)
|
||||
|
||||
# Décision d'action
|
||||
if dist_px <= self.dead_zone_px:
|
||||
action = "none"
|
||||
grbl_gcode = None
|
||||
elif dist_px <= self.grbl_threshold_px:
|
||||
action = "crop"
|
||||
grbl_gcode = None
|
||||
else:
|
||||
action = "grbl"
|
||||
# G91 = coordonnées relatives, G0 = déplacement rapide
|
||||
# Inversion du signe : si tube est à droite (+X image),
|
||||
# la CNC doit reculer (-X GRBL) pour recentrer
|
||||
#cmd = f"G53 G1 X{x:.2f} Y{y:.2f} F{feed}"
|
||||
|
||||
grbl_gcode = (
|
||||
f"G91\n"
|
||||
f"G1 X{-offset_x_mm:.3f} Y{-offset_y_mm:.3f}\n"
|
||||
f"G90"
|
||||
)
|
||||
logger.info(
|
||||
"TubeAligner: décalage %.1fpx (%.2fmm, %.2fmm) → GRBL: %s",
|
||||
dist_px, offset_x_mm, offset_y_mm, grbl_gcode.replace('\n', ' | ')
|
||||
)
|
||||
|
||||
result.update({
|
||||
"detected" : True,
|
||||
"tube_cx" : tx,
|
||||
"tube_cy" : ty,
|
||||
"tube_radius" : tr,
|
||||
"offset_x_px" : offset_x_px,
|
||||
"offset_y_px" : offset_y_px,
|
||||
"offset_x_mm" : round(offset_x_mm, 3),
|
||||
"offset_y_mm" : round(offset_y_mm, 3),
|
||||
"action" : action,
|
||||
"grbl_gcode" : None,
|
||||
"frame_annotated": self._annotate(
|
||||
frame.copy(), cx_img, cy_img, (tx, ty, tr), offset_x_px, offset_y_px
|
||||
),
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
def crop_to_tube(self, frame: np.ndarray, detection: dict) -> np.ndarray:
|
||||
"""
|
||||
Recadrage logiciel : recentre l'image sur le tube détecté.
|
||||
Utilisé quand action == "crop".
|
||||
"""
|
||||
if not detection["detected"]:
|
||||
return frame
|
||||
|
||||
tx = detection["tube_cx"]
|
||||
ty = detection["tube_cy"]
|
||||
tr = detection["tube_radius"]
|
||||
h, w = frame.shape[:2]
|
||||
|
||||
# Fenêtre carrée autour du centre du tube
|
||||
half = tr
|
||||
x1 = max(tx - half, 0)
|
||||
y1 = max(ty - half, 0)
|
||||
x2 = min(tx + half, w)
|
||||
y2 = min(ty + half, h)
|
||||
|
||||
cropped = frame[y1:y2, x1:x2]
|
||||
|
||||
# Redimensionne à la taille originale pour ne pas changer le pipeline
|
||||
return cv2.resize(cropped, (w, h), interpolation=cv2.INTER_LINEAR)
|
||||
|
||||
def _annotate(
|
||||
self,
|
||||
frame: np.ndarray,
|
||||
cx_img: int,
|
||||
cy_img: int,
|
||||
circle: tuple | None,
|
||||
offset_x: int = 0,
|
||||
offset_y: int = 0,
|
||||
) -> np.ndarray:
|
||||
"""
|
||||
Dessine le cercle détecté, le centre image et le vecteur de décalage.
|
||||
"""
|
||||
# Croix centre image
|
||||
cv2.drawMarker(
|
||||
frame, (cx_img, cy_img),
|
||||
(0, 255, 0), cv2.MARKER_CROSS, 20, 1, cv2.LINE_AA
|
||||
)
|
||||
|
||||
if circle is None:
|
||||
cv2.putText(frame, "Tube non detecte", (10, 30),
|
||||
cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 0, 255), 1, cv2.LINE_AA)
|
||||
return frame
|
||||
|
||||
tx, ty, tr = circle
|
||||
|
||||
# Cercle du tube en cyan
|
||||
cv2.circle(frame, (tx, ty), tr, (255, 255, 0), 2, cv2.LINE_AA)
|
||||
|
||||
# Centre du tube en rouge
|
||||
cv2.circle(frame, (tx, ty), 4, (0, 0, 255), -1, cv2.LINE_AA)
|
||||
|
||||
# Vecteur décalage (centre image → centre tube)
|
||||
if abs(offset_x) > 2 or abs(offset_y) > 2:
|
||||
cv2.arrowedLine(
|
||||
frame,
|
||||
(cx_img, cy_img),
|
||||
(tx, ty),
|
||||
(0, 100, 255), 2, cv2.LINE_AA, tipLength=0.2
|
||||
)
|
||||
|
||||
# Texte décalage
|
||||
dist = np.sqrt(offset_x**2 + offset_y**2)
|
||||
cv2.putText(
|
||||
frame,
|
||||
f"dx={offset_x:+d}px dy={offset_y:+d}px dist={dist:.1f}px",
|
||||
(10, 28),
|
||||
cv2.FONT_HERSHEY_SIMPLEX, 0.45, (255, 255, 255), 1, cv2.LINE_AA,
|
||||
)
|
||||
cv2.putText(
|
||||
frame,
|
||||
f"r={tr}px",
|
||||
(10, 48),
|
||||
cv2.FONT_HERSHEY_SIMPLEX, 0.45, (200, 200, 200), 1, cv2.LINE_AA,
|
||||
)
|
||||
|
||||
return frame
|
||||
|
||||
|
||||
Reference in New Issue
Block a user