sloneckity commited on
Commit
30d5634
·
1 Parent(s): e435266

Add NemaQuant Flask application files

Browse files
Files changed (8) hide show
  1. Dockerfile +31 -0
  2. README.md +80 -0
  3. app.py +181 -0
  4. nemaquant.py +246 -0
  5. requirements.txt +7 -0
  6. static/script.js +287 -0
  7. static/style.css +335 -0
  8. 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>