Spaces:
Sleeping
Sleeping
Commit
·
0605276
1
Parent(s):
0a922be
Prepare app for Hugging Face Spaces deployment
Browse files- .hf/space +4 -0
- app.py +385 -205
- nemaquant.py +15 -3
- requirements.txt +0 -1
- static/script.js +574 -179
- static/style.css +457 -206
- 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 |
-
|
| 58 |
-
job_output_dir = RESULT_FOLDER / job_id
|
| 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 =
|
| 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 == '
|
| 80 |
-
input_target = str(
|
| 81 |
-
img_mode_arg = '
|
| 82 |
-
elif input_mode == '
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
|
| 93 |
cmd = [
|
| 94 |
-
|
|
|
|
| 95 |
'-i', input_target,
|
| 96 |
-
'-w', str(WEIGHTS_FILE),
|
| 97 |
'-o', str(output_csv),
|
| 98 |
'-a', str(annotated_dir),
|
| 99 |
'--conf', confidence
|
| 100 |
]
|
| 101 |
|
| 102 |
-
|
| 103 |
|
| 104 |
-
|
| 105 |
-
|
| 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 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 266 |
-
|
| 267 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 268 |
try:
|
| 269 |
-
return
|
| 270 |
-
except
|
| 271 |
-
print(f"
|
| 272 |
-
return jsonify({"error": "
|
| 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 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 286 |
|
| 287 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 288 |
print("----- NemaQuant Flask App Starting -----")
|
| 289 |
print(f"Working directory: {os.getcwd()}")
|
| 290 |
-
|
|
|
|
|
|
|
| 291 |
print(f"Weights file: {WEIGHTS_FILE}")
|
| 292 |
-
print(f"Weights file exists: {
|
| 293 |
-
if
|
| 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 |
-
|
| 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"
|
| 310 |
-
|
| 311 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 312 |
|
| 313 |
-
|
|
|
|
| 314 |
nemaquant_script = APP_ROOT / 'nemaquant.py'
|
| 315 |
print(f"NemaQuant script exists: {nemaquant_script.exists()}")
|
| 316 |
if nemaquant_script.exists():
|
| 317 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 318 |
print("---------------------------------------")
|
| 319 |
|
| 320 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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
|
| 12 |
const statusOutput = document.getElementById('status-output');
|
| 13 |
-
const
|
|
|
|
| 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 |
-
//
|
| 28 |
-
|
| 29 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
});
|
| 31 |
|
| 32 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
inputMode.addEventListener('change', () => {
|
| 34 |
-
|
| 35 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
} else {
|
| 37 |
-
|
|
|
|
|
|
|
| 38 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
});
|
| 40 |
|
| 41 |
-
//
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 52 |
-
|
| 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 |
-
|
| 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 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
|
|
|
| 94 |
}
|
| 95 |
|
| 96 |
-
// Now we can safely parse JSON
|
| 97 |
const data = await response.json();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 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 |
-
|
| 115 |
-
|
| 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 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
startProcessingBtn.disabled = false; // Re-enable button
|
| 133 |
}
|
|
|
|
| 134 |
});
|
| 135 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 136 |
function logStatus(message) {
|
| 137 |
-
|
| 138 |
-
statusOutput.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 139 |
}
|
| 140 |
|
|
|
|
| 141 |
function displayResults(results) {
|
| 142 |
-
currentResults = results;
|
| 143 |
-
resultsTableBody.innerHTML = '';
|
| 144 |
-
currentImageIndex = -1;
|
| 145 |
-
|
|
|
|
| 146 |
|
| 147 |
if (!results || results.length === 0) {
|
| 148 |
-
logStatus("No results
|
| 149 |
clearPreview();
|
| 150 |
-
|
| 151 |
-
exportImagesBtn.disabled = true;
|
| 152 |
-
updateNavButtons(); // Ensure nav buttons are disabled
|
| 153 |
-
return; // Exit early
|
| 154 |
}
|
| 155 |
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
|
|
|
|
|
|
|
|
|
| 164 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 165 |
row.addEventListener('click', () => {
|
| 166 |
-
|
|
|
|
|
|
|
|
|
|
| 167 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 168 |
});
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
//
|
| 172 |
-
|
| 173 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 174 |
}
|
| 175 |
|
|
|
|
| 176 |
function displayImage(index) {
|
| 177 |
-
if (index
|
| 178 |
-
|
| 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 =
|
| 189 |
-
imageInfo.
|
| 190 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 191 |
} else {
|
| 192 |
-
|
| 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 |
-
|
| 200 |
-
|
| 201 |
-
|
| 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 = '
|
| 214 |
-
imageInfo.textContent = '
|
| 215 |
currentImageIndex = -1;
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
prevBtn.disabled =
|
| 221 |
-
nextBtn.disabled =
|
|
|
|
|
|
|
| 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 |
-
//
|
| 237 |
-
|
| 238 |
-
if (
|
| 239 |
-
|
| 240 |
-
return;
|
| 241 |
}
|
| 242 |
-
|
| 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 |
-
|
| 256 |
-
if (
|
| 257 |
-
|
| 258 |
-
|
| 259 |
}
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 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 |
-
//
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
alert('Zoom functionality is not yet implemented.');
|
| 282 |
});
|
| 283 |
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
logStatus('
|
| 287 |
-
|
| 288 |
});
|
| 289 |
|
| 290 |
-
//
|
| 291 |
-
|
| 292 |
-
|
| 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 |
-
/*
|
| 2 |
|
| 3 |
:root {
|
| 4 |
-
--primary-color: #
|
| 5 |
-
--
|
| 6 |
-
--
|
| 7 |
-
--
|
|
|
|
|
|
|
|
|
|
| 8 |
--card-bg: #ffffff;
|
| 9 |
-
--
|
| 10 |
-
--
|
|
|
|
|
|
|
|
|
|
| 11 |
}
|
| 12 |
|
| 13 |
body {
|
| 14 |
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
| 15 |
-
margin:
|
| 16 |
-
|
|
|
|
| 17 |
color: var(--text-color);
|
| 18 |
-
|
|
|
|
| 19 |
}
|
| 20 |
|
| 21 |
h1 {
|
| 22 |
text-align: center;
|
| 23 |
-
color:
|
| 24 |
-
|
|
|
|
|
|
|
| 25 |
}
|
| 26 |
|
| 27 |
.container {
|
| 28 |
-
|
| 29 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
}
|
| 31 |
|
| 32 |
-
.left-panel,
|
| 33 |
-
.right-panel {
|
| 34 |
-
flex: 1;
|
| 35 |
display: flex;
|
| 36 |
flex-direction: column;
|
| 37 |
-
gap:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
}
|
| 39 |
|
| 40 |
.card {
|
| 41 |
background-color: var(--card-bg);
|
| 42 |
-
border:
|
| 43 |
-
|
| 44 |
-
padding:
|
| 45 |
-
|
|
|
|
|
|
|
|
|
|
| 46 |
}
|
| 47 |
|
| 48 |
.card h2 {
|
| 49 |
-
margin
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
}
|
| 56 |
|
| 57 |
-
/* --- General Form Elements --- */
|
| 58 |
label {
|
| 59 |
display: block;
|
| 60 |
-
margin-bottom:
|
| 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 |
-
|
| 70 |
-
|
| 71 |
-
padding: 8px;
|
| 72 |
-
margin-bottom: 15px;
|
| 73 |
border: 1px solid var(--border-color);
|
| 74 |
-
border-radius:
|
| 75 |
-
|
|
|
|
|
|
|
|
|
|
| 76 |
}
|
| 77 |
|
| 78 |
-
select
|
| 79 |
-
|
| 80 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
}
|
| 82 |
|
| 83 |
-
/*
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
border:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
}
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
border:
|
| 92 |
-
|
| 93 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
cursor: pointer;
|
| 95 |
-
transition:
|
| 96 |
-
margin-right: 10px; /* Space between button and text */
|
| 97 |
-
font-size: 0.9rem;
|
| 98 |
-
width: auto; /* Override width */
|
| 99 |
}
|
| 100 |
|
| 101 |
-
|
| 102 |
-
background-color:
|
| 103 |
}
|
| 104 |
|
| 105 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
button {
|
| 107 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 108 |
border: none;
|
| 109 |
-
border-radius:
|
|
|
|
|
|
|
| 110 |
cursor: pointer;
|
| 111 |
-
|
| 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 |
-
|
| 122 |
-
#start-processing,
|
| 123 |
-
#export-csv,
|
| 124 |
-
#export-images {
|
| 125 |
background-color: var(--primary-color);
|
| 126 |
color: white;
|
| 127 |
}
|
| 128 |
|
| 129 |
-
|
| 130 |
-
|
| 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 |
-
|
| 137 |
-
#zoom-in, #zoom-out, #prev-image, #next-image {
|
| 138 |
background-color: var(--secondary-color);
|
| 139 |
color: white;
|
| 140 |
}
|
| 141 |
|
| 142 |
-
|
| 143 |
-
background-color:
|
| 144 |
}
|
| 145 |
|
| 146 |
-
/*
|
|
|
|
|
|
|
|
|
|
| 147 |
|
| 148 |
-
|
| 149 |
-
#parameters {
|
| 150 |
display: flex;
|
| 151 |
-
|
| 152 |
-
|
|
|
|
| 153 |
}
|
| 154 |
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
|
|
|
|
|
|
| 159 |
}
|
| 160 |
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
padding: 0; /* Remove padding */
|
| 169 |
-
margin-bottom: 0;
|
| 170 |
}
|
| 171 |
|
| 172 |
-
|
| 173 |
-
|
| 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 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
cursor: pointer;
|
| 188 |
-
border-radius: 50%;
|
| 189 |
-
border: none;
|
| 190 |
}
|
| 191 |
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
|
|
|
|
|
|
|
|
|
| 197 |
}
|
| 198 |
|
| 199 |
-
/*
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 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 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 214 |
}
|
| 215 |
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
border-radius: 4px;
|
| 219 |
-
transition: width 0.3s ease;
|
| 220 |
}
|
| 221 |
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
|
|
|
|
|
|
| 226 |
}
|
| 227 |
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
|
|
|
|
|
|
|
|
|
| 231 |
}
|
| 232 |
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
display: block; /* Make button block level */
|
| 236 |
-
width: auto;
|
| 237 |
}
|
| 238 |
|
| 239 |
-
|
| 240 |
-
|
| 241 |
font-size: 0.9em;
|
| 242 |
-
color: var(--secondary-color);
|
| 243 |
}
|
| 244 |
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
}
|
| 255 |
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
margin-bottom: 10px;
|
| 259 |
}
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 263 |
}
|
| 264 |
|
| 265 |
-
|
| 266 |
-
|
| 267 |
}
|
| 268 |
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 273 |
font-size: 0.9em;
|
| 274 |
}
|
| 275 |
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
|
|
|
|
|
|
|
|
|
| 282 |
}
|
| 283 |
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
|
|
|
| 287 |
}
|
| 288 |
|
| 289 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 290 |
cursor: pointer;
|
| 291 |
-
transition:
|
| 292 |
}
|
| 293 |
|
| 294 |
-
|
| 295 |
-
background-color:
|
|
|
|
|
|
|
| 296 |
}
|
| 297 |
|
| 298 |
-
|
| 299 |
-
background-color: var(--
|
| 300 |
-
|
|
|
|
| 301 |
}
|
| 302 |
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 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 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
object-fit: contain; /* Ensure image fits well */
|
| 320 |
}
|
| 321 |
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
text-align: center;
|
| 326 |
-
margin-bottom: 10px;
|
| 327 |
}
|
| 328 |
|
| 329 |
-
|
| 330 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 331 |
}
|
| 332 |
|
| 333 |
-
|
| 334 |
-
|
| 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 |
-
|
| 9 |
</head>
|
| 10 |
<body>
|
| 11 |
-
<h1>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
|
| 13 |
<div class="container">
|
| 14 |
<div class="left-panel">
|
| 15 |
<!-- Input Selection -->
|
| 16 |
-
<div class="card
|
| 17 |
-
<h2>Input Selection</h2>
|
| 18 |
-
<
|
| 19 |
-
|
| 20 |
-
<
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
</div>
|
| 30 |
|
| 31 |
<!-- Processing -->
|
| 32 |
-
<div class="card
|
| 33 |
-
<h2>Processing</h2>
|
| 34 |
-
<
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
<
|
| 48 |
-
<button id="export-images">Images</button>
|
| 49 |
</div>
|
| 50 |
-
|
| 51 |
-
<
|
| 52 |
-
<
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
</div>
|
| 63 |
</div>
|
| 64 |
|
| 65 |
<div class="right-panel">
|
| 66 |
-
<!--
|
| 67 |
-
<div class="card
|
| 68 |
-
<h2>
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
</div>
|
| 74 |
|
| 75 |
-
<!--
|
| 76 |
-
<div class="card"
|
| 77 |
-
<h2>
|
| 78 |
-
|
| 79 |
-
|
|
|
|
| 80 |
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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>
|