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

Upload folder using huggingface_hub

Browse files
.DS_Store CHANGED
Binary files a/.DS_Store and b/.DS_Store differ
 
OutputParser/__init__.py CHANGED
@@ -1,131 +1,93 @@
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
 
1
+ import os
2
+ from io import BytesIO
3
+ from typing import Annotated, List
4
+
5
+ import fastapi
6
+ from app.dependencies.yolo_classification_6 import yolo_rule_based_classification
7
+ from dotenv import load_dotenv
8
+ from fastapi import File, UploadFile
9
+ from fastapi.middleware.cors import CORSMiddleware
10
+ from fastapi.staticfiles import StaticFiles
11
+ from PIL import Image
12
+
13
+ load_dotenv()
14
+ app = fastapi.FastAPI()
15
+
16
+ _default_origins = ",".join(
17
+ [
18
+ "http://localhost:5173",
19
+ "http://localhost:5174",
20
+ "http://localhost:8080",
21
+ "http://localhost:8081",
22
+ "http://localhost:8082",
23
+ "https://dusk-upload-suite.lovable.app",
24
+ ]
25
+ )
26
+ _cors_origins = [
27
+ o.strip()
28
+ for o in os.getenv("CORS_ORIGINS", _default_origins).split(",")
29
+ if o.strip()
30
+ ]
31
+
32
+ app.add_middleware(
33
+ CORSMiddleware,
34
+ allow_origins=_cors_origins,
35
+ allow_credentials=True,
36
+ allow_methods=["*"],
37
+ allow_headers=["*"],
38
+ )
39
+
40
+
41
+ BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
42
+ OUTPUT_DIR = os.path.join(BASE_DIR, "output_files")
43
+
44
+ app.mount("/files", StaticFiles(directory=OUTPUT_DIR), name="files")
45
+
46
+
47
+ @app.post("/api/classify-img")
48
+ async def classify_message(
49
+ files: Annotated[List[UploadFile], File(...)],
50
+ ):
51
+ try:
52
+ print("FILES RECEIVED", files)
53
+ imgs = await get_imgs(files)
54
+ print("imgs = ", imgs)
55
+ img_results = []
56
+ for img in imgs:
57
+ try:
58
+ rule_based_view_type, review, final_scores, file_details = (
59
+ await yolo_rule_based_classification(
60
+ img["pil_img"],
61
+ img["file_name"],
62
+ img["img"],
63
+ )
64
+ )
65
+ img_results.append(
66
+ {
67
+ "rule_based_view_type": rule_based_view_type,
68
+ "review": review,
69
+ "final_scores": final_scores,
70
+ "error": None,
71
+ "file_details": file_details,
72
+ }
73
+ )
74
+ except Exception as e:
75
+ print("INSIDE ERROR DUDE", e)
76
+ img_results.append({"error": e})
77
+ print("img_results = ", img_results)
78
+ return img_results
79
+ except Exception as e:
80
+ print("ERROR _ ", e)
81
+ return {"error": str(e)}
82
+
83
+
84
+ async def get_imgs(files: List[UploadFile]) -> list[dict]:
85
+ imgs = []
86
+ for each in files:
87
+ resp = await each.read()
88
+ image_bytes = BytesIO(resp)
89
+ img = Image.open(image_bytes)
90
+ imgs.append({"img": each, "pil_img": img, "file_name": each.filename})
91
+ each.seek(0)
92
+ print("IMAGES = ", imgs)
93
+ return imgs
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/dependencies/yolo_classification_6.py ADDED
@@ -0,0 +1,888 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import shutil
3
+ from datetime import datetime
4
+
5
+ import cv2
6
+ import matplotlib.pyplot as plt
7
+ import numpy as np
8
+ from fastapi import UploadFile
9
+ from PIL import Image
10
+ from ultralytics import YOLO
11
+
12
+ # Models loaded once at import time
13
+ car_detection_model = YOLO(r"car_detection.pt")
14
+ part_detection_model = YOLO(r"car_part.pt")
15
+
16
+ DRIVER_SIDE = "Driver Side View"
17
+ PASSENGER_SIDE = "Passenger Side View"
18
+ FRONT_DRIVER_CORNER = "Front Driver Side Corner View"
19
+ FRONT_PASSENGER_CORNER = "Front Passenger Side Corner View"
20
+ REAR_DRIVER_CORNER = "Rear Driver Side Corner View"
21
+ REAR_PASSENGER_CORNER = "Rear Passenger Side Corner View"
22
+
23
+ CORNER_SWAP = {
24
+ FRONT_PASSENGER_CORNER: FRONT_DRIVER_CORNER,
25
+ FRONT_DRIVER_CORNER: FRONT_PASSENGER_CORNER,
26
+ REAR_PASSENGER_CORNER: REAR_DRIVER_CORNER,
27
+ REAR_DRIVER_CORNER: REAR_PASSENGER_CORNER,
28
+ }
29
+
30
+
31
+ def _enforce_side(label, desired_side):
32
+ """Return label with the correct driver/passenger side applied."""
33
+ if not desired_side or not label or label == "NA":
34
+ return label
35
+ if label in (DRIVER_SIDE, PASSENGER_SIDE):
36
+ return desired_side if label != desired_side else label
37
+ if label in CORNER_SWAP:
38
+ wrong_keyword = "Passenger" if desired_side == DRIVER_SIDE else "Driver"
39
+ return CORNER_SWAP[label] if wrong_keyword in label else label
40
+ return label
41
+
42
+ MIN_RATIO = {
43
+ # Front
44
+ "Front-bumper": 0.015,
45
+ "Grille": 0.010,
46
+ "Headlight": 0.005, # small but important
47
+ "Hood": 0.020,
48
+ "License-plate": 0.002, # very small
49
+
50
+ # Side
51
+ "Front-door": 0.030,
52
+ "Back-door": 0.030,
53
+ "Front-wheel": 0.010,
54
+ "Back-wheel": 0.010,
55
+ "Mirror": 0.001, # small but always relevant
56
+ "Quarter-panel": 0.010,
57
+ "Rocker-panel": 0.010,
58
+ "Roof": 0.020,
59
+
60
+ # Windows
61
+ "Windshield": 0.020,
62
+ "Front-window": 0.015,
63
+ "Back-window": 0.015,
64
+ "Back-windshield": 0.020,
65
+
66
+ # Rear
67
+ "Back-bumper": 0.015,
68
+ "Tail-light": 0.005, # small but important
69
+ "Trunk": 0.020,
70
+
71
+ # Catch-all fallback
72
+ "default": 0.01
73
+ }
74
+
75
+ viewing_angle_rules = {
76
+ "Front Right": {
77
+ "must_be_visible": ["Front-bumper", "Grille", "Headlight", "Front-wheel", "Windshield", "Front-door", "Front-window", "Fender", "Mirror", "Rocker-panel"],
78
+ "optional_parts": ["Hood", "Roof", "Back-door", "Back-wheel", "Back-window", "Quarter-panel"],
79
+ "conflict_parts": ["Tail-light", "Back-bumper", "Back-windshield", "Trunk"]
80
+ },
81
+ "Right": {
82
+ "must_be_visible": ["Front-door", "Back-door", "Mirror", "Quarter-panel", "Fender", "Rocker-panel", "Front-wheel", "Back-wheel", "Back-window", "Front-window"],
83
+ "optional_parts": ["Roof", "Front-bumper", "Back-bumper", "Headlight", "Tail-light", "Hood", "Back-windshield", "Windshield", "Trunk", "Grille", "License-plate"],
84
+ "conflict_parts": []
85
+ },
86
+ "Rear Right": {
87
+ "must_be_visible": ["Back-bumper", "Tail-light", "Back-wheel", "Back-door", "Back-window", "Quarter-panel", "Back-windshield", "Rocker-panel", "Trunk"],
88
+ "optional_parts": ["Roof", "License-plate", "Front-wheel", "Front-door", "Fender", "Mirror", "Front-window"],
89
+ "conflict_parts": ["Front-bumper", "Headlight", "Grille", "Windshield", "Hood"]
90
+ },
91
+ "Rear Left": {
92
+ "must_be_visible": ["Back-bumper", "Tail-light", "Back-wheel", "Back-door", "Back-window", "Quarter-panel", "Back-windshield", "Rocker-panel", "Trunk"],
93
+ "optional_parts": ["Roof", "License-plate", "Front-wheel", "Front-door", "Fender", "Mirror", "Front-window"],
94
+ "conflict_parts": ["Front-bumper", "Headlight", "Grille", "Windshield", "Hood"]
95
+ },
96
+ "Left": {
97
+ "must_be_visible": ["Front-door", "Back-door", "Mirror", "Quarter-panel", "Fender", "Rocker-panel", "Front-wheel", "Back-wheel", "Back-window", "Front-window"],
98
+ "optional_parts": ["Roof", "Front-bumper", "Back-bumper", "Headlight", "Tail-light", "Hood", "Back-windshield", "Windshield", "Trunk", "Grille", "License-plate"],
99
+ "conflict_parts": []
100
+ },
101
+ "Front Left": {
102
+ "must_be_visible": ["Front-bumper", "Grille", "Headlight", "Front-wheel", "Windshield", "Front-door", "Front-window", "Fender", "Mirror", "Rocker-panel"],
103
+ "optional_parts": ["Hood", "Roof", "Back-door", "Back-wheel", "Back-window", "Quarter-panel"],
104
+ "conflict_parts": ["Tail-light", "Back-bumper", "Back-windshield", "Trunk"]
105
+ }
106
+ }
107
+
108
+
109
+ def compute_direction_mirror_refined(detected, fixed_label="Mirror"):
110
+ if fixed_label not in detected:
111
+ return "Unknown", None, None
112
+
113
+ mirror_box = detected[fixed_label][0]
114
+ mirror_center = ((mirror_box[0] + mirror_box[2]) / 2, (mirror_box[1] + mirror_box[3]) / 2)
115
+
116
+ groupA = ["Windshield", "Hood", "Headlight", "Front-bumper", "Front-wheel"]
117
+ groupB = ["Back-wheel", "Back-door", "Quarter-panel", "Rocker-panel", "Back-window"]
118
+
119
+ offsets_A = []
120
+ offsets_B = []
121
+ radial_lines = []
122
+
123
+ for part in groupA:
124
+ if part in detected:
125
+ for box in detected[part]:
126
+ part_center = ((box[0] + box[2]) / 2, (box[1] + box[3]) / 2)
127
+ offset = part_center[0] - mirror_center[0]
128
+ offsets_A.append(offset)
129
+ radial_lines.append((mirror_center, part_center))
130
+
131
+ for part in groupB:
132
+ if part in detected:
133
+ for box in detected[part]:
134
+ part_center = ((box[0] + box[2]) / 2, (box[1] + box[3]) / 2)
135
+ offset = part_center[0] - mirror_center[0]
136
+ offsets_B.append(offset)
137
+ radial_lines.append((mirror_center, part_center))
138
+
139
+ voteA = None
140
+ voteB = None
141
+
142
+ if offsets_A:
143
+ avg_A = sum(offsets_A) / len(offsets_A)
144
+ voteA = "Right" if avg_A > 0 else "Left"
145
+
146
+ if offsets_B:
147
+ avg_B = sum(offsets_B) / len(offsets_B)
148
+ voteB = "Right" if avg_B < 0 else "Left"
149
+
150
+ if voteA and voteB:
151
+ if voteA == voteB:
152
+ direction = f"{voteA} side view"
153
+ else:
154
+ if len(offsets_A) > len(offsets_B):
155
+ direction = f"{voteA} side view"
156
+ elif len(offsets_B) > len(offsets_A):
157
+ direction = f"{voteB} side view"
158
+ else:
159
+ direction = f"{voteB} side view"
160
+ elif voteA:
161
+ direction = f"{voteA} side view"
162
+ elif voteB:
163
+ direction = f"{voteB} side view"
164
+ else:
165
+ direction = "Unknown"
166
+
167
+ if voteA and voteB:
168
+ consensus = (voteA == voteB)
169
+ elif voteA or voteB:
170
+ consensus = True
171
+ else:
172
+ consensus = None
173
+
174
+ return direction, radial_lines, consensus
175
+
176
+
177
+ def compute_direction_front_wheel_refined(detected, fixed_label="Front-wheel"):
178
+ if fixed_label not in detected:
179
+ return "Unknown", None, None
180
+
181
+ front_wheel_box = detected[fixed_label][0]
182
+ front_wheel_center = ((front_wheel_box[0] + front_wheel_box[2]) / 2, (front_wheel_box[1] + front_wheel_box[3]) / 2)
183
+
184
+ groupA = ["Front-bumper", "Headlight", "Fender", "Grille"]
185
+ groupB = ["Front-door", "Back-door", "Rocker-panel", "Front-window", "Back-window", "Back-wheel", "Quarter-panel", "Mirror", "Windshield"]
186
+
187
+ offsets_A = []
188
+ offsets_B = []
189
+ radial_lines = []
190
+
191
+ for part in groupA:
192
+ if part in detected:
193
+ for box in detected[part]:
194
+ part_center = ((box[0] + box[2]) / 2, (box[1] + box[3]) / 2)
195
+ offset = part_center[0] - front_wheel_center[0]
196
+ offsets_A.append(offset)
197
+ radial_lines.append((front_wheel_center, part_center))
198
+
199
+ for part in groupB:
200
+ if part in detected:
201
+ for box in detected[part]:
202
+ part_center = ((box[0] + box[2]) / 2, (box[1] + box[3]) / 2)
203
+ offset = part_center[0] - front_wheel_center[0]
204
+ offsets_B.append(offset)
205
+ radial_lines.append((front_wheel_center, part_center))
206
+
207
+ voteA = None
208
+ voteB = None
209
+
210
+ if offsets_A:
211
+ avg_A = sum(offsets_A) / len(offsets_A)
212
+ voteA = "Right" if avg_A > 0 else "Left"
213
+
214
+ if offsets_B:
215
+ avg_B = sum(offsets_B) / len(offsets_B)
216
+ voteB = "Right" if avg_B < 0 else "Left"
217
+
218
+ if voteA and voteB:
219
+ if voteA == voteB:
220
+ direction = f"{voteA} side view"
221
+ else:
222
+ if len(offsets_A) > len(offsets_B):
223
+ direction = f"{voteA} side view"
224
+ elif len(offsets_B) > len(offsets_A):
225
+ direction = f"{voteB} side view"
226
+ else:
227
+ direction = f"{voteB} side view"
228
+ elif voteA:
229
+ direction = f"{voteA} side view"
230
+ elif voteB:
231
+ direction = f"{voteB} side view"
232
+ else:
233
+ direction = "Unknown"
234
+
235
+ if voteA and voteB:
236
+ consensus = (voteA == voteB)
237
+ elif voteA or voteB:
238
+ consensus = True
239
+ else:
240
+ consensus = None
241
+
242
+ return direction, radial_lines, consensus
243
+
244
+
245
+ def compute_direction_back_wheel_refined(detected, fixed_label="Back-wheel"):
246
+ if fixed_label not in detected:
247
+ return "Unknown", None, None
248
+
249
+ back_wheel_box = detected[fixed_label][0]
250
+ back_wheel_center = ((back_wheel_box[0] + back_wheel_box[2]) / 2,
251
+ (back_wheel_box[1] + back_wheel_box[3]) / 2)
252
+
253
+ groupA = ["Back-bumper", "Tail-light", "Quarter-panel", "Trunk"]
254
+ groupB = ["Front-wheel", "Rocker-panel", "Back-door", "Front-door",
255
+ "Back-window", "Front-window", "Mirror", "Fender"]
256
+
257
+ offsets_A = []
258
+ offsets_B = []
259
+ radial_lines = []
260
+
261
+ for part in groupA:
262
+ if part in detected:
263
+ for box in detected[part]:
264
+ part_center = ((box[0] + box[2]) / 2, (box[1] + box[3]) / 2)
265
+ offset = part_center[0] - back_wheel_center[0]
266
+ offsets_A.append(offset)
267
+ radial_lines.append((back_wheel_center, part_center))
268
+
269
+ for part in groupB:
270
+ if part in detected:
271
+ for box in detected[part]:
272
+ part_center = ((box[0] + box[2]) / 2, (box[1] + box[3]) / 2)
273
+ offset = part_center[0] - back_wheel_center[0]
274
+ offsets_B.append(offset)
275
+ radial_lines.append((back_wheel_center, part_center))
276
+
277
+ voteA = None
278
+ voteB = None
279
+
280
+ if offsets_A:
281
+ avg_A = sum(offsets_A) / len(offsets_A)
282
+ voteA = "Right" if avg_A < 0 else "Left"
283
+ if offsets_B:
284
+ avg_B = sum(offsets_B) / len(offsets_B)
285
+ voteB = "Left" if avg_B < 0 else "Right"
286
+
287
+ if voteA and voteB:
288
+ if voteA == voteB:
289
+ direction = f"{voteA} side view"
290
+ else:
291
+ if len(offsets_A) > len(offsets_B):
292
+ direction = f"{voteA} side view"
293
+ elif len(offsets_B) > len(offsets_A):
294
+ direction = f"{voteB} side view"
295
+ else:
296
+ direction = f"{voteB} side view"
297
+ elif voteA:
298
+ direction = f"{voteA} side view"
299
+ elif voteB:
300
+ direction = f"{voteB} side view"
301
+ else:
302
+ direction = "Unknown"
303
+
304
+ if voteA and voteB:
305
+ consensus = (voteA == voteB)
306
+ elif voteA or voteB:
307
+ consensus = True
308
+ else:
309
+ consensus = None
310
+
311
+ return direction, radial_lines, consensus
312
+
313
+
314
+ def compute_direction_headlight_refined(detected, fixed_label="Headlight"):
315
+ if fixed_label not in detected:
316
+ return "Unknown", None, None
317
+
318
+ headlight_box = detected[fixed_label][0]
319
+ headlight_center = ((headlight_box[0] + headlight_box[2]) / 2, (headlight_box[1] + headlight_box[3]) / 2)
320
+
321
+ groupA = ["Front-wheel", "Fender", "Mirror", "Rocker-panel", "Front-door", "Front-window"]
322
+ groupB = ["Front-bumper", "Grille", "Hood", "Windshield"]
323
+
324
+ offsets_A = []
325
+ offsets_B = []
326
+ radial_lines = []
327
+
328
+ for part in groupA:
329
+ if part in detected:
330
+ for box in detected[part]:
331
+ part_center = ((box[0] + box[2]) / 2, (box[1] + box[3]) / 2)
332
+ offset = part_center[0] - headlight_center[0]
333
+ offsets_A.append(offset)
334
+ radial_lines.append((headlight_center, part_center))
335
+
336
+ for part in groupB:
337
+ if part in detected:
338
+ for box in detected[part]:
339
+ part_center = ((box[0] + box[2]) / 2, (box[1] + box[3]) / 2)
340
+ offset = part_center[0] - headlight_center[0]
341
+ offsets_B.append(offset)
342
+ radial_lines.append((headlight_center, part_center))
343
+
344
+ voteA = None
345
+ voteB = None
346
+
347
+ if offsets_A:
348
+ avg_A = sum(offsets_A) / len(offsets_A)
349
+ voteA = "Right" if avg_A < 0 else "Left"
350
+ if offsets_B:
351
+ avg_B = sum(offsets_B) / len(offsets_B)
352
+ voteB = "Right" if avg_B > 0 else "Left"
353
+
354
+ if voteA and voteB:
355
+ if voteA == voteB:
356
+ direction = f"{voteA} side view"
357
+ else:
358
+ if len(offsets_A) > len(offsets_B):
359
+ direction = f"{voteA} side view"
360
+ elif len(offsets_B) > len(offsets_A):
361
+ direction = f"{voteB} side view"
362
+ else:
363
+ direction = f"{voteB} side view"
364
+ elif voteA:
365
+ direction = f"{voteA} side view"
366
+ elif voteB:
367
+ direction = f"{voteB} side view"
368
+ else:
369
+ direction = "Unknown"
370
+
371
+ if voteA and voteB:
372
+ consensus = (voteA == voteB)
373
+ elif voteA or voteB:
374
+ consensus = True
375
+ else:
376
+ consensus = None
377
+
378
+ return direction, radial_lines, consensus
379
+
380
+
381
+ def compute_direction_tail_refined(detected, fixed_label="Tail-light"):
382
+ if fixed_label not in detected:
383
+ return "Unknown", None, None
384
+
385
+ tail_box = detected[fixed_label][0]
386
+ tail_center = ((tail_box[0] + tail_box[2]) / 2, (tail_box[1] + tail_box[3]) / 2)
387
+
388
+ groupA = ["Trunk", "Back-bumper", "Back-windshield"]
389
+ groupB = ["Back-wheel", "Quarter-panel", "Back-door", "Back-window"]
390
+
391
+ offsets_A = []
392
+ offsets_B = []
393
+ radial_lines = []
394
+
395
+ for part in groupA:
396
+ if part in detected:
397
+ for box in detected[part]:
398
+ part_center = ((box[0] + box[2]) / 2, (box[1] + box[3]) / 2)
399
+ offset = part_center[0] - tail_center[0]
400
+ offsets_A.append(offset)
401
+ radial_lines.append((tail_center, part_center))
402
+
403
+ for part in groupB:
404
+ if part in detected:
405
+ for box in detected[part]:
406
+ part_center = ((box[0] + box[2]) / 2, (box[1] + box[3]) / 2)
407
+ offset = part_center[0] - tail_center[0]
408
+ offsets_B.append(offset)
409
+ radial_lines.append((tail_center, part_center))
410
+
411
+ voteA = None
412
+ voteB = None
413
+ if offsets_A:
414
+ avg_A = sum(offsets_A) / len(offsets_A)
415
+ voteA = "Right" if avg_A < 0 else "Left"
416
+ if offsets_B:
417
+ avg_B = sum(offsets_B) / len(offsets_B)
418
+ voteB = "Right" if avg_B > 0 else "Left"
419
+
420
+ if voteA and voteB:
421
+ if voteA == voteB:
422
+ direction = f"{voteA} side view"
423
+ else:
424
+ if len(offsets_A) > len(offsets_B):
425
+ direction = f"{voteA} side view"
426
+ elif len(offsets_B) > len(offsets_A):
427
+ direction = f"{voteB} side view"
428
+ else:
429
+ direction = f"{voteB} side view"
430
+ elif voteA:
431
+ direction = f"{voteA} side view"
432
+ elif voteB:
433
+ direction = f"{voteB} side view"
434
+ else:
435
+ direction = "Unknown"
436
+
437
+ if voteA and voteB:
438
+ consensus = (voteA == voteB)
439
+ elif voteA or voteB:
440
+ consensus = True
441
+ else:
442
+ consensus = None
443
+
444
+ return direction, radial_lines, consensus
445
+
446
+
447
+ def determine_vehicle_directions(detected):
448
+ directions = {}
449
+ radial_lines_all = {}
450
+ consensus_flags = {}
451
+
452
+ mirror_direction, mirror_radials, mirror_consensus = compute_direction_mirror_refined(detected, fixed_label="Mirror")
453
+ directions["Mirror"] = mirror_direction
454
+ radial_lines_all["Mirror"] = mirror_radials
455
+ consensus_flags["Mirror"] = mirror_consensus
456
+
457
+ tail_direction, tail_radials, tail_consensus = compute_direction_tail_refined(detected, fixed_label="Tail-light")
458
+ directions["Tail-light"] = tail_direction
459
+ radial_lines_all["Tail-light"] = tail_radials
460
+ consensus_flags["Tail-light"] = tail_consensus
461
+
462
+ front_wheel_direction, front_wheel_radials, front_wheel_consensus = compute_direction_front_wheel_refined(detected, fixed_label="Front-wheel")
463
+ directions["Front-wheel"] = front_wheel_direction
464
+ radial_lines_all["Front-wheel"] = front_wheel_radials
465
+ consensus_flags["Front-wheel"] = front_wheel_consensus
466
+
467
+ back_wheel_direction, back_wheel_radials, back_wheel_consensus = compute_direction_back_wheel_refined(detected, fixed_label="Back-wheel")
468
+ directions["Back-wheel"] = back_wheel_direction
469
+ radial_lines_all["Back-wheel"] = back_wheel_radials
470
+ consensus_flags["Back-wheel"] = back_wheel_consensus
471
+
472
+ headlight_direction, headlight_radials, headlight_consensus = compute_direction_headlight_refined(detected, fixed_label="Headlight")
473
+ directions["Headlight"] = headlight_direction
474
+ radial_lines_all["Headlight"] = headlight_radials
475
+ consensus_flags["Headlight"] = headlight_consensus
476
+
477
+ return directions, radial_lines_all, consensus_flags
478
+
479
+
480
+ def find_best_combination(pil_image):
481
+ """
482
+ Runs car + part detection on pil_image.
483
+ Returns (pil_image, detected_parts_dict, detections_dict).
484
+ Returns (pil_image, {}, {}) if no car is found.
485
+ """
486
+ image_array = cv2.cvtColor(np.array(pil_image), cv2.COLOR_RGB2BGR)
487
+ car_results = car_detection_model(image_array)
488
+
489
+ car_detections_to_plot = []
490
+ main_car_bbox = None
491
+ best_area = 0
492
+ for result in car_results:
493
+ for box in result.boxes.data:
494
+ conf = float(box[4])
495
+ if conf < 0.50:
496
+ continue
497
+ x1, y1, x2, y2 = box[0], box[1], box[2], box[3]
498
+ car_detections_to_plot.append((x1, y1, x2, y2, "Car", conf))
499
+ area = (x2 - x1) * (y2 - y1)
500
+ if area > best_area:
501
+ best_area = area
502
+ main_car_bbox = (x1, y1, x2, y2)
503
+
504
+ if main_car_bbox is None:
505
+ return pil_image, {}, {}
506
+
507
+ results = part_detection_model(image_array)
508
+ part_detections_to_plot = []
509
+ detected = {}
510
+
511
+ car_area = (main_car_bbox[2] - main_car_bbox[0]) * (main_car_bbox[3] - main_car_bbox[1])
512
+
513
+ for result in results:
514
+ for box in result.boxes.data:
515
+ conf = float(box[4])
516
+ if conf < 0.65:
517
+ continue
518
+
519
+ x1, y1, x2, y2, conf, class_id = box
520
+ label = result.names[int(class_id)]
521
+
522
+ width = max(0, x2 - x1)
523
+ height = max(0, y2 - y1)
524
+ area = width * height
525
+ part_ratio = area / car_area
526
+
527
+ min_ratio = MIN_RATIO.get(label, MIN_RATIO["default"])
528
+
529
+ if part_ratio < min_ratio and conf < 0.8:
530
+ continue
531
+
532
+ margin = 5
533
+ if (x1 <= main_car_bbox[0] + margin or x2 >= main_car_bbox[2] - margin or
534
+ y1 <= main_car_bbox[1] + margin or y2 >= main_car_bbox[3] - margin):
535
+ if part_ratio < min_ratio * 3:
536
+ continue
537
+
538
+ detected.setdefault(label, []).append((x1, y1, x2, y2))
539
+ part_detections_to_plot.append((x1, y1, x2, y2, label, conf))
540
+
541
+ detections = {
542
+ "car_detection": {
543
+ "image_array": image_array,
544
+ "detections_to_plot": car_detections_to_plot,
545
+ "title": "Car Detections",
546
+ },
547
+ "part_detection": {
548
+ "image_array": image_array,
549
+ "detections_to_plot": part_detections_to_plot,
550
+ "title": "Part Detections",
551
+ },
552
+ }
553
+
554
+ return pil_image, detected, detections
555
+
556
+
557
+ def determine_viewing_angle(detected):
558
+ need_review = False
559
+ detected_parts = set(detected.keys())
560
+
561
+ angle_scores = []
562
+ critical_parts = {
563
+ "Front Right": {"Front-bumper", "Headlight", "Front-door"},
564
+ "Right": {"Front-door", "Back-door", "Front-wheel", "Back-wheel"},
565
+ "Rear Right": {"Tail-light", "Trunk", "Back-windshield", "Back-door"},
566
+ "Rear Left": {"Tail-light", "Trunk", "Back-windshield", "Back-door"},
567
+ "Left": {"Front-door", "Back-door", "Front-wheel", "Back-wheel"},
568
+ "Front Left": {"Front-bumper", "Headlight", "Front-door"}
569
+ }
570
+
571
+ for angle, rules in viewing_angle_rules.items():
572
+ if angle in critical_parts:
573
+ total_critical = len(critical_parts[angle])
574
+ detected_critical = len(critical_parts[angle].intersection(detected_parts))
575
+ critical_ratio = detected_critical / total_critical
576
+ else:
577
+ critical_ratio = None
578
+
579
+ ess_score = sum(3 for part in rules['must_be_visible'] if part in detected_parts)
580
+ opt_score = sum(1 for part in rules['optional_parts'] if part in detected_parts)
581
+ conf_pen = sum(-3 for part in rules['conflict_parts'] if part in detected_parts)
582
+ raw_score = ess_score + opt_score + conf_pen
583
+ total_defined = len(rules['must_be_visible']) + len(rules['optional_parts']) + len(rules['conflict_parts'])
584
+ stage2_score = raw_score / total_defined if total_defined > 0 else 0.0
585
+ angle_scores.append((angle, critical_ratio, stage2_score))
586
+
587
+ print(angle_scores)
588
+ score_map = {angle.lower(): score for (angle, _, score) in angle_scores}
589
+
590
+ eps = 1e-6
591
+ stage_2_thresold = 0.75
592
+ sorted_all = sorted(angle_scores, key=lambda x: x[2], reverse=True)
593
+ symmetric_view = {
594
+ "Front Right": "Front Left",
595
+ "Front Left": "Front Right",
596
+ "Right": "Left",
597
+ "Left": "Right",
598
+ "Rear Right": "Rear Left",
599
+ "Rear Left": "Rear Right"
600
+ }
601
+ top_2_predictions = ["", ""]
602
+ if sorted_all:
603
+ top1 = sorted_all[0]
604
+ top1_key = top1[0].lower()
605
+ top1_score = top1[2]
606
+ top_2_predictions[0] = top1_key
607
+
608
+ second_key = ""
609
+ for angle, _, score in sorted_all[1:]:
610
+ if score <= top1_score and score >= stage_2_thresold and symmetric_view.get(angle, "").lower() != top1_key:
611
+ second_key = angle.lower()
612
+ break
613
+ if top1_score > 0.25 and score / (top1_score - eps) >= 0.60 and symmetric_view.get(angle, "").lower() != top1_key:
614
+ second_key = angle.lower()
615
+ break
616
+ top_2_predictions[1] = second_key
617
+
618
+ angles_above_60 = [item for item in angle_scores if item[1] is not None and item[1] >= 0.60]
619
+ if len(angles_above_60) >= 3:
620
+ need_review = True
621
+ best_angle, *_ = max(angle_scores, key=lambda x: x[2])
622
+ else:
623
+ candidates = [item for item in angle_scores if item[1] is not None and item[1] >= 0.9]
624
+ if candidates:
625
+ best_angle, *_ = max(candidates, key=lambda x: x[1])
626
+ else:
627
+ best_angle, *_ = max(angle_scores, key=lambda x: x[2])
628
+
629
+ if best_angle in ["Front", "Rear"]:
630
+ directions = {"Selected": best_angle}
631
+ else:
632
+ directions_all, _, consensus_all = determine_vehicle_directions(detected)
633
+ print(directions_all)
634
+
635
+ consensus_votes = {
636
+ ref: directions_all[ref]
637
+ for ref, flag in consensus_all.items()
638
+ if flag is True and directions_all[ref] != "Unknown"
639
+ }
640
+
641
+ if consensus_votes:
642
+ _, chosen_dir = next(iter(consensus_votes.items()))
643
+ majority_side = chosen_dir.split()[0]
644
+ else:
645
+ votes = {}
646
+ weights = {"Front-wheel": 1, "Back-wheel": 1, "Mirror": 1, "Headlight": 0.5, "Tail-light": 0.5}
647
+ for ref, dir_val in directions_all.items():
648
+ if dir_val != "Unknown":
649
+ side = dir_val.split()[0]
650
+ weight = weights.get(ref, 1)
651
+ votes[side] = votes.get(side, 0) + weight
652
+ majority_side = max(votes, key=votes.get) if votes else "Unknown"
653
+
654
+ if best_angle.startswith("Front"):
655
+ final_side_classification = "Front " + majority_side
656
+ elif best_angle.startswith("Rear"):
657
+ final_side_classification = "Rear " + majority_side
658
+ else:
659
+ final_side_classification = majority_side
660
+
661
+ directions = {"Selected": final_side_classification}
662
+
663
+ return directions, detected_parts, need_review, top_2_predictions, score_map
664
+
665
+
666
+ def deskew_image(pil_image: Image.Image) -> Image.Image:
667
+ img = cv2.cvtColor(np.array(pil_image), cv2.COLOR_RGB2BGR)
668
+ gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
669
+ edges = cv2.Canny(gray, 50, 150, apertureSize=3)
670
+ lines = cv2.HoughLines(edges, 1, np.pi / 180, 200)
671
+
672
+ angles = []
673
+ if lines is not None:
674
+ for _, theta in lines[:, 0]:
675
+ angle = (theta * 180 / np.pi) - 90
676
+ if angle < -90:
677
+ angle += 180
678
+ if angle > 90:
679
+ angle -= 180
680
+ angles.append(angle)
681
+
682
+ median_angle = np.median(angles) if len(angles) > 0 else 0
683
+
684
+ (h, w) = img.shape[:2]
685
+ center = (w // 2, h // 2)
686
+ M = cv2.getRotationMatrix2D(center, median_angle, 1.0)
687
+ rotated = cv2.warpAffine(img, M, (w, h), flags=cv2.INTER_CUBIC, borderMode=cv2.BORDER_REPLICATE)
688
+
689
+ return Image.fromarray(cv2.cvtColor(rotated, cv2.COLOR_BGR2RGB))
690
+
691
+
692
+ async def yolo_rule_based_classification(pil_image, image_name, img_file: UploadFile):
693
+ """
694
+ Returns (final_secondaries, final_review, final_scores, file_details).
695
+ Tries each rotation (0, 90, -90) and also a deskewed variant — picks the
696
+ image variant that yields the most detected parts.
697
+ """
698
+ rotations = [0, 90, -90]
699
+ best_raw_direction = None
700
+ final_review = False
701
+ fail_count = 0
702
+ best_top_predictions = [False, False]
703
+ best_score_map = {}
704
+ max_detected_labels = 0
705
+ detected_labels = {}
706
+ final_detections = {}
707
+
708
+ for rotation in rotations:
709
+ rotated_image = pil_image.rotate(rotation, expand=True) if rotation != 0 else pil_image
710
+
711
+ # Try original orientation
712
+ _, detected, detections = find_best_combination(rotated_image)
713
+ print(detected)
714
+ if len(detected.keys()) > max_detected_labels:
715
+ max_detected_labels = len(detected.keys())
716
+ detected_labels = detected
717
+ final_detections = detections
718
+
719
+ # Try deskewed version of this rotation
720
+ deskewed_image = deskew_image(rotated_image)
721
+ _, detected, detections = find_best_combination(deskewed_image)
722
+ print(detected)
723
+ if len(detected.keys()) > max_detected_labels:
724
+ max_detected_labels = len(detected.keys())
725
+ detected_labels = detected
726
+ final_detections = detections
727
+
728
+ print(detected_labels)
729
+ direction, detected_labels, need_review, top_predictions, score_map = determine_viewing_angle(detected_labels)
730
+
731
+ best_raw_direction = direction['Selected']
732
+ final_review = need_review
733
+ best_top_predictions = top_predictions
734
+ best_score_map = score_map
735
+
736
+ viewing_angle_map = {
737
+ "front": "Front View",
738
+ "rear": "Rear View",
739
+ "left": DRIVER_SIDE,
740
+ "right": PASSENGER_SIDE,
741
+ "left side view": DRIVER_SIDE,
742
+ "right side view": PASSENGER_SIDE,
743
+ "front right": FRONT_PASSENGER_CORNER,
744
+ "front left": FRONT_DRIVER_CORNER,
745
+ "rear right": REAR_PASSENGER_CORNER,
746
+ "rear left": REAR_DRIVER_CORNER,
747
+ "unknown": "NA",
748
+ }
749
+
750
+ desired_side = None
751
+ if isinstance(best_raw_direction, str):
752
+ raw = best_raw_direction.lower()
753
+ if "left" in raw:
754
+ desired_side = "Driver Side View"
755
+ elif "right" in raw:
756
+ desired_side = "Passenger Side View"
757
+
758
+ stage1_top1_key = best_top_predictions[0] if isinstance(best_top_predictions, (list, tuple)) and len(best_top_predictions) > 0 else False
759
+ stage1_top2_key = best_top_predictions[1] if isinstance(best_top_predictions, (list, tuple)) and len(best_top_predictions) > 1 else False
760
+
761
+ mapped_primary = "NA"
762
+ if stage1_top1_key and isinstance(stage1_top1_key, str):
763
+ mapped_primary = viewing_angle_map.get(stage1_top1_key.lower(), "NA")
764
+ elif isinstance(best_raw_direction, str):
765
+ mapped_primary = viewing_angle_map.get(best_raw_direction.lower(), "NA")
766
+
767
+
768
+ mapped_primary = _enforce_side(mapped_primary, desired_side)
769
+
770
+ final_secondaries = [False, False]
771
+ threshold = 0.75
772
+ eps = 1e-6
773
+
774
+ if stage1_top1_key and isinstance(stage1_top1_key, str):
775
+ mapped_top1 = viewing_angle_map.get(stage1_top1_key.lower(), "NA")
776
+ else:
777
+ mapped_top1 = mapped_primary if mapped_primary != "NA" else False
778
+
779
+ mapped_top1 = _enforce_side(mapped_top1, desired_side)
780
+ final_secondaries[0] = mapped_top1 if mapped_top1 != "NA" else False
781
+
782
+ mapped_top2 = False
783
+ if isinstance(stage1_top2_key, str):
784
+ top2_score = best_score_map.get(stage1_top2_key.lower(), -999.0)
785
+ top1_score = best_score_map.get(stage1_top1_key.lower(), -999.0) if isinstance(stage1_top1_key, str) else -999.0
786
+ if (top2_score >= threshold and top2_score <= top1_score) or (top2_score / (top1_score - eps) >= 0.60 and top1_score > 0.25):
787
+ mapped_top2 = _enforce_side(viewing_angle_map.get(stage1_top2_key.lower(), "NA"), desired_side)
788
+ else:
789
+ mapped_top2 = False
790
+
791
+ final_secondaries[1] = mapped_top2 if mapped_top2 and mapped_top2 != "NA" else False
792
+
793
+ if mapped_primary == "NA" and final_secondaries[0]:
794
+ mapped_primary = final_secondaries[0]
795
+
796
+ if fail_count >= 3:
797
+ final_review = True
798
+
799
+ final_scores = [0, 0]
800
+ if isinstance(stage1_top1_key, str):
801
+ final_scores[0] = best_score_map.get(stage1_top1_key.lower(), 0)
802
+ if isinstance(stage1_top2_key, str) and final_secondaries[1]:
803
+ final_scores[1] = best_score_map.get(stage1_top2_key.lower(), 0)
804
+ final_secondaries = [item for item in final_secondaries if isinstance(item, str)]
805
+ print(final_secondaries)
806
+
807
+ file_details = await store_images(final_detections, image_name, img_file)
808
+
809
+ return final_secondaries, final_review, final_scores, file_details
810
+
811
+
812
+ async def store_images(final_detections, image_name, img_file):
813
+ os.makedirs("./output_files", exist_ok=True)
814
+
815
+ img_folder, extension = os.path.splitext(image_name)
816
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
817
+ ff = f"{img_folder}_{timestamp}"
818
+ output_folder = f"./output_files/{ff}"
819
+ os.makedirs(output_folder, exist_ok=True)
820
+ output_file_car = f"car_detection{extension}"
821
+ output_file_part = f"part_detection{extension}"
822
+
823
+ main_img_path = os.path.join(output_folder, img_file.filename)
824
+ await img_file.seek(0)
825
+ with open(main_img_path, "wb") as buffer:
826
+ shutil.copyfileobj(img_file.file, buffer)
827
+
828
+ try:
829
+ save_detection_img(
830
+ final_detections["car_detection"]["image_array"],
831
+ final_detections["car_detection"]["detections_to_plot"],
832
+ output_folder,
833
+ output_file_car,
834
+ final_detections["car_detection"]["title"],
835
+ )
836
+ except Exception as e:
837
+ print("Error Saving Car detections", e)
838
+
839
+ try:
840
+ save_detection_img(
841
+ final_detections["part_detection"]["image_array"],
842
+ final_detections["part_detection"]["detections_to_plot"],
843
+ output_folder,
844
+ output_file_part,
845
+ final_detections["part_detection"]["title"],
846
+ )
847
+ except Exception as e:
848
+ print("Error Saving Part detections", e)
849
+
850
+ return {
851
+ "main_img_name": f"{ff}/{img_file.filename}",
852
+ "part_detection": f"{ff}/{output_file_part}",
853
+ "car_detection": f"{ff}/{output_file_car}",
854
+ }
855
+
856
+
857
+ def save_detection_img(image_array, detections, save_folder, filename, title="Detections"):
858
+ vis_img = image_array.copy()
859
+
860
+ for x1, y1, x2, y2, label, conf in detections:
861
+ cv2.rectangle(vis_img, (int(x1), int(y1)), (int(x2), int(y2)), (0, 255, 0), 3)
862
+ text = f"{label} {conf:.2f}"
863
+ text_size = cv2.getTextSize(text, cv2.FONT_HERSHEY_SIMPLEX, 0.6, 2)[0]
864
+ cv2.rectangle(
865
+ vis_img,
866
+ (int(x1), int(y1) - text_size[1] - 10),
867
+ (int(x1) + text_size[0], int(y1)),
868
+ (0, 255, 0),
869
+ -1,
870
+ )
871
+ cv2.putText(vis_img, text, (int(x1), int(y1) - 5), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 0, 0), 2)
872
+
873
+ os.makedirs(save_folder, exist_ok=True)
874
+
875
+ if not filename.endswith((".png", ".jpg", ".jpeg")):
876
+ filename += ".png"
877
+ filepath = os.path.join(save_folder, filename)
878
+
879
+ plt.figure(figsize=(12, 8), dpi=300)
880
+ plt.imshow(cv2.cvtColor(vis_img, cv2.COLOR_BGR2RGB))
881
+ plt.title(title, fontsize=16, fontweight="bold")
882
+ plt.axis("off")
883
+ plt.tight_layout()
884
+ plt.savefig(filepath, bbox_inches="tight", dpi=300, format="png", facecolor="white", edgecolor="none")
885
+ plt.close()
886
+
887
+ print(f"High quality image saved: {filepath}")
888
+ return filepath
app/dependencies/yolo_classification_old.py ADDED
@@ -0,0 +1,1387 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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_raw_direction = None
1092
+ final_review = False
1093
+ fail_count = 0
1094
+ best_top_predictions = [False, False]
1095
+ best_score_map = {}
1096
+ max_detected_labels = 0
1097
+ detected_labels = {}
1098
+ for rotation in rotations:
1099
+ rotated_image = (
1100
+ pil_image.rotate(rotation, expand=True) if rotation != 0 else pil_image
1101
+ )
1102
+ pil_image, detected, detections = find_best_combination(rotated_image)
1103
+ if len(detected.keys()) > max_detected_labels:
1104
+ max_detected_labels = len(detected.keys())
1105
+ detected_labels = detected
1106
+ final_detections = detections
1107
+
1108
+ print(detected_labels)
1109
+
1110
+ direction, detected_labels, need_review, top_predictions, score_map = (
1111
+ determine_viewing_angle(detected_labels)
1112
+ )
1113
+
1114
+ best_raw_direction = direction["Selected"]
1115
+
1116
+ final_review = need_review
1117
+ best_top_predictions = top_predictions
1118
+ best_score_map = score_map
1119
+ viewing_angle_map = {
1120
+ "front": "Front View",
1121
+ "rear": "Rear View",
1122
+ "left": "Driver Side View",
1123
+ "right": "Passenger Side View",
1124
+ "left side view": "Driver Side View",
1125
+ "right side view": "Passenger Side View",
1126
+ "front right": "Front Passenger Side Corner View",
1127
+ "front left": "Front Driver Side Corner View",
1128
+ "rear right": "Rear Passenger Side Corner View",
1129
+ "rear left": "Rear Driver Side Corner View",
1130
+ "unknown": "NA",
1131
+ }
1132
+
1133
+ # derive desired_side from Stage-2 raw direction
1134
+ desired_side, opposite_side = None, None
1135
+ if isinstance(best_raw_direction, str):
1136
+ raw = best_raw_direction.lower()
1137
+ if "left" in raw:
1138
+ desired_side = "Driver Side View"
1139
+ opposite_side = "Passenger Side View"
1140
+ elif "right" in raw:
1141
+ desired_side = "Passenger Side View"
1142
+ opposite_side = "Driver Side View"
1143
+
1144
+ # Stage-1 keys (strings or False)
1145
+ stage1_top1_key = (
1146
+ best_top_predictions[0]
1147
+ if isinstance(best_top_predictions, (list, tuple))
1148
+ and len(best_top_predictions) > 0
1149
+ else False
1150
+ )
1151
+ stage1_top2_key = (
1152
+ best_top_predictions[1]
1153
+ if isinstance(best_top_predictions, (list, tuple))
1154
+ and len(best_top_predictions) > 1
1155
+ else False
1156
+ )
1157
+
1158
+ # mapped_primary: prefer Stage-1 top1 mapped -> enforce Stage-2 side if needed, fallback to Stage-2 raw
1159
+ mapped_primary = "NA"
1160
+ if stage1_top1_key and isinstance(stage1_top1_key, str):
1161
+ mapped_primary = viewing_angle_map.get(stage1_top1_key.lower(), "NA")
1162
+ elif isinstance(best_raw_direction, str):
1163
+ mapped_primary = viewing_angle_map.get(best_raw_direction.lower(), "NA")
1164
+
1165
+ # corner swap mapping (for side enforcement)
1166
+ corner_swap = {
1167
+ "Front Passenger Side Corner View": "Front Driver Side Corner View",
1168
+ "Front Driver Side Corner View": "Front Passenger Side Corner View",
1169
+ "Rear Passenger Side Corner View": "Rear Driver Side Corner View",
1170
+ "Rear Driver Side Corner View": "Rear Passenger Side Corner View",
1171
+ }
1172
+
1173
+ # enforce desired_side on mapped_primary if necessary
1174
+ if desired_side and mapped_primary != "NA":
1175
+ if mapped_primary in ("Driver Side View", "Passenger Side View"):
1176
+ if mapped_primary != desired_side:
1177
+ mapped_primary = desired_side
1178
+ elif mapped_primary in corner_swap:
1179
+ if desired_side == "Driver Side View" and "Passenger" in mapped_primary:
1180
+ mapped_primary = corner_swap[mapped_primary]
1181
+ elif desired_side == "Passenger Side View" and "Driver" in mapped_primary:
1182
+ mapped_primary = corner_swap[mapped_primary]
1183
+
1184
+ # Build final_secondaries strictly from Stage-1 keys:
1185
+ final_secondaries = [False, False]
1186
+ threshold = 0.8
1187
+ eps = 1e-6
1188
+
1189
+ # mapped_top1
1190
+ if stage1_top1_key and isinstance(stage1_top1_key, str):
1191
+ mapped_top1 = viewing_angle_map.get(stage1_top1_key.lower(), "NA")
1192
+ else:
1193
+ mapped_top1 = mapped_primary if mapped_primary != "NA" else False
1194
+
1195
+ # enforce side on mapped_top1
1196
+ if desired_side and mapped_top1 and mapped_top1 in corner_swap:
1197
+ if desired_side == "Driver Side View" and "Passenger" in mapped_top1:
1198
+ mapped_top1 = corner_swap[mapped_top1]
1199
+ elif desired_side == "Passenger Side View" and "Driver" in mapped_top1:
1200
+ mapped_top1 = corner_swap[mapped_top1]
1201
+ elif desired_side and mapped_top1 in ("Driver Side View", "Passenger Side View"):
1202
+ if mapped_top1 != desired_side:
1203
+ mapped_top1 = desired_side
1204
+
1205
+ final_secondaries[0] = mapped_top1 if mapped_top1 != "NA" else False
1206
+
1207
+ # mapped_top2
1208
+ mapped_top2 = False
1209
+ if isinstance(stage1_top2_key, str):
1210
+ top2_score = best_score_map.get(stage1_top2_key.lower(), -999.0)
1211
+ top1_score = (
1212
+ best_score_map.get(stage1_top1_key.lower(), -999.0)
1213
+ if isinstance(stage1_top1_key, str)
1214
+ else -999.0
1215
+ )
1216
+ if top2_score >= threshold and top2_score < (top1_score - eps):
1217
+ mapped_top2 = viewing_angle_map.get(stage1_top2_key.lower(), "NA")
1218
+ # enforce desired_side
1219
+ if desired_side and mapped_top2 in corner_swap:
1220
+ if desired_side == "Driver Side View" and "Passenger" in mapped_top2:
1221
+ mapped_top2 = corner_swap[mapped_top2]
1222
+ elif desired_side == "Passenger Side View" and "Driver" in mapped_top2:
1223
+ mapped_top2 = corner_swap[mapped_top2]
1224
+ elif desired_side and mapped_top2 in (
1225
+ "Driver Side View",
1226
+ "Passenger Side View",
1227
+ ):
1228
+ if mapped_top2 != desired_side:
1229
+ mapped_top2 = desired_side
1230
+ else:
1231
+ mapped_top2 = False
1232
+
1233
+ final_secondaries[1] = mapped_top2 if mapped_top2 and mapped_top2 != "NA" else False
1234
+
1235
+ # Fallback if primary is NA
1236
+ if mapped_primary == "NA" and final_secondaries[0]:
1237
+ mapped_primary = final_secondaries[0]
1238
+
1239
+ # Final-review heuristics
1240
+ if fail_count >= 3:
1241
+ final_review = True
1242
+
1243
+ # collect scores
1244
+ final_scores = [0, 0]
1245
+ if isinstance(stage1_top1_key, str):
1246
+ final_scores[0] = best_score_map.get(stage1_top1_key.lower(), 0)
1247
+ if isinstance(stage1_top2_key, str) and final_secondaries[1]:
1248
+ final_scores[1] = best_score_map.get(stage1_top2_key.lower(), 0)
1249
+ final_secondaries = [item for item in final_secondaries if isinstance(item, str)]
1250
+
1251
+ file_details = await store_images(final_detections, image_name, img_file)
1252
+
1253
+ return final_secondaries, final_review, final_scores, file_details
1254
+
1255
+
1256
+ async def store_images(final_detections, image_name, img_file):
1257
+ os.makedirs("./output_files", exist_ok=True)
1258
+
1259
+ img_folder, extension = os.path.splitext(image_name)
1260
+
1261
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
1262
+ ff = f"{img_folder}_{timestamp}"
1263
+ output_folder = f"./output_files/{ff}"
1264
+ os.makedirs(f"{output_folder}", exist_ok=True)
1265
+ output_file_car = f"car_detection{extension}"
1266
+ output_file_part = f"part_detection{extension}"
1267
+
1268
+ print("\n")
1269
+ print("output_file_car = ", output_file_car)
1270
+ print("output_file_car = ", output_file_part)
1271
+ print("\n")
1272
+
1273
+ main_img_path = os.path.join(output_folder, img_file.filename)
1274
+ await img_file.seek(0)
1275
+ with open(main_img_path, "wb") as buffer:
1276
+ shutil.copyfileobj(img_file.file, buffer)
1277
+
1278
+ try:
1279
+ save_detection_img(
1280
+ final_detections["car_detection"]["image_array"],
1281
+ final_detections["car_detection"]["detections_to_plot"],
1282
+ output_folder,
1283
+ output_file_car,
1284
+ final_detections["car_detection"]["title"],
1285
+ )
1286
+ except Exception as e:
1287
+ print("Error Saving Car detections", e)
1288
+
1289
+ try:
1290
+ save_detection_img(
1291
+ final_detections["part_detection"]["image_array"],
1292
+ final_detections["part_detection"]["detections_to_plot"],
1293
+ output_folder,
1294
+ output_file_part,
1295
+ final_detections["part_detection"]["title"],
1296
+ )
1297
+ except Exception as e:
1298
+ print("Error Saving Part detections", e)
1299
+
1300
+ return {
1301
+ "main_img_name": f"{ff}/{img_file.filename}",
1302
+ "part_detection": f"{ff}/{output_file_part}",
1303
+ "car_detection": f"{ff}/{output_file_car}",
1304
+ }
1305
+
1306
+
1307
+ def save_detection_img(
1308
+ image_array, detections, save_folder, filename, title="Detections"
1309
+ ):
1310
+
1311
+ vis_img = image_array.copy()
1312
+
1313
+ # Draw bounding boxes and labels with better styling
1314
+ for x1, y1, x2, y2, label, conf in detections:
1315
+ # Thicker green rectangle
1316
+ cv2.rectangle(vis_img, (int(x1), int(y1)), (int(x2), int(y2)), (0, 255, 0), 3)
1317
+
1318
+ # Better text with background for readability
1319
+ text = f"{label} {conf:.2f}"
1320
+ text_size = cv2.getTextSize(text, cv2.FONT_HERSHEY_SIMPLEX, 0.6, 2)[0]
1321
+ cv2.rectangle(
1322
+ vis_img,
1323
+ (int(x1), int(y1) - text_size[1] - 10),
1324
+ (int(x1) + text_size[0], int(y1)),
1325
+ (0, 255, 0),
1326
+ -1,
1327
+ )
1328
+ cv2.putText(
1329
+ vis_img,
1330
+ text,
1331
+ (int(x1), int(y1) - 5),
1332
+ cv2.FONT_HERSHEY_SIMPLEX,
1333
+ 0.6,
1334
+ (0, 0, 0),
1335
+ 2,
1336
+ )
1337
+
1338
+ # Create save folder
1339
+ os.makedirs(save_folder, exist_ok=True)
1340
+
1341
+ # Add .png extension if not provided
1342
+ if not filename.endswith((".png", ".jpg", ".jpeg")):
1343
+ filename += ".png"
1344
+ filepath = os.path.join(save_folder, filename)
1345
+
1346
+ # High quality save with matplotlib
1347
+ plt.figure(figsize=(12, 8), dpi=300)
1348
+ plt.imshow(cv2.cvtColor(vis_img, cv2.COLOR_BGR2RGB))
1349
+ plt.title(title, fontsize=16, fontweight="bold")
1350
+ plt.axis("off")
1351
+ plt.tight_layout()
1352
+ plt.savefig(
1353
+ filepath,
1354
+ bbox_inches="tight",
1355
+ dpi=300,
1356
+ format="png",
1357
+ facecolor="white",
1358
+ edgecolor="none",
1359
+ )
1360
+ plt.close()
1361
+
1362
+ print(f"High quality image saved: {filepath}")
1363
+ return filepath
1364
+
1365
+
1366
+ def plot_detections(image_array, detections, title="Detections"):
1367
+ """
1368
+ image_array: numpy BGR image
1369
+ detections: list of tuples (x1, y1, x2, y2, label, conf)
1370
+ """
1371
+ vis_img = image_array.copy()
1372
+ for x1, y1, x2, y2, label, conf in detections:
1373
+ cv2.rectangle(vis_img, (int(x1), int(y1)), (int(x2), int(y2)), (0, 255, 0), 2)
1374
+ cv2.putText(
1375
+ vis_img,
1376
+ f"{label} {conf:.2f}",
1377
+ (int(x1), int(y1) - 5),
1378
+ cv2.FONT_HERSHEY_SIMPLEX,
1379
+ 0.5,
1380
+ (0, 255, 0),
1381
+ 2,
1382
+ )
1383
+ plt.figure(figsize=(8, 6))
1384
+ plt.imshow(cv2.cvtColor(vis_img, cv2.COLOR_BGR2RGB))
1385
+ plt.title(title)
1386
+ plt.axis("off")
1387
+ plt.show()