sloneckity commited on
Commit
0605276
·
1 Parent(s): 0a922be

Prepare app for Hugging Face Spaces deployment

Browse files
Files changed (7) hide show
  1. .hf/space +4 -0
  2. app.py +385 -205
  3. nemaquant.py +15 -3
  4. requirements.txt +0 -1
  5. static/script.js +574 -179
  6. static/style.css +457 -206
  7. templates/index.html +139 -68
.hf/space ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ title: NemaQuant Flask App
2
+ sdk: docker
3
+ app_port: 7860
4
+ models: []
app.py CHANGED
@@ -1,4 +1,4 @@
1
- from flask import Flask, render_template, request, jsonify, send_from_directory
2
  import subprocess
3
  import os
4
  from pathlib import Path
@@ -7,6 +7,13 @@ 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,6 +29,9 @@ app.config['ALLOWED_EXTENSIONS'] = {'png', 'jpg', 'jpeg', 'tif', 'tiff'}
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):
@@ -41,6 +51,7 @@ def index():
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
@@ -54,9 +65,8 @@ def process_images():
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 = []
@@ -65,7 +75,7 @@ def process_images():
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:
@@ -74,186 +84,78 @@ def process_images():
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
- # Additional debugging before running the command
111
- print(f"Current working directory: {os.getcwd()}")
112
- print(f"Output directory permissions: {oct(os.stat(str(job_output_dir)).st_mode)[-3:] if os.path.exists(str(job_output_dir)) else 'Directory not found'}")
113
- print(f"Executable Python: {sys.executable}")
114
-
115
- # Run the script, capture output and errors
116
- # Timeout might be needed for long processes on shared infrastructure like HF Spaces
117
- result = subprocess.run(cmd, capture_output=True, text=True, check=False, timeout=300) # 5 min timeout, don't raise exception
118
-
119
- # Get script return code and output
120
- print(f"NemaQuant script return code: {result.returncode}")
121
- print(f"NemaQuant stdout: {result.stdout}")
122
- print(f"NemaQuant stderr: {result.stderr}")
123
-
124
- status_log = f"NemaQuant Output:\n{result.stdout}\nNemaQuant Errors:\n{result.stderr}"
125
- print(status_log) # Log script output
126
-
127
- # Check if the command failed
128
- if result.returncode != 0:
129
- raise subprocess.CalledProcessError(result.returncode, cmd, output=result.stdout, stderr=result.stderr)
130
-
131
- # Check output directory after command runs
132
- print(f"Output directory exists: {os.path.exists(str(job_output_dir))}")
133
- if os.path.exists(str(job_output_dir)):
134
- print(f"Output directory contents: {os.listdir(str(job_output_dir))}")
135
-
136
- # Check output file
137
- print(f"Output CSV exists: {os.path.exists(str(output_csv))}")
138
-
139
- # If CSV doesn't exist, try to check if there are any other CSVs created in the output directory
140
- if not output_csv.exists() and os.path.exists(str(job_output_dir)):
141
- csv_files = [f for f in os.listdir(str(job_output_dir)) if f.endswith('.csv')]
142
- if csv_files:
143
- print(f"Found other CSV files in output directory: {csv_files}")
144
- # Try using the first found CSV instead
145
- output_csv = job_output_dir / csv_files[0]
146
- print(f"Using alternate CSV file: {output_csv}")
147
- else:
148
- # No CSV files found - create a fallback CSV file manually
149
- # This is a workaround in case nemaquant.py's to_csv() fails due to permissions
150
- try:
151
- print("No CSV found - trying to create a fallback CSV file")
152
- # Look for annotated images to get filenames and create a basic CSV
153
- annotated_files = [f for f in os.listdir(str(job_output_dir)) if '_annotated' in f]
154
- if annotated_files:
155
- # Create a simple CSV with the filenames found
156
- fallback_csv = job_output_dir / f"{job_id}_fallback_results.csv"
157
- with open(fallback_csv, 'w') as f:
158
- f.write("filename,num_eggs,fallback\n")
159
- for img_file in annotated_files:
160
- # Extract original filename by removing '_annotated'
161
- orig_filename = img_file.replace('_annotated', '')
162
- f.write(f"{orig_filename},0,true\n")
163
- output_csv = fallback_csv
164
- print(f"Created fallback CSV at {fallback_csv}")
165
- except Exception as csv_error:
166
- print(f"Error creating fallback CSV: {csv_error}")
167
-
168
- # --- Parse Results ---
169
- if not output_csv.exists():
170
- error_message = f"Output CSV not found at {output_csv}"
171
- print(error_message)
172
- # Check if nemaquant.py exists and try to debug file paths
173
- nemaquant_script = APP_ROOT / 'nemaquant.py'
174
- nemaquant_exists = nemaquant_script.exists()
175
- error_message += f"\nNemaQuant script exists: {nemaquant_exists}"
176
- if nemaquant_exists:
177
- # Try to inspect the nemaquant script to understand its file I/O
178
- try:
179
- with open(nemaquant_script, 'r') as f:
180
- script_content = f.read()
181
- # Look for common file output patterns
182
- if 'to_csv(' in script_content:
183
- error_message += "\nNemaQuant script contains 'to_csv()' calls for file output"
184
- if 'open(' in script_content and 'w' in script_content:
185
- error_message += "\nNemaQuant script contains file opening in write mode"
186
- except Exception as e:
187
- error_message += f"\nCould not read nemaquant.py: {str(e)}"
188
-
189
- # Check if any files were written at all
190
- if os.path.exists(str(job_output_dir)):
191
- files_in_output = os.listdir(str(job_output_dir))
192
- error_message += f"\nFiles in output directory: {files_in_output}"
193
-
194
- raise FileNotFoundError(error_message)
195
-
196
- df = pd.read_csv(output_csv)
197
- # Expect columns like 'filename', 'num_eggs' (based on nemaquant.py)
198
- # Find corresponding annotated images
199
- results_list = []
200
- for index, row in df.iterrows():
201
- original_filename = row.get('filename', '')
202
- num_eggs = row.get('num_eggs', 'N/A')
203
- # Construct expected annotated filename (based on nemaquant.py logic)
204
- stem = Path(original_filename).stem
205
- suffix = Path(original_filename).suffix
206
- annotated_filename = f"{stem}_annotated{suffix}"
207
- annotated_path = annotated_dir / annotated_filename
208
-
209
- print(f"Looking for annotated file: {annotated_path}, exists: {annotated_path.exists()}")
210
-
211
- results_list.append({
212
- "filename": original_filename,
213
- "num_eggs": num_eggs,
214
- # Pass relative path within job dir for frontend URL construction
215
- "annotated_filename": annotated_filename if annotated_path.exists() else None,
216
- })
217
-
218
- return jsonify({
219
- "status": "success",
220
- "job_id": job_id,
221
- "results": results_list,
222
- "log": status_log,
223
- "error_files": error_files # Report files that were not processed
224
- })
225
 
226
- except subprocess.CalledProcessError as e:
227
- error_message = f"Error running NemaQuant:\nExit Code: {e.returncode}\nSTDOUT:\n{e.stdout}\nSTDERR:\n{e.stderr}"
228
- print(error_message)
229
- return jsonify({"error": "Processing failed", "log": error_message}), 500
230
- except subprocess.TimeoutExpired as e:
231
- error_message = f"Error running NemaQuant: Process timed out after {e.timeout} seconds.\nSTDOUT:\n{e.stdout}\nSTDERR:\n{e.stderr}"
232
- print(error_message)
233
- return jsonify({"error": "Processing timed out", "log": error_message}), 500
234
- except FileNotFoundError as e:
235
- error_message = f"Error processing results: {e}"
236
- print(error_message)
237
- return jsonify({"error": "Could not find output file", "log": error_message}), 500
238
- except Exception as e:
239
- error_message = f"An unexpected error occurred: {str(e)}\n"
240
- error_message += traceback.format_exc()
241
- print(error_message)
242
- return jsonify({"error": "An unexpected error occurred", "log": error_message}), 500
243
- except Exception as e:
244
- # High-level exception handler for the entire route
245
- error_message = f"Global process error: {str(e)}\n{traceback.format_exc()}"
246
  print(error_message)
247
- return jsonify({"error": "Server error", "log": error_message}), 500
 
 
 
 
 
 
 
 
 
 
 
 
 
248
 
249
 
250
  @app.route('/results/<job_id>/<path:filename>')
251
  def download_file(job_id, filename):
252
  try:
253
- # Construct the full path to the file within the job's output directory
254
- # Use secure_filename on the incoming filename part for safety? Maybe not needed if we trust our generated paths.
255
- # Crucially, validate job_id and filename to prevent directory traversal.
256
- # A simple check: ensure job_id is a valid UUID format and filename doesn't contain '..'
257
  try:
258
  uuid.UUID(job_id, version=4) # Validate UUID format
259
  except ValueError:
@@ -262,59 +164,337 @@ def download_file(job_id, filename):
262
  if '..' in filename or filename.startswith('/'):
263
  return jsonify({"error": "Invalid filename"}), 400
264
 
