Vous pouvez m'aider à apporter des amélioration po...

作成日: 2026年10月22日

使用モデル GPT-5.6 Thinking by 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

==========================================================

CONFIGURATION

==========================================================

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")

Taille de référence pour le côté LE PLUS LONG du rectangle canonique après redressement.

L'autre côté est calculé automatiquement à partir du ratio largeur/hauteur RÉEL détecté sur

chaque image, ce qui préserve l'orientation d'origine (portrait reste portrait, paysage reste

paysage) et évite de déformer la grille de cellules — important pour la segmentation ultérieure.

CANONICAL_LONG_SIDE = 800
CANONICAL_MIN_SIDE = 150 # Garde-fou : aucune dimension de sortie ne descend sous ce minimum

--- Seuils de validation d'un quadrilatère détecté ---

Un quadrilatère est accepté seulement s'il respecte TOUTES ces conditions :

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")

NOTE (mise à jour après diagnostic sur échantillon réel) : une vraie photo de toit prise en angle très

oblique a NATURELLEMENT des angles internes très éloignés de 90° — ce n'est pas une erreur de détection,

c'est la réalité géométrique de la perspective, et c'est justement ce que l'homographie doit corriger.

Un seuil trop strict ici rejette à tort des détections correctes. On le relâche fortement (55° au lieu

de 35°) ; la protection contre les quadrilatères vraiment aberrants reste assurée par MIN/MAX_AREA_RATIO

et MIN/MAX_ASPECT_RATIO, qui filtrent les cas dégénérés indépendamment de l'angle de vue.

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

Sauvegarder une image de debug avec les coins détectés dessinés dessus (utile pour valider visuellement)

SAVE_DEBUG_OVERLAY = True
OUTPUT_FOLDER_DEBUG = "./ImagesDebugCoins"

==========================================================

UTILITAIRES : GESTION DES DOSSIERS

==========================================================

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)

==========================================================

GÉOMÉTRIE : ORDONNANCEMENT ET VALIDATION DES POINTS

==========================================================

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)

text
s = 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

text
poly_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

==========================================================

MÉTHODE 1 : DÉTECTION PAR CONTOURS + APPROXIMATION POLYGONALE

==========================================================

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

text
contours, _ = 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

==========================================================

MÉTHODE 2 (FALLBACK) : DÉTECTION PAR LIGNES DE HOUGH

==========================================================

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)

text
lines_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)

==========================================================

ORCHESTRATION : ESSAIE MÉTHODE 1 PUIS MÉTHODE 2

==========================================================

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,
}

text
corners = 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

==========================================================

REDRESSEMENT (HOMOGRAPHIE)

==========================================================

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.

text
C'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)

text
matrix = 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

==========================================================

TRAITEMENT D'UNE SEULE IMAGE

==========================================================

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": {}}

text
rect, 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}

==========================================================

TRAITEMENT DU DOSSIER COMPLET (récursif, avec rapport CSV)

==========================================================

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)

text
report_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()

思考

Évaluant le code

I’m reviewing correctness, robustness, and maintainability. I’m focusing on corner ordering, perspective validation, numerical safeguards, image bounds, adaptive thresholding, and preserving the panel’s true aspect ratio during rectification.

Évalué le code

回答

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.

思考

Affinant les recommandations

I’m continuing by prioritizing concrete bugs: validate image writes, prevent output folders from being reprocessed, correct diagnostic flags, export useful metrics, and handle unreadable or unusually formatted images gracefully.

Affiné les recommandations

回答

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.

思考

Worked for 49s

回答

Oui. Votre pipeline est bien découpé, mais plusieurs améliorations peuvent augmenter nettement la robustesse sans passer à un modèle IA.

1. Il y a un petit bug dans le diagnostic des angles

Ici :

python
if 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 :

