Nj-1111 commited on
Commit
8d62536
·
verified ·
1 Parent(s): 7b9a943

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +160 -204
app.py CHANGED
@@ -1,59 +1,56 @@
1
  """
2
- Glaucoma CDSS — HuggingFace Spaces
3
- Input : retinal fundus image (JPEG / PNG)
4
- Output: plain-text clinical report + downloadable PDF
 
5
  """
6
 
7
- import os
8
- import sys
9
- import tempfile
10
 
11
  import cv2
12
  import numpy as np
13
  import gradio as gr
 
 
14
 
15
  sys.path.insert(0, os.path.dirname(__file__))
16
-
17
  from phase3pipeline import Phase3Pipeline
18
 
19
-
20
- # ── CONFIG ────────────────────────────────────────────────────────────────
21
  REPO_ID = "Nj-1111/EyeeSEE"
22
- EPOCH = None
23
- TOKEN = os.getenv("HF_TOKEN_2") or os.getenv("HF_TOKEN")
24
- # ──────────────────────────────────────────────────────────────────────────
25
-
26
- print(f"Loading model repo={REPO_ID} epoch={'latest' if EPOCH is None else EPOCH}")
27
 
 
28
  pipeline = Phase3Pipeline(
29
- repo_id=REPO_ID,
30
- epoch=EPOCH,
31
- mc_passes=1,
32
- uncertainty_threshold=0.05,
33
- token=TOKEN,
34
  )
35
-
36
  print("Model ready.")
37
 
38
 
39
- # ── IMAGE LOADER ──────────────────────────────────────────────────────────
 
40
  def _load_image(path: str) -> np.ndarray:
41
  img = cv2.imread(path)
42
-
43
  if img is None:
44
  raw = np.fromfile(path, dtype=np.uint8)
45
  img = cv2.imdecode(raw, cv2.IMREAD_COLOR)
46
-
47
  if img is None:
48
- raise ValueError(f"Cannot read image: {path}")
49
-
50
  return img
51
 
52
 
53
- # ── REPORT BUILDER ────────────────────────────────────────────────────────
54
- def _build_text(r: dict, mc_passes: int) -> str:
55
- isnt = r["isnt"]
 
56
 
 
 
 
57
  lines = [
58
  "=" * 52,
59
  " GLAUCOMA CDSS — CLINICAL REPORT",
@@ -74,93 +71,143 @@ def _build_text(r: dict, mc_passes: int) -> str:
74
  f" Disc Area : {r['disc_area_px']:,} px",
75
  f" Cup Area : {r['cup_area_px']:,} px",
76
  f" Cup/Disc : {r['cup_area_px'] / max(r['disc_area_px'], 1) * 100:.1f}%",
77
- f" MC Passes : {mc_passes}",
78
  ]
79
-
80
-
81
- warnings = r.get("warnings", [])
82
- if warnings:
83
  lines += ["", " Warnings"]
84
- for w in warnings:
85
  lines.append(f" ! {w}")
86
-
87
  lines += [
88
- "",
89
- "=" * 52,
90
- " DISCLAIMER: Research prototype.",
91
- " Not a certified medical device.",
92
  " Validate with a qualified ophthalmologist.",
93
  "=" * 52,
94
  ]
95
-
96
  return "\n".join(lines)
97
 
98
 
99
- # ── PDF BUILDER ───────────────────────────────────────────────────────────
100
- def _build_pdf(text: str):
101
-
102
- try:
103
- from fpdf import FPDF
104
-
105
- # Remove unsupported unicode characters
106
- safe_text = (
107
- text.replace("—", "-")
108
- .replace("•", "-")
109
- .replace("→", "->")
110
- )
111
 
112
- pdf = FPDF()
113
 
114
- pdf.add_page()
 
 
 
 
 
115
 
116
- pdf.set_auto_page_break(auto=True, margin=15)
117
 
118
- pdf.set_font("Courier", size=10)
119
 
120
- for line in safe_text.splitlines():
121
- pdf.multi_cell(0, 6, txt=line)
122
 
123
- out = tempfile.NamedTemporaryFile(
124
- delete=False,
125
- suffix=".pdf"
126
- )
127
 
