JunhanCai commited on
Commit
c48bf8f
·
1 Parent(s): 6918c6b

Fix directory structure

Browse files
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
webserver/Dockerfile DELETED
@@ -1,18 +0,0 @@
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"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
webserver/app.py DELETED
@@ -1,663 +0,0 @@
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, "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))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
webserver/requirements.txt DELETED
@@ -1,8 +0,0 @@
1
- fastapi
2
- uvicorn
3
- python-multipart
4
- jinja2
5
- numpy
6
- torch
7
- scikit-learn
8
- matplotlib