Spaces:
Running
Running
| # 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 ─────────────────────────────────────────────────── | |
| 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 | |