Spaces:
Sleeping
Sleeping
Commit
·
30d5634
1
Parent(s):
e435266
Add NemaQuant Flask application files
Browse files- Dockerfile +31 -0
- README.md +80 -0
- app.py +181 -0
- nemaquant.py +246 -0
- requirements.txt +7 -0
- static/script.js +287 -0
- static/style.css +335 -0
- templates/index.html +100 -0
Dockerfile
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Use an official Python runtime as a parent image
|
| 2 |
+
FROM python:3.9-slim
|
| 3 |
+
|
| 4 |
+
# Set the working directory in the container
|
| 5 |
+
WORKDIR /app
|
| 6 |
+
|
| 7 |
+
# Copy the requirements file into the container at /app
|
| 8 |
+
COPY requirements.txt .
|
| 9 |
+
|
| 10 |
+
# Install any needed packages specified in requirements.txt
|
| 11 |
+
# --no-cache-dir: Disables the cache to reduce image size.
|
| 12 |
+
# -r requirements.txt: Specifies the file containing the list of packages to install.
|
| 13 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 14 |
+
|
| 15 |
+
# Copy the rest of the application code into the container at /app
|
| 16 |
+
# This includes app.py, nemaquant.py, templates/, static/, etc.
|
| 17 |
+
COPY . .
|
| 18 |
+
|
| 19 |
+
# Make port 7860 available to the world outside this container
|
| 20 |
+
# This is the port Flask will run on (as configured in app.py)
|
| 21 |
+
# Hugging Face Spaces typically uses this port
|
| 22 |
+
EXPOSE 7860
|
| 23 |
+
|
| 24 |
+
# Define environment variables (optional, can be useful)
|
| 25 |
+
# ENV NAME=World
|
| 26 |
+
|
| 27 |
+
# Run app.py when the container launches
|
| 28 |
+
# Use gunicorn for production deployment if preferred over Flask's development server
|
| 29 |
+
# CMD ["gunicorn", "--bind", "0.0.0.0:7860", "app:app"]
|
| 30 |
+
# For simplicity during development and typical HF Spaces use:
|
| 31 |
+
CMD ["python", "app.py"]
|
README.md
CHANGED
|
@@ -8,3 +8,83 @@ pinned: false
|
|
| 8 |
---
|
| 9 |
|
| 10 |
Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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`.
|
app.py
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from flask import Flask, render_template, request, jsonify, send_from_directory
|
| 2 |
+
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 |
+
|
| 9 |
+
app = Flask(__name__)
|
| 10 |
+
# Use absolute paths for robustness within Docker/HF Spaces
|
| 11 |
+
APP_ROOT = Path(__file__).parent
|
| 12 |
+
UPLOAD_FOLDER = APP_ROOT / 'uploads' # Keep separate upload folder initially
|
| 13 |
+
RESULT_FOLDER = APP_ROOT / 'results'
|
| 14 |
+
WEIGHTS_FILE = APP_ROOT / 'weights.pt' # Assuming weights are in root
|
| 15 |
+
app.config['UPLOAD_FOLDER'] = str(UPLOAD_FOLDER)
|
| 16 |
+
app.config['RESULT_FOLDER'] = str(RESULT_FOLDER)
|
| 17 |
+
app.config['ALLOWED_EXTENSIONS'] = {'png', 'jpg', 'jpeg', 'tif', 'tiff'}
|
| 18 |
+
|
| 19 |
+
# Ensure upload and result folders exist
|
| 20 |
+
UPLOAD_FOLDER.mkdir(parents=True, exist_ok=True)
|
| 21 |
+
RESULT_FOLDER.mkdir(parents=True, exist_ok=True)
|
| 22 |
+
|
| 23 |
+
def allowed_file(filename):
|
| 24 |
+
return '.' in filename and \
|
| 25 |
+
filename.rsplit('.', 1)[1].lower() in app.config['ALLOWED_EXTENSIONS']
|
| 26 |
+
|
| 27 |
+
@app.route('/')
|
| 28 |
+
def index():
|
| 29 |
+
return render_template('index.html')
|
| 30 |
+
|
| 31 |
+
@app.route('/process', methods=['POST'])
|
| 32 |
+
def process_images():
|
| 33 |
+
if 'files' not in request.files:
|
| 34 |
+
return jsonify({"error": "No file part"}), 400
|
| 35 |
+
|
| 36 |
+
files = request.files.getlist('files')
|
| 37 |
+
input_mode = request.form.get('input_mode', 'single') # Get from form data
|
| 38 |
+
confidence = request.form.get('confidence_threshold', '0.6') # Get from form data
|
| 39 |
+
|
| 40 |
+
if not files or files[0].filename == '':
|
| 41 |
+
return jsonify({"error": "No selected file"}), 400
|
| 42 |
+
|
| 43 |
+
# Create a unique job directory within results
|
| 44 |
+
job_id = str(uuid.uuid4())
|
| 45 |
+
job_input_dir = RESULT_FOLDER / job_id / 'input' # Save inputs within job dir
|
| 46 |
+
job_output_dir = RESULT_FOLDER / job_id / 'output' # Save outputs within job dir
|
| 47 |
+
job_input_dir.mkdir(parents=True, exist_ok=True)
|
| 48 |
+
job_output_dir.mkdir(parents=True, exist_ok=True)
|
| 49 |
+
|
| 50 |
+
saved_files = []
|
| 51 |
+
error_files = []
|
| 52 |
+
|
| 53 |
+
for file in files:
|
| 54 |
+
if file and allowed_file(file.filename):
|
| 55 |
+
filename = secure_filename(file.filename)
|
| 56 |
+
save_path = job_input_dir / filename
|
| 57 |
+
file.save(str(save_path))
|
| 58 |
+
saved_files.append(save_path)
|
| 59 |
+
elif file:
|
| 60 |
+
error_files.append(file.filename)
|
| 61 |
+
|
| 62 |
+
if not saved_files:
|
| 63 |
+
return jsonify({"error": f"No valid files uploaded. Invalid files: {error_files}"}), 400
|
| 64 |
+
|
| 65 |
+
# --- Prepare and Run nemaquant.py ---
|
| 66 |
+
# Determine input target for nemaquant.py
|
| 67 |
+
if input_mode == 'single' and len(saved_files) == 1:
|
| 68 |
+
input_target = str(saved_files[0])
|
| 69 |
+
img_mode_arg = 'file' # nemaquant uses file/dir, not single/directory
|
| 70 |
+
elif input_mode == 'directory' and len(saved_files) >= 1:
|
| 71 |
+
input_target = str(job_input_dir) # Pass the directory containing the images
|
| 72 |
+
img_mode_arg = 'dir'
|
| 73 |
+
else:
|
| 74 |
+
# Mismatch between mode and number of files
|
| 75 |
+
return jsonify({"error": f"Input mode '{input_mode}' requires {'1 file' if input_mode == 'single' else '1 or more files'}, but received {len(saved_files)}."}), 400
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
output_csv = job_output_dir / f"{job_id}_results.csv"
|
| 79 |
+
annotated_dir = job_output_dir # Save annotated images directly in job output dir
|
| 80 |
+
|
| 81 |
+
cmd = [
|
| 82 |
+
'python', str(APP_ROOT / 'nemaquant.py'),
|
| 83 |
+
'-i', input_target,
|
| 84 |
+
'-w', str(WEIGHTS_FILE), # Use absolute path
|
| 85 |
+
'-o', str(output_csv),
|
| 86 |
+
'-a', str(annotated_dir),
|
| 87 |
+
'--conf', confidence
|
| 88 |
+
]
|
| 89 |
+
|
| 90 |
+
# We don't need --key or XY mode for this web interface initially
|
| 91 |
+
|
| 92 |
+
try:
|
| 93 |
+
print(f"Running command: {' '.join(cmd)}") # Log the command
|
| 94 |
+
# Run the script, capture output and errors
|
| 95 |
+
# Timeout might be needed for long processes on shared infrastructure like HF Spaces
|
| 96 |
+
result = subprocess.run(cmd, capture_output=True, text=True, check=True, timeout=300) # 5 min timeout
|
| 97 |
+
status_log = f"NemaQuant Output:\n{result.stdout}\nNemaQuant Errors:\n{result.stderr}"
|
| 98 |
+
print(status_log) # Log script output
|
| 99 |
+
|
| 100 |
+
# --- Parse Results ---
|
| 101 |
+
if not output_csv.exists():
|
| 102 |
+
raise FileNotFoundError(f"Output CSV not found at {output_csv}")
|
| 103 |
+
|
| 104 |
+
df = pd.read_csv(output_csv)
|
| 105 |
+
# Expect columns like 'filename', 'num_eggs' (based on nemaquant.py)
|
| 106 |
+
# Find corresponding annotated images
|
| 107 |
+
results_list = []
|
| 108 |
+
for index, row in df.iterrows():
|
| 109 |
+
original_filename = row.get('filename', '')
|
| 110 |
+
num_eggs = row.get('num_eggs', 'N/A')
|
| 111 |
+
# Construct expected annotated filename (based on nemaquant.py logic)
|
| 112 |
+
stem = Path(original_filename).stem
|
| 113 |
+
suffix = Path(original_filename).suffix
|
| 114 |
+
annotated_filename = f"{stem}_annotated{suffix}"
|
| 115 |
+
annotated_path = annotated_dir / annotated_filename
|
| 116 |
+
|
| 117 |
+
results_list.append({
|
| 118 |
+
"filename": original_filename,
|
| 119 |
+
"num_eggs": num_eggs,
|
| 120 |
+
# Pass relative path within job dir for frontend URL construction
|
| 121 |
+
"annotated_filename": annotated_filename if annotated_path.exists() else None,
|
| 122 |
+
})
|
| 123 |
+
|
| 124 |
+
return jsonify({
|
| 125 |
+
"status": "success",
|
| 126 |
+
"job_id": job_id,
|
| 127 |
+
"results": results_list,
|
| 128 |
+
"log": status_log,
|
| 129 |
+
"error_files": error_files # Report files that were not processed
|
| 130 |
+
})
|
| 131 |
+
|
| 132 |
+
except subprocess.CalledProcessError as e:
|
| 133 |
+
error_message = f"Error running NemaQuant:\nExit Code: {e.returncode}\nSTDOUT:\n{e.stdout}\nSTDERR:\n{e.stderr}"
|
| 134 |
+
print(error_message)
|
| 135 |
+
return jsonify({"error": "Processing failed", "log": error_message}), 500
|
| 136 |
+
except subprocess.TimeoutExpired as e:
|
| 137 |
+
error_message = f"Error running NemaQuant: Process timed out after {e.timeout} seconds.\nSTDOUT:\n{e.stdout}\nSTDERR:\n{e.stderr}"
|
| 138 |
+
print(error_message)
|
| 139 |
+
return jsonify({"error": "Processing timed out", "log": error_message}), 500
|
| 140 |
+
except FileNotFoundError as e:
|
| 141 |
+
error_message = f"Error processing results: {e}"
|
| 142 |
+
print(error_message)
|
| 143 |
+
return jsonify({"error": "Could not find output file", "log": error_message}), 500
|
| 144 |
+
except Exception as e:
|
| 145 |
+
error_message = f"An unexpected error occurred: {str(e)}"
|
| 146 |
+
print(error_message)
|
| 147 |
+
return jsonify({"error": "An unexpected error occurred", "log": error_message}), 500
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
@app.route('/results/<job_id>/<path:filename>')
|
| 151 |
+
def download_file(job_id, filename):
|
| 152 |
+
# Construct the full path to the file within the job's output directory
|
| 153 |
+
# Use secure_filename on the incoming filename part for safety? Maybe not needed if we trust our generated paths.
|
| 154 |
+
# Crucially, validate job_id and filename to prevent directory traversal.
|
| 155 |
+
# A simple check: ensure job_id is a valid UUID format and filename doesn't contain '..'
|
| 156 |
+
try:
|
| 157 |
+
uuid.UUID(job_id, version=4) # Validate UUID format
|
| 158 |
+
except ValueError:
|
| 159 |
+
return "Invalid job ID format", 400
|
| 160 |
+
|
| 161 |
+
if '..' in filename or filename.startswith('/'):
|
| 162 |
+
return "Invalid filename", 400
|
| 163 |
+
|
| 164 |
+
file_dir = RESULT_FOLDER / job_id / 'output'
|
| 165 |
+
# Use send_from_directory for security (handles path joining and prevents traversal above the specified directory)
|
| 166 |
+
print(f"Attempting to send file: {filename} from directory: {file_dir}")
|
| 167 |
+
try:
|
| 168 |
+
return send_from_directory(str(file_dir), filename, as_attachment=False) # Display images inline
|
| 169 |
+
except FileNotFoundError:
|
| 170 |
+
print(f"File not found: {file_dir / filename}")
|
| 171 |
+
return "File not found", 404
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
if __name__ == '__main__':
|
| 175 |
+
# Ensure weights file exists before starting
|
| 176 |
+
if not WEIGHTS_FILE.exists():
|
| 177 |
+
print(f"ERROR: Weights file not found at {WEIGHTS_FILE}")
|
| 178 |
+
print("Please ensure 'weights.pt' is in the application root directory.")
|
| 179 |
+
exit(1) # Exit if weights are missing
|
| 180 |
+
|
| 181 |
+
app.run(debug=True, host='0.0.0.0', port=7860) # Port 7860 is common for HF Spaces
|
nemaquant.py
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python
|
| 2 |
+
# coding: utf-8
|
| 3 |
+
|
| 4 |
+
import argparse
|
| 5 |
+
import numpy as np
|
| 6 |
+
import pandas as pd
|
| 7 |
+
import cv2
|
| 8 |
+
import os
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
from ultralytics import YOLO
|
| 11 |
+
from glob import glob
|
| 12 |
+
import re
|
| 13 |
+
|
| 14 |
+
def options():
|
| 15 |
+
parser = argparse.ArgumentParser(description="Nematode egg image processing with YOLOv8 model.")
|
| 16 |
+
parser.add_argument("-i", "--img", help="Target image directory or image (REQUIRED)", required=True)
|
| 17 |
+
parser.add_argument('-w', '--weights', help='Weights file for use with YOLO11 model')
|
| 18 |
+
parser.add_argument("-o","--output", help="Name of results file. If no file is specified, one will be created from the key file name")
|
| 19 |
+
parser.add_argument("-k", "--key", help="CSV key file to use as output template. If no file is specified, will look for one in target directory. Not used in single-image mode")
|
| 20 |
+
parser.add_argument("-a","--annotated", help="Directory to save annotated image files", required=False)
|
| 21 |
+
parser.add_argument("--conf", help="Confidence cutoff (default = 0.6)", default=0.6, type=float)
|
| 22 |
+
args = parser.parse_args()
|
| 23 |
+
return args
|
| 24 |
+
|
| 25 |
+
# TODO - maybe rework this from a function to custom argparse.Action() subclasses?
|
| 26 |
+
def check_args():
|
| 27 |
+
args = options()
|
| 28 |
+
# basic checks on target file validity
|
| 29 |
+
args.imgpath = Path(args.img)
|
| 30 |
+
if not args.imgpath.exists():
|
| 31 |
+
raise Exception("Target %s is not a valid path" % args.img)
|
| 32 |
+
if args.imgpath.is_file():
|
| 33 |
+
args.img_mode = 'file'
|
| 34 |
+
if not args.imgpath.suffix.lower() in ['.tif','.tiff','.jpg','.jpeg','.png']:
|
| 35 |
+
raise Exception('Target image %s must of type .png, .tif, .tiff, .jpeg, or .jpg' % args.img)
|
| 36 |
+
elif args.imgpath.is_dir():
|
| 37 |
+
args.img_mode = 'dir'
|
| 38 |
+
else:
|
| 39 |
+
raise Exception('Target %s does not appear to be a file or directory.' % args.img)
|
| 40 |
+
|
| 41 |
+
# if no weights file, try using the default weights.pt
|
| 42 |
+
if not args.weights:
|
| 43 |
+
script_dir = Path(__file__).parent
|
| 44 |
+
default_weights = script_dir / 'weights.pt'
|
| 45 |
+
if default_weights.exists():
|
| 46 |
+
args.weights = str(default_weights)
|
| 47 |
+
else:
|
| 48 |
+
raise Exception('No weights file specified and default weights.pt not found in script directory')
|
| 49 |
+
|
| 50 |
+
# check if subdirectories of format XY00/ exist or if we're running on just a dir of images
|
| 51 |
+
if args.img_mode == 'dir':
|
| 52 |
+
subdirs = sorted(list(args.imgpath.glob('XY[0-9][0-9]/')))
|
| 53 |
+
if len(subdirs) == 0:
|
| 54 |
+
print("No subdirectories of format /XY../ found in specified imgdir, checking for images...")
|
| 55 |
+
potential_images = [x for x in args.imgpath.iterdir() if x.suffix.lower() in ['.tif','.tiff','.jpg','.jpeg','.png']]
|
| 56 |
+
if len(potential_images) == 0:
|
| 57 |
+
raise Exception('No valid images (.png, .tif, .tiff, .jpeg, .jpg) in target folder %s' % args.img)
|
| 58 |
+
else:
|
| 59 |
+
print('%s valid images found' % len(potential_images))
|
| 60 |
+
args.xy_mode = False
|
| 61 |
+
args.subimage_paths = potential_images
|
| 62 |
+
else:
|
| 63 |
+
args.xy_mode = True
|
| 64 |
+
args.subdir_paths = subdirs
|
| 65 |
+
|
| 66 |
+
# for /XY00/ subdirectories, we require a valid key
|
| 67 |
+
# ensure that either a key is specified, or if a single .csv exists in the target dir, use that
|
| 68 |
+
if args.xy_mode:
|
| 69 |
+
if args.key:
|
| 70 |
+
args.keypath = Path(args.key)
|
| 71 |
+
if not args.keypath.exists():
|
| 72 |
+
raise Exception('Specified key file does not exist: %s' % args.keypath)
|
| 73 |
+
if args.keypath.suffix != '.csv':
|
| 74 |
+
raise Exception("Specified key file is not a .csv: %s" % args.keypath)
|
| 75 |
+
else:
|
| 76 |
+
print('Running on /XY00/ subdirectories but no key specified. Looking for key file...')
|
| 77 |
+
potential_keys = list(args.imgpath.glob('*.csv'))
|
| 78 |
+
if len(potential_keys) == 0:
|
| 79 |
+
raise Exception("No .csv files found in target folder %s, please check directory" % args.img)
|
| 80 |
+
if len(potential_keys) > 1:
|
| 81 |
+
raise Exception("Multiple .csv files found in target folder %s, please specify which one to use")
|
| 82 |
+
else:
|
| 83 |
+
args.keypath = potential_keys[0]
|
| 84 |
+
args.key = str(potential_keys[0])
|
| 85 |
+
|
| 86 |
+
# if path to results file is specified, ensure it is .csv
|
| 87 |
+
if args.output:
|
| 88 |
+
args.outpath = Path(args.output)
|
| 89 |
+
if args.outpath.suffix != '.csv':
|
| 90 |
+
raise Exception("Specified output file is not a .csv: %s" % args.outpath)
|
| 91 |
+
else:
|
| 92 |
+
# for XY00 subdirs, name it after the required key file
|
| 93 |
+
# for an image directory, name it after the directory
|
| 94 |
+
if args.xy_mode:
|
| 95 |
+
args.output = '%s_eggcounts.csv' % args.keypath.stem
|
| 96 |
+
else:
|
| 97 |
+
args.output = '%s_eggcounts.csv' % args.imgpath.stem
|
| 98 |
+
args.outpath = Path(args.output)
|
| 99 |
+
|
| 100 |
+
# finally, check the target dir to save annotated images in
|
| 101 |
+
if args.annotated:
|
| 102 |
+
args.annotpath = Path(args.annotated)
|
| 103 |
+
if not args.annotpath.exists():
|
| 104 |
+
os.mkdir(args.annotated)
|
| 105 |
+
elif not args.annotpath.is_dir():
|
| 106 |
+
raise Exception("annotated output folder is not a valid directory: %s" % args.annotated)
|
| 107 |
+
return args
|
| 108 |
+
|
| 109 |
+
# parse a key file, make sure it all looks correct and can be merged later
|
| 110 |
+
def parse_key_file(keypath):
|
| 111 |
+
key = pd.read_csv(keypath)
|
| 112 |
+
# drop potential Unnamed: 0 column if rownames from R were included without col header
|
| 113 |
+
key = key.loc[:, ~key.columns.str.contains('^Unnamed')]
|
| 114 |
+
# for now, will only allow 96-row key files
|
| 115 |
+
# can handle edge cases, but much easier if we just require 96
|
| 116 |
+
if key.shape[0] > 96:
|
| 117 |
+
raise Exception("More than 96 rows found in key. Please check formatting and try again")
|
| 118 |
+
# check if it's got at least one column formatted with what looks like plate positions
|
| 119 |
+
well_columns = []
|
| 120 |
+
for col in key.columns:
|
| 121 |
+
if key[col].dtype.kind == "O":
|
| 122 |
+
if all(key[col].str.fullmatch("[A-H][0-9]{1,2}")):
|
| 123 |
+
well_columns.append(col)
|
| 124 |
+
if len(well_columns) == 0:
|
| 125 |
+
raise Exception("No column found with well positions of format A1/A01/H12/etc.")
|
| 126 |
+
elif len(well_columns) > 1:
|
| 127 |
+
raise Exception("Multiple columns found with well positions of format A1/A01/H12/etc.")
|
| 128 |
+
# add a column named keycol, formatted to match the folder output like _A01
|
| 129 |
+
key["keycol"] = key[well_columns[0]]
|
| 130 |
+
# as the key, it should really be unique and complete, raise exception if not the case
|
| 131 |
+
if any(key["keycol"].isna()):
|
| 132 |
+
raise Exception("There appear to be blank well positions in column %s. Please fix and resubmit." % well_columns[0])
|
| 133 |
+
if len(set(key["keycol"])) < len(key["keycol"]):
|
| 134 |
+
raise Exception("There appear to be duplicated well positions in the key file. Please fix and resubmit.")
|
| 135 |
+
# if formatted A1, reformat as A01
|
| 136 |
+
key["keycol"] = key["keycol"].apply(lambda x: "_%s%s" % (re.findall("[A-H]",x)[0], re.findall("[0-9]+", x)[0].zfill(2)))
|
| 137 |
+
return key
|
| 138 |
+
|
| 139 |
+
def main():
|
| 140 |
+
args = check_args()
|
| 141 |
+
if args.key:
|
| 142 |
+
key = parse_key_file(str(args.keypath))
|
| 143 |
+
model = YOLO(args.weights)
|
| 144 |
+
# create a couple empty lists for holding results, easier than adding to empty Pandas DF
|
| 145 |
+
tmp_well = []
|
| 146 |
+
tmp_numeggs = []
|
| 147 |
+
tmp_filenames = []
|
| 148 |
+
# single-image mode
|
| 149 |
+
if args.img_mode == 'file':
|
| 150 |
+
img = cv2.imread(str(args.imgpath))
|
| 151 |
+
results = model.predict(img, imgsz = 1440, max_det=1000, verbose=False, conf=args.conf)
|
| 152 |
+
result = results[0]
|
| 153 |
+
box_classes = [result.names[int(x)] for x in result.boxes.cls]
|
| 154 |
+
# NOTE - filtering by class is not necessary, but would make this easier to extend to multi-class models
|
| 155 |
+
# e.g. if we want to add hatched, empty eggs, etc
|
| 156 |
+
egg_xy = [x.numpy().astype(np.int32) for i,x in enumerate(result.boxes.xyxy) if box_classes[i] == 'egg']
|
| 157 |
+
print('Target image:\n%s' % str(args.imgpath))
|
| 158 |
+
print('n eggs:\n%s' % len(egg_xy))
|
| 159 |
+
if args.annotated:
|
| 160 |
+
annot = img.copy()
|
| 161 |
+
for xy in egg_xy:
|
| 162 |
+
cv2.rectangle(annot, tuple(xy[0:2]), tuple(xy[2:4]), (0,0,255), 4)
|
| 163 |
+
annot_path = args.annotpath / ('%s_annotated%s' % (args.imgpath.stem, args.imgpath.suffix))
|
| 164 |
+
cv2.imwrite(str(annot_path), annot)
|
| 165 |
+
print('Saving annotations to %s...' % str(annot_path))
|
| 166 |
+
# multi-image mode, runs differently depending on whether you have /XY00/ subdirectories
|
| 167 |
+
elif args.img_mode == 'dir':
|
| 168 |
+
if args.xy_mode:
|
| 169 |
+
for subdir in args.subdir_paths:
|
| 170 |
+
# check that the empty file with well name is present
|
| 171 |
+
well = [x.name for x in subdir.iterdir() if re.match("_[A-H][0-9]{1,2}", x.name)][0]
|
| 172 |
+
if len(well) == 0:
|
| 173 |
+
raise Exception("No well position file of format _A01 found in subdirectory:\n%s" % subdir)
|
| 174 |
+
# print the XY subdirectory name for tracking purposes
|
| 175 |
+
xy = subdir.name
|
| 176 |
+
print(xy)
|
| 177 |
+
# search for a filename with CH4 in it
|
| 178 |
+
# TODO - confirm with sweetpotato group that the CH4.tif or CH4.jpg will be present in all cases
|
| 179 |
+
candidate_img_paths = list(subdir.glob('*CH4*'))
|
| 180 |
+
# if none or more than one, just skip the folder vs raise exceptions
|
| 181 |
+
if len(candidate_img_paths) == 0:
|
| 182 |
+
print("No CH4 image found for subdirectory %s" % subdir)
|
| 183 |
+
continue
|
| 184 |
+
elif len(candidate_img_paths) > 1:
|
| 185 |
+
print("Multiple CH4 images found in subdirectory %s" % subdir)
|
| 186 |
+
continue
|
| 187 |
+
impath = candidate_img_paths[0]
|
| 188 |
+
# get the actual output
|
| 189 |
+
img = cv2.imread(str(impath))
|
| 190 |
+
results = model.predict(img, imgsz = 1440, verbose=False, conf=args.conf)
|
| 191 |
+
result = results[0]
|
| 192 |
+
box_classes = [result.names[int(x)] for x in result.boxes.cls]
|
| 193 |
+
egg_xy = [x.numpy().astype(np.int32) for i,x in enumerate(result.boxes.xyxy) if box_classes[i] == 'egg']
|
| 194 |
+
# append relevant output to temporary lists
|
| 195 |
+
tmp_well.append(well)
|
| 196 |
+
tmp_numeggs.append(len(egg_xy))
|
| 197 |
+
tmp_filenames.append(impath.name)
|
| 198 |
+
# annotate and save image if needed
|
| 199 |
+
if args.annotated:
|
| 200 |
+
annot = img.copy()
|
| 201 |
+
for xy in egg_xy:
|
| 202 |
+
cv2.rectangle(annot, tuple(xy[0:2]), tuple(xy[2:4]), (0,0,255), 4)
|
| 203 |
+
annot_path = args.annotpath / ('%s_annotated%s' % (impath.stem, impath.suffix))
|
| 204 |
+
cv2.imwrite(str(annot_path), annot)
|
| 205 |
+
# make a CSV to merge with the key
|
| 206 |
+
results = pd.DataFrame({
|
| 207 |
+
"keycol": tmp_well,
|
| 208 |
+
"num_eggs": tmp_numeggs,
|
| 209 |
+
"filename": tmp_filenames,
|
| 210 |
+
"folder": args.img})
|
| 211 |
+
# merge and save
|
| 212 |
+
outdf = key.merge(results, on = "keycol", how = "left")
|
| 213 |
+
outdf = outdf.drop("keycol", axis = 1)
|
| 214 |
+
else:
|
| 215 |
+
# apply the model on each image
|
| 216 |
+
# running model() on the target dir instead of image-by-image would be cleaner
|
| 217 |
+
# but makes saving annotated images more complicated
|
| 218 |
+
# can maybe revisit later
|
| 219 |
+
for impath in sorted(args.subimage_paths):
|
| 220 |
+
img = cv2.imread(str(impath))
|
| 221 |
+
results = model.predict(img, imgsz = 1440, verbose=False, conf= args.conf)
|
| 222 |
+
result = results[0]
|
| 223 |
+
box_classes = [result.names[int(x)] for x in result.boxes.cls]
|
| 224 |
+
egg_xy = [x.numpy().astype(np.int32) for i,x in enumerate(result.boxes.xyxy) if box_classes[i] == 'egg']
|
| 225 |
+
tmp_numeggs.append(len(egg_xy))
|
| 226 |
+
tmp_filenames.append(impath.name)
|
| 227 |
+
# annotate if needed
|
| 228 |
+
if args.annotated:
|
| 229 |
+
annot = img.copy()
|
| 230 |
+
for xy in egg_xy:
|
| 231 |
+
cv2.rectangle(annot, tuple(xy[0:2]), tuple(xy[2:4]), (0,0,255), 4)
|
| 232 |
+
annot_path = args.annotpath / ('%s_annotated%s' % (impath.stem, impath.suffix))
|
| 233 |
+
cv2.imwrite(str(annot_path), annot)
|
| 234 |
+
outdf = pd.DataFrame({
|
| 235 |
+
'folder': args.imgpath,
|
| 236 |
+
"filename": tmp_filenames,
|
| 237 |
+
"num_eggs": tmp_numeggs})
|
| 238 |
+
# save final pandas df, print some updates for user
|
| 239 |
+
outdf.sort_values(by='filename', inplace=True)
|
| 240 |
+
outdf.to_csv(str(args.outpath), index=False)
|
| 241 |
+
print('Saving output to %s...' % str(args.outpath))
|
| 242 |
+
if args.annotated:
|
| 243 |
+
print('Saving annotated images to %s...' % str(args.annotpath))
|
| 244 |
+
|
| 245 |
+
if __name__ == '__main__':
|
| 246 |
+
main()
|
requirements.txt
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
PyQt6>=6.4.0
|
| 2 |
+
pandas>=1.5.0
|
| 3 |
+
opencv-python>=4.7.0
|
| 4 |
+
numpy>=1.21.0
|
| 5 |
+
ultralytics>=8.0.0
|
| 6 |
+
torch>=2.0.0
|
| 7 |
+
Flask>=2.0.0
|
static/script.js
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Basic front-end logic
|
| 2 |
+
|
| 3 |
+
document.addEventListener('DOMContentLoaded', () => {
|
| 4 |
+
const inputMode = document.getElementById('input-mode');
|
| 5 |
+
const fileInput = document.getElementById('file-input');
|
| 6 |
+
const startProcessingBtn = document.getElementById('start-processing');
|
| 7 |
+
const confidenceSlider = document.getElementById('confidence-threshold');
|
| 8 |
+
const confidenceValue = document.getElementById('confidence-value');
|
| 9 |
+
const progress = document.getElementById('progress');
|
| 10 |
+
const progressText = document.getElementById('progress-text');
|
| 11 |
+
const processingStatus = document.getElementById('processing-status');
|
| 12 |
+
const statusOutput = document.getElementById('status-output');
|
| 13 |
+
const resultsTableBody = document.querySelector('#results-table tbody');
|
| 14 |
+
const previewImage = document.getElementById('preview-image');
|
| 15 |
+
const imageInfo = document.getElementById('image-info');
|
| 16 |
+
const prevBtn = document.getElementById('prev-image');
|
| 17 |
+
const nextBtn = document.getElementById('next-image');
|
| 18 |
+
const exportCsvBtn = document.getElementById('export-csv');
|
| 19 |
+
const exportImagesBtn = document.getElementById('export-images');
|
| 20 |
+
const zoomInBtn = document.getElementById('zoom-in');
|
| 21 |
+
const zoomOutBtn = document.getElementById('zoom-out');
|
| 22 |
+
|
| 23 |
+
let currentResults = [];
|
| 24 |
+
let currentImageIndex = -1;
|
| 25 |
+
let currentJobId = null;
|
| 26 |
+
|
| 27 |
+
// Update confidence value display
|
| 28 |
+
confidenceSlider.addEventListener('input', () => {
|
| 29 |
+
confidenceValue.textContent = confidenceSlider.value;
|
| 30 |
+
});
|
| 31 |
+
|
| 32 |
+
// Handle input mode change (single file vs multiple)
|
| 33 |
+
inputMode.addEventListener('change', () => {
|
| 34 |
+
if (inputMode.value === 'single') {
|
| 35 |
+
fileInput.removeAttribute('multiple');
|
| 36 |
+
} else {
|
| 37 |
+
fileInput.setAttribute('multiple', '');
|
| 38 |
+
}
|
| 39 |
+
});
|
| 40 |
+
|
| 41 |
+
// --- Updated Start Processing Logic ---
|
| 42 |
+
startProcessingBtn.addEventListener('click', async () => {
|
| 43 |
+
const files = fileInput.files;
|
| 44 |
+
if (!files || files.length === 0) {
|
| 45 |
+
logStatus('Error: No files selected.');
|
| 46 |
+
alert('Please select one or more image files.');
|
| 47 |
+
return;
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
const mode = inputMode.value;
|
| 51 |
+
if (mode === 'single' && files.length > 1) {
|
| 52 |
+
logStatus('Error: Single File mode selected, but multiple files chosen.');
|
| 53 |
+
alert('Single File mode allows only one file. Please select one file or switch to Directory mode.');
|
| 54 |
+
return;
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
logStatus('Starting upload and processing...');
|
| 58 |
+
processingStatus.textContent = 'Uploading...';
|
| 59 |
+
startProcessingBtn.disabled = true;
|
| 60 |
+
progress.value = 0;
|
| 61 |
+
progressText.textContent = '0%';
|
| 62 |
+
resultsTableBody.innerHTML = ''; // Clear previous results
|
| 63 |
+
clearPreview(); // Clear image preview
|
| 64 |
+
|
| 65 |
+
const formData = new FormData();
|
| 66 |
+
for (const file of files) {
|
| 67 |
+
formData.append('files', file);
|
| 68 |
+
}
|
| 69 |
+
formData.append('input_mode', mode);
|
| 70 |
+
formData.append('confidence_threshold', confidenceSlider.value);
|
| 71 |
+
|
| 72 |
+
// Basic progress simulation for upload (replace with XHR progress if needed)
|
| 73 |
+
progress.value = 50;
|
| 74 |
+
progressText.textContent = '50%';
|
| 75 |
+
processingStatus.textContent = 'Processing...';
|
| 76 |
+
|
| 77 |
+
try {
|
| 78 |
+
const response = await fetch('/process', {
|
| 79 |
+
method: 'POST',
|
| 80 |
+
body: formData,
|
| 81 |
+
});
|
| 82 |
+
|
| 83 |
+
progress.value = 100;
|
| 84 |
+
progressText.textContent = '100%';
|
| 85 |
+
|
| 86 |
+
const data = await response.json();
|
| 87 |
+
|
| 88 |
+
if (response.ok) {
|
| 89 |
+
logStatus('Processing successful.');
|
| 90 |
+
processingStatus.textContent = 'Processing finished.';
|
| 91 |
+
currentJobId = data.job_id; // Store the job ID
|
| 92 |
+
logStatus(`Job ID: ${currentJobId}`);
|
| 93 |
+
if (data.log) {
|
| 94 |
+
logStatus("--- Server Log ---");
|
| 95 |
+
logStatus(data.log);
|
| 96 |
+
logStatus("--- End Log ---");
|
| 97 |
+
}
|
| 98 |
+
if (data.error_files && data.error_files.length > 0) {
|
| 99 |
+
logStatus(`Warning: Skipped invalid files: ${data.error_files.join(', ')}`);
|
| 100 |
+
}
|
| 101 |
+
displayResults(data.results || []);
|
| 102 |
+
} else {
|
| 103 |
+
logStatus(`Error: ${data.error || 'Unknown processing error.'}`);
|
| 104 |
+
if (data.log) {
|
| 105 |
+
logStatus("--- Server Log ---");
|
| 106 |
+
logStatus(data.log);
|
| 107 |
+
logStatus("--- End Log ---");
|
| 108 |
+
}
|
| 109 |
+
processingStatus.textContent = 'Error during processing.';
|
| 110 |
+
alert(`Processing failed: ${data.error || 'Unknown error'}`);
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
} catch (error) {
|
| 114 |
+
console.error('Fetch Error:', error);
|
| 115 |
+
logStatus(`Network or Server Error: ${error.message}`);
|
| 116 |
+
processingStatus.textContent = 'Network Error.';
|
| 117 |
+
progress.value = 0;
|
| 118 |
+
progressText.textContent = 'Error';
|
| 119 |
+
alert(`An error occurred while communicating with the server: ${error.message}`);
|
| 120 |
+
} finally {
|
| 121 |
+
startProcessingBtn.disabled = false; // Re-enable button
|
| 122 |
+
}
|
| 123 |
+
});
|
| 124 |
+
|
| 125 |
+
function logStatus(message) {
|
| 126 |
+
statusOutput.value += message + '\n';
|
| 127 |
+
statusOutput.scrollTop = statusOutput.scrollHeight; // Auto-scroll
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
function displayResults(results) {
|
| 131 |
+
currentResults = results; // Store results
|
| 132 |
+
resultsTableBody.innerHTML = ''; // Clear existing results
|
| 133 |
+
currentImageIndex = -1; // Reset index
|
| 134 |
+
currentJobId = currentJobId; // Ensure job ID is current
|
| 135 |
+
|
| 136 |
+
if (!results || results.length === 0) {
|
| 137 |
+
logStatus("No results returned from processing.");
|
| 138 |
+
clearPreview();
|
| 139 |
+
exportCsvBtn.disabled = true;
|
| 140 |
+
exportImagesBtn.disabled = true;
|
| 141 |
+
updateNavButtons(); // Ensure nav buttons are disabled
|
| 142 |
+
return; // Exit early
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
results.forEach((result, index) => {
|
| 146 |
+
const row = resultsTableBody.insertRow();
|
| 147 |
+
row.classList.add('result-row'); // Add class for styling/selection
|
| 148 |
+
row.innerHTML = `<td>${result.filename}</td><td>${result.num_eggs}</td>`;
|
| 149 |
+
// Add data attributes for easy access
|
| 150 |
+
row.dataset.index = index;
|
| 151 |
+
row.dataset.filename = result.filename;
|
| 152 |
+
row.dataset.annotatedFilename = result.annotated_filename;
|
| 153 |
+
|
| 154 |
+
row.addEventListener('click', () => {
|
| 155 |
+
displayImage(index);
|
| 156 |
+
});
|
| 157 |
+
});
|
| 158 |
+
|
| 159 |
+
displayImage(0); // Show the first image initially
|
| 160 |
+
// Enable export buttons IF there are results
|
| 161 |
+
exportCsvBtn.disabled = !results.some(r => r.filename); // Enable if at least one result has a filename
|
| 162 |
+
exportImagesBtn.disabled = !results.some(r => r.annotated_filename); // Enable if at least one result has an annotated image
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
function displayImage(index) {
|
| 166 |
+
if (index < 0 || index >= currentResults.length || !currentJobId) {
|
| 167 |
+
clearPreview();
|
| 168 |
+
return;
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
currentImageIndex = index;
|
| 172 |
+
const result = currentResults[index];
|
| 173 |
+
|
| 174 |
+
if (result.annotated_filename) {
|
| 175 |
+
const imageUrl = `/results/${currentJobId}/${result.annotated_filename}`;
|
| 176 |
+
previewImage.src = imageUrl;
|
| 177 |
+
previewImage.alt = `Annotated ${result.filename}`;
|
| 178 |
+
imageInfo.textContent = `Filename: ${result.filename} - Eggs detected: ${result.num_eggs}`;
|
| 179 |
+
logStatus(`Displaying image: ${result.annotated_filename}`);
|
| 180 |
+
} else {
|
| 181 |
+
// Handle cases where annotation failed or wasn't produced
|
| 182 |
+
previewImage.src = ''; // Clear image
|
| 183 |
+
previewImage.alt = 'Annotated image not available';
|
| 184 |
+
imageInfo.textContent = `Filename: ${result.filename} - Eggs detected: ${result.num_eggs} (Annotation N/A)`;
|
| 185 |
+
logStatus(`Annotated image not available for: ${result.filename}`);
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
+
updateNavButtons();
|
| 189 |
+
|
| 190 |
+
// Highlight selected row
|
| 191 |
+
document.querySelectorAll('.result-row').forEach(row => {
|
| 192 |
+
row.classList.remove('selected');
|
| 193 |
+
});
|
| 194 |
+
const selectedRow = resultsTableBody.querySelector(`tr[data-index="${index}"]`);
|
| 195 |
+
if (selectedRow) {
|
| 196 |
+
selectedRow.classList.add('selected');
|
| 197 |
+
}
|
| 198 |
+
}
|
| 199 |
+
|
| 200 |
+
function clearPreview() {
|
| 201 |
+
previewImage.src = '';
|
| 202 |
+
previewImage.alt = 'Annotated image preview';
|
| 203 |
+
imageInfo.textContent = 'Filename: - Eggs detected: -';
|
| 204 |
+
currentImageIndex = -1;
|
| 205 |
+
updateNavButtons();
|
| 206 |
+
}
|
| 207 |
+
|
| 208 |
+
function updateNavButtons() {
|
| 209 |
+
prevBtn.disabled = currentImageIndex <= 0;
|
| 210 |
+
nextBtn.disabled = currentImageIndex < 0 || currentImageIndex >= currentResults.length - 1;
|
| 211 |
+
}
|
| 212 |
+
|
| 213 |
+
prevBtn.addEventListener('click', () => {
|
| 214 |
+
if (currentImageIndex > 0) {
|
| 215 |
+
displayImage(currentImageIndex - 1);
|
| 216 |
+
}
|
| 217 |
+
});
|
| 218 |
+
|
| 219 |
+
nextBtn.addEventListener('click', () => {
|
| 220 |
+
if (currentImageIndex < currentResults.length - 1) {
|
| 221 |
+
displayImage(currentImageIndex + 1);
|
| 222 |
+
}
|
| 223 |
+
});
|
| 224 |
+
|
| 225 |
+
// --- Export Logic (Placeholders - requires backend implementation) ---
|
| 226 |
+
exportCsvBtn.addEventListener('click', () => {
|
| 227 |
+
if (!currentJobId) {
|
| 228 |
+
alert("No job processed yet.");
|
| 229 |
+
return;
|
| 230 |
+
}
|
| 231 |
+
// Construct the CSV download URL
|
| 232 |
+
const csvFilename = `${currentJobId}_results.csv`;
|
| 233 |
+
const downloadUrl = `/results/${currentJobId}/${csvFilename}`;
|
| 234 |
+
logStatus(`Triggering CSV download: ${csvFilename}`);
|
| 235 |
+
// Create a temporary link and click it
|
| 236 |
+
const link = document.createElement('a');
|
| 237 |
+
link.href = downloadUrl;
|
| 238 |
+
link.download = csvFilename; // Suggest filename to browser
|
| 239 |
+
document.body.appendChild(link);
|
| 240 |
+
link.click();
|
| 241 |
+
document.body.removeChild(link);
|
| 242 |
+
});
|
| 243 |
+
|
| 244 |
+
exportImagesBtn.addEventListener('click', () => {
|
| 245 |
+
if (!currentJobId) {
|
| 246 |
+
alert("No job processed yet.");
|
| 247 |
+
return;
|
| 248 |
+
}
|
| 249 |
+
// This is more complex. Ideally, the backend would provide a zip file.
|
| 250 |
+
// For now, just log and maybe open the first image?
|
| 251 |
+
logStatus('Image export clicked. Downloading individual images or a zip file is needed.');
|
| 252 |
+
alert('Image export functionality requires backend support to create a downloadable archive (zip file).');
|
| 253 |
+
// Example: trigger download of the currently viewed image
|
| 254 |
+
if (currentImageIndex !== -1 && currentResults[currentImageIndex].annotated_filename) {
|
| 255 |
+
const imgFilename = currentResults[currentImageIndex].annotated_filename;
|
| 256 |
+
const downloadUrl = `/results/${currentJobId}/${imgFilename}`;
|
| 257 |
+
const link = document.createElement('a');
|
| 258 |
+
link.href = downloadUrl;
|
| 259 |
+
link.download = imgFilename;
|
| 260 |
+
document.body.appendChild(link);
|
| 261 |
+
link.click();
|
| 262 |
+
document.body.removeChild(link);
|
| 263 |
+
}
|
| 264 |
+
});
|
| 265 |
+
|
| 266 |
+
// --- Zoom Logic (Placeholder - requires a library or complex CSS/JS) ---
|
| 267 |
+
zoomInBtn.addEventListener('click', () => {
|
| 268 |
+
console.log('Zoom In clicked');
|
| 269 |
+
logStatus('Zoom functionality not yet implemented.');
|
| 270 |
+
alert('Zoom functionality is not yet implemented.');
|
| 271 |
+
});
|
| 272 |
+
|
| 273 |
+
zoomOutBtn.addEventListener('click', () => {
|
| 274 |
+
console.log('Zoom Out clicked');
|
| 275 |
+
logStatus('Zoom functionality not yet implemented.');
|
| 276 |
+
alert('Zoom functionality is not yet implemented.');
|
| 277 |
+
});
|
| 278 |
+
|
| 279 |
+
// Initial setup
|
| 280 |
+
if (inputMode.value === 'single') {
|
| 281 |
+
fileInput.removeAttribute('multiple');
|
| 282 |
+
}
|
| 283 |
+
logStatus('Application initialized. Ready for file selection.');
|
| 284 |
+
exportCsvBtn.disabled = true;
|
| 285 |
+
exportImagesBtn.disabled = true;
|
| 286 |
+
clearPreview(); // Ensure preview is cleared on load
|
| 287 |
+
});
|
static/style.css
ADDED
|
@@ -0,0 +1,335 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* Refined styling to better match screenshot */
|
| 2 |
+
|
| 3 |
+
:root {
|
| 4 |
+
--primary-color: #0d6efd; /* Bootstrap blue */
|
| 5 |
+
--secondary-color: #6c757d; /* Bootstrap secondary grey */
|
| 6 |
+
--light-grey: #f8f9fa; /* Light background */
|
| 7 |
+
--border-color: #dee2e6; /* Standard border */
|
| 8 |
+
--card-bg: #ffffff;
|
| 9 |
+
--text-color: #212529;
|
| 10 |
+
--selected-row-bg: #e9ecef;
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
body {
|
| 14 |
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
| 15 |
+
margin: 20px;
|
| 16 |
+
background-color: var(--light-grey);
|
| 17 |
+
color: var(--text-color);
|
| 18 |
+
font-size: 0.95rem;
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
h1 {
|
| 22 |
+
text-align: center;
|
| 23 |
+
color: #333;
|
| 24 |
+
margin-bottom: 25px;
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
.container {
|
| 28 |
+
display: flex;
|
| 29 |
+
gap: 25px;
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
.left-panel,
|
| 33 |
+
.right-panel {
|
| 34 |
+
flex: 1;
|
| 35 |
+
display: flex;
|
| 36 |
+
flex-direction: column;
|
| 37 |
+
gap: 20px;
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
.card {
|
| 41 |
+
background-color: var(--card-bg);
|
| 42 |
+
border: 1px solid var(--border-color);
|
| 43 |
+
border-radius: 6px;
|
| 44 |
+
padding: 20px;
|
| 45 |
+
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
.card h2 {
|
| 49 |
+
margin-top: 0;
|
| 50 |
+
margin-bottom: 15px;
|
| 51 |
+
border-bottom: 1px solid var(--border-color);
|
| 52 |
+
padding-bottom: 10px;
|
| 53 |
+
font-size: 1.1rem;
|
| 54 |
+
font-weight: 500;
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
/* --- General Form Elements --- */
|
| 58 |
+
label {
|
| 59 |
+
display: block;
|
| 60 |
+
margin-bottom: 5px;
|
| 61 |
+
font-weight: 500;
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
select,
|
| 65 |
+
input[type="text"],
|
| 66 |
+
input[type="number"],
|
| 67 |
+
input[type="file"]::file-selector-button, /* Style the button part */
|
| 68 |
+
textarea {
|
| 69 |
+
display: block;
|
| 70 |
+
width: calc(100% - 16px); /* Account for padding */
|
| 71 |
+
padding: 8px;
|
| 72 |
+
margin-bottom: 15px;
|
| 73 |
+
border: 1px solid var(--border-color);
|
| 74 |
+
border-radius: 4px;
|
| 75 |
+
font-size: 0.9rem;
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
select {
|
| 79 |
+
width: 100%; /* Select takes full width */
|
| 80 |
+
cursor: pointer;
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
/* Style the actual file input button */
|
| 84 |
+
input[type="file"] {
|
| 85 |
+
padding: 0;
|
| 86 |
+
border: none;
|
| 87 |
+
}
|
| 88 |
+
input[type="file"]::file-selector-button {
|
| 89 |
+
background-color: var(--primary-color);
|
| 90 |
+
color: white;
|
| 91 |
+
border: none;
|
| 92 |
+
padding: 8px 12px;
|
| 93 |
+
border-radius: 4px;
|
| 94 |
+
cursor: pointer;
|
| 95 |
+
transition: background-color 0.2s ease;
|
| 96 |
+
margin-right: 10px; /* Space between button and text */
|
| 97 |
+
font-size: 0.9rem;
|
| 98 |
+
width: auto; /* Override width */
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
input[type="file"]::file-selector-button:hover {
|
| 102 |
+
background-color: #0b5ed7; /* Darker blue on hover */
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
/* --- Buttons --- */
|
| 106 |
+
button {
|
| 107 |
+
padding: 8px 15px;
|
| 108 |
+
border: none;
|
| 109 |
+
border-radius: 4px;
|
| 110 |
+
cursor: pointer;
|
| 111 |
+
font-size: 0.9rem;
|
| 112 |
+
transition: background-color 0.2s ease, box-shadow 0.2s ease;
|
| 113 |
+
text-align: center;
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
button:disabled {
|
| 117 |
+
opacity: 0.65;
|
| 118 |
+
cursor: not-allowed;
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
/* Primary Button Style (e.g., Start Processing) */
|
| 122 |
+
#start-processing,
|
| 123 |
+
#export-csv,
|
| 124 |
+
#export-images {
|
| 125 |
+
background-color: var(--primary-color);
|
| 126 |
+
color: white;
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
#start-processing:not(:disabled):hover,
|
| 130 |
+
#export-csv:not(:disabled):hover,
|
| 131 |
+
#export-images:not(:disabled):hover {
|
| 132 |
+
background-color: #0b5ed7; /* Darker blue */
|
| 133 |
+
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
/* Secondary/Control Button Style (e.g., Zoom, Prev/Next) */
|
| 137 |
+
#zoom-in, #zoom-out, #prev-image, #next-image {
|
| 138 |
+
background-color: var(--secondary-color);
|
| 139 |
+
color: white;
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
#zoom-in:not(:disabled):hover, #zoom-out:not(:disabled):hover, #prev-image:not(:disabled):hover, #next-image:not(:disabled):hover {
|
| 143 |
+
background-color: #5c636a; /* Darker grey */
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
/* --- Specific Sections --- */
|
| 147 |
+
|
| 148 |
+
/* Parameters */
|
| 149 |
+
#parameters {
|
| 150 |
+
display: flex;
|
| 151 |
+
align-items: center; /* Align items vertically */
|
| 152 |
+
flex-wrap: wrap; /* Allow wrapping if needed */
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
#parameters label {
|
| 156 |
+
margin-right: 10px;
|
| 157 |
+
margin-bottom: 0; /* Remove bottom margin for inline */
|
| 158 |
+
display: inline-block; /* Make label inline */
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
#parameters input[type="range"] {
|
| 162 |
+
flex-grow: 1; /* Allow slider to take up space */
|
| 163 |
+
width: auto; /* Override default width */
|
| 164 |
+
height: 5px; /* Make slider thinner */
|
| 165 |
+
cursor: pointer;
|
| 166 |
+
margin: 0 10px; /* Add some margin */
|
| 167 |
+
vertical-align: middle;
|
| 168 |
+
padding: 0; /* Remove padding */
|
| 169 |
+
margin-bottom: 0;
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
/* Basic range slider styling (will vary by browser) */
|
| 173 |
+
input[type=range]::-webkit-slider-thumb {
|
| 174 |
+
-webkit-appearance: none;
|
| 175 |
+
appearance: none;
|
| 176 |
+
width: 16px;
|
| 177 |
+
height: 16px;
|
| 178 |
+
background: var(--primary-color);
|
| 179 |
+
cursor: pointer;
|
| 180 |
+
border-radius: 50%;
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
input[type=range]::-moz-range-thumb {
|
| 184 |
+
width: 16px;
|
| 185 |
+
height: 16px;
|
| 186 |
+
background: var(--primary-color);
|
| 187 |
+
cursor: pointer;
|
| 188 |
+
border-radius: 50%;
|
| 189 |
+
border: none;
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
#parameters #confidence-value {
|
| 193 |
+
font-weight: bold;
|
| 194 |
+
min-width: 30px; /* Ensure space for value */
|
| 195 |
+
text-align: right;
|
| 196 |
+
margin-left: 5px;
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
/* Processing */
|
| 200 |
+
#processing progress {
|
| 201 |
+
width: calc(100% - 50px); /* Adjust width to fit text */
|
| 202 |
+
height: 10px;
|
| 203 |
+
vertical-align: middle;
|
| 204 |
+
margin-right: 5px;
|
| 205 |
+
border: 1px solid var(--border-color);
|
| 206 |
+
border-radius: 4px;
|
| 207 |
+
overflow: hidden; /* Ensure border radius clips the progress */
|
| 208 |
+
}
|
| 209 |
+
|
| 210 |
+
/* Basic progress bar styling */
|
| 211 |
+
progress::-webkit-progress-bar {
|
| 212 |
+
background-color: #e9ecef;
|
| 213 |
+
border-radius: 4px;
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
+
progress::-webkit-progress-value {
|
| 217 |
+
background-color: var(--primary-color);
|
| 218 |
+
border-radius: 4px;
|
| 219 |
+
transition: width 0.3s ease;
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
progress::-moz-progress-bar {
|
| 223 |
+
background-color: var(--primary-color);
|
| 224 |
+
border-radius: 4px;
|
| 225 |
+
transition: width 0.3s ease;
|
| 226 |
+
}
|
| 227 |
+
|
| 228 |
+
#processing #progress-text {
|
| 229 |
+
vertical-align: middle;
|
| 230 |
+
font-weight: bold;
|
| 231 |
+
}
|
| 232 |
+
|
| 233 |
+
#processing button {
|
| 234 |
+
margin-top: 15px;
|
| 235 |
+
display: block; /* Make button block level */
|
| 236 |
+
width: auto;
|
| 237 |
+
}
|
| 238 |
+
|
| 239 |
+
#processing #processing-status {
|
| 240 |
+
margin-top: 10px;
|
| 241 |
+
font-size: 0.9em;
|
| 242 |
+
color: var(--secondary-color);
|
| 243 |
+
}
|
| 244 |
+
|
| 245 |
+
/* Status Log */
|
| 246 |
+
#status-log textarea {
|
| 247 |
+
width: calc(100% - 16px); /* Account for padding */
|
| 248 |
+
height: 180px; /* Slightly taller */
|
| 249 |
+
font-family: monospace;
|
| 250 |
+
font-size: 0.85em;
|
| 251 |
+
border: 1px solid var(--border-color);
|
| 252 |
+
padding: 8px;
|
| 253 |
+
resize: vertical; /* Allow vertical resize */
|
| 254 |
+
}
|
| 255 |
+
|
| 256 |
+
/* Results Summary */
|
| 257 |
+
#export-options {
|
| 258 |
+
margin-bottom: 10px;
|
| 259 |
+
}
|
| 260 |
+
#export-options label {
|
| 261 |
+
display: inline-block;
|
| 262 |
+
margin-right: 10px;
|
| 263 |
+
}
|
| 264 |
+
|
| 265 |
+
#export-options button {
|
| 266 |
+
margin-left: 5px;
|
| 267 |
+
}
|
| 268 |
+
|
| 269 |
+
#results-summary table {
|
| 270 |
+
width: 100%;
|
| 271 |
+
border-collapse: collapse;
|
| 272 |
+
margin-top: 15px;
|
| 273 |
+
font-size: 0.9em;
|
| 274 |
+
}
|
| 275 |
+
|
| 276 |
+
#results-summary th,
|
| 277 |
+
#results-summary td {
|
| 278 |
+
border: 1px solid var(--border-color);
|
| 279 |
+
padding: 10px;
|
| 280 |
+
text-align: left;
|
| 281 |
+
vertical-align: middle;
|
| 282 |
+
}
|
| 283 |
+
|
| 284 |
+
#results-summary th {
|
| 285 |
+
background-color: #e9ecef; /* Lighter grey header */
|
| 286 |
+
font-weight: 600;
|
| 287 |
+
}
|
| 288 |
+
|
| 289 |
+
#results-summary tbody tr {
|
| 290 |
+
cursor: pointer;
|
| 291 |
+
transition: background-color 0.15s ease;
|
| 292 |
+
}
|
| 293 |
+
|
| 294 |
+
#results-summary tbody tr:hover {
|
| 295 |
+
background-color: #f8f9fa; /* Very light grey on hover */
|
| 296 |
+
}
|
| 297 |
+
|
| 298 |
+
#results-summary tbody tr.selected {
|
| 299 |
+
background-color: var(--selected-row-bg); /* Use variable for selected */
|
| 300 |
+
font-weight: 500;
|
| 301 |
+
}
|
| 302 |
+
|
| 303 |
+
/* Image Preview */
|
| 304 |
+
#image-preview #image-container {
|
| 305 |
+
border: 1px solid var(--border-color);
|
| 306 |
+
height: 350px; /* Adjust as needed */
|
| 307 |
+
overflow: auto;
|
| 308 |
+
display: flex;
|
| 309 |
+
justify-content: center;
|
| 310 |
+
align-items: center;
|
| 311 |
+
margin-bottom: 10px;
|
| 312 |
+
background-color: #e9ecef; /* Light background for container */
|
| 313 |
+
}
|
| 314 |
+
|
| 315 |
+
#image-preview img {
|
| 316 |
+
max-width: 100%;
|
| 317 |
+
max-height: 100%;
|
| 318 |
+
display: block;
|
| 319 |
+
object-fit: contain; /* Ensure image fits well */
|
| 320 |
+
}
|
| 321 |
+
|
| 322 |
+
#image-preview #image-info {
|
| 323 |
+
font-size: 0.9em;
|
| 324 |
+
color: var(--secondary-color);
|
| 325 |
+
text-align: center;
|
| 326 |
+
margin-bottom: 10px;
|
| 327 |
+
}
|
| 328 |
+
|
| 329 |
+
#image-preview .image-controls {
|
| 330 |
+
text-align: center;
|
| 331 |
+
}
|
| 332 |
+
|
| 333 |
+
#image-preview .image-controls button {
|
| 334 |
+
margin: 0 5px;
|
| 335 |
+
}
|
templates/index.html
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>NemaQuant - Nematode Egg Detection</title>
|
| 7 |
+
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
| 8 |
+
<!-- Consider adding a CSS framework like Bootstrap -->
|
| 9 |
+
</head>
|
| 10 |
+
<body>
|
| 11 |
+
<h1>NemaQuant - Nematode Egg Detection</h1>
|
| 12 |
+
|
| 13 |
+
<div class="container">
|
| 14 |
+
<div class="left-panel">
|
| 15 |
+
<!-- Input Selection -->
|
| 16 |
+
<div class="card" id="input-selection">
|
| 17 |
+
<h2>Input Selection</h2>
|
| 18 |
+
<label for="input-mode">Input Mode:</label>
|
| 19 |
+
<select id="input-mode" name="input-mode">
|
| 20 |
+
<option value="single">Single File</option>
|
| 21 |
+
<option value="directory">Directory (Multiple Files)</option>
|
| 22 |
+
</select>
|
| 23 |
+
<br>
|
| 24 |
+
<label for="file-input">Input:</label>
|
| 25 |
+
<input type="file" id="file-input" name="files" multiple>
|
| 26 |
+
<!-- We'll hide/show this or use different elements based on mode -->
|
| 27 |
+
<br>
|
| 28 |
+
<!-- Output directory is handled by the server -->
|
| 29 |
+
</div>
|
| 30 |
+
|
| 31 |
+
<!-- Processing -->
|
| 32 |
+
<div class="card" id="processing">
|
| 33 |
+
<h2>Processing</h2>
|
| 34 |
+
<label for="progress">Progress:</label>
|
| 35 |
+
<progress id="progress" value="0" max="100"></progress>
|
| 36 |
+
<span id="progress-text">0%</span>
|
| 37 |
+
<br>
|
| 38 |
+
<button id="start-processing">Start Processing</button>
|
| 39 |
+
<p id="processing-status"></p>
|
| 40 |
+
</div>
|
| 41 |
+
|
| 42 |
+
<!-- Results -->
|
| 43 |
+
<div class="card" id="results-summary">
|
| 44 |
+
<h2>Results</h2>
|
| 45 |
+
<div id="export-options">
|
| 46 |
+
<label>Export:</label>
|
| 47 |
+
<button id="export-csv">CSV</button>
|
| 48 |
+
<button id="export-images">Images</button>
|
| 49 |
+
</div>
|
| 50 |
+
<p>Click on a row to view the annotated image</p>
|
| 51 |
+
<table id="results-table">
|
| 52 |
+
<thead>
|
| 53 |
+
<tr>
|
| 54 |
+
<th>filename</th>
|
| 55 |
+
<th>num_eggs</th>
|
| 56 |
+
</tr>
|
| 57 |
+
</thead>
|
| 58 |
+
<tbody>
|
| 59 |
+
<!-- Results will be populated here by JavaScript -->
|
| 60 |
+
</tbody>
|
| 61 |
+
</table>
|
| 62 |
+
</div>
|
| 63 |
+
</div>
|
| 64 |
+
|
| 65 |
+
<div class="right-panel">
|
| 66 |
+
<!-- Parameters -->
|
| 67 |
+
<div class="card" id="parameters">
|
| 68 |
+
<h2>Parameters</h2>
|
| 69 |
+
<label for="confidence-threshold">Confidence Threshold:</label>
|
| 70 |
+
<input type="range" id="confidence-threshold" name="confidence-threshold" min="0" max="1" step="0.05" value="0.6">
|
| 71 |
+
<span id="confidence-value">0.6</span>
|
| 72 |
+
<!-- Add tooltip/help icon if needed -->
|
| 73 |
+
</div>
|
| 74 |
+
|
| 75 |
+
<!-- Status -->
|
| 76 |
+
<div class="card" id="status-log">
|
| 77 |
+
<h2>Status</h2>
|
| 78 |
+
<textarea id="status-output" rows="10" readonly></textarea>
|
| 79 |
+
</div>
|
| 80 |
+
|
| 81 |
+
<!-- Image Preview -->
|
| 82 |
+
<div class="card" id="image-preview">
|
| 83 |
+
<h2>Image Preview</h2>
|
| 84 |
+
<div id="image-container">
|
| 85 |
+
<img id="preview-image" src="" alt="Annotated image preview">
|
| 86 |
+
</div>
|
| 87 |
+
<p id="image-info">Filename: - Eggs detected: -</p>
|
| 88 |
+
<div class="image-controls">
|
| 89 |
+
<button id="zoom-out">Zoom -</button>
|
| 90 |
+
<button id="zoom-in">Zoom +</button>
|
| 91 |
+
<button id="prev-image" disabled>Prev</button>
|
| 92 |
+
<button id="next-image" disabled>Next</button>
|
| 93 |
+
</div>
|
| 94 |
+
</div>
|
| 95 |
+
</div>
|
| 96 |
+
</div>
|
| 97 |
+
|
| 98 |
+
<script src="{{ url_for('static', filename='script.js') }}"></script>
|
| 99 |
+
</body>
|
| 100 |
+
</html>
|