# src/feature_extractor.py import cv2 import numpy as np from typing import Dict, List from langsmith import traceable # ─── Yardımcı kategorileştirici fonksiyonlar ───────────────────────────────── def _size_category(max_dim_mm: float) -> str: """EAU klinik eşiklerine göre boyut kategorisi.""" if max_dim_mm < 5: return "small" elif max_dim_mm < 10: return "medium" elif max_dim_mm < 20: return "large" else: return "very_large" def _density_category(mean_intensity: float) -> str: """0-255 piksel skalasına göre relative density kategorisi.""" if mean_intensity > 230: return "very_high" elif mean_intensity > 200: return "high" elif mean_intensity > 160: return "moderate" else: return "low" def _shape_from_mask(circularity: float, eccentricity: float) -> str: """Circularity + eccentricity'den şekil kategorisi.""" if circularity >= 0.85: return "round" elif circularity >= 0.65 and eccentricity < 0.7: return "oval" elif circularity >= 0.40: return "irregular" else: return "highly_irregular" def _empty_morphology() -> Dict: return { "area_px": 0, "area_mm2": 0, "perimeter_px": 0, "circularity": 0, "solidity": 0, "eccentricity": 0, "orientation_deg": 0, "equivalent_diameter_mm": 0, "size_category": "unknown", "shape_category": "unknown" } # ─── Bbox-tabanlı fonksiyonlar (fallback — mask yoksa kullanılır) ───────────── def extract_basic_features(detection_result: Dict) -> Dict: return { "stone_detected": detection_result["num_detections"] > 0, "count": detection_result["num_detections"], "image_dimensions": { "height": detection_result["image_shape"][0], "width": detection_result["image_shape"][1] } } def estimate_size_mm(bbox, pixel_spacing_mm: float = 0.7) -> Dict: """Bbox'tan yaklaşık boyut (mask yoksa fallback).""" x1, y1, x2, y2 = bbox width_mm = (x2 - x1) * pixel_spacing_mm height_mm = (y2 - y1) * pixel_spacing_mm max_dim_mm = max(width_mm, height_mm) return { "width_mm": round(width_mm, 1), "height_mm": round(height_mm, 1), "max_dimension_mm": round(max_dim_mm, 1), "size_category": _size_category(max_dim_mm) } def get_location(bbox, image_shape) -> Dict: """Bbox center'dan konum (mask yoksa fallback).""" x1, y1, x2, y2 = bbox cx = (x1 + x2) / 2 cy = (y1 + y2) / 2 H, W = image_shape vertical = "upper" if cy < H / 2 else "lower" horizontal = "left" if cx < W / 2 else "right" return { "quadrant": f"{vertical}-{horizontal}", "normalized_center": { "x": round(cx / W, 3), "y": round(cy / H, 3) } } def get_shape(bbox) -> str: """Bbox aspect ratio'dan şekil (mask yoksa fallback).""" x1, y1, x2, y2 = bbox w = x2 - x1 h = y2 - y1 if w == 0 or h == 0: return "unknown" aspect_ratio = max(w, h) / min(w, h) if aspect_ratio < 1.3: return "round" elif aspect_ratio < 2.0: return "oval" else: return "elongated" def get_density(image, bbox) -> Dict: """Bbox ROI'sinden density (mask yoksa fallback).""" x1, y1, x2, y2 = [int(c) for c in bbox] roi = image[y1:y2, x1:x2] if roi.size == 0: return {"category": "unknown", "mean_intensity": 0} roi_gray = cv2.cvtColor(roi, cv2.COLOR_BGR2GRAY) if len(roi.shape) == 3 else roi bright = roi_gray[roi_gray > 150] mean_int = float(bright.mean()) if len(bright) > 0 else float(roi_gray.mean()) return { "category": _density_category(mean_int), "mean_intensity": round(mean_int, 1) } # ─── Mask-tabanlı fonksiyonlar ──────────────────────────────────────────────── def get_mask_morphology(mask_polygon: List, pixel_spacing_mm: float = 0.7) -> Dict: """ Mask polygon'dan morfometrik analiz. Döndürür: alan, çevre, circularity, solidity, eccentricity, orientation, equivalent_diameter, şekil kategorisi. """ pts = np.array(mask_polygon, dtype=np.float32) if pts.ndim == 1: pts = pts.reshape(-1, 2) if len(pts) < 3: return _empty_morphology() contour_f = pts.reshape((-1, 1, 2)) contour_i = contour_f.astype(np.int32) area_px = float(cv2.contourArea(contour_f)) if area_px < 1: return _empty_morphology() perimeter_px = float(cv2.arcLength(contour_f, closed=True)) # Circularity (1.0 = mükemmel daire) circularity = ( min((4 * np.pi * area_px) / (perimeter_px ** 2), 1.0) if perimeter_px > 0 else 0.0 ) # Solidity: alan / convex hull alanı hull = cv2.convexHull(contour_i) hull_area = float(cv2.contourArea(hull)) solidity = area_px / hull_area if hull_area > 0 else 0.0 # Eccentricity + orientation (fitEllipse ≥ 5 nokta gerektirir) eccentricity = 0.0 orientation_deg = 0.0 if len(contour_f) >= 5: try: _, (minor_ax, major_ax), angle = cv2.fitEllipse(contour_f) if major_ax > 0: eccentricity = float(np.sqrt(max(0.0, 1 - (minor_ax / major_ax) ** 2))) orientation_deg = float(angle) except cv2.error: pass equiv_diameter_mm = round( float(np.sqrt(4 * area_px / np.pi)) * pixel_spacing_mm, 1 ) area_mm2 = round(area_px * (pixel_spacing_mm ** 2), 2) return { "area_px": round(area_px, 1), "area_mm2": area_mm2, "perimeter_px": round(perimeter_px, 1), "circularity": round(circularity, 3), "solidity": round(solidity, 3), "eccentricity": round(eccentricity, 3), "orientation_deg": round(orientation_deg, 1), "equivalent_diameter_mm": equiv_diameter_mm, "size_category": _size_category(equiv_diameter_mm), "shape_category": _shape_from_mask(circularity, eccentricity) } def get_density_profile(image, mask_polygon: List, image_shape: tuple) -> Dict: """ Mask içindeki piksellerden yoğunluk profili. Sadece taş piksellerini kullanır — bbox ROI'sinden daha doğru. Not: Değerler 0-255 PNG piksel skalasında, gerçek HU değil. """ H, W = image_shape[:2] mask_img = np.zeros((H, W), dtype=np.uint8) pts = np.array(mask_polygon, dtype=np.int32) if pts.ndim == 1: pts = pts.reshape(-1, 2) cv2.fillPoly(mask_img, [pts], 255) gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) if len(image.shape) == 3 else image masked_pixels = gray[mask_img > 0] if masked_pixels.size == 0: return { "category": "unknown", "mean_intensity": 0, "std_intensity": 0, "homogeneity": "unknown", "contrast_to_surrounding": 0 } mean_int = float(masked_pixels.mean()) std_int = float(masked_pixels.std()) if std_int < 20: homogeneity = "homogeneous" elif std_int < 40: homogeneity = "moderately_heterogeneous" else: homogeneity = "heterogeneous" # Çevre kontrast: mask bbox'u etrafındaki dış pikseller ys, xs = np.where(mask_img > 0) contrast = 0.0 if len(ys) > 0: pad = 10 y1s = max(0, int(ys.min()) - pad) y2s = min(H, int(ys.max()) + pad) x1s = max(0, int(xs.min()) - pad) x2s = min(W, int(xs.max()) + pad) outer_pixels = gray[y1s:y2s, x1s:x2s][mask_img[y1s:y2s, x1s:x2s] == 0] if outer_pixels.size > 0: contrast = round(mean_int - float(outer_pixels.mean()), 1) return { "category": _density_category(mean_int), "mean_intensity": round(mean_int, 1), "std_intensity": round(std_int, 1), "homogeneity": homogeneity, "contrast_to_surrounding": contrast } def get_location_from_mask(mask_polygon: List, image_shape: tuple) -> Dict: """Mask centroid'den konum — bbox center'dan daha doğru.""" pts = np.array(mask_polygon, dtype=np.float32) if pts.ndim == 1: pts = pts.reshape(-1, 2) contour = pts.reshape((-1, 1, 2)).astype(np.int32) M = cv2.moments(contour) H, W = image_shape if M["m00"] > 0: cx = M["m10"] / M["m00"] cy = M["m01"] / M["m00"] else: cx = float(pts[:, 0].mean()) cy = float(pts[:, 1].mean()) vertical = "upper" if cy < H / 2 else "lower" horizontal = "left" if cx < W / 2 else "right" return { "quadrant": f"{vertical}-{horizontal}", "normalized_center": { "x": round(cx / W, 3), "y": round(cy / H, 3) } } # ─── Ana çıkarım fonksiyonu ─────────────────────────────────────────────────── @traceable(name="Feature Extraction", run_type="tool") def extract_full_features(detection_result: Dict, pixel_spacing_mm: float = 0.7) -> Dict: """ YOLO26-seg detection/segmentation result'tan RAG için komple feature object üret. mask_polygon varsa mask-based özellikler kullanılır, yoksa bbox fallback. """ image = cv2.imread(detection_result["image_path"]) features = extract_basic_features(detection_result) if not features["stone_detected"]: features["message"] = "Bu görüntüde taş tespit edilmedi." features["stones"] = [] return features features["stones"] = [] for i, det in enumerate(detection_result["detections"]): bbox = det["bbox"] mask_polygon = det.get("mask_polygon") has_mask = mask_polygon is not None and len(mask_polygon) >= 3 if has_mask: morph = get_mask_morphology(mask_polygon, pixel_spacing_mm) density_info = get_density_profile( image, mask_polygon, detection_result["image_shape"] ) location_info = get_location_from_mask( mask_polygon, detection_result["image_shape"] ) size_info = { "equivalent_diameter_mm": morph["equivalent_diameter_mm"], "max_dimension_mm": morph["equivalent_diameter_mm"], "area_mm2": morph["area_mm2"], "size_category": morph["size_category"] } shape = morph["shape_category"] else: morph = None size_info = estimate_size_mm(bbox, pixel_spacing_mm) location_info = get_location(bbox, detection_result["image_shape"]) shape = get_shape(bbox) density_info = get_density(image, bbox) stone_features = { "stone_id": i + 1, "detection_confidence": round(det["confidence"], 2), "size": size_info, "location": location_info, "shape": shape, "density": density_info, "bbox_pixels": [round(c, 1) for c in bbox] } if has_mask and morph: stone_features["morphology"] = { "area_px": morph["area_px"], "area_mm2": morph["area_mm2"], "perimeter_px": morph["perimeter_px"], "circularity": morph["circularity"], "solidity": morph["solidity"], "eccentricity": morph["eccentricity"], "orientation_deg": morph["orientation_deg"] } features["stones"].append(stone_features) if features["count"] > 1: features["largest_stone_mm"] = max( s["size"]["max_dimension_mm"] for s in features["stones"] ) features["multiple_stones"] = True areas = [ s["size"].get("area_mm2") for s in features["stones"] if s["size"].get("area_mm2") is not None ] if areas: features["total_stone_area_mm2"] = round(sum(areas), 2) else: features["multiple_stones"] = False return features