tyrwh commited on
Commit
1b2b626
·
1 Parent(s): 9315b87

Adding Keyence folder handling

Browse files
Files changed (4) hide show
  1. app.py +44 -10
  2. nemaquant.py +52 -61
  3. static/script.js +297 -20
  4. templates/index.html +4 -3
app.py CHANGED
@@ -64,14 +64,45 @@ def process_images():
64
  saved_files = []
65
  error_files = []
66
 
67
- for file in files:
68
- if file and allowed_file(file.filename):
69
- filename = secure_filename(file.filename)
70
- save_path = job_output_dir / filename
71
- file.save(str(save_path))
72
- saved_files.append(save_path)
73
- elif file:
74
- error_files.append(file.filename)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
75
 
76
  if not saved_files:
77
  return jsonify({"error": f"No valid files uploaded. Invalid files: {error_files}"}), 400
@@ -82,6 +113,9 @@ def process_images():
82
  elif input_mode == 'single':
83
  input_target = str(saved_files[0])
84
  img_mode_arg = 'file'
 
 
 
85
  else:
86
  return jsonify({"error": f"Invalid input mode: {input_mode}"}), 400
87
 
@@ -91,11 +125,11 @@ def process_images():
91
  cmd = [
92
  sys.executable,
93
  str(APP_ROOT / 'nemaquant.py'),
 
94
  '-i', input_target,
95
  '-w', str(WEIGHTS_FILE),
96
  '-o', str(output_csv),
97
- '-a', str(annotated_dir),
98
- '--conf', confidence
99
  ]
100
 
101
  print(f"[{job_id}] Prepared command: {' '.join(cmd)}")
 
64
  saved_files = []
65
  error_files = []
66
 
67
+ # For keyence mode, validate XY subdirectory structure but flatten files
68
+ if input_mode == 'keyence':
69
+ xy_subdirs = set()
70
+ # First pass: validate XY subdirectory structure
71
+ for file in files:
72
+ if file and file.filename:
73
+ relative_path = file.filename
74
+ if '/' in relative_path:
75
+ first_dir = relative_path.split('/')[0]
76
+ if re.match(r'^XY[0-9][0-9]$', first_dir):
77
+ xy_subdirs.add(first_dir)
78
+
79
+ # Validate that we found XY subdirectories
80
+ if not xy_subdirs:
81
+ return jsonify({
82
+ "error": "Keyence mode requires folder structure with XY[0-9][0-9] subdirectories (e.g., XY01/, XY02/). No such subdirectories found in uploaded folder."
83
+ }), 400
84
+
85
+ # Second pass: save files with flattened structure
86
+ for file in files:
87
+ if file and allowed_file(file.filename):
88
+ # Extract just the filename, ignoring the folder structure
89
+ filename = secure_filename(Path(file.filename).name)
90
+ save_path = job_output_dir / filename
91
+ file.save(str(save_path))
92
+ saved_files.append(save_path)
93
+ elif file:
94
+ error_files.append(file.filename)
95
+
96
+ else:
97
+ # Original file handling for non-keyence modes
98
+ for file in files:
99
+ if file and allowed_file(file.filename):
100
+ filename = secure_filename(file.filename)
101
+ save_path = job_output_dir / filename
102
+ file.save(str(save_path))
103
+ saved_files.append(save_path)
104
+ elif file:
105
+ error_files.append(file.filename)
106
 
107
  if not saved_files:
108
  return jsonify({"error": f"No valid files uploaded. Invalid files: {error_files}"}), 400
 
113
  elif input_mode == 'single':
114
  input_target = str(saved_files[0])
115
  img_mode_arg = 'file'
116
+ elif input_mode == 'keyence':
117
+ input_target = str(job_output_dir)
118
+ img_mode_arg = 'keyence'
119
  else:
120
  return jsonify({"error": f"Invalid input mode: {input_mode}"}), 400
121
 
 
125
  cmd = [
126
  sys.executable,
127
  str(APP_ROOT / 'nemaquant.py'),
128
+ '-m', img_mode_arg,
129
  '-i', input_target,
130
  '-w', str(WEIGHTS_FILE),
131
  '-o', str(output_csv),
132
+ '-a', str(annotated_dir)
 
133
  ]
134
 
135
  print(f"[{job_id}] Prepared command: {' '.join(cmd)}")
nemaquant.py CHANGED
@@ -11,10 +11,11 @@ from pathlib import Path
11
  from ultralytics import YOLO
12
  from glob import glob
13
  import re
14
- import sys # Import sys module
15
 
16
  def options():
17
- parser = argparse.ArgumentParser(description="Nematode egg image processing with YOLOv8 model.")
 
18
  parser.add_argument("-i", "--img", help="Target image directory or image (REQUIRED)", required=True)
19
  parser.add_argument('-w', '--weights', help='Weights file for use with YOLO11 model')
20
  parser.add_argument("-o","--output", help="Name of results file. If no file is specified, one will be created from the key file name")
@@ -24,21 +25,25 @@ def options():
24
  args = parser.parse_args()
25
  return args
26
 
27
- # TODO - maybe rework this from a function to custom argparse.Action() subclasses?
28
  def check_args():