python
deviations = [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 :

python
ANGLE_TOLERANCE_DEG = 55

par :

python
MIN_INTERNAL_ANGLE = 12 MAX_INTERNAL_ANGLE = 168

puis :

python
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

C'est beaucoup plus cohérent avec votre problème.


2. order_points() peut se tromper sous forte perspective

La 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 :

python
def 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.


3. Ajoutez une vraie validation de convexité et de longueur des côté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 :

python
MIN_EDGE_RATIO = 0.03 MAX_OUTSIDE_MARGIN = 0.15 MIN_INTERNAL_ANGLE = 12 MAX_INTERNAL_ANGLE = 168

Puis :

python
def 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 :

python
MIN_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.


4. Ne retournez pas le premier contour à 4 côtés

C'est probablement l'amélioration qui aura le plus d'impact.

Votre code fait actuellement :

python
for 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 :

python
def 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 :

python
cv2.RETR_EXTERNAL

à :

python
cv2.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.


5. Mieux encore : mesurer si les quatre côtés correspondent réellement à des arêtes

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 :

python
def 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 :

python
support = 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.


6. Contour et Hough devraient être mis en concurrence

Actuellement :

text
Contour valide ? oui → on arrête non → Hough

Mais vous pouvez avoir :

text
Contour : candidat acceptable mais médiocre Hough : candidat excellent

Le contour gagnera malgré tout.

Je transformerais la logique en :

text
Contour → candidat + score Hough → candidat + score ↓ prendre le candidat ayant le meilleur score/confiance

Conceptuellement :

python
candidates = [] 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.


7. Vos flags *_tente sont actuellement inexacts

Vous faites :

python
corners = detect_corners_contour(image) if corners is not None: diag["contour_tente"] = True

Cela signifie en réalité :

contour_tente = True seulement si la détection a trouvé quelque chose.

Alors que la méthode a bien été tentée.

Corrigez avec :

python
diag["contour_tente"] = True corners = detect_corners_contour(image) if corners is None: diag["contour_raison"] = "aucun_candidat" else: ...

Même chose pour Hough :

python
diag["hough_tente"] = True corners = detect_corners_hough(image) if corners is None: diag["hough_raison"] = "aucun_candidat" else: ...

8. Vous calculez beaucoup de diagnostics… puis vous ne les écrivez pas dans le CSV

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 :

python
def get_metric(diag, method, metric): metrics = diag.get(f"{method}_metrics") if not metrics: return None return metrics.get(metric)

Et :

python
diag = 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 :

text
80 % des échecs sont-ils dus à l'aire ? aux intersections Hough ? à un angle dégénéré ? à la taille des côtés ?

9. Attention au ratio de votre image redressée

Ce commentaire est légèrement trompeur :

python
# ratio largeur/hauteur RÉEL détecté

Le ratio que vous calculez avec :

python
width = (...) 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 :

python
KNOWN_PANEL_ASPECT_RATIO = 1.65

ou par exemple, si la grille est connue :

python
CELL_WIDTH = 156 CELL_HEIGHT = 156 NB_COLUMNS = 6 NB_ROWS = 10 KNOWN_PANEL_ASPECT_RATIO = ( NB_COLUMNS * CELL_WIDTH / (NB_ROWS * CELL_HEIGHT) )

Puis :

python
def 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.


10. Pour les grandes images, détectez sur une miniature et redressez l'original

Si vos photos font par exemple 4000×3000 ou 6000×4000, Canny + contours + Hough travaillent inutilement sur des millions de pixels.

Ajoutez :

python
DETECTION_MAX_SIDE = 1600

et :

python
def 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 :

python
work_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 :

python
cv2.warpPerspective(...)

est toujours appliqué sur l'original.

C'est généralement le meilleur compromis.


11. Hough : pondérez l'histogramme par la longueur des lignes

Votre histogramme fait actuellement :

python
hist, 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 :

python
lengths = np.array([ math.hypot( line[2] - line[0], line[3] - line[1], ) for line in lines ])

Puis :

python
hist, bin_edges = np.histogram( angles, bins=36, range=(0, 180), weights=lengths, )

J'utiliserais également 36 bins plutôt que 18 :

text
18 bins → résolution 10° 36 bins → résolution 5°

Pour la géométrie du châssis, 5° me paraît plus approprié.


12. Ajoutez un try/except par image

Une seule image atypique ne devrait jamais arrêter 5 000 traitements.

python
for 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 :

python
counts = { "succes": 0, "echec": 0, "erreur_lecture": 0, "erreur_traitement": 0, }

13. Vérifiez aussi cv2.imwrite()

OpenCV ne lève pas nécessairement une exception :

python
cv2.imwrite(...)

renvoie un booléen.

Donc :

python
output_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.


Architecture que je viserais

Je ferais évoluer votre pipeline vers ceci :

text
IMAGE │ 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 :

text
contour → oui ? stop ↓ non Hough

Les 5 changements que je ferais en premier

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.

このQ&Aを共有