265
- file_dir = RESULT_FOLDER / job_id / 'output'
266
- # Use send_from_directory for security (handles path joining and prevents traversal above the specified directory)
267
- print(f"Attempting to send file: {filename} from directory: {file_dir}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
268
  try:
269
- return send_from_directory(str(file_dir), filename, as_attachment=False) # Display images inline
270
- except FileNotFoundError:
271
- print(f"File not found: {file_dir / filename}")
272
- return jsonify({"error": "File not found"}), 404
273
  except Exception as e:
274
- # Catch-all exception handler
275
  error_message = f"File serving error: {str(e)}"
276
  print(error_message)
277
  return jsonify({"error": "Server error", "log": error_message}), 500
278
 
279
 
280
- if __name__ == '__main__':
281
- # Ensure weights file exists before starting
282
- if not WEIGHTS_FILE.exists():
283
- print(f"ERROR: Weights file not found at {WEIGHTS_FILE}")
284
- print("Please ensure 'weights.pt' is in the application root directory.")
285
- exit(1) # Exit if weights are missing
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
286
 
287
- # Log startup information
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
288
  print("----- NemaQuant Flask App Starting -----")
289
  print(f"Working directory: {os.getcwd()}")
290
- print(f"Python version: {sys.version}")
 
 
291
  print(f"Weights file: {WEIGHTS_FILE}")
292
- print(f"Weights file exists: {os.path.exists(str(WEIGHTS_FILE))}")
293
- if os.path.exists(str(WEIGHTS_FILE)):
294
- print(f"Weights file size: {os.path.getsize(str(WEIGHTS_FILE))} bytes")
295
-
296
- # Check if running in Docker or Hugging Face Space container
297
- in_container = os.path.exists('/.dockerenv') or os.environ.get('SPACE_ID')
298
- print(f"Running in container: {in_container}")
299
- if in_container:
300
- print("Container environment detected:")
301
- print(f"User running process: {os.getuid()}:{os.getgid()}")
302
  try:
303
- # Check permissions for key directories
304
- for path in [UPLOAD_FOLDER, RESULT_FOLDER]:
305
- stat_info = os.stat(path)
306
- print(f"Permissions for {path}: {oct(stat_info.st_mode)[-3:]}")
307
- print(f"Owner for {path}: {stat_info.st_uid}:{stat_info.st_gid}")
308
  except Exception as e:
309
- print(f"Error checking permissions: {e}")
310
-
311
- print("---------------------------------------")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
312
 
313
- # Check for nemaquant.py script
 
314
  nemaquant_script = APP_ROOT / 'nemaquant.py'
315
  print(f"NemaQuant script exists: {nemaquant_script.exists()}")
316
  if nemaquant_script.exists():
317
- print(f"NemaQuant script permissions: {oct(os.stat(nemaquant_script).st_mode)[-3:]}")
 
 
 
 
 
 
 
 
318
  print("---------------------------------------")
319
 
320
- app.run(debug=True, host='0.0.0.0', port=7860) # Port 7860 is common for HF Spaces
 
 
 
 
 
 
 
1
+ from flask import Flask, render_template, request, jsonify, send_from_directory, send_file
2
  import subprocess
3
  import os
4
  from pathlib import Path
 
7
  from werkzeug.utils import secure_filename # Added for security
8
  import traceback
9
  import sys
10
+ import re
11
+ from PIL import Image
12
+ import io
13
+ import threading # Add threading
14
+ import time # Add time
15
+ import zipfile # Add zipfile for image export
16
+ from datetime import datetime # Add datetime for timestamps
17
 
18
  app = Flask(__name__)
19
  # Use absolute paths for robustness within Docker/HF Spaces
 
29
  UPLOAD_FOLDER.mkdir(parents=True, exist_ok=True)
30
  RESULT_FOLDER.mkdir(parents=True, exist_ok=True)
31
 
32
+ # Dictionary to store job status
33
+ job_status = {}
34
+
35
  # Global error handler to ensure JSON responses
36
  @app.errorhandler(Exception)
37
  def handle_exception(e):
 
51
 
52
  @app.route('/process', methods=['POST'])
53
  def process_images():
54
+ global job_status
55
  try:
56
  if 'files' not in request.files:
57
  return jsonify({"error": "No file part"}), 400
 
65
 
66
  # Create a unique job directory within results
67
  job_id = str(uuid.uuid4())
68
+ # Save inputs directly into job output dir to simplify cleanup/access
69
+ job_output_dir = RESULT_FOLDER / job_id # Combine input/output
 
70
  job_output_dir.mkdir(parents=True, exist_ok=True)
71
 
72
  saved_files = []
 
75
  for file in files:
76
  if file and allowed_file(file.filename):
77
  filename = secure_filename(file.filename)
78
+ save_path = job_output_dir / filename # Save directly into job dir
79
  file.save(str(save_path))
80
  saved_files.append(save_path)
81
  elif file:
 
84
  if not saved_files:
85
  return jsonify({"error": f"No valid files uploaded. Invalid files: {error_files}"}), 400
86
 
87
+ # --- Prepare and Run nemaquant.py ---\
88
  # Determine input target for nemaquant.py
89
+ if input_mode == 'files' or input_mode == 'folder': # Treat 'files' as 'dir' if multiple files
90
+ input_target = str(job_output_dir) # Process all files in the job dir
91
+ img_mode_arg = 'dir' # Always use dir mode now? Check nemaquant.py
92
+ elif input_mode == 'single':
93
+ if len(saved_files) == 1:
94
+ input_target = str(saved_files[0])
95
+ img_mode_arg = 'file' # Use file mode only if explicitly single and one file
96
+ else:
97
+ # Handle case where 'single' is selected but multiple files uploaded
98
+ # Option 1: Error out
99
+ # return jsonify({"error": "Single file mode selected, but multiple files uploaded."}), 400
100
+ # Option 2: Process only the first file
101
+ input_target = str(saved_files[0])
102
+ img_mode_arg = 'file'
103
+ print(f"Warning: Single file mode selected, processing only the first file: {saved_files[0].name}")
104
+ else: # Should not happen if frontend validates
105
+ return jsonify({"error": f"Invalid input mode: {input_mode}"}), 400
106
+
107
+ # Define output paths within the job directory
108
+ output_csv = job_output_dir / f"results.csv" # Simplified CSV name
109
+ annotated_dir = job_output_dir # Annotations saved in the same place
110
 
111
  cmd = [
112
+ sys.executable, # Use the same python interpreter
113
+ str(APP_ROOT / 'nemaquant.py'),
114
  '-i', input_target,
115
+ '-w', str(WEIGHTS_FILE),
116
  '-o', str(output_csv),
117
  '-a', str(annotated_dir),
118
  '--conf', confidence
119
  ]
120
 
121
+ print(f"[{job_id}] Prepared command: {' '.join(cmd)}")
122
 
123
+ # Initialize job status
124
+ job_status[job_id] = {"status": "starting", "progress": 0, "log": "Job initiated", "results": None, "error": None}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
125
 
126
+ # Run in background thread
127
+ thread = threading.Thread(target=run_nemaquant_background, args=(job_id, cmd, job_output_dir, output_csv, input_mode))
128
+ thread.start()
129
+
130
+ # Return immediately with job ID
131
+ return jsonify({
132
+ "status": "processing",
133
+ "job_id": job_id,
134
+ "initial_log": f"Job '{job_id}' started. Input mode: {input_mode}. Files saved in results/{job_id}/. Polling for progress..."
135
+ })
136
+
137
+ except Exception as e: # Catch setup errors before thread starts
138
+ error_message = f"Error starting process: {str(e)}\\n{traceback.format_exc()}"
 
 
 
 
 
 
 
139
  print(error_message)
140
+ # Ensure a job_id isn't lingering if setup failed badly
141
+ # if 'job_id' in locals() and job_id in job_status: del job_status[job_id] # Might be too aggressive
142
+ return jsonify({"error": "Failed to start processing", "log": error_message}), 500
143
+
144
+
145
+ # --- New Progress Endpoint ---
146
+ @app.route('/progress/<job_id>')
147
+ def get_progress(job_id):
148
+ global job_status
149
+ status = job_status.get(job_id)
150
+ if status:
151
+ return jsonify(status)
152
+ else:
153
+ return jsonify({"status": "error", "error": "Job ID not found"}), 404
154
 
155
 
156
  @app.route('/results/<job_id>/<path:filename>')
157
  def download_file(job_id, filename):
158
  try:
 
 
 
 
159
  try:
160
  uuid.UUID(job_id, version=4) # Validate UUID format
161
  except ValueError:
 
164
  if '..' in filename or filename.startswith('/'):
165
  return jsonify({"error": "Invalid filename"}), 400
166
 
167
+ # Construct the absolute path to the file within the specific job's output directory
168
+ safe_filename = secure_filename(filename)
169
+ try:
170
+ uuid.UUID(job_id, version=4)
171
+ except ValueError:
172
+ print(f"Invalid job ID format requested: {job_id}")
173
+ return jsonify({"error": "Invalid job ID"}), 400
174
+
175
+ file_dir = Path(app.config['RESULT_FOLDER']) / job_id
176
+
177
+ # Prevent path traversal
178
+ file_path = (file_dir / safe_filename).resolve()
179
+ if not str(file_path).startswith(str(file_dir.resolve())):
180
+ print(f"Attempted path traversal: {job_id}/{filename}")
181
+ return jsonify({"error": "Invalid file path"}), 400
182
+
183
+ print(f"Serving file: {file_path}")
184
+
185
+ if not file_path.is_file():
186
+ print(f"File not found: {file_path}")
187
+ if not file_dir.exists():
188
+ print(f"Job directory not found: {file_dir}")
189
+ return jsonify({"error": f"Job directory {job_id} not found"}), 404
190
+ files_in_dir = list(file_dir.iterdir())
191
+ print(f"Files in job directory {file_dir}: {files_in_dir}")
192
+ return jsonify({"error": f"File '{filename}' not found in job '{job_id}'. Available: {[f.name for f in files_in_dir]}"}), 404
193
+
194
+ # Handle TIF/TIFF files by converting to PNG
195
+ if filename.lower().endswith(('.tif', '.tiff')):
196
+ try:
197
+ # Open the TIF image
198
+ with Image.open(file_path) as img:
199
+ # Convert to RGB if necessary
200
+ if img.mode in ('RGBA', 'LA') or (img.mode == 'P' and 'transparency' in img.info):
201
+ img = img.convert('RGBA')
202
+ else:
203
+ img = img.convert('RGB')
204
+
205
+ # Create a BytesIO object to hold the PNG data
206
+ img_byte_arr = io.BytesIO()
207
+ # Save as PNG to the BytesIO object
208
+ img.save(img_byte_arr, format='PNG')
209
+ # Seek to the beginning of the BytesIO object
210
+ img_byte_arr.seek(0)
211
+
212
+ # Return the PNG image
213
+ return send_file(
214
+ img_byte_arr,
215
+ mimetype='image/png',
216
+ as_attachment=False,
217
+ download_name=f"{Path(filename).stem}.png"
218
+ )
219
+ except Exception as e:
220
+ print(f"Error converting TIF to PNG: {e}")
221
+ return jsonify({"error": "Could not convert TIF image"}), 500
222
+
223
+ # For non-TIF files, serve as normal
224
+ mime_type = None
225
+ if safe_filename.lower().endswith(('.png', '.jpg', '.jpeg')):
226
+ try:
227
+ with Image.open(file_path) as img:
228
+ format = img.format
229
+ if format == 'JPEG': mime_type = 'image/jpeg'
230
+ elif format == 'PNG': mime_type = 'image/png'
231
+ print(f"Detected MIME type: {mime_type} for {safe_filename}")
232
+ except Exception as img_err:
233
+ print(f"Could not determine MIME type for {safe_filename} using PIL: {img_err}")
234
+ pass
235
+
236
+ if safe_filename.lower() == "results.csv":
237
+ mime_type = 'text/csv'
238
+
239
  try:
240
+ return send_file(str(file_path), mimetype=mime_type)
241
+ except Exception as send_err:
242
+ print(f"Error sending file {file_path}: {send_err}")
243
+ return jsonify({"error": "Could not send file"}), 500
244
  except Exception as e:
 
245
  error_message = f"File serving error: {str(e)}"
246
  print(error_message)
247
  return jsonify({"error": "Server error", "log": error_message}), 500
248
 
249
 
250
+ @app.route('/export_images/<job_id>')
251
+ def export_images(job_id):
252
+ try:
253
+ try:
254
+ uuid.UUID(job_id, version=4) # Validate UUID format
255
+ except ValueError:
256
+ return jsonify({"error": "Invalid job ID format"}), 400
257
+
258
+ # Get the job directory
259
+ job_dir = Path(app.config['RESULT_FOLDER']) / job_id
260
+ if not job_dir.exists():
261
+ return jsonify({"error": f"Job directory {job_id} not found"}), 404
262
+
263
+ # Find all annotated images
264
+ annotated_files = list(job_dir.glob('*_annotated.*'))
265
+ if not annotated_files:
266
+ return jsonify({"error": "No annotated images found"}), 404
267
+
268
+ # Create a BytesIO object to store the ZIP file
269
+ memory_file = io.BytesIO()
270
+
271
+ # Create the ZIP file
272
+ with zipfile.ZipFile(memory_file, 'w', zipfile.ZIP_DEFLATED) as zf:
273
+ for file_path in annotated_files:
274
+ # Add file to the ZIP with just its name as the archive path
275
+ zf.write(file_path, file_path.name)
276
+
277
+ # Seek to the beginning of the BytesIO object
278
+ memory_file.seek(0)
279
+
280
+ # Generate timestamp for the filename
281
+ timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
282
+
283
+ # Send the ZIP file with timestamp in name
284
+ return send_file(
285
+ memory_file,
286
+ mimetype='application/zip',
287
+ as_attachment=True,
288
+ download_name=f'nemaquant_annotated_{timestamp}.zip'
289
+ )
290
 
291
+ except Exception as e:
292
+ error_message = f"Error exporting images: {str(e)}"
293
+ print(error_message)
294
+ return jsonify({"error": "Server error", "log": error_message}), 500
295
+
296
+
297
+ # --- Helper function to run NemaQuant in background ---
298
+ def run_nemaquant_background(job_id, cmd, job_output_dir, output_csv, input_mode):
299
+ global job_status
300
+ try:
301
+ print(f"[{job_id}] Starting NemaQuant process...")
302
+ job_status[job_id] = {"status": "running", "progress": 5, "log": "Starting NemaQuant...", "results": None, "error": None}
303
+
304
+ # Use Popen for non-blocking execution
305
+ process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, bufsize=1, universal_newlines=True)
306
+
307
+ # --- Monitor stdout for progress (Example: simple line counting) ---
308
+ stdout_log = []
309
+ stderr_log = []
310
+ total_lines_estimate = 50 # Heuristic: estimate total lines for progress
311
+ lines_processed = 0
312
+ last_reported_progress = 5 # Start after initial 5%
313
+
314
+ # Monitor stdout
315
+ if process.stdout:
316
+ for line in iter(process.stdout.readline, ''):
317
+ line_strip = line.strip()
318
+ lines_processed += 1
319
+ stdout_log.append(line_strip)
320
+ print(f"[{job_id}] STDOUT: {line_strip}") # Log in real-time
321
+
322
+ # Check for PROGRESS marker
323
+ if line_strip.startswith("PROGRESS:"):
324
+ try:
325
+ progress_val = int(line_strip.split(":")[1].strip())
326
+ # Ensure progress only increases and stays within bounds (5-95)
327
+ last_reported_progress = max(last_reported_progress, min(progress_val, 95))
328
+ job_status[job_id]["progress"] = last_reported_progress
329
+ print(f"[{job_id}] Parsed progress: {last_reported_progress}%")
330
+ except (IndexError, ValueError):
331
+ print(f"[{job_id}] Warning: Could not parse progress line: {line_strip}")
332
+ # Fallback to line counting if parsing fails
333
+ progress_percent = min(90, 5 + int((lines_processed / total_lines_estimate) * 85))
334
+ job_status[job_id]["progress"] = max(last_reported_progress, progress_percent)
335
+ last_reported_progress = job_status[job_id]["progress"]
336
+ else:
337
+ # Fallback: Update progress based on line count if no marker
338
+ progress_percent = min(90, 5 + int((lines_processed / total_lines_estimate) * 85))
339
+ job_status[job_id]["progress"] = max(last_reported_progress, progress_percent)
340
+ last_reported_progress = job_status[job_id]["progress"]
341
+
342
+ # Update log snippet (always)
343
+ job_status[job_id]["log"] = "\n".join(stdout_log[-5:]) # Keep last 5 lines for status
344
+
345
+ # Monitor stderr
346
+ if process.stderr:
347
+ for line in iter(process.stderr.readline, ''):
348
+ stderr_log.append(line.strip())
349
+ print(f"[{job_id}] STDERR: {line.strip()}") # Log errors in real-time
350
+
351
+ process.stdout.close()
352
+ if process.stderr:
353
+ process.stderr.close()
354
+ return_code = process.wait()
355
+
356
+ # Join logs first to avoid f-string syntax error
357
+ stdout_str = "\n".join(stdout_log)
358
+ stderr_str = "\n".join(stderr_log)
359
+ full_log = f"NemaQuant Output:\n{stdout_str}\nNemaQuant Errors:\n{stderr_str}"
360
+ job_status[job_id]["log"] = full_log # Store full log at the end
361
+
362
+ print(f"[{job_id}] NemaQuant script return code: {return_code}")
363
+
364
+ if return_code != 0:
365
+ raise subprocess.CalledProcessError(return_code, cmd, output=stdout_str, stderr=stderr_str)
366
+
367
+ job_status[job_id]["progress"] = 95 # Mark as almost done before parsing results
368
+
369
+ # --- Check for and Parse Results ---
370
+ print(f"[{job_id}] Checking output directory: {job_output_dir}")
371
+ if os.path.exists(job_output_dir):
372
+ print(f"[{job_id}] Output directory contents: {os.listdir(job_output_dir)}")
373
+ print(f"[{job_id}] Checking for output CSV: {output_csv}")
374
+
375
+ # Simplified fallback check
376
+ if not output_csv.exists():
377
+ csv_files = list(job_output_dir.glob('*.csv'))
378
+ if csv_files:
379
+ print(f"[{job_id}] Original CSV not found, using alternate: {csv_files[0]}")
380
+ output_csv = csv_files[0]
381
+ else:
382
+ raise FileNotFoundError(f"Output CSV not found at {output_csv} and no alternatives found.")
383
+
384
+ # --- Parse Results ---
385
+ df = pd.read_csv(output_csv)
386
+ results_list = []
387
+
388
+ egg_count = None
389
+ if input_mode == 'single':
390
+ egg_count_match = re.search(r'n eggs:\\s*(\\d+)', full_log)
391
+ if egg_count_match:
392
+ egg_count = int(egg_count_match.group(1))
393
+
394
+ for index, row in df.iterrows():
395
+ original_filename = row.get('filename', '')
396
+ num_eggs = egg_count if egg_count is not None else row.get('num_eggs', 'N/A')
397
+ stem = Path(original_filename).stem
398
+ suffix = Path(original_filename).suffix
399
+ annotated_filename = f"{stem}_annotated{suffix}"
400
+ annotated_path = job_output_dir / annotated_filename
401
+
402
+ results_list.append({
403
+ "filename": original_filename,
404
+ "num_eggs": num_eggs,
405
+ "annotated_filename": annotated_filename if annotated_path.exists() else None,
406
+ })
407
+
408
+ job_status[job_id] = {
409
+ "status": "success",
410
+ "progress": 100,
411
+ "log": full_log,
412
+ "results": results_list,
413
+ "error": None
414
+ }
415
+ print(f"[{job_id}] Processing successful.")
416
+
417
+ except subprocess.CalledProcessError as e:
418
+ # Construct error message carefully, avoiding f-string backslash issue again
419
+ stdout_err = e.output if e.output else ""
420
+ stderr_err = e.stderr if e.stderr else ""
421
+ error_message = f"Error running NemaQuant:\nExit Code: {e.returncode}\nSTDOUT:\n{stdout_err}\nSTDERR:\n{stderr_err}"
422
+ print(f"[{job_id}] {error_message}")
423
+ # Ensure log is updated even on error, using the potentially partial log from job_status
424
+ current_log = job_status[job_id].get("log", "") # Get log captured so far
425
+ job_status[job_id] = {"status": "error", "progress": 100, "log": current_log, "results": None, "error": error_message}
426
+ except FileNotFoundError as e:
427
+ error_message = f"Error processing results: {e}"
428
+ print(f"[{job_id}] {error_message}")
429
+ job_status[job_id] = {"status": "error", "progress": 100, "log": job_status[job_id].get("log", ""), "results": None, "error": error_message}
430
+ except Exception as e: # Catch any other unexpected errors
431
+ error_message = f"An unexpected error occurred: {str(e)}\\n{traceback.format_exc()}"
432
+ print(f"[{job_id}] {error_message}")
433
+ job_status[job_id] = {"status": "error", "progress": 100, "log": job_status[job_id].get("log", ""), "results": None, "error": error_message}
434
+
435
+
436
+ # --- Add startup diagnostics ---
437
+ def print_startup_info():
438
  print("----- NemaQuant Flask App Starting -----")
