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.
- pla_analysis/__init__.py +5 -0
- pla_analysis/body3d.py +409 -0
- pla_analysis/tensile.py +287 -0
- pla_analysis-0.1.4.dist-info/METADATA +154 -0
- pla_analysis-0.1.4.dist-info/RECORD +8 -0
- pla_analysis-0.1.4.dist-info/WHEEL +5 -0
- pla_analysis-0.1.4.dist-info/licenses/LICENSE +21 -0
- pla_analysis-0.1.4.dist-info/top_level.txt +1 -0
pla_analysis/__init__.py
ADDED
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)
|
pla_analysis/tensile.py
ADDED
|
@@ -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
|
+

|
|
42
|
+

|
|
43
|
+

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