29
  args = options()
30
  # basic checks on target file validity
31
  args.imgpath = Path(args.img)
32
  if not args.imgpath.exists():
33
  raise Exception("Target %s is not a valid path" % args.img)
34
- if args.imgpath.is_file():
35
- args.img_mode = 'file'
36
- if not args.imgpath.suffix.lower() in ['.tif','.tiff','.jpg','.jpeg','.png']:
37
- raise Exception('Target image %s must of type .png, .tif, .tiff, .jpeg, or .jpg' % args.img)
38
- elif args.imgpath.is_dir():
39
- args.img_mode = 'dir'
40
- else:
41
- raise Exception('Target %s does not appear to be a file or directory.' % args.img)
 
 
 
 
 
42
 
43
  # if no weights file, try using the default weights.pt
44
  if not args.weights:
@@ -49,55 +54,39 @@ def check_args():
49
  else:
50
  raise Exception('No weights file specified and default weights.pt not found in script directory')
51
 
52
- # check if subdirectories of format XY00/ exist or if we're running on just a dir of images
53
- if args.img_mode == 'dir':
54
- subdirs = sorted(list(args.imgpath.glob('XY[0-9][0-9]/')))
55
- if len(subdirs) == 0:
56
- print("No subdirectories of format /XY../ found in specified imgdir, checking for images...")
57
- potential_images = [x for x in args.imgpath.iterdir() if x.suffix.lower() in ['.tif','.tiff','.jpg','.jpeg','.png']]
58
- if len(potential_images) == 0:
59
- raise Exception('No valid images (.png, .tif, .tiff, .jpeg, .jpg) in target folder %s' % args.img)
60
- else:
61
- print('%s valid images found' % len(potential_images))
62
- args.xy_mode = False
63
- args.subimage_paths = potential_images
64
  else:
65
- args.xy_mode = True
66
- args.subdir_paths = subdirs
67
-
68
- # for /XY00/ subdirectories, we require a valid key
69
- # ensure that either a key is specified, or if a single .csv exists in the target dir, use that
70
- if args.xy_mode:
71
- if args.key:
72
- args.keypath = Path(args.key)
73
- if not args.keypath.exists():
74
- raise Exception('Specified key file does not exist: %s' % args.keypath)
75
- if args.keypath.suffix != '.csv':
76
- raise Exception("Specified key file is not a .csv: %s" % args.keypath)
77
  else:
78
- print('Running on /XY00/ subdirectories but no key specified. Looking for key file...')
79
- potential_keys = list(args.imgpath.glob('*.csv'))
80
- if len(potential_keys) == 0:
81
- raise Exception("No .csv files found in target folder %s, please check directory" % args.img)
82
- if len(potential_keys) > 1:
83
- raise Exception("Multiple .csv files found in target folder %s, please specify which one to use")
84
- else:
85
- args.keypath = potential_keys[0]
86
- args.key = str(potential_keys[0])
87
 
88
- # if path to results file is specified, ensure it is .csv
89
- if args.output:
90
- args.outpath = Path(args.output)
91
- if args.outpath.suffix != '.csv':
92
- raise Exception("Specified output file is not a .csv: %s" % args.outpath)
 
 
 
 
 
93
  else:
94
- # for XY00 subdirs, name it after the required key file
95
- # for an image directory, name it after the directory
96
- if args.xy_mode:
97
- args.output = '%s_eggcounts.csv' % args.keypath.stem
98
- else:
99
- args.output = '%s_eggcounts.csv' % args.imgpath.stem
100
- args.outpath = Path(args.output)
101
 
102
  # finally, check the target dir to save annotated images in
103
  if args.annotated:
@@ -154,8 +143,9 @@ def main():
154
  tmp_filenames = []
155
  # single-image mode
156
  if args.img_mode == 'file':
 
157
  img = cv2.imread(str(args.imgpath))
158
- results = model.predict(img, imgsz = 1440, max_det=1000, verbose=False, conf=args.conf)
159
  result = results[0]
160
  box_classes = [result.names[int(x)] for x in result.boxes.cls]
161
  # NOTE - filtering by class is not necessary, but would make this easier to extend to multi-class models
@@ -171,8 +161,9 @@ def main():
171
  cv2.imwrite(str(annot_path), annot)
172
  print('Saving annotations to %s...' % str(annot_path))
173
  # multi-image mode, runs differently depending on whether you have /XY00/ subdirectories
174
- elif args.img_mode == 'dir':
175
- if args.xy_mode:
 
176
  total_subdirs = len(args.subdir_paths)
177
  for i, subdir in enumerate(args.subdir_paths):
178
  # Report progress
@@ -200,7 +191,7 @@ def main():
200
  impath = candidate_img_paths[0]
201
  # get the actual output
202
  img = cv2.imread(str(impath))
203
- results = model.predict(img, imgsz = 1440, verbose=False, conf=args.conf)
204
  result = results[0]
205
  box_classes = [result.names[int(x)] for x in result.boxes.cls]
206
  egg_xy = [x.cpu().numpy().astype(np.int32) for i,x in enumerate(result.boxes.xyxy) if box_classes[i] == 'egg']
