GitHub Actions commited on
Commit
6e79243
·
1 Parent(s): 85022a6

deploy: sync from GitHub 618f20bc48b85000312050d45345eefcae49b3c6

Browse files
Files changed (6) hide show
  1. .gitattributes +2 -32
  2. Dockerfile +9 -10
  3. README.md +52 -15
  4. app.py +34 -14
  5. requirements.txt +2 -3
  6. static/script.js +112 -125
.gitattributes CHANGED
@@ -1,35 +1,5 @@
1
- *.7z filter=lfs diff=lfs merge=lfs -text
2
- *.arrow filter=lfs diff=lfs merge=lfs -text
3
  *.bin filter=lfs diff=lfs merge=lfs -text
4
- *.bz2 filter=lfs diff=lfs merge=lfs -text
5
- *.ckpt filter=lfs diff=lfs merge=lfs -text
6
- *.ftz filter=lfs diff=lfs merge=lfs -text
7
- *.gz filter=lfs diff=lfs merge=lfs -text
8
  *.h5 filter=lfs diff=lfs merge=lfs -text
9
- *.joblib filter=lfs diff=lfs merge=lfs -text
10
- *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
- *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
- *.model filter=lfs diff=lfs merge=lfs -text
13
- *.msgpack filter=lfs diff=lfs merge=lfs -text
14
- *.npy filter=lfs diff=lfs merge=lfs -text
15
- *.npz filter=lfs diff=lfs merge=lfs -text
16
  *.onnx filter=lfs diff=lfs merge=lfs -text
17
- *.ot filter=lfs diff=lfs merge=lfs -text
18
- *.parquet filter=lfs diff=lfs merge=lfs -text
19
- *.pb filter=lfs diff=lfs merge=lfs -text
20
- *.pickle filter=lfs diff=lfs merge=lfs -text
21
- *.pkl filter=lfs diff=lfs merge=lfs -text
22
- *.pt filter=lfs diff=lfs merge=lfs -text
23
- *.pth filter=lfs diff=lfs merge=lfs -text
24
- *.rar filter=lfs diff=lfs merge=lfs -text
25
- *.safetensors filter=lfs diff=lfs merge=lfs -text
26
- saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
- *.tar.* filter=lfs diff=lfs merge=lfs -text
28
- *.tar filter=lfs diff=lfs merge=lfs -text
29
- *.tflite filter=lfs diff=lfs merge=lfs -text
30
- *.tgz filter=lfs diff=lfs merge=lfs -text
31
- *.wasm filter=lfs diff=lfs merge=lfs -text
32
- *.xz filter=lfs diff=lfs merge=lfs -text
33
- *.zip filter=lfs diff=lfs merge=lfs -text
34
- *.zst filter=lfs diff=lfs merge=lfs -text
35
- *tfevents* filter=lfs diff=lfs merge=lfs -text
 
1
+ *.pt filter=lfs diff=lfs merge=lfs -text
2
+ *.pth filter=lfs diff=lfs merge=lfs -text
3
  *.bin filter=lfs diff=lfs merge=lfs -text
 
 
 
 
4
  *.h5 filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
5
  *.onnx filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
Dockerfile CHANGED
@@ -1,14 +1,10 @@
1
- # Use an official Python runtime as a parent image
2
- FROM python:3.12
3
- # FROM nvidia/cuda:12.2.0-runtime-ubuntu22.04
4
 
5
  # run updates before switching over to non-root user
6
  RUN apt-get update && apt-get install -y --no-install-recommends \
7
- libgl1 \
8
  libglib2.0-0 \
