Vous pouvez m'aider à apporter des amélioration po...
创建于:2026年10月22日
使用 GPT-5.6 Thinking 回答 Chat01
创建于:2026年10月22日
使用 GPT-5.6 Thinking 回答 Chat01
Vous pouvez m'aider à apporter des amélioration pour ce code suivant : """
Étape 1 du pipeline de prétraitement : Correction de perspective par homographie.
Ce script parcourt un dossier d'images (avec des angles de prise de vue très
hétérogènes), détecte automatiquement les 4 coins du panneau photovoltaïque
dans chaque image, applique une homographie pour redresser l'image vers un
rectangle canonique, et sépare les résultats en deux dossiers :
- succes/ : images redressées avec succès
- a_verifier/ : images où la détection a échoué ou est jugée peu fiable
(copiées telles quelles, pour révision manuelle ciblée)
Aucun modèle IA n'est utilisé ici : tout est basé sur des algorithmes
classiques de vision par ordinateur (OpenCV), déterministes et sans données
d'entraînement nécessaires.
"""
import cv2
import numpy as np
import os
import shutil
import csv
import math
INPUT_FOLDER = "./Panneau_sain" # Dossier contenant les images à traiter (parcouru récursivement)
OUTPUT_FOLDER_SUCCESS = "./DossierRedressé" # Images redressées avec succès
OUTPUT_FOLDER_REVIEW = "./ImagesAVerifier" # Images où la détection a échoué (copiées sans modification)
REPORT_PATH = "./rapport_homographie.csv" # Rapport récapitulatif de tout le traitement
valid_extensions = (".jpg", ".jpeg", ".png", ".bmp", ".tiff")
CANONICAL_LONG_SIDE = 800
CANONICAL_MIN_SIDE = 150 # Garde-fou : aucune dimension de sortie ne descend sous ce minimum
MIN_AREA_RATIO = 0.15 # Le quadrilatère doit couvrir au moins 15% de l'image (évite les détections trop petites/bruit)
MAX_AREA_RATIO = 0.98 # ... et au maximum 98% (évite de prendre le cadre entier de l'image comme "panneau")
ANGLE_TOLERANCE_DEG = 55 # Chaque angle interne doit être à 90° +/- cette tolérance
MIN_ASPECT_RATIO = 0.25 # Ratio largeur/hauteur minimum plausible pour un groupe de panneaux
MAX_ASPECT_RATIO = 4.0 # Ratio largeur/hauteur maximum plausible
SAVE_DEBUG_OVERLAY = True
OUTPUT_FOLDER_DEBUG = "./ImagesDebugCoins"
def reset_output_folder(folder_path):
"""Supprime entièrement un dossier de sortie (s'il existe) puis le recrée vide."""
if os.path.exists(folder_path):
shutil.rmtree(folder_path)
os.makedirs(folder_path, exist_ok=True)
def order_points(pts):
"""Ordonne 4 points dans l'ordre : haut-gauche, haut-droit, bas-droit, bas-gauche.
Nécessaire car approxPolyDP / les intersections de Hough ne garantissent aucun ordre."""
pts = np.array(pts, dtype=np.float32)
rect = np.zeros((4, 2), dtype=np.float32)
texts = pts.sum(axis=1) rect[0] = pts[np.argmin(s)] # Haut-gauche : somme x+y minimale rect[2] = pts[np.argmax(s)] # Bas-droit : somme x+y maximale diff = np.diff(pts, axis=1) rect[1] = pts[np.argmin(diff)] # Haut-droit : différence x-y minimale rect[3] = pts[np.argmax(diff)] # Bas-gauche : différence x-y maximale return rect
def _angle_at_point(p_prev, p_center, p_next):
"""Calcule l'angle interne (en degrés) au point p_center, formé par p_prev-p_center-p_next."""
v1 = p_prev - p_center
v2 = p_next - p_center
cos_angle = np.dot(v1, v2) / (np.linalg.norm(v1) * np.linalg.norm(v2) + 1e-6)
cos_angle = np.clip(cos_angle, -1.0, 1.0)
return math.degrees(math.acos(cos_angle))
def validate_quadrilateral(rect, image_shape):
"""Vérifie qu'un quadrilatère ordonné (tl, tr, br, bl) est géométriquement plausible.
Retourne (True, "ok", metrics) si valide, sinon (False, raison_du_rejet, metrics).
'metrics' est toujours renseigné (même en cas de rejet), pour permettre un diagnostic
quantitatif : à quel point la détection était-elle proche d'être acceptée ?"""
h_img, w_img = image_shape[:2]
image_area = h_img * w_img
textpoly_area = cv2.contourArea(rect.astype(np.float32)) area_ratio = poly_area / image_area tl, tr, br, bl = rect angles = [ _angle_at_point(bl, tl, tr), _angle_at_point(tl, tr, br), _angle_at_point(tr, br, bl), _angle_at_point(br, bl, tl), ] max_angle_deviation = max(abs(a - 90) for a in angles) width = (np.linalg.norm(tr - tl) + np.linalg.norm(br - bl)) / 2 height = (np.linalg.norm(bl - tl) + np.linalg.norm(br - tr)) / 2 aspect_ratio = width / (height + 1e-6) metrics = { "area_ratio": round(area_ratio, 3), "max_angle_deviation": round(max_angle_deviation, 1), "aspect_ratio": round(aspect_ratio, 2), } if area_ratio < MIN_AREA_RATIO: return False, f"aire_trop_petite ({area_ratio:.2f})", metrics if area_ratio > MAX_AREA_RATIO: return False, f"aire_trop_grande ({area_ratio:.2f})", metrics if max_angle_deviation > ANGLE_TOLERANCE_DEG: return False, f"angle_aberrant ({max_angle_deviation + 90:.0f}°)", metrics if not (MIN_ASPECT_RATIO <= aspect_ratio <= MAX_ASPECT_RATIO): return False, f"ratio_aberrant ({aspect_ratio:.2f})", metrics return True, "ok", metrics
def detect_corners_contour(image):
"""Détecte le plus grand contour quadrangulaire de l'image (méthode 'scanner de document').
Retourne un tableau de 4 points (non ordonnés) ou None si aucun quadrilatère plausible trouvé."""
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
blurred = cv2.GaussianBlur(gray, (5, 5), 0)
edges = cv2.Canny(blurred, 50, 150)
edges = cv2.dilate(edges, np.ones((3, 3), np.uint8), iterations=1) # Comble les petites discontinuités de contour
textcontours, _ = cv2.findContours(edges, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) if not contours: return None contours = sorted(contours, key=cv2.contourArea, reverse=True)[:5] # On examine les 5 plus grands for contour in contours: perimeter = cv2.arcLength(contour, True) approx = cv2.approxPolyDP(contour, 0.02 * perimeter, True) if len(approx) == 4: return approx.reshape(4, 2) return None
def _line_angle(x1, y1, x2, y2):
"""Angle d'une ligne en degrés, ramené entre 0 et 180."""
angle = math.degrees(math.atan2(y2 - y1, x2 - x1))
return angle % 180
def _line_intersection(line1, line2):
"""Calcule le point d'intersection de deux lignes (x1,y1,x2,y2). Retourne None si parallèles."""
x1, y1, x2, y2 = line1
x3, y3, x4, y4 = line2
denom = (x1 - x2) * (y3 - y4) - (y1 - y2) * (x3 - x4)
if abs(denom) < 1e-6:
return None
px = ((x1 * y2 - y1 * x2) * (x3 - x4) - (x1 - x2) * (x3 * y4 - y3 * x4)) / denom
py = ((x1 * y2 - y1 * x2) * (y3 - y4) - (y1 - y2) * (x3 * y4 - y3 * x4)) / denom
return (px, py)
def detect_corners_hough(image):
"""Détecte les 4 coins via les lignes dominantes du grillage/châssis du panneau (transformée de Hough).
Regroupe les lignes en 2 familles d'orientation (ex : ~horizontales et ~verticales), prend les lignes
extrêmes de chaque famille, et calcule leurs 4 intersections. Plus robuste que les contours quand le
panneau est partiellement visible ou que le fond est complexe."""
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
blurred = cv2.GaussianBlur(gray, (5, 5), 0)
edges = cv2.Canny(blurred, 50, 150)
textlines_raw = cv2.HoughLinesP(edges, 1, np.pi / 180, threshold=80, minLineLength=min(image.shape[:2]) // 4, maxLineGap=20) if lines_raw is None or len(lines_raw) < 4: return None # cv2.HoughLinesP renvoie une forme (N, 1, 4) ou (N, 4) selon la version d'OpenCV/NumPy installée. # reshape(-1, 4) normalise dans tous les cas vers une liste de N lignes [x1, y1, x2, y2]. lines = np.asarray(lines_raw).reshape(-1, 4) angles = np.array([_line_angle(*line) for line in lines]) # Regroupement simple en 2 familles d'orientation via un histogramme circulaire (0-180°) hist, bin_edges = np.histogram(angles, bins=18, range=(0, 180)) peak1_bin = np.argmax(hist) peak1_angle = (bin_edges[peak1_bin] + bin_edges[peak1_bin + 1]) / 2 # Deuxième pic, à au moins 30° du premier (pour éviter de retomber sur la même famille) hist_masked = hist.copy() for i, center in enumerate((bin_edges[:-1] + bin_edges[1:]) / 2): if min(abs(center - peak1_angle), 180 - abs(center - peak1_angle)) < 30: hist_masked[i] = 0 if hist_masked.max() == 0: return None peak2_bin = np.argmax(hist_masked) peak2_angle = (bin_edges[peak2_bin] + bin_edges[peak2_bin + 1]) / 2 def close_to(angle, ref, tol=20): diff = min(abs(angle - ref), 180 - abs(angle - ref)) return diff < tol family1 = [line for line, a in zip(lines, angles) if close_to(a, peak1_angle)] family2 = [line for line, a in zip(lines, angles) if close_to(a, peak2_angle)] # Il faut AU MOINS 2 lignes distinctes par famille, sinon "extreme_lines" retomberait sur # la même ligne pour les deux bornes (min=max), ce qui produit un quadrilatère dégénéré # (aire quasi nulle). On l'exige explicitement ici plutôt que de laisser le bug se produire. MIN_LINES_PER_FAMILY = 2 if len(family1) < MIN_LINES_PER_FAMILY or len(family2) < MIN_LINES_PER_FAMILY: return None # Dans chaque famille, on prend les 2 lignes les plus extrêmes (perpendiculairement à leur direction) def extreme_lines(family, angle_ref): # Projette le milieu de chaque ligne sur la normale à la direction dominante normal = np.array([-math.sin(math.radians(angle_ref)), math.cos(math.radians(angle_ref))]) projections = [] for line in family: x1, y1, x2, y2 = line mid = np.array([(x1 + x2) / 2, (y1 + y2) / 2]) projections.append(np.dot(mid, normal)) idx_min = int(np.argmin(projections)) idx_max = int(np.argmax(projections)) # Sécurité supplémentaire : si les deux lignes "extrêmes" sont en réalité quasi identiques # (écart de projection négligeable, ex : doublons issus du Hough), on rejette la famille # plutôt que de produire un quadrilatère plat. spread = abs(projections[idx_max] - projections[idx_min]) if spread < 15: # moins de 15 pixels d'écart = lignes quasi confondues return None, None return family[idx_min], family[idx_max] line1a, line1b = extreme_lines(family1, peak1_angle) line2a, line2b = extreme_lines(family2, peak2_angle) if line1a is None or line2a is None: return None # 4 intersections : (line1a,line2a), (line1a,line2b), (line1b,line2a), (line1b,line2b) corners = [] for l1 in (line1a, line1b): for l2 in (line2a, line2b): pt = _line_intersection(l1, l2) if pt is None: return None corners.append(pt) return np.array(corners, dtype=np.float32)
def get_panel_corners(image):
"""Essaie de détecter un quadrilatère plausible : d'abord par contours, puis par Hough en fallback.
Retourne un dictionnaire complet avec le résultat final ET les métriques diagnostiques de
chaque méthode tentée (même en cas d'échec), pour permettre une analyse quantitative globale."""
diag = {
"contour_tente": False, "contour_valide": False, "contour_metrics": None, "contour_raison": None,
"hough_tente": False, "hough_valide": False, "hough_metrics": None, "hough_raison": None,
}
textcorners = detect_corners_contour(image) if corners is not None: diag["contour_tente"] = True rect_c = order_points(corners) valid, reason, metrics = validate_quadrilateral(rect_c, image.shape) diag["contour_metrics"] = metrics diag["contour_raison"] = reason if valid: diag["contour_valide"] = True return rect_c, "contour", diag corners = detect_corners_hough(image) if corners is not None: diag["hough_tente"] = True rect_h = order_points(corners) valid, reason, metrics = validate_quadrilateral(rect_h, image.shape) diag["hough_metrics"] = metrics diag["hough_raison"] = reason if valid: diag["hough_valide"] = True return rect_h, "hough", diag return None, "aucun_quadrilatere_valide", diag
def compute_canonical_size(rect, long_side=CANONICAL_LONG_SIDE, min_side=CANONICAL_MIN_SIDE):
"""Calcule la taille de sortie (largeur, hauteur) à partir de la géométrie RÉELLE du
quadrilatère détecté, en préservant son orientation (portrait/paysage) et son ratio
largeur/hauteur approximatif. Le côté le plus long est fixé à long_side, l'autre en
découle proportionnellement.
textC'est ce qui évite qu'un panneau photographié en mode portrait ne soit écrasé dans un format paysage fixe (ou inversement), ce qui déformerait la grille de cellules.""" tl, tr, br, bl = rect width = (np.linalg.norm(tr - tl) + np.linalg.norm(br - bl)) / 2 height = (np.linalg.norm(bl - tl) + np.linalg.norm(br - tr)) / 2 aspect_ratio = width / (height + 1e-6) # >1 = paysage, <1 = portrait if aspect_ratio >= 1: target_w = long_side target_h = round(long_side / aspect_ratio) else: target_h = long_side target_w = round(long_side * aspect_ratio) target_w = max(target_w, min_side) target_h = max(target_h, min_side) return int(target_w), int(target_h)
def warp_perspective(image, rect, canonical_size):
"""Calcule la matrice d'homographie (rect source -> rectangle canonique) et redresse l'image."""
target_w, target_h = canonical_size
destination = np.array([
[0, 0],
[target_w - 1, 0],
[target_w - 1, target_h - 1],
[0, target_h - 1],
], dtype=np.float32)
textmatrix = cv2.getPerspectiveTransform(rect, destination) warped = cv2.warpPerspective(image, matrix, (target_w, target_h)) return warped
def draw_debug_overlay(image, rect, method):
"""Dessine les 4 coins détectés et leur ordre, pour vérification visuelle rapide."""
overlay = image.copy()
labels = ["TL", "TR", "BR", "BL"]
for i, (point, label) in enumerate(zip(rect, labels)):
pt = tuple(point.astype(int))
cv2.circle(overlay, pt, 8, (0, 0, 255), -1)
cv2.putText(overlay, label, (pt[0] + 10, pt[1]), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 255, 0), 2)
pts_int = rect.astype(int).reshape((-1, 1, 2))
cv2.polylines(overlay, [pts_int], isClosed=True, color=(255, 0, 0), thickness=2)
cv2.putText(overlay, f"methode: {method}", (10, 25), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 255, 255), 2)
return overlay
def process_single_image(image_path):
"""Traite une image : détection des coins, validation, redressement.
Retourne un dictionnaire décrivant le résultat, incluant les métriques diagnostiques
des deux méthodes tentées (utile pour calibrer les seuils sans avoir à tout ré-annoter)."""
image = cv2.imread(image_path)
if image is None:
return {"status": "erreur_lecture", "method": None, "reason": "image_illisible",
"warped": None, "debug": None, "diag": {}}
textrect, method_or_reason, diag = get_panel_corners(image) if rect is None: return {"status": "echec", "method": None, "reason": method_or_reason, "warped": None, "debug": None, "diag": diag} canonical_size = compute_canonical_size(rect) warped = warp_perspective(image, rect, canonical_size) debug_overlay = draw_debug_overlay(image, rect, method_or_reason) if SAVE_DEBUG_OVERLAY else None return {"status": "succes", "method": method_or_reason, "reason": "ok", "warped": warped, "debug": debug_overlay, "diag": diag}
def process_folder(input_folder=INPUT_FOLDER):
reset_output_folder(OUTPUT_FOLDER_SUCCESS)
reset_output_folder(OUTPUT_FOLDER_REVIEW)
if SAVE_DEBUG_OVERLAY:
reset_output_folder(OUTPUT_FOLDER_DEBUG)
textreport_rows = [] counts = {"succes": 0, "echec": 0, "erreur_lecture": 0} for current_root, _, files in os.walk(input_folder): image_files = [f for f in files if f.lower().endswith(valid_extensions)] if not image_files: continue relative_path = os.path.relpath(current_root, input_folder) success_sub_folder = os.path.join(OUTPUT_FOLDER_SUCCESS, relative_path) review_sub_folder = os.path.join(OUTPUT_FOLDER_REVIEW, relative_path) debug_sub_folder = os.path.join(OUTPUT_FOLDER_DEBUG, relative_path) os.makedirs(success_sub_folder, exist_ok=True) os.makedirs(review_sub_folder, exist_ok=True) if SAVE_DEBUG_OVERLAY: os.makedirs(debug_sub_folder, exist_ok=True) print(f"\n📂 {relative_path} : {len(image_files)} image(s)") for filename in image_files: image_path = os.path.join(current_root, filename) result = process_single_image(image_path) counts[result["status"]] = counts.get(result["status"], 0) + 1 report_rows.append({ "dossier": relative_path, "fichier": filename, "statut": result["status"], "methode": result["method"], "raison": result["reason"], }) if result["status"] == "succes": cv2.imwrite(os.path.join(success_sub_folder, filename), result["warped"]) if SAVE_DEBUG_OVERLAY and result["debug"] is not None: cv2.imwrite(os.path.join(debug_sub_folder, filename), result["debug"]) print(f" ✅ {filename} (méthode: {result['method']})") else: # Image copiée telle quelle dans le dossier de révision manuelle shutil.copy2(image_path, os.path.join(review_sub_folder, filename)) print(f" ⚠️ {filename} → à vérifier ({result['reason']})") # --- Écriture du rapport CSV --- with open(REPORT_PATH, "w", newline="", encoding="utf-8") as f: writer = csv.DictWriter(f, fieldnames=["dossier", "fichier", "statut", "methode", "raison"]) writer.writeheader() writer.writerows(report_rows) total = sum(counts.values()) print("\n" + "=" * 60) print("🎉 Traitement terminé !") print(f" Total traité : {total}") print(f" ✅ Succès : {counts.get('succes', 0)} ({100 * counts.get('succes', 0) / max(total,1):.1f}%)") print(f" ⚠️ À vérifier : {counts.get('echec', 0)} ({100 * counts.get('echec', 0) / max(total,1):.1f}%)") print(f" ❌ Erreurs lecture : {counts.get('erreur_lecture', 0)}") print(f" 📄 Rapport détaillé : {REPORT_PATH}") print("=" * 60)
if name == "main":
process_folder()
Oui. La structure générale est déjà saine : séparation détection/validation/homographie, fallback Hough, rapport CSV et dossier de contrôle manuel. Les gains les plus importants viendront surtout de la sélection du meilleur quadrilatère, de la robustesse géométrique et du diagnostic, plutôt que d’ajouter davantage de seuils fixes.
J’ai repéré deux points qui peuvent fausser vos résultats sans provoquer d’erreur visible : votre CSV ne contient actuellement aucune des métriques diagnostiques pourtant calculées, et les champs contour_tente / hough_tente restent à False lorsque la méthode a bien été exécutée mais n’a trouvé aucun candidat. Ce sont de bons premiers correctifs, car ils rendent ensuite le réglage des seuils beaucoup plus fiable.
Oui. Votre pipeline est bien découpé, mais plusieurs améliorations peuvent augmenter nettement la robustesse sans passer à un modèle IA.
Ici :
pythonif max_angle_deviation > ANGLE_TOLERANCE_DEG: return False, f"angle_aberrant ({max_angle_deviation + 90:.0f}°)", metrics
max_angle_deviation est une distance à 90°, pas l'angle lui-même.
Par exemple, si l'angle problématique vaut 30°, l'écart vaut 60°, et votre message indiquera 150°.
Il vaut mieux conserver l'angle réellement problématique :
pythondeviations = [abs(a - 90) for a in angles] worst_index = int(np.argmax(deviations)) max_angle_deviation = deviations[worst_index] worst_angle = angles[worst_index] metrics = { "area_ratio": round(area_ratio, 3), "max_angle_deviation": round(max_angle_deviation, 1), "worst_angle": round(worst_angle, 1), "aspect_ratio": round(aspect_ratio, 2), } if max_angle_deviation > ANGLE_TOLERANCE_DEG: return False, f"angle_aberrant ({worst_angle:.1f}°)", metrics
Mais je recommande en réalité d'aller plus loin : ne plus vérifier que les angles ressemblent à 90°.
Une projection perspective d'un rectangle peut produire des angles très éloignés de 90°. Ce que vous voulez réellement éliminer, ce sont les quadrilatères quasi dégénérés, avec par exemple des angles de 3° ou 177°.
Je remplacerais donc :
pythonANGLE_TOLERANCE_DEG = 55
par :
pythonMIN_INTERNAL_ANGLE = 12 MAX_INTERNAL_ANGLE = 168
puis :
pythonif min(angles) < MIN_INTERNAL_ANGLE: return False, f"angle_trop_aigu ({min(angles):.1f}°)", metrics if max(angles) > MAX_INTERNAL_ANGLE: return False, f"angle_trop_plat ({max(angles):.1f}°)", metrics
C'est beaucoup plus cohérent avec votre problème.
order_points() peut se tromper sous forte perspectiveLa technique x+y / x-y est très courante, mais elle peut produire des doublons ou un mauvais ordre sur des quadrilatères très déformés.
Je la remplacerais par un ordre circulaire autour du centre :
pythondef order_points(pts): """ Ordonne les points TL, TR, BR, BL de manière plus robuste, même lorsque le quadrilatère est fortement déformé. """ pts = np.asarray(pts, dtype=np.float32).reshape(4, 2) hull = cv2.convexHull(pts).reshape(-1, 2) if len(hull) != 4: raise ValueError("Les 4 points ne forment pas un quadrilatère convexe") center = hull.mean(axis=0) angles = np.arctan2( hull[:, 1] - center[1], hull[:, 0] - center[0], ) ordered = hull[np.argsort(angles)] # Commencer par le point le plus proche du haut-gauche start = np.argmin(ordered[:, 0] + ordered[:, 1]) ordered = np.roll(ordered, -start, axis=0) # On veut TL, TR, BR, BL et non TL, BL, BR, TR if ordered[1, 0] < ordered[3, 0]: ordered = ordered[[0, 3, 2, 1]] return ordered.astype(np.float32)
Cela évite un certain nombre de quadrilatères auto-croisés ou mal ordonnés.
Actuellement, un quadrilatère peut théoriquement passer plusieurs tests tout en étant géométriquement très mauvais.
Je renforcerais validate_quadrilateral() avec :
pythonMIN_EDGE_RATIO = 0.03 MAX_OUTSIDE_MARGIN = 0.15 MIN_INTERNAL_ANGLE = 12 MAX_INTERNAL_ANGLE = 168
Puis :
pythondef validate_quadrilateral(rect, image_shape): h_img, w_img = image_shape[:2] image_area = h_img * w_img rect = np.asarray(rect, dtype=np.float32).reshape(4, 2) # -------------------------------------------------- # 1. Convexité # -------------------------------------------------- contour = rect.reshape(-1, 1, 2) if not cv2.isContourConvex(contour): return False, "quadrilatere_non_convexe", {} # -------------------------------------------------- # 2. Aire # -------------------------------------------------- poly_area = abs(cv2.contourArea(contour)) area_ratio = poly_area / max(image_area, 1) # -------------------------------------------------- # 3. Longueurs # -------------------------------------------------- tl, tr, br, bl = rect edge_lengths = [ np.linalg.norm(tr - tl), np.linalg.norm(br - tr), np.linalg.norm(bl - br), np.linalg.norm(tl - bl), ] image_diagonal = math.hypot(w_img, h_img) min_edge_ratio = min(edge_lengths) / max(image_diagonal, 1) # -------------------------------------------------- # 4. Angles # -------------------------------------------------- angles = [ _angle_at_point(bl, tl, tr), _angle_at_point(tl, tr, br), _angle_at_point(tr, br, bl), _angle_at_point(br, bl, tl), ] # -------------------------------------------------- # 5. Ratio apparent # -------------------------------------------------- width = ( np.linalg.norm(tr - tl) + np.linalg.norm(br - bl) ) / 2 height = ( np.linalg.norm(bl - tl) + np.linalg.norm(br - tr) ) / 2 aspect_ratio = width / max(height, 1e-6) metrics = { "area_ratio": round(area_ratio, 4), "min_angle": round(min(angles), 1), "max_angle": round(max(angles), 1), "min_edge_ratio": round(min_edge_ratio, 4), "aspect_ratio": round(aspect_ratio, 3), } # -------------------------------------------------- # Validation # -------------------------------------------------- if area_ratio < MIN_AREA_RATIO: return False, f"aire_trop_petite ({area_ratio:.3f})", metrics if area_ratio > MAX_AREA_RATIO: return False, f"aire_trop_grande ({area_ratio:.3f})", metrics if min_edge_ratio < MIN_EDGE_RATIO: return False, "cote_trop_court", metrics if min(angles) < MIN_INTERNAL_ANGLE: return False, f"angle_trop_aigu ({min(angles):.1f}°)", metrics if max(angles) > MAX_INTERNAL_ANGLE: return False, f"angle_trop_plat ({max(angles):.1f}°)", metrics # Autoriser légèrement les intersections Hough hors image. margin_x = MAX_OUTSIDE_MARGIN * w_img margin_y = MAX_OUTSIDE_MARGIN * h_img for x, y in rect: if ( x < -margin_x or x > w_img + margin_x or y < -margin_y or y > h_img + margin_y ): return False, "coins_trop_loin_hors_image", metrics return True, "ok", metrics
Je supprimerais dans un premier temps le rejet strict :
pythonMIN_ASPECT_RATIO MAX_ASPECT_RATIO
ou je l'élargirais énormément.
La raison est importante : le width / height que vous calculez est le ratio du quadrilatère projeté dans l'image, et non le vrai ratio physique du panneau. Sous forte perspective, il peut être très différent.
C'est probablement l'amélioration qui aura le plus d'impact.
Votre code fait actuellement :
pythonfor contour in contours: ... if len(approx) == 4: return approx.reshape(4, 2)
Ainsi, le premier quadrilatère gagne, même si le deuxième ou troisième correspond beaucoup mieux au panneau.
Je chercherais plutôt tous les candidats, puis je sélectionnerais le meilleur :
pythondef detect_corners_contour(image): gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) clahe = cv2.createCLAHE( clipLimit=2.0, tileGridSize=(8, 8) ) gray = clahe.apply(gray) blurred = cv2.GaussianBlur(gray, (5, 5), 0) median = np.median(blurred) lower = int(max(20, 0.66 * median)) upper = int(min(255, max(lower + 30, 1.33 * median))) edges = cv2.Canny(blurred, lower, upper) kernel = cv2.getStructuringElement( cv2.MORPH_RECT, (3, 3) ) edges = cv2.morphologyEx( edges, cv2.MORPH_CLOSE, kernel, iterations=1, ) contours, _ = cv2.findContours( edges, cv2.RETR_LIST, cv2.CHAIN_APPROX_SIMPLE, ) candidates = [] for contour in sorted( contours, key=cv2.contourArea, reverse=True, )[:20]: perimeter = cv2.arcLength(contour, True) if perimeter <= 0: continue # Plusieurs epsilon plutôt qu'une seule valeur magique for epsilon_ratio in (0.01, 0.015, 0.02, 0.03, 0.04): approx = cv2.approxPolyDP( contour, epsilon_ratio * perimeter, True, ) if len(approx) != 4: continue points = approx.reshape(4, 2) try: rect = order_points(points) except ValueError: continue valid, _, metrics = validate_quadrilateral( rect, image.shape, ) if not valid: continue # Pour commencer : favoriser les grands quadrilatères. # Ce score pourra ensuite incorporer le support des bords. score = metrics["area_ratio"] candidates.append((score, rect)) if not candidates: return None candidates.sort( key=lambda item: item[0], reverse=True, ) return candidates[0][1]
Le passage de :
pythoncv2.RETR_EXTERNAL
à :
pythoncv2.RETR_LIST
est également intéressant : un panneau peut être entouré par un autre grand contour correspondant au toit ou au bord de l'image.
Le plus grand quadrilatère n'est pas forcément le panneau. On peut donc regarder combien de pixels Canny existent le long de ses quatre côtés.
Par exemple :
pythondef edge_support_score(rect, edges, thickness=5): """ Pourcentage du périmètre du quadrilatère qui est soutenu par des pixels de contour. """ dilated = cv2.dilate( edges, np.ones((thickness, thickness), dtype=np.uint8), iterations=1, ) mask = np.zeros_like(edges) pts = rect.astype(np.int32) for i in range(4): p1 = tuple(pts[i]) p2 = tuple(pts[(i + 1) % 4]) cv2.line( mask, p1, p2, 255, 1, ) perimeter_pixels = mask > 0 count = np.count_nonzero(perimeter_pixels) if count == 0: return 0.0 supported = np.count_nonzero( (dilated > 0) & perimeter_pixels ) return supported / count
Et votre score peut devenir par exemple :
pythonsupport = edge_support_score(rect, edges) score = ( 0.75 * support + 0.25 * min(metrics["area_ratio"] / 0.6, 1.0) )
Cela permet de préférer :
un quadrilatère dont les quatre côtés suivent réellement le châssis du panneau
plutôt que :
simplement le plus grand quadrilatère de l'image.
C'est particulièrement intéressant sur des photos de toitures.
Actuellement :
textContour valide ? oui → on arrête non → Hough
Mais vous pouvez avoir :
textContour : candidat acceptable mais médiocre Hough : candidat excellent
Le contour gagnera malgré tout.
Je transformerais la logique en :
textContour → candidat + score Hough → candidat + score ↓ prendre le candidat ayant le meilleur score/confiance
Conceptuellement :
pythoncandidates = [] contour = detect_corners_contour(image) if contour is not None: ... if valid: candidates.append({ "rect": rect, "method": "contour", "score": score, }) hough = detect_corners_hough(image) if hough is not None: ... if valid: candidates.append({ "rect": rect, "method": "hough", "score": score, }) if not candidates: return None, ... best = max(candidates, key=lambda x: x["score"])
À mon avis, c'est préférable au concept de fallback.
*_tente sont actuellement inexactsVous faites :
pythoncorners = detect_corners_contour(image) if corners is not None: diag["contour_tente"] = True
Cela signifie en réalité :
contour_tente = Trueseulement si la détection a trouvé quelque chose.
Alors que la méthode a bien été tentée.
Corrigez avec :
pythondiag["contour_tente"] = True corners = detect_corners_contour(image) if corners is None: diag["contour_raison"] = "aucun_candidat" else: ...
Même chose pour Hough :
pythondiag["hough_tente"] = True corners = detect_corners_hough(image) if corners is None: diag["hough_raison"] = "aucun_candidat" else: ...
C'est dommage, car votre commentaire indique justement :
« permettre une analyse quantitative globale ».
Votre CSV ne contient actuellement que :
python"dossier" "fichier" "statut" "methode" "raison"
J'ajouterais au minimum :
pythondef get_metric(diag, method, metric): metrics = diag.get(f"{method}_metrics") if not metrics: return None return metrics.get(metric)
Et :
pythondiag = result.get("diag", {}) report_rows.append({ "dossier": relative_path, "fichier": filename, "statut": result["status"], "methode": result["method"], "raison": result["reason"], "contour_raison": diag.get("contour_raison"), "contour_area_ratio": get_metric(diag, "contour", "area_ratio"), "contour_min_angle": get_metric(diag, "contour", "min_angle"), "contour_max_angle": get_metric(diag, "contour", "max_angle"), "contour_aspect_ratio": get_metric(diag, "contour", "aspect_ratio"), "hough_raison": diag.get("hough_raison"), "hough_area_ratio": get_metric(diag, "hough", "area_ratio"), "hough_min_angle": get_metric(diag, "hough", "min_angle"), "hough_max_angle": get_metric(diag, "hough", "max_angle"), "hough_aspect_ratio": get_metric(diag, "hough", "aspect_ratio"), })
C'est ce CSV qui vous permettra ensuite de répondre à des questions comme :
text80 % des échecs sont-ils dus à l'aire ? aux intersections Hough ? à un angle dégénéré ? à la taille des côtés ?
Ce commentaire est légèrement trompeur :
python# ratio largeur/hauteur RÉEL détecté
Le ratio que vous calculez avec :
pythonwidth = (...) height = (...) aspect_ratio = width / height
n'est pas le ratio réel physique du panneau.
C'est son ratio apparent dans la photographie après projection perspective.
Imaginez un rectangle vu presque de côté : un de ses côtés est énormément raccourci par la perspective.
Donc l'homographie peut parfaitement remettre ses quatre coins dans un rectangle, mais le choix de la largeur et de la hauteur de ce rectangle reste à déterminer.
Si vous connaissez les dimensions du panneau ou de l'ensemble de modules, la meilleure solution est :
pythonKNOWN_PANEL_ASPECT_RATIO = 1.65
ou par exemple, si la grille est connue :
pythonCELL_WIDTH = 156 CELL_HEIGHT = 156 NB_COLUMNS = 6 NB_ROWS = 10 KNOWN_PANEL_ASPECT_RATIO = ( NB_COLUMNS * CELL_WIDTH / (NB_ROWS * CELL_HEIGHT) )
Puis :
pythondef compute_canonical_size( rect, long_side=CANONICAL_LONG_SIDE, known_aspect_ratio=None ): if known_aspect_ratio is not None: ratio = known_aspect_ratio else: tl, tr, br, bl = rect width = ( np.linalg.norm(tr - tl) + np.linalg.norm(br - bl) ) / 2 height = ( np.linalg.norm(bl - tl) + np.linalg.norm(br - tr) ) / 2 ratio = width / max(height, 1e-6) if ratio >= 1: target_w = long_side target_h = round(long_side / ratio) else: target_h = long_side target_w = round(long_side * ratio) return ( max(int(target_w), CANONICAL_MIN_SIDE), max(int(target_h), CANONICAL_MIN_SIDE), )
Si votre objectif suivant est la segmentation des cellules photovoltaïques, un ratio connu constitue une grosse amélioration.
Si vos photos font par exemple 4000×3000 ou 6000×4000, Canny + contours + Hough travaillent inutilement sur des millions de pixels.
Ajoutez :
pythonDETECTION_MAX_SIDE = 1600
et :
pythondef resize_for_detection(image, max_side=DETECTION_MAX_SIDE): h, w = image.shape[:2] scale = min( 1.0, max_side / max(h, w) ) if scale == 1.0: return image, 1.0 resized = cv2.resize( image, None, fx=scale, fy=scale, interpolation=cv2.INTER_AREA, ) return resized, scale
Puis :
pythonwork_image, scale = resize_for_detection(image) corners = detect_corners_contour(work_image) if corners is not None: corners = corners / scale
La détection est donc rapide sur 1600 px, mais :
pythoncv2.warpPerspective(...)
est toujours appliqué sur l'original.
C'est généralement le meilleur compromis.
Votre histogramme fait actuellement :
pythonhist, bin_edges = np.histogram( angles, bins=18, range=(0, 180) )
Une petite ligne parasite de 20 pixels compte autant qu'une bordure de panneau de 900 pixels.
Calculez :
pythonlengths = np.array([ math.hypot( line[2] - line[0], line[3] - line[1], ) for line in lines ])
Puis :
pythonhist, bin_edges = np.histogram( angles, bins=36, range=(0, 180), weights=lengths, )
J'utiliserais également 36 bins plutôt que 18 :
text18 bins → résolution 10° 36 bins → résolution 5°
Pour la géométrie du châssis, 5° me paraît plus approprié.
try/except par imageUne seule image atypique ne devrait jamais arrêter 5 000 traitements.
pythonfor filename in sorted(image_files): image_path = os.path.join(current_root, filename) try: result = process_single_image(image_path) except Exception as exc: result = { "status": "erreur_traitement", "method": None, "reason": f"{type(exc).__name__}: {exc}", "warped": None, "debug": None, "diag": {}, }
Il faut alors prévoir :
pythoncounts = { "succes": 0, "echec": 0, "erreur_lecture": 0, "erreur_traitement": 0, }
cv2.imwrite()OpenCV ne lève pas nécessairement une exception :
pythoncv2.imwrite(...)
renvoie un booléen.
Donc :
pythonoutput_path = os.path.join( success_sub_folder, filename, ) saved = cv2.imwrite( output_path, result["warped"], ) if not saved: raise IOError( f"Impossible d'écrire {output_path}" )
Ça évite de comptabiliser un succès alors qu'aucun fichier n'a été produit.
Je ferais évoluer votre pipeline vers ceci :
textIMAGE │ réduction pour détection │ ┌───────────┴────────────┐ │ │ CONTOURS HOUGH │ │ plusieurs candidats plusieurs candidats │ │ └───────────┬────────────┘ │ validation géométrique │ score de confiance │ meilleur candidat │ confiance suffisante ? │ │ oui non │ │ homographie à vérifier │ image originale │ image redressée
Et non plus simplement :
textcontour → oui ? stop ↓ non Hough
Dans votre cas, je commencerais par (1) supprimer le test « proche de 90° » au profit d'un test de dégénérescence 12°–168°, (2) sélectionner le meilleur contour au lieu du premier quadrilatère, (3) mettre contour et Hough en concurrence, (4) exporter toutes les métriques dans le CSV, et (5) si la géométrie des panneaux est connue, imposer le vrai ratio de sortie plutôt que le ratio apparent de la photographie.
Ces cinq changements devraient améliorer à la fois le taux de détection, la qualité des redressements et surtout votre capacité à comprendre automatiquement pourquoi les images restantes échouent.