sloneckity commited on
Commit
7954a1d
·
1 Parent(s): 7d41249

Improve error handling in both frontend and backend

Browse files
Files changed (2) hide show
  1. app.py +176 -143
  2. static/script.js +11 -0
app.py CHANGED
@@ -5,6 +5,8 @@ from pathlib import Path
5
  import uuid
6
  import pandas as pd # Added for CSV parsing
7
  from werkzeug.utils import secure_filename # Added for security
 
 
8
 
9
  app = Flask(__name__)
10
  # Use absolute paths for robustness within Docker/HF Spaces
@@ -20,6 +22,15 @@ app.config['ALLOWED_EXTENSIONS'] = {'png', 'jpg', 'jpeg', 'tif', 'tiff'}
20
  UPLOAD_FOLDER.mkdir(parents=True, exist_ok=True)
21
  RESULT_FOLDER.mkdir(parents=True, exist_ok=True)
22
 
 
 
 
 
 
 
 
 
 
23
  def allowed_file(filename):
24
  return '.' in filename and \
25
  filename.rsplit('.', 1)[1].lower() in app.config['ALLOWED_EXTENSIONS']
@@ -30,160 +41,172 @@ def index():
30
 
31
  @app.route('/process', methods=['POST'])
32
  def process_images():
33
- if 'files' not in request.files:
34
- return jsonify({"error": "No file part"}), 400
35
-
36
- files = request.files.getlist('files')
37
- input_mode = request.form.get('input_mode', 'single') # Get from form data
38
- confidence = request.form.get('confidence_threshold', '0.6') # Get from form data
39
-
40
- if not files or files[0].filename == '':
41
- return jsonify({"error": "No selected file"}), 400
42
-
43
- # Create a unique job directory within results
44
- job_id = str(uuid.uuid4())
45
- job_input_dir = RESULT_FOLDER / job_id / 'input' # Save inputs within job dir
46
- job_output_dir = RESULT_FOLDER / job_id / 'output' # Save outputs within job dir
47
- job_input_dir.mkdir(parents=True, exist_ok=True)
48
- job_output_dir.mkdir(parents=True, exist_ok=True)
49
-
50
- saved_files = []
51
- error_files = []
52
-
53
- for file in files:
54
- if file and allowed_file(file.filename):
55
- filename = secure_filename(file.filename)
56
- save_path = job_input_dir / filename
57
- file.save(str(save_path))
58
- saved_files.append(save_path)
59
- elif file:
60
- error_files.append(file.filename)
61
-
62
- if not saved_files:
63
- return jsonify({"error": f"No valid files uploaded. Invalid files: {error_files}"}), 400
64
-
65
- # --- Prepare and Run nemaquant.py ---
66
- # Determine input target for nemaquant.py
67
- if input_mode == 'single' and len(saved_files) == 1:
68
- input_target = str(saved_files[0])
69
- img_mode_arg = 'file' # nemaquant uses file/dir, not single/directory
70
- elif input_mode == 'directory' and len(saved_files) >= 1:
71
- input_target = str(job_input_dir) # Pass the directory containing the images
72
- img_mode_arg = 'dir'
73
- else:
74
- # Mismatch between mode and number of files
75
- return jsonify({"error": f"Input mode '{input_mode}' requires {'1 file' if input_mode == 'single' else '1 or more files'}, but received {len(saved_files)}."}), 400
76
-
77
-
78
- output_csv = job_output_dir / f"{job_id}_results.csv"
79
- annotated_dir = job_output_dir # Save annotated images directly in job output dir
80
-
81
- cmd = [
82
- 'python', str(APP_ROOT / 'nemaquant.py'),
83
- '-i', input_target,
84
- '-w', str(WEIGHTS_FILE), # Use absolute path
85
- '-o', str(output_csv),
86
- '-a', str(annotated_dir),
87
- '--conf', confidence
88
- ]
89
-
90
- # We don't need --key or XY mode for this web interface initially
91
-
92
  try:
