Spaces:
Sleeping
Sleeping
Commit
·
44d1643
1
Parent(s):
32482ae
Update app files and add gitignore
Browse files- .gitignore +4 -0
- README.md +173 -73
- app.py +83 -198
- static/script.js +1 -10
- 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:
|
| 3 |
-
emoji:
|
| 4 |
colorFrom: indigo
|
| 5 |
colorTo: blue
|
| 6 |
sdk: docker
|
| 7 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
| 11 |
|
| 12 |
-
|
| 13 |
|
| 14 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
|
| 16 |
## Project Structure
|
| 17 |
|
| 18 |
```
|
| 19 |
/
|
| 20 |
-
├── app.py #
|
| 21 |
-
├── nemaquant.py
|
| 22 |
-
├── requirements.txt
|
| 23 |
-
├── Dockerfile
|
| 24 |
-
├── README.md
|
| 25 |
├── templates/
|
| 26 |
-
│ └── index.html
|
| 27 |
├── static/
|
| 28 |
-
│ ├── style.css
|
| 29 |
-
│ └── script.js
|
| 30 |
-
|
| 31 |
-
├── results/ # Directory for output files (created automatically)
|
| 32 |
-
└── weights.pt # YOLO model weights (Ensure this is present or downloaded)
|
| 33 |
```
|
| 34 |
|
| 35 |
-
##
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
4.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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 |
-
|
| 16 |
-
|
| 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 |
-
# 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')
|
| 61 |
-
confidence = request.form.get('confidence_threshold', '0.6')
|
| 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 |
-
|
| 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
|
| 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 |
-
|
| 88 |
-
|
| 89 |
-
|
| 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 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 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,
|
| 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 |
-
|
| 124 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 135 |
})
|
| 136 |
|
| 137 |
-
except Exception as e:
|
| 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)
|
| 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 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 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 |
-
|
| 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}
|
| 234 |
-
pass
|
| 235 |
|
| 236 |
if safe_filename.lower() == "results.csv":
|
| 237 |
mime_type = 'text/csv'
|
| 238 |
|
| 239 |
-
|
| 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)
|
| 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] = {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 311 |
lines_processed = 0
|
| 312 |
-
last_reported_progress = 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}")
|
| 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 |
-
|
| 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 |
-
|
| 348 |
-
|
| 349 |
-
|
| 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
|
| 361 |
-
|
| 362 |
-
print(f"[{job_id}] NemaQuant script return code: {return_code}")
|
| 363 |
|
| 364 |
if return_code != 0:
|
| 365 |
-
|
| 366 |
|
| 367 |
-
job_status[job_id]["progress"] = 95
|
| 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 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 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 |
-
|
| 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:
|
| 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:
|
| 462 |
print("User running process: UID/GID not available on this OS")
|
| 463 |
|
| 464 |
-
|
| 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 |
-
|
| 471 |
-
|
| 472 |
-
|
| 473 |
-
|
| 474 |
-
|
| 475 |
else:
|
| 476 |
-
|
| 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 |
-
|
| 484 |
permissions = oct(nemaquant_script.stat().st_mode)[-3:]
|
| 485 |
print(f"NemaQuant script permissions: {permissions}")
|
| 486 |
-
|
| 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()
|
| 497 |
-
|
| 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 |
-
|
| 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;
|
| 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;
|