128
- pdf.output(out.name)
 
 
 
 
 
 
 
129
 
130
- return out.name
131
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
132
  except Exception as e:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
133
 
134
- print("PDF generation failed:", repr(e))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
135
 
136
- return None
137
 
 
138
 
139
- # ── INFERENCE ─────────────────────────────────────────────────────────────
140
  def analyse(file_path):
141
  if file_path is None:
142
  return "No file uploaded.", None, "Please upload a JPEG or PNG fundus image."
143
-
144
  try:
145
- print(f"Received file: {file_path}")
146
-
147
- img = _load_image(file_path)
148
- print(f"Image loaded successfully. Shape: {img.shape}")
149
-
150
- result = pipeline.run(img)
151
- print("Pipeline inference completed.")
152
-
153
- text = _build_text(result["report"], pipeline.mc_passes)
154
- pdf = _build_pdf(text)
155
-
156
- status = "Analysis completed successfully."
157
- if pdf is None:
158
- status = "Analysis completed, but PDF generation failed."
159
-
160
- return text, pdf, status
161
-
162
  except Exception as e:
163
- print(f"Analysis error: {e}")
164
  return f"Error: {e}", None, f"Analysis failed: {e}"
165
 
166
 
@@ -172,12 +219,9 @@ def set_ready():
172
  return gr.update(interactive=True)
173
 
174
 
175
- # ── UI ────────────────────────────────────────────────────────────────────
176
- with gr.Blocks(title="Glaucoma CDSS") as demo:
177
- gr.Markdown(
178
- "## Glaucoma CDSS\n"
179
- "Upload a retinal fundus image to receive a clinical screening report."
180
- )
181
 
182
  with gr.Row():
183
  file_in = gr.File(
@@ -185,131 +229,43 @@ with gr.Blocks(title="Glaucoma CDSS") as demo:
185
  file_types=[".jpg", ".jpeg", ".png"],
186
  type="filepath",
187
  )
188
-
189
  run_btn = gr.Button("Analyse", variant="primary")
190
 
191
- status_box = gr.Textbox(
192
- label="Status",
193
- value="Awaiting image upload.",
194
- interactive=False,
195
- )
196
-
197
- report_box = gr.Textbox(
198
- label="Clinical Report",
199
- lines=28,
200
- interactive=False,
201
- )
202
-
203
- pdf_out = gr.File(label="Download PDF Report")
204
-
205
- gr.Markdown(
206
- "_Research prototype — NOT a medical device. "
207
- "All results must be reviewed by a qualified ophthalmologist._"
208
- )
209
- gr.Markdown(
210
- """
211
- # Glaucoma CDSS
212
-
213
- Upload a retinal fundus image to generate an AI-assisted glaucoma screening report.
214
-
215
- ---
216
-
217
- ## How to Interpret the Report
218
-
219
- ### 1. vCDR (Vertical Cup-to-Disc Ratio)
220
- The most important glaucoma screening metric.
221
- General interpretation:
222
- - **0.30 – 0.50** → Usually within healthy range
223
- - **0.50 – 0.65** → Borderline / monitor carefully
224
- - **0.65 – 0.80** → Glaucoma suspect
225
- - **> 0.80** → High glaucoma risk
226
-
227
- Higher values indicate enlargement of the optic cup relative to the optic disc.
228
-
229
- ---
230
-
231
- ## 2. ISNT Rule
232
-
233
- Healthy optic nerves typically follow:
234
- **Inferior > Superior > Nasal > Temporal**
235
- This is called the **ISNT Rule**.
236
-
237
- - **Satisfied** → Anatomically more normal
238
- - **Violated** → Possible neuro-retinal rim thinning associated with glaucoma
239
-
240
- ISNT violation alone does NOT confirm glaucoma, but it is an important warning sign.
241
-
242
- ---
243
-
244
- ## 3. Uncertainty Score
245
- Represents model confidence.
246
- - **< 0.05** → Stable prediction
247
- - **0.05 – 0.10** → Moderate uncertainty
248
- - **> 0.10** → Low confidence prediction
249
-
250
- High uncertainty may occur with:
251
- - poor image quality,
252
- - blur,
253
- - extreme lighting,
254
- - incomplete optic disc visibility.
255
-
256
- ---
257
-
258
- ## 4. Structural Measurements
259
-
260
- ### Disc Area
261
- Estimated optic disc size in pixels.
262
- ### Cup Area
263
- Estimated optic cup size in pixels.
264
- ### Cup/Disc %
265
- Percentage of cup area relative to disc area.
266
- Larger cup proportions may indicate glaucomatous damage.
267
 
 
268
  ---
 
269
 
270
- ## 5. Risk Levels
 
 
 
 
 
271
 
272
- ### Healthy
273
- No major structural glaucoma indicators detected.
274
- ### Glaucoma Suspect
275
- One or more warning signs detected:
276
- - elevated vCDR,
277
- - ISNT violation,
278
- - anatomical inconsistency,
279
- - or uncertain segmentation.
280
 
281
- ### High Risk
282
- Strong structural indicators of glaucoma detected.
283
- Clinical ophthalmology review is strongly recommended.
284
 
285
  ---
286
-
287
- ## Important Disclaimer
288
- This system is a **research prototype** and NOT a certified medical device.
289
- The generated report is intended for:
290
- - educational use,
291
- - AI research,
292
- - and preliminary screening assistance only.
293
-
294
- All clinical decisions must be made by a qualified ophthalmologist.
295
- """
296
- )
297
 