9
- libsm6 \
10
- libxrender1 \
11
- libxext6 \
12
  && rm -rf /var/lib/apt/lists/*
13
 
14
  # add new user with ID 1000 to avoid permission issues on HF spaces
@@ -17,7 +13,7 @@ USER user
17
 
18
  # Set home to user's home dir and add local bin to PATH
19
  ENV HOME=/home/user \
20
- PATH=/user/user/.local/bin:$PATH
21
 
22
  # Set the working directory in the container
23
  WORKDIR $HOME/app
@@ -25,7 +21,10 @@ WORKDIR $HOME/app
25
  # Try and run pip command after setting the user with `USER user` to avoid permission issues with Python
26
  # NOTE - this is from the HF Spaces docs, not sure if necessary
27
  COPY --chown=user ./requirements.txt .
28
- RUN pip install --no-cache-dir --upgrade -r requirements.txt
 
 
 
29
 
30
  # Copy the current directory contents into the container at $HOME/app setting the owner to the user
31
  COPY --chown=user . $HOME/app
@@ -40,7 +39,7 @@ COPY --chown=user . $HOME/app
40
  RUN mkdir -p uploads results annotated .yolo_config
41
 
42
  # set the env var for YOLO user config directory
43
- ENV YOLO_CONFIG_DIR=.yolo_config
44
 
45
  # Copy the rest of the application code into the container at /app
46
  # This includes app.py, nemaquant.py, templates/, static/, etc.
 
1
+ # CPU image - use Dockerfile.gpu for GPU support
2
+ FROM python:3.12.13-slim-trixie
3
+ # Cache bust: 2026-03-19
4
 
5
  # run updates before switching over to non-root user
6
  RUN apt-get update && apt-get install -y --no-install-recommends \
 
7
  libglib2.0-0 \
 
 
 
8
  && rm -rf /var/lib/apt/lists/*
9
 
10
  # add new user with ID 1000 to avoid permission issues on HF spaces
 
13
 
14
  # Set home to user's home dir and add local bin to PATH
15
  ENV HOME=/home/user \
16
+ PATH=/home/user/.local/bin:$PATH
17
 
18
  # Set the working directory in the container
19
  WORKDIR $HOME/app
 
21
  # Try and run pip command after setting the user with `USER user` to avoid permission issues with Python
22
  # NOTE - this is from the HF Spaces docs, not sure if necessary
23
  COPY --chown=user ./requirements.txt .
24
+ RUN pip install --no-cache-dir torch==2.7.1 torchvision --index-url https://download.pytorch.org/whl/cpu
25
+ RUN pip install --no-cache-dir --only-binary :all: -r requirements.txt
26
+ # Force headless opencv after ultralytics (which pulls in full opencv-python as a dependency)
27
+ RUN pip install --no-cache-dir --force-reinstall opencv-python-headless==4.13.0.92
28
 
29
  # Copy the current directory contents into the container at $HOME/app setting the owner to the user
30
  COPY --chown=user . $HOME/app
 
39
  RUN mkdir -p uploads results annotated .yolo_config
40
 
41
  # set the env var for YOLO user config directory
42
+ ENV YOLO_CONFIG_DIR=$HOME/app/.yolo_config
43
 
44
  # Copy the rest of the application code into the container at /app
45
  # This includes app.py, nemaquant.py, templates/, static/, etc.
README.md CHANGED
@@ -4,6 +4,7 @@ emoji: 🔬
4
  colorFrom: indigo
5
  colorTo: blue
6
  sdk: docker
 
7
  license: apache-2.0
8
  short_description: "YOLO-based nematode egg detection with real-time processing"
9
  tags:
@@ -64,14 +65,14 @@ Process 500 images for:
64
 
65
  ## Technical Requirements
66
 
67
- - Python 3.9+
68
  - Key Dependencies:
69
- - Flask >= 2.0.0
70
- - PyTorch >= 2.0.0
71
- - Ultralytics >= 8.0.0
72
- - OpenCV Python >= 4.7.0
73
- - Pandas >= 1.5.0
74
- - NumPy >= 1.21.0
75
 
76
  ## Setup and Deployment
77
 
@@ -92,22 +93,62 @@ Process 500 images for:
92
  - Place your `weights.pt` file in the root directory
93
  - Ensure it's a compatible YOLO model trained for egg detection
94
 
95
- 4. **Run the Application**:
 
 
 
 
 
 
96
  ```bash
97
  python app.py
98
  ```
99
  The application will be available at `http://localhost:7860`
100
 
101
- ### Docker Deployment
102
 
103
  1. **Build the Container**:
 
 
 
 
 
 
 
 
 
 
 
 
 
104
  ```bash
105
- docker build -t nemaquant-flask .
 
 
 
 
 
 
 
 
106
  ```
107
 
 
 
108
  2. **Run the Container**:
 
 
 
109
  ```bash
110
- docker run -p 7860:7860 -v $(pwd)/results:/app/results nemaquant-flask
 
 
 
 
 
 
 
 
111
  ```
112
 
113
  ### Hugging Face Spaces Deployment
@@ -184,7 +225,3 @@ Process 500 images for:
184
  - Time of day (free tier performance varies with overall platform usage)
185
 
186
  For most users, the free tier is sufficient for small to medium batches (< 200 images), while the CPU upgrade offers a good balance of cost and performance for larger datasets. GPU options are recommended only for time-sensitive processing of large batches or when processing thousands of images.
187
-
188
- ## License
189
-
190
- [Specify your license here]
 
4
  colorFrom: indigo
5
  colorTo: blue
6
  sdk: docker
7
+ dockerfile: Dockerfile.gpu
8
  license: apache-2.0
9
  short_description: "YOLO-based nematode egg detection with real-time processing"
10
  tags:
 
65
 
66
  ## Technical Requirements
67
 
68
+ - Python 3.11+
69
  - Key Dependencies:
70
+ - Flask = 3.1.1
71
+ - PyTorch = 2.7.1
72
+ - Ultralytics = 8.3.170
73
+ - OpenCV Python = 4.12.0.88
74
+ - Pandas = 2.3.1
75
+ - NumPy = 2.2.6
76
 
77
  ## Setup and Deployment
78
 
 
93
  - Place your `weights.pt` file in the root directory
94
  - Ensure it's a compatible YOLO model trained for egg detection
95
 
96
+ 4. **Setup Environment**:
97
+ ```bash
98
+ mkdir -p uploads results annotated .yolo_config
99
+ export YOLO_CONFIG_DIR="$PWD/.yolo_config"
100
+ ```
101
+
102
+ 5. **Run the Application**:
103
  ```bash
104
  python app.py
105
  ```
106
  The application will be available at `http://localhost:7860`
107
 
108
+ ### Container Deployment
109
 
110
  1. **Build the Container**:
111
+
112
+ DEfault image `breedinginsight/nemaquant` is exclusive for **CPU usage**.
113
+ For **GPU usage** replace the image by: `breedinginsight/nemaquant:latest-gpu`
114
+
115
+ - With Docker
116
+
117
+ ```bash
118
+ docker pull breedinginsight/nemaquant
119
+ ```
120
+
121
+
122
+ - With Apptainer/Singularity + Slurm from a server:
123
+
124
  ```bash
125
+ # 1) On the login node: pull the image once (creates a .sif file)
126
+ apptainer pull nemaquant_latest.sif docker://breedinginsight/nemaquant:latest
127
+
128
+ # 2) Request an interactive compute allocation (adjust for your cluster and analysis)
129
+ salloc -c 4 --mem=16G --time=02:00:00
130
+
131
+ # 3) On the compute node shell that opens, run the app on port 7860
132
+ export PORT=7860
133
+ apptainer run --cleanenv --env PORT=$PORT nemaquant_latest.sif
134
  ```
135
 
136
+ For **GPU usage** replace the image by: `breedinginsight/nemaquant:latest-gpu`
137
+
138
  2. **Run the Container**:
139
+
140
+ - With Docker
141
+
142
  ```bash
143
+ docker run -p 7860:7860 -v $(pwd)/results:/app/results breedinginsight/nemaquant
144
+ ```
145
+
146
+ - With Apptainer/Singularity + Slurm from our local computer (after running the above commands on server):
147
+
148
+ ```bash
149
+ # Replace user and host with your cluster login node.
150
+ # If your cluster requires a direct tunnel to the compute node, adapt accordingly.
151
+ ssh -L 7860:localhost:7860 [userID]@[yourcluster.address]
152
  ```
153
 
154
  ### Hugging Face Spaces Deployment
 
225
  - Time of day (free tier performance varies with overall platform usage)
226
 
227
  For most users, the free tier is sufficient for small to medium batches (< 200 images), while the CPU upgrade offers a good balance of cost and performance for larger datasets. GPU options are recommended only for time-sensitive processing of large batches or when processing thousands of images.
 
 
 
 
app.py CHANGED
@@ -33,20 +33,26 @@ log = logging.getLogger('werkzeug')
33
  log.setLevel(logging.ERROR)
34
 
35
  APP_ROOT = Path(__file__).parent
36
- UPLOAD_FOLDER = APP_ROOT / 'uploads'
37
- RESULTS_FOLDER = APP_ROOT / 'results'
38
- ANNOT_FOLDER = APP_ROOT / 'annotated'
 
 
 
 
 
39
  WEIGHTS_FILE = APP_ROOT / 'weights.pt'
40
  app.config['UPLOAD_FOLDER'] = str(UPLOAD_FOLDER)
41
  app.config['RESULTS_FOLDER'] = str(RESULTS_FOLDER)
42
- app.config['WEIGHTS_FILE'] = str(WEIGHTS_FILE)
43
  app.config['ANNOT_FOLDER'] = str(ANNOT_FOLDER)
 
44
  app.config['ALLOWED_EXTENSIONS'] = {'png', 'jpg', 'jpeg', 'tif', 'tiff'}
45
 
46
- # skip these -- created dirs in dockerfile
47
- # UPLOAD_FOLDER.mkdir(parents=True, exist_ok=True)
48
- # RESULTS_FOLDER.mkdir(parents=True, exist_ok=True)
49
- # ANNOT_FOLDER.mkdir(parents=True, exist_ok=True)
 
50
 
51
  # Load model once at startup, use CUDA if available
52
  MODEL_DEVICE = 'cuda' if cuda.is_available() else 'cpu'
@@ -235,6 +241,7 @@ def get_progress():
235
  with open(pkl_file, 'rb') as pf:
236
  all_results[uuid_base] = pickle.load(pf)
237
  resp['results'] = all_results
 
238
  return jsonify(resp)
239
 
240
  # If still processing, update progress
@@ -283,7 +290,6 @@ def annotate_image():
283
 
284
  if not img_name:
285
  return jsonify({'error': 'File not found'}), 404
286
-
287
  # Load detections from pickle
288
  result_path = Path(app.config['RESULTS_FOLDER']) / session_id / f"{uuid}.pkl"
289
  if not result_path.exists():
@@ -367,17 +373,31 @@ def export_csv():
367
  try:
368
  data = request.json
369
  session_id = session['id']
370
- threshold = float(data.get('confidence', 0.5))
371
  job_state = session.get('job_state')
 
 
372
  if not job_state:
373
  return jsonify({'error': 'Job not found'}), 404
 
 
 
 
 
 
 
 
 
 
 
374
  rows = []
375
- for orig_name, detections in job_state['detections'].items():
376
- count = sum(1 for d in detections if d['score'] >= threshold)
377
- rows.append({'Filename': orig_name, 'EggsDetected': count})
 
 
378
  timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
379
  output = io.StringIO()
380
- writer = csv.DictWriter(output, fieldnames=['Filename', 'EggsDetected'])
381
  writer.writeheader()
382
  writer.writerows(rows)
383
  output.seek(0)
 
33
  log.setLevel(logging.ERROR)
34
 
35
  APP_ROOT = Path(__file__).parent
36
+ # On HF Spaces, the app filesystem overlay makes app-root dirs unreliable across requests.
37
+ # Use /tmp (RAM-backed tmpfs, always writable) when running on HF Spaces.
38
+ # Locally, use app root so files persist across container restarts.
39
+ _ON_HF_SPACES = os.environ.get('SPACE_ID') is not None
40
+ _data_root = Path('/tmp/nemaquant') if _ON_HF_SPACES else APP_ROOT
41
+ UPLOAD_FOLDER = _data_root / 'uploads'
42
+ RESULTS_FOLDER = _data_root / 'results'
43
+ ANNOT_FOLDER = _data_root / 'annotated'
44
  WEIGHTS_FILE = APP_ROOT / 'weights.pt'
45
  app.config['UPLOAD_FOLDER'] = str(UPLOAD_FOLDER)
46
  app.config['RESULTS_FOLDER'] = str(RESULTS_FOLDER)
 
47
  app.config['ANNOT_FOLDER'] = str(ANNOT_FOLDER)
48
+ app.config['WEIGHTS_FILE'] = str(WEIGHTS_FILE)
49
  app.config['ALLOWED_EXTENSIONS'] = {'png', 'jpg', 'jpeg', 'tif', 'tiff'}
50
 
51
+ # Create dirs at startup
52
+ UPLOAD_FOLDER.mkdir(parents=True, exist_ok=True)
53
+ RESULTS_FOLDER.mkdir(parents=True, exist_ok=True)
54
+ ANNOT_FOLDER.mkdir(parents=True, exist_ok=True)
55
+ print(f"Running on HF Spaces: {_ON_HF_SPACES} | Data root: {_data_root}")
56
 
57
  # Load model once at startup, use CUDA if available
58
  MODEL_DEVICE = 'cuda' if cuda.is_available() else 'cpu'
 
241
  with open(pkl_file, 'rb') as pf:
242
  all_results[uuid_base] = pickle.load(pf)
243
  resp['results'] = all_results
244
+ print(f"Job executed successfully! {len(all_results)} results aggregated.")
245
  return jsonify(resp)
246
 
247
  # If still processing, update progress
 
290
 
291
  if not img_name:
292
  return jsonify({'error': 'File not found'}), 404
 
293
  # Load detections from pickle
294
  result_path = Path(app.config['RESULTS_FOLDER']) / session_id / f"{uuid}.pkl"
295
  if not result_path.exists():
 
373
  try:
374
  data = request.json
375
  session_id = session['id']
 
376
  job_state = session.get('job_state')
377
+ filename_map = session.get('filename_map')
378
+ threshold = float(data.get('confidence', 0.5))
379
  if not job_state:
380
  return jsonify({'error': 'Job not found'}), 404
381
+
382
+ # iterate through the results
383
+ results_dir = Path(app.config['RESULTS_FOLDER']) / session_id
384
+ pkl_paths = list(results_dir.glob('*.pkl'))
385
+ all_results = {}
386
+ for path in pkl_paths:
387
+ uuid_base = path.stem
388
+ with open(path, 'rb') as pf:
389
+ all_results[uuid_base] = pickle.load(pf)
390
+
391
+ # populate rows for CSV conversion
392
  rows = []
393
+ for uuid in all_results.keys():
394
+ count = sum(1 for d in all_results[uuid] if d['score'] >= threshold)
395
+ rows.append({'Filename': filename_map[uuid], 'EggsDetected': count, 'ConfidenceThreshold': threshold})
396
+ rows = sorted(rows, key=lambda x: x['Filename'].lower())
397
+ # write the CSV out
398
  timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
399
  output = io.StringIO()
400
+ writer = csv.DictWriter(output, fieldnames=['Filename', 'EggsDetected', 'ConfidenceThreshold'])
401
  writer.writeheader()
402
  writer.writerows(rows)
403
  output.seek(0)
requirements.txt CHANGED
@@ -1,10 +1,9 @@
1
  Flask==3.1.1
2
  numpy==2.2.6
3
- opencv_python==4.12.0.88
4
  pandas==2.3.1
5
  Pillow==11.3.0
6
  torch==2.7.1
7
  ultralytics==8.3.170
8
  watchdog==6.0.0
9
- Werkzeug==3.1.3
10
- gunicorn==21.2.0
 
1
  Flask==3.1.1
2
  numpy==2.2.6
3
+ opencv_python-headless==4.13.0.92
4
  pandas==2.3.1
5
  Pillow==11.3.0
6
  torch==2.7.1
7
  ultralytics==8.3.170
8
  watchdog==6.0.0
9
+ Werkzeug==3.1.3
 
static/script.js CHANGED
@@ -118,29 +118,8 @@ document.addEventListener('DOMContentLoaded', () => {
118
  showSingleCsvDialog(csvFiles[0]);
119
  return;
120
  } else if (csvFiles.length === 0) {
121
- const wantToUpload = window.confirm('No CSV key file was found in the folder. Would you like to supply a CSV key file now?');
122
- if (wantToUpload) {
123
- // Create a hidden file input for CSV upload
124
- let csvInput = document.createElement('input');
125
- csvInput.type = 'file';
126
- csvInput.accept = '.csv,.CSV';
127
- csvInput.style.display = 'none';
128
- document.body.appendChild(csvInput);
129
- csvInput.addEventListener('change', function() {
130
- if (csvInput.files && csvInput.files.length === 1) {
131
- window.selectedKeyenceCsv = csvInput.files[0];
132
- logStatus(`CSV key file supplied: ${csvInput.files[0].name}`);
133
- } else {
134
- window.selectedKeyenceCsv = null;
135
- logStatus('No CSV key file supplied.');
136
- }
137
- document.body.removeChild(csvInput);
138
- });
139
- csvInput.click();
140
- } else {
141
- window.selectedKeyenceCsv = null;
142
- logStatus('No CSV key file will be used.');
143
- }
144
  } else {
145
  // Multiple CSVs found, show a custom dialog for selection
146
  showCsvSelectionDialog(csvFiles);
@@ -1310,8 +1289,7 @@ document.addEventListener('DOMContentLoaded', () => {
1310
  }
1311
 
1312
  // Custom dialog for selecting among multiple CSV files
1313
- function showCsvSelectionDialog(csvFiles) {
1314
- // Create overlay
1315
  const overlay = document.createElement('div');
1316
  overlay.style.position = 'fixed';
1317
  overlay.style.top = 0;
@@ -1324,77 +1302,93 @@ document.addEventListener('DOMContentLoaded', () => {
1324
  overlay.style.alignItems = 'center';
1325
  overlay.style.justifyContent = 'center';
1326
 
1327
- // Create dialog box
1328
  const dialog = document.createElement('div');
1329
  dialog.style.background = '#fff';
1330
  dialog.style.padding = '24px 32px';
1331
  dialog.style.borderRadius = '8px';
1332
  dialog.style.boxShadow = '0 2px 16px rgba(0,0,0,0.2)';
1333
  dialog.style.minWidth = '320px';
1334
- 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>`;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1335
 
1336
  // List CSV files as buttons
1337
  csvFiles.forEach((file, idx) => {
1338
- const btn = document.createElement('button');
1339
- btn.textContent = file.name;
1340
- btn.style.display = 'block';
1341
- btn.style.margin = '8px 0';
1342
- btn.onclick = () => {
1343
  window.selectedKeyenceCsv = file;
1344
  logStatus(`CSV key file selected: ${file.name}`);
1345
  document.body.removeChild(overlay);
1346
- };
1347
  dialog.appendChild(btn);
1348
  });
1349
 
1350
  // Option: Use a different .csv
1351
- const otherBtn = document.createElement('button');
1352
- otherBtn.textContent = 'Use a different .csv';
1353
- otherBtn.style.display = 'block';
1354
- otherBtn.style.margin = '16px 0 8px 0';
1355
- otherBtn.onclick = () => {
1356
- let csvInput = document.createElement('input');
1357
- csvInput.type = 'file';
1358
- csvInput.accept = '.csv,.CSV';
1359
- csvInput.style.display = 'none';
1360
- document.body.appendChild(csvInput);
1361
- csvInput.addEventListener('change', function() {
1362
- if (csvInput.files && csvInput.files.length === 1) {
1363
- window.selectedKeyenceCsv = csvInput.files[0];
1364
- logStatus(`CSV key file supplied: ${csvInput.files[0].name}`);
1365
- } else {
1366
- window.selectedKeyenceCsv = null;
1367
- logStatus('No CSV key file supplied.');
1368
- }
1369
- document.body.removeChild(csvInput);
1370
  document.body.removeChild(overlay);
1371
  });
1372
- csvInput.click();
1373
- };
1374
  dialog.appendChild(otherBtn);
1375
 
1376
  // Option: No key file
1377
- const noneBtn = document.createElement('button');
1378
- noneBtn.textContent = 'No key file';
1379
- noneBtn.style.display = 'block';
1380
- noneBtn.style.margin = '8px 0';
1381
- noneBtn.onclick = () => {
1382
  window.selectedKeyenceCsv = null;
1383
  logStatus('No CSV key file will be used.');
1384
  document.body.removeChild(overlay);
1385
- };
1386
  dialog.appendChild(noneBtn);
1387
 
1388
  // Cancel button
1389
- const cancelBtn = document.createElement('button');
1390
- cancelBtn.textContent = 'Cancel';
1391
- cancelBtn.style.display = 'block';
1392
- cancelBtn.style.margin = '8px 0';
1393
- cancelBtn.onclick = () => {
1394
  // Do not change selection, just close dialog
1395
  logStatus('CSV key file selection cancelled.');
1396
  document.body.removeChild(overlay);
1397
- };
1398
  dialog.appendChild(cancelBtn);
1399
 
1400
  overlay.appendChild(dialog);
@@ -1403,86 +1397,79 @@ document.addEventListener('DOMContentLoaded', () => {
1403
 
1404
  // Custom dialog for single CSV file found
1405
  function showSingleCsvDialog(csvFile) {
1406
- const overlay = document.createElement('div');
1407
- overlay.style.position = 'fixed';
1408
- overlay.style.top = 0;
1409
- overlay.style.left = 0;
1410
- overlay.style.width = '100vw';
1411
- overlay.style.height = '100vh';
1412
- overlay.style.background = 'rgba(0,0,0,0.4)';
1413
- overlay.style.zIndex = 9999;
1414
- overlay.style.display = 'flex';
1415
- overlay.style.alignItems = 'center';
1416
- overlay.style.justifyContent = 'center';
1417
-
1418
- const dialog = document.createElement('div');
1419
- dialog.style.background = '#fff';
1420
- dialog.style.padding = '24px 32px';
1421
- dialog.style.borderRadius = '8px';
1422
- dialog.style.boxShadow = '0 2px 16px rgba(0,0,0,0.2)';
1423
- dialog.style.minWidth = '320px';
1424
- 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>`;
1425
 
1426
  // Yes button
1427
- const yesBtn = document.createElement('button');
1428
- yesBtn.textContent = 'Yes';
1429
- yesBtn.style.display = 'block';
1430
- yesBtn.style.margin = '8px 0';
1431
- yesBtn.onclick = () => {
1432
  window.selectedKeyenceCsv = csvFile;
1433
  logStatus(`CSV key file selected: ${csvFile.name}`);
1434
  document.body.removeChild(overlay);
1435
- };
1436
  dialog.appendChild(yesBtn);
1437
 
1438
  // Use a different .csv file
1439
- const otherBtn = document.createElement('button');
1440
- otherBtn.textContent = 'Use a different .csv file';
1441
- otherBtn.style.display = 'block';
1442
- otherBtn.style.margin = '8px 0';
1443
- otherBtn.onclick = () => {
1444
- let csvInput = document.createElement('input');
1445
- csvInput.type = 'file';
1446
- csvInput.accept = '.csv,.CSV';
1447
- csvInput.style.display = 'none';
1448
- document.body.appendChild(csvInput);
1449
- csvInput.addEventListener('change', function() {
1450
- if (csvInput.files && csvInput.files.length === 1) {
1451
- window.selectedKeyenceCsv = csvInput.files[0];
1452
- logStatus(`CSV key file supplied: ${csvInput.files[0].name}`);
1453
- } else {
1454
- window.selectedKeyenceCsv = null;
1455
- logStatus('No CSV key file supplied.');
1456
- }
1457
- document.body.removeChild(csvInput);
1458
  document.body.removeChild(overlay);
1459
  });
1460
- csvInput.click();
1461
- };
1462
  dialog.appendChild(otherBtn);
1463
 
1464
  // Do not use a key file
1465
- const noneBtn = document.createElement('button');
1466
- noneBtn.textContent = 'Do not use a key file';
1467
- noneBtn.style.display = 'block';
1468
- noneBtn.style.margin = '8px 0';
1469
- noneBtn.onclick = () => {
1470
  window.selectedKeyenceCsv = null;
1471
  logStatus('No CSV key file will be used.');
1472
  document.body.removeChild(overlay);
1473
- };
1474
  dialog.appendChild(noneBtn);
1475
 
1476
  // Cancel button
1477
- const cancelBtn = document.createElement('button');
1478
- cancelBtn.textContent = 'Cancel';
1479
- cancelBtn.style.display = 'block';
1480
- cancelBtn.style.margin = '8px 0';
1481
- cancelBtn.onclick = () => {
1482
  // Do not change selection, just close dialog
1483
  logStatus('CSV key file selection cancelled.');
1484
  document.body.removeChild(overlay);
1485
- };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1486
  dialog.appendChild(cancelBtn);
1487
 
1488
  overlay.appendChild(dialog);
@@ -1501,4 +1488,4 @@ document.addEventListener('DOMContentLoaded', () => {
1501
  confidenceSlider.value = 0.6;
1502
  confidenceSlider.dispatchEvent(new Event('input', { bubbles: true }));
1503
  confidenceSlider.dispatchEvent(new Event('change', { bubbles: true }));
1504
- });
 
118
  showSingleCsvDialog(csvFiles[0]);
119
  return;
120
  } else if (csvFiles.length === 0) {
121
+ showNoCsvDialog();
122
+ return;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
123
  } else {
124
  // Multiple CSVs found, show a custom dialog for selection
125
  showCsvSelectionDialog(csvFiles);
 
1289
  }
1290
 
1291
  // Custom dialog for selecting among multiple CSV files
1292
+ function createDialogOverlay(titleText, messageContent) {
 
1293
  const overlay = document.createElement('div');
1294
  overlay.style.position = 'fixed';
1295
  overlay.style.top = 0;
 
1302
  overlay.style.alignItems = 'center';
1303
  overlay.style.justifyContent = 'center';
1304
 
 
1305
  const dialog = document.createElement('div');
1306
  dialog.style.background = '#fff';
1307
  dialog.style.padding = '24px 32px';
1308
  dialog.style.borderRadius = '8px';
1309
  dialog.style.boxShadow = '0 2px 16px rgba(0,0,0,0.2)';
1310
  dialog.style.minWidth = '320px';
1311
+ const title = document.createElement('h3');
1312
+ title.textContent = titleText;
1313
+ const message = document.createElement('p');
1314
+ if (typeof messageContent === 'string') {
1315
+ message.textContent = messageContent;
1316
+ } else if (messageContent) {
1317
+ message.appendChild(messageContent);
1318
+ }
1319
+ dialog.appendChild(title);
1320
+ dialog.appendChild(message);
1321
+
1322
+ return { overlay, dialog };
1323
+ }
1324
+
1325
+ function createDialogButton(label, onClick, margin = '8px 0') {
1326
+ const btn = document.createElement('button');
1327
+ btn.textContent = label;
1328
+ btn.style.display = 'block';
1329
+ btn.style.margin = margin;
1330
+ btn.onclick = onClick;
1331
+ return btn;
1332
+ }
1333
+
1334
+ function openCsvPicker(onComplete) {
1335
+ let csvInput = document.createElement('input');
1336
+ csvInput.type = 'file';
1337
+ csvInput.accept = '.csv,.CSV';
1338
+ csvInput.style.display = 'none';
1339
+ document.body.appendChild(csvInput);
1340
+ csvInput.addEventListener('change', function() {
1341
+ if (csvInput.files && csvInput.files.length === 1) {
1342
+ window.selectedKeyenceCsv = csvInput.files[0];
1343
+ logStatus(`CSV key file supplied: ${csvInput.files[0].name}`);
1344
+ } else {
1345
+ window.selectedKeyenceCsv = null;
1346
+ logStatus('No CSV key file supplied.');
1347
+ }
1348
+ document.body.removeChild(csvInput);
1349
+ if (onComplete) onComplete();
1350
+ });
1351
+ csvInput.click();
1352
+ }
1353
+
1354
+ function showCsvSelectionDialog(csvFiles) {
1355
+ const { overlay, dialog } = createDialogOverlay(
1356
+ 'Select a CSV key file',
1357
+ 'Multiple CSV files were found. Please select one to use as the key file:'
1358
+ );
1359
 
1360
  // List CSV files as buttons
1361
  csvFiles.forEach((file, idx) => {
1362
+ const btn = createDialogButton(file.name, () => {
 
 
 
 
1363
  window.selectedKeyenceCsv = file;
1364
  logStatus(`CSV key file selected: ${file.name}`);
1365
  document.body.removeChild(overlay);
1366
+ });
1367
  dialog.appendChild(btn);
1368
  });
1369
 
1370
  // Option: Use a different .csv
1371
+ const otherBtn = createDialogButton('Use a different .csv', () => {
1372
+ openCsvPicker(() => {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1373
  document.body.removeChild(overlay);
1374
  });
1375
+ }, '16px 0 8px 0');
 
1376
  dialog.appendChild(otherBtn);
1377
 
1378
  // Option: No key file
1379
+ const noneBtn = createDialogButton('No key file', () => {
 
 
 
 
1380
  window.selectedKeyenceCsv = null;
1381
  logStatus('No CSV key file will be used.');
1382
  document.body.removeChild(overlay);
1383
+ });
1384
  dialog.appendChild(noneBtn);
1385
 
1386
  // Cancel button
1387
+ const cancelBtn = createDialogButton('Cancel', () => {
 
 
 
 
1388
  // Do not change selection, just close dialog
1389
  logStatus('CSV key file selection cancelled.');
1390
  document.body.removeChild(overlay);
1391
+ });
1392
  dialog.appendChild(cancelBtn);
1393
 
1394
  overlay.appendChild(dialog);
 
1397
 
1398
  // Custom dialog for single CSV file found
1399
  function showSingleCsvDialog(csvFile) {
1400
+ const messageFragment = document.createDocumentFragment();
1401
+ messageFragment.append('A CSV file named ');
1402
+ const fileNameStrong = document.createElement('strong');
1403
+ fileNameStrong.textContent = csvFile.name;
1404
+ messageFragment.appendChild(fileNameStrong);
1405
+ messageFragment.append(' was found in the folder. Would you like to use this as the key file for this folder?');
1406
+
1407
+ const { overlay, dialog } = createDialogOverlay(
1408
+ 'CSV File Found',
1409
+ messageFragment
1410
+ );
 
 
 
 
 
 
 
 
1411
 
1412
  // Yes button
1413
+ const yesBtn = createDialogButton('Yes', () => {
 
 
 
 
1414
  window.selectedKeyenceCsv = csvFile;
1415
  logStatus(`CSV key file selected: ${csvFile.name}`);
1416
  document.body.removeChild(overlay);
1417
+ });
1418
  dialog.appendChild(yesBtn);
1419
 
1420
  // Use a different .csv file
1421
+ const otherBtn = createDialogButton('Use a different .csv file', () => {
1422
+ openCsvPicker(() => {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1423
  document.body.removeChild(overlay);
1424
  });
1425
+ });
 
1426
  dialog.appendChild(otherBtn);
1427
 
1428
  // Do not use a key file
1429
+ const noneBtn = createDialogButton('Do not use a key file', () => {
 
 
 
 
1430
  window.selectedKeyenceCsv = null;
1431
  logStatus('No CSV key file will be used.');
1432
  document.body.removeChild(overlay);
1433
+ });
1434
  dialog.appendChild(noneBtn);
1435
 
1436
  // Cancel button
1437
+ const cancelBtn = createDialogButton('Cancel', () => {
 
 
 
 
1438
  // Do not change selection, just close dialog
1439
  logStatus('CSV key file selection cancelled.');
1440
  document.body.removeChild(overlay);
1441
+ });
1442
+ dialog.appendChild(cancelBtn);
1443
+
1444
+ overlay.appendChild(dialog);
1445
+ document.body.appendChild(overlay);
1446
+ }
1447
+
1448
+ // Custom dialog for no CSV file found
1449
+ function showNoCsvDialog() {
1450
+ const { overlay, dialog } = createDialogOverlay(
1451
+ 'No CSV Key File Found',
1452
+ 'No CSV key file was found in the folder. Would you like to supply one now?'
1453
+ );
1454
+
1455
+ const uploadBtn = createDialogButton('Upload a CSV key file', () => {
1456
+ openCsvPicker(() => {
1457
+ document.body.removeChild(overlay);
1458
+ });
1459
+ });
1460
+ dialog.appendChild(uploadBtn);
1461
+
1462
+ const noneBtn = createDialogButton('No key file', () => {
1463
+ window.selectedKeyenceCsv = null;
1464
+ logStatus('No CSV key file will be used.');
1465
+ document.body.removeChild(overlay);
1466
+ });
1467
+ dialog.appendChild(noneBtn);
1468
+
1469
+ const cancelBtn = createDialogButton('Cancel', () => {
1470
+ logStatus('CSV key file selection cancelled.');
1471
+ document.body.removeChild(overlay);
1472
+ });
1473
  dialog.appendChild(cancelBtn);
1474
 
1475
  overlay.appendChild(dialog);
 
1488
  confidenceSlider.value = 0.6;
1489
  confidenceSlider.dispatchEvent(new Event('input', { bubbles: true }));
1490
  confidenceSlider.dispatchEvent(new Event('change', { bubbles: true }));
1491
+ });