sloneckity commited on
Commit
44d1643
·
1 Parent(s): 32482ae

Update app files and add gitignore

Browse files
Files changed (5) hide show
  1. .gitignore +4 -0
  2. README.md +173 -73
  3. app.py +83 -198
  4. static/script.js +1 -10
  5. static/style.css +1 -1
.gitignore ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ .DS_Store
2
+ weights.pt
3
+ weights_nemaquant.v1.onnx
4
+ results/
README.md CHANGED
@@ -1,90 +1,190 @@
1
  ---
2
- title: Nemaquant Flask
3
- emoji: 🏆
4
  colorFrom: indigo
5
  colorTo: blue
6
  sdk: docker
7
- pinned: false
 
 
 
 
 
 
 
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
11
 
12
- # NemaQuant Flask App for Hugging Face Spaces
13
 
14
- This repository contains a Flask application designed to provide a web interface for the `nemaquant.py` script, allowing users to upload images and run nematode egg detection via a Hugging Face Space.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15
 
16
  ## Project Structure
17
 
18
  ```
19
  /
20
- ├── app.py # Main Flask application logic
21
- ├── nemaquant.py # Original image analysis script
22
- ├── requirements.txt # Python dependencies
23
- ├── Dockerfile # Docker configuration for HF Spaces
24
- ├── README.md # This file
25
  ├── templates/
26
- │ └── index.html # HTML template for the frontend
27
  ├── static/
28
- │ ├── style.css # CSS styles
29
- │ └── script.js # JavaScript for frontend interactions
30
- ├── uploads/ # Directory for user-uploaded files (created automatically)
31
- ├── results/ # Directory for output files (created automatically)
32
- └── weights.pt # YOLO model weights (Ensure this is present or downloaded)
33
  ```
34
 
