SHIKARICHACHA commited on
Commit
8e0fabe
·
verified ·
1 Parent(s): 4552864

Upload 4 files

Browse files
Files changed (4) hide show
  1. Dockerfile +26 -0
  2. README.md +41 -12
  3. app.py +270 -0
  4. requirements.txt +6 -0
Dockerfile ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Hugging Face Space - Docker SDK
2
+ FROM python:3.10-slim
3
+
4
+ ENV PIP_NO_CACHE_DIR=1 \
5
+ PYTHONDONTWRITEBYTECODE=1 \
6
+ PYTHONUNBUFFERED=1 \
7
+ GRADIO_SERVER_NAME=0.0.0.0
8
+
9
+ # System deps (build tools for scientific packages)
10
+ RUN apt-get update && apt-get install -y --no-install-recommends \
11
+ build-essential \
12
+ && rm -rf /var/lib/apt/lists/*
13
+
14
+ WORKDIR /app
15
+
16
+ # Install Python deps
17
+ COPY requirements.txt /app/requirements.txt
18
+ RUN pip install --upgrade pip && pip install -r requirements.txt
19
+
20
+ # Copy app
21
+ COPY app.py /app/app.py
22
+
23
+ EXPOSE 7860
24
+
25
+ # HF Spaces passes $PORT; app.py reads it
26
+ CMD ["python", "app.py"]
README.md CHANGED
@@ -1,12 +1,41 @@
1
- ---
2
- title: Root Cannal Detection Dicom
3
- emoji: 😻
4
- colorFrom: purple
5
- colorTo: indigo
6
- sdk: gradio
7
- sdk_version: 6.2.0
8
- app_file: app.py
9
- pinned: false
10
- ---
11
-
12
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Dental AI - Hugging Face Space (Docker + Gradio)
2
+
3
+ This folder contains a self-contained Hugging Face Space using Docker and Gradio. It provides:
4
+
5
+ - Synthetic CBCT-like volume generation
6
+ - DICOM series upload (multiple files)
7
+ - 3D surface visualization (Plotly marching cubes) with correct spacing
8
+ - Axial slice viewer with window/level
9
+ - Fast heuristic root canal candidate detection (Frangi) — ethical, no fake ML
10
+
11
+ ## Files
12
+ - app.py Gradio app entrypoint
13
+ - requirements.txt — Python dependencies (used by Dockerfile)
14
+ - Dockerfile — Dockerized Space runtime binding to $PORT as required by Spaces
15
+
16
+ ## Run locally (optional)
17
+ ```
18
+ python3 -m venv .venv && source .venv/bin/activate
19
+ pip install -r requirements.txt
20
+ python app.py
21
+ # open http://localhost:7860
22
+ ```
23
+
24
+ ## Build and run with Docker locally (optional)
25
+ ```
26
+ docker build -t dental-ai-space .
27
+ docker run -p 7860:7860 -e PORT=7860 dental-ai-space
28
+ # open http://localhost:7860
29
+ ```
30
+
31
+ ## Deploy to Hugging Face Spaces
32
+ 1. Create a new Space at https://huggingface.co/spaces
33
+ - SDK: Docker
34
+ - Space name: e.g., your-username/dental-ai-space
35
+ 2. Push this directory's contents to the Space repository root:
36
+ - Ensure files are at the repo root: `Dockerfile`, `app.py`, `requirements.txt`.
37
+ 3. The Space will build automatically and start.
38
+
39
+ Notes:
40
+ - The app binds to `0.0.0.0` and reads the port from `$PORT`, as required by Spaces.
41
+ - If you prefer Spaces SDK: Gradio (no Docker), you can remove `Dockerfile` and keep `app.py` + `requirements.txt`. The default Space runtime will handle it.
app.py ADDED
@@ -0,0 +1,270 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import numpy as np
3
+ import gradio as gr
4
+ import plotly.graph_objects as go
5
+ from skimage.filters import frangi
6
+ from skimage import measure
7
+ import pydicom
8
+
9
+
10
+ def generate_synthetic_dental_volume(shape=(128, 192, 192)):
11
+ depth, height, width = shape
12
+ vol = np.random.normal(loc=-700.0, scale=60.0, size=shape).astype(np.float32)
13
+ y_grid, x_grid = np.ogrid[:height, :width]
14
+
15
+ teeth = []
16
+ cols = 8
17
+ xs = np.linspace(int(width * 0.15), int(width * 0.85), cols)
18
+ y_center = int(height * 0.65)
19
+ z0, z1 = int(depth * 0.2), int(depth * 0.85)
20
+ for xc in xs:
21
+ rx = int(width * 0.03)
22
+ ry = int(height * 0.05)
23
+ canal_rx = max(2, int(rx * 0.25))
24
+ canal_ry = max(2, int(ry * 0.25))
25
+ teeth.append((int(xc), y_center, rx, ry, canal_rx, canal_ry))
26
+
27
+ for (xc, yc, rx, ry, crx, cry) in teeth:
28
+ ell = ((y_grid - yc) / float(ry)) ** 2 + ((x_grid - xc) / float(rx)) ** 2 <= 1.0
29
+ canal = ((y_grid - yc) / float(cry)) ** 2 + ((x_grid - xc) / float(crx)) ** 2 <= 1.0
30
+ bone_val = 1200.0
31
+ canal_val = -250.0
32
+ for z in range(z0, z1):
33
+ vol[z][ell] = bone_val
34
+ vol[z][canal] = canal_val
35
+
36
+ vol = np.clip(vol, -1000.0, 2000.0)
37
+ spacing = (1.0, 1.0, 1.0) # (sx, sy, sz)
38
+ return vol, spacing
39
+
40
+
41
+ def build_mesh_figure(volume: np.ndarray, threshold: float, spacing):
42
+ try:
43
+ verts, faces, normals, values = measure.marching_cubes(volume, level=threshold, step_size=2)
44
+ # verts are (z, y, x); reorder and scale by spacing
45
+ sx, sy, sz = spacing
46
+ x = verts[:, 2] * sx
47
+ y = verts[:, 1] * sy
48
+ z = verts[:, 0] * sz
49
+ i, j, k = faces.T
50
+ mesh = go.Mesh3d(x=x, y=y, z=z, i=i, j=j, k=k,
51
+ color='lightyellow', opacity=0.65, flatshading=False)
52
+ fig = go.Figure(data=[mesh])
53
+ fig.update_layout(scene=dict(aspectmode='data'))
54
+ return fig
55
+ except Exception:
56
+ # Fallback empty figure if threshold invalid
57
+ fig = go.Figure()
58
+ fig.update_layout(scene=dict(aspectmode='data'))
59
+ return fig
60
+
61
+
62
+ def axial_slice_image(volume: np.ndarray, z_idx: int, points=None, level=400.0, width=1500.0):
63
+ z_idx = int(np.clip(z_idx, 0, volume.shape[0] - 1))
64
+ sl = volume[z_idx]
65
+ vmin = level - width / 2.0
66
+ vmax = level + width / 2.0
67
+ sl = np.clip(sl, vmin, vmax)
68
+ sl = (sl - vmin) / (vmax - vmin + 1e-6)
69
+ sl_rgb = (np.stack([sl, sl, sl], axis=-1) * 255).astype(np.uint8)
70
+ if points:
71
+ for (x, y, z) in points:
72
+ if int(z) == int(z_idx):
73
+ xr = int(np.clip(x, 0, sl_rgb.shape[1] - 1))
74
+ yr = int(np.clip(y, 0, sl_rgb.shape[0] - 1))
75
+ # Draw small cyan cross
76
+ s = 2
77
+ sl_rgb[max(0, yr - s):yr + s + 1, xr: xr + 1] = [0, 255, 255]
78
+ sl_rgb[yr: yr + 1, max(0, xr - s):xr + s + 1] = [0, 255, 255]
79
+ return sl_rgb
80
+
81
+
82
+ def detect_root_canals_fast_axial(volume: np.ndarray, bone_threshold=200.0, downsample=3,
83
+ top_n=40, center_index=None, slice_range=30, slice_step=3):
84
+ vol = volume.astype(np.float32, copy=False)
85
+ mask = vol > bone_threshold
86
+ if np.any(mask):
87
+ coords = np.argwhere(mask)
88
+ zmin, ymin, xmin = coords.min(axis=0)
89
+ zmax, ymax, xmax = coords.max(axis=0)
90
+ margin = 4 * downsample
91
+ zmin = max(0, int(zmin - margin))
92
+ ymin = max(0, int(ymin - margin))
93
+ xmin = max(0, int(xmin - margin))
94
+ zmax = min(vol.shape[0] - 1, int(zmax + margin))
95
+ ymax = min(vol.shape[1] - 1, int(ymax + margin))
96
+ xmax = min(vol.shape[2] - 1, int(xmax + margin))
97
+ else:
98
+ zmin = 0; zmax = vol.shape[0] - 1
99
+ ymin = 0; ymax = vol.shape[1] - 1
100
+ xmin = 0; xmax = vol.shape[2] - 1
101
+
102
+ if center_index is None:
103
+ center_index = vol.shape[0] // 2
104
+ start = max(0, int(center_index) - int(slice_range))
105
+ end = min(vol.shape[0] - 1, int(center_index) + int(slice_range))
106
+ zs = list(range(start, end + 1, int(max(1, slice_step))))
107
+
108
+ points = []
109
+ for z in zs:
110
+ sl = vol[z, ymin:ymax + 1, xmin:xmax + 1]
111
+ p5, p995 = np.percentile(sl, [5, 99.5])
112
+ if p995 <= p5:
113
+ p5, p995 = float(sl.min()), float(sl.max())
114
+ sl = np.clip(sl, p5, p995)
115
+ sl = (sl - p5) / (p995 - p5 + 1e-6)
116
+ inv2 = 1.0 - sl
117
+ ds = int(max(1, downsample))
118
+ inv2_ds = inv2[::ds, ::ds] if ds > 1 else inv2
119
+ resp2 = frangi(inv2_ds, sigmas=np.array([0.6, 1.2]), alpha=0.5, beta=0.5, gamma=15, black_ridges=True)
120
+ k = max(1, int(top_n) // max(1, len(zs)))
121
+ flat = resp2.ravel()
122
+ if flat.size == 0:
123
+ continue
124
+ idxs = np.argpartition(flat, -k)[-k:]
125
+ for idx in idxs:
126
+ r, c = divmod(int(idx), resp2.shape[1])
127
+ y_full = ymin + r * ds
128
+ x_full = xmin + c * ds
129
+ points.append((int(x_full), int(y_full), int(z)))
130
+
131
+ points = list({(x, y, z) for (x, y, z) in points}) # unique
132
+ return points[: int(top_n)]
133
+
134
+
135
+ def load_dicom_series(files):
136
+ datasets = []
137
+ for f in files or []:
138
+ try:
139
+ ds = pydicom.dcmread(f.name, force=True)
140
+ if hasattr(ds, 'pixel_array'):
141
+ datasets.append(ds)
142
+ except Exception:
143
+ continue
144
+ if not datasets:
145
+ raise ValueError('No valid DICOM slices uploaded')
146
+ # Sort
147
+ try:
148
+ datasets.sort(key=lambda x: float(x.SliceLocation) if hasattr(x, 'SliceLocation') else (
149
+ int(x.InstanceNumber) if hasattr(x, 'InstanceNumber') else 0))
150
+ except Exception:
151
+ pass
152
+ rows = int(datasets[0].Rows)
153
+ cols = int(datasets[0].Columns)
154
+ num = len(datasets)
155
+ vol = np.zeros((num, rows, cols), dtype=np.float32)
156
+ for i, ds in enumerate(datasets):
157
+ arr = ds.pixel_array.astype(np.float32)
158
+ slope = float(getattr(ds, 'RescaleSlope', 1.0))
159
+ intercept = float(getattr(ds, 'RescaleIntercept', 0.0))
160
+ vol[i] = arr * slope + intercept
161
+ # Spacing
162
+ sx = 1.0; sy = 1.0; sz = 1.0
163
+ ds0 = datasets[0]
164
+ if hasattr(ds0, 'PixelSpacing') and len(ds0.PixelSpacing) >= 2:
165
+ sy = float(ds0.PixelSpacing[0])
166
+ sx = float(ds0.PixelSpacing[1])
167
+ if hasattr(ds0, 'SpacingBetweenSlices'):
168
+ try:
169
+ sz = float(ds0.SpacingBetweenSlices)
170
+ except Exception:
171
+ pass
172
+ elif hasattr(ds0, 'SliceThickness'):
173
+ try:
174
+ sz = float(ds0.SliceThickness)
175
+ except Exception:
176
+ pass
177
+ return vol, (sx, sy, sz)
178
+
179
+
180
+ # Gradio app
181
+ with gr.Blocks(title="Dental AI - Hugging Face Space") as demo:
182
+ gr.Markdown("""
183
+ # Dental AI Demo (Ethical, Heuristic)
184
+ - Generate a synthetic CBCT-like volume and visualize in 3D and 2D.
185
+ - Run a fast heuristic root canal candidate detector (Frangi) — no fake ML.
186
+ - Or upload a DICOM series (multiple files) to visualize.
187
+ """)
188
+
189
+ vol_state = gr.State(None)
190
+ spacing_state = gr.State((1.0, 1.0, 1.0))
191
+ points_state = gr.State([])
192
+
193
+ with gr.Row():
194
+ gen_btn = gr.Button("🧪 Generate Synthetic Volume", variant="primary")
195
+ files = gr.File(label="Upload DICOM slices (multiple)", file_count="multiple")
196
+
197
+ with gr.Row():
198
+ threshold = gr.Slider(0, 3000, value=200, step=10, label="Bone Threshold (HU)")
199
+ slice_idx = gr.Slider(0, 1, value=0, step=1, label="Axial Slice")
200
+
201
+ with gr.Row():
202
+ detect_btn = gr.Button("🦷 Detect Root Canals (Fast 2.5D)")
203
+ downsample = gr.Slider(1, 8, value=3, step=1, label="Downsample")
204
+ topn = gr.Slider(5, 200, value=40, step=5, label="Top N Candidates")
205
+ sl_range = gr.Slider(5, 120, value=30, step=5, label="Slice Range")
206
+ sl_step = gr.Slider(1, 10, value=3, step=1, label="Slice Step")
207
+
208
+ with gr.Row():
209
+ fig3d = gr.Plot(label="3D Surface")
210
+ img2d = gr.Image(label="Axial Slice", type="numpy")
211
+
212
+ info = gr.Markdown(visible=True)
213
+
214
+ def _update_view(vol, spacing, thr, z, points):
215
+ if vol is None:
216
+ return gr.update(), gr.update()
217
+ fig = build_mesh_figure(vol, thr, spacing)
218
+ img = axial_slice_image(vol, int(z), points, level=400.0, width=1500.0)
219
+ return fig, img
220
+
221
+ def on_generate(thr):
222
+ vol, spacing = generate_synthetic_dental_volume()
223
+ depth = int(vol.shape[0])
224
+ points = []
225
+ fig, img = _update_view(vol, spacing, thr, depth // 2, points)
226
+ info = f"Generated synthetic volume: {vol.shape} with spacing {spacing}"
227
+ return vol, spacing, points, gr.update(minimum=0, maximum=depth - 1, value=depth // 2, step=1), fig, img, info
228
+
229
+ gen_btn.click(on_generate, inputs=[threshold],
230
+ outputs=[vol_state, spacing_state, points_state, slice_idx, fig3d, img2d, info])
231
+
232
+ def on_files(thr, files_list):
233
+ if not files_list:
234
+ return gr.update(), gr.update(), gr.update(), None, None, "No files uploaded"
235
+ try:
236
+ vol, spacing = load_dicom_series(files_list)
237
+ depth = int(vol.shape[0])
238
+ points = []
239
+ fig, img = _update_view(vol, spacing, thr, depth // 2, points)
240
+ info = f"Loaded DICOM series: {vol.shape} with spacing {spacing} (showing middle slice)"
241
+ return vol, spacing, points, gr.update(minimum=0, maximum=depth - 1, value=depth // 2, step=1), fig, img, info
242
+ except Exception as e:
243
+ return gr.update(), gr.update(), gr.update(), None, None, f"❌ Error: {e}"
244
+
245
+ files.change(on_files, inputs=[threshold, files],
246
+ outputs=[vol_state, spacing_state, points_state, slice_idx, fig3d, img2d, info])
247
+
248
+ def on_view_change(vol, spacing, thr, z, points):
249
+ return _update_view(vol, spacing, thr, z, points)
250
+
251
+ threshold.release(on_view_change, inputs=[vol_state, spacing_state, threshold, slice_idx, points_state], outputs=[fig3d, img2d])
252
+ slice_idx.release(on_view_change, inputs=[vol_state, spacing_state, threshold, slice_idx, points_state], outputs=[fig3d, img2d])
253
+
254
+ def on_detect(vol, spacing, thr, z, ds, tn, rge, stp):
255
+ if vol is None:
256
+ return gr.update(), gr.update(), []
257
+ points = detect_root_canals_fast_axial(vol, bone_threshold=thr, downsample=ds,
258
+ top_n=int(tn), center_index=int(z),
259
+ slice_range=int(rge), slice_step=int(stp))
260
+ fig, img = _update_view(vol, spacing, thr, z, points)
261
+ return fig, img, points
262
+
263
+ detect_btn.click(on_detect,
264
+ inputs=[vol_state, spacing_state, threshold, slice_idx, downsample, topn, sl_range, sl_step],
265
+ outputs=[fig3d, img2d, points_state])
266
+
267
+
268
+ if __name__ == "__main__":
269
+ port = int(os.environ.get("PORT", "7860"))
270
+ demo.launch(server_name="0.0.0.0", server_port=port)
requirements.txt ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ gradio>=3.50,<5
2
+ numpy==1.24.4
3
+ scipy==1.10.1
4
+ scikit-image==0.21.0
5
+ pydicom==2.4.4
6
+ plotly==5.18.0