jayvatliq commited on
Commit
6e73df5
·
verified ·
1 Parent(s): 7de6eca

Upload folder using huggingface_hub

Browse files
.DS_Store ADDED
Binary file (6.15 kB). View file
 
.dockerignore ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .venv
2
+ __pycache__
3
+ *.pyc
4
+ *.pyo
5
+ .git
6
+ .gitignore
7
+ .gitattributes
8
+ .DS_Store
9
+ images_car
10
+ output_files
11
+ README.md
Dockerfile ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.13-slim
2
+
3
+ RUN apt-get update && apt-get install -y --no-install-recommends \
4
+ libgl1 \
5
+ libglib2.0-0 \
6
+ libgomp1 \
7
+ && rm -rf /var/lib/apt/lists/*
8
+
9
+ WORKDIR /app
10
+
11
+ COPY requirements.txt .
12
+ RUN pip install --no-cache-dir --upgrade pip && \
13
+ pip install --no-cache-dir -r requirements.txt && \
14
+ pip install --no-cache-dir python-multipart
15
+
16
+ COPY . .
17
+
18
+ RUN mkdir -p /app/output_files
19
+
20
+ EXPOSE 8000
21
+
22
+ CMD ["uvicorn", "OutputParser:app", "--host", "0.0.0.0", "--port", "8000"]
Images_car/0.jpeg ADDED
Images_car/1.jpeg ADDED
Images_car/10.jpeg ADDED
Images_car/11.jpeg ADDED
Images_car/12.jpeg ADDED
Images_car/13.jpeg ADDED
Images_car/14.jpeg ADDED
Images_car/15.jpeg ADDED
Images_car/16.jpeg ADDED
Images_car/17.jpeg ADDED
Images_car/18.jpeg ADDED
Images_car/19.jpeg ADDED
Images_car/2.jpeg ADDED
Images_car/20.jpeg ADDED
Images_car/21.jpeg ADDED
Images_car/22.jpeg ADDED
Images_car/3.jpeg ADDED
Images_car/4.jpeg ADDED
Images_car/5.jpeg ADDED
Images_car/6.jpeg ADDED
Images_car/7.jpeg ADDED
Images_car/8.jpeg ADDED
Images_car/9.jpeg ADDED
OutputParser/__init__.py ADDED
@@ -0,0 +1,131 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import time
3
+ from io import BytesIO
4
+ from typing import List, Optional
5
+
6
+ import fastapi
7
+ from app.dependencies.yolo_classification import yolo_rule_based_classification
8
+ from dotenv import load_dotenv
9
+ from fastapi import Depends, File, HTTPException, UploadFile
10
+ from fastapi.middleware.cors import CORSMiddleware
11
+ from fastapi.staticfiles import StaticFiles
12
+ from PIL import Image
13
+ from PIL.ImageFile import ImageFile
14
+ from ultralytics import YOLO
15
+
16
+ load_dotenv()
17
+ app = fastapi.FastAPI()
18
+
19
+ part_detection_model = YOLO(r"car_part.pt")
20
+ car_detection_model = YOLO(r"car_detection.pt")
21
+
22
+ _default_origins = ",".join(
23
+ [
24
+ "http://localhost:8080",
25
+ "http://localhost:8081",
26
+ "http://localhost:8082",
27
+ "https://dusk-upload-suite.lovable.app",
28
+ ]
29
+ )
30
+ _cors_origins = [
31
+ o.strip()
32
+ for o in os.getenv("CORS_ORIGINS", _default_origins).split(",")
33
+ if o.strip()
34
+ ]
35
+
36
+ app.add_middleware(
37
+ CORSMiddleware,
38
+ allow_origins=_cors_origins,
39
+ allow_credentials=True,
40
+ allow_methods=["*"],
41
+ allow_headers=["*"],
42
+ )
43
+
44
+
45
+ BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
46
+ OUTPUT_DIR = os.path.join(BASE_DIR, "output_files")
47
+
48
+ app.mount("/files", StaticFiles(directory=OUTPUT_DIR), name="files")
49
+
50
+
51
+ @app.post("/api/classify-img")
52
+ async def classify_message(
53
+ files: List[UploadFile] = File(...),
54
+ ):
55
+ try:
56
+ print("FILES RECEIVED", files)
57
+ imgs = await get_imgs(files)
58
+ print("imgs = ", imgs)
59
+ img_results = []
60
+ for img in imgs:
61
+ try:
62
+ rule_based_view_type, review, final_scores, file_details = (
63
+ await yolo_rule_based_classification(
64
+ img["pil_img"],
65
+ img["file_name"],
66
+ img["img"],
67
+ )
68
+ )
69
+ # detections = {
70
+ # "car_detection": {
71
+ # "image_array": image_array,
72
+ # "detections_to_plot": car_detections_to_plot,
73
+ # "title": "Car Detections",
74
+ # },
75
+ # "part_detection": {
76
+ # "image_array": image_array,
77
+ # "detections_to_plot": part_detections_to_plot,
78
+ # "title": "Part Detections",
79
+ # },
80
+ # }
81
+
82
+ # detections = {
83
+ # "car_detection": {
84
+ # # "image_array": image_array,
85
+ # "detections_to_plot": final_detections["car_detection"][
86
+ # "detections_to_plot"
87
+ # ],
88
+ # "title": "Car Detections",
89
+ # },
90
+ # "part_detection": {
91
+ # # "image_array": image_array,
92
+ # "detections_to_plot": final_detections["part_detection"][
93
+ # "detections_to_plot"
94
+ # ],
95
+ # "title": "Part Detections",
96
+ # },
97
+ # }
98
+
99
+ img_results.append(
100
+ {
101
+ "rule_based_view_type": rule_based_view_type,
102
+ "review": review,
103
+ "final_scores": final_scores,
104
+ "error": None,
105
+ "file_details": file_details,
106
+ }
107
+ )
108
+ except Exception as e:
109
+ print("INSIDE ERROR DUDE", e)
110
+ img_results.append({"error": e})
111
+ print("img_results = ", img_results)
112
+ return img_results
113
+ except Exception as e:
114
+ print("ERROR _ ", e)
115
+ return {"error": str(e)}
116
+
117
+
118
+ async def handleViewTypeOrientation():
119
+ pass
120
+
121
+
122
+ async def get_imgs(files: List[UploadFile]) -> list[dict]:
123
+ imgs = []
124
+ for each in files:
125
+ resp = await each.read()
126
+ image_bytes = BytesIO(resp)
127
+ img = Image.open(image_bytes)
128
+ imgs.append({"img": each, "pil_img": img, "file_name": each.filename})
129
+ each.seek(0)
130
+ print("IMAGES = ", imgs)
131
+ return imgs
README.md CHANGED
@@ -1,10 +1,36 @@
1
  ---
2
  title: Car View Classifier
3
- emoji: 🏃
4
  colorFrom: blue
5
- colorTo: yellow
6
  sdk: docker
 
7
  pinned: false
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
  title: Car View Classifier
3
+ emoji: 🚗
4
  colorFrom: blue
5
+ colorTo: purple
6
  sdk: docker
7
+ app_port: 8000
8
  pinned: false
9
  ---
10
 
11
+ # Car View Classifier — Backend
12
+
13
+ FastAPI + YOLO service that classifies car images by viewing angle.
14
+
15
+ ## Endpoints
16
+
17
+ - `POST /api/classify-img` — multipart upload (`files`), returns
18
+ classification + annotated image paths
19
+ - `GET /files/...` — static serving for annotated detection images
20
+
21
+ ## Configuration
22
+
23
+ Environment variables:
24
+
25
+ - `CORS_ORIGINS` — comma-separated list of allowed origins (set this
26
+ to your frontend URL when deploying)
27
+
28
+ ## Local development
29
+
30
+ From the repo root:
31
+
32
+ ```bash
33
+ docker compose up --build
34
+ ```
35
+
36
+ Backend will be available at <http://localhost:8000>.
app/dependencies/yolo_classification.py ADDED
@@ -0,0 +1,1389 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import math
2
+ import os
3
+ import shutil
4
+ from datetime import datetime
5
+
6
+ import cv2
7
+ import matplotlib.pyplot as plt
8
+ import numpy as np
9
+ from fastapi import UploadFile
10
+ from PIL import Image
11
+ from ultralytics import YOLO
12
+
13
+ car_detection_model = YOLO(r"car_detection.pt")
14
+ part_detection_model = YOLO(r"car_part.pt")
15
+
16
+
17
+ MIN_RATIO = {
18
+ # Front
19
+ "Front-bumper": 0.015,
20
+ "Grille": 0.010,
21
+ "Headlight": 0.005, # small but important
22
+ "Hood": 0.020,
23
+ "License-plate": 0.002, # very small
24
+ # Side
25
+ "Front-door": 0.030,
26
+ "Back-door": 0.030,
27
+ "Front-wheel": 0.010,
28
+ "Back-wheel": 0.010,
29
+ "Mirror": 0.001, # small but always relevant
30
+ "Quarter-panel": 0.010,
31
+ "Rocker-panel": 0.010,
32
+ "Roof": 0.020,
33
+ # Windows
34
+ "Windshield": 0.020,
35
+ "Front-window": 0.015,
36
+ "Back-window": 0.015,
37
+ "Back-windshield": 0.020,
38
+ # Rear
39
+ "Back-bumper": 0.015,
40
+ "Tail-light": 0.005, # small but important
41
+ "Trunk": 0.020,
42
+ # Catch-all fallback
43
+ "default": 0.01,
44
+ }
45
+
46
+ # Load the models
47
+
48
+
49
+ # Define viewing angle rules for scoring (using only the available classes)
50
+ viewing_angle_rules = {
51
+ # "Front": {
52
+ # "must_be_visible": ["Front-bumper", "Grille", "Headlight", "Windshield", "License-plate", "Mirror", "Hood"],
53
+ # "optional_parts": ["Front-wheel", "Front-window", "Fender", "Quarter-panel", "Rocker-panel"],
54
+ # "conflict_parts": ["Tail-light", "Back-bumper", "Back-window", "Back-windshield", "Back-wheel", "Trunk"]
55
+ # },
56
+ "Front Right": {
57
+ "must_be_visible": [
58
+ "Front-bumper",
59
+ "Grille",
60
+ "Headlight",
61
+ "Front-wheel",
62
+ "Windshield",
63
+ "Front-door",
64
+ "Front-window",
65
+ "Fender",
66
+ "Mirror",
67
+ "Rocker-panel",
68
+ ],
69
+ "optional_parts": [
70
+ "Hood",
71
+ "Roof",
72
+ "Back-door",
73
+ "Back-wheel",
74
+ "Back-window",
75
+ "Quarter-panel",
76
+ ],
77
+ "conflict_parts": ["Tail-light", "Back-bumper", "Back-windshield", "Trunk"],
78
+ },
79
+ "Right": {
80
+ "must_be_visible": [
81
+ "Front-door",
82
+ "Back-door",
83
+ "Mirror",
84
+ "Quarter-panel",
85
+ "Fender",
86
+ "Rocker-panel",
87
+ "Front-wheel",
88
+ "Back-wheel",
89
+ "Back-window",
90
+ "Front-window",
91
+ ],
92
+ "optional_parts": [
93
+ "Roof",
94
+ "Front-bumper",
95
+ "Back-bumper",
96
+ "Headlight",
97
+ "Tail-light",
98
+ "Hood",
99
+ "Back-windshield",
100
+ ],
101
+ "conflict_parts": ["Grille", "Trunk", "Windshield", "License-plate"],
102
+ },
103
+ "Rear Right": {
104
+ "must_be_visible": [
105
+ "Back-bumper",
106
+ "Tail-light",
107
+ "Back-wheel",
108
+ "Back-door",
109
+ "Back-window",
110
+ "Quarter-panel",
111
+ "Back-windshield",
112
+ "Rocker-panel",
113
+ "Trunk",
114
+ ],
115
+ "optional_parts": [
116
+ "Roof",
117
+ "License-plate",
118
+ "Front-wheel",
119
+ "Front-door",
120
+ "Fender",
121
+ "Mirror",
122
+ "Front-window",
123
+ ],
124
+ "conflict_parts": ["Front-bumper", "Headlight", "Grille", "Windshield", "Hood"],
125
+ },
126
+ # "Rear": {
127
+ # "must_be_visible": ["Back-bumper", "Tail-light", "Trunk", "Back-windshield", "License-plate", "Roof"],
128
+ # "optional_parts": ["Back-window", "Rocker-panel", "Mirror", "Back-wheel", "Back-door"],
129
+ # "conflict_parts": ["Front-bumper", "Headlight", "Grille", "Front-wheel", "Front-door", "Windshield", "Hood", "Fender", "Quarter-panel", "Front-window"]
130
+ # },
131
+ "Rear Left": {
132
+ "must_be_visible": [
133
+ "Back-bumper",
134
+ "Tail-light",
135
+ "Back-wheel",
136
+ "Back-door",
137
+ "Back-window",
138
+ "Quarter-panel",
139
+ "Back-windshield",
140
+ "Rocker-panel",
141
+ "Trunk",
142
+ ],
143
+ "optional_parts": [
144
+ "Roof",
145
+ "License-plate",
146
+ "Front-wheel",
147
+ "Front-door",
148
+ "Fender",
149
+ "Mirror",
150
+ "Front-window",
151
+ ],
152
+ "conflict_parts": ["Front-bumper", "Headlight", "Grille", "Windshield", "Hood"],
153
+ },
154
+ "Left": {
155
+ "must_be_visible": [
156
+ "Front-door",
157
+ "Back-door",
158
+ "Mirror",
159
+ "Quarter-panel",
160
+ "Fender",
161
+ "Rocker-panel",
162
+ "Front-wheel",
163
+ "Back-wheel",
164
+ "Back-window",
165
+ "Front-window",
166
+ ],
167
+ "optional_parts": [
168
+ "Roof",
169
+ "Front-bumper",
170
+ "Back-bumper",
171
+ "Headlight",
172
+ "Tail-light",
173
+ "Hood",
174
+ "Back-windshield",
175
+ ],
176
+ "conflict_parts": ["Grille", "Trunk", "Windshield", "License-plate"],
177
+ },
178
+ "Front Left": {
179
+ "must_be_visible": [
180
+ "Front-bumper",
181
+ "Grille",
182
+ "Headlight",
183
+ "Front-wheel",
184
+ "Windshield",
185
+ "Front-door",
186
+ "Front-window",
187
+ "Fender",
188
+ "Mirror",
189
+ "Rocker-panel",
190
+ ],
191
+ "optional_parts": [
192
+ "Hood",
193
+ "Roof",
194
+ "Back-door",
195
+ "Back-wheel",
196
+ "Back-window",
197
+ "Quarter-panel",
198
+ ],
199
+ "conflict_parts": ["Tail-light", "Back-bumper", "Back-windshield", "Trunk"],
200
+ },
201
+ }
202
+
203
+
204
+ def compute_direction_mirror_refined(detected, fixed_label="Mirror"):
205
+ """
206
+ Compute vehicle side direction using the Mirror as the reference.
207
+
208
+ Group A: ["Windshield", "Hood", "Headlight", "Front-bumper", "Front-wheel"]
209
+ * If these parts are to the right of the Mirror (positive x-offset), they vote "Right side view".
210
+ * If to the left, they vote "Left side view".
211
+ Group B: ["Back-wheel", "Back-door", "Quarter-panel", "Rocker-panel", "Back-window"]
212
+ * If these parts are to the left of the Mirror (negative x-offset), they vote "Right side view".
213
+ * If to the right, they vote "Left side view".
214
+ """
215
+ if fixed_label not in detected:
216
+ # print(f"Reference label '{fixed_label}' not detected.")
217
+ return "Unknown", None, None
218
+
219
+ mirror_box = detected[fixed_label][0]
220
+ mirror_center = (
221
+ (mirror_box[0] + mirror_box[2]) / 2,
222
+ (mirror_box[1] + mirror_box[3]) / 2,
223
+ )
224
+
225
+ groupA = ["Windshield", "Hood", "Headlight", "Front-bumper", "Front-wheel"]
226
+ groupB = ["Back-wheel", "Back-door", "Quarter-panel", "Rocker-panel", "Back-window"]
227
+
228
+ offsets_A = []
229
+ offsets_B = []
230
+ radial_lines = []
231
+
232
+ for part in groupA:
233
+ if part in detected:
234
+ for box in detected[part]:
235
+ part_center = ((box[0] + box[2]) / 2, (box[1] + box[3]) / 2)
236
+ offset = part_center[0] - mirror_center[0]
237
+ offsets_A.append(offset)
238
+ radial_lines.append((mirror_center, part_center))
239
+ # print(f"{part} center: {part_center}, x-offset from Mirror: {offset}")
240
+
241
+ for part in groupB:
242
+ if part in detected:
243
+ for box in detected[part]:
244
+ part_center = ((box[0] + box[2]) / 2, (box[1] + box[3]) / 2)
245
+ offset = part_center[0] - mirror_center[0]
246
+ offsets_B.append(offset)
247
+ radial_lines.append((mirror_center, part_center))
248
+ # print(f"{part} center: {part_center}, x-offset from Mirror: {offset}")
249
+
250
+ voteA = None
251
+ voteB = None
252
+
253
+ if offsets_A:
254
+ avg_A = sum(offsets_A) / len(offsets_A)
255
+ voteA = "Right" if avg_A > 0 else "Left"
256
+
257
+ if offsets_B:
258
+ avg_B = sum(offsets_B) / len(offsets_B)
259
+ voteB = "Right" if avg_B < 0 else "Left"
260
+
261
+ # NEW LOGIC
262
+ if voteA and voteB:
263
+ if voteA == voteB:
264
+ direction = f"{voteA} side view"
265
+ reason = f"Both groups agreed on {voteA} side view."
266
+ else:
267
+ # Conflict → prioritize group with more datapoints
268
+ if len(offsets_A) > len(offsets_B):
269
+ direction = f"{voteA} side view"
270
+ reason = f"Conflict: Group A ({len(offsets_A)}) vs Group B ({len(offsets_B)}). Prioritizing Group A due to more datapoints."
271
+ elif len(offsets_B) > len(offsets_A):
272
+ direction = f"{voteB} side view"
273
+ reason = f"Conflict: Group A ({len(offsets_A)}) vs Group B ({len(offsets_B)}). Prioritizing Group B due to more datapoints."
274
+ else:
275
+ # Equal datapoints → default to Group B (your old rule)
276
+ direction = f"{voteB} side view"
277
+ reason = f"Equal datapoints. Falling back to Group B’s vote ({voteB})."
278
+ elif voteA:
279
+ # Only Group A has datapoints
280
+ direction = f"{voteA} side view"
281
+ reason = f"Only Group A datapoints ({len(offsets_A)}). Voting {voteA}."
282
+ elif voteB:
283
+ # Only Group B has datapoints
284
+ direction = f"{voteB} side view"
285
+ reason = f"Only Group B datapoints ({len(offsets_B)}). Voting {voteB}."
286
+ else:
287
+ direction = "Unknown"
288
+ reason = "No sufficient data."
289
+
290
+ # --- CONSENSUS FLAG LOGIC ---
291
+ if voteA and voteB:
292
+ consensus = voteA == voteB # True if same, False if conflict
293
+ elif voteA or voteB:
294
+ consensus = True # only one group voted
295
+ else:
296
+ consensus = None # no votes at al
297
+
298
+ # print(f"[Mirror] Final guess: {direction}. Reason: {reason}")
299
+ return direction, radial_lines, consensus
300
+
301
+
302
+ def compute_direction_front_wheel_refined(detected, fixed_label="Front-wheel"):
303
+ """
304
+ Compute vehicle side direction using the Front Wheel as the reference.
305
+
306
+ Group A: ["Front-bumper", "Headlight", "Fender", "Grille"]
307
+ * If these parts are to the left of the Front Wheel (negative x-offset), they vote "Left side view".
308
+ * If to the right, they vote "Right side view".
309
+
310
+ Group B: ["Front-door", "Back-door", "Rocker-panel", "Front-window", "Back-window", "Back-wheel", "Quarter-panel", "Mirror", "Windshield"]
311
+ * If these parts are to the right of the Front Wheel (positive x-offset), they vote "Left side view".
312
+ * If to the left, they vote "Right side view".
313
+ """
314
+ if fixed_label not in detected:
315
+ # print(f"Reference label '{fixed_label}' not detected.")
316
+ return "Unknown", None, None
317
+
318
+ front_wheel_box = detected[fixed_label][0]
319
+ front_wheel_center = (
320
+ (front_wheel_box[0] + front_wheel_box[2]) / 2,
321
+ (front_wheel_box[1] + front_wheel_box[3]) / 2,
322
+ )
323
+
324
+ groupA = ["Front-bumper", "Headlight", "Fender", "Grille"]
325
+ groupB = [
326
+ "Front-door",
327
+ "Back-door",
328
+ "Rocker-panel",
329
+ "Front-window",
330
+ "Back-window",
331
+ "Back-wheel",
332
+ "Quarter-panel",
333
+ "Mirror",
334
+ "Windshield",
335
+ ]
336
+
337
+ offsets_A = []
338
+ offsets_B = []
339
+ radial_lines = []
340
+
341
+ for part in groupA:
342
+ if part in detected:
343
+ for box in detected[part]:
344
+ part_center = ((box[0] + box[2]) / 2, (box[1] + box[3]) / 2)
345
+ offset = part_center[0] - front_wheel_center[0]
346
+ offsets_A.append(offset)
347
+ radial_lines.append((front_wheel_center, part_center))
348
+ # print(f"{part} center: {part_center}, x-offset from Front Wheel: {offset}")
349
+
350
+ for part in groupB:
351
+ if part in detected:
352
+ for box in detected[part]:
353
+ part_center = ((box[0] + box[2]) / 2, (box[1] + box[3]) / 2)
354
+ offset = part_center[0] - front_wheel_center[0]
355
+ offsets_B.append(offset)
356
+ radial_lines.append((front_wheel_center, part_center))
357
+ # print(f"{part} center: {part_center}, x-offset from Front Wheel: {offset}")
358
+
359
+ voteA = None
360
+ voteB = None
361
+
362
+ if offsets_A:
363
+ avg_A = sum(offsets_A) / len(offsets_A)
364
+ voteA = "Right" if avg_A > 0 else "Left"
365
+
366
+ if offsets_B:
367
+ avg_B = sum(offsets_B) / len(offsets_B)
368
+ voteB = "Right" if avg_B < 0 else "Left"
369
+
370
+ # NEW LOGIC
371
+ if voteA and voteB:
372
+ if voteA == voteB:
373
+ direction = f"{voteA} side view"
374
+ reason = f"Both groups agreed on {voteA} side view."
375
+ else:
376
+ # Conflict → prioritize group with more datapoints
377
+ if len(offsets_A) > len(offsets_B):
378
+ direction = f"{voteA} side view"
379
+ reason = f"Conflict: Group A ({len(offsets_A)}) vs Group B ({len(offsets_B)}). Prioritizing Group A due to more datapoints."
380
+ elif len(offsets_B) > len(offsets_A):
381
+ direction = f"{voteB} side view"
382
+ reason = f"Conflict: Group A ({len(offsets_A)}) vs Group B ({len(offsets_B)}). Prioritizing Group B due to more datapoints."
383
+ else:
384
+ # Equal datapoints → default to Group B (your old rule)
385
+ direction = f"{voteB} side view"
386
+ reason = f"Equal datapoints. Falling back to Group B’s vote ({voteB})."
387
+ elif voteA:
388
+ # Only Group A has datapoints
389
+ direction = f"{voteA} side view"
390
+ reason = f"Only Group A datapoints ({len(offsets_A)}). Voting {voteA}."
391
+ elif voteB:
392
+ # Only Group B has datapoints
393
+ direction = f"{voteB} side view"
394
+ reason = f"Only Group B datapoints ({len(offsets_B)}). Voting {voteB}."
395
+ else:
396
+ direction = "Unknown"
397
+ reason = "No sufficient data."
398
+
399
+ # --- CONSENSUS FLAG LOGIC ---
400
+ if voteA and voteB:
401
+ consensus = voteA == voteB # True if same, False if conflict
402
+ elif voteA or voteB:
403
+ consensus = True # only one group voted
404
+ else:
405
+ consensus = None # no votes at al
406
+
407
+ # print(f"[Front-wheel] Final guess: {direction}. Reason: {reason}")
408
+ return direction, radial_lines, consensus
409
+
410
+
411
+ def compute_direction_back_wheel_refined(detected, fixed_label="Back-wheel"):
412
+ """
413
+ Compute vehicle side direction using the Back Wheel as the reference.
414
+
415
+ Group A: ["Back-bumper", "Tail-light", "Quarter-panel", "Trunk"]
416
+ * If these parts are to the left of the Back Wheel (negative x-offset), they vote "Right side view".
417
+ * If to the right, they vote "Left side view".
418
+
419
+ Group B: ["Front-wheel", "Rocker-panel", "Back-door", "Front-door", "Back-window", "Front-window", "Mirror", "Fender"]
420
+ * If these parts are to the left of the Back Wheel (negative x-offset), they vote "Left side view".
421
+ * If to the right, they vote "Right side view".
422
+ """
423
+ if fixed_label not in detected:
424
+ # print(f"Reference label '{fixed_label}' not detected.")
425
+ return "Unknown", None, None
426
+
427
+ back_wheel_box = detected[fixed_label][0]
428
+ back_wheel_center = (
429
+ (back_wheel_box[0] + back_wheel_box[2]) / 2,
430
+ (back_wheel_box[1] + back_wheel_box[3]) / 2,
431
+ )
432
+
433
+ groupA = ["Back-bumper", "Tail-light", "Quarter-panel", "Trunk"]
434
+ groupB = [
435
+ "Front-wheel",
436
+ "Rocker-panel",
437
+ "Back-door",
438
+ "Front-door",
439
+ "Back-window",
440
+ "Front-window",
441
+ "Mirror",
442
+ "Fender",
443
+ ]
444
+
445
+ offsets_A = []
446
+ offsets_B = []
447
+ radial_lines = []
448
+
449
+ for part in groupA:
450
+ if part in detected:
451
+ for box in detected[part]:
452
+ part_center = ((box[0] + box[2]) / 2, (box[1] + box[3]) / 2)
453
+ offset = part_center[0] - back_wheel_center[0]
454
+ offsets_A.append(offset)
455
+ radial_lines.append((back_wheel_center, part_center))
456
+
457
+ for part in groupB:
458
+ if part in detected:
459
+ for box in detected[part]:
460
+ part_center = ((box[0] + box[2]) / 2, (box[1] + box[3]) / 2)
461
+ offset = part_center[0] - back_wheel_center[0]
462
+ offsets_B.append(offset)
463
+ radial_lines.append((back_wheel_center, part_center))
464
+
465
+ voteA = None
466
+ voteB = None
467
+
468
+ if offsets_A:
469
+ avg_A = sum(offsets_A) / len(offsets_A)
470
+ # For Group A: parts to the left (negative offset) vote "Right side view", else "Left side view"
471
+ voteA = "Right" if avg_A < 0 else "Left"
472
+ if offsets_B:
473
+ avg_B = sum(offsets_B) / len(offsets_B)
474
+ voteB = "Left" if avg_B < 0 else "Right"
475
+
476
+ # NEW LOGIC
477
+ if voteA and voteB:
478
+ if voteA == voteB:
479
+ direction = f"{voteA} side view"
480
+ reason = f"Both groups agreed on {voteA} side view."
481
+ else:
482
+ # Conflict → prioritize group with more datapoints
483
+ if len(offsets_A) > len(offsets_B):
484
+ direction = f"{voteA} side view"
485
+ reason = f"Conflict: Group A ({len(offsets_A)}) vs Group B ({len(offsets_B)}). Prioritizing Group A due to more datapoints."
486
+ elif len(offsets_B) > len(offsets_A):
487
+ direction = f"{voteB} side view"
488
+ reason = f"Conflict: Group A ({len(offsets_A)}) vs Group B ({len(offsets_B)}). Prioritizing Group B due to more datapoints."
489
+ else:
490
+ # Equal datapoints → default to Group B (your old rule)
491
+ direction = f"{voteB} side view"
492
+ reason = f"Equal datapoints. Falling back to Group B’s vote ({voteB})."
493
+ elif voteA:
494
+ # Only Group A has datapoints
495
+ direction = f"{voteA} side view"
496
+ reason = f"Only Group A datapoints ({len(offsets_A)}). Voting {voteA}."
497
+ elif voteB:
498
+ # Only Group B has datapoints
499
+ direction = f"{voteB} side view"
500
+ reason = f"Only Group B datapoints ({len(offsets_B)}). Voting {voteB}."
501
+ else:
502
+ direction = "Unknown"
503
+ reason = "No sufficient data."
504
+
505
+ # --- CONSENSUS FLAG LOGIC ---
506
+ if voteA and voteB:
507
+ consensus = voteA == voteB # True if same, False if conflict
508
+ elif voteA or voteB:
509
+ consensus = True # only one group voted
510
+ else:
511
+ consensus = None # no votes at al
512
+
513
+ # print(f"[Back-wheel] Final guess: {direction}. Reason: {reason}")
514
+ return direction, radial_lines, consensus
515
+
516
+
517
+ def compute_direction_headlight_refined(detected, fixed_label="Headlight"):
518
+ """
519
+ Compute vehicle side direction using the Headlight as the reference.
520
+ 'Fender', 'Windshield', 'Headlight', 'Grille', 'Front-wheel', 'Hood'
521
+ Group A: ["Front-wheel", "Fender", "Mirror", "Rocker-panel", "Front-door", "Front-window"]
522
+ * If these parts are to the left of the Headlight (negative x-offset), they vote "Right side view".
523
+ * If to the right, they vote "Left side view".
524
+
525
+ Group B: ["Front-bumper", "Grille", "Hood", "Windshield"]
526
+ * If these parts are to the right of the Headlight (positive x-offset), they vote "Right side view".
527
+ * If to the left, they vote "Left side view".
528
+ """
529
+ if fixed_label not in detected:
530
+ # print(f"Reference label '{fixed_label}' not detected.")
531
+ return "Unknown", None, None
532
+
533
+ headlight_box = detected[fixed_label][0]
534
+ headlight_center = (
535
+ (headlight_box[0] + headlight_box[2]) / 2,
536
+ (headlight_box[1] + headlight_box[3]) / 2,
537
+ )
538
+
539
+ groupA = [
540
+ "Front-wheel",
541
+ "Fender",
542
+ "Mirror",
543
+ "Rocker-panel",
544
+ "Front-door",
545
+ "Front-window",
546
+ ]
547
+ groupB = ["Front-bumper", "Grille", "Hood", "Windshield"]
548
+
549
+ offsets_A = []
550
+ offsets_B = []
551
+ radial_lines = []
552
+
553
+ for part in groupA:
554
+ if part in detected:
555
+ for box in detected[part]:
556
+ part_center = ((box[0] + box[2]) / 2, (box[1] + box[3]) / 2)
557
+ offset = part_center[0] - headlight_center[0]
558
+ offsets_A.append(offset)
559
+ radial_lines.append((headlight_center, part_center))
560
+ # print(f"{part} center: {part_center}, x-offset from Headlight: {offset}")
561
+
562
+ for part in groupB:
563
+ if part in detected:
564
+ for box in detected[part]:
565
+ part_center = ((box[0] + box[2]) / 2, (box[1] + box[3]) / 2)
566
+ offset = part_center[0] - headlight_center[0]
567
+ offsets_B.append(offset)
568
+ radial_lines.append((headlight_center, part_center))
569
+ # print(f"{part} center: {part_center}, x-offset from Headlight: {offset}")
570
+
571
+ voteA = None
572
+ voteB = None
573
+
574
+ if offsets_A:
575
+ avg_A = sum(offsets_A) / len(offsets_A)
576
+ voteA = "Right" if avg_A < 0 else "Left"
577
+ if offsets_B:
578
+ avg_B = sum(offsets_B) / len(offsets_B)
579
+ voteB = "Right" if avg_B > 0 else "Left"
580
+
581
+ # NEW LOGIC
582
+ if voteA and voteB:
583
+ if voteA == voteB:
584
+ direction = f"{voteA} side view"
585
+ reason = f"Both groups agreed on {voteA} side view."
586
+ else:
587
+ # Conflict → prioritize group with more datapoints
588
+ if len(offsets_A) > len(offsets_B):
589
+ direction = f"{voteA} side view"
590
+ reason = f"Conflict: Group A ({len(offsets_A)}) vs Group B ({len(offsets_B)}). Prioritizing Group A due to more datapoints."
591
+ elif len(offsets_B) > len(offsets_A):
592
+ direction = f"{voteB} side view"
593
+ reason = f"Conflict: Group A ({len(offsets_A)}) vs Group B ({len(offsets_B)}). Prioritizing Group B due to more datapoints."
594
+ else:
595
+ # Equal datapoints → default to Group B (your old rule)
596
+ direction = f"{voteB} side view"
597
+ reason = f"Equal datapoints. Falling back to Group B’s vote ({voteB})."
598
+ elif voteA:
599
+ # Only Group A has datapoints
600
+ direction = f"{voteA} side view"
601
+ reason = f"Only Group A datapoints ({len(offsets_A)}). Voting {voteA}."
602
+ elif voteB:
603
+ # Only Group B has datapoints
604
+ direction = f"{voteB} side view"
605
+ reason = f"Only Group B datapoints ({len(offsets_B)}). Voting {voteB}."
606
+ else:
607
+ direction = "Unknown"
608
+ reason = "No sufficient data."
609
+
610
+ # --- CONSENSUS FLAG LOGIC ---
611
+ if voteA and voteB:
612
+ consensus = voteA == voteB # True if same, False if conflict
613
+ elif voteA or voteB:
614
+ consensus = True # only one group voted
615
+ else:
616
+ consensus = None # no votes at al
617
+
618
+ # print(f"[Headlight] Final guess: {direction}. Reason: {reason}")
619
+ return direction, radial_lines, consensus
620
+
621
+
622
+ def compute_direction_tail_refined(detected, fixed_label="Tail-light"):
623
+ """
624
+ Compute vehicle side direction using the Tail-light as the reference.
625
+
626
+ Group A: ["Trunk", "Back-bumper", "Back-windshield"]
627
+ * If these parts are to the left (negative x-offset) of the Tail-light, vote "Right side view";
628
+ if to the right, vote "Left side view".
629
+ Group B: ["Back-wheel", "Quarter-panel", "Back-door", "Back-window"]
630
+ * If these parts are to the right (positive x-offset) of the Tail-light, vote "Right side view";
631
+ if to the left, vote "Left side view".
632
+ """
633
+ if fixed_label not in detected:
634
+ # print(f"Reference label '{fixed_label}' not detected.")
635
+ return "Unknown", None, None
636
+
637
+ tail_box = detected[fixed_label][0]
638
+ tail_center = ((tail_box[0] + tail_box[2]) / 2, (tail_box[1] + tail_box[3]) / 2)
639
+
640
+ groupA = ["Trunk", "Back-bumper", "Back-windshield"]
641
+ groupB = ["Back-wheel", "Quarter-panel", "Back-door", "Back-window"]
642
+
643
+ offsets_A = []
644
+ offsets_B = []
645
+ radial_lines = []
646
+
647
+ for part in groupA:
648
+ if part in detected:
649
+ for box in detected[part]:
650
+ part_center = ((box[0] + box[2]) / 2, (box[1] + box[3]) / 2)
651
+ offset = part_center[0] - tail_center[0]
652
+ offsets_A.append(offset)
653
+ radial_lines.append((tail_center, part_center))
654
+ # print(f"{part} center: {part_center}, x-offset from Tail-light: {offset}")
655
+
656
+ for part in groupB:
657
+ if part in detected:
658
+ for box in detected[part]:
659
+ part_center = ((box[0] + box[2]) / 2, (box[1] + box[3]) / 2)
660
+ offset = part_center[0] - tail_center[0]
661
+ offsets_B.append(offset)
662
+ radial_lines.append((tail_center, part_center))
663
+ # print(f"{part} center: {part_center}, x-offset from Tail-light: {offset}")
664
+
665
+ voteA = None
666
+ voteB = None
667
+ if offsets_A:
668
+ avg_A = sum(offsets_A) / len(offsets_A)
669
+ # print(f"Average Group A offset (Tail-light): {avg_A}")
670
+ voteA = "Right" if avg_A < 0 else "Left"
671
+ if offsets_B:
672
+ avg_B = sum(offsets_B) / len(offsets_B)
673
+ # print(f"Average Group B offset (Tail-light): {avg_B}")
674
+ voteB = "Right" if avg_B > 0 else "Left"
675
+
676
+ # NEW LOGIC
677
+ if voteA and voteB:
678
+ if voteA == voteB:
679
+ direction = f"{voteA} side view"
680
+ reason = f"Both groups agreed on {voteA} side view."
681
+ else:
682
+ # Conflict → prioritize group with more datapoints
683
+ if len(offsets_A) > len(offsets_B):
684
+ direction = f"{voteA} side view"
685
+ reason = f"Conflict: Group A ({len(offsets_A)}) vs Group B ({len(offsets_B)}). Prioritizing Group A due to more datapoints."
686
+ elif len(offsets_B) > len(offsets_A):
687
+ direction = f"{voteB} side view"
688
+ reason = f"Conflict: Group A ({len(offsets_A)}) vs Group B ({len(offsets_B)}). Prioritizing Group B due to more datapoints."
689
+ else:
690
+ # Equal datapoints → default to Group B (your old rule)
691
+ direction = f"{voteB} side view"
692
+ reason = f"Equal datapoints. Falling back to Group B’s vote ({voteB})."
693
+ elif voteA:
694
+ # Only Group A has datapoints
695
+ direction = f"{voteA} side view"
696
+ reason = f"Only Group A datapoints ({len(offsets_A)}). Voting {voteA}."
697
+ elif voteB:
698
+ # Only Group B has datapoints
699
+ direction = f"{voteB} side view"
700
+ reason = f"Only Group B datapoints ({len(offsets_B)}). Voting {voteB}."
701
+ else:
702
+ direction = "Unknown"
703
+ reason = "No sufficient data."
704
+
705
+ # --- CONSENSUS FLAG LOGIC ---
706
+ if voteA and voteB:
707
+ consensus = voteA == voteB # True if same, False if conflict
708
+ elif voteA or voteB:
709
+ consensus = True # only one group voted
710
+ else:
711
+ consensus = None # no votes at al
712
+
713
+ # print(f"[Tail-light] Final guess: {direction}. Reason: {reason}")
714
+ return direction, radial_lines, consensus
715
+
716
+
717
+ def determine_vehicle_directions(detected):
718
+ directions = {}
719
+ radial_lines_all = {}
720
+ consensus_flags = {}
721
+
722
+ # Mirror-based direction
723
+ mirror_direction, mirror_radials, mirror_consensus = (
724
+ compute_direction_mirror_refined(detected, fixed_label="Mirror")
725
+ )
726
+ directions["Mirror"] = mirror_direction
727
+ radial_lines_all["Mirror"] = mirror_radials
728
+ consensus_flags["Mirror"] = mirror_consensus
729
+
730
+ # Tail-light-based direction
731
+ tail_direction, tail_radials, tail_consensus = compute_direction_tail_refined(
732
+ detected, fixed_label="Tail-light"
733
+ )
734
+ directions["Tail-light"] = tail_direction
735
+ radial_lines_all["Tail-light"] = tail_radials
736
+ consensus_flags["Tail-light"] = tail_consensus
737
+
738
+ # Front-wheel-based direction
739
+ front_wheel_direction, front_wheel_radials, front_wheel_consensus = (
740
+ compute_direction_front_wheel_refined(detected, fixed_label="Front-wheel")
741
+ )
742
+ directions["Front-wheel"] = front_wheel_direction
743
+ radial_lines_all["Front-wheel"] = front_wheel_radials
744
+ consensus_flags["Front-wheel"] = front_wheel_consensus
745
+
746
+ # Back-wheel-based direction
747
+ back_wheel_direction, back_wheel_radials, back_wheel_consensus = (
748
+ compute_direction_back_wheel_refined(detected, fixed_label="Back-wheel")
749
+ )
750
+ directions["Back-wheel"] = back_wheel_direction
751
+ radial_lines_all["Back-wheel"] = back_wheel_radials
752
+ consensus_flags["Back-wheel"] = back_wheel_consensus
753
+
754
+ # Headlight-based direction
755
+ headlight_direction, headlight_radials, headlight_consensus = (
756
+ compute_direction_headlight_refined(detected, fixed_label="Headlight")
757
+ )
758
+ directions["Headlight"] = headlight_direction
759
+ radial_lines_all["Headlight"] = headlight_radials
760
+ consensus_flags["Headlight"] = headlight_consensus
761
+
762
+ return directions, radial_lines_all, consensus_flags
763
+
764
+
765
+ def find_best_combination(pil_image):
766
+ """
767
+ Returns:
768
+ directions: {"Selected": "<angle>"} or {"Selected":"Not Applicable"/"Unknown"}
769
+ detected_parts: set(...)
770
+ need_review: bool
771
+ top_2_predictions: [top1_key_lower, top2_key_lower_or_False]
772
+ score_map: dict mapping angle_key_lower -> stage2_score (for all angles)
773
+ """
774
+ need_review = False
775
+ image_array = cv2.cvtColor(np.array(pil_image), cv2.COLOR_RGB2BGR)
776
+ car_results = car_detection_model(image_array)
777
+
778
+ # --- CAR DETECTION VISUALIZATION ---
779
+ car_detections_to_plot = []
780
+ main_car_bbox = None
781
+ best_area = 0
782
+ num = 0
783
+ for result in car_results:
784
+ for box in result.boxes.data:
785
+ num += 1
786
+ conf = float(box[4])
787
+ if conf < 0.50: # confidence threshold for cars
788
+ continue
789
+ x1, y1, x2, y2 = box[0], box[1], box[2], box[3]
790
+ car_detections_to_plot.append((x1, y1, x2, y2, "Car", conf))
791
+ area = (x2 - x1) * (y2 - y1)
792
+ if area > best_area:
793
+ best_area = area
794
+ main_car_bbox = (x1, y1, x2, y2)
795
+
796
+ # print("Number of car boxes:", num)
797
+ # plot_detections(image_array, car_detections_to_plot, title="Car Detections")
798
+
799
+ if main_car_bbox is None:
800
+ # keep shape consistent
801
+ return pil_image, {}, {}
802
+
803
+ # --- PART DETECTION VISUALIZATION ---
804
+ results = part_detection_model(image_array)
805
+ part_detections_to_plot = []
806
+ detected = {}
807
+
808
+ car_area = (main_car_bbox[2] - main_car_bbox[0]) * (
809
+ main_car_bbox[3] - main_car_bbox[1]
810
+ )
811
+
812
+ ignored_parts = []
813
+ kept_parts = []
814
+
815
+ for result in results:
816
+ for box in result.boxes.data:
817
+ conf = float(box[4])
818
+ if conf < 0.65:
819
+ ignored_parts.append((result.names[int(box[5])], "low_conf", conf))
820
+ continue
821
+
822
+ x1, y1, x2, y2, conf, class_id = box
823
+ label = result.names[int(class_id)]
824
+
825
+ width = max(0, x2 - x1)
826
+ height = max(0, y2 - y1)
827
+ area = width * height
828
+ part_ratio = area / car_area
829
+
830
+ # lookup per-part min ratio
831
+ min_ratio = MIN_RATIO.get(label, MIN_RATIO["default"])
832
+
833
+ # filter: too small relative to car
834
+ if part_ratio < min_ratio and conf < 0.8:
835
+ ignored_parts.append(
836
+ (label, "too_small", float(part_ratio), float(conf))
837
+ )
838
+ continue
839
+
840
+ # filter: truncated parts touching bbox edges
841
+ margin = 5
842
+ if (
843
+ x1 <= main_car_bbox[0] + margin
844
+ or x2 >= main_car_bbox[2] - margin
845
+ or y1 <= main_car_bbox[1] + margin
846
+ or y2 >= main_car_bbox[3] - margin
847
+ ):
848
+ if part_ratio < min_ratio * 3: # stricter rule for edge-cut parts
849
+ ignored_parts.append(
850
+ (label, "truncated_edge", float(part_ratio), float(conf))
851
+ )
852
+ continue
853
+
854
+ # keep part
855
+ detected.setdefault(label, []).append((x1, y1, x2, y2))
856
+ part_detections_to_plot.append((x1, y1, x2, y2, label, conf))
857
+ kept_parts.append((label, float(part_ratio), float(conf)))
858
+
859
+ # plot_detections(image_array, part_detections_to_plot, title="Part Detections")
860
+
861
+ detections = {
862
+ "car_detection": {
863
+ "image_array": image_array,
864
+ "detections_to_plot": car_detections_to_plot,
865
+ "title": "Car Detections",
866
+ },
867
+ "part_detection": {
868
+ "image_array": image_array,
869
+ "detections_to_plot": part_detections_to_plot,
870
+ "title": "Part Detections",
871
+ },
872
+ }
873
+
874
+ return pil_image, detected, detections
875
+
876
+
877
+ def determine_viewing_angle(detected):
878
+ """
879
+ Returns:
880
+ directions: {"Selected": "<angle>"} or {"Selected":"Not Applicable"/"Unknown"}
881
+ detected_parts: set(...)
882
+ need_review: bool
883
+ top_2_predictions: [top1_key_lower, top2_key_lower_or_False]
884
+ score_map: dict mapping angle_key_lower -> stage2_score (for all angles)
885
+ """
886
+ need_review = False
887
+
888
+ # plot_detections(image_array, part_detections_to_plot, title="Part Detections")
889
+ detected_parts = set(detected.keys())
890
+
891
+ # print(f"[Summary] Detected Parts (inside main car): {detected_parts}")
892
+
893
+ # Compute scores for each angle
894
+ angle_scores = []
895
+ critical_parts = {
896
+ # "Front": {"Front-bumper", "Headlight", "Windshield", "Grille"},
897
+ "Front Right": {"Front-bumper", "Headlight", "Front-door"},
898
+ "Right": {"Front-door", "Back-door", "Front-wheel", "Back-wheel"},
899
+ "Rear Right": {"Tail-light", "Trunk", "Back-windshield", "Back-door"},
900
+ # "Rear": {"Tail-light", "Trunk", "Back-windshield", "Back-bumper"},
901
+ "Rear Left": {"Tail-light", "Trunk", "Back-windshield", "Back-door"},
902
+ "Left": {"Front-door", "Back-door", "Front-wheel", "Back-wheel"},
903
+ "Front Left": {"Front-bumper", "Headlight", "Front-door"},
904
+ }
905
+
906
+ for angle, rules in viewing_angle_rules.items():
907
+ if angle in critical_parts:
908
+ total_critical = len(critical_parts[angle])
909
+ detected_critical = len(critical_parts[angle].intersection(detected_parts))
910
+ critical_ratio = detected_critical / total_critical
911
+ else:
912
+ critical_ratio = None
913
+
914
+ ess_score = sum(
915
+ 3 for part in rules["must_be_visible"] if part in detected_parts
916
+ )
917
+ opt_score = sum(1 for part in rules["optional_parts"] if part in detected_parts)
918
+ conf_pen = sum(-3 for part in rules["conflict_parts"] if part in detected_parts)
919
+ raw_score = ess_score + opt_score + conf_pen
920
+ total_defined = (
921
+ len(rules["must_be_visible"])
922
+ + len(rules["optional_parts"])
923
+ + len(rules["conflict_parts"])
924
+ )
925
+ stage2_score = raw_score / total_defined if total_defined > 0 else 0.0
926
+ # print(f"[Summary] Angle: {angle}, Stage2 Score: {stage2_score:.2f}")
927
+ angle_scores.append((angle, critical_ratio, stage2_score))
928
+
929
+ # Build score_map for all angles (lowercase keys)
930
+ score_map = {angle.lower(): score for (angle, _, score) in angle_scores}
931
+
932
+ # ---- Stage-1 selection: ALWAYS pick top1; pick top2 only if strictly lower and >= threshold ----
933
+ # eps = 1e-6
934
+ # view_diff_thresold = 0.20
935
+ # critical_thresold = 0.75
936
+ stage_2_thresold = 0.80
937
+ # print(angle_scores)
938
+ sorted_all = sorted(angle_scores, key=lambda x: x[2], reverse=True)
939
+ top_2_predictions = ["", ""] # [top1_key_lower, top2_key_lower_or_False]
940
+ # print("sorted",sorted_all)
941
+ if sorted_all:
942
+ top1 = sorted_all[0]
943
+ top1_key = top1[0].lower()
944
+ top1_score = top1[2]
945
+ top1_cric_score = top1[1]
946
+ top_2_predictions[0] = top1_key
947
+
948
+ # find second candidate: strictly lower than top1 and >= threshold
949
+ second_key = ""
950
+ for angle, crit, score in sorted_all[1:]:
951
+ # print(angle,score)
952
+ if score < top1_score and score >= stage_2_thresold:
953
+ # print(angle)
954
+ second_key = angle.lower()
955
+ break
956
+ top_2_predictions[1] = second_key
957
+
958
+ # print(f"[Summary] Stage1 Top1: {top_2_predictions[0]}, Stage1 Top2 (or False): {top_2_predictions[1]}")
959
+
960
+ # Existing selection logic to pick the best_angle (unchanged)
961
+ angles_above_60 = [
962
+ item for item in angle_scores if item[1] is not None and item[1] >= 0.60
963
+ ]
964
+ if len(angles_above_60) >= 3:
965
+ need_review = True
966
+ best_angle, best_critical, best_stage2 = max(angle_scores, key=lambda x: x[2])
967
+ else:
968
+ candidates = [
969
+ item for item in angle_scores if item[1] is not None and item[1] >= 0.9
970
+ ]
971
+ if candidates:
972
+ best_angle, best_critical, best_stage2 = max(candidates, key=lambda x: x[1])
973
+ else:
974
+ best_angle, best_critical, best_stage2 = max(
975
+ angle_scores, key=lambda x: x[2]
976
+ )
977
+
978
+ # print(f"[Summary] Final Viewing Angle (from scoring): {best_angle}")
979
+
980
+ # Stage-2 direction (geometric)
981
+ if best_angle in ["Front", "Rear"]:
982
+ directions = {"Selected": best_angle}
983
+ else:
984
+ directions_all, radial_lines_all, consensus_all = determine_vehicle_directions(
985
+ detected
986
+ )
987
+
988
+ # --- NEW CONSENSUS PRIORITIZATION ---
989
+ # 1. Check if any anchor has consensus=True
990
+ consensus_votes = {
991
+ ref: directions_all[ref]
992
+ for ref, flag in consensus_all.items()
993
+ if flag is True and directions_all[ref] != "Unknown"
994
+ }
995
+
996
+ if consensus_votes:
997
+ # Pick the first consensus-true vote (or implement a tie-breaker if needed)
998
+ chosen_ref, chosen_dir = next(iter(consensus_votes.items()))
999
+ # print(f"[Consensus Override] Using {chosen_ref} vote because consensus=True → {chosen_dir}")
1000
+ majority_side = chosen_dir.split()[0]
1001
+ else:
1002
+ # --- FALL BACK TO ORIGINAL VOTING LOGIC ---
1003
+ votes = {}
1004
+ weights = {
1005
+ "Front-wheel": 1,
1006
+ "Back-wheel": 1,
1007
+ "Mirror": 1,
1008
+ "Headlight": 0.5,
1009
+ "Tail-light": 0.5,
1010
+ }
1011
+ for ref, dir_val in directions_all.items():
1012
+ if dir_val != "Unknown":
1013
+ side = dir_val.split()[0]
1014
+ weight = weights.get(ref, 1)
1015
+ votes[side] = votes.get(side, 0) + weight
1016
+ majority_side = max(votes, key=votes.get) if votes else "Unknown"
1017
+
1018
+ # print(f"[Stage-2 Majority Side] {majority_side}")
1019
+
1020
+ if best_angle.startswith("Front"):
1021
+ final_side_classification = "Front " + majority_side
1022
+ elif best_angle.startswith("Rear"):
1023
+ final_side_classification = "Rear " + majority_side
1024
+ else:
1025
+ final_side_classification = majority_side
1026
+
1027
+ directions = {"Selected": final_side_classification}
1028
+ # print(f"[Summary] Final side classification: {final_side_classification}.")
1029
+
1030
+ return directions, detected_parts, need_review, top_2_predictions, score_map
1031
+
1032
+
1033
+ def deskew_image(pil_image: Image.Image) -> Image.Image:
1034
+ """
1035
+ Correct skew/rotation of an input PIL Image using Hough Line Transform.
1036
+
1037
+ Args:
1038
+ pil_image (PIL.Image.Image): Input image.
1039
+
1040
+ Returns:
1041
+ PIL.Image.Image: Deskewed image.
1042
+ """
1043
+ # Convert PIL to OpenCV (BGR)
1044
+ img = cv2.cvtColor(np.array(pil_image), cv2.COLOR_RGB2BGR)
1045
+
1046
+ # Convert to grayscale
1047
+ gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
1048
+
1049
+ # Detect edges
1050
+ edges = cv2.Canny(gray, 50, 150, apertureSize=3)
1051
+
1052
+ # Hough Line Transform
1053
+ lines = cv2.HoughLines(edges, 1, np.pi / 180, 200)
1054
+
1055
+ angles = []
1056
+ if lines is not None:
1057
+ for rho, theta in lines[:, 0]:
1058
+ angle = (theta * 180 / np.pi) - 90
1059
+ # Normalize angle to [-90, 90]
1060
+ if angle < -90:
1061
+ angle += 180
1062
+ if angle > 90:
1063
+ angle -= 180
1064
+ angles.append(angle)
1065
+
1066
+ # Use median angle
1067
+ median_angle = np.median(angles) if len(angles) > 0 else 0
1068
+ # print("Estimated angle:", median_angle)
1069
+
1070
+ # Rotate image to deskew
1071
+ (h, w) = img.shape[:2]
1072
+ center = (w // 2, h // 2)
1073
+ M = cv2.getRotationMatrix2D(center, median_angle, 1.0)
1074
+ rotated = cv2.warpAffine(
1075
+ img, M, (w, h), flags=cv2.INTER_CUBIC, borderMode=cv2.BORDER_REPLICATE
1076
+ )
1077
+
1078
+ # Convert back to PIL (RGB)
1079
+ return Image.fromarray(cv2.cvtColor(rotated, cv2.COLOR_BGR2RGB))
1080
+
1081
+
1082
+ async def yolo_rule_based_classification(pil_image, image_name, img_file: UploadFile):
1083
+ """
1084
+ Returns:
1085
+ mapped_primary: canonical label (Stage-1 top1 mapped, with Stage-2 side enforced)
1086
+ final_review: bool
1087
+ final_secondaries: two-slot list [mapped_top1, mapped_top2_or_False]
1088
+ - mapped_top2 is included only if Stage-1 top2 exists AND its Stage-1 score >= 0.85
1089
+ """
1090
+ rotations = [0, 90, -90]
1091
+ best_direction = "NA"
1092
+ best_raw_direction = None
1093
+ final_review = False
1094
+ fail_count = 0
1095
+ best_top_predictions = [False, False]
1096
+ best_score_map = {}
1097
+ best_score = -999.0
1098
+ max_detected_labels = 0
1099
+ detected_labels = {}
1100
+ for rotation in rotations:
1101
+ rotated_image = (
1102
+ pil_image.rotate(rotation, expand=True) if rotation != 0 else pil_image
1103
+ )
1104
+ pil_image, detected, detections = find_best_combination(rotated_image)
1105
+ if len(detected.keys()) > max_detected_labels:
1106
+ max_detected_labels = len(detected.keys())
1107
+ detected_labels = detected
1108
+ final_detections = detections
1109
+
1110
+ print(detected_labels)
1111
+
1112
+ direction, detected_labels, need_review, top_predictions, score_map = (
1113
+ determine_viewing_angle(detected_labels)
1114
+ )
1115
+
1116
+ best_raw_direction = direction["Selected"]
1117
+
1118
+ final_review = need_review
1119
+ best_top_predictions = top_predictions
1120
+ best_score_map = score_map
1121
+ viewing_angle_map = {
1122
+ "front": "Front View",
1123
+ "rear": "Rear View",
1124
+ "left": "Driver Side View",
1125
+ "right": "Passenger Side View",
1126
+ "left side view": "Driver Side View",
1127
+ "right side view": "Passenger Side View",
1128
+ "front right": "Front Passenger Side Corner View",
1129
+ "front left": "Front Driver Side Corner View",
1130
+ "rear right": "Rear Passenger Side Corner View",
1131
+ "rear left": "Rear Driver Side Corner View",
1132
+ "unknown": "NA",
1133
+ }
1134
+
1135
+ # derive desired_side from Stage-2 raw direction
1136
+ desired_side, opposite_side = None, None
1137
+ if isinstance(best_raw_direction, str):
1138
+ raw = best_raw_direction.lower()
1139
+ if "left" in raw:
1140
+ desired_side = "Driver Side View"
1141
+ opposite_side = "Passenger Side View"
1142
+ elif "right" in raw:
1143
+ desired_side = "Passenger Side View"
1144
+ opposite_side = "Driver Side View"
1145
+
1146
+ # Stage-1 keys (strings or False)
1147
+ stage1_top1_key = (
1148
+ best_top_predictions[0]
1149
+ if isinstance(best_top_predictions, (list, tuple))
1150
+ and len(best_top_predictions) > 0
1151
+ else False
1152
+ )
1153
+ stage1_top2_key = (
1154
+ best_top_predictions[1]
1155
+ if isinstance(best_top_predictions, (list, tuple))
1156
+ and len(best_top_predictions) > 1
1157
+ else False
1158
+ )
1159
+
1160
+ # mapped_primary: prefer Stage-1 top1 mapped -> enforce Stage-2 side if needed, fallback to Stage-2 raw
1161
+ mapped_primary = "NA"
1162
+ if stage1_top1_key and isinstance(stage1_top1_key, str):
1163
+ mapped_primary = viewing_angle_map.get(stage1_top1_key.lower(), "NA")
1164
+ elif isinstance(best_raw_direction, str):
1165
+ mapped_primary = viewing_angle_map.get(best_raw_direction.lower(), "NA")
1166
+
1167
+ # corner swap mapping (for side enforcement)
1168
+ corner_swap = {
1169
+ "Front Passenger Side Corner View": "Front Driver Side Corner View",
1170
+ "Front Driver Side Corner View": "Front Passenger Side Corner View",
1171
+ "Rear Passenger Side Corner View": "Rear Driver Side Corner View",
1172
+ "Rear Driver Side Corner View": "Rear Passenger Side Corner View",
1173
+ }
1174
+
1175
+ # enforce desired_side on mapped_primary if necessary
1176
+ if desired_side and mapped_primary != "NA":
1177
+ if mapped_primary in ("Driver Side View", "Passenger Side View"):
1178
+ if mapped_primary != desired_side:
1179
+ mapped_primary = desired_side
1180
+ elif mapped_primary in corner_swap:
1181
+ if desired_side == "Driver Side View" and "Passenger" in mapped_primary:
1182
+ mapped_primary = corner_swap[mapped_primary]
1183
+ elif desired_side == "Passenger Side View" and "Driver" in mapped_primary:
1184
+ mapped_primary = corner_swap[mapped_primary]
1185
+
1186
+ # Build final_secondaries strictly from Stage-1 keys:
1187
+ final_secondaries = [False, False]
1188
+ threshold = 0.8
1189
+ eps = 1e-6
1190
+
1191
+ # mapped_top1
1192
+ if stage1_top1_key and isinstance(stage1_top1_key, str):
1193
+ mapped_top1 = viewing_angle_map.get(stage1_top1_key.lower(), "NA")
1194
+ else:
1195
+ mapped_top1 = mapped_primary if mapped_primary != "NA" else False
1196
+
1197
+ # enforce side on mapped_top1
1198
+ if desired_side and mapped_top1 and mapped_top1 in corner_swap:
1199
+ if desired_side == "Driver Side View" and "Passenger" in mapped_top1:
1200
+ mapped_top1 = corner_swap[mapped_top1]
1201
+ elif desired_side == "Passenger Side View" and "Driver" in mapped_top1:
1202
+ mapped_top1 = corner_swap[mapped_top1]
1203
+ elif desired_side and mapped_top1 in ("Driver Side View", "Passenger Side View"):
1204
+ if mapped_top1 != desired_side:
1205
+ mapped_top1 = desired_side
1206
+
1207
+ final_secondaries[0] = mapped_top1 if mapped_top1 != "NA" else False
1208
+
1209
+ # mapped_top2
1210
+ mapped_top2 = False
1211
+ if isinstance(stage1_top2_key, str):
1212
+ top2_score = best_score_map.get(stage1_top2_key.lower(), -999.0)
1213
+ top1_score = (
1214
+ best_score_map.get(stage1_top1_key.lower(), -999.0)
1215
+ if isinstance(stage1_top1_key, str)
1216
+ else -999.0
1217
+ )
1218
+ if top2_score >= threshold and top2_score < (top1_score - eps):
1219
+ mapped_top2 = viewing_angle_map.get(stage1_top2_key.lower(), "NA")
1220
+ # enforce desired_side
1221
+ if desired_side and mapped_top2 in corner_swap:
1222
+ if desired_side == "Driver Side View" and "Passenger" in mapped_top2:
1223
+ mapped_top2 = corner_swap[mapped_top2]
1224
+ elif desired_side == "Passenger Side View" and "Driver" in mapped_top2:
1225
+ mapped_top2 = corner_swap[mapped_top2]
1226
+ elif desired_side and mapped_top2 in (
1227
+ "Driver Side View",
1228
+ "Passenger Side View",
1229
+ ):
1230
+ if mapped_top2 != desired_side:
1231
+ mapped_top2 = desired_side
1232
+ else:
1233
+ mapped_top2 = False
1234
+
1235
+ final_secondaries[1] = mapped_top2 if mapped_top2 and mapped_top2 != "NA" else False
1236
+
1237
+ # Fallback if primary is NA
1238
+ if mapped_primary == "NA" and final_secondaries[0]:
1239
+ mapped_primary = final_secondaries[0]
1240
+
1241
+ # Final-review heuristics
1242
+ if fail_count >= 3:
1243
+ final_review = True
1244
+
1245
+ # collect scores
1246
+ final_scores = [0, 0]
1247
+ if isinstance(stage1_top1_key, str):
1248
+ final_scores[0] = best_score_map.get(stage1_top1_key.lower(), 0)
1249
+ if isinstance(stage1_top2_key, str) and final_secondaries[1]:
1250
+ final_scores[1] = best_score_map.get(stage1_top2_key.lower(), 0)
1251
+ final_secondaries = [item for item in final_secondaries if isinstance(item, str)]
1252
+
1253
+ file_details = await store_images(final_detections, image_name, img_file)
1254
+
1255
+ return final_secondaries, final_review, final_scores, file_details
1256
+
1257
+
1258
+ async def store_images(final_detections, image_name, img_file):
1259
+ os.makedirs("./output_files", exist_ok=True)
1260
+
1261
+ img_folder, extension = os.path.splitext(image_name)
1262
+
1263
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
1264
+ ff = f"{img_folder}_{timestamp}"
1265
+ output_folder = f"./output_files/{ff}"
1266
+ os.makedirs(f"{output_folder}", exist_ok=True)
1267
+ output_file_car = f"car_detection{extension}"
1268
+ output_file_part = f"part_detection{extension}"
1269
+
1270
+ print("\n")
1271
+ print("output_file_car = ", output_file_car)
1272
+ print("output_file_car = ", output_file_part)
1273
+ print("\n")
1274
+
1275
+ main_img_path = os.path.join(output_folder, img_file.filename)
1276
+ await img_file.seek(0)
1277
+ with open(main_img_path, "wb") as buffer:
1278
+ shutil.copyfileobj(img_file.file, buffer)
1279
+
1280
+ try:
1281
+ save_detection_img(
1282
+ final_detections["car_detection"]["image_array"],
1283
+ final_detections["car_detection"]["detections_to_plot"],
1284
+ output_folder,
1285
+ output_file_car,
1286
+ final_detections["car_detection"]["title"],
1287
+ )
1288
+ except Exception as e:
1289
+ print("Error Saving Car detections", e)
1290
+
1291
+ try:
1292
+ save_detection_img(
1293
+ final_detections["part_detection"]["image_array"],
1294
+ final_detections["part_detection"]["detections_to_plot"],
1295
+ output_folder,
1296
+ output_file_part,
1297
+ final_detections["part_detection"]["title"],
1298
+ )
1299
+ except Exception as e:
1300
+ print("Error Saving Part detections", e)
1301
+
1302
+ return {
1303
+ "main_img_name": f"{ff}/{img_file.filename}",
1304
+ "part_detection": f"{ff}/{output_file_part}",
1305
+ "car_detection": f"{ff}/{output_file_car}",
1306
+ }
1307
+
1308
+
1309
+ def save_detection_img(
1310
+ image_array, detections, save_folder, filename, title="Detections"
1311
+ ):
1312
+
1313
+ vis_img = image_array.copy()
1314
+
1315
+ # Draw bounding boxes and labels with better styling
1316
+ for x1, y1, x2, y2, label, conf in detections:
1317
+ # Thicker green rectangle
1318
+ cv2.rectangle(vis_img, (int(x1), int(y1)), (int(x2), int(y2)), (0, 255, 0), 3)
1319
+
1320
+ # Better text with background for readability
1321
+ text = f"{label} {conf:.2f}"
1322
+ text_size = cv2.getTextSize(text, cv2.FONT_HERSHEY_SIMPLEX, 0.6, 2)[0]
1323
+ cv2.rectangle(
1324
+ vis_img,
1325
+ (int(x1), int(y1) - text_size[1] - 10),
1326
+ (int(x1) + text_size[0], int(y1)),
1327
+ (0, 255, 0),
1328
+ -1,
1329
+ )
1330
+ cv2.putText(
1331
+ vis_img,
1332
+ text,
1333
+ (int(x1), int(y1) - 5),
1334
+ cv2.FONT_HERSHEY_SIMPLEX,
1335
+ 0.6,
1336
+ (0, 0, 0),
1337
+ 2,
1338
+ )
1339
+
1340
+ # Create save folder
1341
+ os.makedirs(save_folder, exist_ok=True)
1342
+
1343
+ # Add .png extension if not provided
1344
+ if not filename.endswith((".png", ".jpg", ".jpeg")):
1345
+ filename += ".png"
1346
+ filepath = os.path.join(save_folder, filename)
1347
+
1348
+ # High quality save with matplotlib
1349
+ plt.figure(figsize=(12, 8), dpi=300)
1350
+ plt.imshow(cv2.cvtColor(vis_img, cv2.COLOR_BGR2RGB))
1351
+ plt.title(title, fontsize=16, fontweight="bold")
1352
+ plt.axis("off")
1353
+ plt.tight_layout()
1354
+ plt.savefig(
1355
+ filepath,
1356
+ bbox_inches="tight",
1357
+ dpi=300,
1358
+ format="png",
1359
+ facecolor="white",
1360
+ edgecolor="none",
1361
+ )
1362
+ plt.close()
1363
+
1364
+ print(f"High quality image saved: {filepath}")
1365
+ return filepath
1366
+
1367
+
1368
+ def plot_detections(image_array, detections, title="Detections"):
1369
+ """
1370
+ image_array: numpy BGR image
1371
+ detections: list of tuples (x1, y1, x2, y2, label, conf)
1372
+ """
1373
+ vis_img = image_array.copy()
1374
+ for x1, y1, x2, y2, label, conf in detections:
1375
+ cv2.rectangle(vis_img, (int(x1), int(y1)), (int(x2), int(y2)), (0, 255, 0), 2)
1376
+ cv2.putText(
1377
+ vis_img,
1378
+ f"{label} {conf:.2f}",
1379
+ (int(x1), int(y1) - 5),
1380
+ cv2.FONT_HERSHEY_SIMPLEX,
1381
+ 0.5,
1382
+ (0, 255, 0),
1383
+ 2,
1384
+ )
1385
+ plt.figure(figsize=(8, 6))
1386
+ plt.imshow(cv2.cvtColor(vis_img, cv2.COLOR_BGR2RGB))
1387
+ plt.title(title)
1388
+ plt.axis("off")
1389
+ plt.show()
car_detection.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:61252040b0d2151e5026e5f412b7affcca7f2569bb8c921a3b381cf2187e991f
3
+ size 114390482
car_part.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:f881ed88c009272069945b78be27bacc138d48b956e46453a1aa78964f381256
3
+ size 124819994
requirements.txt ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ fastapi==0.116.2
2
+ pydantic==2.11.9
3
+ pydantic_core==2.33.2
4
+ python-dotenv==1.1.1
5
+ requests==2.32.5
6
+ langchain==0.3.27
7
+ Pillow==11.3.0
8
+ pandas==2.3.2
9
+ ultralytics==8.3.201
10
+ matplotlib==3.10.6
11
+ uvicorn==0.37.0
12
+ sympy==1.14.0
13
+ python-multipart==0.0.29