Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ New Features

- Catalog public API exposed [#3761, #3777, #3778, #3799, #3814, #3835, #3854, #3856, #3863, #3867, #3930, #3906, #3912, #3899, #3907, #3990]

- Added `skewer` mode to footprint selection that only selects when clicking inside a footprint. [#3962]

Cubeviz
^^^^^^^

Expand Down
3 changes: 2 additions & 1 deletion jdaviz/core/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -410,10 +410,11 @@ class FootprintOverlayClickMessage(Message):
Message emitted when a user clicks on a viewer to select a footprint/region overlay.
"""

def __init__(self, data, *args, **kwargs):
def __init__(self, data, mode="nearest", *args, **kwargs):
super().__init__(*args, **kwargs)
self.x = data["domain"]["x"]
self.y = data["domain"]["y"]
self.mode = mode


class RedshiftMessage(Message):
Expand Down
28 changes: 20 additions & 8 deletions jdaviz/core/loaders/resolvers/resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@
from jdaviz.core.user_api import LoaderUserApi
from jdaviz.core.tools import ICON_DIR
from jdaviz.core.region_translators import is_stcs_string, stcs_string2region
from jdaviz.utils import download_uri_to_path, find_closest_polygon_mark, layer_is_image_data
from jdaviz.utils import (download_uri_to_path, find_closest_polygon_mark,
find_polygon_mark_with_skewer, layer_is_image_data)
from glue.core.message import DataCollectionAddMessage, DataCollectionDeleteMessage


Expand Down Expand Up @@ -320,7 +321,7 @@ def __init__(self, *args, **kwargs):
def custom_toolbar(viewer):
if (self.parsed_input_is_query and self.treat_table_as_query and
's_region' in self.observation_table.headers_avail):
return viewer.toolbar._original_tools_nested[:3] + [['jdaviz:selectregion']], 'jdaviz:selectregion' # noqa: E501
return viewer.toolbar._original_tools_nested[:3] + ['jdaviz:selectregion', 'jdaviz:skewerregion'], 'jdaviz:selectregion' # noqa: E501
return None, None

self.custom_toolbar.callable = custom_toolbar
Expand Down Expand Up @@ -657,19 +658,30 @@ def _on_region_select(self, msg):
]

click_x, click_y = msg.x, msg.y
selected_idx = find_closest_polygon_mark(click_x, click_y, region_marks)

if selected_idx is not None:
# Determine selection mode
if msg.mode == 'skewer':
selected_indices = find_polygon_mark_with_skewer(
click_x, click_y, click_viewer, region_marks)
else:
selected_idx = find_closest_polygon_mark(click_x, click_y, region_marks)
selected_indices = [selected_idx] if selected_idx is not None else None

if selected_indices is not None:
currently_selected = set()
for row in self.observation_table.selected_rows:
idx = self.observation_table.items.index(row)
currently_selected.add(idx)

# Toggle selection
if selected_idx in currently_selected:
currently_selected.discard(selected_idx)
# Toggle all found footprints as a group
# If ALL are selected, deselect ALL; otherwise select ALL
selected_indices_set = set(selected_indices)
if selected_indices_set.issubset(currently_selected):
# All found footprints are already selected - deselect them all
currently_selected -= selected_indices_set
else:
currently_selected.add(selected_idx)
# At least one is not selected - select them all
currently_selected |= selected_indices_set
Comment on lines +676 to +684
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the block we'll need to update to support 'click to deselect' in a near-future PR.


# Update the table selection
if currently_selected:
Expand Down
141 changes: 141 additions & 0 deletions jdaviz/core/loaders/resolvers/test_resolver.py
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just want to say that these tests are so clear, thank you.

Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from jdaviz.app import Application
from jdaviz.core.loaders.resolvers.resolver import BaseResolver, find_closest_polygon_mark
from jdaviz.core.marks import RegionOverlay
from jdaviz.utils import find_polygon_mark_with_skewer

import numpy as np
from astropy.table import Table
Expand Down Expand Up @@ -307,3 +308,143 @@ def test_remove_footprints_from_viewer(deconfigged_helper, image_nddata_wcs):
ldr._obj.toggle_custom_toolbar()
# Assert footprints removed
assert not any(isinstance(m, RegionOverlay) for m in viewer.figure.marks)


def test_skewer_selection_inside_footprint(deconfigged_helper, image_nddata_wcs):
"""Test that skewer selection only selects when click is inside a footprint."""
deconfigged_helper.load(image_nddata_wcs, format='Image', data_label='test_image')

table = Table()
table['Dataset'] = ['obs1']
table['s_region'] = [
'POLYGON 337.499 -20.831 337.501 -20.831 337.501 -20.829 337.499 -20.829'
]

ldr = deconfigged_helper.loaders['object']
ldr.object = table
ldr.treat_table_as_query = True
ldr._obj.vue_link_by_wcs()
ldr._obj.toggle_custom_toolbar()

viewer = list(deconfigged_helper.app._viewer_store.values())[0]
footprints = [m for m in viewer.figure.marks if isinstance(m, RegionOverlay)]
assert len(footprints) == 1

# Get center of footprint (should be inside)
mark = footprints[0]
center_x = np.mean(mark.x)
center_y = np.mean(mark.y)

# Test skewer selection finds the footprint when clicking inside
indices = find_polygon_mark_with_skewer(center_x, center_y, viewer, footprints)
assert indices == [0]

# Test clicking outside footprint (using a point far away)
far_x = mark.x[0] - 1000
far_y = mark.y[0] - 1000
idx_outside = find_polygon_mark_with_skewer(far_x, far_y, viewer, footprints)
assert idx_outside is None


def test_skewer_selection_smallest_footprint(deconfigged_helper, image_nddata_wcs):
"""Test that skewer selection selects all footprints when multiple contain the click."""
deconfigged_helper.load(image_nddata_wcs, format='Image', data_label='test_image')

table = Table()
table['Dataset'] = ['large', 'small']
# Create two overlapping footprints - small one inside large one
table['s_region'] = [
# Large footprint
'POLYGON 337.498 -20.832 337.502 -20.832 337.502 -20.828 337.498 -20.828',
# Small footprint (inside large one)
'POLYGON 337.499 -20.831 337.501 -20.831 337.501 -20.829 337.499 -20.829'
]

ldr = deconfigged_helper.loaders['object']
ldr.object = table
ldr.treat_table_as_query = True
ldr._obj.vue_link_by_wcs()
ldr._obj.toggle_custom_toolbar()

viewer = list(deconfigged_helper.app._viewer_store.values())[0]
footprints = [m for m in viewer.figure.marks if isinstance(m, RegionOverlay)]
assert len(footprints) == 2

# Find the small footprint's center
small_mark = [m for m in footprints if m.label == 1][0]
center_x = np.mean(small_mark.x)
center_y = np.mean(small_mark.y)

# Click in the center of small footprint (which is also inside large)
# Should select both footprints (labels 0 and 1)
indices = find_polygon_mark_with_skewer(center_x, center_y, viewer, footprints)
assert set(indices) == {0, 1}


def test_skewer_selection_vs_nearest_edge(deconfigged_helper, image_nddata_wcs):
"""Test that skewer selection differs from nearest-edge selection for edge clicks."""
deconfigged_helper.load(image_nddata_wcs, format='Image', data_label='test_image')

table = Table()
table['Dataset'] = ['obs1', 'obs2']
table['s_region'] = [
'POLYGON 337.499 -20.831 337.501 -20.831 337.501 -20.829 337.499 -20.829',
'POLYGON 337.502 -20.831 337.504 -20.831 337.504 -20.829 337.502 -20.829'
]

ldr = deconfigged_helper.loaders['object']
ldr.object = table
ldr.treat_table_as_query = True
ldr._obj.vue_link_by_wcs()
ldr._obj.toggle_custom_toolbar()

viewer = list(deconfigged_helper.app._viewer_store.values())[0]
footprints = [m for m in viewer.figure.marks if isinstance(m, RegionOverlay)]
assert len(footprints) == 2

# Click between the two footprints (outside both)
mark1 = [m for m in footprints if m.label == 0][0]
mark2 = [m for m in footprints if m.label == 1][0]

# Point between the two footprints
between_x = (np.max(mark1.x) + np.min(mark2.x)) / 2
between_y = (np.max(mark1.y) + np.min(mark2.y)) / 2

# Nearest-edge selection should find something (closest edge)
nearest_idx = find_closest_polygon_mark(between_x, between_y, footprints)
assert nearest_idx is not None

# Skewer selection should return None (not inside any footprint)
skewer_idx = find_polygon_mark_with_skewer(between_x, between_y, viewer, footprints)
assert skewer_idx is None


def test_skewer_selection_with_empty_region(deconfigged_helper, image_nddata_wcs):
"""Test skewer selection handles empty s_region gracefully."""
deconfigged_helper.load(image_nddata_wcs, format='Image', data_label='test_image')

table = Table()
table['Dataset'] = ['obs1', 'obs2']
table['s_region'] = [
'POLYGON 337.499 -20.831 337.501 -20.831 337.501 -20.829 337.499 -20.829',
'' # Empty region
]

ldr = deconfigged_helper.loaders['object']
ldr.object = table
ldr.treat_table_as_query = True
ldr._obj.vue_link_by_wcs()
ldr._obj.toggle_custom_toolbar()

viewer = list(deconfigged_helper.app._viewer_store.values())[0]
footprints = [m for m in viewer.figure.marks if isinstance(m, RegionOverlay)]
# Only one footprint should be created (second one is empty)
assert len(footprints) == 1

mark = footprints[0]
center_x = np.mean(mark.x)
center_y = np.mean(mark.y)

# Should still work with the single valid footprint
indices = find_polygon_mark_with_skewer(center_x, center_y, viewer, footprints)
assert indices == [0]
25 changes: 24 additions & 1 deletion jdaviz/core/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -475,6 +475,29 @@ def is_visible(self):
)


@viewer_tool
class SkewerSelectRegion(CheckableTool, HubListener):
icon = os.path.join(ICON_DIR, 'skewer_select.svg')
tool_id = 'jdaviz:skewerregion'
action_text = 'Select/identify smallest region containing cursor'
tool_tip = 'Select/identify smallest region containing cursor'

def activate(self):
self.viewer.add_event_callback(self.on_mouse_event,
events=['click'])

def deactivate(self):
self.viewer.remove_event_callback(self.on_mouse_event)

def on_mouse_event(self, data):
msg = FootprintOverlayClickMessage(data, mode="skewer", sender=self)
self.viewer.session.hub.broadcast(msg)

def is_visible(self):
return any(isinstance(m, RegionOverlay) and m.visible
for m in self.viewer.figure.marks)


@viewer_tool
class SelectRegionOverlay(CheckableTool, HubListener):
icon = os.path.join(ICON_DIR, 'footprint_select.svg')
Expand All @@ -490,7 +513,7 @@ def deactivate(self):
self.viewer.remove_event_callback(self.on_mouse_event)

def on_mouse_event(self, data):
msg = FootprintOverlayClickMessage(data, sender=self)
msg = FootprintOverlayClickMessage(data, mode="nearest", sender=self)
self.viewer.session.hub.broadcast(msg)

def is_visible(self):
Expand Down
1 change: 1 addition & 0 deletions jdaviz/data/icons/skewer_select.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
59 changes: 59 additions & 0 deletions jdaviz/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
from photutils.utils import make_random_cmap
from regions import CirclePixelRegion, CircleAnnulusPixelRegion
from specutils.utils.wcs_utils import SpectralGWCS
from spherical_geometry.polygon import SphericalPolygon
import stdatamodels

from glue.config import settings
Expand Down Expand Up @@ -1281,3 +1282,61 @@ def find_closest_polygon_mark(px, py, marks):
closest_idx = mark.label

return closest_idx


def find_polygon_mark_with_skewer(px, py, viewer, marks):
"""
Spherical (great-circle) selection: only selects if the click is INSIDE a mark.
If multiple marks contain the click, returns all of them.

Parameters
----------
px : float
X coordinate of the click in pixel space.
py : float
Y coordinate of the click in pixel space.
viewer : JdavizViewer
The viewer instance where the click occurred.
marks : list of RegionOverlay
List of mark objects to check.

Returns
-------
chosen_labels : list of int or None
List of observation indices for all marks containing the click,
or None if no marks contain it.
"""
# Convert pixel coordinates to sky coordinates (ICRS)
skycoord_icrs = viewer.state.reference_data.coords.pixel_to_world(px, py).icrs
ra_deg = skycoord_icrs.ra.deg
dec_deg = skycoord_icrs.dec.deg

containing_labels = []

for mark in marks:
label = mark.label
x_pix = np.asarray(mark.x)
y_pix = np.asarray(mark.y)

if len(x_pix) == 0 or len(y_pix) == 0:
continue

# Drop duplicate closing vertex if present
if len(x_pix) > 1 and x_pix[0] == x_pix[-1] and y_pix[0] == y_pix[-1]:
x_pix = x_pix[:-1]
y_pix = y_pix[:-1]

# Convert mark vertices to sky coordinates
verts_icrs = viewer.state.reference_data.coords.pixel_to_world(x_pix, y_pix).icrs
spherical_polygon = SphericalPolygon.from_lonlat(
verts_icrs.ra.deg, verts_icrs.dec.deg, degrees=True)

# Check if the click point is inside this polygon
if spherical_polygon.contains_lonlat(ra_deg, dec_deg, degrees=True):
containing_labels.append(label)

# Return all footprints that contain the click point
if containing_labels:
return containing_labels

return None
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ dependencies = [
"s3fs>=2024.10.0",
"joblib>=1.3.0",
"ipyvuedraggable>=1.1.0",
"spherical-geometry",
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note: this dependency is currently needed to do proper contains and intersects operations for points and polygons on a sphere. Sedona has an implementation of spherical region operations in a PR to astropy regions (astropy/regions#618), which could make this dependency unnecessary at some point in the future, though we will have to test if the Python implementation in regions is too slow for our use compared to the C implementation in spherical-geometry.

Copy link
Copy Markdown
Contributor

@bmorris3 bmorris3 Feb 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For most supported workflows within jdaviz, the viewer FOV is expected to be <<1 deg. At that scale, one could argue that the small angle approximation could be assumed valid and we could use cartesian polygon overlap instead of spherical. We haven't made that assumption because it breaks down at the poles of any coordinate frame.

]
license-files = ["LICENSE.rst", "licenses/IPYFILECHOOSER_LICENSE.rst", "licenses/IMEXAM_LICENSE.txt", "licenses/GINGA_LICENSE.txt", "licenses/TEMPLATE_LICENCE.rst"]
dynamic = [
Expand Down
Loading