Spaces:
Runtime error
Runtime error
| import numpy as np | |
| import cv2 | |
| from PIL import Image | |
| import random | |
| def generate_mask(image, thresh_val=None, min_size=9, crop_top=0, crop_bottom=0, crop_left=0, crop_right=0, max_size=52, typical_unit_size=40): | |
| """ | |
| Generate a mask from the input image using optimized OpenCV functions. | |
| Replaces skimage dependency for better performance in Cloud Run. | |
| """ | |
| if image is None: | |
| return None, None, None | |
| # 1. Conversión eficiente a NumPy | |
| if isinstance(image, Image.Image): | |
| if image.mode != 'L': | |
| image = image.convert('L') | |
| img = np.asarray(image) | |
| elif isinstance(image, np.ndarray): | |
| if len(image.shape) == 3: | |
| img = cv2.cvtColor(image, cv2.COLOR_RGB2GRAY) | |
| else: | |
| img = image | |
| else: | |
| return None, None, None | |
| h, w = img.shape | |
| # 2. Recorte (Slicing) | |
| y1 = max(0, crop_top) | |
| y2 = min(h, h - crop_bottom) | |
| x1 = max(0, crop_left) | |
| x2 = min(w, w - crop_right) | |
| if y2 <= y1 or x2 <= x1: | |
| return None, None, None | |
| # Usamos slicing directo. OpenCV maneja la memoria eficientemente. | |
| img_crop = img[y1:y2, x1:x2] | |
| # 3. Binarización | |
| # Suavizar para mejorar el cálculo del umbral, sin perder la imagen original | |
| img_blur = cv2.GaussianBlur(img_crop, (5, 5), 0) | |
| if thresh_val is None or thresh_val == "" or str(thresh_val).lower() in ["none", "auto"]: | |
| # img_blur para Otsu | |
| otsu_thresh, bw = cv2.threshold(img_blur, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) | |
| print(f"Umbral calculado automáticamente (Otsu): {otsu_thresh}") | |
| else: | |
| try: | |
| thresh_int = int(thresh_val) | |
| except ValueError: | |
| print(f"Advertencia: '{thresh_val}' no es un número válido. Usando Otsu por defecto.") | |
| otsu_thresh, bw = cv2.threshold(img_blur, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) | |
| thresh_int = otsu_thresh # Solo para el print | |
| else: | |
| _, bw = cv2.threshold(img_crop, thresh_int, 255, cv2.THRESH_BINARY) | |
| print(f"Umbral manual aplicado: {thresh_int}") | |
| # Fallback automático si no hay blancos (check rápido con np.any) | |
| # if not np.any(bw): | |
| # _, bw = cv2.threshold(img_crop, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) | |
| # 4. Etiquetado optimizado (Connected Components) | |
| # Reemplaza a skimage.measure.label y regionprops | |
| # stats es una matriz donde cada fila es [x, y, w, h, area] | |
| num_labels, labels, stats, centroids = cv2.connectedComponentsWithStats(bw, connectivity=8) | |
| # 5. Generación de Máscara Vectorizada (Sin bucles lentos) | |
| # stats[:, 4] es la columna de áreas. El índice 0 es el fondo. | |
| valid_indices = np.where(stats[:, 4] >= min_size)[0] | |
| valid_indices = valid_indices[valid_indices != 0] # Quitar fondo | |
| # Crear Look-Up Table (LUT) para generar la máscara final rápido | |
| lut = np.zeros(num_labels, dtype=np.uint8) | |
| lut[valid_indices] = 255 | |
| mask = lut[labels] # Aplicación vectorial de la LUT | |
| # Crear versión color para visualización | |
| mask_color = cv2.cvtColor(mask, cv2.COLOR_GRAY2RGB) | |
| mask_color[mask > 0] = [0, 255, 0] # Verde (formato BGR para OpenCV interno, luego PIL lo interpreta) | |
| # Empaquetamos datos (compatible con la estructura anterior pero con extras de OpenCV) | |
| processed_data = { | |
| "mask": mask, | |
| "labels": labels, | |
| "stats": stats, # Nuevo: Stats de OpenCV (x,y,w,h,area) | |
| "centroids": centroids, # Nuevo: Centroides de OpenCV | |
| "min_size": min_size, | |
| "max_size": max_size, | |
| "typical_unit_size": typical_unit_size, | |
| "img_crop": img_crop | |
| } | |
| # Convertir a PIL para el return final | |
| mask_img = Image.fromarray(mask_color) | |
| img_crop_pil = Image.fromarray(img_crop) | |
| return mask_img, processed_data, img_crop_pil | |
| def visualize_structures( | |
| input_data, | |
| min_size=1, | |
| visualization_mode="contours", | |
| max_size=1000, | |
| typical_unit_size=40 | |
| ): | |
| """ | |
| Analyze and visualize structures using optimized OpenCV calls. | |
| """ | |
| # 1. Extracción de datos | |
| stats = None | |
| centroids = None | |
| if isinstance(input_data, dict): | |
| if input_data is None: | |
| return None, "Debes procesar una imagen primero" | |
| mask = input_data["mask"] | |
| img_crop = input_data.get("img_crop", mask.copy()) | |
| # Intentamos recuperar stats ya calculados para ahorrar tiempo | |
| stats = input_data.get("stats") | |
| centroids = input_data.get("centroids") | |
| min_size = input_data.get("min_size", min_size) | |
| max_size = input_data.get("max_size", max_size) | |
| typical_unit_size = input_data.get("typical_unit_size", typical_unit_size) | |
| elif isinstance(input_data, Image.Image): | |
| mask = np.array(input_data.convert("L")) | |
| mask = (mask > 0).astype(np.uint8) * 255 | |
| img_crop = mask | |
| else: | |
| return None, "Input data type not supported" | |
| # Imagen base a color | |
| if len(img_crop.shape) == 2: | |
| img_color = cv2.cvtColor(img_crop, cv2.COLOR_GRAY2RGB) | |
| else: | |
| img_color = img_crop.copy() | |
| # 2. Si no tenemos stats (vino de una imagen directa), calcularlos | |
| if stats is None: | |
| num_labels, labels, stats, centroids = cv2.connectedComponentsWithStats(mask, connectivity=8) | |
| # 3. Filtrado vectorial de estructuras válidas | |
| # stats[:, 4] es el área. | |
| areas = stats[:, 4] | |
| valid_mask = (areas >= min_size) | |
| valid_mask[0] = False # Ignorar el fondo (label 0) | |
| valid_indices = np.where(valid_mask)[0] | |
| count = len(valid_indices) | |
| if count == 0: | |
| return Image.fromarray(img_color), "No se detectaron estructuras." | |
| total_units = 0 | |
| structure_counter = 0 | |
| # Lógica específica por modo de visualización | |
| # -- MODO 1: Bounding Boxes (Muy rápido, usa stats directos) -- | |
| if visualization_mode == "bounding_boxes": | |
| for idx in valid_indices: | |
| structure_counter += 1 | |
| x, y, w, h = stats[idx, 0:4] | |
| area = areas[idx] | |
| # Calcular unidades | |
| units = 1 if area <= max_size else max(1, round(area / typical_unit_size)) | |
| total_units += units | |
| label_text = str(structure_counter) if units == 1 else f"{structure_counter}({units}u)" | |
| cv2.rectangle(img_color, (x, y), (x+w, y+h), (0, 255, 0), 2) | |
| cv2.putText(img_color, label_text, (x, y-5), | |
| cv2.FONT_HERSHEY_SIMPLEX, 0.4, (0, 0, 255), 1) | |
| # -- MODO 2: Connected Components (Coloreado por labels) -- | |
| elif visualization_mode == "connected_components": | |
| # Generar colores aleatorios para la LUT | |
| # Necesitamos colores para todos los labels (incluso los inválidos, para simplificar indexing) | |
| colors = np.random.randint(0, 255, size=(len(stats), 3), dtype=np.uint8) | |
| colors[0] = [0, 0, 0] # Fondo negro | |
| # Crear imagen de etiquetas coloreada | |
| # Recuperamos labels si no los tenemos (raro, pero posible si input fue Image) | |
| if 'labels' not in locals(): | |
| _, labels, _, _ = cv2.connectedComponentsWithStats(mask, connectivity=8) | |
| # Pintar | |
| colored_labels = colors[labels] | |
| # Iterar solo para poner texto y contar | |
| for idx in valid_indices: | |
| structure_counter += 1 | |
| area = areas[idx] | |
| cx, cy = int(centroids[idx][0]), int(centroids[idx][1]) | |
| units = 1 if area <= max_size else max(1, round(area / typical_unit_size)) | |
| total_units += units | |
| label_text = str(structure_counter) if units == 1 else f"{structure_counter}({units}u)" | |
| cv2.putText(colored_labels, label_text, (cx-10, cy), | |
| cv2.FONT_HERSHEY_SIMPLEX, 0.4, (255, 255, 255), 1) | |
| # Mezclar con original | |
| img_color = cv2.addWeighted(img_color, 0.3, colored_labels, 0.7, 0) | |
| # -- MODO 3: Contornos / Relleno / Elipses / Círculos -- | |
| else: | |
| # Encontrar contornos es rápido en la máscara binaria ya limpia | |
| # Usamos la máscara en lugar de 'labels' porque findContours espera imagen de 8-bits | |
| contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) | |
| # Filtramos contornos manualmente por si acaso (aunque la máscara ya debería estar limpia) | |
| valid_contours = [c for c in contours if cv2.contourArea(c) >= min_size] | |
| # Recalculamos count basado en contornos encontrados | |
| count = len(valid_contours) | |
| structure_counter = 0 | |
| for i, cnt in enumerate(valid_contours): | |
| structure_counter += 1 | |
| area = cv2.contourArea(cnt) | |
| units = 1 if area <= max_size else max(1, round(area / typical_unit_size)) | |
| total_units += units | |
| # Centroide para texto | |
| M = cv2.moments(cnt) | |
| if M["m00"] != 0: | |
| cx = int(M["m10"] / M["m00"]) | |
| cy = int(M["m01"] / M["m00"]) | |
| else: | |
| x,y,w,h = cv2.boundingRect(cnt) | |
| cx, cy = x + w//2, y + h//2 | |
| label_text = f"{structure_counter}" if units == 1 else f"{structure_counter}({units}u)" | |
| if visualization_mode == "contours": | |
| cv2.drawContours(img_color, [cnt], -1, (0, 255, 0), 2) | |
| cv2.putText(img_color, label_text, (cx-10, cy), cv2.FONT_HERSHEY_SIMPLEX, 0.4, (0, 0, 255), 1) | |
| elif visualization_mode == "filled": | |
| color = (random.randint(50, 255), random.randint(50, 255), random.randint(50, 255)) | |
| cv2.drawContours(img_color, [cnt], -1, color, -1) | |
| cv2.putText(img_color, label_text, (cx-10, cy), cv2.FONT_HERSHEY_SIMPLEX, 0.4, (255, 255, 255), 1) | |
| elif visualization_mode == "ellipses": | |
| if len(cnt) >= 5: | |
| ellipse = cv2.fitEllipse(cnt) | |
| cv2.ellipse(img_color, ellipse, (0, 255, 0), 2) | |
| cv2.putText(img_color, label_text, (cx-10, cy), cv2.FONT_HERSHEY_SIMPLEX, 0.4, (0, 0, 255), 1) | |
| else: | |
| # Fallback a rectangulo si no alcanza para elipse | |
| x,y,w,h = cv2.boundingRect(cnt) | |
| cv2.rectangle(img_color, (x, y), (x+w, y+h), (0, 255, 0), 2) | |
| else: # Default: Circles / Points | |
| cv2.circle(img_color, (cx, cy), 8, (0, 0, 255), 2) | |
| cv2.putText(img_color, label_text, (cx-10, cy-15), cv2.FONT_HERSHEY_SIMPLEX, 0.4, (0, 255, 0), 1) | |
| return Image.fromarray(img_color), f"Estructuras: {count} | Unidades estimadas: {total_units}" |