439
  print(f"Working directory: {os.getcwd()}")
440
+ # Fix: Perform replace before the f-string
441
+ python_version_single_line = sys.version.replace('\n', ' ')
442
+ print(f"Python version: {python_version_single_line}")
443
  print(f"Weights file: {WEIGHTS_FILE}")
444
+ print(f"Weights file exists: {WEIGHTS_FILE.exists()}")
445
+ if WEIGHTS_FILE.exists():
 
 
 
 
 
 
 
 
446
  try:
447
+ print(f"Weights file size: {WEIGHTS_FILE.stat().st_size} bytes")
 
 
 
 
448
  except Exception as e:
449
+ print(f"Could not get weights file size: {e}")
450
+ else:
451
+ print("Weights file size: N/A")
452
+
453
+ # Check if running inside a container (basic check)
454
+ is_container = os.path.exists('/.dockerenv') or 'DOCKER_HOST' in os.environ
455
+ print(f"Running in container: {is_container}")
456
+ if is_container:
457
+ print("Container environment detected:")
458
+ try:
459
+ user_info = f"{os.getuid()}:{os.getgid()}"
460
+ print(f"User running process: {user_info}")
461
+ except AttributeError: # os.getuid/gid not available on Windows
462
+ print("User running process: UID/GID not available on this OS")
463
+
464
+ # Check permissions (use absolute paths inside container context)
465
+ container_upload_path = "/app/uploads"
466
+ container_results_path = "/app/results"
467
+ for path_str in [container_upload_path, container_results_path]:
468
+ path_obj = Path(path_str)
469
+ if path_obj.exists():
470
+ stat_info = path_obj.stat()
471
+ permissions = oct(stat_info.st_mode)[-3:]
472
+ owner = f"{stat_info.st_uid}:{stat_info.st_gid}"
473
+ print(f"Permissions for {path_str}: {permissions}")
474
+ print(f"Owner for {path_str}: {owner}")
475
+ else:
476
+ print(f"Directory {path_str} does not exist.")
477
 
478
+ print("---------------------------------------")
479
+ # Check NemaQuant script details
480
  nemaquant_script = APP_ROOT / 'nemaquant.py'
481
  print(f"NemaQuant script exists: {nemaquant_script.exists()}")
482
  if nemaquant_script.exists():
483
+ try:
484
+ permissions = oct(nemaquant_script.stat().st_mode)[-3:]
485
+ print(f"NemaQuant script permissions: {permissions}")
486
+ # Check if executable
487
+ # is_executable = os.access(str(nemaquant_script), os.X_OK)
488
+ # print(f"NemaQuant script is executable: {is_executable}") # This might be unreliable depending on FS/user
489
+ except Exception as e:
490
+ print(f"Could not get NemaQuant script details: {e}")
491
+
492
  print("---------------------------------------")
493
 
494
+
495
+ if __name__ == '__main__':
496
+ print_startup_info() # Print info before starting server
497
+ # Use 0.0.0.0 to be accessible within Docker network
498
+ # Use a port other than 5000 if needed, e.g., 7860 for HF Spaces compatibility
499
+ # Turn off debug mode for production or use a WSGI server like gunicorn
500
+ app.run(host='0.0.0.0', port=7860, debug=True) # Keep debug for now
nemaquant.py CHANGED
@@ -10,6 +10,7 @@ from pathlib import Path
10
  from ultralytics import YOLO
11
  from glob import glob
12
  import re
 
13
 
14
  def options():
15
  parser = argparse.ArgumentParser(description="Nematode egg image processing with YOLOv8 model.")
@@ -166,7 +167,13 @@ def main():
166
  # multi-image mode, runs differently depending on whether you have /XY00/ subdirectories
167
  elif args.img_mode == 'dir':
168
  if args.xy_mode:
169
- for subdir in args.subdir_paths:
 
 
 
 
 
 
170
  # check that the empty file with well name is present
171
  well = [x.name for x in subdir.iterdir() if re.match("_[A-H][0-9]{1,2}", x.name)][0]
172
  if len(well) == 0:
@@ -216,7 +223,13 @@ def main():
216
  # running model() on the target dir instead of image-by-image would be cleaner
217
  # but makes saving annotated images more complicated
218
  # can maybe revisit later
219
- for impath in sorted(args.subimage_paths):
 
 
 
 
 
 
220
  img = cv2.imread(str(impath))
221
  results = model.predict(img, imgsz = 1440, verbose=False, conf= args.conf)
222
  result = results[0]
@@ -232,7 +245,6 @@ def main():
232
  annot_path = args.annotpath / ('%s_annotated%s' % (impath.stem, impath.suffix))
233
  cv2.imwrite(str(annot_path), annot)
234
  outdf = pd.DataFrame({
235
- 'folder': args.imgpath,
236
  "filename": tmp_filenames,
237
  "num_eggs": tmp_numeggs})
238
  # save final pandas df, print some updates for user
 
10
  from ultralytics import YOLO
11
  from glob import glob
12
  import re
13
+ import sys # Import sys module
14
 
15
  def options():
16
  parser = argparse.ArgumentParser(description="Nematode egg image processing with YOLOv8 model.")
 
167
  # multi-image mode, runs differently depending on whether you have /XY00/ subdirectories
168
  elif args.img_mode == 'dir':
169
  if args.xy_mode:
170
+ total_subdirs = len(args.subdir_paths)
171
+ for i, subdir in enumerate(args.subdir_paths):
172
+ # Report progress
173
+ progress_percent = int(((i + 1) / total_subdirs) * 90) # Scale to 0-90% range
174
+ print(f"PROGRESS: {progress_percent}")
175
+ sys.stdout.flush() # Flush output buffer
176
+
177
  # check that the empty file with well name is present
178
  well = [x.name for x in subdir.iterdir() if re.match("_[A-H][0-9]{1,2}", x.name)][0]
179
  if len(well) == 0:
 
223
  # running model() on the target dir instead of image-by-image would be cleaner
224
  # but makes saving annotated images more complicated
225
  # can maybe revisit later
226
+ total_images = len(args.subimage_paths)
227
+ for i, impath in enumerate(sorted(args.subimage_paths)):
228
+ # Report progress
229
+ progress_percent = int(((i + 1) / total_images) * 90) # Scale to 0-90% range
230
+ print(f"PROGRESS: {progress_percent}")
231
+ sys.stdout.flush() # Flush output buffer
232
+
233
  img = cv2.imread(str(impath))
234
  results = model.predict(img, imgsz = 1440, verbose=False, conf= args.conf)
235
  result = results[0]
 
245
  annot_path = args.annotpath / ('%s_annotated%s' % (impath.stem, impath.suffix))
246
  cv2.imwrite(str(annot_path), annot)
247
  outdf = pd.DataFrame({
 
248
  "filename": tmp_filenames,
249
  "num_eggs": tmp_numeggs})
250
  # save final pandas df, print some updates for user
requirements.txt CHANGED
@@ -1,4 +1,3 @@
1
- PyQt6>=6.4.0
2
  pandas>=1.5.0
3
  opencv-python>=4.7.0
4
  numpy>=1.21.0
 
 
1
  pandas>=1.5.0
2
  opencv-python>=4.7.0
3
  numpy>=1.21.0
static/script.js CHANGED
@@ -1,66 +1,221 @@
1
  // Basic front-end logic
2
 