@@ -237,7 +228,7 @@ def main():
237
  sys.stdout.flush() # Flush output buffer
238
 
239
  img = cv2.imread(str(impath))
240
- results = model.predict(img, imgsz = 1440, verbose=False, conf= args.conf)
241
  result = results[0]
242
  box_classes = [result.names[int(x)] for x in result.boxes.cls]
243
  egg_xy = [x.cpu().numpy().astype(np.int32) for i,x in enumerate(result.boxes.xyxy) if box_classes[i] == 'egg']
 
11
  from ultralytics import YOLO
12
  from glob import glob
13
  import re
14
+ import sys
15
 
16
  def options():
17
+ parser = argparse.ArgumentParser(description="Nematode egg image processing with YOLO11 model.")
18
+ parser.add_argument("-m", "--img_mode", help="Mode to run", required=True)
19
  parser.add_argument("-i", "--img", help="Target image directory or image (REQUIRED)", required=True)
20
  parser.add_argument('-w', '--weights', help='Weights file for use with YOLO11 model')
21
  parser.add_argument("-o","--output", help="Name of results file. If no file is specified, one will be created from the key file name")
 
25
  args = parser.parse_args()
26
  return args
27
 
 
28
  def check_args():
29
  args = options()
30
  # basic checks on target file validity
31
  args.imgpath = Path(args.img)
32
  if not args.imgpath.exists():
33
  raise Exception("Target %s is not a valid path" % args.img)
34
+ # check if img_mode is specified and valid
35
+ if args.img_mode:
36
+ valid_modes = ['dir', 'file', 'keyence']
37
+ if args.img_mode not in valid_modes:
38
+ raise Exception(f"img_mode must be one of: {', '.join(valid_modes)}")
39
+ # check for potential images in the target directory
40
+ if args.img_mode in ['dir','keyence']:
41
+ potential_images = [x for x in args.imgpath.iterdir() if x.suffix.lower() in ['.tif','.tiff','.jpg','.jpeg','.png']]
42
+ if len(potential_images) == 0:
43
+ raise Exception('No valid images (.png, .tif, .tiff, .jpeg, .jpg) in target folder %s' % args.img)
44
+ else:
45
+ print('%s valid images found' % len(potential_images))
46
+ args.subimage_paths = potential_images
47
 
48
  # if no weights file, try using the default weights.pt
49
  if not args.weights:
 
54
  else:
55
  raise Exception('No weights file specified and default weights.pt not found in script directory')
56
 
57
+ # for /XY00/ subdirectories, we require a valid key
58
+ # ensure that either a key is specified, or if a single .csv exists in the target dir, use that
59
+ if args.img_mode == 'keyence':
60
+ if args.key:
61
+ args.keypath = Path(args.key)
62
+ if not arg.keypath.exists():
63
+ raise Exception('Specified key file does not exist: %s' % args.keypath)
64
+ if args.keypath.suffix != '.csv':
65
+ raise Exception("Specified key file is not a .csv: %s" % args.keypath)
 
 
 
66
  else:
67
+ print('Running on /XY00/ subdirectories but no key specified. Looking for key file...')
68
+ potential_keys = list(args.imgpath.glob('*.csv'))
69
+ if len(potential_keys) == 0:
70
+ raise Exception("No .csv files found in target folder %s, please check directory" % args.img)
71
+ if len(potential_keys) > 1:
72
+ raise Exception("Multiple .csv files found in target folder %s, please specify which one to use")
 
 
 
 
 
 
73
  else:
74
+ args.keypath = potential_keys[0]
75
+ args.key = str(potential_keys[0])
 
 
 
 
 
 
 
76
 
77
+ # if path to results file is specified, ensure it is .csv
78
+ if args.output:
79
+ args.outpath = Path(args.output)
80
+ if args.outpath.suffix != '.csv':
81
+ raise Exception("Specified output file is not a .csv: %s" % args.outpath)
82
+ else:
83
+ # for XY00 subdirs, name it after the required key file
84
+ # for an image directory, name it after the directory
85
+ if args.img_mode == 'keyence':
86
+ args.output = '%s_eggcounts.csv' % args.keypath.stem
87
  else:
88
+ args.output = '%s_eggcounts.csv' % args.imgpath.stem
89
+ args.outpath = Path(args.output)
 
 
 
 
 
90
 
91
  # finally, check the target dir to save annotated images in
92
  if args.annotated:
 
143
  tmp_filenames = []
144
  # single-image mode
145
  if args.img_mode == 'file':
146
+ # imread then apply model, one-step predict() can't handle TIFF
147
  img = cv2.imread(str(args.imgpath))
148
+ results = model.predict(img, imgsz = 1440, max_det=1000, verbose=False, conf=0.05)
149
  result = results[0]
150
  box_classes = [result.names[int(x)] for x in result.boxes.cls]
151
  # NOTE - filtering by class is not necessary, but would make this easier to extend to multi-class models
 
161
  cv2.imwrite(str(annot_path), annot)
162
  print('Saving annotations to %s...' % str(annot_path))
163
  # multi-image mode, runs differently depending on whether you have /XY00/ subdirectories
