JunhanCai commited on
Commit
706d281
·
1 Parent(s): 449bb60

Fix directory structure

Browse files
Files changed (3) hide show
  1. Dockerfile +18 -0
  2. app.py +663 -0
  3. requirements.txt +16 -0
Dockerfile ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.12-slim
2
+
3
+ WORKDIR /webserver
4
+
5
+ COPY requirements.txt .
6
+
7
+ RUN pip install --no-cache-dir -r requirements.txt
8
+
9
+ # 5. 安全设置:创建一个普通用户运行程序(Hugging Face 推荐)
10
+ RUN useradd -m -u 1000 user
11
+ USER user
12
+ ENV PATH="/home/user/.local/bin:$PATH"
13
+
14
+ # 6. 搬运代码:把当前文件夹所有代码复制到电脑里
15
+ COPY --chown=user . /webserver
16
+
17
+ # 7. 启动:按下“开机键”
18
+ CMD ["uvicorn", "webserver:app", "--host", "0.0.0.0", "--port", "7860"]
app.py ADDED
@@ -0,0 +1,663 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import multiprocessing
3
+ import signal
4
+ import shutil
5
+ import uuid
6
+ import re
7
+ from datetime import datetime
8
+ from pathlib import Path
9
+ from threading import Thread
10
+ from typing import Optional
11
+
12
+ import matplotlib
13
+ matplotlib.use("Agg")
14
+ import matplotlib.pyplot as plt
15
+ import numpy as np
16
+ import json
17
+ import torch
18
+ from fastapi import FastAPI, File, Form, HTTPException, Request, UploadFile
19
+ from fastapi.responses import FileResponse, JSONResponse, RedirectResponse
20
+ from fastapi.templating import Jinja2Templates
21
+
22
+ from webserver.train_service import TrainConfig, predict_with_checkpoint, run_finetune_job
23
+ from webserver.label_utils import load_label_mapping, apply_label_mapping
24
+
25
+ BASE_DIR = os.path.dirname(__file__)
26
+ UPLOAD_DIR = os.path.join(BASE_DIR, "uploads")
27
+ RUNS_DIR = os.path.join(BASE_DIR, "runs")
28
+ PREDICTIONS_DIR = os.path.join(BASE_DIR, "predictions")
29
+ TEMPLATE_DIR = os.path.join(BASE_DIR, "webserver/templates")
30
+
31
+ os.makedirs(UPLOAD_DIR, exist_ok=True)
32
+ os.makedirs(RUNS_DIR, exist_ok=True)
33
+ os.makedirs(PREDICTIONS_DIR, exist_ok=True)
34
+
35
+ app = FastAPI(title="Raman Fine-Tune Webserver")
36
+ templates = Jinja2Templates(directory=TEMPLATE_DIR)
37
+
38
+ if multiprocessing.current_process().name == "MainProcess":
39
+ JOB_MANAGER = multiprocessing.Manager()
40
+ JOBS = JOB_MANAGER.dict()
41
+ else:
42
+ JOB_MANAGER = None
43
+ JOBS = {}
44
+ JOB_PROCESSES = {}
45
+ JOB_CONTEXT = multiprocessing.get_context("spawn")
46
+
47
+
48
+ def _save_upload(file_obj: UploadFile, dst_path: str):
49
+ with open(dst_path, "wb") as out:
50
+ shutil.copyfileobj(file_obj.file, out)
51
+
52
+
53
+ def _load_report_text(report_path: str):
54
+ if not os.path.isfile(report_path):
55
+ return None
56
+ with open(report_path, "r", encoding="utf-8") as f:
57
+ return f.read()
58
+
59
+
60
+ def _build_artifact_entries(base_dir: str, artifact_map: dict, route_prefix: str):
61
+ entries = []
62
+ for key, filename in artifact_map.items():
63
+ file_path = os.path.join(base_dir, filename)
64
+ if not os.path.isfile(file_path):
65
+ continue
66
+ entries.append(
67
+ {
68
+ "key": key,
69
+ "filename": filename,
70
+ "url": f"/{route_prefix}/{os.path.basename(base_dir)}/{filename}",
71
+ "is_image": filename.lower().endswith((".png", ".jpg", ".jpeg", ".webp", ".gif")),
72
+ "is_text": filename.lower().endswith((".txt", ".json", ".csv")),
73
+ }
74
+ )
75
+ return entries
76
+
77
+
78
+ def _safe_result_file(root_dir: str, item_id: str, filename: str):
79
+ safe_name = os.path.basename(filename)
80
+ folder = os.path.join(root_dir, item_id)
81
+ file_path = os.path.join(folder, safe_name)
82
+ if not os.path.isfile(file_path):
83
+ raise HTTPException(status_code=404, detail="File not found")
84
+ return file_path
85
+
86
+
87
+ def _safe_uploaded_name(filename: str) -> str:
88
+ safe_name = os.path.basename(filename or "")
89
+ if not safe_name:
90
+ raise HTTPException(status_code=400, detail="Uploaded file is missing a filename")
91
+ return safe_name
92
+
93
+
94
+ def _is_optional_file(upload: Optional[UploadFile]) -> bool:
95
+ return upload is None or not getattr(upload, "filename", "") or not str(upload.filename).strip()
96
+
97
+
98
+ def _is_blank_upload(upload: Optional[UploadFile]) -> bool:
99
+ return upload is None or not getattr(upload, "filename", "") or not str(upload.filename).strip()
100
+
101
+
102
+ def _render_predict_results_fragment(
103
+ prediction_id: str,
104
+ summary: dict,
105
+ rows: list[dict],
106
+ top5_rows: list[dict],
107
+ download_csv: str,
108
+ preview_image: str,
109
+ ):
110
+ return templates.env.get_template("predict_result_fragment.html").render(
111
+ prediction_id=prediction_id,
112
+ summary=summary,
113
+ rows=rows,
114
+ top5_rows=top5_rows,
115
+ download_csv=download_csv,
116
+ preview_image=preview_image,
117
+ )
118
+
119
+
120
+ def _parse_numeric_text_file(file_path: str) -> np.ndarray:
121
+ rows = []
122
+ with open(file_path, "r", encoding="utf-8", errors="ignore") as f:
123
+ for raw_line in f:
124
+ line = raw_line.strip()
125
+ if not line or line.startswith("#"):
126
+ continue
127
+ tokens = [token for token in re.split(r"[\s,]+", line) if token]
128
+ values = []
129
+ for token in tokens:
130
+ try:
131
+ values.append(float(token))
132
+ except ValueError:
133
+ continue
134
+ if values:
135
+ rows.append(values)
136
+
137
+ if not rows:
138
+ raise ValueError("No numeric data found in text file")
139
+
140
+ max_cols = max(len(row) for row in rows)
141
+ if max_cols == 1:
142
+ return np.asarray([row[0] for row in rows], dtype=np.float32)
143
+
144
+ return np.asarray([[row[0], row[1]] for row in rows if len(row) >= 2], dtype=np.float32)
145
+
146
+
147
+ def _load_prediction_spectrum(file_path: str) -> tuple[np.ndarray, Optional[np.ndarray], str]:
148
+ extension = os.path.splitext(file_path)[1].lower()
149
+ if extension in {".txt", ".csv"}:
150
+ data = _parse_numeric_text_file(file_path)
151
+ if data.ndim == 1:
152
+ spectra = data.astype(np.float32).reshape(1, -1)
153
+ return spectra, None, "text_intensity_only"
154
+
155
+ if data.ndim == 2 and data.shape[1] >= 2:
156
+ wavenumbers = data[:, 0].astype(np.float32)
157
+ spectra = data[:, 1].astype(np.float32).reshape(1, -1)
158
+ return spectra, wavenumbers, "text_wavenumber_intensity"
159
+
160
+ raise ValueError("Text spectrum must contain either one intensity column or two columns: wavenumber, intensity")
161
+
162
+ if extension == ".npy":
163
+ spectra = np.load(file_path, allow_pickle=True)
164
+ return np.asarray(spectra, dtype=np.float32), None, "npy"
165
+
166
+ raise ValueError("Spectrum file must be .txt, .csv, or .npy")
167
+
168
+
169
+ def _load_prediction_wavenumbers(file_path: str) -> np.ndarray:
170
+ extension = os.path.splitext(file_path)[1].lower()
171
+ if extension in {".txt", ".csv"}:
172
+ data = _parse_numeric_text_file(file_path)
173
+ if data.ndim == 1:
174
+ return data.astype(np.float32).reshape(-1)
175
+ if data.ndim == 2 and data.shape[1] >= 1:
176
+ return data[:, 0].astype(np.float32).reshape(-1)
177
+ raise ValueError("Wavelength text file must contain one numeric column")
178
+
179
+ if extension == ".npy":
180
+ return np.asarray(np.load(file_path, allow_pickle=True), dtype=np.float32).reshape(-1)
181
+
182
+ raise ValueError("Wavelength file must be .txt, .csv, or .npy")
183
+
184
+
185
+ def _build_manual_wavenumbers(length: int, low_cm: float, high_cm: float) -> np.ndarray:
186
+ if low_cm is None or high_cm is None:
187
+ raise ValueError("Manual wavelength range requires both low and high values")
188
+ if high_cm <= low_cm:
189
+ raise ValueError("Manual wavelength range high value must be greater than low value")
190
+ return np.linspace(float(low_cm), float(high_cm), int(length), dtype=np.float32)
191
+
192
+
193
+ def _save_prediction_preview(prediction_dir: str, target_wavenumbers: np.ndarray, processed_spectra: np.ndarray) -> str:
194
+ spectra = np.asarray(processed_spectra, dtype=np.float32)
195
+ wavenumbers = np.asarray(target_wavenumbers, dtype=np.float32).reshape(-1)
196
+ if spectra.ndim != 2 or spectra.shape[1] != wavenumbers.shape[0]:
197
+ raise ValueError("processed spectra and wavenumbers must have matching 2D/1D shapes")
198
+
199
+ sample_count = spectra.shape[0]
200
+ preview_count = min(sample_count, 6)
201
+ fig, ax = plt.subplots(figsize=(8, 4.5))
202
+ for idx in range(preview_count):
203
+ label = f"Sample {idx + 1}" if sample_count > 1 else "Input spectrum"
204
+ ax.plot(wavenumbers, spectra[idx], linewidth=1.0, alpha=0.9, label=label)
205
+
206
+ ax.set_title(f"Input Spectra Preview ({sample_count} sample{'s' if sample_count != 1 else ''})")
207
+ ax.set_xlabel("Wavenumber (cm$^{-1}$)")
208
+ ax.set_ylabel("Normalized intensity")
209
+ ax.set_xlim(float(wavenumbers.min()), float(wavenumbers.max()))
210
+ ax.grid(True, linestyle="--", alpha=0.3)
211
+ if preview_count > 1:
212
+ ax.legend(frameon=False, fontsize=8)
213
+ fig.tight_layout()
214
+
215
+ preview_path = os.path.join(prediction_dir, "input_spectra_preview.png")
216
+ fig.savefig(preview_path, dpi=300, bbox_inches="tight")
217
+ plt.close(fig)
218
+ return preview_path
219
+
220
+
221
+ def _reap_job_process(job_id: str, process: multiprocessing.Process):
222
+ process.join()
223
+ JOB_PROCESSES.pop(job_id, None)
224
+
225
+
226
+ @app.get("/")
227
+ def index(request: Request):
228
+ return templates.TemplateResponse(request, "index.html", {"request": request})
229
+
230
+
231
+ @app.get("/predict")
232
+ def predict_page(request: Request):
233
+ return templates.TemplateResponse(request, "predict.html", {"request": request})
234
+
235
+
236
+ @app.post("/start")
237
+ def start_job(
238
+ request: Request,
239
+ spectral_file: UploadFile = File(...),
240
+ labels_file: UploadFile = File(...),
241
+ wavenumbers_file: UploadFile = File(...),
242
+ model_file: UploadFile = File(...),
243
+ label_mapping_file: Optional[UploadFile] = File(None),
244
+ epochs: int = Form(60),
245
+ lr: float = Form(1e-4),
246
+ weight_decay: float = Form(1e-3),
247
+ patience: int = Form(12),
248
+ batch_size: int = Form(64),
249
+ patch_num: int = Form(100),
250
+ embedding_dim: int = Form(512),
251
+ num_layers: int = Form(12),
252
+ num_heads: int = Form(16),
253
+ freeze_encoder: bool = Form(False),
254
+ label_smoothing: float = Form(0.0),
255
+ ):
256
+ if _is_optional_file(label_mapping_file):
257
+ label_mapping_file = None
258
+
259
+ for f in [spectral_file, labels_file, wavenumbers_file, model_file] + ([label_mapping_file] if label_mapping_file is not None else []):
260
+ if not f.filename.endswith(".npy") and f is not model_file:
261
+ if f is label_mapping_file and os.path.splitext(f.filename)[1].lower() not in {".json", ".txt"}:
262
+ raise HTTPException(status_code=400, detail=f"{f.filename} must be .json or .txt")
263
+ elif f is not label_mapping_file:
264
+ raise HTTPException(status_code=400, detail=f"{f.filename} must be .npy")
265
+ if f is model_file and not f.filename.endswith(".pth"):
266
+ raise HTTPException(status_code=400, detail="Model must be .pth")
267
+
268
+ job_id = datetime.now().strftime("%Y%m%d_%H%M%S") + "_" + uuid.uuid4().hex[:8]
269
+ job_upload_dir = os.path.join(UPLOAD_DIR, job_id)
270
+ job_run_dir = os.path.join(RUNS_DIR, job_id)
271
+ os.makedirs(job_upload_dir, exist_ok=True)
272
+ os.makedirs(job_run_dir, exist_ok=True)
273
+
274
+ spectral_path = os.path.join(job_upload_dir, "spectral.npy")
275
+ labels_path = os.path.join(job_upload_dir, "labels.npy")
276
+ wavenumbers_path = os.path.join(job_upload_dir, "wavenumbers.npy")
277
+ model_path = os.path.join(job_upload_dir, "model.pth")
278
+ label_mapping_path = os.path.join(job_upload_dir, _safe_uploaded_name(label_mapping_file.filename)) if label_mapping_file is not None else None
279
+
280
+ _save_upload(spectral_file, spectral_path)
281
+ _save_upload(labels_file, labels_path)
282
+ _save_upload(wavenumbers_file, wavenumbers_path)
283
+ _save_upload(model_file, model_path)
284
+ if label_mapping_file is not None:
285
+ _save_upload(label_mapping_file, label_mapping_path)
286
+
287
+ config = TrainConfig(
288
+ epochs=epochs,
289
+ lr=lr,
290
+ weight_decay=weight_decay,
291
+ patience=patience,
292
+ batch_size=batch_size,
293
+ patch_num=patch_num,
294
+ embedding_dim=embedding_dim,
295
+ num_layers=num_layers,
296
+ num_heads=num_heads,
297
+ freeze_encoder=freeze_encoder,
298
+ label_smoothing=label_smoothing,
299
+ )
300
+
301
+ input_paths = {
302
+ "spectral": spectral_path,
303
+ "labels": labels_path,
304
+ "wavenumbers": wavenumbers_path,
305
+ "model": model_path,
306
+ "label_mapping": label_mapping_path,
307
+ }
308
+
309
+ JOBS[job_id] = {
310
+ "status": "queued",
311
+ "message": "Job queued",
312
+ "updated_at": datetime.now().isoformat(timespec="seconds"),
313
+ "progress": 0,
314
+ "phase": "queued",
315
+ "current_epoch": 0,
316
+ "total_epochs": epochs,
317
+ "device_label": "Detecting...",
318
+ "device_backend": "",
319
+ "device_name": "",
320
+ }
321
+
322
+ process = JOB_CONTEXT.Process(
323
+ target=run_finetune_job,
324
+ args=(job_id, input_paths, job_run_dir, config, JOBS),
325
+ daemon=False,
326
+ )
327
+ process.start()
328
+ JOB_PROCESSES[job_id] = process
329
+ Thread(target=_reap_job_process, args=(job_id, process), daemon=True).start()
330
+ job_record = dict(JOBS[job_id])
331
+ job_record["pid"] = process.pid
332
+ JOBS[job_id] = job_record
333
+
334
+ if request.headers.get("accept", "").find("application/json") >= 0 or request.headers.get("x-requested-with") == "XMLHttpRequest":
335
+ return JSONResponse({"job_id": job_id, "status_url": f"/status/{job_id}", "stop_url": f"/stop/{job_id}"})
336
+
337
+ return RedirectResponse(url=f"/status/{job_id}", status_code=303)
338
+
339
+
340
+ @app.post("/stop/{job_id}")
341
+ def stop_job(job_id: str):
342
+ if job_id not in JOBS:
343
+ raise HTTPException(status_code=404, detail="Job not found")
344
+
345
+ job = dict(JOBS[job_id])
346
+ if job.get("status") in {"done", "error", "cancelled"}:
347
+ raise HTTPException(status_code=409, detail="Job is already finished")
348
+
349
+ process = JOB_PROCESSES.get(job_id)
350
+ if process is not None:
351
+ if process.is_alive():
352
+ process.terminate()
353
+ process.join(timeout=5)
354
+ if process.is_alive():
355
+ process.kill()
356
+ process.join(timeout=5)
357
+ else:
358
+ pid = job.get("pid")
359
+ if pid:
360
+ try:
361
+ os.kill(int(pid), signal.SIGTERM)
362
+ except ProcessLookupError:
363
+ pass
364
+
365
+ JOBS[job_id] = {
366
+ **job,
367
+ "status": "cancelled",
368
+ "message": "Job cancelled by user",
369
+ "phase": "cancelled",
370
+ "progress": min(int(job.get("progress", 0) or 0), 99),
371
+ "updated_at": datetime.now().isoformat(timespec="seconds"),
372
+ }
373
+ return JSONResponse({"job_id": job_id, "status": "cancelled"})
374
+
375
+
376
+ @app.post("/predict")
377
+ def run_prediction(
378
+ request: Request,
379
+ spectral_file: UploadFile = File(...),
380
+ wavenumbers_file: Optional[UploadFile] = File(None),
381
+ model_file: UploadFile = File(...),
382
+ label_mapping_file: Optional[UploadFile] = File(None),
383
+ manual_low_cm: Optional[float] = Form(None),
384
+ manual_high_cm: Optional[float] = Form(None),
385
+ ):
386
+ if _is_blank_upload(spectral_file):
387
+ raise HTTPException(status_code=400, detail="Please choose a spectral file before running prediction.")
388
+ if _is_blank_upload(model_file):
389
+ raise HTTPException(status_code=400, detail="Please choose a saved model (.pth) before running prediction.")
390
+
391
+ if _is_blank_upload(wavenumbers_file):
392
+ wavenumbers_file = None
393
+
394
+ if _is_optional_file(label_mapping_file):
395
+ label_mapping_file = None
396
+
397
+ spectral_name = _safe_uploaded_name(spectral_file.filename)
398
+ model_name = _safe_uploaded_name(model_file.filename)
399
+ wavenumbers_name = _safe_uploaded_name(wavenumbers_file.filename) if wavenumbers_file is not None else None
400
+ label_mapping_name = _safe_uploaded_name(label_mapping_file.filename) if label_mapping_file is not None else None
401
+
402
+ if os.path.splitext(model_name)[1].lower() != ".pth":
403
+ raise HTTPException(status_code=400, detail="Model file must be .pth")
404
+
405
+ spectral_ext = os.path.splitext(spectral_name)[1].lower()
406
+ if spectral_ext not in {".npy", ".txt", ".csv"}:
407
+ raise HTTPException(status_code=400, detail="Spectral file must be .npy, .txt, or .csv")
408
+
409
+ if wavenumbers_file is not None:
410
+ wavenumbers_ext = os.path.splitext(wavenumbers_name or "")[1].lower()
411
+ if wavenumbers_ext not in {".npy", ".txt", ".csv"}:
412
+ raise HTTPException(status_code=400, detail="Wavelength file must be .npy, .txt, or .csv")
413
+
414
+ if label_mapping_file is not None:
415
+ label_mapping_ext = os.path.splitext(label_mapping_name or "")[1].lower()
416
+ if label_mapping_ext not in {".json", ".txt"}:
417
+ raise HTTPException(status_code=400, detail="True label mapping file must be .json or .txt")
418
+
419
+ prediction_id = datetime.now().strftime("%Y%m%d_%H%M%S") + "_" + uuid.uuid4().hex[:8]
420
+ prediction_dir = os.path.join(PREDICTIONS_DIR, prediction_id)
421
+ os.makedirs(prediction_dir, exist_ok=True)
422
+
423
+ spectral_path = os.path.join(prediction_dir, spectral_name)
424
+ wavenumbers_path = os.path.join(prediction_dir, wavenumbers_name) if wavenumbers_name is not None else None
425
+ model_path = os.path.join(prediction_dir, model_name)
426
+ label_mapping_path = os.path.join(prediction_dir, label_mapping_name) if label_mapping_name is not None else None
427
+
428
+ _save_upload(spectral_file, spectral_path)
429
+ _save_upload(model_file, model_path)
430
+ if wavenumbers_file is not None:
431
+ _save_upload(wavenumbers_file, wavenumbers_path)
432
+ if label_mapping_file is not None:
433
+ _save_upload(label_mapping_file, label_mapping_path)
434
+
435
+ display_label_mapping = None
436
+ if label_mapping_path is not None:
437
+ display_label_mapping = load_label_mapping(label_mapping_path)
438
+
439
+ try:
440
+ spectral, inferred_wavenumbers, spectrum_source = _load_prediction_spectrum(spectral_path)
441
+
442
+ if inferred_wavenumbers is not None:
443
+ wavenumbers = inferred_wavenumbers
444
+ wavenumber_source = "embedded_in_spectrum"
445
+ elif wavenumbers_file is not None:
446
+ wavenumbers = _load_prediction_wavenumbers(wavenumbers_path)
447
+ wavenumber_source = "uploaded_wavelength_file"
448
+ elif manual_low_cm is not None or manual_high_cm is not None:
449
+ if manual_low_cm is None or manual_high_cm is None:
450
+ raise ValueError("Manual wavelength range requires both low and high values")
451
+ wavenumbers = _build_manual_wavenumbers(spectral.shape[-1], manual_low_cm, manual_high_cm)
452
+ wavenumber_source = "manual_range"
453
+ else:
454
+ raise HTTPException(
455
+ status_code=400,
456
+ detail="No wavelength information found in the spectrum file. Upload a wavelength file or provide a manual wavelength range.",
457
+ )
458
+
459
+ if spectral.ndim == 1:
460
+ spectral = spectral.reshape(1, -1)
461
+ if spectral.ndim != 2:
462
+ raise ValueError(f"Spectrum data must be 1D or 2D after loading, got shape {spectral.shape}")
463
+
464
+ preview_path = _save_prediction_preview(prediction_dir, wavenumbers, spectral)
465
+ except ValueError as exc:
466
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
467
+
468
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
469
+
470
+ try:
471
+ results = predict_with_checkpoint(model_path, spectral, wavenumbers, device, display_label_mapping=display_label_mapping)
472
+ except (ValueError, RuntimeError) as exc:
473
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
474
+
475
+ # Ensure we display mapped (human) labels when available. Prefer explicit display mapping
476
+ display_class_names = apply_label_mapping(
477
+ results.get("raw_class_names", results.get("class_names", [])),
478
+ display_label_mapping or results.get("checkpoint_label_mapping"),
479
+ )
480
+
481
+ top5_rows = []
482
+ top5_indices = np.argsort(results["logits"], axis=1)[:, ::-1][:, : min(5, results["logits"].shape[1])]
483
+ top5_logits = np.take_along_axis(results["logits"], top5_indices, axis=1)
484
+ for idx, (indices_row, logits_row) in enumerate(zip(top5_indices, top5_logits), start=1):
485
+ top5_rows.append(
486
+ {
487
+ "sample_index": idx,
488
+ "top5": [
489
+ {
490
+ "rank": rank + 1,
491
+ "class_name": display_class_names[class_idx] if class_idx < len(display_class_names) else str(class_idx),
492
+ "logit": float(logit_value),
493
+ }
494
+ for rank, (class_idx, logit_value) in enumerate(zip(indices_row.tolist(), logits_row.tolist()))
495
+ ],
496
+ }
497
+ )
498
+
499
+ rows = []
500
+ for idx, (pred_index, confidence) in enumerate(
501
+ zip(results["pred_indices"], results["confidences"]),
502
+ start=1,
503
+ ):
504
+ pred_index = int(pred_index)
505
+ pred_label_display = display_class_names[pred_index] if pred_index < len(display_class_names) else str(pred_index)
506
+ rows.append(
507
+ {
508
+ "sample_index": idx,
509
+ "pred_index": pred_index,
510
+ "pred_label": pred_label_display,
511
+ "confidence": float(confidence),
512
+ }
513
+ )
514
+
515
+ csv_path = os.path.join(prediction_dir, "predictions.csv")
516
+ with open(csv_path, "w", encoding="utf-8") as f:
517
+ f.write("sample_index,predicted_index,predicted_label,confidence\n")
518
+ for row in rows:
519
+ f.write(
520
+ f"{row['sample_index']},{row['pred_index']},{row['pred_label']},{row['confidence']:.6f}\n"
521
+ )
522
+
523
+ summary = {
524
+ "prediction_id": prediction_id,
525
+ "num_samples": len(rows),
526
+ "class_names": results["class_names"],
527
+ "raw_class_names": results.get("raw_class_names", []),
528
+ "model_config": results["model_config"],
529
+ "preprocess_config": results["preprocess_config"],
530
+ "download_csv": f"/predictions/{prediction_id}/predictions.csv",
531
+ "preview_image": f"/predictions/{prediction_id}/{os.path.basename(preview_path)}",
532
+ "spectrum_source": spectrum_source,
533
+ "wavenumber_source": wavenumber_source,
534
+ "label_mapping_source": label_mapping_name or ("checkpoint" if results.get("checkpoint_label_mapping") else None),
535
+ }
536
+ with open(os.path.join(prediction_dir, "prediction_summary.json"), "w", encoding="utf-8") as f:
537
+ json.dump(summary, f, indent=2, ensure_ascii=False)
538
+
539
+ if request.headers.get("accept", "").find("application/json") >= 0 or request.headers.get("x-requested-with") == "XMLHttpRequest":
540
+ return JSONResponse(
541
+ {
542
+ "prediction_id": prediction_id,
543
+ "summary": summary,
544
+ "rows": rows,
545
+ "top5_rows": top5_rows,
546
+ "download_csv": summary["download_csv"],
547
+ "preview_image": summary["preview_image"],
548
+ "results_html": _render_predict_results_fragment(
549
+ prediction_id,
550
+ summary,
551
+ rows,
552
+ top5_rows,
553
+ summary["download_csv"],
554
+ summary["preview_image"],
555
+ ),
556
+ }
557
+ )
558
+
559
+ return templates.TemplateResponse(
560
+ request,
561
+ "predict.html",
562
+ {
563
+ "request": request,
564
+ "prediction_id": prediction_id,
565
+ "summary": summary,
566
+ "rows": rows,
567
+ "top5_rows": top5_rows,
568
+ "download_csv": summary["download_csv"],
569
+ "preview_image": summary["preview_image"],
570
+ },
571
+ )
572
+
573
+
574
+ @app.get("/status/{job_id}")
575
+ def status_page(job_id: str, request: Request):
576
+ if job_id not in JOBS:
577
+ raise HTTPException(status_code=404, detail="Job not found")
578
+ job = {
579
+ "status": "queued",
580
+ "message": "Job queued",
581
+ "updated_at": None,
582
+ "progress": 0,
583
+ "phase": "queued",
584
+ "current_epoch": 0,
585
+ "total_epochs": 0,
586
+ "device_label": "Detecting...",
587
+ "device_backend": "",
588
+ "device_name": "",
589
+ **JOBS[job_id],
590
+ }
591
+ if not job.get("total_epochs"):
592
+ job["total_epochs"] = 0
593
+ can_stop = job.get("status") in {"queued", "running"}
594
+ summary = job.get("summary", {}) or {}
595
+ artifact_map = summary.get("artifacts", {}) or {}
596
+ run_dir = os.path.join(RUNS_DIR, job_id)
597
+ report_path = os.path.join(run_dir, artifact_map.get("classification_report", "classification_report.txt"))
598
+
599
+ visual_keys = ["training_history", "tsne", "confusion_matrix"]
600
+ download_keys = [
601
+ "training_history",
602
+ "tsne",
603
+ "confusion_matrix",
604
+ "roc_curves",
605
+ "classification_report",
606
+ "final_model",
607
+ "best_class_model",
608
+ "best_recon_model",
609
+ ]
610
+
611
+ visual_artifacts = []
612
+ download_artifacts = []
613
+ for key in visual_keys + download_keys:
614
+ filename = artifact_map.get(key)
615
+ if not filename:
616
+ continue
617
+ file_path = os.path.join(run_dir, filename)
618
+ if not os.path.isfile(file_path):
619
+ continue
620
+ artifact_info = {
621
+ "key": key,
622
+ "filename": filename,
623
+ "url": f"/runs/{job_id}/{filename}",
624
+ "is_image": filename.lower().endswith((".png", ".jpg", ".jpeg", ".webp", ".gif")),
625
+ }
626
+ if key in visual_keys and artifact_info["is_image"]:
627
+ visual_artifacts.append(artifact_info)
628
+ if key in download_keys:
629
+ download_artifacts.append(artifact_info)
630
+
631
+ return templates.TemplateResponse(
632
+ request,
633
+ "status.html",
634
+ {
635
+ "request": request,
636
+ "job_id": job_id,
637
+ "job": job,
638
+ "summary": summary,
639
+ "can_stop": can_stop,
640
+ "visual_artifacts": visual_artifacts,
641
+ "download_artifacts": download_artifacts,
642
+ "report_text": _load_report_text(report_path),
643
+ },
644
+ )
645
+
646
+
647
+ @app.get("/api/status/{job_id}")
648
+ def status_api(job_id: str):
649
+ if job_id not in JOBS:
650
+ raise HTTPException(status_code=404, detail="Job not found")
651
+ return JOBS[job_id]
652
+
653
+
654
+ @app.get("/runs/{job_id}/{filename}")
655
+ def job_artifact(job_id: str, filename: str):
656
+ file_path = _safe_result_file(RUNS_DIR, job_id, filename)
657
+ return FileResponse(file_path, filename=os.path.basename(file_path))
658
+
659
+
660
+ @app.get("/predictions/{prediction_id}/{filename}")
661
+ def prediction_artifact(prediction_id: str, filename: str):
662
+ file_path = _safe_result_file(PREDICTIONS_DIR, prediction_id, filename)
663
+ return FileResponse(file_path, filename=os.path.basename(file_path))
requirements.txt ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ fastapi
2
+ uvicorn
3
+ python-multipart
4
+ jinja2
5
+ matplotlib==3.10.8
6
+ numpy==2.4.1
7
+ optuna==4.6.0
8
+ pandas==3.0.0
9
+ scikit-learn
10
+ scipy==1.17.0
11
+ seaborn==0.13.2
12
+ timm==1.0.24
13
+ torch==2.9.1+rocm6.4
14
+ torchvision==0.24.1+rocm6.4
15
+ tqdm==4.67.1
16
+ umap-learn==0.5.9.post2