3
  document.addEventListener('DOMContentLoaded', () => {
4
- const inputMode = document.getElementById('input-mode');
 
5
  const fileInput = document.getElementById('file-input');
 
 
 
6
  const startProcessingBtn = document.getElementById('start-processing');
7
  const confidenceSlider = document.getElementById('confidence-threshold');
8
  const confidenceValue = document.getElementById('confidence-value');
9
  const progress = document.getElementById('progress');
10
  const progressText = document.getElementById('progress-text');
11
- const processingStatus = document.getElementById('processing-status');
12
  const statusOutput = document.getElementById('status-output');
13
- const resultsTableBody = document.querySelector('#results-table tbody');
 
14
  const previewImage = document.getElementById('preview-image');
15
  const imageInfo = document.getElementById('image-info');
16
  const prevBtn = document.getElementById('prev-image');
17
  const nextBtn = document.getElementById('next-image');
18
- const exportCsvBtn = document.getElementById('export-csv');
19
- const exportImagesBtn = document.getElementById('export-images');
20
  const zoomInBtn = document.getElementById('zoom-in');
21
  const zoomOutBtn = document.getElementById('zoom-out');
 
 
 
22
 
23
  let currentResults = [];
24
  let currentImageIndex = -1;
25
  let currentJobId = null;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26
 
27
- // Update confidence value display
28
- confidenceSlider.addEventListener('input', () => {
29
- confidenceValue.textContent = confidenceSlider.value;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30
  });
31
 
32
- // Handle input mode change (single file vs multiple)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
  inputMode.addEventListener('change', () => {
34
- if (inputMode.value === 'single') {
35
- fileInput.removeAttribute('multiple');
 
 
 
 
 
 
 
 
36
  } else {
37
- fileInput.setAttribute('multiple', '');
 
 
38
  }
 
 
 
 
 
39
  });
40
 
41
- // --- Updated Start Processing Logic ---
 
 
 
 
 
 
42
  startProcessingBtn.addEventListener('click', async () => {
43
  const files = fileInput.files;
44
  if (!files || files.length === 0) {
45
  logStatus('Error: No files selected.');
46
- alert('Please select one or more image files.');
47
  return;
48
  }
49
 
50
  const mode = inputMode.value;
51
- if (mode === 'single' && files.length > 1) {
52
- logStatus('Error: Single File mode selected, but multiple files chosen.');
53
- alert('Single File mode allows only one file. Please select one file or switch to Directory mode.');
54
- return;
55
- }
56
 
 
 
57
  logStatus('Starting upload and processing...');
58
- processingStatus.textContent = 'Uploading...';
59
- startProcessingBtn.disabled = true;
60
- progress.value = 0;
61
- progressText.textContent = '0%';
62
  resultsTableBody.innerHTML = ''; // Clear previous results
63
  clearPreview(); // Clear image preview
 
 
 
 
 
64
 
65
  const formData = new FormData();
66
  for (const file of files) {
@@ -69,158 +224,424 @@ document.addEventListener('DOMContentLoaded', () => {
69
  formData.append('input_mode', mode);
70
  formData.append('confidence_threshold', confidenceSlider.value);
71
 
72
- // Basic progress simulation for upload (replace with XHR progress if needed)
73
- progress.value = 50;
74
- progressText.textContent = '50%';
75
- processingStatus.textContent = 'Processing...';
76
-
77
  try {
78
  const response = await fetch('/process', {
79
  method: 'POST',
80
  body: formData,
81
  });
82
 
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) {
100
- logStatus('Processing successful.');
101
- processingStatus.textContent = 'Processing finished.';
102
- currentJobId = data.job_id; // Store the job ID
103
- logStatus(`Job ID: ${currentJobId}`);
104
- if (data.log) {
105
- logStatus("--- Server Log ---");
106
- logStatus(data.log);
107
- logStatus("--- End Log ---");
108
- }
109
- if (data.error_files && data.error_files.length > 0) {
110
- logStatus(`Warning: Skipped invalid files: ${data.error_files.join(', ')}`);
111
- }
112
- displayResults(data.results || []);
113
  } else {
114
- logStatus(`Error: ${data.error || 'Unknown processing error.'}`);
115
- if (data.log) {
116
- logStatus("--- Server Log ---");
117
- logStatus(data.log);
118
- logStatus("--- End Log ---");
119
- }
120
- processingStatus.textContent = 'Error during processing.';
121
- alert(`Processing failed: ${data.error || 'Unknown error'}`);
122
  }
123
 
124
  } catch (error) {
125
- console.error('Fetch Error:', error);
126
- logStatus(`Network or Server Error: ${error.message}`);
127
- processingStatus.textContent = 'Network Error.';
128
- progress.value = 0;
129
- progressText.textContent = 'Error';
130
- alert(`An error occurred while communicating with the server: ${error.message}`);
131
- } finally {
132
- startProcessingBtn.disabled = false; // Re-enable button
133
  }
 
134
  });
135
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
136
  function logStatus(message) {
137
- statusOutput.value += message + '\n';
138
- statusOutput.scrollTop = statusOutput.scrollHeight; // Auto-scroll
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
139
  }
140
 
 
141
  function displayResults(results) {
142
- currentResults = results; // Store results
143
- resultsTableBody.innerHTML = ''; // Clear existing results
144
- currentImageIndex = -1; // Reset index
145
- currentJobId = currentJobId; // Ensure job ID is current
 
146
 
147
  if (!results || results.length === 0) {
148
- logStatus("No results returned from processing.");
149
  clearPreview();
150
- exportCsvBtn.disabled = true;
151
- exportImagesBtn.disabled = true;
152
- updateNavButtons(); // Ensure nav buttons are disabled
153
- return; // Exit early
154
  }
155
 
156
- results.forEach((result, index) => {
157
- const row = resultsTableBody.insertRow();
158
- row.classList.add('result-row'); // Add class for styling/selection
159
- row.innerHTML = `<td>${result.filename}</td><td>${result.num_eggs}</td>`;
160
- // Add data attributes for easy access
161
- row.dataset.index = index;
162
- row.dataset.filename = result.filename;
163
- row.dataset.annotatedFilename = result.annotated_filename;
 
 
 
164
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
165
  row.addEventListener('click', () => {
166
- displayImage(index);
 
 
 
167
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
168
  });
169
-
170
- displayImage(0); // Show the first image initially
171
- // Enable export buttons IF there are results
172
- exportCsvBtn.disabled = !results.some(r => r.filename); // Enable if at least one result has a filename
173
- exportImagesBtn.disabled = !results.some(r => r.annotated_filename); // Enable if at least one result has an annotated image
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
174
  }
175
 
 
176
  function displayImage(index) {
177
- if (index < 0 || index >= currentResults.length || !currentJobId) {
178
- clearPreview();
179
- return;
180
- }
181
-
182
  currentImageIndex = index;
183
  const result = currentResults[index];
184
-
185
  if (result.annotated_filename) {
186
  const imageUrl = `/results/${currentJobId}/${result.annotated_filename}`;
187
  previewImage.src = imageUrl;
188
- previewImage.alt = `Annotated ${result.filename}`;
189
- imageInfo.textContent = `Filename: ${result.filename} - Eggs detected: ${result.num_eggs}`;
190
- logStatus(`Displaying image: ${result.annotated_filename}`);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
191
  } else {
192
- // Handle cases where annotation failed or wasn't produced
193
- previewImage.src = ''; // Clear image
194
- previewImage.alt = 'Annotated image not available';
195
- imageInfo.textContent = `Filename: ${result.filename} - Eggs detected: ${result.num_eggs} (Annotation N/A)`;
196
- logStatus(`Annotated image not available for: ${result.filename}`);
197
  }
198
 
199
- updateNavButtons();
200
-
201
- // Highlight selected row
202
- document.querySelectorAll('.result-row').forEach(row => {
203
- row.classList.remove('selected');
204
- });
205
- const selectedRow = resultsTableBody.querySelector(`tr[data-index="${index}"]`);
206
- if (selectedRow) {
207
- selectedRow.classList.add('selected');
208
- }
209
  }
210
 
211
  function clearPreview() {
212
  previewImage.src = '';
213
- previewImage.alt = 'Annotated image preview';
214
- imageInfo.textContent = 'Filename: - Eggs detected: -';
215
  currentImageIndex = -1;
216
- updateNavButtons();
217
- }
218
-
219
- function updateNavButtons() {
220
- prevBtn.disabled = currentImageIndex <= 0;
221
- nextBtn.disabled = currentImageIndex < 0 || currentImageIndex >= currentResults.length - 1;
 
 
222
  }
223
 
 
224
  prevBtn.addEventListener('click', () => {
225
  if (currentImageIndex > 0) {
226
  displayImage(currentImageIndex - 1);
@@ -233,66 +654,40 @@ document.addEventListener('DOMContentLoaded', () => {
233
  }
234
  });
235
 
236
- // --- Export Logic (Placeholders - requires backend implementation) ---
237
- exportCsvBtn.addEventListener('click', () => {
238
- if (!currentJobId) {
239
- alert("No job processed yet.");
240
- return;
241
  }
242
- // Construct the CSV download URL
243
- const csvFilename = `${currentJobId}_results.csv`;
244
- const downloadUrl = `/results/${currentJobId}/${csvFilename}`;
245
- logStatus(`Triggering CSV download: ${csvFilename}`);
246
- // Create a temporary link and click it
247
- const link = document.createElement('a');
248
- link.href = downloadUrl;
249
- link.download = csvFilename; // Suggest filename to browser
250
- document.body.appendChild(link);
251
- link.click();
252
- document.body.removeChild(link);
253
- });
254
 
255
- exportImagesBtn.addEventListener('click', () => {
256
- if (!currentJobId) {
257
- alert("No job processed yet.");
258
- return;
259
  }
260
- // This is more complex. Ideally, the backend would provide a zip file.
261
- // For now, just log and maybe open the first image?
262
- logStatus('Image export clicked. Downloading individual images or a zip file is needed.');
263
- alert('Image export functionality requires backend support to create a downloadable archive (zip file).');
264
- // Example: trigger download of the currently viewed image
265
- if (currentImageIndex !== -1 && currentResults[currentImageIndex].annotated_filename) {
266
- const imgFilename = currentResults[currentImageIndex].annotated_filename;
267
- const downloadUrl = `/results/${currentJobId}/${imgFilename}`;
268
- const link = document.createElement('a');
269
- link.href = downloadUrl;
270
- link.download = imgFilename;
271
- document.body.appendChild(link);
272
- link.click();
273
- document.body.removeChild(link);
274
  }
275
  });
276
 
277
- // --- Zoom Logic (Placeholder - requires a library or complex CSS/JS) ---
278
- zoomInBtn.addEventListener('click', () => {
279
- console.log('Zoom In clicked');
280
- logStatus('Zoom functionality not yet implemented.');
281
- alert('Zoom functionality is not yet implemented.');
282
  });
283
 
284
- zoomOutBtn.addEventListener('click', () => {
285
- console.log('Zoom Out clicked');
286
- logStatus('Zoom functionality not yet implemented.');
287
- alert('Zoom functionality is not yet implemented.');
288
  });
289
 
290
- // Initial setup
291
- if (inputMode.value === 'single') {
292
- fileInput.removeAttribute('multiple');
293
- }
294
- logStatus('Application initialized. Ready for file selection.');
295
- exportCsvBtn.disabled = true;
296
- exportImagesBtn.disabled = true;
297
- clearPreview(); // Ensure preview is cleared on load
298
  });
 
1
  // Basic front-end logic
2
 
3
  document.addEventListener('DOMContentLoaded', () => {
4
+ // UI Elements
5
+ const dropZone = document.getElementById('drop-zone');
6
  const fileInput = document.getElementById('file-input');
7
+ const fileList = document.getElementById('file-list');
8
+ const uploadText = document.getElementById('upload-text');
9
+ const inputMode = document.getElementById('input-mode');
10
  const startProcessingBtn = document.getElementById('start-processing');
11
  const confidenceSlider = document.getElementById('confidence-threshold');
12
  const confidenceValue = document.getElementById('confidence-value');
13
  const progress = document.getElementById('progress');
14
  const progressText = document.getElementById('progress-text');
15
+ const progressPercentage = document.getElementById('progress-percentage');
16
  const statusOutput = document.getElementById('status-output');
17
+ const clearLogBtn = document.getElementById('clear-log');
18
+ const resultsTableBody = document.querySelector('.results-table tbody');
19
  const previewImage = document.getElementById('preview-image');
20
  const imageInfo = document.getElementById('image-info');
21
  const prevBtn = document.getElementById('prev-image');
22
  const nextBtn = document.getElementById('next-image');
 
 
23
  const zoomInBtn = document.getElementById('zoom-in');
24
  const zoomOutBtn = document.getElementById('zoom-out');
25
+ const exportCsvBtn = document.getElementById('export-csv');
26
+ const exportImagesBtn = document.getElementById('export-images');
27
+ const inputModeHelp = document.getElementById('input-mode-help');
28
 
29
  let currentResults = [];
30
  let currentImageIndex = -1;
31
  let currentJobId = null;
32
+ let currentZoomLevel = 1;
33
+ const MAX_ZOOM = 3;
34
+ const MIN_ZOOM = 0.5;
35
+ let progressInterval = null; // Interval timer for polling
36
+
37
+ // Pagination and sorting variables
38
+ const RESULTS_PER_PAGE = 10;
39
+ let currentPage = 1;
40
+ let totalPages = 1;
41
+ let currentSortField = null;
42
+ let currentSortDirection = 'asc';
43
+
44
+ // Input mode change
45
+ inputMode.addEventListener('change', () => {
46
+ const mode = inputMode.value;
47
+ if (mode === 'files') {
48
+ fileInput.removeAttribute('webkitdirectory');
49
+ fileInput.removeAttribute('directory');
50
+ fileInput.setAttribute('multiple', '');
51
+ inputModeHelp.textContent = 'Choose one or more image files for processing';
52
+ uploadText.textContent = 'Drag and drop images here or click to browse';
53
+ } else {
54
+ fileInput.setAttribute('webkitdirectory', '');
55
+ fileInput.setAttribute('directory', '');
56
+ fileInput.removeAttribute('multiple');
57
+ inputModeHelp.textContent = 'Select a folder containing images to process';
58
+ uploadText.textContent = 'Click to select a folder containing images';
59
+ }
60
+ // Clear any existing files
61
+ fileInput.value = '';
62
+ fileList.innerHTML = '';
63
+ updateUploadState();
64
+ });
65
 
66
+ // File Upload Handling
67
+ function handleFiles(files) {
68
+ // Log the selected files
69
+ // logStatus(`Selected ${files.length} file(s):`);
70
+ // Array.from(files).forEach(file => {
71
+ // logStatus(`- ${file.name} (${formatFileSize(file.size)})`);
72
+ // });
73
+ const allowedTypes = ['image/png', 'image/jpeg', 'image/jpg', 'image/tiff', 'image/tif'];
74
+ const validFiles = Array.from(files).filter(file => {
75
+ // For folder mode, check if file is in a subdirectory
76
+ if (inputMode.value === 'folder') {
77
+ // Only include files with valid types and exclude any hidden files
78
+ return allowedTypes.includes(file.type) &&
79
+ file.webkitRelativePath &&
80
+ !file.webkitRelativePath.startsWith('.');
81
+ }
82
+ return allowedTypes.includes(file.type);
83
+ });
84
+ const invalidFiles = Array.from(files).filter(file => !allowedTypes.includes(file.type));
85
+
86
+ if (invalidFiles.length > 0) {
87
+ logStatus(`Warning: Skipped ${invalidFiles.length} invalid files. Only PNG, JPG, and TIFF are supported.`);
88
+ // Log each invalid file
89
+ invalidFiles.forEach(file => {
90
+ logStatus(`- Skipped: ${file.name} (invalid type: ${file.type || 'unknown'})`);
91
+ });
92
+ }
93
+
94
+ if (validFiles.length === 0) {
95
+ logStatus('Error: No valid image files selected.');
96
+ return;
97
+ }
98
+
99
+ fileList.innerHTML = '';
100
+
101
+ // Just show an icon to indicate files are selected
102
+ const summaryDiv = document.createElement('div');
103
+ summaryDiv.className = 'file-summary';
104
+ summaryDiv.innerHTML = `
105
+ <div class="summary-header">
106
+ <i class="ri-file-list-3-line"></i>
107
+ <span>Images ready for processing</span>
108
+ </div>
109
+ `;
110
+ fileList.appendChild(summaryDiv);
111
+
112
+ fileInput.files = files;
113
+ updateUploadState(validFiles.length);
114
+ }
115
+
116
+ function formatFileSize(bytes) {
117
+ if (bytes === 0) return '0 B';
118
+ const k = 1024;
119
+ const sizes = ['B', 'KB', 'MB', 'GB'];
120
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
121
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
122
+ }
123
+
124
+ // Drag and Drop
125
+ ['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
126
+ dropZone.addEventListener(eventName, preventDefaults, false);
127
+ document.body.addEventListener(eventName, preventDefaults, false);
128
+ });
129
+
130
+ function preventDefaults(e) {
131
+ e.preventDefault();
132
+ e.stopPropagation();
133
+ }
134
+
135
+ ['dragenter', 'dragover'].forEach(eventName => {
136
+ dropZone.addEventListener(eventName, highlight, false);
137
  });
138
 
139
+ ['dragleave', 'drop'].forEach(eventName => {
140
+ dropZone.addEventListener(eventName, unhighlight, false);
141
+ });
142
+
143
+ function highlight() {
144
+ dropZone.classList.add('drag-over');
145
+ }
146
+
147
+ function unhighlight() {
148
+ dropZone.classList.remove('drag-over');
149
+ }
150
+
151
+ dropZone.addEventListener('drop', (e) => {
152
+ const dt = e.dataTransfer;
153
+ const files = dt.files;
154
+ handleFiles(files);
155
+ });
156
+
157
+ // Click to upload
158
+ dropZone.addEventListener('click', () => {
159
+ fileInput.click();
160
+ });
161
+
162
+ fileInput.addEventListener('change', () => {
163
+ handleFiles(fileInput.files);
164
+ });
165
+
166
+ // Input mode change
167
  inputMode.addEventListener('change', () => {
168
+ updateUploadState();
169
+ });
170
+
171
+ function updateUploadState(validFileCount) {
172
+ const files = fileInput.files;
173
+ if (!files || files.length === 0) {
174
+ uploadText.textContent = inputMode.value === 'folder'
175
+ ? 'Click to select a folder containing images'
176
+ : 'Drag and drop images here or click to browse';
177
+ startProcessingBtn.disabled = true;
178
  } else {
179
+ // Show the count here
180
+ uploadText.textContent = `${validFileCount} image${validFileCount === 1 ? '' : 's'} selected`;
181
+ startProcessingBtn.disabled = validFileCount === 0;
182
  }
183
+ }
184
+
185
+ // Confidence threshold
186
+ confidenceSlider.addEventListener('input', () => {
187
+ confidenceValue.textContent = confidenceSlider.value;
188
  });
189
 
190
+ // Clear log
191
+ clearLogBtn.addEventListener('click', () => {
192
+ statusOutput.textContent = '';
193
+ logStatus('Log cleared');
194
+ });
195
+
196
+ // Processing
197
  startProcessingBtn.addEventListener('click', async () => {
198
  const files = fileInput.files;
199
  if (!files || files.length === 0) {
200
  logStatus('Error: No files selected.');
 
201
  return;
202
  }
203
 
204
  const mode = inputMode.value;
205
+ // Removed single file warning, as backend now handles it
206
+ // if (mode === 'single' && files.length > 1) { ... }
 
 
 
207
 
208
+ // Start processing
209
+ setLoading(true);
210
  logStatus('Starting upload and processing...');
211
+ updateProgress(0, 'Uploading files...');
 
 
 
212
  resultsTableBody.innerHTML = ''; // Clear previous results
213
  clearPreview(); // Clear image preview
214
+ currentResults = []; // Reset results data
215
+ if (progressInterval) {
216
+ clearInterval(progressInterval);
217
+ progressInterval = null;
218
+ }
219
 
220
  const formData = new FormData();
221
  for (const file of files) {
 
224
  formData.append('input_mode', mode);
225
  formData.append('confidence_threshold', confidenceSlider.value);
226
 
 
 
 
 
 
227
  try {
228
  const response = await fetch('/process', {
229
  method: 'POST',
230
  body: formData,
231
  });
232
 
233
+ // Handle non-JSON initial response or network errors
234
+ if (!response.ok) {
235
+ let errorText = `HTTP error! status: ${response.status}`;
236
+ try {
237
+ const errorData = await response.json();
238
+ errorText += `: ${errorData.error || 'Unknown server error'}`;
239
+ if (errorData.log) logStatus(`Server Log: ${errorData.log}`);
240
+ } catch (e) {
241
+ // Response wasn't JSON
242
+ errorText += ` - ${response.statusText}`;
243
+ }
244
+ throw new Error(errorText);
245
  }
246
 
 
247
  const data = await response.json();
248
+
249
+ // Check for errors returned immediately by /process
250
+ if (data.error) {
251
+ logStatus(`Error starting process: ${data.error}`);
252
+ if(data.log) logStatus(`Details: ${data.log}`);
253
+ throw new Error(data.error); // Throw to trigger catch block
254
+ }
255
 
256
+ // If processing started successfully, begin polling
257
+ if (data.status === 'processing' && data.job_id) {
258
+ currentJobId = data.job_id;
259
+ logStatus(data.initial_log || `Processing started with Job ID: ${currentJobId}. Polling for progress...`);
260
+ updateProgress(1, 'Processing started...'); // Small initial progress
261
+ pollProgress(currentJobId);
 
 
 
 
 
 
 
 
262
  } else {
263
+ // Should not happen if backend is correct, but handle defensively
264
+ throw new Error('Unexpected response from server after starting process.');
 
 
 
 
 
 
265
  }
266
 
267
  } catch (error) {
268
+ logStatus(`Error: ${error.message}`);
269
+ updateProgress(0, 'Error occurred');
270
+ setLoading(false);
271
+ if (progressInterval) {
272
+ clearInterval(progressInterval);
273
+ progressInterval = null;
274
+ }
 
275
  }
276
+ // Removed finally block here, setLoading(false) is handled by pollProgress or catch block
277
  });
278
 
279
+ // --- New Polling Function ---
280
+ function pollProgress(jobId) {
281
+ if (progressInterval) {
282
+ clearInterval(progressInterval); // Clear any existing timer
283
+ }
284
+
285
+ progressInterval = setInterval(async () => {
286
+ try {
287
+ const response = await fetch(`/progress/${jobId}`);
288
+ if (!response.ok) {
289
+ // Handle cases where the progress endpoint itself fails
290
+ let errorText = `Progress check failed: ${response.status}`;
291
+ try {
292
+ const errorData = await response.json();
293
+ errorText += `: ${errorData.error || 'Unknown progress error'}`;
294
+ } catch(e) { errorText += ` - ${response.statusText}`; }
295
+ throw new Error(errorText);
296
+ }
297
+
298
+ const data = await response.json();
299
+
300
+ // Update UI based on status
301
+ updateProgress(data.progress || 0, data.status);
302
+ // Optionally update log with snippets from data.log if desired during processing
303
+ // logStatus(`[Progress ${data.progress}%] ${data.log || data.status}`);
304
+
305
+ if (data.status === 'success') {
306
+ clearInterval(progressInterval);
307
+ progressInterval = null;
308
+ updateProgress(100, 'Processing complete');
309
+ logStatus("Processing finished successfully.");
310
+ displayResults(data.results || []);
311
+ setLoading(false);
312
+ } else if (data.status === 'error') {
313
+ clearInterval(progressInterval);
314
+ progressInterval = null;
315
+ logStatus(`Error during processing: ${data.error || 'Unknown error'}`);
316
+ updateProgress(data.progress || 100, 'Error'); // Show error state on progress bar
317
+ setLoading(false);
318
+ } else if (data.status === 'running' || data.status === 'starting') {
319
+ // Continue polling
320
+ // logStatus(`Processing status: ${data.status} (${data.progress}%)`); // Removed for cleaner log
321
+ // You could add snippets from data.log here if the backend provides useful intermediate logs
322
+ } else {
323
+ // Unknown status - stop polling to prevent infinite loops
324
+ clearInterval(progressInterval);
325
+ progressInterval = null;
326
+ logStatus(`Warning: Unknown job status received: ${data.status}. Stopping progress updates.`);
327
+ updateProgress(data.progress || 0, `Unknown (${data.status})`);
328
+ setLoading(false);
329
+ }
330
+
331
+ } catch (error) {
332
+ clearInterval(progressInterval);
333
+ progressInterval = null;
334
+ logStatus(`Error polling progress: ${error.message}`);
335
+ updateProgress(0, 'Polling Error');
336
+ setLoading(false);
337
+ }
338
+ }, 2000); // Poll every 2 seconds
339
+ }
340
+
341
+ // --- UI Update Functions ---
342
+ function setLoading(isLoading) {
343
+ startProcessingBtn.disabled = isLoading;
344
+ if (isLoading) {
345
+ startProcessingBtn.innerHTML = '<i class="ri-loader-4-line"></i> Processing...';
346
+ document.body.classList.add('processing');
347
+ } else {
348
+ startProcessingBtn.innerHTML = '<i class="ri-play-line"></i> Start Processing';
349
+ document.body.classList.remove('processing');
350
+ }
351
+ }
352
+
353
+ function updateProgress(value, message) {
354
+ progress.value = value;
355
+ progressPercentage.textContent = `${value}%`;
356
+ progressText.textContent = message;
357
+ }
358
+
359
  function logStatus(message) {
360
+ const timestamp = new Date().toLocaleTimeString();
361
+ statusOutput.innerHTML += `[${timestamp}] ${message}\n`;
362
+ statusOutput.scrollTop = statusOutput.scrollHeight;
363
+ }
364
+
365
+ // Add click handlers for sortable columns
366
+ document.querySelectorAll('.results-table th[data-sort]').forEach(header => {
367
+ header.addEventListener('click', () => {
368
+ const field = header.dataset.sort;
369
+ if (currentSortField === field) {
370
+ currentSortDirection = currentSortDirection === 'asc' ? 'desc' : 'asc';
371
+ } else {
372
+ currentSortField = field;
373
+ currentSortDirection = 'asc';
374
+ }
375
+
376
+ // Update sort indicators
377
+ document.querySelectorAll('.results-table th[data-sort]').forEach(h => {
378
+ h.classList.remove('sort-asc', 'sort-desc');
379
+ });
380
+ header.classList.add(`sort-${currentSortDirection}`);
381
+
382
+ // Sort and redisplay results
383
+ sortResults();
384
+ displayResultsPage(currentPage);
385
+ });
386
+ });
387
+
388
+ function sortResults() {
389
+ if (!currentSortField) return;
390
+
391
+ currentResults.sort((a, b) => {
392
+ let aVal = a[currentSortField];
393
+ let bVal = b[currentSortField];
394
+
395
+ // Handle numeric sorting for num_eggs
396
+ if (currentSortField === 'num_eggs') {
397
+ aVal = parseInt(aVal) || 0;
398
+ bVal = parseInt(bVal) || 0;
399
+ }
400
+
401
+ if (aVal < bVal) return currentSortDirection === 'asc' ? -1 : 1;
402
+ if (aVal > bVal) return currentSortDirection === 'asc' ? 1 : -1;
403
+ return 0;
404
+ });
405
  }
406
 
407
+ // Results Display
408
  function displayResults(results) {
409
+ currentResults = results;
410
+ resultsTableBody.innerHTML = '';
411
+ currentImageIndex = -1;
412
+ currentSortField = null;
413
+ currentSortDirection = 'asc';
414
 
415
  if (!results || results.length === 0) {
416
+ logStatus("No results to display");
417
  clearPreview();
418
+ return;
 
 
 
419
  }
420
 
421
+ // Reset sort indicators
422
+ document.querySelectorAll('.results-table th[data-sort]').forEach(h => {
423
+ h.classList.remove('sort-asc', 'sort-desc');
424
+ });
425
+
426
+ // Calculate pagination
427
+ totalPages = Math.ceil(results.length / RESULTS_PER_PAGE);
428
+ currentPage = 1;
429
+
430
+ // Display current page
431
+ displayResultsPage(currentPage);
432
 
433
+ // Enable export buttons
434
+ exportCsvBtn.disabled = false;
435
+ exportImagesBtn.disabled = false;
436
+
437
+ // Show first image
438
+ displayImage(0);
439
+ logStatus(`Displayed ${results.length} results`);
440
+ }
441
+
442
+ // Display a specific page of results
443
+ function displayResultsPage(page) {
444
+ resultsTableBody.innerHTML = '';
445
+
446
+ // Calculate start and end indices for current page
447
+ const startIndex = (page - 1) * RESULTS_PER_PAGE;
448
+ const endIndex = Math.min(startIndex + RESULTS_PER_PAGE, currentResults.length);
449
+
450
+ // Display results for current page
451
+ for (let i = startIndex; i < endIndex; i++) {
452
+ const result = currentResults[i];
453
+ const row = resultsTableBody.insertRow();
454
+ row.innerHTML = `
455
+ <td>
456
+ <i class="ri-image-line"></i>
457
+ ${result.filename}
458
+ </td>
459
+ <td>${result.num_eggs}</td>
460
+ <td class="text-right">
461
+ <button class="view-button" title="Click to view image">
462
+ <i class="ri-eye-line"></i>
463
+ View
464
+ </button>
465
+ </td>
466
+ `;
467
+
468
+ // Store the original index to maintain image preview relationship
469
+ row.dataset.originalIndex = currentResults.indexOf(result);
470
+
471
  row.addEventListener('click', () => {
472
+ const originalIndex = parseInt(row.dataset.originalIndex);
473
+ displayImage(originalIndex);
474
+ document.querySelectorAll('.results-table tr').forEach(r => r.classList.remove('selected'));
475
+ row.classList.add('selected');
476
  });
477
+ }
478
+
479
+ // Update pagination UI
480
+ updatePaginationControls();
481
+ }
482
+
483
+ // Update pagination controls with enhanced display
484
+ function updatePaginationControls() {
485
+ const paginationContainer = document.getElementById('pagination-controls');
486
+ if (!paginationContainer) return;
487
+
488
+ paginationContainer.innerHTML = '';
489
+
490
+ if (totalPages <= 1) return;
491
+
492
+ const controls = document.createElement('div');
493
+ controls.className = 'pagination-controls';
494
+
495
+ // Previous button
496
+ const prevButton = document.createElement('button');
497
+ prevButton.innerHTML = '<i class="ri-arrow-left-s-line"></i>';
498
+ prevButton.className = 'pagination-btn';
499
+ prevButton.disabled = currentPage === 1;
500
+ prevButton.addEventListener('click', () => {
501
+ if (currentPage > 1) {
502
+ currentPage--;
503
+ displayResultsPage(currentPage);
504
+ }
505
  });
506
+ controls.appendChild(prevButton);
507
+
508
+ // Page numbers with ellipsis
509
+ const addPageButton = (pageNum) => {
510
+ const button = document.createElement('button');
511
+ button.textContent = pageNum;
512
+ button.className = 'pagination-btn' + (pageNum === currentPage ? ' active' : '');
513
+ button.addEventListener('click', () => {
514
+ currentPage = pageNum;
515
+ displayResultsPage(currentPage);
516
+ });
517
+ controls.appendChild(button);
518
+ };
519
+
520
+ const addEllipsis = () => {
521
+ const span = document.createElement('span');
522
+ span.className = 'pagination-ellipsis';
523
+ span.textContent = '...';
524
+ controls.appendChild(span);
525
+ };
526
+
527
+ // First page
528
+ addPageButton(1);
529
+
530
+ // Calculate visible page range
531
+ if (totalPages > 7) {
532
+ if (currentPage > 3) {
533
+ addEllipsis();
534
+ }
535
+
536
+ for (let i = Math.max(2, currentPage - 1); i <= Math.min(currentPage + 1, totalPages - 1); i++) {
537
+ addPageButton(i);
538
+ }
539
+
540
+ if (currentPage < totalPages - 2) {
541
+ addEllipsis();
542
+ }
543
+ } else {
544
+ // Show all pages if total pages is small
545
+ for (let i = 2; i < totalPages; i++) {
546
+ addPageButton(i);
547
+ }
548
+ }
549
+
550
+ // Last page if not already added
551
+ if (totalPages > 1) {
552
+ addPageButton(totalPages);
553
+ }
554
+
555
+ // Next button
556
+ const nextButton = document.createElement('button');
557
+ nextButton.innerHTML = '<i class="ri-arrow-right-s-line"></i>';
558
+ nextButton.className = 'pagination-btn';
559
+ nextButton.disabled = currentPage === totalPages;
560
+ nextButton.addEventListener('click', () => {
561
+ if (currentPage < totalPages) {
562
+ currentPage++;
563
+ displayResultsPage(currentPage);
564
+ }
565
+ });
566
+ controls.appendChild(nextButton);
567
+
568
+ // Add page info
569
+ const pageInfo = document.createElement('div');
570
+ pageInfo.className = 'pagination-info';
571
+ pageInfo.textContent = `Page ${currentPage} of ${totalPages}`;
572
+
573
+ // Add both controls and info to container
574
+ paginationContainer.appendChild(controls);
575
+ paginationContainer.appendChild(pageInfo);
576
  }
577
 
578
+ // Image Preview
579
  function displayImage(index) {
580
+ if (!currentResults[index]) return;
581
+
 
 
 
582
  currentImageIndex = index;
583
  const result = currentResults[index];
584
+
585
  if (result.annotated_filename) {
586
  const imageUrl = `/results/${currentJobId}/${result.annotated_filename}`;
587
  previewImage.src = imageUrl;
588
+ previewImage.alt = result.filename;
589
+ imageInfo.innerHTML = `
590
+ <i class="ri-image-line"></i> ${result.filename}
591
+ <br>
592
+ <i class="ri-egg-line"></i> ${result.num_eggs} eggs detected
593
+ `;
594
+
595
+ // Enable zoom controls
596
+ zoomInBtn.disabled = false;
597
+ zoomOutBtn.disabled = false;
598
+
599
+ // Calculate which page this image should be on
600
+ const targetPage = Math.floor(index / RESULTS_PER_PAGE) + 1;
601
+
602
+ // If we're not on the correct page, switch to it
603
+ if (currentPage !== targetPage) {
604
+ currentPage = targetPage;
605
+ displayResultsPage(currentPage);
606
+ }
607
+
608
+ // Remove selection from all rows
609
+ document.querySelectorAll('.results-table tr').forEach(r => r.classList.remove('selected'));
610
+
611
+ // Find and highlight the corresponding row
612
+ const rows = resultsTableBody.querySelectorAll('tr');
613
+ rows.forEach(row => {
614
+ if (parseInt(row.dataset.originalIndex) === index) {
615
+ row.classList.add('selected');
616
+ // Scroll the row into view if needed
617
+ row.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
618
+ }
619
+ });
620
  } else {
621
+ clearPreview();
 
 
 
 
622
  }
623
 
624
+ // Update navigation
625
+ prevBtn.disabled = index <= 0;
626
+ nextBtn.disabled = index >= currentResults.length - 1;
 
 
 
 
 
 
 
627
  }
628
 
629
  function clearPreview() {
630
  previewImage.src = '';
631
+ previewImage.alt = 'No image selected';
632
+ imageInfo.textContent = 'Select an image from the results to view';
633
  currentImageIndex = -1;
634
+ currentZoomLevel = 1;
635
+ updateZoom();
636
+
637
+ // Disable controls
638
+ prevBtn.disabled = true;
639
+ nextBtn.disabled = true;
640
+ zoomInBtn.disabled = true;
641
+ zoomOutBtn.disabled = true;
642
  }
643
 
644
+ // Image Navigation
645
  prevBtn.addEventListener('click', () => {
646
  if (currentImageIndex > 0) {
647
  displayImage(currentImageIndex - 1);
 
654
  }
655
  });
656
 
657
+ // Zoom Controls
658
+ function updateZoom() {
659
+ if (previewImage.src) {
660
+ previewImage.style.transform = `scale(${currentZoomLevel})`;
 
661
  }
662
+ }
 
 
 
 
 
 
 
 
 
 
 
663
 
664
+ zoomInBtn.addEventListener('click', () => {
665
+ if (currentZoomLevel < MAX_ZOOM) {
666
+ currentZoomLevel += 0.25;
667
+ updateZoom();
668
  }
669
+ });
670
+
671
+ zoomOutBtn.addEventListener('click', () => {
672
+ if (currentZoomLevel > MIN_ZOOM) {
673
+ currentZoomLevel -= 0.25;
674
+ updateZoom();
 
 
 
 
 
 
 
 
675
  }
676
  });
677
 
678
+ // Export Handlers
679
+ exportCsvBtn.addEventListener('click', () => {
680
+ if (!currentJobId) return;
681
+ window.location.href = `/results/${currentJobId}/results.csv`;
 
682
  });
683
 
684
+ exportImagesBtn.addEventListener('click', () => {
685
+ if (!currentJobId) return;
686
+ logStatus('Downloading annotated images...');
687
+ window.location.href = `/export_images/${currentJobId}`;
688
  });
689
 
690
+ // Initialize
691
+ updateUploadState();
692
+ logStatus('Application ready');
 
 
 
 
 
693
  });
static/style.css CHANGED
@@ -1,116 +1,326 @@
1
- /* Refined styling to better match screenshot */
2
 
3
  :root {
4
- --primary-color: #0d6efd; /* Bootstrap blue */
5
- --secondary-color: #6c757d; /* Bootstrap secondary grey */
6
- --light-grey: #f8f9fa; /* Light background */
7
- --border-color: #dee2e6; /* Standard border */
 
 
 
8
  --card-bg: #ffffff;
9
- --text-color: #212529;
10
- --selected-row-bg: #e9ecef;
 
 
 
11
  }
12
 
13
  body {
14
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
15
- margin: 20px;
16
- background-color: var(--light-grey);
 
17
  color: var(--text-color);
18
- font-size: 0.95rem;
 
19
  }
20
 
21
  h1 {
22
  text-align: center;
23
- color: #333;
24
- margin-bottom: 25px;
 
 
25
  }
26
 
27
  .container {
28
- display: flex;
29
- gap: 25px;
 
 
 
 
30
  }
31
 
32
- .left-panel,
33
- .right-panel {
34
- flex: 1;
35
  display: flex;
36
  flex-direction: column;
37
- gap: 20px;
 
 
 
 
 
 
38
  }
39
 
40
  .card {
41
  background-color: var(--card-bg);
42
- border: 1px solid var(--border-color);
43
- border-radius: 6px;
44
- padding: 20px;
45
- box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
 
 
 
46
  }
47
 
48
  .card h2 {
49
- margin-top: 0;
50
- margin-bottom: 15px;
51
- border-bottom: 1px solid var(--border-color);
52
- padding-bottom: 10px;
53
- font-size: 1.1rem;
54
- font-weight: 500;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
55
  }
56
 
57
- /* --- General Form Elements --- */
58
  label {
59
  display: block;
60
- margin-bottom: 5px;
61
  font-weight: 500;
 
 
62
  }
63
 
64
  select,
65
  input[type="text"],
66
  input[type="number"],
67
- input[type="file"]::file-selector-button, /* Style the button part */
68
  textarea {
69
- display: block;
70
- width: calc(100% - 16px); /* Account for padding */
71
- padding: 8px;
72
- margin-bottom: 15px;
73
  border: 1px solid var(--border-color);
74
- border-radius: 4px;
75
- font-size: 0.9rem;
 
 
 
76
  }
77
 
78
- select {
79
- width: 100%; /* Select takes full width */
80
- cursor: pointer;
 
 
 
 
81
  }
82
 
83
- /* Style the actual file input button */
84
- input[type="file"] {
85
- padding: 0;
86
- border: none;
 
 
 
 
87
  }
88
- input[type="file"]::file-selector-button {
89
- background-color: var(--primary-color);
90
- color: white;
91
- border: none;
92
- padding: 8px 12px;
93
- border-radius: 4px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
94
  cursor: pointer;
95
- transition: background-color 0.2s ease;
96
- margin-right: 10px; /* Space between button and text */
97
- font-size: 0.9rem;
98
- width: auto; /* Override width */
99
  }
100
 
101
- input[type="file"]::file-selector-button:hover {
102
- background-color: #0b5ed7; /* Darker blue on hover */
103
  }
104
 
105
- /* --- Buttons --- */
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
106
  button {
107
- padding: 8px 15px;
 
 
 
 
108
  border: none;
109
- border-radius: 4px;
 
 
110
  cursor: pointer;
111
- font-size: 0.9rem;
112
- transition: background-color 0.2s ease, box-shadow 0.2s ease;
113
- text-align: center;
114
  }
115
 
116
  button:disabled {
@@ -118,218 +328,259 @@ button:disabled {
118
  cursor: not-allowed;
119
  }
120
 
121
- /* Primary Button Style (e.g., Start Processing) */
122
- #start-processing,
123
- #export-csv,
124
- #export-images {
125
  background-color: var(--primary-color);
126
  color: white;
127
  }
128
 
129
- #start-processing:not(:disabled):hover,
130
- #export-csv:not(:disabled):hover,
131
- #export-images:not(:disabled):hover {
132
- background-color: #0b5ed7; /* Darker blue */
133
- box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
134
  }
135
 
136
- /* Secondary/Control Button Style (e.g., Zoom, Prev/Next) */
137
- #zoom-in, #zoom-out, #prev-image, #next-image {
138
  background-color: var(--secondary-color);
139
  color: white;
140
  }
141
 
142
- #zoom-in:not(:disabled):hover, #zoom-out:not(:disabled):hover, #prev-image:not(:disabled):hover, #next-image:not(:disabled):hover {
143
- background-color: #5c636a; /* Darker grey */
144
  }
145
 
146
- /* --- Specific Sections --- */
 
 
 
147
 
148
- /* Parameters */
149
- #parameters {
150
  display: flex;
151
- align-items: center; /* Align items vertically */
152
- flex-wrap: wrap; /* Allow wrapping if needed */
 
153
  }
154
 
155
- #parameters label {
156
- margin-right: 10px;
157
- margin-bottom: 0; /* Remove bottom margin for inline */
158
- display: inline-block; /* Make label inline */
 
 
159
  }
160
 
161
- #parameters input[type="range"] {
162
- flex-grow: 1; /* Allow slider to take up space */
163
- width: auto; /* Override default width */
164
- height: 5px; /* Make slider thinner */
165
- cursor: pointer;
166
- margin: 0 10px; /* Add some margin */
167
- vertical-align: middle;
168
- padding: 0; /* Remove padding */
169
- margin-bottom: 0;
170
  }