164
+ elif args.img_mode in ['dir', 'keyence']:
165
+ subdir_paths = []
166
+ if args.img_mode == 'keyence':
167
  total_subdirs = len(args.subdir_paths)
168
  for i, subdir in enumerate(args.subdir_paths):
169
  # Report progress
 
191
  impath = candidate_img_paths[0]
192
  # get the actual output
193
  img = cv2.imread(str(impath))
194
+ results = model.predict(img, imgsz = 1440, verbose=False, conf=0.05)
195
  result = results[0]
196
  box_classes = [result.names[int(x)] for x in result.boxes.cls]
197
  egg_xy = [x.cpu().numpy().astype(np.int32) for i,x in enumerate(result.boxes.xyxy) if box_classes[i] == 'egg']
 
228
  sys.stdout.flush() # Flush output buffer
229
 
230
  img = cv2.imread(str(impath))
231
+ results = model.predict(img, imgsz = 1440, verbose=False, conf=0.05)
232
  result = results[0]
233
  box_classes = [result.names[int(x)] for x in result.boxes.cls]
234
  egg_xy = [x.cpu().numpy().astype(np.int32) for i,x in enumerate(result.boxes.xyxy) if box_classes[i] == 'egg']
static/script.js CHANGED
@@ -59,12 +59,18 @@ document.addEventListener('DOMContentLoaded', () => {
59
  fileInput.setAttribute('multiple', '');
60
  inputModeHelp.textContent = 'Choose one or more image files for processing';
61
  uploadText.textContent = 'Drag and drop images here or click to browse';
62
- } else {
63
  fileInput.setAttribute('webkitdirectory', '');
64
  fileInput.setAttribute('directory', '');
65
  fileInput.removeAttribute('multiple');
66
  inputModeHelp.textContent = 'Select a folder containing images to process';
67
  uploadText.textContent = 'Click to select a folder containing images';
 
 
 
 
 
 
68
  }
69
  // Clear any existing files
70
  fileInput.value = '';
@@ -75,17 +81,104 @@ document.addEventListener('DOMContentLoaded', () => {
75
  // File Upload Handling
76
  function handleFiles(files) {
77
  const allowedTypes = ['image/png', 'image/jpeg', 'image/jpg', 'image/tiff', 'image/tif'];
78
- const validFiles = Array.from(files).filter(file => {
79
- if (inputMode.value === 'folder') {
80
- return allowedTypes.includes(file.type) &&
81
- file.webkitRelativePath &&
82
- !file.webkitRelativePath.startsWith('.');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
83
  }
84
- return allowedTypes.includes(file.type);
85
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
86
  const invalidFiles = Array.from(files).filter(file => !allowedTypes.includes(file.type));
87
 
88
- if (invalidFiles.length > 0) {
 
89
  logStatus(`Warning: Skipped ${invalidFiles.length} invalid files. Only PNG, JPG, and TIFF are supported.`);
90
  invalidFiles.forEach(file => {
91
  logStatus(`- Skipped: ${file.name} (invalid type: ${file.type || 'unknown'})`);
@@ -169,14 +262,17 @@ document.addEventListener('DOMContentLoaded', () => {
169
  });
170
 
171
  function updateUploadState(validFileCount) {
172
- const files = fileInput.files;
173
- if (!files || files.length === 0) {
174
- uploadText.textContent = inputMode.value === 'folder'
175
- ? 'Click to select a folder containing images'
176
- : 'Drag and drop images here or click to browse';
 
 
 
 
177
  startProcessingBtn.disabled = true;
178
  } else {
179
- // Show the count here
180
  uploadText.textContent = `${validFileCount} image${validFileCount === 1 ? '' : 's'} selected`;
181
  startProcessingBtn.disabled = validFileCount === 0;
182
  }
@@ -195,7 +291,8 @@ document.addEventListener('DOMContentLoaded', () => {
195
 
196
  // Processing
197
  startProcessingBtn.addEventListener('click', async () => {
198
- const files = fileInput.files;
 
199
  if (!files || files.length === 0) {
200
  logStatus('Error: No files selected.');
201
  return;
@@ -284,7 +381,7 @@ document.addEventListener('DOMContentLoaded', () => {
284
 
285
  const totalImages = Array.from(fileInput.files).filter(file => {
286
  const allowedTypes = ['image/png', 'image/jpeg', 'image/jpg', 'image/tiff', 'image/tif'];
287
- if (inputMode.value === 'folder') {
288
  return allowedTypes.includes(file.type) &&
289
  file.webkitRelativePath &&
290
  !file.webkitRelativePath.startsWith('.');
@@ -311,7 +408,7 @@ document.addEventListener('DOMContentLoaded', () => {
311
  updateProgress(data.progress || 0, data.status);
312
 
313
  // Update image counter based on progress percentage
314
- if (data.status === 'running' || data.status === 'starting') {
315
  const processedImages = Math.floor((data.progress / 90) * totalImages); // 90 is the max progress before completion
316
  imageCounter.textContent = `${processedImages} of ${totalImages} images`;
317
  }
@@ -378,7 +475,7 @@ document.addEventListener('DOMContentLoaded', () => {
378
  progressText.textContent = message;
379
 
380
  // Clear the image counter when not processing
381
- if (message === 'Ready to process' || message === 'Processing complete' || message.startsWith('Error')) {
382
  imageCounter.textContent = '';
383
  }
384
  }
@@ -922,7 +1019,187 @@ document.addEventListener('DOMContentLoaded', () => {
922
  imageInfo.innerHTML = infoText;
923
  }
924
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
925
  // Initialize
926
  updateUploadState();
927
  logStatus('Application ready');
928
- });
 
59
  fileInput.setAttribute('multiple', '');
60
  inputModeHelp.textContent = 'Choose one or more image files for processing';
61
  uploadText.textContent = 'Drag and drop images here or click to browse';
62
+ } else if (mode === 'folder') {
63
  fileInput.setAttribute('webkitdirectory', '');
64
  fileInput.setAttribute('directory', '');
65
  fileInput.removeAttribute('multiple');
66
  inputModeHelp.textContent = 'Select a folder containing images to process';
67
  uploadText.textContent = 'Click to select a folder containing images';
68
+ } else if (mode === 'keyence') {
69
+ fileInput.setAttribute('webkitdirectory', '');
70
+ fileInput.setAttribute('directory', '');
71
+ fileInput.removeAttribute('multiple');
72
+ inputModeHelp.textContent = 'Select a Keyence output folder. Folder should have subdirectories of format XY01, XY02, etc.';
73
+ uploadText.textContent = 'Click to select a Keyence output folder';
74
  }
75
  // Clear any existing files
76
  fileInput.value = '';
 
81
  // File Upload Handling
82
  function handleFiles(files) {
83
  const allowedTypes = ['image/png', 'image/jpeg', 'image/jpg', 'image/tiff', 'image/tif'];
84
+ // --- Keyence folder validation ---
85
+ if (inputMode.value === 'keyence') {
86
+ // Collect all unique first-level subdirectory names from webkitRelativePath
87
+ const subdirs = new Set();
88
+ Array.from(files).forEach(file => {
89
+ if (file.webkitRelativePath) {
90
+ const parts = file.webkitRelativePath.split('/');
91
+ if (parts.length > 2) { // Parent/Subdir/File
92
+ subdirs.add(parts[1]);
93
+ }
94
+ }
95
+ });
96
+ // Check for at least one subdir matching XY[0-9]{2}
97
+ const xyPattern = /^XY\d{2}$/;
98
+ const hasXY = Array.from(subdirs).some(name => xyPattern.test(name));
99
+ if (!hasXY) {
100
+ // Print the first 5 values in subdirs for debugging
101
+ const subdirArr = Array.from(subdirs).slice(0, 5);
102
+ logStatus('First 5 subdirectories found: ' + (subdirArr.length ? subdirArr.join(', ') : '[none]'));
103
+ logStatus('Error: The selected folder does not contain any subdirectory named like "XY01", "XY02", etc. Please select a valid Keyence output folder.');
104
+ fileList.innerHTML = '';
105
+ startProcessingBtn.disabled = true;
106
+ return;
107
  }
108
+ // --- Check for .csv file(s) ---
109
+ const csvFiles = Array.from(files).filter(file => file.name.toLowerCase().endsWith('.csv'));
110
+ if (csvFiles.length === 1) {
111
+ // Show a custom dialog with multiple options
112
+ showSingleCsvDialog(csvFiles[0]);
113
+ return;
114
+ } else if (csvFiles.length === 0) {
115
+ const wantToUpload = window.confirm('No CSV key file was found in the folder. Would you like to supply a CSV key file now?');
116
+ if (wantToUpload) {
117
+ // Create a hidden file input for CSV upload
118
+ let csvInput = document.createElement('input');
119
+ csvInput.type = 'file';
120
+ csvInput.accept = '.csv,.CSV';
121
+ csvInput.style.display = 'none';
122
+ document.body.appendChild(csvInput);
123
+ csvInput.addEventListener('change', function() {
124
+ if (csvInput.files && csvInput.files.length === 1) {
125
+ window.selectedKeyenceCsv = csvInput.files[0];
126
+ logStatus(`CSV key file supplied: ${csvInput.files[0].name}`);
127
+ } else {
128
+ window.selectedKeyenceCsv = null;
129
+ logStatus('No CSV key file supplied.');
130
+ }
131
+ document.body.removeChild(csvInput);
132
+ });
133
+ csvInput.click();
134
+ } else {
135
+ window.selectedKeyenceCsv = null;
136
+ logStatus('No CSV key file will be used.');
137
+ }
138
+ } else {
139
+ // Multiple CSVs found, show a custom dialog for selection
140
+ showCsvSelectionDialog(csvFiles);
141
+ return; // Wait for user selection before proceeding
142
+ }
143
+ }
144
+ let validFiles;
145
+ if (inputMode.value === 'keyence') {
146
+ // Only analyze the first valid image file (alphabetically) in each subdirectory
147
+ const allowedTypes = ['image/png', 'image/jpeg', 'image/jpg', 'image/tiff', 'image/tif'];
148
+ // Map: subdir name -> array of files in that subdir
149
+ const subdirFiles = {};
150
+ Array.from(files).forEach(file => {
151
+ if (file.webkitRelativePath) {
152
+ const parts = file.webkitRelativePath.split('/');
153
+ if (parts.length > 2) { // Parent/Subdir/File
154
+ const subdir = parts[1];
155
+ if (!subdirFiles[subdir]) subdirFiles[subdir] = [];
156
+ if (allowedTypes.includes(file.type) && !file.webkitRelativePath.startsWith('.')) {
157
+ subdirFiles[subdir].push(file);
158
+ }
159
+ }
160
+ }
161
+ });
162
+ // For each subdir, pick the first file alphabetically
163
+ validFiles = Object.values(subdirFiles).map(filesArr => {
164
+ return filesArr.sort((a, b) => a.name.localeCompare(b.name))[0];
165
+ }).filter(Boolean); // Remove undefined
166
+ } else {
167
+ validFiles = Array.from(files).filter(file => {
168
+ if (inputMode.value === 'folder') {
169
+ return allowedTypes.includes(file.type) &&
170
+ file.webkitRelativePath &&
171
+ !file.webkitRelativePath.startsWith('.');
172
+ }
173
+ return allowedTypes.includes(file.type);
174
+ });
175
+ }
176
+ filteredValidFiles = validFiles;
177
+
178
  const invalidFiles = Array.from(files).filter(file => !allowedTypes.includes(file.type));
179
 
180
+ // Only print invalid file warnings if not in Keyence mode
181
+ if (invalidFiles.length > 0 && inputMode.value !== 'keyence') {
182
  logStatus(`Warning: Skipped ${invalidFiles.length} invalid files. Only PNG, JPG, and TIFF are supported.`);
183
  invalidFiles.forEach(file => {
184
  logStatus(`- Skipped: ${file.name} (invalid type: ${file.type || 'unknown'})`);
 
262
  });
263
 
264
  function updateUploadState(validFileCount) {
265
+ // Use filteredValidFiles for enabling/disabling the button
266
+ if (!filteredValidFiles || filteredValidFiles.length === 0) {
267
+ if (inputMode.value === 'folder') {
268
+ uploadText.textContent = 'Click to select a folder containing images';
269
+ } else if (inputMode.value === 'keyence') {
270
+ uploadText.textContent = 'Click to select a Keyence output folder';
271
+ } else {
272
+ uploadText.textContent = 'Drag and drop images here or click to browse';
273
+ }
274
  startProcessingBtn.disabled = true;
275
  } else {
 
276
  uploadText.textContent = `${validFileCount} image${validFileCount === 1 ? '' : 's'} selected`;
277
  startProcessingBtn.disabled = validFileCount === 0;
278
  }
 
291
 
292
  // Processing
293
  startProcessingBtn.addEventListener('click', async () => {
294
+ // Use filteredValidFiles for processing
295
+ const files = filteredValidFiles;
296
  if (!files || files.length === 0) {
297
  logStatus('Error: No files selected.');
298
  return;
 
381
 
382
  const totalImages = Array.from(fileInput.files).filter(file => {
383
  const allowedTypes = ['image/png', 'image/jpeg', 'image/jpg', 'image/tiff', 'image/tif'];
384
+ if (inputMode.value === 'folder' || inputMode.value === 'keyence') {
385
  return allowedTypes.includes(file.type) &&
386
  file.webkitRelativePath &&
387
  !file.webkitRelativePath.startsWith('.');
 
408
  updateProgress(data.progress || 0, data.status);
409
 
410
  // Update image counter based on progress percentage
411
+ if (data.status === 'Running' || data.status === 'Starting') {
412
  const processedImages = Math.floor((data.progress / 90) * totalImages); // 90 is the max progress before completion
413
  imageCounter.textContent = `${processedImages} of ${totalImages} images`;
414
  }
 
475
  progressText.textContent = message;
476
 
477
  // Clear the image counter when not processing
478
+ if (message === 'Please upload an image' || message === 'Processing complete' || message.startsWith('Error')) {
479
  imageCounter.textContent = '';
480
  }
481
  }
 
1019
  imageInfo.innerHTML = infoText;
1020
  }
1021
 
1022
+ // Custom dialog for selecting among multiple CSV files
1023
+ function showCsvSelectionDialog(csvFiles) {
1024
+ // Create overlay
1025
+ const overlay = document.createElement('div');
1026
+ overlay.style.position = 'fixed';
1027
+ overlay.style.top = 0;
1028
+ overlay.style.left = 0;
1029
+ overlay.style.width = '100vw';
1030
+ overlay.style.height = '100vh';
1031
+ overlay.style.background = 'rgba(0,0,0,0.4)';
1032
+ overlay.style.zIndex = 9999;
1033
+ overlay.style.display = 'flex';
1034
+ overlay.style.alignItems = 'center';
1035
+ overlay.style.justifyContent = 'center';
1036
+
1037
+ // Create dialog box
1038
+ const dialog = document.createElement('div');
1039
+ dialog.style.background = '#fff';
1040
+ dialog.style.padding = '24px 32px';
1041
+ dialog.style.borderRadius = '8px';
1042
+ dialog.style.boxShadow = '0 2px 16px rgba(0,0,0,0.2)';
1043
+ dialog.style.minWidth = '320px';
1044
+ dialog.innerHTML = `<h3>Select a CSV key file</h3><p>Multiple CSV files were found. Please select one to use as the key file:</p>`;
1045
+
1046
+ // List CSV files as buttons
1047
+ csvFiles.forEach((file, idx) => {
1048
+ const btn = document.createElement('button');
1049
+ btn.textContent = file.name;
1050
+ btn.style.display = 'block';
1051
+ btn.style.margin = '8px 0';
1052
+ btn.onclick = () => {
1053
+ window.selectedKeyenceCsv = file;
1054
+ logStatus(`CSV key file selected: ${file.name}`);
1055
+ document.body.removeChild(overlay);
1056
+ };
1057
+ dialog.appendChild(btn);
1058
+ });
1059
+
1060
+ // Option: Use a different .csv
1061
+ const otherBtn = document.createElement('button');
1062
+ otherBtn.textContent = 'Use a different .csv';
1063
+ otherBtn.style.display = 'block';
1064
+ otherBtn.style.margin = '16px 0 8px 0';
1065
+ otherBtn.onclick = () => {
1066
+ let csvInput = document.createElement('input');
1067
+ csvInput.type = 'file';
1068
+ csvInput.accept = '.csv,.CSV';
1069
+ csvInput.style.display = 'none';
1070
+ document.body.appendChild(csvInput);
1071
+ csvInput.addEventListener('change', function() {
1072
+ if (csvInput.files && csvInput.files.length === 1) {
1073
+ window.selectedKeyenceCsv = csvInput.files[0];
1074
+ logStatus(`CSV key file supplied: ${csvInput.files[0].name}`);
1075
+ } else {
1076
+ window.selectedKeyenceCsv = null;
1077
+ logStatus('No CSV key file supplied.');
1078
+ }
1079
+ document.body.removeChild(csvInput);
1080
+ document.body.removeChild(overlay);
1081
+ });
1082
+ csvInput.click();
1083
+ };
1084
+ dialog.appendChild(otherBtn);
1085
+
1086
+ // Option: No key file
1087
+ const noneBtn = document.createElement('button');
1088
+ noneBtn.textContent = 'No key file';
1089
+ noneBtn.style.display = 'block';
1090
+ noneBtn.style.margin = '8px 0';
1091
+ noneBtn.onclick = () => {
1092
+ window.selectedKeyenceCsv = null;
1093
+ logStatus('No CSV key file will be used.');
1094
+ document.body.removeChild(overlay);
1095
+ };
1096
+ dialog.appendChild(noneBtn);
1097
+
1098
+ // Cancel button
1099
+ const cancelBtn = document.createElement('button');
1100
+ cancelBtn.textContent = 'Cancel';
1101
+ cancelBtn.style.display = 'block';
1102
+ cancelBtn.style.margin = '8px 0';
1103
+ cancelBtn.onclick = () => {
1104
+ // Do not change selection, just close dialog
1105
+ logStatus('CSV key file selection cancelled.');
1106
+ document.body.removeChild(overlay);
1107
+ };
1108
+ dialog.appendChild(cancelBtn);
1109
+
1110
+ overlay.appendChild(dialog);
1111
+ document.body.appendChild(overlay);
1112
+ }
1113
+
1114
+ // Custom dialog for single CSV file found
1115
+ function showSingleCsvDialog(csvFile) {
1116
+ const overlay = document.createElement('div');
1117
+ overlay.style.position = 'fixed';
1118
+ overlay.style.top = 0;
1119
+ overlay.style.left = 0;
1120
+ overlay.style.width = '100vw';
1121
+ overlay.style.height = '100vh';
1122
+ overlay.style.background = 'rgba(0,0,0,0.4)';
1123
+ overlay.style.zIndex = 9999;
1124
+ overlay.style.display = 'flex';
1125
+ overlay.style.alignItems = 'center';
1126
+ overlay.style.justifyContent = 'center';
1127
+
1128
+ const dialog = document.createElement('div');
1129
+ dialog.style.background = '#fff';
1130
+ dialog.style.padding = '24px 32px';
1131
+ dialog.style.borderRadius = '8px';
1132
+ dialog.style.boxShadow = '0 2px 16px rgba(0,0,0,0.2)';
1133
+ dialog.style.minWidth = '320px';
1134
+ dialog.innerHTML = `<h3>CSV File Found</h3><p>A CSV file named <strong>${csvFile.name}</strong> was found in the folder. Would you like to use this as the key file for this folder?</p>`;
1135
+
1136
+ // Yes button
1137
+ const yesBtn = document.createElement('button');
1138
+ yesBtn.textContent = 'Yes';
1139
+ yesBtn.style.display = 'block';
1140
+ yesBtn.style.margin = '8px 0';
1141
+ yesBtn.onclick = () => {
1142
+ window.selectedKeyenceCsv = csvFile;
1143
+ logStatus(`CSV key file selected: ${csvFile.name}`);
1144
+ document.body.removeChild(overlay);
1145
+ };
1146
+ dialog.appendChild(yesBtn);
1147
+
1148
+ // Use a different .csv file
1149
+ const otherBtn = document.createElement('button');
1150
+ otherBtn.textContent = 'Use a different .csv file';
1151
+ otherBtn.style.display = 'block';
1152
+ otherBtn.style.margin = '8px 0';
1153
+ otherBtn.onclick = () => {
1154
+ let csvInput = document.createElement('input');
1155
+ csvInput.type = 'file';
1156
+ csvInput.accept = '.csv,.CSV';
1157
+ csvInput.style.display = 'none';
1158
+ document.body.appendChild(csvInput);
1159
+ csvInput.addEventListener('change', function() {
1160
+ if (csvInput.files && csvInput.files.length === 1) {
1161
+ window.selectedKeyenceCsv = csvInput.files[0];
1162
+ logStatus(`CSV key file supplied: ${csvInput.files[0].name}`);
1163
+ } else {
1164
+ window.selectedKeyenceCsv = null;
1165
+ logStatus('No CSV key file supplied.');
1166
+ }
1167
+ document.body.removeChild(csvInput);
1168
+ document.body.removeChild(overlay);
1169
+ });
1170
+ csvInput.click();
1171
+ };
1172
+ dialog.appendChild(otherBtn);
1173
+
1174
+ // Do not use a key file
1175
+ const noneBtn = document.createElement('button');
1176
+ noneBtn.textContent = 'Do not use a key file';
1177
+ noneBtn.style.display = 'block';
1178
+ noneBtn.style.margin = '8px 0';
1179
+ noneBtn.onclick = () => {
1180
+ window.selectedKeyenceCsv = null;
1181
+ logStatus('No CSV key file will be used.');
1182
+ document.body.removeChild(overlay);
1183
+ };
1184
+ dialog.appendChild(noneBtn);
1185
+
1186
+ // Cancel button
1187
+ const cancelBtn = document.createElement('button');
1188
+ cancelBtn.textContent = 'Cancel';
1189
+ cancelBtn.style.display = 'block';
1190
+ cancelBtn.style.margin = '8px 0';
1191
+ cancelBtn.onclick = () => {
1192
+ // Do not change selection, just close dialog
1193
+ logStatus('CSV key file selection cancelled.');
1194
+ document.body.removeChild(overlay);
1195
+ };
1196
+ dialog.appendChild(cancelBtn);
1197
+
1198
+ overlay.appendChild(dialog);
1199
+ document.body.appendChild(overlay);
1200
+ }
1201
+
1202
  // Initialize
1203
  updateUploadState();
1204
  logStatus('Application ready');
1205
+ });
templates/index.html CHANGED
@@ -12,7 +12,7 @@
12
  <i class="ri-microscope-line"></i>
13
  NemaQuant
14
  <small style="display: block; font-size: 1rem; font-weight: normal; color: var(--text-muted);">
15
- Automated Nematode Egg Detection and Counting
16
  </small>
17
  </h1>
18
 
@@ -26,6 +26,7 @@
26
  <select id="input-mode" name="input-mode" class="form-control">
27
  <option value="files">Select Image(s)</option>
28
  <option value="folder">Select Folder</option>
 
29
  </select>
30
  <small class="input-help" style="color: var(--text-muted); margin-top: 0.25rem; display: block;">
31
  <span id="input-mode-help">Choose one or more image files for processing</span>
@@ -50,11 +51,11 @@
50
  <div class="form-group">
51
  <label for="confidence-threshold">
52
  Confidence Threshold
53
- <i class="ri-information-line" data-tooltip="Recommended range: 0.5-0.7; higher values reduce false detections but may miss eggs, lower values catch more eggs but may include false positives"></i>
54
  </label>
55
  <div class="range-with-value">
56
  <input type="range" id="confidence-threshold" name="confidence-threshold"
57
- min="0" max="1" step="0.05" value="0.6">
58
  <span id="confidence-value">0.6</span>
59
  </div>
60
  </div>
 
12
  <i class="ri-microscope-line"></i>
13
  NemaQuant
14
  <small style="display: block; font-size: 1rem; font-weight: normal; color: var(--text-muted);">
15
+ Automated nematode egg detection and counting
16
  </small>
17
  </h1>
18
 
 
26
  <select id="input-mode" name="input-mode" class="form-control">
27
  <option value="files">Select Image(s)</option>
28
  <option value="folder">Select Folder</option>
29
+ <option value="keyence">Keyence Output Folder</option>
30
  </select>
31
  <small class="input-help" style="color: var(--text-muted); margin-top: 0.25rem; display: block;">
32
  <span id="input-mode-help">Choose one or more image files for processing</span>
 
51
  <div class="form-group">
52
  <label for="confidence-threshold">
53
  Confidence Threshold
54
+ <i class="ri-information-line" data-tooltip="Recommended range: 0.4 - 0.7. Higher values produce fewer false positives but more false negatives, while lower values produce fewer false negatives but more false positives."></i>
55
  </label>
56
  <div class="range-with-value">
57
  <input type="range" id="confidence-threshold" name="confidence-threshold"
58
+ min="0.05" max="0.95" step="0.05" value="0.6">
59
  <span id="confidence-value">0.6</span>
60
  </div>
61
  </div>