pla-analysis 0.1.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,5 @@
1
+ __version__ = "0.1.4"
2
+
3
+ # Esto permite usar: pla_analysis.body3d.analyze(...)
4
+ from . import body3d
5
+ from . import tensile
pla_analysis/body3d.py ADDED
@@ -0,0 +1,409 @@
1
+ import cv2
2
+ import numpy as np
3
+ import matplotlib.pyplot as plt
4
+ import os
5
+ import shutil
6
+ import tempfile
7
+ from pathlib import Path
8
+
9
+ class BodyTracker:
10
+ """
11
+ Clase interna para realizar el seguimiento del punto negro en los frames.
12
+ """
13
+ def __init__(self, frames_folder):
14
+ self.frames_folder = frames_folder
15
+ self.frames = sorted(
16
+ [f for f in os.listdir(frames_folder) if f.endswith(('.tif', '.png', '.jpg'))]
17
+ )
18
+ if not self.frames:
19
+ raise ValueError(f"No se encontraron imágenes en '{frames_folder}'")
20
+
21
+ self.escala_px_mm = None
22
+ self.roi = None
23
+ self.umbral = 127
24
+ self.desplazamientos = []
25
+ # Para dibujar la estela del movimiento
26
+ self.trayectoria = []
27
+
28
+ # Para el reporte comparativo
29
+ self.img_inicio = None
30
+ self.img_max_desp = None
31
+ self.max_desp_registrado = 0.0
32
+
33
+ def configurar_escala_interactiva(self):
34
+ """Abre interfaz gráfica para calibrar escala."""
35
+ primer_frame = os.path.join(self.frames_folder, self.frames[0])
36
+ img = cv2.imread(primer_frame, cv2.IMREAD_GRAYSCALE)
37
+
38
+ # Guardamos imagen inicial para el reporte
39
+ self.img_inicio = img.copy()
40
+
41
+ img_clean = cv2.cvtColor(img, cv2.COLOR_GRAY2BGR)
42
+ img_display = img_clean.copy()
43
+
44
+ puntos = []
45
+ nombre_ventana = 'Calibracion: Click Inicio y Fin (r=Reset, ENTER=Confirmar)'
46
+
47
+ def click_event(event, x, y, flags, params):
48
+ if event == cv2.EVENT_LBUTTONDOWN:
49
+ if len(puntos) >= 2:
50
+ puntos.clear()
51
+ img_display[:] = img_clean[:]
52
+
53
+ puntos.append((x, y))
54
+ cv2.circle(img_display, (x, y), 5, (0, 255, 0), -1)
55
+
56
+ if len(puntos) == 2:
57
+ cv2.line(img_display, puntos[0], puntos[1], (0, 255, 0), 2)
58
+
59
+ cv2.imshow(nombre_ventana, img_display)
60
+
61
+ print("Selecciona dos puntos para la escala.")
62
+ cv2.namedWindow(nombre_ventana)
63
+ cv2.setMouseCallback(nombre_ventana, click_event)
64
+ cv2.imshow(nombre_ventana, img_display)
65
+
66
+ while True:
67
+ key = cv2.waitKey(1) & 0xFF
68
+ if key == ord('r'):
69
+ puntos.clear()
70
+ img_display[:] = img_clean[:]
71
+ cv2.imshow(nombre_ventana, img_display)
72
+ elif key == 13: # ENTER
73
+ if len(puntos) == 2: break
74
+ else: print("Necesitas 2 puntos.")
75
+
76
+ cv2.destroyAllWindows()
77
+
78
+ dist_px = np.sqrt((puntos[1][0] - puntos[0][0])**2 + (puntos[1][1] - puntos[0][1])**2)
79
+ print(f"Distancia en píxeles: {dist_px:.2f}")
80
+
81
+ while True:
82
+ try:
83
+ entrada = input(f"¿Cuántos mm son esos {dist_px:.2f} px en la realidad?: ")
84
+ dist_real = float(entrada)
85
+ if dist_real <= 0: continue
86
+ break
87
+ except ValueError:
88
+ print("Introduce un número válido.")
89
+
90
+ self.escala_px_mm = dist_px / dist_real
91
+ return self.escala_px_mm
92
+
93
+ def seleccionar_roi_interactiva(self):
94
+ """Abre interfaz para seleccionar ROI."""
95
+ primer_frame = os.path.join(self.frames_folder, self.frames[0])
96
+ img = cv2.imread(primer_frame, cv2.IMREAD_GRAYSCALE)
97
+
98
+ print("Selecciona la región de interés a analizar y pulsa ENTER")
99
+ try:
100
+ roi = cv2.selectROI('Selecciona la region de interes', img, showCrosshair=True)
101
+ finally:
102
+ cv2.destroyAllWindows()
103
+
104
+ if roi[2] == 0 or roi[3] == 0:
105
+ raise ValueError("Región no válida.")
106
+ self.roi = roi
107
+ return roi
108
+
109
+ def calibrar_umbral_interactivo(self):
110
+ """Abre interfaz con slider para umbral."""
111
+ primer_frame = os.path.join(self.frames_folder, self.frames[0])
112
+ img = cv2.imread(primer_frame, cv2.IMREAD_GRAYSCALE)
113
+ x, y, w, h = self.roi
114
+ img_roi = img[y:y+h, x:x+w]
115
+
116
+ def nada(x): pass
117
+
118
+ ventana_umbral = 'Ajuste Blanco y Negro (ENTER para confirmar)'
119
+ cv2.namedWindow(ventana_umbral)
120
+ cv2.createTrackbar('Umbral', ventana_umbral, self.umbral, 255, nada)
121
+
122
+ print("Ajusta el umbral hasta aislar el punto negro y pulsa ENTER")
123
+ while True:
124
+ u = cv2.getTrackbarPos('Umbral', ventana_umbral)
125
+ self.umbral = u
126
+ _, th = cv2.threshold(img_roi, u, 255, cv2.THRESH_BINARY)
127
+ cv2.imshow(ventana_umbral, th)
128
+ if cv2.waitKey(1) & 0xFF == 13: break
129
+
130
+ cv2.destroyAllWindows()
131
+ return self.umbral
132
+
133
+ def _dibujar_dashboard(self, frame_bgr, desplazamiento_actual, frame_idx):
134
+ h_video, w_video = frame_bgr.shape[:2]
135
+ ancho_panel = 500
136
+ fondo_color = (245, 245, 245)
137
+ panel = np.ones((h_video, ancho_panel, 3), dtype=np.uint8)
138
+ panel[:] = fondo_color
139
+
140
+ # --- HEADER ---
141
+ altura_header = 140
142
+ cv2.rectangle(panel, (0, 0), (ancho_panel, altura_header), (230, 230, 230), -1)
143
+ cv2.line(panel, (0, altura_header), (ancho_panel, altura_header), (180, 180, 180), 1)
144
+
145
+ texto_disp = f"{desplazamiento_actual:.3f} mm" if desplazamiento_actual is not None else "--"
146
+
147
+ cv2.putText(panel, "Analisis en Tiempo Real", (20, 35), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (80, 80, 80), 1, cv2.LINE_AA)
148
+ cv2.putText(panel, "Desplazamiento horizontal:", (20, 70), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (100, 100, 100), 1, cv2.LINE_AA)
149
+ cv2.putText(panel, texto_disp, (20, 110), cv2.FONT_HERSHEY_SIMPLEX, 1.2, (0, 0, 150), 2, cv2.LINE_AA)
150
+
151
+ progreso = frame_idx / len(self.frames)
152
+ w_barra = ancho_panel - 40
153
+ cv2.rectangle(panel, (20, 125), (20 + w_barra, 130), (200, 200, 200), -1)
154
+ cv2.rectangle(panel, (20, 125), (20 + int(w_barra * progreso), 130), (0, 180, 0), -1)
155
+
156
+ # --- GRÁFICA ---
157
+ datos_validos = [d for d in self.desplazamientos if d is not None]
158
+
159
+ if len(datos_validos) > 1:
160
+ margen_x = 40
161
+ margen_y_inf = 40
162
+ y_inicio_grafica = altura_header + 20
163
+ h_grafica = h_video - y_inicio_grafica - margen_y_inf
164
+ w_grafica = ancho_panel - 2 * margen_x
165
+
166
+ min_val = min(datos_validos)
167
+ max_val = max(datos_validos)
168
+
169
+ # Estabilización
170
+ if abs(max_val - min_val) < 1.0:
171
+ centro = (max_val + min_val) / 2
172
+ min_val = centro - 0.5
173
+ max_val = centro + 0.5
174
+
175
+ span = max_val - min_val
176
+ min_val -= span * 0.1
177
+ max_val += span * 0.1
178
+ rango_val = max_val - min_val
179
+
180
+ puntos_plot = []
181
+ total_frames = len(self.frames)
182
+
183
+ for i, val in enumerate(datos_validos):
184
+ px = int(margen_x + (i / total_frames) * w_grafica)
185
+ val_norm = (val - min_val) / rango_val
186
+ py = int((y_inicio_grafica + h_grafica) - (val_norm * h_grafica))
187
+ puntos_plot.append((px, py))
188
+
189
+ color_ejes = (100, 100, 100)
190
+ cv2.line(panel, (margen_x, y_inicio_grafica), (margen_x, h_video - margen_y_inf), color_ejes, 1)
191
+ cv2.line(panel, (margen_x, h_video - margen_y_inf), (ancho_panel - margen_x, h_video - margen_y_inf), color_ejes, 1)
192
+
193
+ if len(puntos_plot) > 1:
194
+ cv2.polylines(panel, [np.array(puntos_plot)], False, (200, 50, 0), 2, cv2.LINE_AA)
195
+
196
+ if puntos_plot:
197
+ cv2.circle(panel, puntos_plot[-1], 4, (0, 0, 255), -1, cv2.LINE_AA)
198
+
199
+ return np.hstack((frame_bgr, panel))
200
+
201
+ def procesar(self, guardar_video=False, nombre_video_salida="resultado_analisis.mp4"):
202
+ x, y, w, h = self.roi
203
+ pos_inicial_x = None
204
+
205
+ print(f"Procesando {len(self.frames)} frames...")
206
+ cv2.namedWindow("Analisis en Vivo", cv2.WINDOW_NORMAL)
207
+
208
+ video_writer = None
209
+
210
+ # Reiniciar para este análisis
211
+ self.img_max_desp = self.img_inicio.copy() # Por defecto
212
+ self.max_desp_registrado = 0.0
213
+
214
+ for idx, frame_name in enumerate(self.frames):
215
+ path = os.path.join(self.frames_folder, frame_name)
216
+ img = cv2.imread(path, cv2.IMREAD_GRAYSCALE)
217
+ frame_vis = cv2.cvtColor(img, cv2.COLOR_GRAY2BGR)
218
+
219
+ # --- Detección ---
220
+ img_roi = img[y:y+h, x:x+w]
221
+ _, img_bin = cv2.threshold(img_roi, self.umbral, 255, cv2.THRESH_BINARY)
222
+ img_inv = cv2.bitwise_not(img_bin)
223
+
224
+ M = cv2.moments(img_inv)
225
+ desp_mm = None
226
+
227
+ if M["m00"] != 0:
228
+ cx_global = x + int(M["m10"] / M["m00"])
229
+ cy_global = y + int(M["m01"] / M["m00"])
230
+
231
+ # Guardar trayectoria (Estela)
232
+ self.trayectoria.append((cx_global, cy_global))
233
+
234
+ # Visualización
235
+ cv2.rectangle(frame_vis, (x, y), (x+w, y+h), (0, 255, 0), 2)
236
+
237
+ # Dibujar Estela (Línea amarilla)
238
+ if len(self.trayectoria) > 1:
239
+ cv2.polylines(frame_vis, [np.array(self.trayectoria)], False, (0, 255, 255), 1, cv2.LINE_AA)
240
+
241
+ cv2.circle(frame_vis, (cx_global, cy_global), 5, (0, 0, 255), -1)
242
+
243
+ if pos_inicial_x is None:
244
+ pos_inicial_x = cx_global
245
+ desp_mm = 0.0
246
+ else:
247
+ desp_px = cx_global - pos_inicial_x
248
+ desp_mm = desp_px / self.escala_px_mm
249
+
250
+ # Chequear si este es el frame de máximo desplazamiento absoluto
251
+ if abs(desp_mm) > abs(self.max_desp_registrado):
252
+ self.max_desp_registrado = desp_mm
253
+ self.img_max_desp = img.copy() # Guardar foto original en B/N
254
+ else:
255
+ cv2.rectangle(frame_vis, (x, y), (x+w, y+h), (0, 0, 255), 2)
256
+ cv2.putText(frame_vis, "LOST", (x, y-10), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0,0,255), 2)
257
+
258
+ self.desplazamientos.append(desp_mm)
259
+
260
+ # --- Dashboard ---
261
+ dashboard = self._dibujar_dashboard(frame_vis, desp_mm, idx)
262
+ cv2.imshow("Analisis en Vivo", dashboard)
263
+
264
+ # --- Grabar ---
265
+ if guardar_video:
266
+ if video_writer is None:
267
+ h_dash, w_dash = dashboard.shape[:2]
268
+ fourcc = cv2.VideoWriter_fourcc(*'mp4v')
269
+ video_writer = cv2.VideoWriter(nombre_video_salida, fourcc, 25.0, (w_dash, h_dash))
270
+ video_writer.write(dashboard)
271
+
272
+ if cv2.waitKey(1) & 0xFF == ord('q'):
273
+ print("\nAnálisis interrumpido.")
274
+ break
275
+
276
+ if video_writer:
277
+ video_writer.release()
278
+ print(f"Video guardado como: {nombre_video_salida}")
279
+
280
+ cv2.destroyAllWindows()
281
+ return self.desplazamientos
282
+
283
+ def generar_reporte_comparativo(self, save_path="resultado_comparacion.png"):
284
+ """Genera una imagen resumen con Inicio vs Máximo Desplazamiento."""
285
+ if self.img_inicio is None or self.img_max_desp is None:
286
+ return
287
+
288
+ print("Generando reporte comparativo...")
289
+
290
+ # Crear figura de Matplotlib
291
+ fig = plt.figure(figsize=(12, 6))
292
+ gs = fig.add_gridspec(1, 3) # 1 fila, 3 columnas
293
+
294
+ # 1. Imagen Inicio
295
+ ax1 = fig.add_subplot(gs[0, 0])
296
+ ax1.imshow(self.img_inicio, cmap='gray')
297
+ ax1.set_title("Inicio (0 mm)")
298
+ ax1.axis('off')
299
+ # Dibujar recuadro de ROI referencia
300
+ if self.roi:
301
+ x, y, w, h = self.roi
302
+ rect = plt.Rectangle((x, y), w, h, linewidth=2, edgecolor='g', facecolor='none')
303
+ ax1.add_patch(rect)
304
+
305
+ # 2. Imagen Máximo
306
+ ax2 = fig.add_subplot(gs[0, 1])
307
+ ax2.imshow(self.img_max_desp, cmap='gray')
308
+ ax2.set_title(f"Desplazamiento horizontal máximo\n({self.max_desp_registrado:.2f} mm)")
309
+ ax2.axis('off')
310
+ if self.roi:
311
+ x, y, w, h = self.roi
312
+ # ROI en rojo para el máximo
313
+ rect = plt.Rectangle((x, y), w, h, linewidth=2, edgecolor='r', facecolor='none')
314
+ ax2.add_patch(rect)
315
+
316
+ # 3. Gráfica Final
317
+ ax3 = fig.add_subplot(gs[0, 2])
318
+ validos = [d for d in self.desplazamientos if d is not None]
319
+ if validos:
320
+ ax3.plot(validos, color='blue', label='Desplazamiento')
321
+ ax3.axhline(self.max_desp_registrado, color='red', linestyle='--', alpha=0.7)
322
+ ax3.set_title("Evolución Temporal")
323
+ ax3.set_xlabel("Frames")
324
+ ax3.set_ylabel("Desplazamiento (mm)")
325
+ ax3.grid(True, alpha=0.3)
326
+
327
+ plt.tight_layout()
328
+ plt.savefig(save_path, dpi=150)
329
+ plt.close()
330
+ print(f"Reporte guardado como: {save_path}")
331
+
332
+
333
+ def _extraer_frames_a_temp(video_path, temp_dir):
334
+ if not os.path.exists(video_path):
335
+ raise FileNotFoundError(f"Video no encontrado: {video_path}")
336
+ cap = cv2.VideoCapture(video_path)
337
+ if not cap.isOpened():
338
+ raise IOError("No se pudo abrir el video.")
339
+ print(f"Extrayendo frames del video completo...")
340
+ count = 0
341
+ while True:
342
+ ret, frame = cap.read()
343
+ if not ret: break
344
+ gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
345
+ filename = os.path.join(temp_dir, f"frame_{count:06d}.tif")
346
+ cv2.imwrite(filename, gray)
347
+ count += 1
348
+ cap.release()
349
+ print(f"\nTotal frames extraídos: {count}")
350
+ return count
351
+
352
+ def analyze(video_path, plot_result=True, save_video=True):
353
+ """
354
+ Función principal.
355
+ """
356
+ temp_dir = tempfile.mkdtemp(prefix="pla_body3d_")
357
+
358
+ try:
359
+ num_frames = _extraer_frames_a_temp(video_path, temp_dir)
360
+ if num_frames == 0: return None
361
+
362
+ tracker = BodyTracker(temp_dir)
363
+ tracker.configurar_escala_interactiva()
364
+ tracker.seleccionar_roi_interactiva()
365
+ tracker.calibrar_umbral_interactivo()
366
+
367
+ # Procesar y Grabar
368
+ desplazamientos = tracker.procesar(guardar_video=save_video, nombre_video_salida="resultado_video.mp4")
369
+
370
+ # Generar Reporte Comparativo (Visual)
371
+ tracker.generar_reporte_comparativo("reporte_comparativo.png")
372
+
373
+ validos = [d for d in desplazamientos if d is not None]
374
+ if not validos: return {}
375
+
376
+ max_disp_izq = min(validos)
377
+ max_disp_abs = max(validos, key=abs)
378
+
379
+ results = {
380
+ "max_displacement_left": max_disp_izq,
381
+ "max_displacement_absolute": max_disp_abs,
382
+ "raw_data": desplazamientos,
383
+ "total_frames": num_frames
384
+ }
385
+
386
+ print(f"\nDesplazamiento horizontal máximo: {max_disp_izq:.4f} mm")
387
+
388
+ if plot_result:
389
+ plt.figure(figsize=(10, 5))
390
+ plt.plot(desplazamientos, label="Desplazamiento")
391
+ plt.axhline(max_disp_izq, color='r', linestyle='--', label=f"Max: {max_disp_izq:.2f}mm")
392
+ plt.title("Resultado Final: Análisis de desplazamiento horizontal")
393
+ plt.xlabel("Frame")
394
+ plt.ylabel("Desplazamiento (mm)")
395
+ plt.legend()
396
+ plt.grid(True, alpha=0.3)
397
+ plt.show()
398
+
399
+ return results
400
+
401
+ except Exception as e:
402
+ print(f"Error durante el análisis: {e}")
403
+ import traceback
404
+ traceback.print_exc()
405
+ return None
406
+
407
+ finally:
408
+ if os.path.exists(temp_dir):
409
+ shutil.rmtree(temp_dir)
@@ -0,0 +1,287 @@
1
+ import cv2
2
+ import numpy as np
3
+ import matplotlib.pyplot as plt
4
+ import os
5
+
6
+ class TensileOpticalTracker:
7
+ """
8
+ Clase especializada en detectar líneas de extensómetro en una secuencia de imágenes.
9
+ """
10
+ def __init__(self, frames_folder, l0_mm=35.0, a0_mm2=15.0):
11
+ self.frames_folder = frames_folder
12
+ self.l0_mm = l0_mm
13
+ self.a0_mm2 = a0_mm2
14
+
15
+ # Validar que es una carpeta
16
+ if not os.path.isdir(frames_folder):
17
+ raise ValueError(f"La ruta '{frames_folder}' no es un directorio válido.")
18
+
19
+ # Cargar imágenes ordenadas
20
+ self.frames = sorted(
21
+ [f for f in os.listdir(frames_folder) if f.lower().endswith(('.tif', '.tiff', '.png', '.jpg', '.jpeg', '.bmp'))]
22
+ )
23
+
24
+ if not self.frames:
25
+ raise ValueError(f"No se encontraron imágenes válidas en '{frames_folder}'")
26
+
27
+ self.roi = None
28
+ self.px_per_mm = None
29
+ self.desplazamientos = []
30
+ self.binary_threshold = 90
31
+
32
+ def seleccionar_roi_interactiva(self):
33
+ """
34
+ Permite seleccionar el ROI. Si la imagen es gigante, la reduce visualmente
35
+ para que quepa en la pantalla, pero guarda las coordenadas originales.
36
+ """
37
+ first_frame_path = os.path.join(self.frames_folder, self.frames[0])
38
+ img = cv2.imread(first_frame_path)
39
+
40
+ if img is None:
41
+ raise ValueError(f"No se pudo leer la imagen: {first_frame_path}")
42
+
43
+ print("Selecciona el recuadro que contenga AMBAS líneas")
44
+
45
+ # --- LÓGICA DE REDIMENSIONADO INTELIGENTE ---
46
+ alto_pantalla_objetivo = 800 # Altura cómoda para cualquier monitor
47
+ h_orig, w_orig = img.shape[:2]
48
+ factor_escala = 1.0
49
+
50
+ img_mostrar = img
51
+
52
+ # Si la imagen es muy alta, la reducimos para la selección
53
+ if h_orig > alto_pantalla_objetivo:
54
+ factor_escala = alto_pantalla_objetivo / h_orig
55
+ nuevo_ancho = int(w_orig * factor_escala)
56
+ nuevo_alto = int(h_orig * factor_escala)
57
+ img_mostrar = cv2.resize(img, (nuevo_ancho, nuevo_alto))
58
+
59
+ try:
60
+ # Seleccionamos sobre la imagen (posiblemente reducida)
61
+ roi_temp = cv2.selectROI("Selecciona la region de interes y pulsa ENTER", img_mostrar, showCrosshair=True)
62
+ finally:
63
+ cv2.destroyAllWindows()
64
+ cv2.waitKey(1)
65
+
66
+ if roi_temp[2] == 0 or roi_temp[3] == 0:
67
+ raise ValueError("Region de interes inválida (ancho o alto es 0).")
68
+
69
+ # --- RESTAURAR COORDENADAS ORIGINALES ---
70
+ # Si redujimos la imagen, tenemos que "agrandar" el ROI seleccionado
71
+ # para que coincida con la imagen original de alta calidad.
72
+ if factor_escala != 1.0:
73
+ x = int(roi_temp[0] / factor_escala)
74
+ y = int(roi_temp[1] / factor_escala)
75
+ w = int(roi_temp[2] / factor_escala)
76
+ h = int(roi_temp[3] / factor_escala)
77
+ self.roi = (x, y, w, h)
78
+ else:
79
+ self.roi = roi_temp
80
+
81
+ return self.roi
82
+
83
+ def _medir_distancia_en_frame(self, img_gray):
84
+ """Calcula distancia y posiciones de líneas."""
85
+ x, y, w, h = self.roi
86
+ crop = img_gray[y:y+h, x:x+w]
87
+
88
+ # Binarizar (invertido: fondo blanco -> negro, líneas negras -> blancas)
89
+ _, binary = cv2.threshold(crop, self.binary_threshold, 255, cv2.THRESH_BINARY_INV)
90
+ proyeccion_y = np.sum(binary, axis=1)
91
+
92
+ # Umbral de detección (40% del ancho)
93
+ umbral_pixels = w * 255 * 0.40
94
+ filas_con_linea = np.where(proyeccion_y > umbral_pixels)[0]
95
+
96
+ if filas_con_linea.size < 2:
97
+ return None, None, None
98
+
99
+ top_y_local = filas_con_linea[0]
100
+ bottom_y_local = filas_con_linea[-1]
101
+
102
+ dist = bottom_y_local - top_y_local
103
+ # Retornar coordenadas globales para dibujar
104
+ return dist, (y + top_y_local), (y + bottom_y_local)
105
+
106
+ def _dibujar_dashboard(self, frame_bgr, desplazamiento_actual, frame_idx):
107
+ """Visualización en vivo con GRÁFICA GRANDE."""
108
+ h_video, w_video = frame_bgr.shape[:2]
109
+
110
+ # Si el video es GIGANTE (4K), la gráfica de 800px se verá pequeña.
111
+ # Ajustamos el ancho del panel relativo al video (minimo 800px)
112
+ ancho_panel = max(800, int(w_video * 0.4))
113
+
114
+ panel = np.ones((h_video, ancho_panel, 3), dtype=np.uint8) * 245
115
+
116
+ # --- HEADER ---
117
+ altura_header = 120
118
+ cv2.rectangle(panel, (0, 0), (ancho_panel, altura_header), (230, 230, 230), -1)
119
+ cv2.line(panel, (0, altura_header), (ancho_panel, altura_header), (180, 180, 180), 1)
120
+
121
+ texto_disp = f"{desplazamiento_actual:.3f} mm" if desplazamiento_actual is not None else "--"
122
+
123
+ # Escalar fuente según resolución
124
+ font_scale_title = 0.8 if h_video < 1000 else 1.5
125
+ font_scale_val = 1.2 if h_video < 1000 else 2.5
126
+
127
+ cv2.putText(panel, "Tensile Analysis Live", (20, 40 if h_video < 1000 else 80),
128
+ cv2.FONT_HERSHEY_SIMPLEX, font_scale_title, (80, 80, 80), 2, cv2.LINE_AA)
129
+ cv2.putText(panel, f"Elongacion: {texto_disp}", (20, 90 if h_video < 1000 else 180),
130
+ cv2.FONT_HERSHEY_SIMPLEX, font_scale_val, (0, 100, 0), 2, cv2.LINE_AA)
131
+
132
+ # --- GRÁFICA ---
133
+ datos_validos = [d for d in self.desplazamientos if d is not None]
134
+
135
+ if len(datos_validos) > 1:
136
+ margen_x = 50
137
+ margen_y_inf = 50
138
+ y_inicio = altura_header + 30
139
+ h_graf = h_video - y_inicio - margen_y_inf
140
+ w_graf = ancho_panel - 2 * margen_x
141
+
142
+ min_v = min(datos_validos)
143
+ max_v = max(datos_validos)
144
+
145
+ if abs(max_v - min_v) < 0.1:
146
+ centro = (max_v + min_v) / 2
147
+ min_v = centro - 0.05
148
+ max_v = centro + 0.05
149
+
150
+ span = max_v - min_v
151
+ min_v -= span * 0.1
152
+ max_v += span * 0.1
153
+ rango = max_v - min_v
154
+
155
+ puntos = []
156
+ total = len(self.frames)
157
+ for i, val in enumerate(datos_validos):
158
+ px = int(margen_x + (i / total) * w_graf)
159
+ norm = (val - min_v) / rango
160
+ py = int((y_inicio + h_graf) - (norm * h_graf))
161
+ puntos.append((px, py))
162
+
163
+ color_eje = (100,100,100)
164
+ thickness_graph = 3 if h_video < 1000 else 6
165
+
166
+ cv2.line(panel, (margen_x, y_inicio), (margen_x, h_video - margen_y_inf), color_eje, 2)
167
+ cv2.line(panel, (margen_x, h_video - margen_y_inf), (ancho_panel - margen_x, h_video - margen_y_inf), color_eje, 2)
168
+
169
+ if len(puntos) > 1:
170
+ cv2.polylines(panel, [np.array(puntos)], False, (200, 0, 0), thickness_graph, cv2.LINE_AA)
171
+
172
+ if puntos:
173
+ cv2.circle(panel, puntos[-1], thickness_graph * 2, (0, 0, 255), -1, cv2.LINE_AA)
174
+
175
+ return np.hstack((frame_bgr, panel))
176
+
177
+ def procesar(self):
178
+ """Bucle principal."""
179
+ print(f"Procesando {len(self.frames)} frames desde: {self.frames_folder}")
180
+
181
+ # Creamos ventana resizable
182
+ cv2.namedWindow("Tensile Analysis Live", cv2.WINDOW_NORMAL)
183
+
184
+ # Opcional: Si la imagen original es 4K, la ventana se abrirá gigante.
185
+ # Forzamos que la ventana empiece con un tamaño razonable (ej. 1280x720)
186
+ # El usuario luego puede maximizarla si quiere.
187
+ cv2.resizeWindow("Tensile Analysis Live", 1280, 720)
188
+
189
+ x, y, w, h = self.roi
190
+
191
+ # --- FASE 1: CALIBRACIÓN ---
192
+ temps_calib = []
193
+ for i in range(min(5, len(self.frames))):
194
+ p = os.path.join(self.frames_folder, self.frames[i])
195
+ im = cv2.imread(p, cv2.IMREAD_GRAYSCALE)
196
+ d, _, _ = self._medir_distancia_en_frame(im)
197
+ if d is not None: temps_calib.append(d)
198
+
199
+ if not temps_calib:
200
+ raise ValueError("No se detectaron líneas al inicio para calibrar.")
201
+
202
+ dist_inicial_px = np.mean(temps_calib)
203
+ self.px_per_mm = dist_inicial_px / self.l0_mm
204
+
205
+ # --- FASE 2: ANÁLISIS ---
206
+ for idx, frame_name in enumerate(self.frames):
207
+ path = os.path.join(self.frames_folder, frame_name)
208
+ img = cv2.imread(path, cv2.IMREAD_GRAYSCALE)
209
+ frame_vis = cv2.cvtColor(img, cv2.COLOR_GRAY2BGR)
210
+
211
+ dist_px, y_top, y_bot = self._medir_distancia_en_frame(img)
212
+ desp_mm = None
213
+
214
+ cv2.rectangle(frame_vis, (x, y), (x+w, y+h), (0, 255, 0), 1)
215
+
216
+ if dist_px is not None:
217
+ cv2.line(frame_vis, (x, y_top), (x+w, y_top), (0, 0, 255), 2)
218
+ cv2.line(frame_vis, (x, y_bot), (x+w, y_bot), (0, 0, 255), 2)
219
+
220
+ longitud_actual_mm = dist_px / self.px_per_mm
221
+ desp_mm = longitud_actual_mm - self.l0_mm
222
+ else:
223
+ if self.desplazamientos:
224
+ desp_mm = self.desplazamientos[-1]
225
+ cv2.putText(frame_vis, "LOST", (x, y-5), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0,0,255), 1)
226
+
227
+ self.desplazamientos.append(desp_mm)
228
+
229
+ dashboard = self._dibujar_dashboard(frame_vis, desp_mm, idx)
230
+ cv2.imshow("Tensile Analysis Live", dashboard)
231
+
232
+ if cv2.waitKey(1) & 0xFF == ord('q'):
233
+ print("Interrumpido por usuario.")
234
+ break
235
+
236
+ cv2.destroyAllWindows()
237
+ return self.desplazamientos
238
+
239
+
240
+ def analyze(frames_folder, l0_mm=35.0, a0_mm2=15.0):
241
+ """
242
+ Función principal.
243
+ """
244
+ try:
245
+ tracker = TensileOpticalTracker(frames_folder, l0_mm=l0_mm, a0_mm2=a0_mm2)
246
+ tracker.seleccionar_roi_interactiva()
247
+
248
+ desplazamientos_lista = tracker.procesar()
249
+
250
+ # Limpieza de datos
251
+ desplazamientos_arr = np.array(
252
+ [d if d is not None else np.nan for d in desplazamientos_lista],
253
+ dtype=np.float64
254
+ )
255
+
256
+ if np.all(np.isnan(desplazamientos_arr)):
257
+ max_disp = 0.0
258
+ print("No se detectó movimiento válido en ningún frame.")
259
+ else:
260
+ max_disp = np.nanmax(desplazamientos_arr)
261
+
262
+ results = {
263
+ "displacement_mm": desplazamientos_lista,
264
+ "max_displacement": max_disp,
265
+ "parameters": {"l0": l0_mm, "a0": a0_mm2}
266
+ }
267
+
268
+ print(f" Desplazamiento Máximo: {max_disp:.4f} mm")
269
+
270
+ plt.figure(figsize=(10, 6))
271
+ plt.plot(desplazamientos_arr, color='navy', linewidth=2, label='Extensómetro Óptico')
272
+ plt.axhline(max_disp, color='red', linestyle='--', alpha=0.5, label=f'Max: {max_disp:.2f} mm')
273
+ plt.title(f"Gráfica Final (L0={l0_mm}mm)")
274
+ plt.xlabel("Frame")
275
+ plt.ylabel("Elongación [mm]")
276
+ plt.legend()
277
+ plt.grid(True, alpha=0.3)
278
+ print("Mostrando gráfica final (cierre la ventana para terminar)...")
279
+ plt.show()
280
+
281
+ return results
282
+
283
+ except Exception as e:
284
+ print(f"Error durante el análisis: {e}")
285
+ import traceback
286
+ traceback.print_exc()
287
+ return None
@@ -0,0 +1,154 @@
1
+ Metadata-Version: 2.4
2
+ Name: pla_analysis
3
+ Version: 0.1.4
4
+ Summary: A computer vision library for automated analysis of tensile and flexion mechanical tests.
5
+ Author-email: Haritz Aseguinolaza <haritz.aseguinolaza@alumni.mondragon.edu>, Aimar Seco <aimar.seco@alumni.mondragon.edu>, Aratz Zabala <aratz.zabala@alumni.mondragon.edu>, Aitor Otzerin <aitor.otzerin@alumni.mondragon.edu>
6
+ License: MIT License
7
+
8
+ Copyright (c) 2026 Haritz Aseguinolaza, Aimar Seco, Aratz Zabala, Aitor Otzerin
9
+
10
+ Permission is hereby granted, free of charge, to any person obtaining a copy
11
+ of this software and associated documentation files (the "Software"), to deal
12
+ in the Software without restriction, including without limitation the rights
13
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
14
+ copies of the Software, and to permit persons to whom the Software is
15
+ furnished to do so, subject to the following conditions:
16
+
17
+ The above copyright notice and this permission notice shall be included in all
18
+ copies or substantial portions of the Software.
19
+
20
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
23
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
24
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
25
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
26
+ SOFTWARE.
27
+ Keywords: computer-vision,mechanical-engineering,tensile-test,flexion-test,optical-tracking,displacement-measurement,opencv,material-science,extensometer,automation
28
+ Classifier: Programming Language :: Python :: 3
29
+ Classifier: License :: OSI Approved :: MIT License
30
+ Classifier: Operating System :: OS Independent
31
+ Requires-Python: >=3.7
32
+ Description-Content-Type: text/markdown
33
+ License-File: LICENSE
34
+ Requires-Dist: opencv-python
35
+ Requires-Dist: numpy
36
+ Requires-Dist: matplotlib
37
+ Dynamic: license-file
38
+
39
+ # PLA Analysis Library
40
+
41
+ ![Version](https://img.shields.io/pypi/v/pla_analysis)
42
+ ![Python](https://img.shields.io/badge/python-3.7%2B-blue)
43
+ ![License](https://img.shields.io/badge/license-MIT-green)
44
+
45
+ **pla_analysis** is a computer vision library designed for the automation and analysis of mechanical tests. This tool allows for the extraction of precise quantitative data (displacement, elongation) from video recordings or image sequences, eliminating the need for physical contact sensors.
46
+
47
+ ## Installation
48
+
49
+ You can install the library directly from PyPI:
50
+
51
+ ```bash
52
+ pip install pla_analysis
53
+ ```
54
+
55
+ ## Usage Guide
56
+
57
+ The library is divided into two specialized modules according to the type of mechanical test. The operation of each is detailed below.
58
+
59
+ ---
60
+
61
+ ### 1. Tensile Module (Optical Extensometry)
62
+
63
+ This module automates elongation measurement in tensile tests by tracking two physical marks on the specimen.
64
+
65
+ #### Input Requirements
66
+ * **Input:** A folder (directory) containing the image sequence (frames) of the test ordered chronologically.
67
+ * **Specimen:** Must have **two horizontal black lines** marked on a clear background.
68
+
69
+ #### Example Code
70
+
71
+ ```python
72
+ import pla_analysis
73
+
74
+ # Path to the folder containing the images .jpg/.tif/.png
75
+ frames_folder = "C:/path/to/my_tensile_frames"
76
+
77
+ # Run analysis
78
+ # l0_mm: Real initial distance between the two lines (Default: 35.0 mm)
79
+ # a0_mm2: Cross-sectional area of the specimen (Default: 15.0 mm^2)
80
+ pla_analysis.tensile.analyze(frames_folder, l0_mm=35.0, a0_mm2=15.0)
81
+ ```
82
+
83
+ #### Interactive Workflow
84
+
85
+ 1. **ROI (Region of Interest) Selection:**
86
+ Upon running the code, the first frame will open. You must draw a box with the mouse that meets two vital conditions:
87
+ * **Must contain BOTH black lines.**
88
+ * Must have some **vertical clearance** (towards the direction the specimen stretches) to avoid losing the lines during the test.
89
+ * **Efficiency:** Try to adjust the width horizontally to the specimen. Selecting an unnecessarily large region will increase computational cost and could slow down the analysis.
90
+
91
+ 2. **Live Analysis:**
92
+ The program will process the sequence showing a dashboard with line detection and the elongation graph generating frame by frame.
93
+
94
+ 3. **Results:**
95
+ Upon completion, the maximum displacement will be shown in the terminal, and a window will open with the detailed final graph of `Elongation (mm) vs Frames`.
96
+
97
+ ---
98
+
99
+ ### 2. Body3D Module (Flexion Tracking)
100
+
101
+ Designed to track the displacement of a specific point (centroid) in flexion tests or rigid body motion.
102
+
103
+ #### Experiment Preparation (Important)
104
+ To ensure proper computer vision performance, the input video must meet the following criteria:
105
+ * **Contrast:** The specimen must be light (white) and the background also white or very light.
106
+ * **Marker:** Draw a **single black or dark blue dot** on the area you wish to analyze.
107
+ * **Editing:** It is recommended to crop the video beforehand to eliminate dead time at the start or end. The less "filler" the video has, the faster and more accurate the analysis will be.
108
+
109
+ #### Example Code
110
+
111
+ ```python
112
+ import pla_analysis
113
+
114
+ # Path to the video file (.mp4, .avi, etc.)
115
+ video_path = "C:/path/to/flexion_test.mp4"
116
+
117
+ # save_video=True will generate an .mp4 file with the visual result overlaid
118
+ pla_analysis.body3d.analyze(video_path, save_video=True)
119
+ ```
120
+
121
+ #### Interactive Workflow
122
+
123
+ 1. **Scale Calibration:**
124
+ The system needs to transform pixels to millimeters.
125
+ * Click on **two points** on the image whose real distance you know (e.g., the width of the specimen).
126
+ * Enter the real distance in millimeters in the terminal (e.g., `10.5`).
127
+
128
+ 2. **ROI Selection:**
129
+ Select a box enclosing the area where the black dot will move. Ensure sufficient **clearance** so the dot does not exit the frame during maximum flexion.
130
+
131
+ 3. **Threshold Adjustment:**
132
+ A window with a slider will appear.
133
+ * Move the bar until **only the black dot** is visible in black and the rest of the image appears totally white.
134
+ * Press `ENTER` to confirm.
135
+
136
+ 4. **Results and Export:**
137
+ The system will automatically generate in your execution folder:
138
+ * `resultado_analisis.mp4`: Live dashboard video.
139
+ * `reporte_comparativo.png`: Static image comparing the initial state (rest) vs. maximum displacement.
140
+
141
+ ---
142
+
143
+ ## Authors
144
+
145
+ Project developed by students from Mondragon Unibertsitatea:
146
+
147
+ * **Haritz Aseguinolaza**
148
+ * **Aimar Seco**
149
+ * **Aratz Zabala**
150
+ * **Aitor Otzerin**
151
+
152
+ ## License
153
+
154
+ This project is distributed under the MIT license. See the `LICENSE` file for more information.
@@ -0,0 +1,8 @@
1
+ pla_analysis/__init__.py,sha256=2aMzgSVTZ6in-L46X0zfx8E2yyOVLZiQAlssMCziUgg,123
2
+ pla_analysis/body3d.py,sha256=KMzxcPai8GYlrMm-sNYocsNx7zDBZlFwGVjPD6-_NRY,16354
3
+ pla_analysis/tensile.py,sha256=YLQKJGrvIuCAenXPZTVmo5B5arM5fqNS_4W4gPTaye0,11730
4
+ pla_analysis-0.1.4.dist-info/licenses/LICENSE,sha256=hHAbC2z6st-nvwl3q0wvEhTVjztzkJ-qcMoHJGaKfT0,1136
5
+ pla_analysis-0.1.4.dist-info/METADATA,sha256=oN4SdSS9oDRsGf8S5nfDTG7DNUKnQyXqCnUeT1r3TTE,6889
6
+ pla_analysis-0.1.4.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
7
+ pla_analysis-0.1.4.dist-info/top_level.txt,sha256=szlh8wdcmAlpkGCzYGc1IsxjQNkpVTwWUe04MylhNTI,13
8
+ pla_analysis-0.1.4.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.9.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Haritz Aseguinolaza, Aimar Seco, Aratz Zabala, Aitor Otzerin
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ pla_analysis