171
 
172
- /* Basic range slider styling (will vary by browser) */
173
- input[type=range]::-webkit-slider-thumb {
174
- -webkit-appearance: none;
175
- appearance: none;
176
- width: 16px;
177
- height: 16px;
178
- background: var(--primary-color);
179
- cursor: pointer;
180
- border-radius: 50%;
181
  }
182
 
183
- input[type=range]::-moz-range-thumb {
184
- width: 16px;
185
- height: 16px;
186
- background: var(--primary-color);
187
- cursor: pointer;
188
- border-radius: 50%;
189
- border: none;
190
  }
191
 
192
- #parameters #confidence-value {
193
- font-weight: bold;
194
- min-width: 30px; /* Ensure space for value */
195
- text-align: right;
196
- margin-left: 5px;
 
 
 
197
  }
198
 
199
- /* Processing */
200
- #processing progress {
201
- width: calc(100% - 50px); /* Adjust width to fit text */
202
- height: 10px;
203
- vertical-align: middle;
204
- margin-right: 5px;
205
- border: 1px solid var(--border-color);
206
- border-radius: 4px;
207
- overflow: hidden; /* Ensure border radius clips the progress */
208
  }
209
 
210
- /* Basic progress bar styling */
211
- progress::-webkit-progress-bar {
212
- background-color: #e9ecef;
213
- border-radius: 4px;
 
 
 
 
 
 
 
 
 
 
 
214
  }
