Spaces:
Sleeping
fix(wave9): 10 P0 prod bugs from 5-agent audit
Browse filesBackend:
- main.py _process_sync: per-image try/except; single failure no longer
aborts entire batch (was returning 500 on N>=2 photos with custom model).
- main.py update_inspection: model_versions added to JSONB column list,
was silently dropping every completed inspect's model metadata.
- ml_service.py check_model_files_available: fix REGISTRY ImportError
(symbol does not exist; use get_registry() public API).
- ml_service.py _analyze_via_roboflow: emit canonical Inspection schema
with full summary skeleton, severity object, cost stub, type_tr — was
crashing frontend with undefined.level_tr / undefined.min_tl.
- config.py s3_public_endpoint: default '' (was localhost:9000 leaking
into prod URLs). Empty falls back to s3_endpoint (B2 host).
- worker.py: detect rediss:// and set broker_use_ssl with ssl_cert_reqs=NONE
(Upstash TLS Redis was 503'ing every async inspect).
- main.py thumbnail_url: filter out local:// URLs from list response.
Frontend:
- api.ts: dynamic timeout for /inspect — sync mode 5min minimum or 60s
per file (was 60s fixed, multi-photo always timed out client-side).
- use-inspection-polling.ts: maxDuration 3min -> 10min (20 foto CPU batch
can take 12min).
- SeverityBadge: guard undefined level (Roboflow path) — was crashing
React tree -> ErrorBoundary -> 'sunucu hatasi' UI.
- CostDisplay: guard undefined total_cost_range_tl (Roboflow path).
- ImageWithOverlay: render placeholder for local:// URLs instead of
silently broken <img>.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- apps/web/lib/api.ts +7 -0
- apps/web/lib/use-inspection-polling.ts +3 -2
- packages/ui/src/components/CostDisplay.tsx +7 -2
- packages/ui/src/components/ImageWithOverlay.tsx +20 -12
- packages/ui/src/components/SeverityBadge.tsx +4 -1
- services/backend/config.py +5 -2
- services/backend/main.py +79 -16
- services/backend/ml_service.py +116 -30
- services/backend/worker.py +14 -5
|
@@ -347,11 +347,18 @@ export const inspections = {
|
|
| 347 |
// it automatically so that the multipart boundary token is included in
|
| 348 |
// the header. Setting `Content-Type: multipart/form-data` here strips the
|
| 349 |
// boundary and the backend rejects the upload as malformed.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 350 |
const res = await client().post<
|
| 351 |
InspectionCreateResponse | SyncInspectionResponse
|
| 352 |
>(path, form, {
|
| 353 |
headers: apiKey ? { 'X-API-Key': apiKey } : undefined,
|
| 354 |
signal,
|
|
|
|
| 355 |
onUploadProgress: (evt) => {
|
| 356 |
if (onUploadProgress && evt.total) {
|
| 357 |
onUploadProgress(evt.loaded, evt.total);
|
|
|
|
| 347 |
// it automatically so that the multipart boundary token is included in
|
| 348 |
// the header. Setting `Content-Type: multipart/form-data` here strips the
|
| 349 |
// boundary and the backend rejects the upload as malformed.
|
| 350 |
+
// CPU-only HF Spaces: tek inference 9-36s; N foto * 45s + upload süresi
|
| 351 |
+
// global 60s timeout'u aşar → "Bağlantı zaman aşımı" yanlış göstergesi.
|
| 352 |
+
// Per-call uzatma: en az 5dk, foto başına 60s. Sync mode'da kritik.
|
| 353 |
+
const dynamicTimeout = mode === 'sync'
|
| 354 |
+
? Math.max(300_000, files.length * 60_000)
|
| 355 |
+
: 120_000;
|
| 356 |
const res = await client().post<
|
| 357 |
InspectionCreateResponse | SyncInspectionResponse
|
| 358 |
>(path, form, {
|
| 359 |
headers: apiKey ? { 'X-API-Key': apiKey } : undefined,
|
| 360 |
signal,
|
| 361 |
+
timeout: dynamicTimeout,
|
| 362 |
onUploadProgress: (evt) => {
|
| 363 |
if (onUploadProgress && evt.total) {
|
| 364 |
onUploadProgress(evt.loaded, evt.total);
|
|
@@ -25,7 +25,8 @@ export interface UseInspectionPollingOptions {
|
|
| 25 |
intervalMs?: number;
|
| 26 |
/**
|
| 27 |
* Max polling duration in ms before giving up.
|
| 28 |
-
* Default
|
|
|
|
| 29 |
*/
|
| 30 |
maxDurationMs?: number;
|
| 31 |
/** Max single interval after exponential backoff. Default 8000. */
|
|
@@ -57,7 +58,7 @@ export function useInspectionPolling(
|
|
| 57 |
): PollingReturn {
|
| 58 |
const {
|
| 59 |
intervalMs = 1_500,
|
| 60 |
-
maxDurationMs =
|
| 61 |
maxIntervalMs = 8_000,
|
| 62 |
enabled = true,
|
| 63 |
} = opts;
|
|
|
|
| 25 |
intervalMs?: number;
|
| 26 |
/**
|
| 27 |
* Max polling duration in ms before giving up.
|
| 28 |
+
* Default 600_000 (10 min) — CPU inference can be 9-36s/foto; 20 foto
|
| 29 |
+
* batch = up to 12 dakika. 3dk default kullanıcıyı erken "timeout" yapıyordu.
|
| 30 |
*/
|
| 31 |
maxDurationMs?: number;
|
| 32 |
/** Max single interval after exponential backoff. Default 8000. */
|
|
|
|
| 58 |
): PollingReturn {
|
| 59 |
const {
|
| 60 |
intervalMs = 1_500,
|
| 61 |
+
maxDurationMs = 600_000,
|
| 62 |
maxIntervalMs = 8_000,
|
| 63 |
enabled = true,
|
| 64 |
} = opts;
|
|
@@ -22,8 +22,13 @@ const CONF_COLOR: Record<'high' | 'medium' | 'low', string> = {
|
|
| 22 |
};
|
| 23 |
|
| 24 |
export function CostDisplay({ summary, className }: Props) {
|
| 25 |
-
|
| 26 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
|
| 28 |
return (
|
| 29 |
<div
|
|
|
|
| 22 |
};
|
| 23 |
|
| 24 |
export function CostDisplay({ summary, className }: Props) {
|
| 25 |
+
// Guard: Roboflow / pretrained path total_cost_range_tl döndürmeyebilir;
|
| 26 |
+
// undefined destructure crash önlenir.
|
| 27 |
+
const range = Array.isArray(summary?.total_cost_range_tl)
|
| 28 |
+
? summary.total_cost_range_tl
|
| 29 |
+
: [0, 0];
|
| 30 |
+
const [min, max] = [Number(range[0]) || 0, Number(range[1]) || 0];
|
| 31 |
+
const mid = summary?.total_cost_midpoint_tl ?? (min + max) / 2;
|
| 32 |
|
| 33 |
return (
|
| 34 |
<div
|
|
@@ -177,18 +177,26 @@ export function ImageWithOverlay({
|
|
| 177 |
)}
|
| 178 |
style={{ aspectRatio }}
|
| 179 |
>
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 192 |
<canvas
|
| 193 |
ref={canvasRef}
|
| 194 |
className="pointer-events-none absolute inset-0 h-full w-full"
|
|
|
|
| 177 |
)}
|
| 178 |
style={{ aspectRatio }}
|
| 179 |
>
|
| 180 |
+
{/* Guard: backend STORAGE_OPTIONAL'da `local://skipped/...` URL'i
|
| 181 |
+
dönebilir; browser bunu fetch edemez — placeholder göster. */}
|
| 182 |
+
{imageUrl && !imageUrl.startsWith('local://') ? (
|
| 183 |
+
<img
|
| 184 |
+
src={imageUrl}
|
| 185 |
+
alt={alt}
|
| 186 |
+
draggable={false}
|
| 187 |
+
loading="lazy"
|
| 188 |
+
decoding="async"
|
| 189 |
+
className="block h-full w-full select-none object-contain"
|
| 190 |
+
onLoad={(e) => {
|
| 191 |
+
const t = e.currentTarget;
|
| 192 |
+
setImgDim({ w: t.naturalWidth, h: t.naturalHeight });
|
| 193 |
+
}}
|
| 194 |
+
/>
|
| 195 |
+
) : (
|
| 196 |
+
<div className="flex h-full w-full items-center justify-center bg-slate-100 text-sm text-slate-500">
|
| 197 |
+
Görsel kaydedilmedi (storage opsiyonel mod)
|
| 198 |
+
</div>
|
| 199 |
+
)}
|
| 200 |
<canvas
|
| 201 |
ref={canvasRef}
|
| 202 |
className="pointer-events-none absolute inset-0 h-full w-full"
|
|
@@ -44,6 +44,9 @@ export function SeverityBadge({
|
|
| 44 |
showDot = false,
|
| 45 |
showIcon = true,
|
| 46 |
}: Props) {
|
|
|
|
|
|
|
|
|
|
| 47 |
const Icon = ICONS[level];
|
| 48 |
return (
|
| 49 |
<span
|
|
@@ -57,7 +60,7 @@ export function SeverityBadge({
|
|
| 57 |
{showDot && (
|
| 58 |
<span className={cn('h-1.5 w-1.5 rounded-full', DOT_COLORS[level])} aria-hidden />
|
| 59 |
)}
|
| 60 |
-
{showIcon && (
|
| 61 |
<Icon
|
| 62 |
className={cn(size === 'sm' ? 'h-3 w-3' : 'h-3.5 w-3.5', ICON_COLORS[level])}
|
| 63 |
aria-hidden
|
|
|
|
| 44 |
showDot = false,
|
| 45 |
showIcon = true,
|
| 46 |
}: Props) {
|
| 47 |
+
// Guard: backend bazı path'lerde severity dönmeyebilir (Roboflow) — null
|
| 48 |
+
// veya bilinmeyen değer için render yapma (React.createElement crash önlenir).
|
| 49 |
+
if (!level || !(level in STYLES)) return null;
|
| 50 |
const Icon = ICONS[level];
|
| 51 |
return (
|
| 52 |
<span
|
|
|
|
| 60 |
{showDot && (
|
| 61 |
<span className={cn('h-1.5 w-1.5 rounded-full', DOT_COLORS[level])} aria-hidden />
|
| 62 |
)}
|
| 63 |
+
{showIcon && Icon && (
|
| 64 |
<Icon
|
| 65 |
className={cn(size === 'sm' ? 'h-3 w-3' : 'h-3.5 w-3.5', ICON_COLORS[level])}
|
| 66 |
aria-hidden
|
|
@@ -101,9 +101,12 @@ class Settings(BaseSettings):
|
|
| 101 |
ml_unload_after_inference: bool = False
|
| 102 |
|
| 103 |
# ---- S3 storage ----
|
| 104 |
-
#
|
| 105 |
s3_endpoint: str = "http://minio:9000"
|
| 106 |
-
|
|
|
|
|
|
|
|
|
|
| 107 |
s3_access_key: str = "minioadmin"
|
| 108 |
s3_secret_key: str = "minioadmin"
|
| 109 |
s3_bucket: str = "inspections"
|
|
|
|
| 101 |
ml_unload_after_inference: bool = False
|
| 102 |
|
| 103 |
# ---- S3 storage ----
|
| 104 |
+
# Production: AWS S3 / R2 / B2. Dev: MinIO.
|
| 105 |
s3_endpoint: str = "http://minio:9000"
|
| 106 |
+
# NOT: production'da MUTLAKA S3_PUBLIC_ENDPOINT env'i set edilmeli;
|
| 107 |
+
# default localhost'tu — kullanici-yuzlu image URL'leri prod'da kirildi.
|
| 108 |
+
# Bos string fallback: storage.get_image_url s3_endpoint'i kullanir.
|
| 109 |
+
s3_public_endpoint: str = ""
|
| 110 |
s3_access_key: str = "minioadmin"
|
| 111 |
s3_secret_key: str = "minioadmin"
|
| 112 |
s3_bucket: str = "inspections"
|
|
@@ -360,8 +360,10 @@ def update_inspection(inspection_id: str, **fields) -> None:
|
|
| 360 |
return
|
| 361 |
sets = []
|
| 362 |
values: list = []
|
|
|
|
|
|
|
| 363 |
for k, v in fields.items():
|
| 364 |
-
if k in
|
| 365 |
v = _json.dumps(v) if v is not None else None
|
| 366 |
sets.append(f"{k} = %s")
|
| 367 |
values.append(v)
|
|
@@ -959,34 +961,81 @@ async def _process_sync(files: List[UploadFile], auth: AuthContext,
|
|
| 959 |
results: List[dict] = []
|
| 960 |
per_image: List[dict] = []
|
| 961 |
|
|
|
|
| 962 |
for i, f in enumerate(files):
|
| 963 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 964 |
image_urls.append(url)
|
| 965 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 966 |
# GPU inference event-loop'u bloklamasin -> threadpool
|
|
|
|
| 967 |
try:
|
| 968 |
-
# ml_pipeline.analyze(image, retries=2, source=...)
|
| 969 |
r = await asyncio.to_thread(ml_pipeline.analyze, img, 2, model)
|
| 970 |
except RuntimeError as e:
|
| 971 |
-
|
| 972 |
-
|
| 973 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 974 |
except Exception as e: # noqa: BLE001
|
| 975 |
-
# CUDA OOM, model exception vs — kullaniciya net mesaj
|
| 976 |
msg = str(e).lower()
|
| 977 |
if "out of memory" in msg or "cuda" in msg:
|
| 978 |
-
logger.error("GPU OOM
|
| 979 |
-
|
| 980 |
-
|
| 981 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 982 |
if isinstance(r, dict):
|
| 983 |
img_blk = r.get("image") if isinstance(r.get("image"), dict) else {}
|
| 984 |
-
# Pipeline'in legacy "<inline>" donmesi durumunda override et.
|
| 985 |
if (not img_blk.get("url")) or img_blk.get("url") == "<inline>":
|
| 986 |
img_blk["url"] = url
|
| 987 |
r["image"] = img_blk
|
| 988 |
results.append(r)
|
| 989 |
-
# Per-image kart kaydi (frontend "N foto = N sonuc" beklentisi).
|
| 990 |
per_image.append({
|
| 991 |
"index": i,
|
| 992 |
"url": url,
|
|
@@ -998,7 +1047,20 @@ async def _process_sync(files: List[UploadFile], auth: AuthContext,
|
|
| 998 |
"multi_part_damages": (r.get("multi_part_damages") if isinstance(r, dict) else []) or [],
|
| 999 |
})
|
| 1000 |
|
| 1001 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1002 |
# inspection_id'yi zorla yerlestir
|
| 1003 |
aggregated["inspection_id"] = inspection_id
|
| 1004 |
aggregated["images"] = per_image
|
|
@@ -1267,7 +1329,8 @@ async def list_inspections(
|
|
| 1267 |
iu = r.get("image_urls")
|
| 1268 |
if isinstance(iu, list) and iu:
|
| 1269 |
cand = iu[0]
|
| 1270 |
-
if isinstance(cand, str) and cand and cand != "<inline>"
|
|
|
|
| 1271 |
thumb = cand
|
| 1272 |
|
| 1273 |
inspection_id_val = r["id"] if "id" in r else r.get("inspection_id")
|
|
|
|
| 360 |
return
|
| 361 |
sets = []
|
| 362 |
values: list = []
|
| 363 |
+
# JSONB sutunlar — psycopg2 dict adapt edemiyor, manuel json-dump.
|
| 364 |
+
_JSONB_COLS = {"result", "image_urls", "model_versions", "metadata"}
|
| 365 |
for k, v in fields.items():
|
| 366 |
+
if k in _JSONB_COLS:
|
| 367 |
v = _json.dumps(v) if v is not None else None
|
| 368 |
sets.append(f"{k} = %s")
|
| 369 |
values.append(v)
|
|
|
|
| 961 |
results: List[dict] = []
|
| 962 |
per_image: List[dict] = []
|
| 963 |
|
| 964 |
+
failed_indices: List[int] = []
|
| 965 |
for i, f in enumerate(files):
|
| 966 |
+
try:
|
| 967 |
+
content, url = await _store_upload(f, inspection_id, i)
|
| 968 |
+
except HTTPException:
|
| 969 |
+
raise # 400/413 - validation, kullaniciya net don
|
| 970 |
+
except Exception as e: # noqa: BLE001
|
| 971 |
+
logger.exception("Store upload hatasi index=%d: %s", i, e)
|
| 972 |
+
per_image.append({
|
| 973 |
+
"index": i, "url": None, "status": "failed",
|
| 974 |
+
"error": f"Goruntu yuklenemedi: {e}",
|
| 975 |
+
"image": {"url": None}, "parts": [], "summary": {},
|
| 976 |
+
"unassigned_damages": [], "multi_part_damages": [],
|
| 977 |
+
})
|
| 978 |
+
failed_indices.append(i)
|
| 979 |
+
continue
|
| 980 |
image_urls.append(url)
|
| 981 |
+
try:
|
| 982 |
+
img = _decode_image(content, i)
|
| 983 |
+
except HTTPException as e:
|
| 984 |
+
per_image.append({
|
| 985 |
+
"index": i, "url": url, "status": "failed",
|
| 986 |
+
"error": str(e.detail),
|
| 987 |
+
"image": {"url": url}, "parts": [], "summary": {},
|
| 988 |
+
"unassigned_damages": [], "multi_part_damages": [],
|
| 989 |
+
})
|
| 990 |
+
failed_indices.append(i)
|
| 991 |
+
continue
|
| 992 |
# GPU inference event-loop'u bloklamasin -> threadpool
|
| 993 |
+
# Per-image error containment: tek foto fail tum batch'i ucurmasin.
|
| 994 |
try:
|
|
|
|
| 995 |
r = await asyncio.to_thread(ml_pipeline.analyze, img, 2, model)
|
| 996 |
except RuntimeError as e:
|
| 997 |
+
logger.error("ML pipeline runtime hatasi index=%d: %s", i, e)
|
| 998 |
+
if len(files) == 1:
|
| 999 |
+
raise HTTPException(status_code=503, detail="ML servisi su an kullanilamiyor")
|
| 1000 |
+
per_image.append({
|
| 1001 |
+
"index": i, "url": url, "status": "failed",
|
| 1002 |
+
"error": f"ML servisi: {e}",
|
| 1003 |
+
"image": {"url": url}, "parts": [], "summary": {},
|
| 1004 |
+
"unassigned_damages": [], "multi_part_damages": [],
|
| 1005 |
+
})
|
| 1006 |
+
failed_indices.append(i)
|
| 1007 |
+
continue
|
| 1008 |
except Exception as e: # noqa: BLE001
|
|
|
|
| 1009 |
msg = str(e).lower()
|
| 1010 |
if "out of memory" in msg or "cuda" in msg:
|
| 1011 |
+
logger.error("GPU OOM index=%d: %s", i, e)
|
| 1012 |
+
if len(files) == 1:
|
| 1013 |
+
raise HTTPException(status_code=503, detail="GPU bellegi yetersiz, daha kucuk goruntu deneyin")
|
| 1014 |
+
per_image.append({
|
| 1015 |
+
"index": i, "url": url, "status": "failed",
|
| 1016 |
+
"error": "GPU bellegi yetersiz",
|
| 1017 |
+
"image": {"url": url}, "parts": [], "summary": {},
|
| 1018 |
+
"unassigned_damages": [], "multi_part_damages": [],
|
| 1019 |
+
})
|
| 1020 |
+
failed_indices.append(i)
|
| 1021 |
+
continue
|
| 1022 |
+
logger.exception("ML analyze hatasi index=%d: %s", i, e)
|
| 1023 |
+
if len(files) == 1:
|
| 1024 |
+
raise HTTPException(status_code=500, detail=f"Analiz hatasi: {e}")
|
| 1025 |
+
per_image.append({
|
| 1026 |
+
"index": i, "url": url, "status": "failed",
|
| 1027 |
+
"error": f"Analiz hatasi: {e}",
|
| 1028 |
+
"image": {"url": url}, "parts": [], "summary": {},
|
| 1029 |
+
"unassigned_damages": [], "multi_part_damages": [],
|
| 1030 |
+
})
|
| 1031 |
+
failed_indices.append(i)
|
| 1032 |
+
continue
|
| 1033 |
if isinstance(r, dict):
|
| 1034 |
img_blk = r.get("image") if isinstance(r.get("image"), dict) else {}
|
|
|
|
| 1035 |
if (not img_blk.get("url")) or img_blk.get("url") == "<inline>":
|
| 1036 |
img_blk["url"] = url
|
| 1037 |
r["image"] = img_blk
|
| 1038 |
results.append(r)
|
|
|
|
| 1039 |
per_image.append({
|
| 1040 |
"index": i,
|
| 1041 |
"url": url,
|
|
|
|
| 1047 |
"multi_part_damages": (r.get("multi_part_damages") if isinstance(r, dict) else []) or [],
|
| 1048 |
})
|
| 1049 |
|
| 1050 |
+
# En az 1 sonuc yoksa toplu fail; sıralamayı sayfanın bozulmaması için
|
| 1051 |
+
# results bos olsa bile per_image listesi UI'a doner.
|
| 1052 |
+
if not results:
|
| 1053 |
+
raise HTTPException(status_code=500, detail=(
|
| 1054 |
+
f"Hicbir goruntu analiz edilemedi ({len(failed_indices)} adet basarisiz)"
|
| 1055 |
+
))
|
| 1056 |
+
|
| 1057 |
+
try:
|
| 1058 |
+
aggregated = aggregate_results(results) if len(results) > 1 else dict(results[0])
|
| 1059 |
+
except Exception as e: # noqa: BLE001
|
| 1060 |
+
logger.exception("aggregate_results crash: %s", e)
|
| 1061 |
+
# Fallback: ilk sonucu ham dondur, per_image kullanici icin yeterli
|
| 1062 |
+
aggregated = dict(results[0])
|
| 1063 |
+
aggregated["_aggregation_error"] = str(e)
|
| 1064 |
# inspection_id'yi zorla yerlestir
|
| 1065 |
aggregated["inspection_id"] = inspection_id
|
| 1066 |
aggregated["images"] = per_image
|
|
|
|
| 1329 |
iu = r.get("image_urls")
|
| 1330 |
if isinstance(iu, list) and iu:
|
| 1331 |
cand = iu[0]
|
| 1332 |
+
if isinstance(cand, str) and cand and cand != "<inline>" \
|
| 1333 |
+
and not cand.startswith("local://"):
|
| 1334 |
thumb = cand
|
| 1335 |
|
| 1336 |
inspection_id_val = r["id"] if "id" in r else r.get("inspection_id")
|
|
@@ -25,6 +25,7 @@ import logging
|
|
| 25 |
import sys
|
| 26 |
import threading
|
| 27 |
import time
|
|
|
|
| 28 |
from pathlib import Path
|
| 29 |
from typing import Any, Optional
|
| 30 |
|
|
@@ -340,8 +341,9 @@ class MLPipeline:
|
|
| 340 |
"""Pretrained Roboflow scratch_dent v3 modelini HTTP API ile cagir.
|
| 341 |
|
| 342 |
Custom pipeline atlatilir; ne damage modeli ne parts ne severity
|
| 343 |
-
yuklenir.
|
| 344 |
-
|
|
|
|
| 345 |
"""
|
| 346 |
import cv2 # local import
|
| 347 |
from roboflow_inference import ( # type: ignore
|
|
@@ -361,30 +363,106 @@ class MLPipeline:
|
|
| 361 |
raise RuntimeError("Goruntu JPEG encode edilemedi")
|
| 362 |
img_bytes = buf.tobytes()
|
| 363 |
|
| 364 |
-
|
| 365 |
img_bytes,
|
| 366 |
workspace="carpro",
|
| 367 |
project="car-scratch-and-dent",
|
| 368 |
version=3,
|
| 369 |
)
|
| 370 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 371 |
return {
|
| 372 |
-
"
|
|
|
|
| 373 |
"parts": [],
|
| 374 |
-
"
|
|
|
|
| 375 |
"summary": {
|
| 376 |
-
"
|
| 377 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 378 |
"model_source": "roboflow",
|
| 379 |
"note": (
|
| 380 |
"Roboflow scratch_dent v3 hosted inference. "
|
| 381 |
-
"
|
| 382 |
-
"sadece basit hasar bbox tespiti."
|
| 383 |
),
|
| 384 |
},
|
|
|
|
| 385 |
"model_versions": {
|
| 386 |
"pretrained_source": "roboflow_cardd_scratch_dent_v3",
|
|
|
|
| 387 |
},
|
|
|
|
| 388 |
}
|
| 389 |
|
| 390 |
|
|
@@ -533,29 +611,37 @@ def check_model_files_available(model_id: Optional[str]) -> tuple[bool, str]:
|
|
| 533 |
if not model_id or model_id == DEFAULT_MODEL_ID:
|
| 534 |
return True, ""
|
| 535 |
try:
|
| 536 |
-
|
| 537 |
-
|
| 538 |
-
|
| 539 |
-
|
| 540 |
-
|
| 541 |
-
|
| 542 |
-
|
| 543 |
-
|
| 544 |
-
|
| 545 |
-
|
| 546 |
-
|
| 547 |
-
|
| 548 |
-
|
| 549 |
-
|
| 550 |
-
|
| 551 |
-
|
| 552 |
-
|
| 553 |
-
|
| 554 |
-
|
| 555 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 556 |
except Exception as exc: # noqa: BLE001
|
| 557 |
logger.warning("check_model_files_available hata: %s", exc)
|
| 558 |
-
return True, ""
|
| 559 |
|
| 560 |
|
| 561 |
def resolve_model_id(model_id: Optional[str]) -> str:
|
|
|
|
| 25 |
import sys
|
| 26 |
import threading
|
| 27 |
import time
|
| 28 |
+
from datetime import datetime, timezone
|
| 29 |
from pathlib import Path
|
| 30 |
from typing import Any, Optional
|
| 31 |
|
|
|
|
| 341 |
"""Pretrained Roboflow scratch_dent v3 modelini HTTP API ile cagir.
|
| 342 |
|
| 343 |
Custom pipeline atlatilir; ne damage modeli ne parts ne severity
|
| 344 |
+
yuklenir. Frontend Inspection schema uyumlu bir cikti uretir:
|
| 345 |
+
Roboflow detection'lari `unassigned_damages` listesine konur
|
| 346 |
+
(parts boş kalir, schema match olur).
|
| 347 |
"""
|
| 348 |
import cv2 # local import
|
| 349 |
from roboflow_inference import ( # type: ignore
|
|
|
|
| 363 |
raise RuntimeError("Goruntu JPEG encode edilemedi")
|
| 364 |
img_bytes = buf.tobytes()
|
| 365 |
|
| 366 |
+
raw_damages = run_roboflow_damage_inference(
|
| 367 |
img_bytes,
|
| 368 |
workspace="carpro",
|
| 369 |
project="car-scratch-and-dent",
|
| 370 |
version=3,
|
| 371 |
)
|
| 372 |
|
| 373 |
+
# TR ceviri + frontend Damage schema'sina map et
|
| 374 |
+
TR = {"dent": "Göçük", "scratch": "Çizik", "damage": "Hasar"}
|
| 375 |
+
SEVERITY_TR = {"hafif": "Hafif", "orta": "Orta", "agir": "Ağır"}
|
| 376 |
+
|
| 377 |
+
unassigned: list[dict[str, Any]] = []
|
| 378 |
+
for d in raw_damages:
|
| 379 |
+
x1, y1, x2, y2 = d.get("bbox", [0, 0, 0, 0])
|
| 380 |
+
bbox_w = max(0.0, x2 - x1)
|
| 381 |
+
bbox_h = max(0.0, y2 - y1)
|
| 382 |
+
area_ratio = (bbox_w * bbox_h) / max(1.0, float(w) * float(h))
|
| 383 |
+
# Basit kural-tabanli severity (confidence + alan)
|
| 384 |
+
conf = float(d.get("confidence", 0.0))
|
| 385 |
+
if area_ratio > 0.15 or conf > 0.85:
|
| 386 |
+
sev_level, sev_conf = "orta", 0.5
|
| 387 |
+
elif area_ratio > 0.05 or conf > 0.7:
|
| 388 |
+
sev_level, sev_conf = "hafif", 0.5
|
| 389 |
+
else:
|
| 390 |
+
sev_level, sev_conf = "hafif", 0.4
|
| 391 |
+
|
| 392 |
+
dtype = d.get("class") or "damage"
|
| 393 |
+
unassigned.append({
|
| 394 |
+
"id": d.get("id", 0),
|
| 395 |
+
"type": dtype,
|
| 396 |
+
"type_tr": TR.get(dtype, dtype.title()),
|
| 397 |
+
"confidence": conf,
|
| 398 |
+
"bbox": d.get("bbox", [0, 0, 0, 0]),
|
| 399 |
+
"polygon": d.get("polygon", []) or [],
|
| 400 |
+
"polygon_normalized": [], # detection-only model
|
| 401 |
+
"area_ratio": area_ratio,
|
| 402 |
+
"severity": {
|
| 403 |
+
"level": sev_level,
|
| 404 |
+
"level_tr": SEVERITY_TR.get(sev_level, sev_level.title()),
|
| 405 |
+
"confidence": sev_conf,
|
| 406 |
+
"method": "rule_based_roboflow",
|
| 407 |
+
},
|
| 408 |
+
"cost": {
|
| 409 |
+
"min_tl": 0,
|
| 410 |
+
"max_tl": 0,
|
| 411 |
+
"confidence": "low",
|
| 412 |
+
"source": "roboflow_no_cost",
|
| 413 |
+
},
|
| 414 |
+
"is_multi_part": False,
|
| 415 |
+
"is_low_confidence_match": False,
|
| 416 |
+
"source": "roboflow",
|
| 417 |
+
})
|
| 418 |
+
|
| 419 |
+
total_damage = len(unassigned)
|
| 420 |
+
most_severe = None
|
| 421 |
+
most_severe_tr = None
|
| 422 |
+
if unassigned:
|
| 423 |
+
# En agir level'i bul
|
| 424 |
+
order = {"hafif": 1, "orta": 2, "agir": 3}
|
| 425 |
+
top = max(unassigned, key=lambda u: order.get(u["severity"]["level"], 0))
|
| 426 |
+
most_severe = top["severity"]["level"]
|
| 427 |
+
most_severe_tr = top["severity"]["level_tr"]
|
| 428 |
+
|
| 429 |
return {
|
| 430 |
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
| 431 |
+
"image": {"url": None, "width": int(w), "height": int(h)},
|
| 432 |
"parts": [],
|
| 433 |
+
"unassigned_damages": unassigned,
|
| 434 |
+
"multi_part_damages": [],
|
| 435 |
"summary": {
|
| 436 |
+
"total_parts_inspected": 0,
|
| 437 |
+
"damaged_parts_count": 0,
|
| 438 |
+
"clean_parts_count": 0,
|
| 439 |
+
"total_damage_count": total_damage,
|
| 440 |
+
"unknown_part_damages_count": total_damage,
|
| 441 |
+
"multi_part_damages_count": 0,
|
| 442 |
+
"most_severe_level": most_severe,
|
| 443 |
+
"most_severe_level_tr": most_severe_tr,
|
| 444 |
+
"total_damage_area_ratio": sum(u["area_ratio"] for u in unassigned),
|
| 445 |
+
"total_cost_range_tl": [0, 0],
|
| 446 |
+
"total_cost_midpoint_tl": 0,
|
| 447 |
+
"cost_confidence": "low",
|
| 448 |
+
"repair_recommendation": "manual_review" if unassigned else "hasar_yok",
|
| 449 |
+
"repair_recommendation_tr": (
|
| 450 |
+
"Manuel inceleme önerilir (Roboflow: maliyet yok)"
|
| 451 |
+
if unassigned else "Hasar tespit edilmedi"
|
| 452 |
+
),
|
| 453 |
+
"estimated_repair_days": 1 if unassigned else 0,
|
| 454 |
"model_source": "roboflow",
|
| 455 |
"note": (
|
| 456 |
"Roboflow scratch_dent v3 hosted inference. "
|
| 457 |
+
"Parça segmentasyonu / maliyet hesaplaması yapılmaz."
|
|
|
|
| 458 |
),
|
| 459 |
},
|
| 460 |
+
"visualization_urls": {"annotated": None, "parts": None, "damages": None},
|
| 461 |
"model_versions": {
|
| 462 |
"pretrained_source": "roboflow_cardd_scratch_dent_v3",
|
| 463 |
+
"requested_model": "pretrained_roboflow_cardd",
|
| 464 |
},
|
| 465 |
+
"model_source": "roboflow",
|
| 466 |
}
|
| 467 |
|
| 468 |
|
|
|
|
| 611 |
if not model_id or model_id == DEFAULT_MODEL_ID:
|
| 612 |
return True, ""
|
| 613 |
try:
|
| 614 |
+
# Registry'den entry'i al — modul `get_registry()` veya direkt erisim
|
| 615 |
+
# destekliyor. REGISTRY sembolu yok; eski kodda hata kaynagiydi.
|
| 616 |
+
from pretrained_registry import get_registry # type: ignore
|
| 617 |
+
reg = get_registry()
|
| 618 |
+
entry = None
|
| 619 |
+
# PretrainedRegistry sinifinda get() metodu var
|
| 620 |
+
if hasattr(reg, "get"):
|
| 621 |
+
entry = reg.get(model_id)
|
| 622 |
+
# Eger entry uygunsa dosya/key check
|
| 623 |
+
if entry is not None and hasattr(entry, "is_available"):
|
| 624 |
+
if not entry.is_available():
|
| 625 |
+
return False, (
|
| 626 |
+
f"'{getattr(entry, 'name', model_id)}' modeli bu deploy'da "
|
| 627 |
+
"indirilmemis. Lutfen 'Kendi Modellerim' (custom) "
|
| 628 |
+
"secenegini kullanin."
|
| 629 |
+
)
|
| 630 |
+
return True, ""
|
| 631 |
+
# Source-level model_id ise (pretrained_roboflow_cardd vs)
|
| 632 |
+
for m in list_available_models():
|
| 633 |
+
if m.get("id") == model_id:
|
| 634 |
+
if m.get("available") is False:
|
| 635 |
+
return False, (
|
| 636 |
+
f"'{m.get('name', model_id)}' modeli bu deploy'da "
|
| 637 |
+
"indirilmemis. Lutfen 'Kendi Modellerim' (custom) "
|
| 638 |
+
"secenegini kullanin."
|
| 639 |
+
)
|
| 640 |
+
return True, ""
|
| 641 |
+
return False, f"Model bulunamadi: {model_id}"
|
| 642 |
except Exception as exc: # noqa: BLE001
|
| 643 |
logger.warning("check_model_files_available hata: %s", exc)
|
| 644 |
+
return True, "" # fail-open; gerçek inference patlarsa orada handle
|
| 645 |
|
| 646 |
|
| 647 |
def resolve_model_id(model_id: Optional[str]) -> str:
|
|
@@ -36,10 +36,17 @@ logger = logging.getLogger(__name__)
|
|
| 36 |
|
| 37 |
# ============================ Celery app ============================
|
| 38 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
celery_app = Celery(
|
| 40 |
"arac_hasar",
|
| 41 |
-
broker=
|
| 42 |
-
backend=
|
| 43 |
)
|
| 44 |
|
| 45 |
celery_app.conf.update(
|
|
@@ -48,15 +55,17 @@ celery_app.conf.update(
|
|
| 48 |
result_serializer="json",
|
| 49 |
timezone="Europe/Istanbul",
|
| 50 |
enable_utc=True,
|
| 51 |
-
# Limitler — uzun pipeline'lara karsi guvenli
|
| 52 |
task_soft_time_limit=180,
|
| 53 |
task_time_limit=240,
|
| 54 |
task_acks_late=True,
|
| 55 |
task_reject_on_worker_lost=True,
|
| 56 |
-
worker_prefetch_multiplier=1,
|
| 57 |
-
worker_max_tasks_per_child=50,
|
| 58 |
broker_connection_retry_on_startup=True,
|
| 59 |
)
|
|
|
|
|
|
|
|
|
|
| 60 |
|
| 61 |
|
| 62 |
# ============================ Pub-sub helper ============================
|
|
|
|
| 36 |
|
| 37 |
# ============================ Celery app ============================
|
| 38 |
|
| 39 |
+
_broker_url = settings.redis_url
|
| 40 |
+
# Upstash/managed Redis genelde rediss:// (TLS) — kombu default insecure
|
| 41 |
+
# warning veriyor + 'ssl_cert_reqs' eksik error firlatiyor. Explicit set.
|
| 42 |
+
import ssl as _ssl
|
| 43 |
+
_uses_tls = _broker_url.startswith("rediss://")
|
| 44 |
+
_ssl_opts = {"ssl_cert_reqs": _ssl.CERT_NONE} if _uses_tls else None
|
| 45 |
+
|
| 46 |
celery_app = Celery(
|
| 47 |
"arac_hasar",
|
| 48 |
+
broker=_broker_url,
|
| 49 |
+
backend=_broker_url,
|
| 50 |
)
|
| 51 |
|
| 52 |
celery_app.conf.update(
|
|
|
|
| 55 |
result_serializer="json",
|
| 56 |
timezone="Europe/Istanbul",
|
| 57 |
enable_utc=True,
|
|
|
|
| 58 |
task_soft_time_limit=180,
|
| 59 |
task_time_limit=240,
|
| 60 |
task_acks_late=True,
|
| 61 |
task_reject_on_worker_lost=True,
|
| 62 |
+
worker_prefetch_multiplier=1,
|
| 63 |
+
worker_max_tasks_per_child=50,
|
| 64 |
broker_connection_retry_on_startup=True,
|
| 65 |
)
|
| 66 |
+
if _ssl_opts:
|
| 67 |
+
celery_app.conf.broker_use_ssl = _ssl_opts
|
| 68 |
+
celery_app.conf.redis_backend_use_ssl = _ssl_opts
|
| 69 |
|
| 70 |
|
| 71 |
# ============================ Pub-sub helper ============================
|