298
  run_btn.click(
299
- fn=set_busy,
300
- inputs=None,
301
- outputs=[run_btn, status_box],
302
- queue=False,
303
  ).then(
304
- fn=analyse,
305
- inputs=[file_in],
306
- outputs=[report_box, pdf_out, status_box],
307
  ).then(
308
- fn=set_ready,
309
- inputs=None,
310
- outputs=[run_btn],
311
- queue=False,
312
  )
313
 
 
 
 
314
  if __name__ == "__main__":
315
- demo.launch()
 
 
1
  """
2
+ Glaucoma CDSS
3
+ =============
4
+ Gradio UI : /
5
+ REST API : /predict (JSON) | /predict/pdf (PDF file) | /health
6
  """
7
 
8
+ import os, sys, tempfile, shutil
9
+ from pathlib import Path
 
10
 
11
  import cv2
12
  import numpy as np
13
  import gradio as gr
14
+ from fastapi import FastAPI, File, UploadFile, HTTPException
15
+ from fastapi.responses import JSONResponse, FileResponse
16
 
17
  sys.path.insert(0, os.path.dirname(__file__))
 
18
  from phase3pipeline import Phase3Pipeline
19
 
20
+ # ── config ────────────────────────────────────────────────────────────────────
 
21
  REPO_ID = "Nj-1111/EyeeSEE"
22
+ EPOCH = None
23
+ TOKEN = os.getenv("HF_TOKEN_2") or os.getenv("HF_TOKEN")
24
+ # ─────────────────────────────────────────────────────────────────────────────
 
 
25
 
26
+ print(f"Loading model repo={REPO_ID} epoch={'latest' if EPOCH is None else EPOCH}")
27
  pipeline = Phase3Pipeline(
28
+ repo_id=REPO_ID, epoch=EPOCH, mc_passes=1,
29
+ uncertainty_threshold=0.05, token=TOKEN,
 
 
 
30
  )
 
31
  print("Model ready.")
32
 
33
 
34
+ # ── shared helpers ────────────────────────────────────────────────────────────
35
+
36
  def _load_image(path: str) -> np.ndarray:
37
  img = cv2.imread(path)
 
38
  if img is None:
39
  raw = np.fromfile(path, dtype=np.uint8)
40
  img = cv2.imdecode(raw, cv2.IMREAD_COLOR)
 
41
  if img is None:
42
+ raise ValueError(f"Cannot decode image: {path}")
 
43
  return img
44
 
45
 
46
+ def _run_pipeline(path: str) -> dict:
47
+ img = _load_image(path)
48
+ result = pipeline.run(img)
49
+ return result["report"]
50
 