35
- ## Hugging Face Space Setup
36
-
37
- 1. **Create a new Space:** Go to [huggingface.co/new-space](https://huggingface.co/new-space).
38
- 2. **Owner & Space Name:** Choose your username/organization and a name for the space (e.g., `nemaquant-demo`).
39
- 3. **License:** Select an appropriate license (e.g., Apache 2.0).
40
- 4. **SDK:** Select **Docker**. Choose the **Blank** template.
41
- 5. **Hardware:** Select appropriate hardware (e.g., CPU Basic should be sufficient initially, but might need upgrading depending on model size and processing time).
42
- 6. **Secrets (Optional):** If your application needs API keys or other secrets, add them here.
43
- 7. **Create Space.**
44
-
45
- 8. **Upload Files:** Upload all the files from this repository to your new Hugging Face Space repository. You can do this via the web interface or using Git:
46
- ```bash
47
- # Clone your new space repository
48
- git clone https://huggingface.co/spaces/YOUR_USERNAME/YOUR_SPACE_NAME
49
- cd YOUR_SPACE_NAME
50
-
51
- # Add all your project files (ensure you have weights.pt)
52
- # ... copy files here ...
53
-
54
- # Commit and push
55
- git add .
56
- git commit -m "Initial commit of NemaQuant app"
57
- git push
58
- ```
59
-
60
- 9. **Model Weights (`weights.pt`):**
61
- * Make sure the `weights.pt` file required by `nemaquant.py` is included in the root directory of your repository.
62
- * If the weights file is large, consider using Git LFS (Large File Storage). Enable LFS in your Space settings under the "Settings" tab.
63
- ```bash
64
- # Install git-lfs if you haven't already
65
- git lfs install
66
- git lfs track "*.pt" # Track the weights file
67
- git add .gitattributes # Add the tracking file
68
- git add weights.pt
69
- git commit -m "Add weights file using LFS"
70
- git push
71
- ```
72
-
73
- 10. **Building:** The Space will automatically start building the Docker image based on your `Dockerfile`. Monitor the build logs.
74
-
75
- 11. **Running:** Once built, the application should start automatically. You can access it via the public URL provided for your Space.
76
-
77
- ## Local Development (Optional)
78
-
79
- 1. **Install Docker:** Make sure you have Docker installed and running.
80
- 2. **Build the image:**
81
- ```bash
82
- docker build -t nemaquant-flask .
83
- ```
84
- 3. **Run the container:**
85
- ```bash
86
- docker run -p 7860:7860 -v $(pwd)/results:/app/results nemaquant-flask
87
- ```
88
- * This maps port 7860 from the container to your host machine.
89
- * It also mounts the local `results` directory into the container's `/app/results` directory, so you can easily access generated files.
90
- 4. Open your browser to `http://localhost:7860`.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: NemaQuant
3
+ emoji: 🔬
4
  colorFrom: indigo
5
  colorTo: blue
6
  sdk: docker
7
+ license: apache-2.0
8
+ short_description: "YOLO-based nematode egg detection with real-time processing"
9
+ tags:
10
+ - microscopy
11
+ - object-detection
12
+ - yolo
13
+ - biology
14
+ - image-processing
15
  ---
16
 
17
+ # NemaQuant Flask App
18
 
19
+ A modern web application for automated nematode egg detection and counting using deep learning. This application provides an intuitive interface to process microscope images and identify nematode eggs using the YOLO object detection model.
20
 
21
+ ## Quick Performance Guide
22
+ - **Free Tier** (2 vCPU): 19 images ≈ 1:13 minutes
23
+ - **CPU Upgrade** ($0.03/hr): 3-4x faster
24
+ - **GPU Option** ($0.40/hr): 8-10x faster
25
+
26
+ Process 500 images for:
27
+ - FREE on basic tier (≈ 35 min)
28
+ - $0.09 with CPU upgrade (≈ 10 min)
29
+ - $0.03 with GPU (≈ 4 min)
30
+
31
+ ## Features
32
+
33
+ - **Multiple Input Modes**:
34
+ - Single image processing
35
+ - Batch processing of multiple images
36
+ - Directory/folder processing
37
+ - **Real-time Progress Tracking**: Monitor processing progress for large batches
38
+ - **Interactive Results**:
39
+ - Sortable results table
40
+ - Pagination for large datasets
41
+ - Export functionality for annotated images
42
+ - **Flexible Image Support**:
43
+ - Supports PNG, JPG, JPEG, TIF, and TIFF formats
44
+ - Automatic conversion of TIF/TIFF to PNG for web viewing
45
+ - **Confidence Threshold Adjustment**: Fine-tune detection sensitivity
46
+ - **Batch Export**: Download all annotated images as a ZIP file
47
 
48
  ## Project Structure
49
 
50
  ```
51
  /
52
+ ├── app.py # Flask application with REST endpoints and core logic
53
+ ├── nemaquant.py # YOLO-based image analysis script
54
+ ├── requirements.txt # Python dependencies
55
+ ├── Dockerfile # Container configuration
56
+ ├── README.md
57
  ├── templates/
58
+ │ └── index.html # Modern, responsive web interface
59
  ├── static/
60
+ │ ├── style.css # CSS styling
61
+ │ └── script.js # Frontend interactivity and async processing
62
+ └── weights.pt # YOLO model weights (required)
 
 
63
  ```
64
 
65
+ ## Technical Requirements
66
+
67
+ - Python 3.9+
68
+ - Key Dependencies:
69
+ - Flask >= 2.0.0
70
+ - PyTorch >= 2.0.0
71
+ - Ultralytics >= 8.0.0
72
+ - OpenCV Python >= 4.7.0
73
+ - Pandas >= 1.5.0
74
+ - NumPy >= 1.21.0
75
+
76
+ ## Setup and Deployment
77
+
78
+ ### Local Development
79
+
80
+ 1. **Clone the Repository**:
81
+ ```bash
82
+ git clone <repository-url>
83
+ cd nemaquant-flask
84
+ ```
85
+
86
+ 2. **Install Dependencies**:
87
+ ```bash
88
+ pip install -r requirements.txt
89
+ ```
90
+
91
+ 3. **Prepare Model Weights**:
92
+ - Place your `weights.pt` file in the root directory
93
+ - Ensure it's a compatible YOLO model trained for egg detection
94
+
95
+ 4. **Run the Application**:
96
+ ```bash
97
+ python app.py
98
+ ```
99
+ The application will be available at `http://localhost:7860`
100
+
101
+ ### Docker Deployment
102
+
103
+ 1. **Build the Container**:
104
+ ```bash
105
+ docker build -t nemaquant-flask .
106
+ ```
107
+
108
+ 2. **Run the Container**:
109
+ ```bash
110
+ docker run -p 7860:7860 -v $(pwd)/results:/app/results nemaquant-flask
111
+ ```
112
+
113
+ ### Hugging Face Spaces Deployment
114
+
115
+ 1. Create a new Space on [Hugging Face](https://huggingface.co/new-space)
116
+ 2. Select Docker as the SDK
117
+ 3. Configure the Space:
118
+ - Set app_port to 7860
119
+ - Enable Git LFS for the weights file
120
+ 4. Push your code:
121
+ ```bash
122
+ git lfs install
123
+ git lfs track "*.pt"
124
+ git add .gitattributes weights.pt
125
+ git add .
126
+ git commit -m "Initial deployment"
127
+ git push
128
+ ```
129
+
130
+ ## Usage
131
+
132
+ 1. **Select Input Mode**:
133
+ - Choose between single image, multiple images, or folder processing
134
+ - For folder mode, ensure images follow the expected structure
135
+
136
+ 2. **Upload Images**:
137
+ - Drag and drop or use the file browser
138
+ - Supported formats: PNG, JPG, JPEG, TIF, TIFF
139
+
140
+ 3. **Configure Processing**:
141
+ - Adjust confidence threshold if needed (default: 0.6)
142
+ - Click "Process Images" to start
143
+
144
+ 4. **View Results**:
145
+ - Monitor real-time progress
146
+ - View detected egg counts in the results table
147
+ - Sort and paginate through results
148
+ - Download annotated images individually or as a batch
149
+
150
+ ## Processing Time & Cost Estimates
151
+
152
+ ### Free Tier (CPU Basic - 2 vCPU, 16GB RAM)
153
+ - **Processing Time**:
154
+ - 19 images: ~1:13 (actual measured time)
155
+ - 100 images: ~6-7 minutes
156
+ - 500 images: ~30-35 minutes
157
+ - **Cost**: FREE
158
+ - **Limitations**:
159
+ - Slower processing speed
160
+ - 48 hour inactivity timeout
161
+ - Shared resources may affect performance
162
+ - Processing time may increase with server load
163
+
164
+ ### CPU Upgrade Option (8 vCPU, 32GB RAM)
165
+ - **Processing Time** (estimated 3-4x faster than free tier):
166
+ - 100 images: ~2 minutes
167
+ - 500 images: ~10 minutes
168
+ - **Cost**:
169
+ - $0.03/hour for CPU Upgrade
170
+ - Estimated cost for 100 images: ~$0.03
171
+ - Estimated cost for 500 images: ~$0.09 (0.17 hours * $0.03/hour)
172
+
173
+ ### GPU Options (Optional)
174
+ - **Nvidia T4 Small (4 vCPU, 16GB VRAM)**:
175
+ - Processing Time (estimated 8-10x faster than free tier):
176
+ - 100 images: ~45 seconds
177
+ - 500 images: ~4 minutes
178
+ - Cost: $0.40/hour (~$0.01 for 100 images, ~$0.03 for 500 images)
179
+
180
+ **Note**: These estimates are approximate and may vary based on:
181
+ - Image size and complexity
182
+ - Server load and availability
183
+ - Network conditions for upload/download
184
+ - Time of day (free tier performance varies with overall platform usage)
185
+
186
+ For most users, the free tier is sufficient for small to medium batches (< 200 images), while the CPU upgrade offers a good balance of cost and performance for larger datasets. GPU options are recommended only for time-sensitive processing of large batches or when processing thousands of images.
187
+
188
+ ## License
189
+
190
+ [Specify your license here]
app.py CHANGED
@@ -3,47 +3,41 @@ import subprocess
3
  import os
4
  from pathlib import Path
5
  import uuid
6
- import pandas as pd # Added for CSV parsing
7
- from werkzeug.utils import secure_filename # Added for security
8
  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
20
  APP_ROOT = Path(__file__).parent
21
- UPLOAD_FOLDER = APP_ROOT / 'uploads' # Keep separate upload folder initially
22
  RESULT_FOLDER = APP_ROOT / 'results'
23
- WEIGHTS_FILE = APP_ROOT / 'weights.pt' # Assuming weights are in root
24
  app.config['UPLOAD_FOLDER'] = str(UPLOAD_FOLDER)
25
  app.config['RESULT_FOLDER'] = str(RESULT_FOLDER)
26
  app.config['ALLOWED_EXTENSIONS'] = {'png', 'jpg', 'jpeg', 'tif', 'tiff'}
27
 
28
- # Ensure upload and result folders exist
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):
38
- # Log the exception
39
  print(f"Unhandled exception: {str(e)}")
40
  print(traceback.format_exc())
41
- # Return JSON instead of HTML for HTTP errors
42
  return jsonify({"error": "Server error", "log": str(e)}), 500
43
 
44
  def allowed_file(filename):
45
- return '.' in filename and \
46
- filename.rsplit('.', 1)[1].lower() in app.config['ALLOWED_EXTENSIONS']
47
 
48
  @app.route('/')
49
  def index():
@@ -57,16 +51,14 @@ def process_images():
57
  return jsonify({"error": "No file part"}), 400
58
 
59
  files = request.files.getlist('files')
60
- input_mode = request.form.get('input_mode', 'single') # Get from form data
61
- confidence = request.form.get('confidence_threshold', '0.6') # Get from form data
62
 
63
  if not files or files[0].filename == '':
64
  return jsonify({"error": "No selected file"}), 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,7 +67,7 @@ def process_images():
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,32 +76,20 @@ def process_images():
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),
@@ -120,96 +100,65 @@ def process_images():
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:
162
  return jsonify({"error": "Invalid job ID format"}), 400