93
- print(f"Running command: {' '.join(cmd)}") # Log the command
94
- print(f"Input directory contents: {os.listdir(str(job_input_dir))}")
95
- print(f"Weights file exists: {os.path.exists(str(WEIGHTS_FILE))}")
96
-
97
- # Run the script, capture output and errors
98
- # Timeout might be needed for long processes on shared infrastructure like HF Spaces
99
- result = subprocess.run(cmd, capture_output=True, text=True, check=True, timeout=300) # 5 min timeout
100
- status_log = f"NemaQuant Output:\n{result.stdout}\nNemaQuant Errors:\n{result.stderr}"
101
- print(status_log) # Log script output
102
-
103
- # Check output directory after command runs
104
- print(f"Output directory exists: {os.path.exists(str(job_output_dir))}")
105
- if os.path.exists(str(job_output_dir)):
106
- print(f"Output directory contents: {os.listdir(str(job_output_dir))}")
107
-
108
- # Check output file
109
- print(f"Output CSV exists: {os.path.exists(str(output_csv))}")
110
-
111
- # --- Parse Results ---
112
- if not output_csv.exists():
113
- raise FileNotFoundError(f"Output CSV not found at {output_csv}")
114
-
115
- df = pd.read_csv(output_csv)
116
- # Expect columns like 'filename', 'num_eggs' (based on nemaquant.py)
117
- # Find corresponding annotated images
118
- results_list = []
119
- for index, row in df.iterrows():
120
- original_filename = row.get('filename', '')
121
- num_eggs = row.get('num_eggs', 'N/A')
122
- # Construct expected annotated filename (based on nemaquant.py logic)
123
- stem = Path(original_filename).stem
124
- suffix = Path(original_filename).suffix
125
- annotated_filename = f"{stem}_annotated{suffix}"
126
- annotated_path = annotated_dir / annotated_filename
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
127
 
128
- print(f"Looking for annotated file: {annotated_path}, exists: {annotated_path.exists()}")
129
-
130
- results_list.append({
131
- "filename": original_filename,
132
- "num_eggs": num_eggs,
133
- # Pass relative path within job dir for frontend URL construction
134
- "annotated_filename": annotated_filename if annotated_path.exists() else None,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
135
  })
136
 
137
- return jsonify({
138
- "status": "success",
139
- "job_id": job_id,
140
- "results": results_list,
141
- "log": status_log,
142
- "error_files": error_files # Report files that were not processed
143
- })
144
-
145
- except subprocess.CalledProcessError as e:
146
- error_message = f"Error running NemaQuant:\nExit Code: {e.returncode}\nSTDOUT:\n{e.stdout}\nSTDERR:\n{e.stderr}"
147
- print(error_message)
148
- return jsonify({"error": "Processing failed", "log": error_message}), 500
149
- except subprocess.TimeoutExpired as e:
150
- error_message = f"Error running NemaQuant: Process timed out after {e.timeout} seconds.\nSTDOUT:\n{e.stdout}\nSTDERR:\n{e.stderr}"
151
- print(error_message)
152
- return jsonify({"error": "Processing timed out", "log": error_message}), 500
153
- except FileNotFoundError as e:
154
- error_message = f"Error processing results: {e}"
155
- print(error_message)
156
- return jsonify({"error": "Could not find output file", "log": error_message}), 500
157
  except Exception as e:
158
- error_message = f"An unexpected error occurred: {str(e)}\n"
159
- import traceback
160
- error_message += traceback.format_exc()
161
  print(error_message)
162
- return jsonify({"error": "An unexpected error occurred", "log": error_message}), 500
163
 
164
 
165
  @app.route('/results/<job_id>/<path:filename>')
166
  def download_file(job_id, filename):
167
- # Construct the full path to the file within the job's output directory
168
- # Use secure_filename on the incoming filename part for safety? Maybe not needed if we trust our generated paths.
169
- # Crucially, validate job_id and filename to prevent directory traversal.
170
- # A simple check: ensure job_id is a valid UUID format and filename doesn't contain '..'
171
- try:
172
- uuid.UUID(job_id, version=4) # Validate UUID format
173
- except ValueError:
174
- return "Invalid job ID format", 400
175
-
176
- if '..' in filename or filename.startswith('/'):
177
- return "Invalid filename", 400
178
-
179
- file_dir = RESULT_FOLDER / job_id / 'output'
180
- # Use send_from_directory for security (handles path joining and prevents traversal above the specified directory)
181
- print(f"Attempting to send file: {filename} from directory: {file_dir}")
182
  try:
183
- return send_from_directory(str(file_dir), filename, as_attachment=False) # Display images inline
184
- except FileNotFoundError:
185
- print(f"File not found: {file_dir / filename}")
186
- return "File not found", 404
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
187
 
188
 
189
  if __name__ == '__main__':
@@ -193,4 +216,14 @@ if __name__ == '__main__':
193
  print("Please ensure 'weights.pt' is in the application root directory.")
194
  exit(1) # Exit if weights are missing
195
 
 
 
 
 
 
 
 
 
 
 
196
  app.run(debug=True, host='0.0.0.0', port=7860) # Port 7860 is common for HF Spaces
 
5
  import uuid
6
  import pandas as pd # Added for CSV parsing
7
  from werkzeug.utils import secure_filename # Added for security
8
+ import traceback
9
+ import sys
10
 
11
  app = Flask(__name__)
12
  # Use absolute paths for robustness within Docker/HF Spaces
 
22
  UPLOAD_FOLDER.mkdir(parents=True, exist_ok=True)
23
  RESULT_FOLDER.mkdir(parents=True, exist_ok=True)
24
 
25
+ # Global error handler to ensure JSON responses
26
+ @app.errorhandler(Exception)
27
+ def handle_exception(e):
28
+ # Log the exception
29
+ print(f"Unhandled exception: {str(e)}")
30
+ print(traceback.format_exc())
31
+ # Return JSON instead of HTML for HTTP errors
32
+ return jsonify({"error": "Server error", "log": str(e)}), 500
33
+
34
  def allowed_file(filename):
35
  return '.' in filename and \
36
  filename.rsplit('.', 1)[1].lower() in app.config['ALLOWED_EXTENSIONS']
 
41
 
42
  @app.route('/process', methods=['POST'])
43
  def process_images():
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
44
  try:
45
+ if 'files' not in request.files:
46
+ return jsonify({"error": "No file part"}), 400
47
+
48
+ files = request.files.getlist('files')
49
+ input_mode = request.form.get('input_mode', 'single') # Get from form data
50
+ confidence = request.form.get('confidence_threshold', '0.6') # Get from form data
51
+
52
+ if not files or files[0].filename == '':
53
+ return jsonify({"error": "No selected file"}), 400
54
+
55
+ # Create a unique job directory within results
56
+ job_id = str(uuid.uuid4())
57
+ job_input_dir = RESULT_FOLDER / job_id / 'input' # Save inputs within job dir
58
+ job_output_dir = RESULT_FOLDER / job_id / 'output' # Save outputs within job dir
59
+ job_input_dir.mkdir(parents=True, exist_ok=True)
60
+ job_output_dir.mkdir(parents=True, exist_ok=True)
61
+
62
+ saved_files = []
63
+ error_files = []
64
+
65
+ for file in files:
66
+ if file and allowed_file(file.filename):
67
+ filename = secure_filename(file.filename)
68
+ save_path = job_input_dir / filename
69
+ file.save(str(save_path))
70
+ saved_files.append(save_path)
71
+ elif file:
72
+ error_files.append(file.filename)
73
+
74
+ if not saved_files:
75
+ return jsonify({"error": f"No valid files uploaded. Invalid files: {error_files}"}), 400
76
+
77
+ # --- Prepare and Run nemaquant.py ---
78
+ # Determine input target for nemaquant.py
79
+ if input_mode == 'single' and len(saved_files) == 1:
80
+ input_target = str(saved_files[0])
81
+ img_mode_arg = 'file' # nemaquant uses file/dir, not single/directory
82
+ elif input_mode == 'directory' and len(saved_files) >= 1:
83
+ input_target = str(job_input_dir) # Pass the directory containing the images
84
+ img_mode_arg = 'dir'
85
+ else:
86
+ # Mismatch between mode and number of files
87
+ return jsonify({"error": f"Input mode '{input_mode}' requires {'1 file' if input_mode == 'single' else '1 or more files'}, but received {len(saved_files)}."}), 400
88
+
89
+
90
+ output_csv = job_output_dir / f"{job_id}_results.csv"
91
+ annotated_dir = job_output_dir # Save annotated images directly in job output dir
92
+
93
+ cmd = [
94
+ 'python', str(APP_ROOT / 'nemaquant.py'),
95
+ '-i', input_target,
96
+ '-w', str(WEIGHTS_FILE), # Use absolute path
97
+ '-o', str(output_csv),
98
+ '-a', str(annotated_dir),
99
+ '--conf', confidence
100
+ ]
101
+
102
+ # We don't need --key or XY mode for this web interface initially
103
+
104
+ try:
105
+ print(f"Running command: {' '.join(cmd)}") # Log the command
106
+ print(f"Input directory contents: {os.listdir(str(job_input_dir))}")
107
+ print(f"Weights file exists: {os.path.exists(str(WEIGHTS_FILE))}")
108
+ print(f"Weights file size: {os.path.getsize(str(WEIGHTS_FILE)) if os.path.exists(str(WEIGHTS_FILE)) else 'File not found'} bytes")
109
 