215
 
216
- progress::-webkit-progress-value {
217
- background-color: var(--primary-color);
218
- border-radius: 4px;
219
- transition: width 0.3s ease;
220
  }
221
 
222
- progress::-moz-progress-bar {
223
- background-color: var(--primary-color);
224
- border-radius: 4px;
225
- transition: width 0.3s ease;
 
 
226
  }
227
 
228
- #processing #progress-text {
229
- vertical-align: middle;
230
- font-weight: bold;
 
 
 
231
  }
232
 
233
- #processing button {
234
- margin-top: 15px;
235
- display: block; /* Make button block level */
236
- width: auto;
237
  }
238
 
239
- #processing #processing-status {
240
- margin-top: 10px;
241
  font-size: 0.9em;
242
- color: var(--secondary-color);
243
  }
244
 
245
- /* Status Log */
246
- #status-log textarea {
247
- width: calc(100% - 16px); /* Account for padding */
248
- height: 180px; /* Slightly taller */
249
- font-family: monospace;
250
- font-size: 0.85em;
251
- border: 1px solid var(--border-color);
252
- padding: 8px;
253
- resize: vertical; /* Allow vertical resize */
254
  }
255
 
256
- /* Results Summary */
257
- #export-options {
258
- margin-bottom: 10px;
259
  }
260
- #export-options label {
261
- display: inline-block;
262
- margin-right: 10px;
 
 
 
 
 
 
263
  }
264
 
265
- #export-options button {
266
- margin-left: 5px;
267
  }
268
 
269
- #results-summary table {
270
- width: 100%;
271
- border-collapse: collapse;
272
- margin-top: 15px;
 
 
 
 
 
 
 
 
 
273
  font-size: 0.9em;
274
  }
275
 