163
 
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',
@@ -220,67 +169,47 @@ def download_file(job_id, filename):
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',
@@ -293,95 +222,76 @@ def export_images(job_id):
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
 
@@ -412,89 +322,64 @@ def run_nemaquant_background(job_id, cmd, job_output_dir, output_csv, input_mode
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
 
3
  import os
4
  from pathlib import Path
5
  import uuid
6
+ import pandas as pd
7
+ from werkzeug.utils import secure_filename
8
  import traceback
9
  import sys
10
  import re
11
  from PIL import Image
12
  import io
13
+ import threading
14
+ import time
15
+ from datetime import datetime
16
+ import zipfile
17
 
18
  app = Flask(__name__)
19
+
20
  APP_ROOT = Path(__file__).parent
21
+ UPLOAD_FOLDER = APP_ROOT / 'uploads'
22
  RESULT_FOLDER = APP_ROOT / 'results'
23
+ WEIGHTS_FILE = APP_ROOT / 'weights.pt'
24
  app.config['UPLOAD_FOLDER'] = str(UPLOAD_FOLDER)
25
  app.config['RESULT_FOLDER'] = str(RESULT_FOLDER)
26
  app.config['ALLOWED_EXTENSIONS'] = {'png', 'jpg', 'jpeg', 'tif', 'tiff'}
27
 
 
28
  UPLOAD_FOLDER.mkdir(parents=True, exist_ok=True)
29
  RESULT_FOLDER.mkdir(parents=True, exist_ok=True)
30
 
 
31
  job_status = {}
32
 
 
33
  @app.errorhandler(Exception)
34
  def handle_exception(e):
 
35
  print(f"Unhandled exception: {str(e)}")
36
  print(traceback.format_exc())
 
37
  return jsonify({"error": "Server error", "log": str(e)}), 500
38
 
39
  def allowed_file(filename):
40
+ return '.' in filename and filename.rsplit('.', 1)[1].lower() in app.config['ALLOWED_EXTENSIONS']
 
41
 
42
  @app.route('/')
43
  def index():
 
51
  return jsonify({"error": "No file part"}), 400
52
 
53
  files = request.files.getlist('files')
54
+ input_mode = request.form.get('input_mode', 'single')
55
+ confidence = request.form.get('confidence_threshold', '0.6')
56
 
57
  if not files or files[0].filename == '':
58
  return jsonify({"error": "No selected file"}), 400
59
 
 
60
  job_id = str(uuid.uuid4())
61
+ job_output_dir = RESULT_FOLDER / job_id
 
62
  job_output_dir.mkdir(parents=True, exist_ok=True)
63
 
64
  saved_files = []
 
67
  for file in files:
68
  if file and allowed_file(file.filename):
69
  filename = secure_filename(file.filename)
70
+ save_path = job_output_dir / filename
71
  file.save(str(save_path))
72
  saved_files.append(save_path)
73
  elif file:
 
76
  if not saved_files:
77
  return jsonify({"error": f"No valid files uploaded. Invalid files: {error_files}"}), 400
78
 
79
+ if input_mode in ['files', 'folder']:
80
+ input_target = str(job_output_dir)
81
+ img_mode_arg = 'dir'
 
 
82
  elif input_mode == 'single':
83
+ input_target = str(saved_files[0])
84
+ img_mode_arg = 'file'
85
+ else:
86
+ return jsonify({"error": f"Invalid input mode: {input_mode}"}), 400
87
+
88
+ output_csv = job_output_dir / "results.csv"
89
+ annotated_dir = job_output_dir
 
 
 
 
 
 
 
 
 
 
90
 
91
  cmd = [
92
+ sys.executable,
93
  str(APP_ROOT / 'nemaquant.py'),
94
  '-i', input_target,
95
  '-w', str(WEIGHTS_FILE),
 
100
 
101
  print(f"[{job_id}] Prepared command: {' '.join(cmd)}")
102
 
103
+ job_status[job_id] = {
104
+ "status": "starting",
105
+ "progress": 0,
106
+ "log": "Job initiated",
107
+ "results": None,
108
+ "error": None
109
+ }
110
 
 
111
  thread = threading.Thread(target=run_nemaquant_background, args=(job_id, cmd, job_output_dir, output_csv, input_mode))
112
  thread.start()
113
 
 
114
  return jsonify({
115
  "status": "processing",
116
  "job_id": job_id,
117
+ "initial_log": f"Job '{job_id}' started. Input mode: {input_mode}. Files saved in results/{job_id}/. Polling for progress..."
118
  })
119
 
120
+ except Exception as e:
121
  error_message = f"Error starting process: {str(e)}\\n{traceback.format_exc()}"
122
  print(error_message)
 
 
123
  return jsonify({"error": "Failed to start processing", "log": error_message}), 500
124
 
 
 
125
  @app.route('/progress/<job_id>')
126
  def get_progress(job_id):
 
127
  status = job_status.get(job_id)
128
+ return jsonify(status) if status else (jsonify({"status": "error", "error": "Job ID not found"}), 404)
 
 
 
 
129
 
130
  @app.route('/results/<job_id>/<path:filename>')
131
  def download_file(job_id, filename):
132
  try:
133
  try:
134
+ uuid.UUID(job_id, version=4)
135
  except ValueError:
136
  return jsonify({"error": "Invalid job ID format"}), 400
137
 
138
  if '..' in filename or filename.startswith('/'):
139
  return jsonify({"error": "Invalid filename"}), 400
140
 
 
141
  safe_filename = secure_filename(filename)
 
 
 
 
 
 
142
  file_dir = Path(app.config['RESULT_FOLDER']) / job_id
 
 
143
  file_path = (file_dir / safe_filename).resolve()
 
 
 
144
 
145
+ if not str(file_path).startswith(str(file_dir.resolve())):
146
+ print(f"Attempted path traversal: {job_id}/{filename}")
147
+ return jsonify({"error": "Invalid file path"}), 400
148
 
149
  if not file_path.is_file():
 
150
  if not file_dir.exists():
 
151
  return jsonify({"error": f"Job directory {job_id} not found"}), 404
152
  files_in_dir = list(file_dir.iterdir())
 
153
  return jsonify({"error": f"File '{filename}' not found in job '{job_id}'. Available: {[f.name for f in files_in_dir]}"}), 404
154
 
 
155
  if filename.lower().endswith(('.tif', '.tiff')):
156
  try:
 
157
  with Image.open(file_path) as img:
158
+ img = img.convert('RGBA') if img.mode in ('RGBA', 'LA') or (img.mode == 'P' and 'transparency' in img.info) else img.convert('RGB')
 
 
 
 
 
 
159
  img_byte_arr = io.BytesIO()
 
160
  img.save(img_byte_arr, format='PNG')
 
161
  img_byte_arr.seek(0)
 
 
162
  return send_file(
163
  img_byte_arr,
164
  mimetype='image/png',
 
169
  print(f"Error converting TIF to PNG: {e}")
170
  return jsonify({"error": "Could not convert TIF image"}), 500
171
 
 
172
  mime_type = None
173
  if safe_filename.lower().endswith(('.png', '.jpg', '.jpeg')):
174
  try:
175
  with Image.open(file_path) as img:
176
+ mime_type = 'image/jpeg' if img.format == 'JPEG' else 'image/png'
 
 
 
177
  except Exception as img_err:
178
+ print(f"Could not determine MIME type for {safe_filename}: {img_err}")
 
179
 
180
  if safe_filename.lower() == "results.csv":
181
  mime_type = 'text/csv'
182
 
183
+ return send_file(str(file_path), mimetype=mime_type)
 
 
 
 
184
  except Exception as e:
185
  error_message = f"File serving error: {str(e)}"
186
  print(error_message)
187
  return jsonify({"error": "Server error", "log": error_message}), 500
188
 
 
189
  @app.route('/export_images/<job_id>')
190
  def export_images(job_id):
191
  try:
192
  try:
193
+ uuid.UUID(job_id, version=4)
194
  except ValueError:
195
  return jsonify({"error": "Invalid job ID format"}), 400
196
 
 
197
  job_dir = Path(app.config['RESULT_FOLDER']) / job_id
198
  if not job_dir.exists():
199
  return jsonify({"error": f"Job directory {job_id} not found"}), 404
200
 
 
201
  annotated_files = list(job_dir.glob('*_annotated.*'))
202
  if not annotated_files:
203
  return jsonify({"error": "No annotated images found"}), 404
204
 
 
205
  memory_file = io.BytesIO()
 
 
206
  with zipfile.ZipFile(memory_file, 'w', zipfile.ZIP_DEFLATED) as zf:
207
  for file_path in annotated_files:
 
208
  zf.write(file_path, file_path.name)
209
 
 
210
  memory_file.seek(0)
 
 
211
  timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
212
 
 
213
  return send_file(
214
  memory_file,
215
  mimetype='application/zip',
 
222
  print(error_message)
223
  return jsonify({"error": "Server error", "log": error_message}), 500
224
 
 
 
225
  def run_nemaquant_background(job_id, cmd, job_output_dir, output_csv, input_mode):
226
  global job_status
227
  try:
228
  print(f"[{job_id}] Starting NemaQuant process...")
229
+ job_status[job_id] = {
230
+ "status": "running",
231
+ "progress": 5,
232
+ "log": "Starting NemaQuant...",
233
+ "results": None,
234
+ "error": None
235
+ }
236
 
 
237
  process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, bufsize=1, universal_newlines=True)
238
 
 
239
  stdout_log = []
240
  stderr_log = []
241
+ total_lines_estimate = 50
242
  lines_processed = 0
243
+ last_reported_progress = 5
244
 
 
245
  if process.stdout:
246
  for line in iter(process.stdout.readline, ''):
247
  line_strip = line.strip()
248
  lines_processed += 1
249
  stdout_log.append(line_strip)
250
+ print(f"[{job_id}] STDOUT: {line_strip}")
251
 
 
252
  if line_strip.startswith("PROGRESS:"):
253
  try:
254
  progress_val = int(line_strip.split(":")[1].strip())
 
255
  last_reported_progress = max(last_reported_progress, min(progress_val, 95))
256
  job_status[job_id]["progress"] = last_reported_progress
 
257
  except (IndexError, ValueError):
 
 
258
  progress_percent = min(90, 5 + int((lines_processed / total_lines_estimate) * 85))
259
  job_status[job_id]["progress"] = max(last_reported_progress, progress_percent)
260
  last_reported_progress = job_status[job_id]["progress"]
261
  else:
 
262
  progress_percent = min(90, 5 + int((lines_processed / total_lines_estimate) * 85))
263
  job_status[job_id]["progress"] = max(last_reported_progress, progress_percent)
264
  last_reported_progress = job_status[job_id]["progress"]
265
 
266
+ job_status[job_id]["log"] = "\n".join(stdout_log[-5:])
 
267
 
 
268
  if process.stderr:
269
+ for line in iter(process.stderr.readline, ''):
270
+ stderr_log.append(line.strip())
271
+ print(f"[{job_id}] STDERR: {line.strip()}")
272
 
273
  process.stdout.close()
274
  if process.stderr:
275
  process.stderr.close()
276
  return_code = process.wait()
277
 
 
278
  stdout_str = "\n".join(stdout_log)
279
  stderr_str = "\n".join(stderr_log)
280
  full_log = f"NemaQuant Output:\n{stdout_str}\nNemaQuant Errors:\n{stderr_str}"
281
+ job_status[job_id]["log"] = full_log
 
 
282
 
283
  if return_code != 0:
284
+ raise subprocess.CalledProcessError(return_code, cmd, output=stdout_str, stderr=stderr_str)
285
 
286
+ job_status[job_id]["progress"] = 95
287
 
 
 
 
 
 
 
 
288
  if not output_csv.exists():
289
+ csv_files = list(job_output_dir.glob('*.csv'))
290
+ if csv_files:
291
+ output_csv = csv_files[0]
292
+ else:
293
+ raise FileNotFoundError(f"Output CSV not found at {output_csv} and no alternatives found.")
294
+
 
 
295
  df = pd.read_csv(output_csv)
296
  results_list = []
297
 
 
322
  "results": results_list,
323
  "error": None
324
  }
 
325
 
326
  except subprocess.CalledProcessError as e:
 
327
  stdout_err = e.output if e.output else ""
328
  stderr_err = e.stderr if e.stderr else ""
329
  error_message = f"Error running NemaQuant:\nExit Code: {e.returncode}\nSTDOUT:\n{stdout_err}\nSTDERR:\n{stderr_err}"
330
+ current_log = job_status[job_id].get("log", "")
 
 
331
  job_status[job_id] = {"status": "error", "progress": 100, "log": current_log, "results": None, "error": error_message}
332
  except FileNotFoundError as e:
333
  error_message = f"Error processing results: {e}"
 
334
  job_status[job_id] = {"status": "error", "progress": 100, "log": job_status[job_id].get("log", ""), "results": None, "error": error_message}
335
+ except Exception as e:
336
  error_message = f"An unexpected error occurred: {str(e)}\\n{traceback.format_exc()}"
 
337
  job_status[job_id] = {"status": "error", "progress": 100, "log": job_status[job_id].get("log", ""), "results": None, "error": error_message}
338
 
 
 
339
  def print_startup_info():
340
  print("----- NemaQuant Flask App Starting -----")
341
  print(f"Working directory: {os.getcwd()}")
 
342
  python_version_single_line = sys.version.replace('\n', ' ')
343
  print(f"Python version: {python_version_single_line}")
344
  print(f"Weights file: {WEIGHTS_FILE}")
345
  print(f"Weights file exists: {WEIGHTS_FILE.exists()}")
346
+
347
  if WEIGHTS_FILE.exists():
348
  try:
349
  print(f"Weights file size: {WEIGHTS_FILE.stat().st_size} bytes")
350
  except Exception as e:
351
  print(f"Could not get weights file size: {e}")
 
 
352
 
 
353
  is_container = os.path.exists('/.dockerenv') or 'DOCKER_HOST' in os.environ
354
  print(f"Running in container: {is_container}")
355
+
356
  if is_container:
 
357
  try:
358
  user_info = f"{os.getuid()}:{os.getgid()}"
359
  print(f"User running process: {user_info}")
360
+ except AttributeError:
361
  print("User running process: UID/GID not available on this OS")
362
 
363
+ for path_str in ["/app/uploads", "/app/results"]:
 
 
 
364
  path_obj = Path(path_str)
365
  if path_obj.exists():
366
+ stat_info = path_obj.stat()
367
+ permissions = oct(stat_info.st_mode)[-3:]
368
+ owner = f"{stat_info.st_uid}:{stat_info.st_gid}"
369
+ print(f"Permissions for {path_str}: {permissions}")
370
+ print(f"Owner for {path_str}: {owner}")
371
  else:
372
+ print(f"Directory {path_str} does not exist.")
373
 
 
 
374
  nemaquant_script = APP_ROOT / 'nemaquant.py'
375
  print(f"NemaQuant script exists: {nemaquant_script.exists()}")
376
  if nemaquant_script.exists():
377
+ try:
378
  permissions = oct(nemaquant_script.stat().st_mode)[-3:]
379
  print(f"NemaQuant script permissions: {permissions}")
380
+ except Exception as e:
 
 
 
381
  print(f"Could not get NemaQuant script details: {e}")
382
 
 
 
 
383
  if __name__ == '__main__':
384
+ print_startup_info()
385
+ app.run(host='0.0.0.0', port=7860, debug=True)
 
 
 
static/script.js CHANGED
@@ -74,16 +74,9 @@ document.addEventListener('DOMContentLoaded', () => {
74
 
75
  // File Upload Handling
76
  function handleFiles(files) {
77
- // Log the selected files
78
- // logStatus(`Selected ${files.length} file(s):`);
79
- // Array.from(files).forEach(file => {
80
- // logStatus(`- ${file.name} (${formatFileSize(file.size)})`);
81
- // });
82
  const allowedTypes = ['image/png', 'image/jpeg', 'image/jpg', 'image/tiff', 'image/tif'];
83
  const validFiles = Array.from(files).filter(file => {
84
- // For folder mode, check if file is in a subdirectory
85
  if (inputMode.value === 'folder') {
86
- // Only include files with valid types and exclude any hidden files
87
  return allowedTypes.includes(file.type) &&
88
  file.webkitRelativePath &&
89
  !file.webkitRelativePath.startsWith('.');
@@ -94,7 +87,6 @@ document.addEventListener('DOMContentLoaded', () => {
94
 
95
  if (invalidFiles.length > 0) {
96
  logStatus(`Warning: Skipped ${invalidFiles.length} invalid files. Only PNG, JPG, and TIFF are supported.`);
97
- // Log each invalid file
98
  invalidFiles.forEach(file => {
99
  logStatus(`- Skipped: ${file.name} (invalid type: ${file.type || 'unknown'})`);
100
  });
@@ -159,8 +151,7 @@ document.addEventListener('DOMContentLoaded', () => {
159
 
160
  dropZone.addEventListener('drop', (e) => {
161
  const dt = e.dataTransfer;
162
- const files = dt.files;
163
- handleFiles(files);
164
  });
165
 
166
  // Click to upload
 
74
 
75
  // File Upload Handling
76
  function handleFiles(files) {
 
 
 
 
 
77
  const allowedTypes = ['image/png', 'image/jpeg', 'image/jpg', 'image/tiff', 'image/tif'];
78
  const validFiles = Array.from(files).filter(file => {
 
79
  if (inputMode.value === 'folder') {
 
80
  return allowedTypes.includes(file.type) &&
81
  file.webkitRelativePath &&
82
  !file.webkitRelativePath.startsWith('.');
 
87
 
88
  if (invalidFiles.length > 0) {
89
  logStatus(`Warning: Skipped ${invalidFiles.length} invalid files. Only PNG, JPG, and TIFF are supported.`);
 
90
  invalidFiles.forEach(file => {
91
  logStatus(`- Skipped: ${file.name} (invalid type: ${file.type || 'unknown'})`);
92
  });
 
151
 
152
  dropZone.addEventListener('drop', (e) => {
153
  const dt = e.dataTransfer;
154
+ handleFiles(dt.files);
 
155
  });
156
 
157
  // Click to upload
static/style.css CHANGED
@@ -1,7 +1,7 @@
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;
 
1
  /* Modern, consistent styling for NemaQuant */
2
 
3
  :root {
4
+ --primary-color: #2563eb;
5
  --primary-hover: #1d4ed8;
6
  --secondary-color: #64748b;
7
  --secondary-hover: #475569;