110
+ # Run the script, capture output and errors
111
+ # Timeout might be needed for long processes on shared infrastructure like HF Spaces
112
+ result = subprocess.run(cmd, capture_output=True, text=True, check=True, timeout=300) # 5 min timeout
113
+ status_log = f"NemaQuant Output:\n{result.stdout}\nNemaQuant Errors:\n{result.stderr}"
114
+ print(status_log) # Log script output
115
+
116
+ # Check output directory after command runs
117
+ print(f"Output directory exists: {os.path.exists(str(job_output_dir))}")
118
+ if os.path.exists(str(job_output_dir)):
119
+ print(f"Output directory contents: {os.listdir(str(job_output_dir))}")
120
+
121
+ # Check output file
122
+ print(f"Output CSV exists: {os.path.exists(str(output_csv))}")
123
+
124
+ # --- Parse Results ---
125
+ if not output_csv.exists():
126
+ raise FileNotFoundError(f"Output CSV not found at {output_csv}")
127
+
128
+ df = pd.read_csv(output_csv)
129
+ # Expect columns like 'filename', 'num_eggs' (based on nemaquant.py)
130
+ # Find corresponding annotated images
131
+ results_list = []
132
+ for index, row in df.iterrows():
133
+ original_filename = row.get('filename', '')
134
+ num_eggs = row.get('num_eggs', 'N/A')
135
+ # Construct expected annotated filename (based on nemaquant.py logic)
136
+ stem = Path(original_filename).stem
137
+ suffix = Path(original_filename).suffix
138
+ annotated_filename = f"{stem}_annotated{suffix}"
139
+ annotated_path = annotated_dir / annotated_filename
140
+
141
+ print(f"Looking for annotated file: {annotated_path}, exists: {annotated_path.exists()}")
142
+
143
+ results_list.append({
144
+ "filename": original_filename,
145
+ "num_eggs": num_eggs,
146
+ # Pass relative path within job dir for frontend URL construction
147
+ "annotated_filename": annotated_filename if annotated_path.exists() else None,
148
+ })
149
+
150
+ return jsonify({
151
+ "status": "success",
152
+ "job_id": job_id,
153
+ "results": results_list,
154
+ "log": status_log,
155
+ "error_files": error_files # Report files that were not processed
156
  })
157
 
158
+ except subprocess.CalledProcessError as e:
159
+ error_message = f"Error running NemaQuant:\nExit Code: {e.returncode}\nSTDOUT:\n{e.stdout}\nSTDERR:\n{e.stderr}"
160
+ print(error_message)
161
+ return jsonify({"error": "Processing failed", "log": error_message}), 500
162
+ except subprocess.TimeoutExpired as e:
163
+ error_message = f"Error running NemaQuant: Process timed out after {e.timeout} seconds.\nSTDOUT:\n{e.stdout}\nSTDERR:\n{e.stderr}"
164
+ print(error_message)
165
+ return jsonify({"error": "Processing timed out", "log": error_message}), 500
166
+ except FileNotFoundError as e:
167
+ error_message = f"Error processing results: {e}"
168
+ print(error_message)
169
+ return jsonify({"error": "Could not find output file", "log": error_message}), 500
170
+ except Exception as e:
171
+ error_message = f"An unexpected error occurred: {str(e)}\n"
172
+ error_message += traceback.format_exc()
173
+ print(error_message)
174
+ return jsonify({"error": "An unexpected error occurred", "log": error_message}), 500
 
 
 