51
+
52
+ def _build_text(r: dict) -> str:
53
+ isnt = r["isnt"]
54
  lines = [
55
  "=" * 52,
56
  " GLAUCOMA CDSS — CLINICAL REPORT",
 
71
  f" Disc Area : {r['disc_area_px']:,} px",
72
  f" Cup Area : {r['cup_area_px']:,} px",
73
  f" Cup/Disc : {r['cup_area_px'] / max(r['disc_area_px'], 1) * 100:.1f}%",
74
+ f" MC Passes : {pipeline.mc_passes}",
75
  ]
76
+ if r.get("warnings"):
 
 
 
77
  lines += ["", " Warnings"]
78
+ for w in r["warnings"]:
79
  lines.append(f" ! {w}")
 
80
  lines += [
81
+ "", "=" * 52,
82
+ " DISCLAIMER: Research prototype. Not a certified medical device.",
 
 
83
  " Validate with a qualified ophthalmologist.",
84
  "=" * 52,
85
  ]
 
86
  return "\n".join(lines)
87
 
88
 
89
+ def _build_pdf(text: str) -> str:
90
+ from fpdf import FPDF
91
+ safe = text.replace("—", "-").replace("•", "-").replace("→", "->")
92
+ pdf = FPDF()
93
+ pdf.add_page()
94
+ pdf.set_auto_page_break(auto=True, margin=15)
95
+ pdf.set_font("Courier", size=10)
96
+ for line in safe.splitlines():
97
+ pdf.multi_cell(0, 6, txt=line)
98
+ out = tempfile.NamedTemporaryFile(delete=False, suffix=".pdf")
99
+ pdf.output(out.name)
100
+ return out.name
101
 
 
102
 
103
+ def _save_upload(upload: UploadFile) -> str:
104
+ suffix = Path(upload.filename).suffix or ".jpg"
105
+ tmp = tempfile.NamedTemporaryFile(delete=False, suffix=suffix)
106
+ shutil.copyfileobj(upload.file, tmp)
107
+ tmp.flush()
108
+ return tmp.name
109
 
 
110
 
111
+ # ── FastAPI app + endpoints ───────────────────────────────────────────────────
112
 
113
+ app = FastAPI(title="Glaucoma CDSS API", version="1.0")
 
114
 
 
 
 
 
115
 
116
+ @app.get("/health")
117
+ def health():
118
+ """Check that the model is loaded and ready."""
119
+ return {
120
+ "status": "ok",
121
+ "repo": REPO_ID,
122
+ "epoch": "latest" if EPOCH is None else EPOCH,
123
+ }
124
 
 
125
 
126
+ @app.post("/predict")
127
+ async def predict(file: UploadFile = File(...)):
128
+ """
129
+ Run glaucoma screening on an uploaded fundus image.
130
+
131
+ Request : multipart/form-data field name = file
132
+ Accepts : JPEG, PNG
133
+ Response : JSON clinical report
134
+
135
+ JSON fields
136
+ -----------
137
+ vcdr float vertical cup-to-disc ratio
138
+ risk_level string Healthy | Glaucoma Suspect | High Risk
139
+ uncertainty float MC-Dropout variance (lower = more confident)
140
+ sanity_passed bool True if cup is naturally inside disc
141
+ isnt object inferior/superior/nasal/temporal rim thickness
142
+ structural object disc_area_px, cup_area_px, cup_disc_pct
143
+ warnings list clinical warning strings
144
+ """
145
+ path = _save_upload(file)
146
+ try:
147
+ r = _run_pipeline(path)
148
  except Exception as e:
149
+ raise HTTPException(status_code=422, detail=str(e))
150
+ finally:
151
+ os.remove(path)
152
+
153
+ return JSONResponse({
154
+ "vcdr": r["vcdr"],
155
+ "risk_level": r["risk_level"],
156
+ "uncertainty": r["uncertainty"],
157
+ "sanity_passed": r["sanity_passed"],
158
+ "isnt": {
159
+ "inferior": r["isnt"]["inferior"],
160
+ "superior": r["isnt"]["superior"],
161
+ "nasal": r["isnt"]["nasal"],
162
+ "temporal": r["isnt"]["temporal"],
163
+ "rule_satisfied": r["isnt"]["rule_satisfied"],
164
+ },
165
+ "structural": {
166
+ "disc_area_px": r["disc_area_px"],
167
+ "cup_area_px": r["cup_area_px"],
168
+ "cup_disc_pct": round(r["cup_area_px"] / max(r["disc_area_px"], 1) * 100, 1),
169
+ },
170
+ "warnings": r.get("warnings", []),
171
+ })
172
+
173
+
174
+ @app.post("/predict/pdf")
175
+ async def predict_pdf(file: UploadFile = File(...)):
176
+ """
177
+ Run glaucoma screening and return a downloadable PDF report.
178
 
179
+ Request : multipart/form-data field name = file
180
+ Accepts : JPEG, PNG
181
+ Response : application/pdf
182
+ """
183
+ path = _save_upload(file)
184
+ try:
185
+ r = _run_pipeline(path)
186
+ text = _build_text(r)
187
+ pdf = _build_pdf(text)
188
+ except Exception as e:
189
+ raise HTTPException(status_code=422, detail=str(e))
190
+ finally:
191
+ os.remove(path)
192
+
193
+ return FileResponse(
194
+ pdf,
195
+ media_type="application/pdf",
196
+ filename="glaucoma_report.pdf",
197
+ )
198
 
 
199
 
200
+ # ── Gradio UI (mounted at /) ──────────────────────────────────────────────────
201
 
 
202
  def analyse(file_path):
203
  if file_path is None:
204
  return "No file uploaded.", None, "Please upload a JPEG or PNG fundus image."
 
205
  try:
206
+ r = _run_pipeline(file_path)
207
+ text = _build_text(r)
208
+ pdf = _build_pdf(text)
209
+ return text, pdf, "Analysis completed."
 
 
 
 
 
 
 
 
 
 
 
 
 
210
  except Exception as e:
 
211
  return f"Error: {e}", None, f"Analysis failed: {e}"
212
 
213
 
 
219
  return gr.update(interactive=True)
220
 
221
 
222
+ with gr.Blocks(title="Glaucoma CDSS") as gradio_ui:
223
+
224
+ gr.Markdown("## Glaucoma CDSS\nUpload a retinal fundus image to receive a clinical screening report.")
 
 
 
225
 
226
  with gr.Row():
227
  file_in = gr.File(
 
229
  file_types=[".jpg", ".jpeg", ".png"],
230
  type="filepath",
231
  )
 
232
  run_btn = gr.Button("Analyse", variant="primary")
233
 
234
+ status_box = gr.Textbox(label="Status", value="Awaiting upload.", interactive=False)
235
+ report_box = gr.Textbox(label="Clinical Report", lines=28, interactive=False)
236
+ pdf_out = gr.File(label="Download PDF Report")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
237
 
238
+ gr.Markdown("""
239
  ---
240
+ ### How to interpret results
241
 
242
+ | vCDR | Indication |
243
+ |------|------------|
244
+ | 0.30 – 0.50 | Usually healthy |
245
+ | 0.50 – 0.65 | Borderline |
246
+ | 0.65 – 0.80 | Glaucoma suspect |
247
+ | > 0.80 | High risk |
248
 
249
+ **ISNT Rule** — healthy nerves follow Inferior > Superior > Nasal > Temporal.
250
+ Violation suggests neuro-retinal rim thinning.
 
 
 
 
 
 
251
 
252
+ **Uncertainty** values above 0.05 indicate low model confidence (check image quality).
 
 
253
 
254
  ---
255
+ *Research prototype — NOT a medical device. All results must be reviewed by a qualified ophthalmologist.*
256
+ """)
 
 
 
 
 
 
 
 
 
257
 
258
  run_btn.click(
259
+ fn=set_busy, inputs=None, outputs=[run_btn, status_box], queue=False,
 
 
 
260
  ).then(
261
+ fn=analyse, inputs=[file_in], outputs=[report_box, pdf_out, status_box],
 
 
262
  ).then(
263
+ fn=set_ready, inputs=None, outputs=[run_btn], queue=False,
 
 
 
264
  )
265
 
266
+
267
+ app = gr.mount_gradio_app(app, gradio_ui, path="/")
268
+
269
  if __name__ == "__main__":
270
+ import uvicorn
271
+ uvicorn.run(app, host="0.0.0.0", port=7860)