276
- #results-summary th,
277
- #results-summary td {
278
- border: 1px solid var(--border-color);
279
- padding: 10px;
280
- text-align: left;
281
- vertical-align: middle;
 
 
 
282
  }
283
 
284
- #results-summary th {
285
- background-color: #e9ecef; /* Lighter grey header */
286
- font-weight: 600;
 
287
  }
288
 
289
- #results-summary tbody tr {
 
 
 
 
 
 
 
 
 
 
 
290
  cursor: pointer;
291
- transition: background-color 0.15s ease;
292
  }
293
 
294
- #results-summary tbody tr:hover {
295
- background-color: #f8f9fa; /* Very light grey on hover */
 
 
296
  }
297
 
298
- #results-summary tbody tr.selected {
299
- background-color: var(--selected-row-bg); /* Use variable for selected */
300
- font-weight: 500;
 
301
  }
302
 
303
- /* Image Preview */
304
- #image-preview #image-container {
305
- border: 1px solid var(--border-color);
306
- height: 350px; /* Adjust as needed */
307
- overflow: auto;
308
- display: flex;
309
- justify-content: center;
310
- align-items: center;
311
- margin-bottom: 10px;
312
- background-color: #e9ecef; /* Light background for container */
313
  }
314
 
315
- #image-preview img {
316
- max-width: 100%;
317
- max-height: 100%;
318
- display: block;
319
- object-fit: contain; /* Ensure image fits well */
320
  }
321
 
322
- #image-preview #image-info {
323
- font-size: 0.9em;
324
- color: var(--secondary-color);
325
- text-align: center;
326
- margin-bottom: 10px;
327
  }
328
 
329
- #image-preview .image-controls {
330
- text-align: center;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
331
  }
332
 
333
- #image-preview .image-controls button {
334
- margin: 0 5px;
335
  }
 
1
+ /* Modern, consistent styling for NemaQuant */
2
 
3
  :root {
4
+ --primary-color: #2563eb; /* Modern blue */
5
+ --primary-hover: #1d4ed8;
6
+ --secondary-color: #64748b;
7
+ --secondary-hover: #475569;
8
+ --success-color: #22c55e;
9
+ --danger-color: #ef4444;
10
+ --background: #f8fafc;
11
  --card-bg: #ffffff;
12
+ --border-color: #e2e8f0;
13
+ --text-color: #1e293b;
14
+ --text-muted: #64748b;
15
+ --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
16
+ --shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
17
  }
18
 
19
  body {
20
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
21
+ margin: 0;
22
+ padding: 20px;
23
+ background-color: var(--background);
24
  color: var(--text-color);
25
+ line-height: 1.5;
26
+ min-height: 100vh;
27
  }
28
 
29
  h1 {
30
  text-align: center;
31
+ color: var(--text-color);
32
+ font-size: 1.875rem;
33
+ font-weight: 600;
34
+ margin: 1rem 0;
35
  }
36
 
37
  .container {
38
+ max-width: 1600px;
39
+ margin: 0 auto;
40
+ display: grid;
41
+ grid-template-columns: minmax(300px, 1fr) minmax(600px, 2fr);
42
+ gap: 1.5rem;
43
+ padding: 0 1rem;
44
  }
45
 
46
+ .left-panel, .right-panel {
 
 
47
  display: flex;
48
  flex-direction: column;
49
+ gap: 1rem;
50
+ }
51
+
52
+ @media (max-width: 1024px) {
53
+ .container {
54
+ grid-template-columns: 1fr;
55
+ }
56
  }
57
 
58
  .card {
59
  background-color: var(--card-bg);
60
+ border-radius: 0.5rem;
61
+ box-shadow: var(--shadow);
62
+ padding: 1.25rem;
63
+ }
64
+
65
+ .card.compact {
66
+ padding: 1rem;
67
  }
68
 
69
  .card h2 {
70
+ margin: 0 0 1rem 0;
71
+ color: var(--text-color);
72
+ font-size: 1.25rem;
73
+ font-weight: 600;
74
+ display: flex;
75
+ align-items: center;
76
+ gap: 0.5rem;
77
+ }
78
+
79
+ .card.compact h2 {
80
+ font-size: 1rem;
81
+ margin-bottom: 0.75rem;
82
+ }
83
+
84
+ /* Form Elements */
85
+ .form-group {
86
+ margin-bottom: 1rem;
87
+ }
88
+
89
+ .form-group:last-child {
90
+ margin-bottom: 0;
91
  }
92
 
 
93
  label {
94
  display: block;
95
+ margin-bottom: 0.5rem;
96
  font-weight: 500;
97
+ color: var(--text-color);
98
+ font-size: 0.875rem;
99
  }
100
 
101
  select,
102
  input[type="text"],
103
  input[type="number"],
 
104
  textarea {
105
+ width: 100%;
106
+ padding: 0.5rem;
 
 
107
  border: 1px solid var(--border-color);
108
+ border-radius: 0.375rem;
109
+ background-color: white;
110
+ color: var(--text-color);
111
+ font-size: 0.875rem;
112
+ transition: border-color 0.15s ease;
113
  }
114
 
115
+ select:focus,
116
+ input[type="text"]:focus,
117
+ input[type="number"]:focus,
118
+ textarea:focus {
119
+ outline: none;
120
+ border-color: var(--primary-color);
121
+ box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
122
  }
123
 
124
+ /* File Upload */
125
+ .file-upload {
126
+ border: 2px dashed var(--border-color);
127
+ border-radius: 0.5rem;
128
+ padding: 1.5rem;
129
+ text-align: center;
130
+ cursor: pointer;
131
+ transition: all 0.15s ease;
132
  }
133
+
134
+ .file-upload:hover,
135
+ .file-upload.drag-over {
136
+ border-color: var(--primary-color);
137
+ background-color: rgba(37, 99, 235, 0.05);
138
+ }
139
+
140
+ .file-upload input[type="file"] {
141
+ display: none;
142
+ }
143
+
144
+ .file-upload-text {
145
+ color: var(--text-muted);
146
+ margin: 0.5rem 0;
147
+ font-size: 0.875rem;
148
+ }
149
+
150
+ .file-list {
151
+ margin-top: 0.75rem;
152
+ text-align: left;
153
+ }
154
+
155
+ .file-item {
156
+ display: flex;
157
+ align-items: center;
158
+ gap: 0.5rem;
159
+ padding: 0.375rem;
160
+ font-size: 0.875rem;
161
+ border-radius: 0.25rem;
162
+ }
163
+
164
+ .file-item:hover {
165
+ background-color: var(--background);
166
+ }
167
+
168
+ /* Image Preview Section */
169
+ .preview-section {
170
+ display: flex;
171
+ flex-direction: column;
172
+ margin-bottom: 1rem;
173
+ }
174
+
175
+ .image-preview {
176
+ position: relative;
177
+ border-radius: 0.5rem;
178
+ overflow: hidden;
179
+ background-color: var(--background);
180
+ display: flex;
181
+ align-items: center;
182
+ justify-content: center;
183
+ height: 400px;
184
+ }
185
+
186
+ .image-preview img {
187
+ max-width: 100%;
188
+ max-height: 100%;
189
+ object-fit: contain;
190
+ transition: transform 0.2s ease;
191
+ }
192
+
193
+ .image-controls {
194
+ display: flex;
195
+ gap: 0.5rem;
196
+ margin-top: 1rem;
197
+ justify-content: center;
198
+ }
199
+
200
+ .image-info {
201
+ margin: 1rem 0;
202
+ padding: 0.75rem;
203
+ background-color: var(--background);
204
+ border-radius: 0.375rem;
205
+ font-size: 0.875rem;
206
+ }
207
+
208
+ /* Results Section */
209
+ .results-section {
210
+ display: flex;
211
+ flex-direction: column;
212
+ gap: 1rem;
213
+ min-height: 200px;
214
+ max-height: 400px;
215
+ }
216
+
217
+ .results-header {
218
+ display: flex;
219
+ align-items: center;
220
+ justify-content: space-between;
221
+ margin-bottom: 0.5rem;
222
+ }
223
+
224
+ .export-buttons {
225
+ display: flex;
226
+ gap: 0.5rem;
227
+ }
228
+
229
+ .table-container {
230
+ flex: 1;
231
+ overflow-y: auto;
232
+ border: 1px solid var(--border-color);
233
+ border-radius: 0.5rem;
234
+ background: white;
235
+ }
236
+
237
+ .results-table {
238
+ width: 100%;
239
+ border-collapse: collapse;
240
+ font-size: 0.875rem;
241
+ }
242
+
243
+ .results-table th {
244
+ background-color: var(--background);
245
+ font-weight: 600;
246
+ position: sticky;
247
+ top: 0;
248
+ z-index: 1;
249
+ border-bottom: 2px solid var(--border-color);
250
+ }
251
+
252
+ .results-table th,
253
+ .results-table td {
254
+ padding: 0.75rem;
255
+ text-align: left;
256
+ }
257
+
258
+ .results-table tbody tr {
259
+ border-bottom: 1px solid var(--border-color);
260
  cursor: pointer;
261
+ transition: all 0.15s ease;
 
 
 
262
  }
263
 
264
+ .results-table tbody tr:hover {
265
+ background-color: rgba(37, 99, 235, 0.05);
266
  }
267
 
268
+ .results-table tbody tr.selected {
269
+ background-color: rgba(37, 99, 235, 0.1);
270
+ }
271
+
272
+ .text-right {
273
+ text-align: right !important;
274
+ }
275
+
276
+ .view-button {
277
+ display: inline-flex;
278
+ align-items: center;
279
+ gap: 0.25rem;
280
+ padding: 0.375rem 0.75rem;
281
+ font-size: 0.75rem;
282
+ color: var(--primary-color);
283
+ background-color: rgba(37, 99, 235, 0.1);
284
+ border-radius: 0.375rem;
285
+ transition: all 0.15s ease;
286
+ }
287
+
288
+ .view-button:hover {
289
+ background-color: rgba(37, 99, 235, 0.2);
290
+ }
291
+
292
+ .view-button i {
293
+ font-size: 1rem;
294
+ }
295
+
296
+ /* Status Log */
297
+ .status-log {
298
+ background-color: var(--background);
299
+ border-radius: 0.375rem;
300
+ padding: 0.75rem;
301
+ font-family: ui-monospace, monospace;
302
+ font-size: 0.75rem;
303
+ line-height: 1.4;
304
+ height: 120px;
305
+ overflow-y: auto;
306
+ white-space: pre-wrap;
307
+ color: var(--text-color);
308
+ border: 1px solid var(--border-color);
309
+ }
310
+
311
+ /* Buttons */
312
  button {
313
+ display: inline-flex;
314
+ align-items: center;
315
+ justify-content: center;
316
+ gap: 0.375rem;
317
+ padding: 0.5rem 1rem;
318
  border: none;
319
+ border-radius: 0.375rem;
320
+ font-weight: 500;
321
+ font-size: 0.875rem;
322
  cursor: pointer;
323
+ transition: all 0.15s ease;
 
 
324
  }
325
 
326
  button:disabled {
 
328
  cursor: not-allowed;
329
  }
330
 
331
+ .btn-primary {
 
 
 
332
  background-color: var(--primary-color);
333
  color: white;
334
  }
335
 
336
+ .btn-primary:hover:not(:disabled) {
337
+ background-color: var(--primary-hover);
 
 
 
338
  }
339
 
340
+ .btn-secondary {
 
341
  background-color: var(--secondary-color);
342
  color: white;
343
  }
344
 
345
+ .btn-secondary:hover:not(:disabled) {
346
+ background-color: var(--secondary-hover);
347
  }
348
 
349
+ /* Progress Bar */
350
+ .progress-container {
351
+ margin: 1rem 0;
352
+ }
353
 
354
+ .progress-info {
 
355
  display: flex;
356
+ justify-content: space-between;
357
+ margin-bottom: 0.5rem;
358
+ font-size: 0.875rem;
359
  }
360
 
361
+ progress {
362
+ width: 100%;
363
+ height: 0.5rem;
364
+ border-radius: 1rem;
365
+ overflow: hidden;
366
+ background-color: var(--border-color);
367
  }
368
 
369
+ progress::-webkit-progress-bar {
370
+ background-color: var(--border-color);
371
+ }
372
+
373
+ progress::-webkit-progress-value {
374
+ background-color: var(--primary-color);
375
+ transition: width 0.3s ease;
 
 
376
  }
377
 
378
+ progress::-moz-progress-bar {
379
+ background-color: var(--primary-color);
 
 
 
 
 
 
 
380
  }
381
 
382
+ /* Loading States */
383
+ .loading {
384
+ position: relative;
385
+ pointer-events: none;
 
 
 
386
  }
387
 
388
+ .loading::after {
389
+ content: "";
390
+ position: absolute;
391
+ inset: 0;
392
+ background-color: rgba(255, 255, 255, 0.7);
393
+ display: flex;
394
+ align-items: center;
395
+ justify-content: center;
396
  }
397
 
398
+ /* Tooltips */
399
+ [data-tooltip] {
400
+ position: relative;
401
+ cursor: help;
 
 
 
 
 
402
  }
403
 
404
+ [data-tooltip]::after {
405
+ content: attr(data-tooltip);
406
+ position: absolute;
407
+ bottom: 100%;
408
+ left: 50%;
409
+ transform: translateX(-50%);
410
+ padding: 0.5rem;
411
+ background-color: var(--text-color);
412
+ color: white;
413
+ font-size: 0.75rem;
414
+ border-radius: 0.25rem;
415
+ white-space: nowrap;
416
+ opacity: 0;
417
+ pointer-events: none;
418
+ transition: opacity 0.15s ease;
419
  }
420
 
421
+ [data-tooltip]:hover::after {
422
+ opacity: 1;
 
 
423
  }
424
 
425
+ /* File Upload Styles */
426
+ .file-summary {
427
+ background: var(--bg-secondary);
428
+ border-radius: 6px;
429
+ padding: 0.75rem;
430
+ margin-bottom: 0.75rem;
431
  }