175
  except Exception as e:
176
+ # High-level exception handler for the entire route
177
+ error_message = f"Global process error: {str(e)}\n{traceback.format_exc()}"
 
178
  print(error_message)
179
+ return jsonify({"error": "Server error", "log": error_message}), 500
180
 
181
 
182
  @app.route('/results/<job_id>/<path:filename>')
183
  def download_file(job_id, filename):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
184
  try:
185
+ # Construct the full path to the file within the job's output directory
186
+ # Use secure_filename on the incoming filename part for safety? Maybe not needed if we trust our generated paths.
187
+ # Crucially, validate job_id and filename to prevent directory traversal.
188
+ # A simple check: ensure job_id is a valid UUID format and filename doesn't contain '..'
189
+ try:
190
+ uuid.UUID(job_id, version=4) # Validate UUID format
191
+ except ValueError:
192
+ return jsonify({"error": "Invalid job ID format"}), 400
193
+
194
+ if '..' in filename or filename.startswith('/'):
195
+ return jsonify({"error": "Invalid filename"}), 400
196
+
197
+ file_dir = RESULT_FOLDER / job_id / 'output'
198
+ # Use send_from_directory for security (handles path joining and prevents traversal above the specified directory)
199
+ print(f"Attempting to send file: {filename} from directory: {file_dir}")
200
+ try:
201
+ return send_from_directory(str(file_dir), filename, as_attachment=False) # Display images inline
202
+ except FileNotFoundError:
203
+ print(f"File not found: {file_dir / filename}")
204
+ return jsonify({"error": "File not found"}), 404
205
+ except Exception as e:
206
+ # Catch-all exception handler
207
+ error_message = f"File serving error: {str(e)}"
208
+ print(error_message)
209
+ return jsonify({"error": "Server error", "log": error_message}), 500
210
 
211
 
212
  if __name__ == '__main__':
 
216
  print("Please ensure 'weights.pt' is in the application root directory.")
217
  exit(1) # Exit if weights are missing
218
 
219
+ # Log startup information
220
+ print("----- NemaQuant Flask App Starting -----")
221
+ print(f"Working directory: {os.getcwd()}")
222
+ print(f"Python version: {sys.version}")
223
+ print(f"Weights file: {WEIGHTS_FILE}")
224
+ print(f"Weights file exists: {os.path.exists(str(WEIGHTS_FILE))}")
225
+ if os.path.exists(str(WEIGHTS_FILE)):
226
+ print(f"Weights file size: {os.path.getsize(str(WEIGHTS_FILE))} bytes")
227
+ print("---------------------------------------")
228
+
229
  app.run(debug=True, host='0.0.0.0', port=7860) # Port 7860 is common for HF Spaces
static/script.js CHANGED
@@ -83,6 +83,17 @@ document.addEventListener('DOMContentLoaded', () => {
83
  progress.value = 100;
84
  progressText.textContent = '100%';
85
 
 
 
 
 
 
 
 
 
 
 
 
86
  const data = await response.json();
87
 
88
  if (response.ok) {
 
83
  progress.value = 100;
84
  progressText.textContent = '100%';
85
 
86
+ // First check if the response is valid
87
+ const contentType = response.headers.get('content-type');
88
+ if (!contentType || !contentType.includes('application/json')) {
89
+ // Handle non-JSON response
90
+ const textResponse = await response.text();
91
+ logStatus(`Error: Server returned non-JSON response: ${textResponse.substring(0, 200)}...`);
92
+ processingStatus.textContent = 'Error: Server returned invalid format';
93
+ throw new Error('Server returned non-JSON response');
94
+ }
95
+
96
+ // Now we can safely parse JSON
97
  const data = await response.json();
98
 
99
  if (response.ok) {