Files changed (1) hide show
  1. app3.py +430 -0
app3.py ADDED
@@ -0,0 +1,430 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import uuid
3
+ import json
4
+ import mimetypes
5
+ import base64
6
+ from typing import List, Tuple, Dict, Any
7
+
8
+ import boto3
9
+ import supabase
10
+ import numpy as np
11
+ import cv2
12
+
13
+ from fastapi import FastAPI, File, UploadFile, HTTPException, Form
14
+ from fastapi.middleware.cors import CORSMiddleware
15
+ from fastapi.responses import JSONResponse
16
+ from botocore.exceptions import NoCredentialsError
17
+ from ultralytics import YOLO
18
+
19
+ # ==============================================================================
20
+ # 1. CONFIGURAÇÃO
21
+ # ==============================================================================
22
+
23
+ AWS_ACCESS_KEY_ID = os.getenv("AWS_ACCESS_KEY_ID")
24
+ AWS_SECRET_ACCESS_KEY = os.getenv("AWS_SECRET_ACCESS_KEY")
25
+ AWS_S3_BUCKET_NAME = os.getenv("AWS_S3_BUCKET_NAME")
26
+ AWS_S3_REGION = os.getenv("AWS_S3_REGION")
27
+ SUPABASE_URL = os.getenv("SUPABASE_URL")
28
+ SUPABASE_KEY = os.getenv("SUPABASE_KEY")
29
+ YOLO_MODEL_PATH = os.getenv("YOLO_MODEL_PATH", "best.pt")
30
+
31
+ if not all([AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_S3_BUCKET_NAME, AWS_S3_REGION, SUPABASE_URL, SUPABASE_KEY]):
32
+ raise RuntimeError("Erro: faltam secrets da AWS ou Supabase.")
33
+
34
+ try:
35
+ s3_client = boto3.client(
36
+ 's3',
37
+ aws_access_key_id=AWS_ACCESS_KEY_ID,
38
+ aws_secret_access_key=AWS_SECRET_ACCESS_KEY,
39
+ region_name=AWS_S3_REGION
40
+ )
41
+ supabase_client = supabase.create_client(SUPABASE_URL, SUPABASE_KEY)
42
+ print("Clientes S3 e Supabase inicializados.")
43
+ except Exception as e:
44
+ raise RuntimeError(f"Erro ao inicializar clientes: {e}")
45
+
46
+ try:
47
+ yolo_model = YOLO(YOLO_MODEL_PATH)
48
+ print(f"YOLO carregado: {YOLO_MODEL_PATH}")
49
+ except Exception as e:
50
+ raise RuntimeError(f"Falha ao carregar YOLO: {e}")
51
+
52
+ app = FastAPI(title="CorroScan API — YOLO + OpenCV")
53
+
54
+ app.add_middleware(
55
+ CORSMiddleware,
56
+ allow_origins=["*"], # restrinja em produção
57
+ allow_credentials=True,
58
+ allow_methods=["*"],
59
+ allow_headers=["*"],
60
+ )
61
+
62
+ # ==============================================================================
63
+ # 2. HELPERS
64
+ # ==============================================================================
65
+
66
+
67
+
68
+ def _to_data_uri_from_rgb(img_rgb: np.ndarray) -> str:
69
+ if img_rgb is None or img_rgb.size == 0:
70
+ return None
71
+ bgr = cv2.cvtColor(img_rgb, cv2.COLOR_RGB2BGR)
72
+ ok, buf = cv2.imencode(".png", bgr)
73
+ if not ok:
74
+ return None
75
+ b64 = base64.b64encode(buf.tobytes()).decode("ascii")
76
+ return f"data:image/png;base64,{b64}"
77
+
78
+ def draw_boxes_on_bgr(img_bgr: np.ndarray, boxes_xyxy: np.ndarray, labels: List[str]) -> np.ndarray:
79
+ out = img_bgr.copy()
80
+ for (x1, y1, x2, y2), label in zip(boxes_xyxy, labels):
81
+ x1, y1, x2, y2 = map(int, [x1, y1, x2, y2])
82
+ cv2.rectangle(out, (x1, y1), (x2, y2), (0, 200, 0), 2)
83
+ cv2.putText(out, label, (x1, max(y1 - 5, 0)), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 220, 0), 2, cv2.LINE_AA)
84
+ return cv2.cvtColor(out, cv2.COLOR_BGR2RGB)
85
+
86
+ def circular_roi_from_mask(mask_clean: np.ndarray, shrink: float = 0.9) -> np.ndarray:
87
+ cnts, _ = cv2.findContours(mask_clean, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
88
+ if not cnts:
89
+ return mask_clean.copy()
90
+ c = max(cnts, key=cv2.contourArea)
91
+ (x, y), r = cv2.minEnclosingCircle(c)
92
+ r = max(1, int(r * shrink))
93
+ cx, cy = int(x), int(y)
94
+ roi = np.zeros_like(mask_clean)
95
+ cv2.circle(roi, (cx, cy), r, 255, -1)
96
+ return roi
97
+
98
+ def remove_specular_highlights(hsv_iso: np.ndarray, mask: np.ndarray, v_spec: int = 230) -> np.ndarray:
99
+ v = hsv_iso[..., 2]
100
+ spec = cv2.inRange(v, v_spec, 255)
101
+ return cv2.bitwise_and(mask, cv2.bitwise_not(spec))
102
+
103
+ def remove_small_components(mask: np.ndarray, min_area_ratio: float, also_border: bool = True) -> np.ndarray:
104
+ if mask.max() == 0:
105
+ return mask
106
+ num_labels, labels, stats, _ = cv2.connectedComponentsWithStats(mask, connectivity=8)
107
+ total = int(np.count_nonzero(mask))
108
+ min_area = max(1, int(total * min_area_ratio))
109
+ out = np.zeros_like(mask)
110
+ H, W = mask.shape[:2]
111
+ for i in range(1, num_labels):
112
+ x, y, w, h, area = stats[i]
113
+ if area < min_area:
114
+ continue
115
+ if also_border and (x == 0 or y == 0 or x + w == W or y + h == H):
116
+ continue
117
+ out[labels == i] = 255
118
+ return out
119
+
120
+ def dilate_around(mask: np.ndarray, it: int = 1) -> np.ndarray:
121
+ k = np.ones((3, 3), np.uint8)
122
+ return cv2.dilate(mask, k, iterations=it)
123
+
124
+ def illum_normalize_L(img_bgr: np.ndarray, sigma: float = 21) -> np.ndarray:
125
+ lab = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2LAB)
126
+ L = lab[..., 0].astype(np.float32)
127
+ base = cv2.GaussianBlur(L, (0, 0), sigma)
128
+ base = np.maximum(base, 1.0)
129
+ Ln = (L / base) * 128.0
130
+ Ln = np.clip(Ln, 0, 255).astype(np.uint8)
131
+ return Ln
132
+
133
+ def texture_map(Ln: np.ndarray, ksize: int = 3) -> np.ndarray:
134
+ lap = cv2.Laplacian(Ln, cv2.CV_16S, ksize=ksize)
135
+ t = np.abs(lap).astype(np.uint16)
136
+ t = np.clip(t, 0, 255).astype(np.uint8)
137
+ return t
138
+
139
+ def get_corrosion_mask(hsv_iso: np.ndarray, mask_clean: np.ndarray, mode: str, src_bgr_iso: np.ndarray = None) -> np.ndarray:
140
+ mode = (mode or "white").lower()
141
+ kernel = np.ones((3, 3), np.uint8)
142
+
143
+ if mode == "white":
144
+ # cor conservadora
145
+ lower = np.array([0, 0, 115], dtype=np.uint8)
146
+ upper = np.array([180, 45, 215], dtype=np.uint8)
147
+ m_color = cv2.inRange(hsv_iso, lower, upper)
148
+
149
+ # especular duro e vizinhança
150
+ v = hsv_iso[..., 2]
151
+ s = hsv_iso[..., 1]
152
+ spec_core = cv2.inRange(v, 220, 255) & cv2.inRange(s, 0, 40)
153
+ spec = dilate_around(spec_core, it=2)
154
+
155
+ if src_bgr_iso is None:
156
+ raise ValueError("src_bgr_iso é necessário para textura no modo white.")
157
+
158
+ Ln = illum_normalize_L(src_bgr_iso)
159
+ tmap = texture_map(Ln, ksize=3)
160
+ tmask = cv2.inRange(tmap, 8, 255)
161
+
162
+ m = m_color
163
+ m = cv2.bitwise_and(m, cv2.bitwise_not(spec))
164
+ m = cv2.bitwise_and(m, tmask)
165
+
166
+ elif mode == "black":
167
+ lower = np.array([0, 0, 0], dtype=np.uint8)
168
+ upper = np.array([180, 255, 60], dtype=np.uint8)
169
+ m = cv2.inRange(hsv_iso, lower, upper)
170
+
171
+ elif mode == "red":
172
+ lower1 = np.array([0, 80, 60], dtype=np.uint8)
173
+ upper1 = np.array([10, 255, 255], dtype=np.uint8)
174
+ lower2 = np.array([170, 80, 60], dtype=np.uint8)
175
+ upper2 = np.array([180, 255, 255], dtype=np.uint8)
176
+ m = cv2.bitwise_or(cv2.inRange(hsv_iso, lower1, upper1),
177
+ cv2.inRange(hsv_iso, lower2, upper2))
178
+ else:
179
+ return get_corrosion_mask(hsv_iso, mask_clean, "white", src_bgr_iso)
180
+
181
+ m = cv2.bitwise_and(m, m, mask=mask_clean)
182
+ m = cv2.morphologyEx(m, cv2.MORPH_OPEN, kernel, iterations=1)
183
+ m = cv2.morphologyEx(m, cv2.MORPH_CLOSE, kernel, iterations=1)
184
+ m = remove_small_components(m, min_area_ratio=0.005, also_border=True)
185
+ return m
186
+
187
+ def process_image_bgr(img_bgr: np.ndarray, corrosion_type: str = "white") -> Tuple[Dict[str, Any], np.ndarray]:
188
+ if img_bgr is None or img_bgr.size == 0:
189
+ raise ValueError("Imagem vazia.")
190
+
191
+ # suaviza reflexos preservando bordas
192
+ img_bgr = cv2.bilateralFilter(img_bgr, d=7, sigmaColor=60, sigmaSpace=60)
193
+ hsv = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2HSV)
194
+
195
+ # objeto principal
196
+ lower_bg = np.array([0, 0, 0], dtype=np.uint8)
197
+ upper_bg = np.array([180, 255, 50], dtype=np.uint8)
198
+ mask_bg = cv2.inRange(hsv, lower_bg, upper_bg)
199
+ mask_obj = cv2.bitwise_not(mask_bg)
200
+ kernel = np.ones((5, 5), np.uint8)
201
+ mask_obj = cv2.morphologyEx(mask_obj, cv2.MORPH_OPEN, kernel)
202
+ mask_obj = cv2.morphologyEx(mask_obj, cv2.MORPH_CLOSE, kernel)
203
+
204
+ contours, _ = cv2.findContours(mask_obj, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
205
+ if not contours:
206
+ raise ValueError("Nenhum objeto detectado no crop.")
207
+
208
+ largest = max(contours, key=cv2.contourArea)
209
+ mask_clean = np.zeros_like(mask_obj)
210
+ cv2.drawContours(mask_clean, [largest], -1, 255, cv2.FILLED)
211
+
212
+ # foca no tampo
213
+ roi_circle = circular_roi_from_mask(mask_clean, shrink=0.9)
214
+ mask_clean = cv2.bitwise_and(mask_clean, roi_circle)
215
+
216
+ isolated = cv2.bitwise_and(img_bgr, img_bgr, mask=mask_clean)
217
+ hsv_iso = cv2.cvtColor(isolated, cv2.COLOR_BGR2HSV)
218
+
219
+ mask_corrosion = get_corrosion_mask(
220
+ hsv_iso=hsv_iso,
221
+ mask_clean=mask_clean,
222
+ mode=corrosion_type,
223
+ src_bgr_iso=isolated
224
+ )
225
+
226
+ total_pixels = int(np.count_nonzero(mask_clean))
227
+ corrosion_pixels = int(np.count_nonzero(mask_corrosion))
228
+ percent = (corrosion_pixels / max(1, total_pixels)) * 100.0
229
+
230
+ isolated_rgb = cv2.cvtColor(isolated, cv2.COLOR_BGR2RGB)
231
+ corrosion_vis_rgb = cv2.bitwise_and(isolated_rgb, isolated_rgb, mask=mask_corrosion)
232
+
233
+ analysis_results = {
234
+ "corrosion_type": corrosion_type,
235
+ "percent": round(percent, 4),
236
+ "total_pixels": total_pixels,
237
+ "corrosion_pixels": corrosion_pixels,
238
+ "isolated_image": _to_data_uri_from_rgb(isolated_rgb),
239
+ "corrosion_image": _to_data_uri_from_rgb(corrosion_vis_rgb),
240
+ }
241
+ return analysis_results, corrosion_vis_rgb
242
+
243
+ # ==============================================================================
244
+ # 3. ENDPOINTS
245
+ # ==============================================================================
246
+
247
+ @app.get("/")
248
+ def read_root():
249
+ return {"status": "ok", "message": "API YOLO + OpenCV pronta."}
250
+
251
+ @app.post("/analyze")
252
+ async def analyze(
253
+ file: UploadFile = File(...),
254
+ corrosion_type: str = Form("white") # "white" | "black" | "red"
255
+ ):
256
+ """
257
+ 1) YOLO detecta parafusos e gera boxes
258
+ 2) Para cada box, recorta e roda OpenCV conforme corrosion_type
259
+ 3) Sobe original e crops no S3
260
+ 4) Insere metadados básicos no Supabase
261
+ """
262
+ content = await file.read()
263
+ s3_key_original = None
264
+ s3_keys_crops: List[str] = []
265
+
266
+ try:
267
+ # decodifica
268
+ nparr = np.frombuffer(content, np.uint8)
269
+ img_bgr = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
270
+ if img_bgr is None:
271
+ raise ValueError("Imagem inválida.")
272
+
273
+ # YOLO
274
+ img_rgb = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB)
275
+ yolo_results = yolo_model.predict(source=img_rgb, imgsz=1280, conf=0.5, iou=0.4, verbose=False)
276
+ if not yolo_results:
277
+ raise ValueError("YOLO não retornou resultados.")
278
+ r0 = yolo_results[0]
279
+ names = r0.names if hasattr(r0, "names") else {}
280
+
281
+ boxes = r0.boxes
282
+ if boxes is None or boxes.xyxy is None or len(boxes) == 0:
283
+ raise ValueError("Nenhum parafuso detectado.")
284
+
285
+ xyxy = boxes.xyxy.cpu().numpy().astype(int)
286
+ cls_ids = boxes.cls.cpu().numpy().astype(int) if boxes.cls is not None else np.zeros((xyxy.shape[0],), dtype=int)
287
+ confs = boxes.conf.cpu().numpy() if boxes.conf is not None else np.ones((xyxy.shape[0],), dtype=float)
288
+
289
+ detections_payload = []
290
+ H, W = img_bgr.shape[:2]
291
+
292
+ for i, (x1, y1, x2, y2) in enumerate(xyxy):
293
+ # leve inset para evitar etiqueta e borda
294
+ inset = 0.05
295
+ w = x2 - x1
296
+ h = y2 - y1
297
+ x1 += int(w * inset)
298
+ y1 += int(h * inset)
299
+ x2 -= int(w * inset)
300
+ y2 -= int(h * inset)
301
+
302
+ x1 = max(0, min(x1, W - 1))
303
+ y1 = max(0, min(y1, H - 1))
304
+ x2 = max(x1 + 1, min(x2, W))
305
+ y2 = max(y1 + 1, min(y2, H))
306
+
307
+ crop_bgr = img_bgr[y1:y2, x1:x2].copy()
308
+ if crop_bgr.size == 0:
309
+ continue
310
+
311
+ try:
312
+ analysis, corrosion_rgb = process_image_bgr(crop_bgr, corrosion_type=corrosion_type)
313
+ except Exception as e:
314
+ analysis = {"error": f"Falha na análise do crop {i}: {e}", "corrosion_type": corrosion_type}
315
+ corrosion_rgb = None
316
+
317
+ s3_key_crop = None
318
+ if corrosion_rgb is not None:
319
+ bgr_result = cv2.cvtColor(corrosion_rgb, cv2.COLOR_RGB2BGR)
320
+ ok, buffer = cv2.imencode('.png', bgr_result)
321
+ if ok:
322
+ s3_key_crop = f"imagens_resultados/{uuid.uuid4()}.png"
323
+ s3_client.put_object(
324
+ Bucket=AWS_S3_BUCKET_NAME,
325
+ Key=s3_key_crop,
326
+ Body=buffer.tobytes(),
327
+ ContentType='image/png'
328
+ )
329
+ s3_keys_crops.append(s3_key_crop)
330
+
331
+ cls_id = int(cls_ids[i]) if i < len(cls_ids) else 0
332
+ label = names.get(cls_id, f"class_{cls_id}")
333
+ score = float(confs[i]) if i < len(confs) else 0.0
334
+
335
+ detections_payload.append({
336
+ "index": i,
337
+ "bbox_xyxy": [int(x1), int(y1), int(x2), int(y2)],
338
+ "class_id": cls_id,
339
+ "class_name": label or "Parafuso",
340
+ "score": round(score, 4),
341
+ "analysis": analysis,
342
+ "s3_result_key": s3_key_crop
343
+ })
344
+
345
+ if not detections_payload:
346
+ raise ValueError("Nenhum crop válido para análise.")
347
+
348
+ # upload do original
349
+ content_type = file.content_type or 'application/octet-stream'
350
+ extensao = mimetypes.guess_extension(content_type) or '.jpg'
351
+ s3_key_original = f"imagens_originais/{uuid.uuid4()}{extensao}"
352
+ s3_client.put_object(
353
+ Bucket=AWS_S3_BUCKET_NAME,
354
+ Key=s3_key_original,
355
+ Body=content,
356
+ ContentType=content_type
357
+ )
358
+
359
+ # imagem anotada
360
+ labels_for_draw = [f"{d['class_name']} {d['score']:.2f}" for d in detections_payload]
361
+ annotated_rgb = draw_boxes_on_bgr(img_bgr, xyxy, labels_for_draw)
362
+ annotated_data_uri = _to_data_uri_from_rgb(annotated_rgb)
363
+
364
+ # resumo para Supabase
365
+ valid_percents = [d["analysis"].get("percent") for d in detections_payload if isinstance(d.get("analysis"), dict) and d["analysis"].get("percent") is not None]
366
+ avg_percent = round(float(np.mean(valid_percents)), 4) if valid_percents else 0.0
367
+ first_result_key = next((d["s3_result_key"] for d in detections_payload if d.get("s3_result_key")), None)
368
+
369
+ dados_para_inserir = {
370
+ "nome_amostra": file.filename,
371
+ "percentual_corrosao": avg_percent,
372
+ "pixels_totais_obj": None,
373
+ "pixels_corrosao": None,
374
+ "imagem_original": s3_key_original,
375
+ "imagem_resultado": first_result_key
376
+ }
377
+ response = supabase_client.from_("amostras").insert(dados_para_inserir).execute()
378
+ new_record_id = response.data[0]['id'] if response and response.data else None
379
+
380
+ out = {
381
+ "corrosion_type": corrosion_type,
382
+ "database_id": new_record_id,
383
+ "detections_count": len(detections_payload),
384
+ "annotated_image": annotated_data_uri,
385
+ "detections": detections_payload
386
+ }
387
+ return JSONResponse(content=out)
388
+
389
+ except Exception as e:
390
+ import traceback
391
+ print("Erro no /analyze:", repr(e))
392
+ traceback.print_exc()
393
+ if s3_key_original:
394
+ try:
395
+ s3_client.delete_object(Bucket=AWS_S3_BUCKET_NAME, Key=s3_key_original)
396
+ except Exception:
397
+ pass
398
+ for key in s3_keys_crops:
399
+ try:
400
+ s3_client.delete_object(Bucket=AWS_S3_BUCKET_NAME, Key=key)
401
+ except Exception:
402
+ pass
403
+ raise HTTPException(status_code=500, detail=f"Erro interno: {e}")
404
+
405
+ @app.get("/samples/{sample_id}")
406
+ async def get_sample_images(sample_id: int):
407
+ try:
408
+ response = supabase_client.from_("amostras").select("imagem_original, imagem_resultado").eq("id", sample_id).single().execute()
409
+ if not response.data:
410
+ raise HTTPException(status_code=404, detail=f"Amostra {sample_id} não encontrada.")
411
+ amostra = response.data
412
+ s3_key_original = amostra.get("imagem_original")
413
+ s3_key_resultado = amostra.get("imagem_resultado")
414
+
415
+ links = {}
416
+ if s3_key_original:
417
+ links['url_original'] = s3_client.generate_presigned_url(
418
+ 'get_object',
419
+ Params={'Bucket': AWS_S3_BUCKET_NAME, 'Key': s3_key_original},
420
+ ExpiresIn=3600
421
+ )
422
+ if s3_key_resultado:
423
+ links['url_resultado'] = s3_client.generate_presigned_url(
424
+ 'get_object',
425
+ Params={'Bucket': AWS_S3_BUCKET_NAME, 'Key': s3_key_resultado},
426
+ ExpiresIn=3600
427
+ )
428
+ return JSONResponse(content=links)
429
+ except Exception as e:
430
+ raise HTTPException(status_code=500, detail=f"Erro ao buscar links: {e}")