432
 
433
+ .summary-header {
434
+ display: flex;
435
+ align-items: center;
436
+ gap: 0.5rem;
437
+ color: var(--text-primary);
438
+ font-weight: 500;
439
  }
440
 
441
+ .summary-header i {
442
+ color: var(--primary-color);
 
 
443
  }
444
 
445
+ .total-size {
446
+ color: var(--text-muted);
447
  font-size: 0.9em;
 
448
  }
449
 
450
+ .more-files {
451
+ display: flex;
452
+ align-items: center;
453
+ gap: 0.5rem;
454
+ padding: 0.5rem;
455
+ color: var(--text-muted);
456
+ font-style: italic;
457
+ border-top: 1px dashed var(--border-color);
458
+ margin-top: 0.5rem;
459
  }
460
 
461
+ .more-files i {
462
+ color: var(--text-muted);
 
463
  }
464
+
465
+ .file-item {
466
+ display: flex;
467
+ align-items: center;
468
+ gap: 0.5rem;
469
+ padding: 0.5rem;
470
+ border-radius: 4px;
471
+ background: var(--bg-primary);
472
+ margin: 0.25rem 0;
473
  }
474
 
475
+ .file-item:hover {
476
+ background: var(--bg-secondary);
477
  }
478
 
479
+ .file-item i {
480
+ color: var(--primary-color);
481
+ }
482
+
483
+ .file-name {
484
+ flex: 1;
485
+ overflow: hidden;
486
+ text-overflow: ellipsis;
487
+ white-space: nowrap;
488
+ }
489
+
490
+ .file-size {
491
+ color: var(--text-muted);
492
  font-size: 0.9em;
493
  }
494
 
495
+ /* Pagination */
496
+ .pagination-container {
497
+ display: flex;
498
+ flex-direction: column;
499
+ align-items: center;
500
+ gap: 0.75rem;
501
+ margin-top: 1rem;
502
+ padding-top: 1rem;
503
+ border-top: 1px solid var(--border-color);
504
  }
505
 
506
+ .pagination-controls {
507
+ display: flex;
508
+ align-items: center;
509
+ gap: 0.25rem;
510
  }
511
 
512
+ .pagination-btn {
513
+ min-width: 2rem;
514
+ height: 2rem;
515
+ padding: 0 0.5rem;
516
+ display: flex;
517
+ align-items: center;
518
+ justify-content: center;
519
+ border: 1px solid var(--border-color);
520
+ border-radius: 0.375rem;
521
+ background-color: var(--card-bg);
522
+ color: var(--text-color);
523
+ font-size: 0.875rem;
524
  cursor: pointer;
525
+ transition: all 0.2s ease-in-out;
526
  }
527
 
528
+ .pagination-btn:hover:not(:disabled) {
529
+ background-color: var(--background);
530
+ border-color: var(--primary-color);
531
+ color: var(--primary-color);
532
  }
533
 
534
+ .pagination-btn.active {
535
+ background-color: var(--primary-color);
536
+ border-color: var(--primary-color);
537
+ color: white;
538
  }
539
 
540
+ .pagination-btn:disabled {
541
+ opacity: 0.5;
542
+ cursor: not-allowed;
543
+ border-color: var(--border-color);
 
 
 
 
 
 
544
  }
545
 
546
+ .pagination-ellipsis {
547
+ color: var(--text-muted);
548
+ padding: 0 0.25rem;
549
+ user-select: none;
 
550
  }
551
 
552
+ .pagination-info {
553
+ color: var(--text-muted);
554
+ font-size: 0.875rem;
 
 
555
  }
556
 
557
+ /* Sortable Table Headers */
558
+ .results-table th[data-sort] {
559
+ cursor: pointer;
560
+ user-select: none;
561
+ position: relative;
562
+ padding-right: 1.5rem;
563
+ }
564
+
565
+ .results-table th[data-sort] .sort-icon {
566
+ position: absolute;
567
+ right: 0.5rem;
568
+ top: 50%;
569
+ transform: translateY(-50%);
570
+ opacity: 0.3;
571
+ transition: opacity 0.2s ease;
572
+ }
573
+
574
+ .results-table th[data-sort]:hover .sort-icon {
575
+ opacity: 1;
576
+ }
577
+
578
+ .results-table th.sort-asc .sort-icon,
579
+ .results-table th.sort-desc .sort-icon {
580
+ opacity: 1;
581
+ color: var(--primary-color);
582
  }
583
 
584
+ .results-table th.sort-asc .sort-icon {
585
+ transform: translateY(-50%) rotate(180deg);
586
  }
templates/index.html CHANGED
@@ -5,92 +5,163 @@
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
  <title>NemaQuant - Nematode Egg Detection</title>
7
  <link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
8
- <!-- Consider adding a CSS framework like Bootstrap -->
9
  </head>
10
  <body>
11
- <h1>NemaQuant - Nematode Egg Detection</h1>
 
 
 
 
 
 
12
 
13
  <div class="container">
14
  <div class="left-panel">
15
  <!-- Input Selection -->
16
- <div class="card" id="input-selection">
17
- <h2>Input Selection</h2>
18
- <label for="input-mode">Input Mode:</label>
19
- <select id="input-mode" name="input-mode">
20
- <option value="single">Single File</option>
21
- <option value="directory">Directory (Multiple Files)</option>
22
- </select>
23
- <br>
24
- <label for="file-input">Input:</label>
25
- <input type="file" id="file-input" name="files" multiple>
26
- <!-- We'll hide/show this or use different elements based on mode -->
27
- <br>
28
- <!-- Output directory is handled by the server -->
 
 
 
 
 
 
 
 
 
 
29
  </div>
30
 
31
  <!-- Processing -->
32
- <div class="card" id="processing">
33
- <h2>Processing</h2>
34
- <label for="progress">Progress:</label>
35
- <progress id="progress" value="0" max="100"></progress>
36
- <span id="progress-text">0%</span>
37
- <br>
38
- <button id="start-processing">Start Processing</button>
39
- <p id="processing-status"></p>
40
- </div>
 
 
 
 
41
 
42
- <!-- Results -->
43
- <div class="card" id="results-summary">
44
- <h2>Results</h2>
45
- <div id="export-options">
46
- <label>Export:</label>
47
- <button id="export-csv">CSV</button>
48
- <button id="export-images">Images</button>
49
  </div>
50
- <p>Click on a row to view the annotated image</p>
51
- <table id="results-table">
52
- <thead>
53
- <tr>
54
- <th>filename</th>
55
- <th>num_eggs</th>
56
- </tr>
57
- </thead>
58
- <tbody>
59
- <!-- Results will be populated here by JavaScript -->
60
- </tbody>
61
- </table>
 
 
 
 
 
62
  </div>
63
  </div>
64
 
65
  <div class="right-panel">
66
- <!-- Parameters -->
67
- <div class="card" id="parameters">
68
- <h2>Parameters</h2>
69
- <label for="confidence-threshold">Confidence Threshold:</label>
70
- <input type="range" id="confidence-threshold" name="confidence-threshold" min="0" max="1" step="0.05" value="0.6">
71
- <span id="confidence-value">0.6</span>
72
- <!-- Add tooltip/help icon if needed -->
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
73
  </div>
74
 
75
- <!-- Status -->
76
- <div class="card" id="status-log">
77
- <h2>Status</h2>
78
- <textarea id="status-output" rows="10" readonly></textarea>
79
- </div>
 
80
 
81
- <!-- Image Preview -->
82
- <div class="card" id="image-preview">
83
- <h2>Image Preview</h2>
84
- <div id="image-container">
85
- <img id="preview-image" src="" alt="Annotated image preview">
86
- </div>
87
- <p id="image-info">Filename: - Eggs detected: -</p>
88
- <div class="image-controls">
89
- <button id="zoom-out">Zoom -</button>
90
- <button id="zoom-in">Zoom +</button>
91
- <button id="prev-image" disabled>Prev</button>
92
- <button id="next-image" disabled>Next</button>
93
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
94
  </div>
95
  </div>
96
  </div>
 
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
  <title>NemaQuant - Nematode Egg Detection</title>
7
  <link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
8
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/[email protected]/fonts/remixicon.css">
9
  </head>
10
  <body>
11
+ <h1>
12
+ <i class="ri-microscope-line"></i>
13
+ NemaQuant
14
+ <small style="display: block; font-size: 1rem; font-weight: normal; color: var(--text-muted);">
15
+ Automated Nematode Egg Detection
16
+ </small>
17
+ </h1>
18
 
19
  <div class="container">
20
  <div class="left-panel">
21
  <!-- Input Selection -->
22
+ <div class="card compact">
23
+ <h2><i class="ri-upload-cloud-line"></i> Input Selection</h2>
24
+ <div class="form-group">
25
+ <label for="input-mode">Input Mode</label>
26
+ <select id="input-mode" name="input-mode" class="form-control">
27
+ <option value="files">Select Image(s)</option>
28
+ <option value="folder">Select Folder</option>
29
+ </select>
30
+ <small class="input-help" style="color: var(--text-muted); margin-top: 0.25rem; display: block;">
31
+ <span id="input-mode-help">Choose one or more image files for processing</span>
32
+ </small>
33
+ </div>
34
+
35
+ <div class="file-upload" id="drop-zone">
36
+ <input type="file" id="file-input" name="files" accept="image/*" multiple hidden>
37
+ <i class="ri-upload-cloud-2-line" style="font-size: 2rem; color: var(--primary-color);"></i>
38
+ <p class="file-upload-text">
39
+ <span id="upload-text">Drag and drop images here or click to browse</span>
40
+ <br>
41
+ <small style="color: var(--text-muted);">Supported formats: PNG, JPG, TIFF</small>
42
+ </p>
43
+ <div id="file-list" class="file-list"></div>
44
+ </div>
45
  </div>
46
 
47
  <!-- Processing -->
48
+ <div class="card compact">
49
+ <h2><i class="ri-settings-4-line"></i> Processing</h2>
50
+ <div class="form-group">
51
+ <label for="confidence-threshold" data-tooltip="Higher values mean more confident detections">
52
+ Confidence Threshold
53
+ <i class="ri-information-line"></i>
54
+ </label>
55
+ <div class="range-with-value">
56
+ <input type="range" id="confidence-threshold" name="confidence-threshold"
57
+ min="0" max="1" step="0.05" value="0.6">
58
+ <span id="confidence-value">0.6</span>
59
+ </div>
60
+ </div>
61
 
62
+ <div class="progress-container">
63
+ <div class="progress-info">
64
+ <span id="progress-text">Ready to process</span>
65
+ <span id="progress-percentage">0%</span>
66
+ </div>
67
+ <progress id="progress" value="0" max="100"></progress>
 
68
  </div>
69
+
70
+ <button id="start-processing" class="btn-primary">
71
+ <i class="ri-play-line"></i>
72
+ Start Processing
73
+ </button>
74
+ </div>
75
+
76
+ <!-- Status Log -->
77
+ <div class="card compact">
78
+ <h2>
79
+ <i class="ri-terminal-box-line"></i>
80
+ Processing Log
81
+ <button id="clear-log" class="btn-secondary" style="margin-left: auto; padding: 0.25rem 0.5rem;">
82
+ <i class="ri-delete-bin-line"></i>
83
+ </button>
84
+ </h2>
85
+ <div id="status-output" class="status-log"></div>
86
  </div>
87
  </div>
88
 
89
  <div class="right-panel">
90
+ <!-- Image Preview -->
91
+ <div class="card preview-section">
92
+ <h2>
93
+ <i class="ri-image-edit-line"></i>
94
+ Image Preview
95
+ <div class="export-buttons">
96
+ <button id="export-csv" class="btn-secondary" disabled>
97
+ <i class="ri-file-excel-line"></i>
98
+ Export CSV
99
+ </button>
100
+ <button id="export-images" class="btn-secondary" disabled>
101
+ <i class="ri-image-line"></i>
102
+ Export Images
103
+ </button>
104
+ </div>
105
+ </h2>
106
+
107
+ <div class="image-preview" id="image-container">
108
+ <img id="preview-image" src="" alt="Preview will appear here">
109
+ </div>
110
+
111
+ <div class="image-info" id="image-info">
112
+ Select an image from the results to view
113
+ </div>
114
+
115
+ <div class="image-controls">
116
+ <button id="prev-image" class="btn-secondary" disabled>
117
+ <i class="ri-arrow-left-s-line"></i>
118
+ Previous
119
+ </button>
120
+ <button id="zoom-out" class="btn-secondary" disabled>
121
+ <i class="ri-zoom-out-line"></i>
122
+ </button>
123
+ <button id="zoom-in" class="btn-secondary" disabled>
124
+ <i class="ri-zoom-in-line"></i>
125
+ </button>
126
+ <button id="next-image" class="btn-secondary" disabled>
127
+ Next
128
+ <i class="ri-arrow-right-s-line"></i>
129
+ </button>
130
+ </div>
131
  </div>
132
 
133
+ <!-- Results -->
134
+ <div class="card">
135
+ <h2>
136
+ <i class="ri-file-list-3-line"></i>
137
+ Results
138
+ </h2>
139
 
140
+ <div class="table-container">
141
+ <table class="results-table">
142
+ <thead>
143
+ <tr>
144
+ <th data-sort="filename">
145
+ Filename
146
+ <i class="ri-arrow-up-down-line sort-icon"></i>
147
+ </th>
148
+ <th data-sort="num_eggs">
149
+ Eggs Detected
150
+ <i class="ri-arrow-up-down-line sort-icon"></i>
151
+ </th>
152
+ <th class="text-right">Action</th>
153
+ </tr>
154
+ </thead>
155
+ <tbody>
156
+ <!-- Results will be populated here -->
157
+ </tbody>
158
+ </table>
159
+
160
+ <!-- Pagination controls -->
161
+ <div id="pagination-controls" class="pagination-container">
162
+ <!-- Pagination buttons will be generated here -->
163
+ </div>
164
+ </div>
165
  </div>
166
  </div>
167
  </div>