IslamAbdelslam commited on
Commit
41c2fc8
·
unverified ·
1 Parent(s): 31dfcee

update v0.2

Browse files
Files changed (3) hide show
  1. Dockerfile +13 -7
  2. main.py +337 -18
  3. requirements.txt +9 -4
Dockerfile CHANGED
@@ -13,12 +13,15 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
13
  libgomp1 \
14
  && rm -rf /var/lib/apt/lists/*
15
 
16
- # Runtime limits to reduce CPU/RAM pressure on free-tier containers
17
  ENV PYTHONUNBUFFERED=1 \
 
18
  OMP_NUM_THREADS=1 \
19
  MKL_NUM_THREADS=1 \
20
  OPENBLAS_NUM_THREADS=1 \
21
  NUMEXPR_NUM_THREADS=1 \
 
 
22
  MALLOC_ARENA_MAX=2 \
23
  ATEN_CPU_CAPABILITY=default \
24
  MKL_SERVICE_FORCE_INTEL=1
@@ -32,15 +35,18 @@ RUN pip install --no-cache-dir torch==2.5.1 torchvision==0.20.1 --index-url http
32
  # Install pinned fastai
33
  RUN pip install --no-cache-dir fastai==2.8.7
34
 
35
- # Install remaining requirements
36
  RUN pip install --no-cache-dir -r requirements.txt
 
37
 
38
- RUN pip install gdown
39
- # Copy the application code and model
40
  COPY . .
41
 
42
- # Expose port
 
 
 
43
  EXPOSE 7860
44
 
45
- # Run the API with one worker for lower memory footprint
46
- CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860", "--workers", "1", "--timeout-keep-alive", "10"]
 
13
  libgomp1 \
14
  && rm -rf /var/lib/apt/lists/*
15
 
16
+ # Runtime limits to reduce CPU/RAM pressure on small instances
17
  ENV PYTHONUNBUFFERED=1 \
18
+ PYTHONFAULTHANDLER=1 \
19
  OMP_NUM_THREADS=1 \
20
  MKL_NUM_THREADS=1 \
21
  OPENBLAS_NUM_THREADS=1 \
22
  NUMEXPR_NUM_THREADS=1 \
23
+ PREDICT_TIMEOUT_SECONDS=50 \
24
+ MAX_IMAGE_DIM=1024 \
25
  MALLOC_ARENA_MAX=2 \
26
  ATEN_CPU_CAPABILITY=default \
27
  MKL_SERVICE_FORCE_INTEL=1
 
35
  # Install pinned fastai
36
  RUN pip install --no-cache-dir fastai==2.8.7
37
 
38
+ # Install remaining requirements and gdown
39
  RUN pip install --no-cache-dir -r requirements.txt
40
+ RUN pip install --no-cache-dir gdown
41
 
42
+ # Copy the application code
 
43
  COPY . .
44
 
45
+ # Download model from Google Drive into the location expected by main.py
46
+ RUN gdown 1ppniUVWmgfNg_wnLFwx5YA-rk6mYQkMB -O /app/export.pkl
47
+
48
+ # Expose default app port
49
  EXPOSE 7860
50
 
51
+ # Railway uses PORT at runtime; fallback to 7860 locally
52
+ CMD sh -c 'uvicorn main:app --host 0.0.0.0 --port ${PORT:-7860} --workers 1 --timeout-keep-alive 10'
main.py CHANGED
@@ -9,6 +9,10 @@ import shutil
9
  import os
10
  import warnings
11
  import asyncio
 
 
 
 
12
 
13
  # Suppress warnings
14
  warnings.filterwarnings("ignore")
@@ -26,12 +30,21 @@ try:
26
  import torch
27
  from fastai.vision.all import load_learner, PILImage
28
  from PIL import Image, UnidentifiedImageError
 
29
  except ImportError:
30
  raise RuntimeError(
31
- "FastAI is not installed. Please install fastai and torch.")
32
 
33
  MAX_UPLOAD_MB = int(os.getenv("MAX_UPLOAD_MB", "10"))
34
  MAX_UPLOAD_BYTES = MAX_UPLOAD_MB * 1024 * 1024
 
 
 
 
 
 
 
 
35
 
36
  app = FastAPI(title="Pneumonia Detection API")
37
 
@@ -60,12 +73,34 @@ for p in possible_paths:
60
  model_path = p
61
  break
62
 
63
- if model_path is None:
64
- raise FileNotFoundError("Could not find export.pkl.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
65
 
66
- print(f"Loading model from: {model_path}")
67
- learn = load_learner(model_path)
68
- learn.model.eval()
 
 
 
69
 
70
  try:
71
  torch.set_num_threads(1)
@@ -88,16 +123,269 @@ async def root():
88
  "predict": ["/predict"],
89
  "accepted_file_fields": ["file"],
90
  "max_upload_mb": MAX_UPLOAD_MB,
 
 
91
  }
92
 
93
 
94
  @app.get("/health")
95
  async def health():
96
- return {"status": "ok"}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
97
 
98
 
99
  @app.post("/predict")
100
  async def predict(request: Request, file: UploadFile | None = File(default=None)):
 
 
 
 
 
 
 
101
  incoming_file: Any = file
102
  if incoming_file is None:
103
  form = await request.form()
@@ -136,24 +424,48 @@ async def predict(request: Request, file: UploadFile | None = File(default=None)
136
  with Image.open(tmp_path) as raw_img:
137
  raw_img.load()
138
  rgb_img = raw_img.convert("RGB")
139
- if max(rgb_img.size) > 2048:
140
- rgb_img.thumbnail((2048, 2048), Image.Resampling.LANCZOS)
 
141
  rgb_img.save(normalized_path, format="JPEG", quality=95)
142
  except UnidentifiedImageError as e:
143
  raise HTTPException(
144
  status_code=400, detail=f"Invalid image file: {e}")
145
 
146
- img = PILImage.create(normalized_path)
147
  # FastAI progress bars can break in some hosted environments; disable per-call.
148
  async with predict_lock:
149
- with learn.no_bar():
150
- with torch.inference_mode():
151
- pred_label, _, probabilities = learn.predict(img)
152
-
153
- vocab = [str(label).strip() for label in learn.dls.vocab]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
154
  class_probs = {
155
  class_name: float(prob)
156
- for class_name, prob in zip(vocab, probabilities.tolist())
157
  }
158
 
159
  def get_prob(*aliases: str) -> float:
@@ -182,6 +494,8 @@ async def predict(request: Request, file: UploadFile | None = File(default=None)
182
  "chest x-ray image",
183
  "chest xray image",
184
  "chest_xray",
 
 
185
  )
186
  if chest_xray_prob > 0.0:
187
  other_prob = max(0.0, 1.0 - chest_xray_prob)
@@ -210,9 +524,14 @@ async def predict(request: Request, file: UploadFile | None = File(default=None)
210
  except HTTPException:
211
  raise
212
  except Exception as e:
213
- print(f"[predict] error={e}", flush=True)
 
 
 
214
  raise HTTPException(
215
- status_code=500, detail=f"Error predicting: {str(e)}")
 
 
216
  finally:
217
  if normalized_path.exists():
218
  normalized_path.unlink()
 
9
  import os
10
  import warnings
11
  import asyncio
12
+ import logging
13
+ import multiprocessing as mp
14
+ import time
15
+ import traceback
16
 
17
  # Suppress warnings
18
  warnings.filterwarnings("ignore")
 
30
  import torch
31
  from fastai.vision.all import load_learner, PILImage
32
  from PIL import Image, UnidentifiedImageError
33
+ import numpy as np
34
  except ImportError:
35
  raise RuntimeError(
36
+ "Required ML packages are missing. Please install fastai, torch, and numpy.")
37
 
38
  MAX_UPLOAD_MB = int(os.getenv("MAX_UPLOAD_MB", "10"))
39
  MAX_UPLOAD_BYTES = MAX_UPLOAD_MB * 1024 * 1024
40
+ PREDICT_TIMEOUT_SECONDS = float(os.getenv("PREDICT_TIMEOUT_SECONDS", "50"))
41
+ MAX_IMAGE_DIM = int(os.getenv("MAX_IMAGE_DIM", "1024"))
42
+ MODEL_IMAGE_SIZE = int(os.getenv("MODEL_IMAGE_SIZE", "224"))
43
+ CONFIGURED_INFERENCE_START_METHOD = os.getenv("INFERENCE_START_METHOD")
44
+ SAFE_INFERENCE_START_METHOD = os.getenv("SAFE_INFERENCE_START_METHOD", "spawn")
45
+ INFERENCE_CRASH_THRESHOLD = int(os.getenv("INFERENCE_CRASH_THRESHOLD", "2"))
46
+
47
+ logger = logging.getLogger("uvicorn.error")
48
 
49
  app = FastAPI(title="Pneumonia Detection API")
50
 
 
73
  model_path = p
74
  break
75
 
76
+ learn = None
77
+ model_load_error = None
78
+ active_inference_start_method = SAFE_INFERENCE_START_METHOD
79
+ consecutive_inference_crashes = 0
80
+ last_prediction_vocab: list[str] = []
81
+ last_inference_stage: str | None = None
82
+ last_inference_error: str | None = None
83
+
84
+ _MODEL_MEAN = torch.tensor([0.485, 0.456, 0.406],
85
+ dtype=torch.float32).view(1, 3, 1, 1)
86
+ _MODEL_STD = torch.tensor([0.229, 0.224, 0.225],
87
+ dtype=torch.float32).view(1, 3, 1, 1)
88
+
89
+
90
+ def load_model() -> None:
91
+ global model_load_error
92
+
93
+ if model_path is None:
94
+ model_load_error = "Could not find export.pkl."
95
+ logger.error(model_load_error)
96
+ return
97
 
98
+ model_load_error = None
99
+
100
+
101
+ @app.on_event("startup")
102
+ async def startup_event() -> None:
103
+ load_model()
104
 
105
  try:
106
  torch.set_num_threads(1)
 
123
  "predict": ["/predict"],
124
  "accepted_file_fields": ["file"],
125
  "max_upload_mb": MAX_UPLOAD_MB,
126
+ "predict_timeout_seconds": PREDICT_TIMEOUT_SECONDS,
127
+ "max_image_dim": MAX_IMAGE_DIM,
128
  }
129
 
130
 
131
  @app.get("/health")
132
  async def health():
133
+ return {
134
+ "status": "ok",
135
+ "model_loaded": model_load_error is None,
136
+ "model_error": model_load_error,
137
+ }
138
+
139
+
140
+ @app.get("/diag")
141
+ async def diagnostics():
142
+ return {
143
+ "status": "ok",
144
+ "model_loaded": model_load_error is None,
145
+ "model_error": model_load_error,
146
+ "model_path": str(model_path) if model_path is not None else None,
147
+ "vocab": last_prediction_vocab,
148
+ "settings": {
149
+ "max_upload_mb": MAX_UPLOAD_MB,
150
+ "predict_timeout_seconds": PREDICT_TIMEOUT_SECONDS,
151
+ "max_image_dim": MAX_IMAGE_DIM,
152
+ "model_image_size": MODEL_IMAGE_SIZE,
153
+ "configured_inference_start_method": CONFIGURED_INFERENCE_START_METHOD,
154
+ "inference_start_method": active_inference_start_method,
155
+ "safe_inference_start_method": SAFE_INFERENCE_START_METHOD,
156
+ "inference_crash_threshold": INFERENCE_CRASH_THRESHOLD,
157
+ "consecutive_inference_crashes": consecutive_inference_crashes,
158
+ },
159
+ "runtime": {
160
+ "pythonunbuffered": os.getenv("PYTHONUNBUFFERED"),
161
+ "omp_num_threads": os.getenv("OMP_NUM_THREADS"),
162
+ "mkl_num_threads": os.getenv("MKL_NUM_THREADS"),
163
+ "openblas_num_threads": os.getenv("OPENBLAS_NUM_THREADS"),
164
+ "aten_cpu_capability": os.getenv("ATEN_CPU_CAPABILITY"),
165
+ },
166
+ "lock": {
167
+ "predict_lock_locked": predict_lock.locked(),
168
+ },
169
+ "last_inference": {
170
+ "stage": last_inference_stage,
171
+ "error": last_inference_error,
172
+ },
173
+ "versions": {
174
+ "torch": getattr(torch, "__version__", None),
175
+ },
176
+ }
177
+
178
+
179
+ def _predict_from_path(image_path: Path, learner=None):
180
+ # Run all model work in one sync function so it can be moved to a worker thread.
181
+ active_learn = learner or learn
182
+ if active_learn is None:
183
+ raise RuntimeError(model_load_error or "Model is not loaded")
184
+
185
+ with Image.open(image_path) as raw_img:
186
+ rgb_img = raw_img.convert("RGB")
187
+ resized = rgb_img.resize(
188
+ (MODEL_IMAGE_SIZE, MODEL_IMAGE_SIZE), Image.Resampling.BILINEAR)
189
+ arr = np.asarray(resized, dtype=np.float32) / 255.0
190
+ inputs = torch.from_numpy(arr).permute(2, 0, 1).unsqueeze(0)
191
+
192
+ device = next(active_learn.model.parameters()).device
193
+ inputs = inputs.to(device)
194
+ mean = _MODEL_MEAN.to(device)
195
+ std = _MODEL_STD.to(device)
196
+ inputs = (inputs - mean) / std
197
+
198
+ vocab = [str(label).strip() for label in active_learn.dls.vocab]
199
+ with active_learn.no_bar():
200
+ with torch.inference_mode():
201
+ outputs = active_learn.model(inputs)
202
+ if outputs.ndim == 1:
203
+ outputs = outputs.unsqueeze(0)
204
+
205
+ if outputs.shape[-1] == 1:
206
+ positive_prob = torch.sigmoid(outputs)[0].flatten()
207
+ if len(vocab) >= 2:
208
+ probabilities = torch.zeros(
209
+ len(vocab), device=positive_prob.device)
210
+ probabilities[0] = 1 - positive_prob[0]
211
+ probabilities[1] = positive_prob[0]
212
+ else:
213
+ probabilities = torch.stack(
214
+ [1 - positive_prob, positive_prob], dim=0).flatten()
215
+ else:
216
+ probabilities = torch.softmax(outputs, dim=-1)[0]
217
+
218
+ if len(vocab) > 0 and probabilities.numel() != len(vocab):
219
+ if probabilities.numel() < len(vocab):
220
+ padded = torch.zeros(
221
+ len(vocab), device=probabilities.device)
222
+ padded[:probabilities.numel()] = probabilities
223
+ probabilities = padded
224
+ else:
225
+ probabilities = probabilities[:len(vocab)]
226
+
227
+ pred_index = int(torch.argmax(probabilities).item())
228
+ pred_label = vocab[pred_index] if pred_index < len(
229
+ vocab) else str(pred_index)
230
+ return pred_label, pred_index, probabilities, vocab
231
+
232
+
233
+ class InferenceSubprocessCrash(RuntimeError):
234
+ def __init__(self, exit_code: int | None):
235
+ self.exit_code = exit_code
236
+ super().__init__(
237
+ f"Inference subprocess crashed (exit code {exit_code}).")
238
+
239
+
240
+ def _predict_subprocess_worker(image_path: str, model_path_str: str | None, conn) -> None:
241
+ try:
242
+ conn.send({"status": "stage", "stage": "worker_started"})
243
+ local_learn = learn
244
+ if local_learn is None:
245
+ if not model_path_str:
246
+ raise RuntimeError(
247
+ "Model path is missing in subprocess worker")
248
+ local_learn = load_learner(Path(model_path_str))
249
+ local_learn.model.eval()
250
+ conn.send({"status": "stage", "stage": "learner_loaded"})
251
+
252
+ try:
253
+ torch.set_num_threads(1)
254
+ torch.set_num_interop_threads(1)
255
+ torch.backends.mkldnn.enabled = False
256
+ except RuntimeError:
257
+ pass
258
+
259
+ conn.send({"status": "stage", "stage": "inference_preparing"})
260
+ pred_label, _, probabilities, vocab = _predict_from_path(
261
+ Path(image_path),
262
+ local_learn,
263
+ )
264
+ conn.send({"status": "stage", "stage": "inference_finished"})
265
+
266
+ payload = {
267
+ "ok": True,
268
+ "pred_label": str(pred_label),
269
+ "probabilities": probabilities.tolist(),
270
+ "vocab": vocab,
271
+ }
272
+ conn.send(payload)
273
+ except Exception as exc:
274
+ conn.send(
275
+ {
276
+ "ok": False,
277
+ "error_type": type(exc).__name__,
278
+ "error_message": str(exc),
279
+ "error_repr": repr(exc),
280
+ "traceback": traceback.format_exc(),
281
+ }
282
+ )
283
+ finally:
284
+ conn.close()
285
+
286
+
287
+ def _predict_via_subprocess(image_path: Path, timeout_seconds: float, start_method: str):
288
+ global last_inference_stage, last_inference_error
289
+ ctx = mp.get_context(start_method)
290
+ parent_conn, child_conn = ctx.Pipe(duplex=False)
291
+ proc = ctx.Process(
292
+ target=_predict_subprocess_worker,
293
+ args=(str(image_path), str(model_path)
294
+ if model_path is not None else None, child_conn),
295
+ daemon=True,
296
+ )
297
+
298
+ try:
299
+ proc.start()
300
+ child_conn.close()
301
+ deadline = time.monotonic() + timeout_seconds
302
+
303
+ while True:
304
+ if parent_conn.poll(0.2):
305
+ try:
306
+ payload = parent_conn.recv()
307
+ except EOFError:
308
+ if not proc.is_alive():
309
+ last_inference_error = (
310
+ f"Subprocess exited before returning a payload (exit code {proc.exitcode})."
311
+ )
312
+ raise InferenceSubprocessCrash(proc.exitcode)
313
+ raise RuntimeError(
314
+ "Inference subprocess closed its pipe without returning a result."
315
+ )
316
+ proc.join(timeout=1)
317
+ if not isinstance(payload, dict):
318
+ raise RuntimeError(
319
+ f"Unexpected inference payload type: {type(payload).__name__}"
320
+ )
321
+ if payload.get("status") == "stage":
322
+ last_inference_stage = str(payload.get("stage"))
323
+ continue
324
+ if not payload.get("ok"):
325
+ error_type = payload.get(
326
+ "error_type") or "InferenceWorkerError"
327
+ error_message = (
328
+ payload.get("error_message")
329
+ or payload.get("error_repr")
330
+ or "Unknown inference error"
331
+ )
332
+ traceback_text = payload.get("traceback")
333
+ if traceback_text:
334
+ logger.error(
335
+ "Inference worker traceback:\n%s", traceback_text)
336
+ last_inference_error = f"{error_type}: {error_message}"
337
+ raise RuntimeError(f"{error_type}: {error_message}")
338
+ return payload
339
+
340
+ if not proc.is_alive():
341
+ last_inference_error = (
342
+ f"Subprocess exited before returning a payload (exit code {proc.exitcode})."
343
+ )
344
+ raise InferenceSubprocessCrash(proc.exitcode)
345
+
346
+ if time.monotonic() >= deadline:
347
+ raise TimeoutError("Inference subprocess timed out")
348
+ finally:
349
+ if proc.is_alive():
350
+ proc.terminate()
351
+ proc.join(timeout=2)
352
+ parent_conn.close()
353
+
354
+
355
+ def _record_inference_success() -> None:
356
+ global consecutive_inference_crashes
357
+ consecutive_inference_crashes = 0
358
+
359
+
360
+ def _record_inference_crash() -> bool:
361
+ global consecutive_inference_crashes, active_inference_start_method
362
+ consecutive_inference_crashes += 1
363
+ should_switch = (
364
+ active_inference_start_method != SAFE_INFERENCE_START_METHOD
365
+ and consecutive_inference_crashes >= INFERENCE_CRASH_THRESHOLD
366
+ )
367
+ if should_switch:
368
+ logger.warning(
369
+ "Switching inference subprocess mode from %s to %s after %d consecutive crashes",
370
+ active_inference_start_method,
371
+ SAFE_INFERENCE_START_METHOD,
372
+ consecutive_inference_crashes,
373
+ )
374
+ active_inference_start_method = SAFE_INFERENCE_START_METHOD
375
+ consecutive_inference_crashes = 0
376
+ return True
377
+ return False
378
 
379
 
380
  @app.post("/predict")
381
  async def predict(request: Request, file: UploadFile | None = File(default=None)):
382
+ load_model()
383
+ if model_load_error is not None:
384
+ raise HTTPException(
385
+ status_code=503,
386
+ detail=model_load_error or "Model is not available.",
387
+ )
388
+
389
  incoming_file: Any = file
390
  if incoming_file is None:
391
  form = await request.form()
 
424
  with Image.open(tmp_path) as raw_img:
425
  raw_img.load()
426
  rgb_img = raw_img.convert("RGB")
427
+ if max(rgb_img.size) > MAX_IMAGE_DIM:
428
+ rgb_img.thumbnail(
429
+ (MAX_IMAGE_DIM, MAX_IMAGE_DIM), Image.Resampling.LANCZOS)
430
  rgb_img.save(normalized_path, format="JPEG", quality=95)
431
  except UnidentifiedImageError as e:
432
  raise HTTPException(
433
  status_code=400, detail=f"Invalid image file: {e}")
434
 
 
435
  # FastAI progress bars can break in some hosted environments; disable per-call.
436
  async with predict_lock:
437
+ try:
438
+ prediction = await asyncio.to_thread(
439
+ _predict_via_subprocess,
440
+ normalized_path,
441
+ PREDICT_TIMEOUT_SECONDS,
442
+ active_inference_start_method,
443
+ )
444
+ except TimeoutError:
445
+ raise HTTPException(
446
+ status_code=504,
447
+ detail=(
448
+ "Prediction timed out before platform edge timeout. "
449
+ "Try a smaller image or increase resources."
450
+ ),
451
+ )
452
+ except InferenceSubprocessCrash as exc:
453
+ switched = _record_inference_crash()
454
+ detail = f"Inference worker crashed (exit code {exc.exit_code})."
455
+ if switched:
456
+ detail += " Automatically switched to safer inference mode; retry the request."
457
+ raise HTTPException(status_code=503, detail=detail)
458
+
459
+ _record_inference_success()
460
+
461
+ pred_label = prediction["pred_label"]
462
+ probabilities = prediction["probabilities"]
463
+ vocab = prediction["vocab"]
464
+ global last_prediction_vocab
465
+ last_prediction_vocab = vocab
466
  class_probs = {
467
  class_name: float(prob)
468
+ for class_name, prob in zip(vocab, probabilities)
469
  }
470
 
471
  def get_prob(*aliases: str) -> float:
 
494
  "chest x-ray image",
495
  "chest xray image",
496
  "chest_xray",
497
+ "other",
498
+ "Other",
499
  )
500
  if chest_xray_prob > 0.0:
501
  other_prob = max(0.0, 1.0 - chest_xray_prob)
 
524
  except HTTPException:
525
  raise
526
  except Exception as e:
527
+ print(f"[predict] error={type(e).__name__}: {e!r}", flush=True)
528
+ error_message = str(e).strip() or repr(e)
529
+ global last_inference_error
530
+ last_inference_error = f"{type(e).__name__}: {error_message}"
531
  raise HTTPException(
532
+ status_code=500,
533
+ detail=f"Error predicting: {type(e).__name__}: {error_message}",
534
+ )
535
  finally:
536
  if normalized_path.exists():
537
  normalized_path.unlink()
requirements.txt CHANGED
@@ -1,4 +1,9 @@
1
- fastapi
2
- uvicorn
3
- python-multipart
4
- ipython
 
 
 
 
 
 
1
+ fastapi==0.115.6
2
+ uvicorn==0.34.0
3
+ python-multipart==0.0.20
4
+ ipython==8.31.0
5
+ pillow==11.1.0
6
+ torch==2.5.1
7
+ torchvision==0.20.1
8
+ fastai==2.8.7
9
+ scikit-learn==1.3.2