Spaces:
Running
Running
Update app.py
Browse files
app.py
CHANGED
|
@@ -234,6 +234,44 @@ def _resolve_path(file_obj):
|
|
| 234 |
return ""
|
| 235 |
|
| 236 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 237 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 238 |
# Event handlers β image
|
| 239 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
@@ -241,7 +279,7 @@ def _resolve_path(file_obj):
|
|
| 241 |
|
| 242 |
def on_file_upload(file_obj):
|
| 243 |
"""Load any image or DICOM and switch the panel to fixation-click mode."""
|
| 244 |
-
_no_change = (None, [], "", gr.update(), gr.update(), gr.update())
|
| 245 |
if file_obj is None:
|
| 246 |
return _no_change
|
| 247 |
|
|
@@ -265,6 +303,7 @@ def on_file_upload(file_obj):
|
|
| 265 |
gr.update(visible=False), # upload_zone β hide
|
| 266 |
gr.update(value=pil, visible=True, label=_FIXATION_LABEL), # image_panel β show
|
| 267 |
gr.update(visible=True), # delete_btn β show
|
|
|
|
| 268 |
)
|
| 269 |
|
| 270 |
|
|
@@ -272,17 +311,17 @@ def on_select(orig_image: Image.Image, points: list, weight: float, evt: gr.Sele
|
|
| 272 |
"""Record a fixation click in original-image pixel coords."""
|
| 273 |
if orig_image is None:
|
| 274 |
gr.Warning("Upload an image first.")
|
| 275 |
-
return points, gr.update()
|
| 276 |
x_px, y_px = float(evt.index[0]), float(evt.index[1])
|
| 277 |
new_points = points + [(x_px, y_px, float(weight))]
|
| 278 |
-
return new_points, draw_points(orig_image, new_points)
|
| 279 |
|
| 280 |
|
| 281 |
def on_clear(orig_image):
|
| 282 |
"""Remove all fixations but keep the current image."""
|
| 283 |
if orig_image is None:
|
| 284 |
-
return [], gr.update()
|
| 285 |
-
return [], gr.update(value=orig_image)
|
| 286 |
|
| 287 |
|
| 288 |
def on_delete():
|
|
@@ -294,6 +333,7 @@ def on_delete():
|
|
| 294 |
gr.update(value=None, visible=True), # upload_zone β show (reset)
|
| 295 |
gr.update(value=None, visible=False), # image_panel β hide
|
| 296 |
gr.update(visible=False), # delete_btn β hide
|
|
|
|
| 297 |
)
|
| 298 |
|
| 299 |
|
|
@@ -376,13 +416,13 @@ def on_apply_fixfile(fixfile_json, id_col, x_col, y_col, time_col, orig_image, i
|
|
| 376 |
ID column doesn't match the uploaded filename)."""
|
| 377 |
if orig_image is None:
|
| 378 |
gr.Warning("Load an image first, then apply the fixation file.")
|
| 379 |
-
return gr.update(), gr.update()
|
| 380 |
if not fixfile_json:
|
| 381 |
gr.Warning("Upload a fixation file first.")
|
| 382 |
-
return gr.update(), gr.update()
|
| 383 |
if not x_col or not y_col:
|
| 384 |
gr.Warning("Pick the X and Y columns first.")
|
| 385 |
-
return gr.update(), gr.update()
|
| 386 |
|
| 387 |
df = pd.read_json(fixfile_json)
|
| 388 |
|
|
@@ -402,7 +442,7 @@ def on_apply_fixfile(fixfile_json, id_col, x_col, y_col, time_col, orig_image, i
|
|
| 402 |
|
| 403 |
if sub.empty:
|
| 404 |
gr.Warning("No usable fixation rows found.")
|
| 405 |
-
return gr.update(), gr.update()
|
| 406 |
|
| 407 |
w, h = orig_image.size
|
| 408 |
x_vals = sub[x_col].astype(float).to_numpy()
|
|
@@ -413,13 +453,20 @@ def on_apply_fixfile(fixfile_json, id_col, x_col, y_col, time_col, orig_image, i
|
|
| 413 |
t_raw = sub[time_col].astype(float).to_numpy()
|
| 414 |
order = np.argsort(t_raw) # chronological order
|
| 415 |
x_px, y_px, t_raw = x_px[order], y_px[order], t_raw[order]
|
| 416 |
-
|
| 417 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 418 |
else:
|
| 419 |
-
weight = np.
|
| 420 |
|
| 421 |
new_points = [(float(xp), float(yp), float(wt)) for xp, yp, wt in zip(x_px, y_px, weight)]
|
| 422 |
-
return new_points, draw_points(orig_image, new_points)
|
| 423 |
|
| 424 |
|
| 425 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
@@ -487,19 +534,20 @@ def run(orig_image: Image.Image, points: list, preset_name: str):
|
|
| 487 |
class_probs = {c: float(p) for c, p in zip(predictor.classes, probs)}
|
| 488 |
predicted_class = max(class_probs, key=class_probs.get)
|
| 489 |
|
| 490 |
-
#
|
|
|
|
| 491 |
img_size = predictor.img_size
|
| 492 |
-
mask_full = patch_to_image(patch_mask[0].cpu().numpy(), img_size, img_size)
|
| 493 |
display_img = np.array(orig_image.resize((img_size, img_size)))
|
| 494 |
-
|
| 495 |
-
|
|
|
|
| 496 |
|
| 497 |
prob_lines = "\n".join(
|
| 498 |
f"- **{c}**: {p:.3f}" for c, p in sorted(class_probs.items(), key=lambda kv: -kv[1])
|
| 499 |
)
|
| 500 |
summary = f"### Predicted: **{predicted_class}**\n\n{prob_lines}"
|
| 501 |
|
| 502 |
-
return class_probs, summary, overlay,
|
| 503 |
|
| 504 |
|
| 505 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
@@ -540,13 +588,20 @@ with gr.Blocks(title="GazeAlign", css=_CSS) as demo:
|
|
| 540 |
file_types=[".jpg", ".jpeg", ".png", ".bmp", ".tif", ".tiff", ".webp", ".dcm"],
|
| 541 |
type="filepath",
|
| 542 |
)
|
|
|
|
|
|
|
|
|
|
| 543 |
image_panel = gr.Image(
|
| 544 |
-
label=_FIXATION_LABEL, type="pil", interactive=
|
|
|
|
|
|
|
|
|
|
|
|
|
| 545 |
)
|
| 546 |
|
| 547 |
weight_slider = gr.Slider(
|
| 548 |
0.0, 1.0, value=0.5, step=0.05,
|
| 549 |
-
label="
|
| 550 |
)
|
| 551 |
with gr.Row():
|
| 552 |
clear_btn = gr.Button("Clear fixations")
|
|
@@ -568,8 +623,8 @@ with gr.Blocks(title="GazeAlign", css=_CSS) as demo:
|
|
| 568 |
run_btn = gr.Button("Run GazeAlign", variant="primary", elem_id="run-btn")
|
| 569 |
label_output = gr.Label(label="Predicted class (probabilities)", num_top_classes=5)
|
| 570 |
summary_output = gr.Markdown()
|
| 571 |
-
overlay_output = gr.Image(label="Gaze-
|
| 572 |
-
mask_output = gr.Image(label="
|
| 573 |
|
| 574 |
gr.Markdown(
|
| 575 |
"<div class='footer-note'>Fixation tables use raw pixel or normalised "
|
|
@@ -582,18 +637,18 @@ with gr.Blocks(title="GazeAlign", css=_CSS) as demo:
|
|
| 582 |
upload_zone.upload(
|
| 583 |
on_file_upload,
|
| 584 |
[upload_zone],
|
| 585 |
-
[orig_image_state, points_state, image_name_state, upload_zone, image_panel, delete_btn],
|
| 586 |
)
|
| 587 |
image_panel.select(
|
| 588 |
on_select,
|
| 589 |
[orig_image_state, points_state, weight_slider],
|
| 590 |
-
[points_state, image_panel],
|
| 591 |
)
|
| 592 |
-
clear_btn.click(on_clear, [orig_image_state], [points_state, image_panel])
|
| 593 |
delete_btn.click(
|
| 594 |
on_delete,
|
| 595 |
None,
|
| 596 |
-
[orig_image_state, points_state, image_name_state, upload_zone, image_panel, delete_btn],
|
| 597 |
)
|
| 598 |
|
| 599 |
fixfile.upload(
|
|
@@ -604,7 +659,7 @@ with gr.Blocks(title="GazeAlign", css=_CSS) as demo:
|
|
| 604 |
apply_fix_btn.click(
|
| 605 |
on_apply_fixfile,
|
| 606 |
[fixfile_df_state, id_col_dd, x_col_dd, y_col_dd, time_col_dd, orig_image_state, image_name_state],
|
| 607 |
-
[points_state, image_panel],
|
| 608 |
)
|
| 609 |
|
| 610 |
run_btn.click(
|
|
|
|
| 234 |
return ""
|
| 235 |
|
| 236 |
|
| 237 |
+
def _status(points: list) -> str:
|
| 238 |
+
"""Small feedback line so it's obvious when fixations register."""
|
| 239 |
+
n = len(points) if points else 0
|
| 240 |
+
if not n:
|
| 241 |
+
return "_No fixations yet β click the image, or load a fixation file below._"
|
| 242 |
+
return f"**{n}** fixation(s) placed."
|
| 243 |
+
|
| 244 |
+
|
| 245 |
+
def gaze_duration_heatmap(points, height, width, img_w, img_h, sigma=None):
|
| 246 |
+
"""Gaussian-splat heatmap of the fixations, each blob weighted by its
|
| 247 |
+
dwell / duration (the 3rd component of each point), rendered at
|
| 248 |
+
(height, width). This is the *observed* gaze heatmap β not the model's
|
| 249 |
+
learned attention.
|
| 250 |
+
|
| 251 |
+
points: list of (x_px, y_px, weight) in original-image pixels.
|
| 252 |
+
"""
|
| 253 |
+
hm = np.zeros((height, width), dtype=np.float32)
|
| 254 |
+
if not points:
|
| 255 |
+
return hm
|
| 256 |
+
if sigma is None:
|
| 257 |
+
sigma = max(height, width) / 22.0
|
| 258 |
+
sx, sy = width / max(img_w, 1), height / max(img_h, 1)
|
| 259 |
+
rad = max(int(sigma * 3), 1)
|
| 260 |
+
for x_px, y_px, wgt in points:
|
| 261 |
+
cx, cy = int(round(x_px * sx)), int(round(y_px * sy))
|
| 262 |
+
if not (0 <= cx < width and 0 <= cy < height):
|
| 263 |
+
continue
|
| 264 |
+
x0, x1 = max(cx - rad, 0), min(cx + rad + 1, width)
|
| 265 |
+
y0, y1 = max(cy - rad, 0), min(cy + rad + 1, height)
|
| 266 |
+
xv, yv = np.meshgrid(np.arange(x0, x1), np.arange(y0, y1))
|
| 267 |
+
g = np.exp(-((xv - cx) ** 2 + (yv - cy) ** 2) / (2.0 * sigma ** 2))
|
| 268 |
+
# +0.15 floor so short-dwell fixations still register a little.
|
| 269 |
+
hm[y0:y1, x0:x1] += g.astype(np.float32) * (0.15 + float(wgt))
|
| 270 |
+
if hm.max() > 0:
|
| 271 |
+
hm /= hm.max()
|
| 272 |
+
return hm
|
| 273 |
+
|
| 274 |
+
|
| 275 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 276 |
# Event handlers β image
|
| 277 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
|
|
| 279 |
|
| 280 |
def on_file_upload(file_obj):
|
| 281 |
"""Load any image or DICOM and switch the panel to fixation-click mode."""
|
| 282 |
+
_no_change = (None, [], "", gr.update(), gr.update(), gr.update(), gr.update())
|
| 283 |
if file_obj is None:
|
| 284 |
return _no_change
|
| 285 |
|
|
|
|
| 303 |
gr.update(visible=False), # upload_zone β hide
|
| 304 |
gr.update(value=pil, visible=True, label=_FIXATION_LABEL), # image_panel β show
|
| 305 |
gr.update(visible=True), # delete_btn β show
|
| 306 |
+
_status([]), # fix_status
|
| 307 |
)
|
| 308 |
|
| 309 |
|
|
|
|
| 311 |
"""Record a fixation click in original-image pixel coords."""
|
| 312 |
if orig_image is None:
|
| 313 |
gr.Warning("Upload an image first.")
|
| 314 |
+
return points, gr.update(), _status(points)
|
| 315 |
x_px, y_px = float(evt.index[0]), float(evt.index[1])
|
| 316 |
new_points = points + [(x_px, y_px, float(weight))]
|
| 317 |
+
return new_points, draw_points(orig_image, new_points), _status(new_points)
|
| 318 |
|
| 319 |
|
| 320 |
def on_clear(orig_image):
|
| 321 |
"""Remove all fixations but keep the current image."""
|
| 322 |
if orig_image is None:
|
| 323 |
+
return [], gr.update(), _status([])
|
| 324 |
+
return [], gr.update(value=orig_image), _status([])
|
| 325 |
|
| 326 |
|
| 327 |
def on_delete():
|
|
|
|
| 333 |
gr.update(value=None, visible=True), # upload_zone β show (reset)
|
| 334 |
gr.update(value=None, visible=False), # image_panel β hide
|
| 335 |
gr.update(visible=False), # delete_btn β hide
|
| 336 |
+
_status([]), # fix_status
|
| 337 |
)
|
| 338 |
|
| 339 |
|
|
|
|
| 416 |
ID column doesn't match the uploaded filename)."""
|
| 417 |
if orig_image is None:
|
| 418 |
gr.Warning("Load an image first, then apply the fixation file.")
|
| 419 |
+
return gr.update(), gr.update(), gr.update()
|
| 420 |
if not fixfile_json:
|
| 421 |
gr.Warning("Upload a fixation file first.")
|
| 422 |
+
return gr.update(), gr.update(), gr.update()
|
| 423 |
if not x_col or not y_col:
|
| 424 |
gr.Warning("Pick the X and Y columns first.")
|
| 425 |
+
return gr.update(), gr.update(), gr.update()
|
| 426 |
|
| 427 |
df = pd.read_json(fixfile_json)
|
| 428 |
|
|
|
|
| 442 |
|
| 443 |
if sub.empty:
|
| 444 |
gr.Warning("No usable fixation rows found.")
|
| 445 |
+
return gr.update(), gr.update(), gr.update()
|
| 446 |
|
| 447 |
w, h = orig_image.size
|
| 448 |
x_vals = sub[x_col].astype(float).to_numpy()
|
|
|
|
| 453 |
t_raw = sub[time_col].astype(float).to_numpy()
|
| 454 |
order = np.argsort(t_raw) # chronological order
|
| 455 |
x_px, y_px, t_raw = x_px[order], y_px[order], t_raw[order]
|
| 456 |
+
# Per-fixation dwell = gap to the next fixation (last one gets the
|
| 457 |
+
# median gap); normalised to [0,1] so it weights the duration heatmap.
|
| 458 |
+
if len(t_raw) > 1:
|
| 459 |
+
dwell = np.diff(t_raw, append=t_raw[-1] + np.median(np.diff(t_raw)))
|
| 460 |
+
dwell = np.clip(dwell, 0, None)
|
| 461 |
+
dmax = float(dwell.max())
|
| 462 |
+
weight = dwell / dmax if dmax > 0 else np.ones_like(dwell)
|
| 463 |
+
else:
|
| 464 |
+
weight = np.ones(1)
|
| 465 |
else:
|
| 466 |
+
weight = np.ones(len(sub))
|
| 467 |
|
| 468 |
new_points = [(float(xp), float(yp), float(wt)) for xp, yp, wt in zip(x_px, y_px, weight)]
|
| 469 |
+
return new_points, draw_points(orig_image, new_points), _status(new_points)
|
| 470 |
|
| 471 |
|
| 472 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
|
|
| 534 |
class_probs = {c: float(p) for c, p in zip(predictor.classes, probs)}
|
| 535 |
predicted_class = max(class_probs, key=class_probs.get)
|
| 536 |
|
| 537 |
+
# Output visual: the *observed* gaze-fixation heatmap, each fixation
|
| 538 |
+
# weighted by its dwell/duration β overlaid on the image and shown raw.
|
| 539 |
img_size = predictor.img_size
|
|
|
|
| 540 |
display_img = np.array(orig_image.resize((img_size, img_size)))
|
| 541 |
+
gaze_hm = gaze_duration_heatmap(points, img_size, img_size, w, h)
|
| 542 |
+
overlay = make_overlay(display_img, gaze_hm)
|
| 543 |
+
heatmap_img = heatmap_to_image(gaze_hm)
|
| 544 |
|
| 545 |
prob_lines = "\n".join(
|
| 546 |
f"- **{c}**: {p:.3f}" for c, p in sorted(class_probs.items(), key=lambda kv: -kv[1])
|
| 547 |
)
|
| 548 |
summary = f"### Predicted: **{predicted_class}**\n\n{prob_lines}"
|
| 549 |
|
| 550 |
+
return class_probs, summary, overlay, heatmap_img
|
| 551 |
|
| 552 |
|
| 553 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
|
|
| 588 |
file_types=[".jpg", ".jpeg", ".png", ".bmp", ".tif", ".tiff", ".webp", ".dcm"],
|
| 589 |
type="filepath",
|
| 590 |
)
|
| 591 |
+
# interactive=False β a pure display surface that reports click
|
| 592 |
+
# coordinates via .select (an interactive Image opens an editor
|
| 593 |
+
# instead and never fires reliable pixel coords).
|
| 594 |
image_panel = gr.Image(
|
| 595 |
+
label=_FIXATION_LABEL, type="pil", interactive=False,
|
| 596 |
+
visible=False, height=440,
|
| 597 |
+
)
|
| 598 |
+
fix_status = gr.Markdown(
|
| 599 |
+
"_No fixations yet β click the image, or load a fixation file below._"
|
| 600 |
)
|
| 601 |
|
| 602 |
weight_slider = gr.Slider(
|
| 603 |
0.0, 1.0, value=0.5, step=0.05,
|
| 604 |
+
label="Dwell / duration weight for the next click",
|
| 605 |
)
|
| 606 |
with gr.Row():
|
| 607 |
clear_btn = gr.Button("Clear fixations")
|
|
|
|
| 623 |
run_btn = gr.Button("Run GazeAlign", variant="primary", elem_id="run-btn")
|
| 624 |
label_output = gr.Label(label="Predicted class (probabilities)", num_top_classes=5)
|
| 625 |
summary_output = gr.Markdown()
|
| 626 |
+
overlay_output = gr.Image(label="Gaze-fixation heatmap (dwell-weighted) β overlay")
|
| 627 |
+
mask_output = gr.Image(label="Gaze-fixation heatmap (weighted by duration)")
|
| 628 |
|
| 629 |
gr.Markdown(
|
| 630 |
"<div class='footer-note'>Fixation tables use raw pixel or normalised "
|
|
|
|
| 637 |
upload_zone.upload(
|
| 638 |
on_file_upload,
|
| 639 |
[upload_zone],
|
| 640 |
+
[orig_image_state, points_state, image_name_state, upload_zone, image_panel, delete_btn, fix_status],
|
| 641 |
)
|
| 642 |
image_panel.select(
|
| 643 |
on_select,
|
| 644 |
[orig_image_state, points_state, weight_slider],
|
| 645 |
+
[points_state, image_panel, fix_status],
|
| 646 |
)
|
| 647 |
+
clear_btn.click(on_clear, [orig_image_state], [points_state, image_panel, fix_status])
|
| 648 |
delete_btn.click(
|
| 649 |
on_delete,
|
| 650 |
None,
|
| 651 |
+
[orig_image_state, points_state, image_name_state, upload_zone, image_panel, delete_btn, fix_status],
|
| 652 |
)
|
| 653 |
|
| 654 |
fixfile.upload(
|
|
|
|
| 659 |
apply_fix_btn.click(
|
| 660 |
on_apply_fixfile,
|
| 661 |
[fixfile_df_state, id_col_dd, x_col_dd, y_col_dd, time_col_dd, orig_image_state, image_name_state],
|
| 662 |
+
[points_state, image_panel, fix_status],
|
| 663 |
)
|
| 664 |
|
| 665 |
run_btn.click(
|