SakibAhmed commited on
Commit
cd9ae77
·
verified ·
1 Parent(s): 7616805

Upload 8 files

Browse files
Files changed (5) hide show
  1. Dockerfile +27 -39
  2. app.py +179 -143
  3. processing.py +96 -83
  4. requirements.txt +7 -8
  5. templates/index.html +431 -0
Dockerfile CHANGED
@@ -1,39 +1,27 @@
1
- # Use an official Python runtime as a parent image
2
- FROM python:3.10-slim
3
-
4
- # Set the working directory in the container
5
- WORKDIR /app
6
-
7
- # Install system dependencies for OpenCV and other packages
8
- RUN apt-get update && apt-get install -y \
9
- libgl1-mesa-glx \
10
- libglib2.0-0 \
11
- libsm6 \
12
- libxext6 \
13
- libxrender-dev \
14
- libgomp1 \
15
- && rm -rf /var/lib/apt/lists/*
16
-
17
- # Copy the requirements file
18
- COPY requirements.txt requirements.txt
19
-
20
- # Install Python packages
21
- RUN pip install --no-cache-dir -r requirements.txt
22
-
23
- # Copy application code
24
- COPY . /app
25
-
26
- # Create a non-root user
27
- RUN useradd -m -u 1000 user
28
-
29
- # Change ownership
30
- RUN chown -R user:user /app
31
-
32
- # Switch to the non-root user
33
- USER user
34
-
35
- # Expose the port Gunicorn will run on (Using 7860 as in CMD)
36
- EXPOSE 7860
37
-
38
- # Command to run the app
39
- CMD ["python", "app.py", "--host", "0.0.0.0", "--port", "7860"]
 
1
+ FROM python:3.10-slim
2
+
3
+ WORKDIR /app
4
+ ENV PYTHONUNBUFFERED=1
5
+
6
+ RUN apt-get update && apt-get install -y --no-install-recommends \
7
+ libgl1 \
8
+ libglib2.0-0 \
9
+ libsm6 \
10
+ libxext6 \
11
+ libxrender1 \
12
+ libgomp1 \
13
+ && rm -rf /var/lib/apt/lists/*
14
+
15
+ COPY requirements.txt ./requirements.txt
16
+ RUN pip install --no-cache-dir -r requirements.txt
17
+
18
+ COPY . /app
19
+
20
+ RUN mkdir -p /app/models /app/static/uploads \
21
+ && useradd -m -u 1000 user \
22
+ && chown -R user:user /app
23
+
24
+ USER user
25
+ EXPOSE 7860
26
+
27
+ CMD ["python", "app.py"]
 
 
 
 
 
 
 
 
 
 
 
 
app.py CHANGED
@@ -1,143 +1,179 @@
1
- import os
2
- import torch
3
- from flask import Flask, request, jsonify, render_template, Response
4
- from flask_cors import CORS
5
- from werkzeug.utils import secure_filename
6
- from ultralytics import YOLO
7
- from dotenv import load_dotenv
8
- import time
9
- import json
10
- import traceback
11
-
12
- # Import the processing logic
13
- from processing import process_images
14
-
15
- # Load environment variables from .env file
16
- load_dotenv()
17
-
18
- app = Flask(__name__)
19
-
20
- # Enable CORS for all routes
21
- CORS(app)
22
-
23
- # --- Configuration ---
24
- UPLOAD_FOLDER = 'static/uploads'
25
- MODELS_FOLDER = 'models'
26
- ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg'}
27
-
28
- # --- Load model names from .env file ---
29
- PARTS_MODEL_NAME = os.getenv('PARTS_MODEL_NAME', 'best_parts_EP336.pt')
30
- DAMAGE_MODEL_NAME = os.getenv('DAMAGE_MODEL_NAME', 'best_new_EP382.pt')
31
-
32
- # --- Model Paths ---
33
- PARTS_MODEL_PATH = os.path.join(MODELS_FOLDER, PARTS_MODEL_NAME)
34
- DAMAGE_MODEL_PATH = os.path.join(MODELS_FOLDER, DAMAGE_MODEL_NAME)
35
-
36
- app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER
37
- os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True)
38
- os.makedirs(MODELS_FOLDER, exist_ok=True)
39
- os.makedirs('templates', exist_ok=True)
40
-
41
- # --- Determine Device ---
42
- device = "cuda" if torch.cuda.is_available() else "cpu"
43
- print(f"Using device: {device}")
44
-
45
- # --- Load YOLO Models ---
46
- parts_model, damage_model = None, None
47
-
48
- # Load Parts Model
49
- try:
50
- if not os.path.exists(PARTS_MODEL_PATH):
51
- print(f"Warning: Parts model file not found at {PARTS_MODEL_PATH}")
52
- else:
53
- parts_model = YOLO(PARTS_MODEL_PATH)
54
- parts_model.to(device)
55
- print(f"Successfully loaded parts model '{PARTS_MODEL_NAME}' on {device}.")
56
- except Exception as e:
57
- print(f"Error loading Parts Model ({PARTS_MODEL_NAME}): {e}")
58
-
59
- # Load Damage Model
60
- try:
61
- if not os.path.exists(DAMAGE_MODEL_PATH):
62
- print(f"Warning: Damage model file not found at {DAMAGE_MODEL_PATH}")
63
- else:
64
- damage_model = YOLO(DAMAGE_MODEL_PATH)
65
- damage_model.to(device)
66
- print(f"Successfully loaded damage model '{DAMAGE_MODEL_NAME}' on {device}.")
67
- except Exception as e:
68
- print(f"Error loading Damage Model ({DAMAGE_MODEL_NAME}): {e}")
69
-
70
-
71
- def allowed_file(filename):
72
- """Checks if a file's extension is in the ALLOWED_EXTENSIONS set."""
73
- return '.' in filename and \
74
- filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
75
-
76
- @app.route('/')
77
- def home():
78
- """Serve the main HTML page."""
79
- return render_template('index.html')
80
-
81
- @app.route('/predict', methods=['POST'])
82
- def predict():
83
- """
84
- Endpoint to receive one or more images, process them immediately,
85
- and return the prediction results.
86
- """
87
- # 1. --- Get Session Key and Validate ---
88
- # Session key can be used for logging or grouping, but doesn't control logic.
89
- session_key = request.form.get('session_key')
90
- if not session_key:
91
- return jsonify({"error": "No session_key provided in the payload"}), 400
92
-
93
- # 2. --- File Validation ---
94
- if 'file' not in request.files:
95
- return jsonify({"error": "No file part in the request"}), 400
96
-
97
- files = request.files.getlist('file')
98
- if not files or all(f.filename == '' for f in files):
99
- return jsonify({"error": "No selected files"}), 400
100
-
101
- # 3. --- Save Files and Prepare for Processing ---
102
- saved_filepaths = []
103
- for file in files:
104
- if file and allowed_file(file.filename):
105
- # Create a unique filename to prevent overwrites
106
- unique_filename = f"{session_key}_{int(time.time()*1000)}_{secure_filename(file.filename)}"
107
- filepath = os.path.join(app.config['UPLOAD_FOLDER'], unique_filename)
108
- file.save(filepath)
109
- saved_filepaths.append(filepath)
110
- else:
111
- print(f"Skipped invalid file: {file.filename}")
112
-
113
- if not saved_filepaths:
114
- return jsonify({"error": "No valid files were uploaded. Allowed types: png, jpg, jpeg"}), 400
115
-
116
- # 4. --- Run Prediction ---
117
- try:
118
- print(f"Processing {len(saved_filepaths)} file(s) for session '{session_key}'...")
119
-
120
- # This function processes the images and returns the prediction results.
121
- results = process_images(parts_model, damage_model, saved_filepaths)
122
-
123
- print(f"Processing complete for session '{session_key}'.")
124
-
125
- # Return the results as a JSON response
126
- return Response(json.dumps(results), mimetype='application/json')
127
-
128
- except Exception as e:
129
- print(f"An error occurred during processing for session {session_key}: {e}")
130
- traceback.print_exc()
131
- return jsonify({"error": f"An error occurred during processing: {str(e)}"}), 500
132
- finally:
133
- # 5. --- Clean up the saved files ---
134
- for filepath in saved_filepaths:
135
- try:
136
- if os.path.exists(filepath):
137
- os.remove(filepath)
138
- except Exception as e:
139
- print(f"Error cleaning up file {filepath}: {e}")
140
-
141
-
142
- if __name__ == '__main__':
143
- app.run(host='0.0.0.0', port=7860, debug=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import traceback
3
+ import uuid
4
+
5
+ import torch
6
+ from dotenv import load_dotenv
7
+ from flask import Flask, jsonify, render_template, request
8
+ from flask_cors import CORS
9
+ from ultralytics import YOLO
10
+ from werkzeug.utils import secure_filename
11
+
12
+ from processing import process_images
13
+
14
+
15
+ BASE_DIR = os.path.dirname(os.path.abspath(__file__))
16
+ load_dotenv(os.path.join(BASE_DIR, '.env'))
17
+
18
+ app = Flask(__name__)
19
+ CORS(app)
20
+
21
+ ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg'}
22
+
23
+
24
+ def resolve_app_path(configured_path, default_relative_path):
25
+ """Resolve configured relative paths from the application directory."""
26
+ path = configured_path or default_relative_path
27
+ if os.path.isabs(path):
28
+ return path
29
+ return os.path.join(BASE_DIR, path)
30
+
31
+
32
+ UPLOAD_FOLDER = resolve_app_path(os.getenv('UPLOAD_FOLDER'), os.path.join('static', 'uploads'))
33
+ MODELS_FOLDER = resolve_app_path(os.getenv('MODELS_FOLDER'), 'models')
34
+ PARTS_MODEL_NAME = os.getenv('PARTS_MODEL_NAME', 'best_parts_EP336.pt')
35
+ DAMAGE_MODEL_NAME = os.getenv('DAMAGE_MODEL_NAME', 'best_new_EP382.pt')
36
+
37
+
38
+ def resolve_model_path(model_name):
39
+ """Resolve an absolute model path or a filename relative to MODELS_FOLDER."""
40
+ if os.path.isabs(model_name):
41
+ return model_name
42
+ return os.path.join(MODELS_FOLDER, model_name)
43
+
44
+
45
+ PARTS_MODEL_PATH = resolve_model_path(PARTS_MODEL_NAME)
46
+ DAMAGE_MODEL_PATH = resolve_model_path(DAMAGE_MODEL_NAME)
47
+
48
+ app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER
49
+ os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True)
50
+ os.makedirs(MODELS_FOLDER, exist_ok=True)
51
+
52
+ device = 'cuda' if torch.cuda.is_available() else 'cpu'
53
+ print(f'Using device: {device}')
54
+
55
+
56
+ def load_model(model_path, label):
57
+ """Load a YOLO model if its configured file exists."""
58
+ if not os.path.isfile(model_path):
59
+ print(f'Warning: {label} model file not found at {model_path}')
60
+ return None
61
+
62
+ try:
63
+ model = YOLO(model_path)
64
+ model.to(device)
65
+ print(f"Successfully loaded {label.lower()} model '{os.path.basename(model_path)}' on {device}.")
66
+ return model
67
+ except Exception as exc:
68
+ print(f'Error loading {label} model ({model_path}): {exc}')
69
+ traceback.print_exc()
70
+ return None
71
+
72
+
73
+ parts_model = load_model(PARTS_MODEL_PATH, 'Parts')
74
+ damage_model = load_model(DAMAGE_MODEL_PATH, 'Damage')
75
+
76
+
77
+ def allowed_file(filename):
78
+ """Return True when filename has an allowed image extension."""
79
+ return bool(filename) and '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
80
+
81
+
82
+ def model_readiness_error():
83
+ """Return a useful model readiness payload, or None when both models are loaded."""
84
+ missing = []
85
+ if parts_model is None:
86
+ missing.append({'model': 'parts', 'path': PARTS_MODEL_PATH})
87
+ if damage_model is None:
88
+ missing.append({'model': 'damage', 'path': DAMAGE_MODEL_PATH})
89
+
90
+ if not missing:
91
+ return None
92
+
93
+ return {
94
+ 'error': 'Prediction models are not ready.',
95
+ 'missing_or_unloaded_models': missing,
96
+ 'hint': 'Place the configured .pt files at the listed paths or update MODELS_FOLDER/model names in .env.'
97
+ }
98
+
99
+
100
+ @app.route('/')
101
+ def home():
102
+ """Serve the main HTML page."""
103
+ return render_template('index.html')
104
+
105
+
106
+ @app.route('/health', methods=['GET'])
107
+ def health():
108
+ """Expose basic service/model readiness without running inference."""
109
+ readiness = model_readiness_error()
110
+ if readiness:
111
+ return jsonify({'status': 'degraded', **readiness}), 503
112
+ return jsonify({'status': 'ok', 'device': device}), 200
113
+
114
+
115
+ @app.route('/predict', methods=['POST'])
116
+ def predict():
117
+ """Receive one or more images and return part/damage predictions."""
118
+ readiness = model_readiness_error()
119
+ if readiness:
120
+ return jsonify(readiness), 503
121
+
122
+ raw_session_key = request.form.get('session_key', '').strip()
123
+ session_key = secure_filename(raw_session_key)[:100] or uuid.uuid4().hex
124
+
125
+ if 'file' not in request.files:
126
+ return jsonify({'error': 'No file part in the request'}), 400
127
+
128
+ files = request.files.getlist('file')
129
+ if not files or all(not file.filename for file in files):
130
+ return jsonify({'error': 'No selected files'}), 400
131
+
132
+ saved_images = []
133
+ skipped_files = []
134
+
135
+ try:
136
+ for index, file in enumerate(files):
137
+ original_filename = file.filename or ''
138
+ if not (file and allowed_file(original_filename)):
139
+ skipped_files.append(original_filename or f'file_{index}')
140
+ continue
141
+
142
+ safe_original = secure_filename(original_filename) or f'image_{index}.jpg'
143
+ unique_filename = f'{uuid.uuid4().hex}_{safe_original}'
144
+ filepath = os.path.join(app.config['UPLOAD_FOLDER'], unique_filename)
145
+ file.save(filepath)
146
+ saved_images.append({
147
+ 'path': filepath,
148
+ 'filename': original_filename,
149
+ 'index': index,
150
+ })
151
+
152
+ if not saved_images:
153
+ return jsonify({
154
+ 'error': 'No valid files were uploaded. Allowed types: png, jpg, jpeg',
155
+ 'skipped_files': skipped_files,
156
+ }), 400
157
+
158
+ print(f"Processing {len(saved_images)} file(s) for session '{session_key}'...")
159
+ results = process_images(parts_model, damage_model, saved_images)
160
+ print(f"Processing complete for session '{session_key}'.")
161
+ return jsonify(results)
162
+
163
+ except Exception as exc:
164
+ print(f'An error occurred during processing for session {session_key}: {exc}')
165
+ traceback.print_exc()
166
+ return jsonify({'error': f'An error occurred during processing: {exc}'}), 500
167
+
168
+ finally:
169
+ for image in saved_images:
170
+ filepath = image['path']
171
+ try:
172
+ if os.path.exists(filepath):
173
+ os.remove(filepath)
174
+ except Exception as exc:
175
+ print(f'Error cleaning up file {filepath}: {exc}')
176
+
177
+
178
+ if __name__ == '__main__':
179
+ app.run(host='0.0.0.0', port=7860, debug=False)
processing.py CHANGED
@@ -1,83 +1,96 @@
1
- # processing.py
2
-
3
- import os
4
- from ultralytics import YOLO
5
-
6
- # --- Configuration ---
7
- # These are the specific parts that require a subsequent damage check.
8
- DAMAGE_CHECK_PARTS = {
9
- 'driver_front_side',
10
- 'driver_rear_side',
11
- 'passenger_front_side',
12
- 'passenger_rear_side',
13
- }
14
-
15
- def run_single_inference(model, filepath):
16
- """
17
- Helper function to run inference for a single model and format the result.
18
- """
19
- if model is None:
20
- return None # Return None if the model isn't loaded
21
-
22
- results = model(filepath, verbose=False) # verbose=False to keep logs clean
23
- result = results[0]
24
-
25
- # Check if it's a classification model with probabilities
26
- if result.probs is not None:
27
- probs = result.probs
28
- top1_index = probs.top1
29
- top1_confidence = float(probs.top1conf)
30
- class_name = model.names[top1_index]
31
- else: # Fallback for detection models or if probs are not available
32
- # Assuming the top prediction is what we need
33
- top1_index = result.boxes.cls[0].int() if len(result.boxes) > 0 else 0
34
- top1_confidence = float(result.boxes.conf[0]) if len(result.boxes) > 0 else 0.0
35
- class_name = model.names[top1_index] if len(result.boxes) > 0 else "unknown"
36
-
37
- return {
38
- "class": class_name,
39
- "confidence": round(top1_confidence, 4)
40
- }
41
-
42
- def process_images(parts_model, damage_model, image_paths):
43
- """
44
- Processes a list of images.
45
- 1. Runs the 'parts_model' on every image.
46
- 2. If the detected part is in DAMAGE_CHECK_PARTS, it then runs the 'damage_model'.
47
- 3. Otherwise, the damage status defaults to 'correct'.
48
- """
49
- if parts_model is None or damage_model is None:
50
- raise RuntimeError("One or more models are not loaded. Check server logs.")
51
-
52
- final_results = []
53
-
54
- for filepath in image_paths:
55
- filename = os.path.basename(filepath)
56
- print(f"Processing {filename}...")
57
-
58
- # 1. First, predict the part
59
- part_prediction = run_single_inference(parts_model, filepath)
60
- predicted_part = part_prediction.get("class") if part_prediction else "unknown"
61
-
62
- damage_prediction = None
63
- # 2. Conditionally predict the damage
64
- if predicted_part in DAMAGE_CHECK_PARTS:
65
- print(f" -> Part '{predicted_part}' requires damage check. Running damage model...")
66
- damage_prediction = run_single_inference(damage_model, filepath)
67
- else:
68
- print(f" -> Part '{predicted_part}' does not require damage check. Defaulting to 'correct'.")
69
- # 3. For other parts, default to 'correct'
70
- damage_prediction = {
71
- "class": "correct",
72
- "confidence": 1.0,
73
- "note": "Result by default, not by model inference."
74
- }
75
-
76
- # Assemble the final result for this image
77
- final_results.append({
78
- "filename": filename,
79
- "part_prediction": part_prediction,
80
- "damage_prediction": damage_prediction
81
- })
82
-
83
- return final_results
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+
3
+
4
+ DAMAGE_CHECK_PARTS = {
5
+ 'driver_front_side',
6
+ 'driver_rear_side',
7
+ 'passenger_front_side',
8
+ 'passenger_rear_side',
9
+ }
10
+
11
+
12
+ def _class_name(model, class_index):
13
+ """Read a class label from YOLO names whether it is a dict or a list."""
14
+ names = model.names
15
+ if isinstance(names, dict):
16
+ return names.get(class_index, 'unknown')
17
+ if 0 <= class_index < len(names):
18
+ return names[class_index]
19
+ return 'unknown'
20
+
21
+
22
+ def run_single_inference(model, filepath):
23
+ """Run one YOLO model on one image and normalize the top prediction."""
24
+ if model is None:
25
+ raise RuntimeError('Inference model is not loaded.')
26
+
27
+ results = model(filepath, verbose=False)
28
+ if not results:
29
+ return {'class': 'unknown', 'confidence': 0.0}
30
+
31
+ result = results[0]
32
+
33
+ if result.probs is not None:
34
+ class_index = int(result.probs.top1)
35
+ confidence = float(result.probs.top1conf)
36
+ class_name = _class_name(model, class_index)
37
+ elif result.boxes is not None and len(result.boxes) > 0:
38
+ class_index = int(result.boxes.cls[0].item())
39
+ confidence = float(result.boxes.conf[0].item())
40
+ class_name = _class_name(model, class_index)
41
+ else:
42
+ class_name = 'unknown'
43
+ confidence = 0.0
44
+
45
+ return {
46
+ 'class': class_name,
47
+ 'confidence': round(confidence, 4),
48
+ }
49
+
50
+
51
+ def process_images(parts_model, damage_model, image_inputs):
52
+ """
53
+ Process uploaded images while preserving each browser-visible filename/index.
54
+
55
+ image_inputs accepts dictionaries with path, filename, and index. Plain path
56
+ strings are also accepted for compatibility with older callers.
57
+ """
58
+ if parts_model is None or damage_model is None:
59
+ raise RuntimeError('One or more models are not loaded. Check server logs.')
60
+
61
+ final_results = []
62
+
63
+ for fallback_index, image_input in enumerate(image_inputs):
64
+ if isinstance(image_input, dict):
65
+ filepath = image_input['path']
66
+ filename = image_input.get('filename') or os.path.basename(filepath)
67
+ client_index = image_input.get('index', fallback_index)
68
+ else:
69
+ filepath = image_input
70
+ filename = os.path.basename(filepath)
71
+ client_index = fallback_index
72
+
73
+ print(f'Processing {filename}...')
74
+
75
+ part_prediction = run_single_inference(parts_model, filepath)
76
+ predicted_part = part_prediction['class']
77
+
78
+ if predicted_part in DAMAGE_CHECK_PARTS:
79
+ print(f" -> Part '{predicted_part}' requires damage check. Running damage model...")
80
+ damage_prediction = run_single_inference(damage_model, filepath)
81
+ else:
82
+ print(f" -> Part '{predicted_part}' does not require damage check. Defaulting to 'correct'.")
83
+ damage_prediction = {
84
+ 'class': 'correct',
85
+ 'confidence': 1.0,
86
+ 'note': 'Result by default, not by model inference.',
87
+ }
88
+
89
+ final_results.append({
90
+ 'index': client_index,
91
+ 'filename': filename,
92
+ 'part_prediction': part_prediction,
93
+ 'damage_prediction': damage_prediction,
94
+ })
95
+
96
+ return final_results
requirements.txt CHANGED
@@ -1,8 +1,7 @@
1
- Flask==3.1.1
2
- flask_cors==5.0.1
3
- python-dotenv==1.1.0
4
- torch
5
- ultralytics==8.3.151
6
- Werkzeug==3.1.3
7
- opencv-python-headless==4.10.0.84
8
- psycopg2-binary==2.9.10
 
1
+ Flask==3.1.1
2
+ flask_cors==5.0.1
3
+ python-dotenv==1.1.0
4
+ torch
5
+ ultralytics==8.3.151
6
+ Werkzeug==3.1.3
7
+ opencv-python-headless==4.10.0.84
 
templates/index.html ADDED
@@ -0,0 +1,431 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>YOLO Vision AI - Multi-Image Analysis</title>
7
+ <style>
8
+ * {
9
+ margin: 0;
10
+ padding: 0;
11
+ box-sizing: border-box;
12
+ }
13
+
14
+ body {
15
+ font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
16
+ background: linear-gradient(135deg, #0f0f23 0%, #1a1a2e 50%, #16213e 100%);
17
+ min-height: 100vh;
18
+ overflow-x: hidden;
19
+ position: relative;
20
+ color: #e0e0e0;
21
+ }
22
+
23
+ .particles {
24
+ position: absolute; width: 100%; height: 100%; overflow: hidden; z-index: 0;
25
+ }
26
+ .particle {
27
+ position: absolute; width: 2px; height: 2px; background: #00d4ff; border-radius: 50%; animation: float 6s ease-in-out infinite; opacity: 0.6;
28
+ }
29
+ @keyframes float {
30
+ 0%, 100% { transform: translateY(0px) rotate(0deg); }
31
+ 50% { transform: translateY(-20px) rotate(180deg); }
32
+ }
33
+
34
+ .container {
35
+ position: relative; z-index: 1; max-width: 800px; margin: 0 auto; padding: 2rem; min-height: 100vh; display: flex; flex-direction: column; justify-content: flex-start; align-items: center;
36
+ }
37
+
38
+ .header {
39
+ text-align: center; margin-bottom: 2rem; animation: slideDown 1s ease-out; width: 100%;
40
+ }
41
+ .title {
42
+ font-size: 3.5rem; font-weight: 700; background: linear-gradient(45deg, #00d4ff, #ff00ff, #00ff88); background-size: 200% 200%; -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; animation: gradientShift 3s ease-in-out infinite; margin-bottom: 1rem; text-shadow: 0 0 30px rgba(0, 212, 255, 0.5);
43
+ }
44
+ .subtitle {
45
+ font-size: 1.2rem; color: #a0a0a0; font-weight: 300;
46
+ }
47
+
48
+ .upload-area {
49
+ width: 100%; max-width: 550px; min-height: 300px; border: 2px dashed #00d4ff; border-radius: 20px; background: rgba(0, 212, 255, 0.05); backdrop-filter: blur(10px); display: flex; flex-direction: column; justify-content: center; align-items: center; cursor: pointer; transition: all 0.3s ease; position: relative; overflow: hidden; animation: slideUp 1s ease-out 0.3s both;
50
+ }
51
+ .upload-area:hover {
52
+ border-color: #ff00ff; background: rgba(255, 0, 255, 0.05); transform: translateY(-5px); box-shadow: 0 20px 40px rgba(0, 212, 255, 0.2);
53
+ }
54
+ .upload-area.dragover {
55
+ border-color: #00ff88; background: rgba(0, 255, 136, 0.1); transform: scale(1.02);
56
+ }
57
+ .upload-icon {
58
+ font-size: 4rem; color: #00d4ff; margin-bottom: 1rem; transition: all 0.3s ease;
59
+ }
60
+ .upload-area:hover .upload-icon {
61
+ color: #ff00ff; transform: scale(1.1);
62
+ }
63
+ .upload-text {
64
+ color: #ffffff; font-size: 1.1rem; margin-bottom: 0.5rem; font-weight: 500;
65
+ }
66
+ .upload-subtext {
67
+ color: #a0a0a0; font-size: 0.9rem;
68
+ }
69
+ .file-input {
70
+ display: none;
71
+ }
72
+
73
+ .file-list-container {
74
+ display: none;
75
+ width: 100%;
76
+ max-width: 550px;
77
+ margin-top: 2rem;
78
+ animation: fadeIn 0.5s ease;
79
+ }
80
+ #fileList {
81
+ list-style: none;
82
+ background: rgba(0, 212, 255, 0.05);
83
+ border-radius: 10px;
84
+ padding: 1rem;
85
+ max-height: 200px;
86
+ overflow-y: auto;
87
+ border: 1px solid rgba(0, 212, 255, 0.2);
88
+ }
89
+ #fileList li {
90
+ padding: 0.5rem;
91
+ border-bottom: 1px solid rgba(255, 255, 255, 0.1);
92
+ color: #c0c0c0;
93
+ }
94
+ #fileList li:last-child {
95
+ border-bottom: none;
96
+ }
97
+ .analyze-button {
98
+ display: block;
99
+ width: 100%;
100
+ background: linear-gradient(45deg, #00d4ff, #0099cc); border: none; color: white; padding: 15px 40px; font-size: 1.1rem; font-weight: 600; border-radius: 50px; cursor: pointer; margin-top: 1.5rem; transition: all 0.3s ease; text-transform: uppercase; letter-spacing: 1px;
101
+ }
102
+ .analyze-button:hover {
103
+ transform: translateY(-2px); box-shadow: 0 10px 25px rgba(0, 212, 255, 0.4); background: linear-gradient(45deg, #ff00ff, #cc0099);
104
+ }
105
+ .analyze-button:disabled {
106
+ opacity: 0.6; cursor: not-allowed; transform: none; box-shadow: none; background: #555;
107
+ }
108
+
109
+ .loading {
110
+ display: none; margin-top: 2rem; text-align: center;
111
+ }
112
+ .spinner {
113
+ width: 40px; height: 40px; border: 4px solid rgba(0, 212, 255, 0.3); border-top: 4px solid #00d4ff; border-radius: 50%; animation: spin 1s linear infinite; margin: 0 auto;
114
+ }
115
+
116
+ #results-container {
117
+ margin-top: 2rem;
118
+ width: 100%;
119
+ max-width: 550px; /* Adjusted max-width */
120
+ }
121
+ .result-card {
122
+ padding: 1.5rem;
123
+ background: rgba(255, 255, 255, 0.05);
124
+ backdrop-filter: blur(15px);
125
+ border: 1px solid rgba(0, 212, 255, 0.3);
126
+ animation: slideUp 0.5s ease-out;
127
+ margin-bottom: 2rem;
128
+ border-radius: 15px; /* Unified border radius */
129
+ }
130
+
131
+ /* --- NEW: Image style within the card --- */
132
+ .result-image {
133
+ width: 100%;
134
+ height: auto;
135
+ max-height: 400px;
136
+ object-fit: contain;
137
+ border-radius: 10px;
138
+ margin-bottom: 1.5rem;
139
+ background-color: rgba(0,0,0,0.2);
140
+ }
141
+
142
+ .result-card h3 {
143
+ font-size: 1.2rem;
144
+ color: #00d4ff;
145
+ margin-bottom: 1.5rem;
146
+ padding-bottom: 1rem;
147
+ border-bottom: 1px solid rgba(0, 212, 255, 0.2);
148
+ font-weight: 600;
149
+ word-wrap: break-word;
150
+ }
151
+ .prediction-block {
152
+ margin-bottom: 1.5rem;
153
+ }
154
+ .prediction-block:last-child {
155
+ margin-bottom: 0;
156
+ }
157
+ .prediction-title {
158
+ font-size: 0.9rem;
159
+ color: #a0a0a0;
160
+ text-transform: uppercase;
161
+ letter-spacing: 1px;
162
+ margin-bottom: 0.5rem;
163
+ }
164
+ .prediction-class {
165
+ font-size: 1.8rem; /* Made class name larger */
166
+ font-weight: 700;
167
+ color: #00ff88;
168
+ text-transform: capitalize;
169
+ line-height: 1.2;
170
+ }
171
+ .prediction-confidence {
172
+ font-size: 1rem; /* Slightly larger confidence text */
173
+ color: #e0e0e0;
174
+ }
175
+ .damage-note {
176
+ font-size: 0.8rem;
177
+ color: #aaa;
178
+ font-style: italic;
179
+ margin-top: 4px;
180
+ }
181
+
182
+ .error {
183
+ color: #ff4444; background: rgba(255, 68, 68, 0.1); padding: 1rem; border-radius: 10px; border: 1px solid #ff4444; margin-top: 2rem; width: 100%; max-width: 550px;
184
+ }
185
+
186
+ @keyframes slideDown { from { opacity: 0; transform: translateY(-50px); } to { opacity: 1; transform: translateY(0); } }
187
+ @keyframes slideUp { from { opacity: 0; transform: translateY(50px); } to { opacity: 1; transform: translateY(0); } }
188
+ @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
189
+ @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
190
+ @keyframes gradientShift { 0%, 100% { background-position: 0% 50%; } 50% { background-position: 100% 50%; } }
191
+
192
+ @media (max-width: 768px) {
193
+ .title { font-size: 2.5rem; } .container { padding: 1rem; } .upload-area { min-height: 250px; }
194
+ }
195
+ </style>
196
+ </head>
197
+ <body>
198
+ <div class="particles" id="particles"></div>
199
+
200
+ <div class="container">
201
+ <div class="header">
202
+ <h1 class="title">YOLO Vision AI</h1>
203
+ <p class="subtitle">Multi-Image Vehicle Part & Damage Analysis</p>
204
+ </div>
205
+
206
+ <div class="upload-area" id="uploadArea">
207
+ <div class="upload-icon">🔮</div>
208
+ <div class="upload-text">Drop your images here or click to upload</div>
209
+ <div class="upload-subtext">Supports PNG, JPG, JPEG formats</div>
210
+ <input type="file" id="fileInput" class="file-input" accept=".png,.jpg,.jpeg" multiple>
211
+ </div>
212
+
213
+ <div class="file-list-container" id="fileListContainer">
214
+ <ul id="fileList"></ul>
215
+ <button class="analyze-button" id="analyzeButton">🚀 Analyze Images</button>
216
+ </div>
217
+
218
+ <div class="loading" id="loading">
219
+ <div class="spinner"></div>
220
+ <p style="color: #00d4ff; margin-top: 1rem;">Processing your images...</p>
221
+ </div>
222
+
223
+ <div id="results-container"></div>
224
+
225
+ <div class="error" id="errorContainer" style="display: none;"></div>
226
+ </div>
227
+
228
+ <script>
229
+ // Create animated particles
230
+ function createParticles() {
231
+ const container = document.getElementById('particles');
232
+ if (container.children.length > 0) return;
233
+ for (let i = 0; i < 50; i++) {
234
+ const particle = document.createElement('div');
235
+ particle.className = 'particle';
236
+ particle.style.left = Math.random() * 100 + '%';
237
+ particle.style.top = Math.random() * 100 + '%';
238
+ particle.style.animationDelay = Math.random() * 6 + 's';
239
+ particle.style.animationDuration = (3 + Math.random() * 3) + 's';
240
+ container.appendChild(particle);
241
+ }
242
+ }
243
+ createParticles();
244
+
245
+ // DOM elements
246
+ const uploadArea = document.getElementById('uploadArea');
247
+ const fileInput = document.getElementById('fileInput');
248
+ const fileListContainer = document.getElementById('fileListContainer');
249
+ const fileList = document.getElementById('fileList');
250
+ const analyzeButton = document.getElementById('analyzeButton');
251
+ const loading = document.getElementById('loading');
252
+ const errorContainer = document.getElementById('errorContainer');
253
+ const resultsContainer = document.getElementById('results-container');
254
+
255
+ // Store file objects and their data URLs for preview.
256
+ let fileDataStore = [];
257
+ const sessionKey = (window.crypto && crypto.randomUUID)
258
+ ? crypto.randomUUID()
259
+ : `session-${Date.now()}-${Math.random().toString(16).slice(2)}`;
260
+
261
+ // Event Listeners
262
+ uploadArea.addEventListener('click', () => fileInput.click());
263
+ fileInput.addEventListener('change', handleFileSelect);
264
+ ['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
265
+ uploadArea.addEventListener(eventName, preventDefaults, false);
266
+ });
267
+ ['dragenter', 'dragover'].forEach(eventName => {
268
+ uploadArea.addEventListener(eventName, () => uploadArea.classList.add('dragover'), false);
269
+ });
270
+ ['dragleave', 'drop'].forEach(eventName => {
271
+ uploadArea.addEventListener(eventName, () => uploadArea.classList.remove('dragover'), false);
272
+ });
273
+ uploadArea.addEventListener('drop', handleDrop, false);
274
+ analyzeButton.addEventListener('click', analyzeImages);
275
+
276
+ function preventDefaults(e) {
277
+ e.preventDefault();
278
+ e.stopPropagation();
279
+ }
280
+
281
+ function handleDrop(e) {
282
+ handleFiles(e.dataTransfer.files);
283
+ }
284
+
285
+ function handleFileSelect(e) {
286
+ handleFiles(e.target.files);
287
+ }
288
+
289
+ // --- UPDATED: Reads files and generates data URLs for previews ---
290
+ async function handleFiles(files) {
291
+ if (files.length === 0) return;
292
+
293
+ // Clear previous selections and results
294
+ resetUI();
295
+ fileDataStore = [];
296
+
297
+ const filePromises = Array.from(files).map(file => {
298
+ return new Promise((resolve, reject) => {
299
+ const reader = new FileReader();
300
+ reader.onload = (e) => {
301
+ fileDataStore.push({ file: file, dataURL: e.target.result });
302
+ resolve();
303
+ };
304
+ reader.onerror = reject;
305
+ reader.readAsDataURL(file);
306
+ });
307
+ });
308
+
309
+ await Promise.all(filePromises);
310
+
311
+ // Update the UI list
312
+ fileDataStore.forEach(item => {
313
+ const listItem = document.createElement('li');
314
+ listItem.textContent = `${item.file.name} (${(item.file.size / 1024).toFixed(1)} KB)`;
315
+ fileList.appendChild(listItem);
316
+ });
317
+
318
+ fileListContainer.style.display = 'block';
319
+ uploadArea.style.display = 'none'; // Hide upload area after selection
320
+ }
321
+
322
+ async function analyzeImages() {
323
+ if (fileDataStore.length === 0) {
324
+ showError('Please select one or more images first');
325
+ return;
326
+ }
327
+
328
+ loading.style.display = 'block';
329
+ analyzeButton.disabled = true;
330
+ hideError();
331
+ resultsContainer.innerHTML = '';
332
+
333
+ try {
334
+ const formData = new FormData();
335
+ formData.append('session_key', sessionKey);
336
+ fileDataStore.forEach(item => {
337
+ formData.append('file', item.file);
338
+ });
339
+
340
+ const response = await fetch('/predict', { method: 'POST', body: formData });
341
+ const data = await response.json();
342
+
343
+ if (response.ok) {
344
+ displayResults(data);
345
+ } else {
346
+ showError(data.error || 'An unknown error occurred during prediction');
347
+ }
348
+ } catch (error) {
349
+ showError('Failed to connect to the server. Please check your connection and try again.');
350
+ console.error('Error:', error);
351
+ } finally {
352
+ loading.style.display = 'none';
353
+ analyzeButton.disabled = false;
354
+ fileInput.value = '';
355
+ }
356
+ }
357
+
358
+ // --- UPDATED: Displays results with image previews ---
359
+ function displayResults(results) {
360
+ if (!Array.isArray(results) || results.length === 0) {
361
+ resultsContainer.innerHTML = '<p>No results were returned from the server.</p>';
362
+ return;
363
+ }
364
+
365
+ results.forEach(result => {
366
+ const fileData = Number.isInteger(result.index)
367
+ ? fileDataStore[result.index]
368
+ : fileDataStore.find(item => item.file.name === result.filename);
369
+ if (!fileData) return;
370
+
371
+ const card = document.createElement('div');
372
+ card.className = 'result-card';
373
+
374
+ const partPred = result.part_prediction || { class: 'unknown', confidence: 0 };
375
+ const damagePred = result.damage_prediction || { class: 'unknown', confidence: 0 };
376
+ const safeFilename = escapeHtml(result.filename || fileData.file.name);
377
+ const safePartClass = escapeHtml(String(partPred.class || 'unknown').replace(/_/g, ' '));
378
+ const safeDamageClass = escapeHtml(String(damagePred.class || 'unknown'));
379
+ const damageNote = damagePred.note
380
+ ? `<div class="damage-note">${escapeHtml(String(damagePred.note))}</div>`
381
+ : '';
382
+ const damageColor = damagePred.class === 'correct' ? '#00ff88' : '#ff4444';
383
+
384
+ card.innerHTML = `
385
+ <img src="${fileData.dataURL}" alt="${safeFilename}" class="result-image">
386
+ <h3>${safeFilename}</h3>
387
+ <div class="prediction-block">
388
+ <div class="prediction-title">Part Detected</div>
389
+ <div class="prediction-class">${safePartClass}</div>
390
+ <div class="prediction-confidence">Confidence: ${(Number(partPred.confidence || 0) * 100).toFixed(2)}%</div>
391
+ </div>
392
+ <div class="prediction-block">
393
+ <div class="prediction-title">Damage Status</div>
394
+ <div class="prediction-class" style="color: ${damageColor};">${safeDamageClass}</div>
395
+ <div class="prediction-confidence">Confidence: ${(Number(damagePred.confidence || 0) * 100).toFixed(2)}%</div>
396
+ ${damageNote}
397
+ </div>
398
+ `;
399
+ resultsContainer.appendChild(card);
400
+ });
401
+ }
402
+
403
+ function escapeHtml(value) {
404
+ return value.replace(/[&<>"']/g, char => ({
405
+ '&': '&amp;',
406
+ '<': '&lt;',
407
+ '>': '&gt;',
408
+ '"': '&quot;',
409
+ "'": '&#039;'
410
+ })[char]);
411
+ }
412
+
413
+ function resetUI() {
414
+ fileList.innerHTML = '';
415
+ resultsContainer.innerHTML = '';
416
+ fileListContainer.style.display = 'none';
417
+ uploadArea.style.display = 'flex';
418
+ hideError();
419
+ }
420
+
421
+ function showError(message) {
422
+ errorContainer.textContent = message;
423
+ errorContainer.style.display = 'block';
424
+ }
425
+
426
+ function hideError() {
427
+ errorContainer.style.display = 'none';
428
+ }
429
+ </script>
430
+ </body>
431
+ </html>