From d92947557150b61102e98d584d91644c20589216 Mon Sep 17 00:00:00 2001 From: Anne Haley Date: Wed, 16 Jul 2025 13:59:53 -0400 Subject: [PATCH 01/29] Add basic controls to define warp points --- large_image/tilesource/jupyter.py | 68 ++++++++++++++++++++++++++++--- 1 file changed, 62 insertions(+), 6 deletions(-) diff --git a/large_image/tilesource/jupyter.py b/large_image/tilesource/jupyter.py index 4020473ad..b103a012e 100644 --- a/large_image/tilesource/jupyter.py +++ b/large_image/tilesource/jupyter.py @@ -91,7 +91,10 @@ class IPyLeafletMixin: def __init__(self, *args, **kwargs) -> None: self._jupyter_server_manager = None - self._map = Map(ts=self) + self._map = Map( + ts=self, + editWarp=kwargs.get('editWarp', False), + ) if ipyleafletPresent: self.to_map = self._map.to_map self.from_map = self._map.from_map @@ -167,7 +170,9 @@ def __init__( self, *, ts: Optional[IPyLeafletMixin] = None, metadata: Optional[dict] = None, url: Optional[str] = None, gc: Optional[Any] = None, id: Optional[str] = None, - resource: Optional[str] = None) -> None: + resource: Optional[str] = None, + editWarp: bool = False, + ) -> None: """ Specify the large image to be used with the IPyLeaflet Map. One of (a) a tile source, (b) metadata dictionary and tile url, (c) girder client @@ -187,6 +192,9 @@ def __init__( self._layer = self._map = self._metadata = self._frame_slider = None self._frame_histograms: Optional[dict[int, Any]] = None self._ts = ts + self._edit_warp = editWarp + if self._edit_warp: + self.warp_points = dict(src=[], dst=[]) if (not url or not metadata) and gc and (id or resource): fileId = None if id is None: @@ -282,7 +290,7 @@ def make_map( Create an ipyleaflet map given large_image metadata, an optional ipyleaflet layer, and the center of the tile source. """ - from ipyleaflet import Map, basemaps, projections + from ipyleaflet import FullScreenControl, Map, basemaps, projections from ipywidgets import VBox try: @@ -351,11 +359,60 @@ def make_map( self._map = m children.append(m) - self.add_region_indicator() + if self._edit_warp: + children.append(self.add_warp_editor()) + else: + # Only add region indicator if not using warp editor so that + # the map doesn't have conflicting on_interaction callbacks + self.add_region_indicator() + self._map.add(FullScreenControl()) + return VBox(children) + + def add_warp_editor(self): + from ipyleaflet import DivIcon, Marker + from ipywidgets import VBox, Label + + help_text = Label('To begin editing a warp, click on the image to place reference points.') + children = [help_text] + marker_style = ( + 'border-radius: 50%; position: relative;' + 'height: 16px; width: 16px; top: -8px; left: -8px;' + 'text-align: center; font-size: 11px;' + ) + + def handle_drag(event): + old = [round(v) for v in event.get('old')] + new = [round(v) for v in event.get('new')] + marker_title = event.get('owner').title + group_name = marker_title[:3] + index = int(marker_title[3:]) + self.warp_points[group_name][index] = new + if group_name == 'dst' and self.warp_points['src'][index] is None: + self.warp_points['src'][index] = old + html = f'
{index}
' + icon = DivIcon(html=html, icon_size=[0, 0]) + marker = Marker(location=old, draggable=True, icon=icon, title=f'src {index}') + marker.observe(handle_drag, 'location') + self._map.add(marker) + + def handle_interaction(**kwargs): + if kwargs.get('type') == 'click': + coords = kwargs.get('coordinates') + index = len(self.warp_points['src']) + html = f'
{index}
' + icon = DivIcon(html=html, icon_size=[0, 0]) + marker = Marker(location=coords, draggable=True, icon=icon, title=f'dst {index}') + marker.observe(handle_drag, 'location') + self._map.add(marker) + self.warp_points['src'].append(None) + self.warp_points['dst'].append(coords) + help_text.value = 'After placing reference points, you can drag them to start defining the warp.' + + self._map.on_interaction(handle_interaction) return VBox(children) def add_region_indicator(self): - from ipyleaflet import FullScreenControl, GeomanDrawControl, Popup + from ipyleaflet import GeomanDrawControl, Popup from ipywidgets import HTML metadata = self._metadata @@ -440,7 +497,6 @@ def handle_draw(target, action, geo_json): draw_control.on_draw(handle_draw) self._map.on_interaction(handle_interaction) self._map.add(draw_control) - self._map.add(FullScreenControl()) @property def layer(self) -> Any: From bc564d23929c9e1d49a93af415b8af61961e7f03 Mon Sep 17 00:00:00 2001 From: Anne Haley Date: Wed, 16 Jul 2025 14:54:51 -0400 Subject: [PATCH 02/29] Add display of json and yaml schemas with warp points --- large_image/tilesource/jupyter.py | 27 ++++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/large_image/tilesource/jupyter.py b/large_image/tilesource/jupyter.py index b103a012e..9419980c8 100644 --- a/large_image/tilesource/jupyter.py +++ b/large_image/tilesource/jupyter.py @@ -21,6 +21,7 @@ import os import threading import weakref +import yaml from typing import Any, Optional, Union, cast from urllib.parse import parse_qs, quote, urlencode, urlparse, urlunparse @@ -370,16 +371,34 @@ def make_map( def add_warp_editor(self): from ipyleaflet import DivIcon, Marker - from ipywidgets import VBox, Label + from ipywidgets import Accordion, HTML, Label, VBox help_text = Label('To begin editing a warp, click on the image to place reference points.') - children = [help_text] + yaml_schema = HTML('yaml') + json_schema = HTML('json') + schemas = Accordion(children=[yaml_schema, json_schema], titles=('YAML', 'JSON')) + schemas.layout.display = 'none' + children = [help_text, schemas] marker_style = ( 'border-radius: 50%; position: relative;' 'height: 16px; width: 16px; top: -8px; left: -8px;' 'text-align: center; font-size: 11px;' ) + def update_schemas(): + help_text.value = 'Reference the schemas below to use this warp with the MultiFileTileSource (either as YAML or JSON).' + schema = dict(sources=[ + dict( + # TODO: is there a better way to get the path value? + path=str(self._ts._initValues[0][0]), + z=0, position=dict(x=0, y=0, warp=self.warp_points) + ) + ]) + json_schema.value = f'
{json.dumps(schema, indent=4)}
' + yaml_schema.value = f'
{yaml.dump(schema)}
' + schemas.layout.display = 'block' + + def handle_drag(event): old = [round(v) for v in event.get('old')] new = [round(v) for v in event.get('new')] @@ -394,6 +413,7 @@ def handle_drag(event): marker = Marker(location=old, draggable=True, icon=icon, title=f'src {index}') marker.observe(handle_drag, 'location') self._map.add(marker) + update_schemas() def handle_interaction(**kwargs): if kwargs.get('type') == 'click': @@ -406,7 +426,8 @@ def handle_interaction(**kwargs): self._map.add(marker) self.warp_points['src'].append(None) self.warp_points['dst'].append(coords) - help_text.value = 'After placing reference points, you can drag them to start defining the warp.' + help_text.value = 'After placing reference points, you can drag them to define the warp.' + schemas.layout.display = 'none' self._map.on_interaction(handle_interaction) return VBox(children) From cade572434304eadcfc4b9a0e0f7a75a375106bb Mon Sep 17 00:00:00 2001 From: Anne Haley Date: Mon, 21 Jul 2025 14:56:29 -0400 Subject: [PATCH 03/29] Only show matched points in schemas --- large_image/tilesource/jupyter.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/large_image/tilesource/jupyter.py b/large_image/tilesource/jupyter.py index 9419980c8..782b6f932 100644 --- a/large_image/tilesource/jupyter.py +++ b/large_image/tilesource/jupyter.py @@ -387,11 +387,15 @@ def add_warp_editor(self): def update_schemas(): help_text.value = 'Reference the schemas below to use this warp with the MultiFileTileSource (either as YAML or JSON).' + matched_points = { + k: [point for index, point in enumerate(v) if self.warp_points.get('src')[index] and self.warp_points.get('dst')[index]] + for k, v in self.warp_points.items() + } schema = dict(sources=[ dict( # TODO: is there a better way to get the path value? path=str(self._ts._initValues[0][0]), - z=0, position=dict(x=0, y=0, warp=self.warp_points) + z=0, position=dict(x=0, y=0, warp=matched_points) ) ]) json_schema.value = f'
{json.dumps(schema, indent=4)}
' @@ -427,7 +431,6 @@ def handle_interaction(**kwargs): self.warp_points['src'].append(None) self.warp_points['dst'].append(coords) help_text.value = 'After placing reference points, you can drag them to define the warp.' - schemas.layout.display = 'none' self._map.on_interaction(handle_interaction) return VBox(children) From 0936694dbd54b7254a728b6706e5e117ca1a71e3 Mon Sep 17 00:00:00 2001 From: Anne Haley Date: Mon, 21 Jul 2025 18:26:12 -0400 Subject: [PATCH 04/29] First pass at applying transform via multi source --- large_image/tilesource/jupyter.py | 78 +++++++++++++++++++++++++++---- 1 file changed, 70 insertions(+), 8 deletions(-) diff --git a/large_image/tilesource/jupyter.py b/large_image/tilesource/jupyter.py index 782b6f932..fd955b08b 100644 --- a/large_image/tilesource/jupyter.py +++ b/large_image/tilesource/jupyter.py @@ -161,6 +161,10 @@ def iplmap(self) -> Any: """ return self._map.map + @property + def warp_points(self): + return self._map.warp_points + class Map: """ @@ -190,7 +194,7 @@ def __init__( :param resource: a girder resource path of an item or file that exists on the girder client. """ - self._layer = self._map = self._metadata = self._frame_slider = None + self._layer = self._map = self._metadata = self.frame_selector = None self._frame_histograms: Optional[dict[int, Any]] = None self._ts = ts self._edit_warp = editWarp @@ -371,14 +375,16 @@ def make_map( def add_warp_editor(self): from ipyleaflet import DivIcon, Marker - from ipywidgets import Accordion, HTML, Label, VBox + from ipywidgets import Accordion, Checkbox, HTML, Label, VBox help_text = Label('To begin editing a warp, click on the image to place reference points.') + transform_checkbox = Checkbox(description='Show Transformed', value=True) + transform_checkbox.layout.display = 'none' yaml_schema = HTML('yaml') json_schema = HTML('json') schemas = Accordion(children=[yaml_schema, json_schema], titles=('YAML', 'JSON')) schemas.layout.display = 'none' - children = [help_text, schemas] + children = [transform_checkbox, help_text, schemas] marker_style = ( 'border-radius: 50%; position: relative;' 'height: 16px; width: 16px; top: -8px; left: -8px;' @@ -401,7 +407,8 @@ def update_schemas(): json_schema.value = f'
{json.dumps(schema, indent=4)}
' yaml_schema.value = f'
{yaml.dump(schema)}
' schemas.layout.display = 'block' - + transform_checkbox.layout.display = 'block' + self.update_warp(transform_checkbox.value) def handle_drag(event): old = [round(v) for v in event.get('old')] @@ -432,6 +439,10 @@ def handle_interaction(**kwargs): self.warp_points['dst'].append(coords) help_text.value = 'After placing reference points, you can drag them to define the warp.' + def toggle_transform(event): + self.update_warp(event.get('new')) + + transform_checkbox.observe(toggle_transform, names=['value']) self._map.on_interaction(handle_interaction) return VBox(children) @@ -575,7 +586,21 @@ def from_map(self, coordinate: Union[list[float], tuple[float, float]]) -> tuple return transf.transform(x, y) return x, self._metadata['sizeY'] - y + def update_warp(self, show_warp): + current_frame = self.frame_selector.currentFrame if self.frame_selector is not None else 0 + if show_warp and len(self.warp_points.get('src')): + matched_points = { + k: [point for index, point in enumerate(v) if self.warp_points.get('src')[index] and self.warp_points.get('dst')[index]] + for k, v in self.warp_points.items() + } + self.update_layer_query(frame=current_frame, style=dict(warp=matched_points)) + else: + self.update_layer_query(frame=current_frame, style=dict()) + def update_frame(self, frame, style, **kwargs): + self.update_layer_query(frame=frame, style=style) + + def update_layer_query(self, frame, style, **kwargs): if self._layer: parsed_url = urlparse(self._layer.url) query = parsed_url.query @@ -640,6 +665,21 @@ def default(self, obj): return json.JSONEncoder.default(self, obj) +multi_source = None +def _lazyImportMultiSource(): + """ + Import the large_image_source_multi module. This is only needed when editWarp is used. + """ + global multi_source + + if multi_source is None: + try: + import large_image_source_multi as multi_source + except ImportError: + msg = 'large_image_source_multi module not found.' + raise TileSourceError(msg) + + def launch_tile_server(tile_source: IPyLeafletMixin, port: int = 0) -> Any: import tornado.httpserver import tornado.netutil @@ -666,6 +706,20 @@ def ports(self) -> tuple[int, ...]: def port(self) -> int: return self.ports[0] + def get_warp_source(self, warp): + if multi_source is None: + _lazyImportMultiSource() + # TODO: is there a better way to get the path value? + path = str(self.tile_source._initValues[0][0]) + return multi_source.open(dict(sources=[ + dict( + path=path, + position=dict( + x=0, y=0, warp=warp + ) + ) + ])) + manager = RequestManager(tile_source) # NOTE: set `ports` manually after launching server @@ -705,12 +759,20 @@ def get(self) -> None: z = int(self.get_argument('z')) frame = int(self.get_argument('frame', default='0')) style = self.get_argument('style', default=None) - if style: - manager.tile_source.style = json.loads(style) # type: ignore[attr-defined] + warp = None encoding = self.get_argument('encoding', 'PNG') + if style: + style = json.loads(style) + warp = style.get('warp') + if warp is None: + manager.tile_source.style = style # type: ignore[attr-defined] try: - tile_binary = manager.tile_source.getTile( # type: ignore[attr-defined] - x, y, z, encoding=encoding, frame=frame) + if warp is not None: + tile_binary = manager.get_warp_source(warp).getTile( # type: ignore[attr-defined] + x, y, z, encoding=encoding, frame=frame) + else: + tile_binary = manager.tile_source.getTile( # type: ignore[attr-defined] + x, y, z, encoding=encoding, frame=frame) except TileSourceXYZRangeError as e: self.clear() self.set_status(404) From 0b94c4cbf2fc06db3a8ec5393687e0fb8e895e0a Mon Sep 17 00:00:00 2001 From: Anne Haley Date: Tue, 22 Jul 2025 09:08:42 -0400 Subject: [PATCH 05/29] Correct coordinate ordering for warp points --- large_image/tilesource/jupyter.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/large_image/tilesource/jupyter.py b/large_image/tilesource/jupyter.py index fd955b08b..61ff0aa04 100644 --- a/large_image/tilesource/jupyter.py +++ b/large_image/tilesource/jupyter.py @@ -410,15 +410,18 @@ def update_schemas(): transform_checkbox.layout.display = 'block' self.update_warp(transform_checkbox.value) + def convert_coordinate(coord): + return [coord[1], coord[0]] + def handle_drag(event): old = [round(v) for v in event.get('old')] new = [round(v) for v in event.get('new')] marker_title = event.get('owner').title group_name = marker_title[:3] index = int(marker_title[3:]) - self.warp_points[group_name][index] = new + self.warp_points[group_name][index] = convert_coordinate(new) if group_name == 'dst' and self.warp_points['src'][index] is None: - self.warp_points['src'][index] = old + self.warp_points['src'][index] = convert_coordinate(old) html = f'
{index}
' icon = DivIcon(html=html, icon_size=[0, 0]) marker = Marker(location=old, draggable=True, icon=icon, title=f'src {index}') @@ -436,7 +439,7 @@ def handle_interaction(**kwargs): marker.observe(handle_drag, 'location') self._map.add(marker) self.warp_points['src'].append(None) - self.warp_points['dst'].append(coords) + self.warp_points['dst'].append(convert_coordinate(coords)) help_text.value = 'After placing reference points, you can drag them to define the warp.' def toggle_transform(event): From aeed1eabcb43be0a8acd5056461b16da059c5763 Mon Sep 17 00:00:00 2001 From: Anne Haley Date: Tue, 22 Jul 2025 10:00:49 -0400 Subject: [PATCH 06/29] Allow a user to add a reference image and control opacity --- large_image/tilesource/jupyter.py | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/large_image/tilesource/jupyter.py b/large_image/tilesource/jupyter.py index 61ff0aa04..baa128086 100644 --- a/large_image/tilesource/jupyter.py +++ b/large_image/tilesource/jupyter.py @@ -95,6 +95,7 @@ def __init__(self, *args, **kwargs) -> None: self._map = Map( ts=self, editWarp=kwargs.get('editWarp', False), + reference=kwargs.get('reference'), ) if ipyleafletPresent: self.to_map = self._map.to_map @@ -176,7 +177,7 @@ def __init__( metadata: Optional[dict] = None, url: Optional[str] = None, gc: Optional[Any] = None, id: Optional[str] = None, resource: Optional[str] = None, - editWarp: bool = False, + editWarp: bool = False, reference: Optional[IPyLeafletMixin] = None, ) -> None: """ Specify the large image to be used with the IPyLeaflet Map. One of (a) @@ -198,6 +199,8 @@ def __init__( self._frame_histograms: Optional[dict[int, Any]] = None self._ts = ts self._edit_warp = editWarp + self._reference = reference + self._reference_layer = None if self._edit_warp: self.warp_points = dict(src=[], dst=[]) if (not url or not metadata) and gc and (id or resource): @@ -296,7 +299,7 @@ def make_map( ipyleaflet layer, and the center of the tile source. """ from ipyleaflet import FullScreenControl, Map, basemaps, projections - from ipywidgets import VBox + from ipywidgets import FloatSlider, VBox try: default_zoom = metadata['levels'] - metadata['sourceLevels'] @@ -361,6 +364,26 @@ def make_map( m.fit_bounds(bounds=[[0, 0], [metadata['sizeY'], metadata['sizeX']]]) if self._geospatial: m.add_layer(layer) + + if self._reference is not None: + default_opacity = 0.5 + self._reference_layer = self._reference.as_leaflet_layer() + self._reference_layer.opacity = default_opacity + m.add_layer(self._reference_layer) + + def update_reference_opacity(event): + self._reference_layer.opacity = event.get('new', default_opacity) + + reference_slider = FloatSlider( + description='Reference Opacity', + value=default_opacity, step=0.1, + min=0, max=1, + readout_format='.1f', + style={'description_width': 'initial'} + ) + reference_slider.observe(update_reference_opacity, names=['value']) + children.append(reference_slider) + self._map = m children.append(m) From 40b2021b78f7678e199f454b9541d56a227f8726 Mon Sep 17 00:00:00 2001 From: Anne Haley Date: Tue, 22 Jul 2025 10:07:33 -0400 Subject: [PATCH 07/29] Fix formatting --- large_image/tilesource/jupyter.py | 105 ++++++++++++++++-------------- 1 file changed, 57 insertions(+), 48 deletions(-) diff --git a/large_image/tilesource/jupyter.py b/large_image/tilesource/jupyter.py index baa128086..c61c9c145 100644 --- a/large_image/tilesource/jupyter.py +++ b/large_image/tilesource/jupyter.py @@ -21,11 +21,11 @@ import os import threading import weakref -import yaml from typing import Any, Optional, Union, cast from urllib.parse import parse_qs, quote, urlencode, urlparse, urlunparse import numpy as np +import yaml import large_image from large_image.exceptions import TileSourceError, TileSourceXYZRangeError @@ -379,7 +379,7 @@ def update_reference_opacity(event): value=default_opacity, step=0.1, min=0, max=1, readout_format='.1f', - style={'description_width': 'initial'} + style={'description_width': 'initial'}, ) reference_slider.observe(update_reference_opacity, names=['value']) children.append(reference_slider) @@ -398,7 +398,7 @@ def update_reference_opacity(event): def add_warp_editor(self): from ipyleaflet import DivIcon, Marker - from ipywidgets import Accordion, Checkbox, HTML, Label, VBox + from ipywidgets import HTML, Accordion, Checkbox, Label, VBox help_text = Label('To begin editing a warp, click on the image to place reference points.') transform_checkbox = Checkbox(description='Show Transformed', value=True) @@ -415,17 +415,21 @@ def add_warp_editor(self): ) def update_schemas(): - help_text.value = 'Reference the schemas below to use this warp with the MultiFileTileSource (either as YAML or JSON).' + help_text.value = ( + 'Reference the schemas below to use this warp with ' + 'the MultiFileTileSource (either as YAML or JSON).' + ) matched_points = { - k: [point for index, point in enumerate(v) if self.warp_points.get('src')[index] and self.warp_points.get('dst')[index]] + k: [point for index, point in enumerate(v) if self.warp_points.get( + 'src')[index] and self.warp_points.get('dst')[index]] for k, v in self.warp_points.items() } schema = dict(sources=[ dict( # TODO: is there a better way to get the path value? path=str(self._ts._initValues[0][0]), - z=0, position=dict(x=0, y=0, warp=matched_points) - ) + z=0, position=dict(x=0, y=0, warp=matched_points), + ), ]) json_schema.value = f'
{json.dumps(schema, indent=4)}
' yaml_schema.value = f'
{yaml.dump(schema)}
' @@ -463,7 +467,9 @@ def handle_interaction(**kwargs): self._map.add(marker) self.warp_points['src'].append(None) self.warp_points['dst'].append(convert_coordinate(coords)) - help_text.value = 'After placing reference points, you can drag them to define the warp.' + help_text.value = ( + 'After placing reference points, you can drag them to define the warp.' + ) def toggle_transform(event): self.update_warp(event.get('new')) @@ -616,7 +622,8 @@ def update_warp(self, show_warp): current_frame = self.frame_selector.currentFrame if self.frame_selector is not None else 0 if show_warp and len(self.warp_points.get('src')): matched_points = { - k: [point for index, point in enumerate(v) if self.warp_points.get('src')[index] and self.warp_points.get('dst')[index]] + k: [point for index, point in enumerate(v) if self.warp_points.get( + 'src')[index] and self.warp_points.get('dst')[index]] for k, v in self.warp_points.items() } self.update_layer_query(frame=current_frame, style=dict(warp=matched_points)) @@ -692,6 +699,8 @@ def default(self, obj): multi_source = None + + def _lazyImportMultiSource(): """ Import the large_image_source_multi module. This is only needed when editWarp is used. @@ -706,45 +715,46 @@ def _lazyImportMultiSource(): raise TileSourceError(msg) -def launch_tile_server(tile_source: IPyLeafletMixin, port: int = 0) -> Any: - import tornado.httpserver - import tornado.netutil - import tornado.web +class RequestManager: + def __init__(self, tile_source: IPyLeafletMixin) -> None: + self._tile_source_ = weakref.ref(tile_source) + self._ports = () - class RequestManager: - def __init__(self, tile_source: IPyLeafletMixin) -> None: - self._tile_source_ = weakref.ref(tile_source) - self._ports = () + @property + def tile_source(self) -> IPyLeafletMixin: + return cast(IPyLeafletMixin, self._tile_source_()) - @property - def tile_source(self) -> IPyLeafletMixin: - return cast(IPyLeafletMixin, self._tile_source_()) + @tile_source.setter + def tile_source(self, source: IPyLeafletMixin) -> None: + self._tile_source_ = weakref.ref(source) - @tile_source.setter - def tile_source(self, source: IPyLeafletMixin) -> None: - self._tile_source_ = weakref.ref(source) + @property + def ports(self) -> tuple[int, ...]: + return self._ports - @property - def ports(self) -> tuple[int, ...]: - return self._ports + @property + def port(self) -> int: + return self.ports[0] + + def get_warp_source(self, warp): + if multi_source is None: + _lazyImportMultiSource() + # TODO: is there a better way to get the path value? + path = str(self.tile_source._initValues[0][0]) + return multi_source.open(dict(sources=[ + dict( + path=path, + position=dict( + x=0, y=0, warp=warp, + ), + ), + ])) - @property - def port(self) -> int: - return self.ports[0] - - def get_warp_source(self, warp): - if multi_source is None: - _lazyImportMultiSource() - # TODO: is there a better way to get the path value? - path = str(self.tile_source._initValues[0][0]) - return multi_source.open(dict(sources=[ - dict( - path=path, - position=dict( - x=0, y=0, warp=warp - ) - ) - ])) + +def launch_tile_server(tile_source: IPyLeafletMixin, port: int = 0) -> Any: + import tornado.httpserver + import tornado.netutil + import tornado.web manager = RequestManager(tile_source) # NOTE: set `ports` manually after launching server @@ -793,12 +803,11 @@ def get(self) -> None: if warp is None: manager.tile_source.style = style # type: ignore[attr-defined] try: + source = manager.tile_source if warp is not None: - tile_binary = manager.get_warp_source(warp).getTile( # type: ignore[attr-defined] - x, y, z, encoding=encoding, frame=frame) - else: - tile_binary = manager.tile_source.getTile( # type: ignore[attr-defined] - x, y, z, encoding=encoding, frame=frame) + source = manager.get_warp_source(warp) + tile_binary = source.getTile( # type: ignore[attr-defined] + x, y, z, encoding=encoding, frame=frame) except TileSourceXYZRangeError as e: self.clear() self.set_status(404) From 6ad03398be801bb3dfb6d75ca091d4bfa4b4cd16 Mon Sep 17 00:00:00 2001 From: Anne Haley Date: Tue, 22 Jul 2025 11:58:32 -0400 Subject: [PATCH 08/29] Fix typing --- large_image/tilesource/jupyter.py | 91 ++++++++++++++++++------------- 1 file changed, 53 insertions(+), 38 deletions(-) diff --git a/large_image/tilesource/jupyter.py b/large_image/tilesource/jupyter.py index c61c9c145..93950577d 100644 --- a/large_image/tilesource/jupyter.py +++ b/large_image/tilesource/jupyter.py @@ -30,6 +30,7 @@ import large_image from large_image.exceptions import TileSourceError, TileSourceXYZRangeError from large_image.tilesource.utilities import JSONDict +from large_image.widgets.components import FrameSelector ipyleafletPresent = importlib.util.find_spec('ipyleaflet') is not None ipyvuePresent = importlib.util.find_spec('ipyvue') is not None @@ -166,6 +167,11 @@ def iplmap(self) -> Any: def warp_points(self): return self._map.warp_points + @property + def image_path(self): + # TODO: is there a better way to get the path value? + return str(self._initValues[0][0]) # type: ignore[attr-defined] + class Map: """ @@ -195,14 +201,15 @@ def __init__( :param resource: a girder resource path of an item or file that exists on the girder client. """ - self._layer = self._map = self._metadata = self.frame_selector = None + self._layer = self._map = self._metadata = None + self.frame_selector: Optional[FrameSelector] = None self._frame_histograms: Optional[dict[int, Any]] = None self._ts = ts self._edit_warp = editWarp self._reference = reference self._reference_layer = None if self._edit_warp: - self.warp_points = dict(src=[], dst=[]) + self.warp_points: dict[str, list[Optional[list[int]]]] = dict(src=[], dst=[]) if (not url or not metadata) and gc and (id or resource): fileId = None if id is None: @@ -341,8 +348,6 @@ def make_map( children: list[Any] = [] frames = metadata.get('frames') if frames is not None and ipyvuePresent and aiohttpPresent: - from large_image.widgets.components import FrameSelector - self.frame_selector = FrameSelector() self.frame_selector.imageMetadata = metadata self.frame_selector.updateFrameCallback = self.update_frame @@ -368,11 +373,13 @@ def make_map( if self._reference is not None: default_opacity = 0.5 self._reference_layer = self._reference.as_leaflet_layer() - self._reference_layer.opacity = default_opacity - m.add_layer(self._reference_layer) + if self._reference_layer is not None: + self._reference_layer.opacity = default_opacity + m.add_layer(self._reference_layer) def update_reference_opacity(event): - self._reference_layer.opacity = event.get('new', default_opacity) + if self._reference_layer is not None: + self._reference_layer.opacity = event.get('new', default_opacity) reference_slider = FloatSlider( description='Reference Opacity', @@ -415,20 +422,16 @@ def add_warp_editor(self): ) def update_schemas(): + if self._ts is None: + return help_text.value = ( 'Reference the schemas below to use this warp with ' 'the MultiFileTileSource (either as YAML or JSON).' ) - matched_points = { - k: [point for index, point in enumerate(v) if self.warp_points.get( - 'src')[index] and self.warp_points.get('dst')[index]] - for k, v in self.warp_points.items() - } schema = dict(sources=[ dict( - # TODO: is there a better way to get the path value? - path=str(self._ts._initValues[0][0]), - z=0, position=dict(x=0, y=0, warp=matched_points), + path=self._ts.image_path, + z=0, position=dict(x=0, y=0, warp=self.get_matched_warp_points()), ), ]) json_schema.value = f'
{json.dumps(schema, indent=4)}
' @@ -453,7 +456,8 @@ def handle_drag(event): icon = DivIcon(html=html, icon_size=[0, 0]) marker = Marker(location=old, draggable=True, icon=icon, title=f'src {index}') marker.observe(handle_drag, 'location') - self._map.add(marker) + if self._map is not None: + self._map.add(marker) update_schemas() def handle_interaction(**kwargs): @@ -464,7 +468,8 @@ def handle_interaction(**kwargs): icon = DivIcon(html=html, icon_size=[0, 0]) marker = Marker(location=coords, draggable=True, icon=icon, title=f'dst {index}') marker.observe(handle_drag, 'location') - self._map.add(marker) + if self._map is not None: + self._map.add(marker) self.warp_points['src'].append(None) self.warp_points['dst'].append(convert_coordinate(coords)) help_text.value = ( @@ -475,7 +480,8 @@ def toggle_transform(event): self.update_warp(event.get('new')) transform_checkbox.observe(toggle_transform, names=['value']) - self._map.on_interaction(handle_interaction) + if self._map is not None: + self._map.on_interaction(handle_interaction) return VBox(children) def add_region_indicator(self): @@ -618,14 +624,24 @@ def from_map(self, coordinate: Union[list[float], tuple[float, float]]) -> tuple return transf.transform(x, y) return x, self._metadata['sizeY'] - y + def get_matched_warp_points(self): + src = self.warp_points.get('src') + dst = self.warp_points.get('dst') + if src is None or dst is None: + return {} + return { + k: [ + point for index, point in enumerate(v) + if len(src) > index and src[index] and + len(dst) > index and dst[index] + ] + for k, v in self.warp_points.items() + } + def update_warp(self, show_warp): current_frame = self.frame_selector.currentFrame if self.frame_selector is not None else 0 - if show_warp and len(self.warp_points.get('src')): - matched_points = { - k: [point for index, point in enumerate(v) if self.warp_points.get( - 'src')[index] and self.warp_points.get('dst')[index]] - for k, v in self.warp_points.items() - } + matched_points = self.get_matched_warp_points() + if show_warp and len(matched_points): self.update_layer_query(frame=current_frame, style=dict(warp=matched_points)) else: self.update_layer_query(frame=current_frame, style=dict()) @@ -677,9 +693,10 @@ async def fetch(url): async with session.get(url) as response: self._frame_histograms[frame] = await response.json() # type: ignore # rewrite whole object for watcher - self.frame_selector.frameHistograms = ( - self._frame_histograms.copy() # type: ignore - ) + if self.frame_selector is not None and self._frame_histograms is not None: + self.frame_selector.frameHistograms = ( + self._frame_histograms.copy() # type: ignore + ) asyncio.ensure_future(fetch(histogram_url)) @@ -737,18 +754,16 @@ def port(self) -> int: return self.ports[0] def get_warp_source(self, warp): - if multi_source is None: - _lazyImportMultiSource() - # TODO: is there a better way to get the path value? - path = str(self.tile_source._initValues[0][0]) - return multi_source.open(dict(sources=[ - dict( - path=path, - position=dict( - x=0, y=0, warp=warp, + _lazyImportMultiSource() + if multi_source is not None and self.tile_source is not None: + return multi_source.open(dict(sources=[ + dict( + path=self.tile_source.image_path, + position=dict( + x=0, y=0, warp=warp, + ), ), - ), - ])) + ])) def launch_tile_server(tile_source: IPyLeafletMixin, port: int = 0) -> Any: From 03d60c7f214868cc780b9dc8e1efca3ea7355ae0 Mon Sep 17 00:00:00 2001 From: Anne Haley Date: Tue, 22 Jul 2025 12:33:44 -0400 Subject: [PATCH 09/29] Invert Y axis from map coordinates --- large_image/tilesource/jupyter.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/large_image/tilesource/jupyter.py b/large_image/tilesource/jupyter.py index 93950577d..6a48196fb 100644 --- a/large_image/tilesource/jupyter.py +++ b/large_image/tilesource/jupyter.py @@ -440,8 +440,11 @@ def update_schemas(): transform_checkbox.layout.display = 'block' self.update_warp(transform_checkbox.value) - def convert_coordinate(coord): - return [coord[1], coord[0]] + def convert_coordinate(map_coord): + y, x = map_coord + if self._ts is not None: + y = self._ts.sizeY - y + return [x, y] def handle_drag(event): old = [round(v) for v in event.get('old')] From 5bcb3500644a57bcb104b110a81b49e1222f2b80 Mon Sep 17 00:00:00 2001 From: Anne Haley Date: Tue, 22 Jul 2025 13:51:12 -0400 Subject: [PATCH 10/29] Set width and height of warped multi source --- large_image/tilesource/jupyter.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/large_image/tilesource/jupyter.py b/large_image/tilesource/jupyter.py index 6a48196fb..611748723 100644 --- a/large_image/tilesource/jupyter.py +++ b/large_image/tilesource/jupyter.py @@ -759,14 +759,18 @@ def port(self) -> int: def get_warp_source(self, warp): _lazyImportMultiSource() if multi_source is not None and self.tile_source is not None: - return multi_source.open(dict(sources=[ - dict( - path=self.tile_source.image_path, - position=dict( - x=0, y=0, warp=warp, + return multi_source.open(dict( + sources=[ + dict( + path=self.tile_source.image_path, + position=dict( + x=0, y=0, warp=warp, + ), ), - ), - ])) + ], + width=self.tile_source.sizeX, + height=self.tile_source.sizeY, + )) def launch_tile_server(tile_source: IPyLeafletMixin, port: int = 0) -> Any: From 586c88d1a56e342196874fb611132d48b9c58346 Mon Sep 17 00:00:00 2001 From: Anne Haley Date: Tue, 22 Jul 2025 16:17:08 -0400 Subject: [PATCH 11/29] Don't allow warp editor mode if source path does not exist --- large_image/tilesource/jupyter.py | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/large_image/tilesource/jupyter.py b/large_image/tilesource/jupyter.py index 611748723..2a9bdeddd 100644 --- a/large_image/tilesource/jupyter.py +++ b/large_image/tilesource/jupyter.py @@ -167,11 +167,6 @@ def iplmap(self) -> Any: def warp_points(self): return self._map.warp_points - @property - def image_path(self): - # TODO: is there a better way to get the path value? - return str(self._initValues[0][0]) # type: ignore[attr-defined] - class Map: """ @@ -403,10 +398,20 @@ def update_reference_opacity(event): self._map.add(FullScreenControl()) return VBox(children) + def warp_editor_validate_source(self): + if self._ts is None: + msg = 'Warp editor mode not allowed; source is not defined.' + raise TileSourceError(msg) + + if not os.path.exists(str(self._ts.largeImagePath)): # type: ignore[attr-defined] + msg = 'Warp editor mode not allowed; source file does not exist.' + raise TileSourceError(msg) + def add_warp_editor(self): from ipyleaflet import DivIcon, Marker from ipywidgets import HTML, Accordion, Checkbox, Label, VBox + self.warp_editor_validate_source() help_text = Label('To begin editing a warp, click on the image to place reference points.') transform_checkbox = Checkbox(description='Show Transformed', value=True) transform_checkbox.layout.display = 'none' @@ -430,7 +435,7 @@ def update_schemas(): ) schema = dict(sources=[ dict( - path=self._ts.image_path, + path=str(self._ts.largeImagePath), # type: ignore[attr-defined] z=0, position=dict(x=0, y=0, warp=self.get_matched_warp_points()), ), ]) @@ -443,7 +448,7 @@ def update_schemas(): def convert_coordinate(map_coord): y, x = map_coord if self._ts is not None: - y = self._ts.sizeY - y + y = self._ts.sizeY - y # type: ignore[attr-defined] return [x, y] def handle_drag(event): @@ -762,7 +767,7 @@ def get_warp_source(self, warp): return multi_source.open(dict( sources=[ dict( - path=self.tile_source.image_path, + path=str(self.tile_source.largeImagePath), position=dict( x=0, y=0, warp=warp, ), From c1ad0201c618f41b75a56df619152f709d6d40c9 Mon Sep 17 00:00:00 2001 From: Anne Haley Date: Mon, 28 Jul 2025 16:48:42 -0400 Subject: [PATCH 12/29] Create both src and dst point on click --- large_image/tilesource/jupyter.py | 42 +++++++++++++++++-------------- 1 file changed, 23 insertions(+), 19 deletions(-) diff --git a/large_image/tilesource/jupyter.py b/large_image/tilesource/jupyter.py index 2a9bdeddd..33d788402 100644 --- a/large_image/tilesource/jupyter.py +++ b/large_image/tilesource/jupyter.py @@ -451,35 +451,39 @@ def convert_coordinate(map_coord): y = self._ts.sizeY - y # type: ignore[attr-defined] return [x, y] + def create_reference_point_pair(coord): + converted = convert_coordinate(coord) + index = len(self.warp_points['src']) + self.warp_points['src'].append(converted) + self.warp_points['dst'].append(converted) + + for group_name, color in [ + ('src', '#ff6a5e'), ('dst', '#19a7ff'), + ]: + html = f'
{index}
' + icon = DivIcon(html=html, icon_size=[0, 0]) + marker = Marker( + location=coord, + draggable=True, + icon=icon, + title=f'{group_name} {index}', + ) + marker.observe(handle_drag, 'location') + if self._map is not None: + self._map.add(marker) + def handle_drag(event): - old = [round(v) for v in event.get('old')] new = [round(v) for v in event.get('new')] marker_title = event.get('owner').title group_name = marker_title[:3] index = int(marker_title[3:]) self.warp_points[group_name][index] = convert_coordinate(new) - if group_name == 'dst' and self.warp_points['src'][index] is None: - self.warp_points['src'][index] = convert_coordinate(old) - html = f'
{index}
' - icon = DivIcon(html=html, icon_size=[0, 0]) - marker = Marker(location=old, draggable=True, icon=icon, title=f'src {index}') - marker.observe(handle_drag, 'location') - if self._map is not None: - self._map.add(marker) update_schemas() def handle_interaction(**kwargs): if kwargs.get('type') == 'click': - coords = kwargs.get('coordinates') - index = len(self.warp_points['src']) - html = f'
{index}
' - icon = DivIcon(html=html, icon_size=[0, 0]) - marker = Marker(location=coords, draggable=True, icon=icon, title=f'dst {index}') - marker.observe(handle_drag, 'location') - if self._map is not None: - self._map.add(marker) - self.warp_points['src'].append(None) - self.warp_points['dst'].append(convert_coordinate(coords)) + create_reference_point_pair(kwargs.get('coordinates')) + update_schemas() help_text.value = ( 'After placing reference points, you can drag them to define the warp.' ) From 772c49d018e489d90b221ea9ba4ad9160998df5c Mon Sep 17 00:00:00 2001 From: Anne Haley Date: Mon, 28 Jul 2025 16:50:08 -0400 Subject: [PATCH 13/29] Use `noCache=False` on warped multi source --- large_image/tilesource/jupyter.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/large_image/tilesource/jupyter.py b/large_image/tilesource/jupyter.py index 33d788402..8637d5302 100644 --- a/large_image/tilesource/jupyter.py +++ b/large_image/tilesource/jupyter.py @@ -779,7 +779,7 @@ def get_warp_source(self, warp): ], width=self.tile_source.sizeX, height=self.tile_source.sizeY, - )) + ), noCache=False) def launch_tile_server(tile_source: IPyLeafletMixin, port: int = 0) -> Any: From a837250efdb7d0473dae7c4c197a762f6e423864 Mon Sep 17 00:00:00 2001 From: Anne Haley Date: Tue, 29 Jul 2025 11:44:45 -0400 Subject: [PATCH 14/29] Add buttons to copy schemas to clipboard --- large_image/tilesource/jupyter.py | 49 +++++++++++++++++++++++++------ 1 file changed, 40 insertions(+), 9 deletions(-) diff --git a/large_image/tilesource/jupyter.py b/large_image/tilesource/jupyter.py index 8637d5302..93e8c49f0 100644 --- a/large_image/tilesource/jupyter.py +++ b/large_image/tilesource/jupyter.py @@ -19,6 +19,8 @@ import importlib.util import json import os +import re +import time import threading import weakref from typing import Any, Optional, Union, cast @@ -409,7 +411,8 @@ def warp_editor_validate_source(self): def add_warp_editor(self): from ipyleaflet import DivIcon, Marker - from ipywidgets import HTML, Accordion, Checkbox, Label, VBox + from IPython.display import Javascript + from ipywidgets import HTML, Accordion, Button, Checkbox, Label, Output, VBox self.warp_editor_validate_source() help_text = Label('To begin editing a warp, click on the image to place reference points.') @@ -417,15 +420,46 @@ def add_warp_editor(self): transform_checkbox.layout.display = 'none' yaml_schema = HTML('yaml') json_schema = HTML('json') - schemas = Accordion(children=[yaml_schema, json_schema], titles=('YAML', 'JSON')) + copy_yaml_button = Button(description='Copy YAML', icon='fa-copy') + copy_json_button = Button(description='Copy JSON', icon='fa-copy') + yaml_box = VBox(children=[copy_yaml_button, yaml_schema]) + json_box = VBox(children=[copy_json_button, json_schema]) + schemas = Accordion(children=[yaml_box, json_box], titles=('YAML', 'JSON')) schemas.layout.display = 'none' - children = [transform_checkbox, help_text, schemas] + schema_copy_output = Output() + schema_copy_output.layout.display = 'none' + children = [transform_checkbox, help_text, schemas, schema_copy_output] marker_style = ( 'border-radius: 50%; position: relative;' 'height: 16px; width: 16px; top: -8px; left: -8px;' 'text-align: center; font-size: 11px;' ) + def get_schema(): + return dict(sources=[ + dict( + path=str(self._ts.largeImagePath), # type: ignore[attr-defined] + z=0, position=dict(x=0, y=0, warp=self.get_matched_warp_points()), + ), + ]) + + def copy_schema(button): + content = '' + schema = get_schema() + desc = button.description + if 'YAML' in desc: + content = yaml.dump(schema) + elif 'JSON' in desc: + content = json.dumps(schema) + copy_js = Javascript(f"navigator.clipboard.writeText('{re.escape(content)}')") + schema_copy_output.clear_output() + schema_copy_output.append_display_data(copy_js) + button.description = 'Copied!' + button.icon = 'fa-check' + time.sleep(2) + button.description = desc + button.icon = 'fa-copy' + def update_schemas(): if self._ts is None: return @@ -433,12 +467,7 @@ def update_schemas(): 'Reference the schemas below to use this warp with ' 'the MultiFileTileSource (either as YAML or JSON).' ) - schema = dict(sources=[ - dict( - path=str(self._ts.largeImagePath), # type: ignore[attr-defined] - z=0, position=dict(x=0, y=0, warp=self.get_matched_warp_points()), - ), - ]) + schema = get_schema() json_schema.value = f'
{json.dumps(schema, indent=4)}
' yaml_schema.value = f'
{yaml.dump(schema)}
' schemas.layout.display = 'block' @@ -492,6 +521,8 @@ def toggle_transform(event): self.update_warp(event.get('new')) transform_checkbox.observe(toggle_transform, names=['value']) + copy_yaml_button.on_click(copy_schema) + copy_json_button.on_click(copy_schema) if self._map is not None: self._map.on_interaction(handle_interaction) return VBox(children) From e2a2140798beb822795f068771921d305f296d3d Mon Sep 17 00:00:00 2001 From: Anne Haley Date: Tue, 29 Jul 2025 13:37:55 -0400 Subject: [PATCH 15/29] Allow removal of point pairs with double-click events --- large_image/tilesource/jupyter.py | 43 +++++++++++++++++-------------- 1 file changed, 23 insertions(+), 20 deletions(-) diff --git a/large_image/tilesource/jupyter.py b/large_image/tilesource/jupyter.py index 93e8c49f0..d29a03fca 100644 --- a/large_image/tilesource/jupyter.py +++ b/large_image/tilesource/jupyter.py @@ -429,6 +429,7 @@ def add_warp_editor(self): schema_copy_output = Output() schema_copy_output.layout.display = 'none' children = [transform_checkbox, help_text, schemas, schema_copy_output] + markers = {'src': [], 'dst': []} marker_style = ( 'border-radius: 50%; position: relative;' 'height: 16px; width: 16px; top: -8px; left: -8px;' @@ -439,7 +440,7 @@ def get_schema(): return dict(sources=[ dict( path=str(self._ts.largeImagePath), # type: ignore[attr-defined] - z=0, position=dict(x=0, y=0, warp=self.get_matched_warp_points()), + z=0, position=dict(x=0, y=0, warp=self.warp_points), ), ]) @@ -478,7 +479,7 @@ def convert_coordinate(map_coord): y, x = map_coord if self._ts is not None: y = self._ts.sizeY - y # type: ignore[attr-defined] - return [x, y] + return [int(x), int(y)] def create_reference_point_pair(coord): converted = convert_coordinate(coord) @@ -497,10 +498,26 @@ def create_reference_point_pair(coord): icon=icon, title=f'{group_name} {index}', ) + marker.on_dblclick(lambda **e: remove_reference_point_pair(marker)) marker.observe(handle_drag, 'location') + markers[group_name].append(marker) if self._map is not None: self._map.add(marker) + def remove_reference_point_pair(marker): + index = int(marker.title[3:]) + for group_name in ['src', 'dst']: + del self.warp_points[group_name][index] + if self._map is not None: + self._map.remove(markers[group_name][index]) + del markers[group_name][index] + # reset indices of remaining markers + for i, m in enumerate(markers[group_name]): + m.title = f'{group_name} {i}' + html = re.sub(r'>\d+<', f'>{i}<', m.icon.html) + m.icon = DivIcon(html=html, icon_size=[0, 0]) + update_schemas() + def handle_drag(event): new = [round(v) for v in event.get('new')] marker_title = event.get('owner').title @@ -514,7 +531,8 @@ def handle_interaction(**kwargs): create_reference_point_pair(kwargs.get('coordinates')) update_schemas() help_text.value = ( - 'After placing reference points, you can drag them to define the warp.' + 'After placing reference points, you can drag them to define the warp. ' + 'You may also double-click any point to remove the point pair.' ) def toggle_transform(event): @@ -667,25 +685,10 @@ def from_map(self, coordinate: Union[list[float], tuple[float, float]]) -> tuple return transf.transform(x, y) return x, self._metadata['sizeY'] - y - def get_matched_warp_points(self): - src = self.warp_points.get('src') - dst = self.warp_points.get('dst') - if src is None or dst is None: - return {} - return { - k: [ - point for index, point in enumerate(v) - if len(src) > index and src[index] and - len(dst) > index and dst[index] - ] - for k, v in self.warp_points.items() - } - def update_warp(self, show_warp): current_frame = self.frame_selector.currentFrame if self.frame_selector is not None else 0 - matched_points = self.get_matched_warp_points() - if show_warp and len(matched_points): - self.update_layer_query(frame=current_frame, style=dict(warp=matched_points)) + if show_warp and len(self.warp_points['src']): + self.update_layer_query(frame=current_frame, style=dict(warp=self.warp_points)) else: self.update_layer_query(frame=current_frame, style=dict()) From 6c76080ee4dc047d38a765756010fba5af30fece Mon Sep 17 00:00:00 2001 From: Anne Haley Date: Tue, 29 Jul 2025 14:25:31 -0400 Subject: [PATCH 16/29] Refactor to split complex function --- large_image/tilesource/jupyter.py | 156 +++++++++++++++++------------- 1 file changed, 90 insertions(+), 66 deletions(-) diff --git a/large_image/tilesource/jupyter.py b/large_image/tilesource/jupyter.py index d29a03fca..a01e17d5a 100644 --- a/large_image/tilesource/jupyter.py +++ b/large_image/tilesource/jupyter.py @@ -20,8 +20,8 @@ import json import os import re -import time import threading +import time import weakref from typing import Any, Optional, Union, cast from urllib.parse import parse_qs, quote, urlencode, urlparse, urlunparse @@ -207,6 +207,7 @@ def __init__( self._reference_layer = None if self._edit_warp: self.warp_points: dict[str, list[Optional[list[int]]]] = dict(src=[], dst=[]) + self._warp_widgets: dict = dict() if (not url or not metadata) and gc and (id or resource): fileId = None if id is None: @@ -409,44 +410,30 @@ def warp_editor_validate_source(self): msg = 'Warp editor mode not allowed; source file does not exist.' raise TileSourceError(msg) - def add_warp_editor(self): - from ipyleaflet import DivIcon, Marker - from IPython.display import Javascript - from ipywidgets import HTML, Accordion, Button, Checkbox, Label, Output, VBox + def get_warp_schema(self): + return dict(sources=[ + dict( + path=str(self._ts.largeImagePath), # type: ignore[attr-defined] + z=0, position=dict(x=0, y=0, warp=self.warp_points), + ), + ]) - self.warp_editor_validate_source() - help_text = Label('To begin editing a warp, click on the image to place reference points.') - transform_checkbox = Checkbox(description='Show Transformed', value=True) - transform_checkbox.layout.display = 'none' - yaml_schema = HTML('yaml') - json_schema = HTML('json') - copy_yaml_button = Button(description='Copy YAML', icon='fa-copy') - copy_json_button = Button(description='Copy JSON', icon='fa-copy') - yaml_box = VBox(children=[copy_yaml_button, yaml_schema]) - json_box = VBox(children=[copy_json_button, json_schema]) - schemas = Accordion(children=[yaml_box, json_box], titles=('YAML', 'JSON')) - schemas.layout.display = 'none' - schema_copy_output = Output() - schema_copy_output.layout.display = 'none' - children = [transform_checkbox, help_text, schemas, schema_copy_output] - markers = {'src': [], 'dst': []} - marker_style = ( - 'border-radius: 50%; position: relative;' - 'height: 16px; width: 16px; top: -8px; left: -8px;' - 'text-align: center; font-size: 11px;' - ) + def convert_warp_coordinate(self, map_coord): + y, x = map_coord + if self._ts is not None: + y = self._ts.sizeY - y # type: ignore[attr-defined] + return [int(x), int(y)] - def get_schema(): - return dict(sources=[ - dict( - path=str(self._ts.largeImagePath), # type: ignore[attr-defined] - z=0, position=dict(x=0, y=0, warp=self.warp_points), - ), - ]) + def toggle_warp_transform(self, event): + self.update_warp(event.get('new')) - def copy_schema(button): + def copy_warp_schema(self, button): + from IPython.display import Javascript + + schema_copy_output = self._warp_widgets.get('copy_output') + if schema_copy_output is not None: content = '' - schema = get_schema() + schema = self.get_warp_schema() desc = button.description if 'YAML' in desc: content = yaml.dump(schema) @@ -461,28 +448,71 @@ def copy_schema(button): button.description = desc button.icon = 'fa-copy' - def update_schemas(): - if self._ts is None: - return - help_text.value = ( - 'Reference the schemas below to use this warp with ' - 'the MultiFileTileSource (either as YAML or JSON).' - ) - schema = get_schema() - json_schema.value = f'
{json.dumps(schema, indent=4)}
' - yaml_schema.value = f'
{yaml.dump(schema)}
' - schemas.layout.display = 'block' - transform_checkbox.layout.display = 'block' - self.update_warp(transform_checkbox.value) - - def convert_coordinate(map_coord): - y, x = map_coord - if self._ts is not None: - y = self._ts.sizeY - y # type: ignore[attr-defined] - return [int(x), int(y)] + def update_warp_schemas(self): + yaml_schema = self._warp_widgets.get('yaml') + json_schema = self._warp_widgets.get('json') + schema_accordion = self._warp_widgets.get('accordion') + transform_checkbox = self._warp_widgets.get('transform') + help_text = self._warp_widgets.get('help_text') + if ( + self._ts is None or + yaml_schema is None or + json_schema is None or + schema_accordion is None or + transform_checkbox is None or + help_text is None + ): + return + schema = self.get_warp_schema() + yaml.Dumper.ignore_aliases = lambda self, data: True + yaml_schema.value = f'
{yaml.dump(schema)}
' + json_schema.value = f'
{json.dumps(schema, indent=4)}
' + schema_accordion.layout.display = 'block' + transform_checkbox.layout.display = 'block' + help_text.value = ( + 'Reference the schemas below to use this warp with ' + 'the MultiFileTileSource (either as YAML or JSON).' + ) + self.update_warp(transform_checkbox.value) + + def add_warp_editor(self): + from ipyleaflet import DivIcon, Marker + from ipywidgets import HTML, Accordion, Button, Checkbox, Label, Output, VBox + + self.warp_editor_validate_source() + markers = {'src': [], 'dst': []} + marker_style = ( + 'border-radius: 50%; position: relative;' + 'height: 16px; width: 16px; top: -8px; left: -8px;' + 'text-align: center; font-size: 11px;' + ) + help_text = Label('To begin editing a warp, click on the image to place reference points.') + transform_checkbox = Checkbox(description='Show Transformed', value=True) + transform_checkbox.layout.display = 'none' + transform_checkbox.observe(self.toggle_warp_transform, names=['value']) + yaml_schema = HTML('yaml') + json_schema = HTML('json') + copy_yaml_button = Button(description='Copy YAML', icon='fa-copy') + copy_json_button = Button(description='Copy JSON', icon='fa-copy') + copy_yaml_button.on_click(self.copy_warp_schema) + copy_json_button.on_click(self.copy_warp_schema) + yaml_box = VBox(children=[copy_yaml_button, yaml_schema]) + json_box = VBox(children=[copy_json_button, json_schema]) + schema_accordion = Accordion(children=[yaml_box, json_box], titles=('YAML', 'JSON')) + schema_accordion.layout.display = 'none' + schema_copy_output = Output() + schema_copy_output.layout.display = 'none' + self._warp_widgets = dict( + help_text=help_text, + transform=transform_checkbox, + yaml=yaml_schema, + json=json_schema, + accordion=schema_accordion, + copy_output=schema_copy_output, + ) def create_reference_point_pair(coord): - converted = convert_coordinate(coord) + converted = self.convert_warp_coordinate(coord) index = len(self.warp_points['src']) self.warp_points['src'].append(converted) self.warp_points['dst'].append(converted) @@ -516,34 +546,28 @@ def remove_reference_point_pair(marker): m.title = f'{group_name} {i}' html = re.sub(r'>\d+<', f'>{i}<', m.icon.html) m.icon = DivIcon(html=html, icon_size=[0, 0]) - update_schemas() + self.update_warp_schemas() def handle_drag(event): new = [round(v) for v in event.get('new')] marker_title = event.get('owner').title group_name = marker_title[:3] index = int(marker_title[3:]) - self.warp_points[group_name][index] = convert_coordinate(new) - update_schemas() + self.warp_points[group_name][index] = self.convert_warp_coordinate(new) + self.update_warp_schemas() def handle_interaction(**kwargs): if kwargs.get('type') == 'click': create_reference_point_pair(kwargs.get('coordinates')) - update_schemas() + self.update_warp_schemas() help_text.value = ( 'After placing reference points, you can drag them to define the warp. ' 'You may also double-click any point to remove the point pair.' ) - def toggle_transform(event): - self.update_warp(event.get('new')) - - transform_checkbox.observe(toggle_transform, names=['value']) - copy_yaml_button.on_click(copy_schema) - copy_json_button.on_click(copy_schema) if self._map is not None: self._map.on_interaction(handle_interaction) - return VBox(children) + return VBox([transform_checkbox, help_text, schema_accordion, schema_copy_output]) def add_region_indicator(self): from ipyleaflet import GeomanDrawControl, Popup From d9d9d36461d51c8553f80b05d52ad5e5c14578d8 Mon Sep 17 00:00:00 2001 From: Anne Haley Date: Tue, 29 Jul 2025 14:28:26 -0400 Subject: [PATCH 17/29] Fix `Function definition does not bind loop variable` --- large_image/tilesource/jupyter.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/large_image/tilesource/jupyter.py b/large_image/tilesource/jupyter.py index a01e17d5a..829b39c63 100644 --- a/large_image/tilesource/jupyter.py +++ b/large_image/tilesource/jupyter.py @@ -528,7 +528,7 @@ def create_reference_point_pair(coord): icon=icon, title=f'{group_name} {index}', ) - marker.on_dblclick(lambda **e: remove_reference_point_pair(marker)) + marker.on_dblclick(lambda m=marker, **e: remove_reference_point_pair(m)) marker.observe(handle_drag, 'location') markers[group_name].append(marker) if self._map is not None: From 4e44d4f663f12fe75fca0164b7f0ff3a246220e4 Mon Sep 17 00:00:00 2001 From: Anne Haley Date: Tue, 29 Jul 2025 14:47:19 -0400 Subject: [PATCH 18/29] Fix typing --- large_image/tilesource/jupyter.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/large_image/tilesource/jupyter.py b/large_image/tilesource/jupyter.py index 829b39c63..ff391abf7 100644 --- a/large_image/tilesource/jupyter.py +++ b/large_image/tilesource/jupyter.py @@ -411,6 +411,8 @@ def warp_editor_validate_source(self): raise TileSourceError(msg) def get_warp_schema(self): + if self._ts is None: + return return dict(sources=[ dict( path=str(self._ts.largeImagePath), # type: ignore[attr-defined] @@ -464,9 +466,10 @@ def update_warp_schemas(self): ): return schema = self.get_warp_schema() - yaml.Dumper.ignore_aliases = lambda self, data: True - yaml_schema.value = f'
{yaml.dump(schema)}
' - json_schema.value = f'
{json.dumps(schema, indent=4)}
' + json_content = json.dumps(schema, indent=4) + yaml_content = yaml.dump(json.loads(json_content)) # convert from json to avoid aliases + yaml_schema.value = f'
{yaml_content}
' + json_schema.value = f'
{json_content}
' schema_accordion.layout.display = 'block' transform_checkbox.layout.display = 'block' help_text.value = ( @@ -480,7 +483,7 @@ def add_warp_editor(self): from ipywidgets import HTML, Accordion, Button, Checkbox, Label, Output, VBox self.warp_editor_validate_source() - markers = {'src': [], 'dst': []} + markers: dict = {'src': [], 'dst': []} marker_style = ( 'border-radius: 50%; position: relative;' 'height: 16px; width: 16px; top: -8px; left: -8px;' From 6dd96f8c6322820e88b481c6cacadb16c383edef Mon Sep 17 00:00:00 2001 From: Anne Haley Date: Fri, 1 Aug 2025 14:41:18 -0400 Subject: [PATCH 19/29] Explicitly return None --- large_image/tilesource/jupyter.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/large_image/tilesource/jupyter.py b/large_image/tilesource/jupyter.py index ff391abf7..9d09e7f12 100644 --- a/large_image/tilesource/jupyter.py +++ b/large_image/tilesource/jupyter.py @@ -412,7 +412,7 @@ def warp_editor_validate_source(self): def get_warp_schema(self): if self._ts is None: - return + return None return dict(sources=[ dict( path=str(self._ts.largeImagePath), # type: ignore[attr-defined] From 4639160b61119cbd755601cbb0afcea44307f2f2 Mon Sep 17 00:00:00 2001 From: Anne Haley Date: Fri, 1 Aug 2025 17:18:52 -0400 Subject: [PATCH 20/29] When transform enabled, make src points invisible and compute their positions with inverse transform --- large_image/tilesource/jupyter.py | 86 ++++++++++++++++++++++++------- 1 file changed, 67 insertions(+), 19 deletions(-) diff --git a/large_image/tilesource/jupyter.py b/large_image/tilesource/jupyter.py index 9d09e7f12..701047e1c 100644 --- a/large_image/tilesource/jupyter.py +++ b/large_image/tilesource/jupyter.py @@ -38,6 +38,22 @@ ipyvuePresent = importlib.util.find_spec('ipyvue') is not None aiohttpPresent = importlib.util.find_spec('aiohttp') is not None +skimage_transform = None + + +def _lazyImportSkimageTransform(): + """ + Import the skimage.transform module. This is only needed when `editWarp=True` is used. + """ + global skimage_transform + + if skimage_transform is None: + try: + import skimage.transform as skimage_transform + except ImportError: + msg = 'scikit-image transform module not found.' + raise TileSourceError(msg) + class IPyLeafletMixin: """Mixin class to support interactive visualization in JupyterLab. @@ -208,6 +224,7 @@ def __init__( if self._edit_warp: self.warp_points: dict[str, list[Optional[list[int]]]] = dict(src=[], dst=[]) self._warp_widgets: dict = dict() + self._warp_markers: dict = {'src': [], 'dst': []} if (not url or not metadata) and gc and (id or resource): fileId = None if id is None: @@ -410,6 +427,41 @@ def warp_editor_validate_source(self): msg = 'Warp editor mode not allowed; source file does not exist.' raise TileSourceError(msg) + def convert_coordinate_map_to_warp(self, map_coord): + y, x = map_coord + if self._ts is not None: + y = self._ts.sizeY - y # type: ignore[attr-defined] + return [int(x), int(y)] + + def convert_coordinate_warp_to_map(self, warp_coord): + x, y = warp_coord + if self._ts is not None: + y = self._ts.sizeY - y # type: ignore[attr-defined] + return [int(y), int(x)] + + def toggle_warp_transform(self, event): + self.update_warp(event.get('new')) + + def inverse_warp(self, coord): + warp_src = self.warp_points['src'] + warp_dst = self.warp_points['dst'] + if len(warp_src) == 0: + return coord + if len(warp_src) == 1: + inverse_coord = [ + v + warp_src[0][i] - warp_dst[0][i] + for i, v in enumerate(coord) + ] + return inverse_coord + _lazyImportSkimageTransform() + if len(warp_src) <= 3: + transformer = skimage_transform.AffineTransform() + else: + transformer = skimage_transform.ThinPlateSplineTransform() + transformer.estimate(np.array(warp_dst), np.array(warp_src)) + inverse_coord = transformer([coord])[0] + return [int(v) for v in inverse_coord] + def get_warp_schema(self): if self._ts is None: return None @@ -420,15 +472,6 @@ def get_warp_schema(self): ), ]) - def convert_warp_coordinate(self, map_coord): - y, x = map_coord - if self._ts is not None: - y = self._ts.sizeY - y # type: ignore[attr-defined] - return [int(x), int(y)] - - def toggle_warp_transform(self, event): - self.update_warp(event.get('new')) - def copy_warp_schema(self, button): from IPython.display import Javascript @@ -483,7 +526,6 @@ def add_warp_editor(self): from ipywidgets import HTML, Accordion, Button, Checkbox, Label, Output, VBox self.warp_editor_validate_source() - markers: dict = {'src': [], 'dst': []} marker_style = ( 'border-radius: 50%; position: relative;' 'height: 16px; width: 16px; top: -8px; left: -8px;' @@ -515,10 +557,13 @@ def add_warp_editor(self): ) def create_reference_point_pair(coord): - converted = self.convert_warp_coordinate(coord) + converted = self.convert_coordinate_map_to_warp(coord) + locations = dict(src=converted, dst=converted) + if transform_checkbox.value: + locations['src'] = self.inverse_warp(locations['src']) index = len(self.warp_points['src']) - self.warp_points['src'].append(converted) - self.warp_points['dst'].append(converted) + self.warp_points['src'].append(locations['src']) + self.warp_points['dst'].append(locations['dst']) for group_name, color in [ ('src', '#ff6a5e'), ('dst', '#19a7ff'), @@ -526,14 +571,15 @@ def create_reference_point_pair(coord): html = f'
{index}
' icon = DivIcon(html=html, icon_size=[0, 0]) marker = Marker( - location=coord, + location=self.convert_coordinate_warp_to_map(locations[group_name]), draggable=True, icon=icon, title=f'{group_name} {index}', + visible=(group_name == 'dst' or not transform_checkbox.value), ) marker.on_dblclick(lambda m=marker, **e: remove_reference_point_pair(m)) marker.observe(handle_drag, 'location') - markers[group_name].append(marker) + self._warp_markers[group_name].append(marker) if self._map is not None: self._map.add(marker) @@ -542,10 +588,10 @@ def remove_reference_point_pair(marker): for group_name in ['src', 'dst']: del self.warp_points[group_name][index] if self._map is not None: - self._map.remove(markers[group_name][index]) - del markers[group_name][index] + self._map.remove(self._warp_markers[group_name][index]) + del self._warp_markers[group_name][index] # reset indices of remaining markers - for i, m in enumerate(markers[group_name]): + for i, m in enumerate(self._warp_markers[group_name]): m.title = f'{group_name} {i}' html = re.sub(r'>\d+<', f'>{i}<', m.icon.html) m.icon = DivIcon(html=html, icon_size=[0, 0]) @@ -556,7 +602,7 @@ def handle_drag(event): marker_title = event.get('owner').title group_name = marker_title[:3] index = int(marker_title[3:]) - self.warp_points[group_name][index] = self.convert_warp_coordinate(new) + self.warp_points[group_name][index] = self.convert_coordinate_map_to_warp(new) self.update_warp_schemas() def handle_interaction(**kwargs): @@ -713,6 +759,8 @@ def from_map(self, coordinate: Union[list[float], tuple[float, float]]) -> tuple return x, self._metadata['sizeY'] - y def update_warp(self, show_warp): + for marker in self._warp_markers['src']: + marker.visible = not show_warp current_frame = self.frame_selector.currentFrame if self.frame_selector is not None else 0 if show_warp and len(self.warp_points['src']): self.update_layer_query(frame=current_frame, style=dict(warp=self.warp_points)) From 97396fc4fa26aca7fb611db883a251df3cab615a Mon Sep 17 00:00:00 2001 From: Anne Haley Date: Tue, 5 Aug 2025 11:44:29 -0400 Subject: [PATCH 21/29] Update warp during drag, not just after release --- large_image/tilesource/jupyter.py | 34 +++++++++++++++++++++---------- 1 file changed, 23 insertions(+), 11 deletions(-) diff --git a/large_image/tilesource/jupyter.py b/large_image/tilesource/jupyter.py index 701047e1c..cc746e1ab 100644 --- a/large_image/tilesource/jupyter.py +++ b/large_image/tilesource/jupyter.py @@ -225,6 +225,7 @@ def __init__( self.warp_points: dict[str, list[Optional[list[int]]]] = dict(src=[], dst=[]) self._warp_widgets: dict = dict() self._warp_markers: dict = {'src': [], 'dst': []} + self._dragging_marker_id: Optional[str] = None if (not url or not metadata) and gc and (id or resource): fileId = None if id is None: @@ -521,6 +522,20 @@ def update_warp_schemas(self): ) self.update_warp(transform_checkbox.value) + def start_drag(self, marker): + self._dragging_marker_id = marker.title + + def handle_drag(self, coords): + if self._dragging_marker_id is not None: + marker_title = self._dragging_marker_id + group_name = marker_title[:3] + index = int(marker_title[3:]) + self.warp_points[group_name][index] = self.convert_coordinate_map_to_warp(coords) + self.update_warp_schemas() + + def end_drag(self): + self._dragging_marker_id = None + def add_warp_editor(self): from ipyleaflet import DivIcon, Marker from ipywidgets import HTML, Accordion, Button, Checkbox, Label, Output, VBox @@ -578,7 +593,8 @@ def create_reference_point_pair(coord): visible=(group_name == 'dst' or not transform_checkbox.value), ) marker.on_dblclick(lambda m=marker, **e: remove_reference_point_pair(m)) - marker.observe(handle_drag, 'location') + marker.on_mousedown(lambda m=marker, **e: self.start_drag(m)) + marker.on_mouseup(lambda **e: self.end_drag()) self._warp_markers[group_name].append(marker) if self._map is not None: self._map.add(marker) @@ -597,22 +613,18 @@ def remove_reference_point_pair(marker): m.icon = DivIcon(html=html, icon_size=[0, 0]) self.update_warp_schemas() - def handle_drag(event): - new = [round(v) for v in event.get('new')] - marker_title = event.get('owner').title - group_name = marker_title[:3] - index = int(marker_title[3:]) - self.warp_points[group_name][index] = self.convert_coordinate_map_to_warp(new) - self.update_warp_schemas() - def handle_interaction(**kwargs): - if kwargs.get('type') == 'click': - create_reference_point_pair(kwargs.get('coordinates')) + event_type = kwargs.get('type') + coords = [round(v) for v in kwargs.get('coordinates')] + if event_type == 'click': + create_reference_point_pair(coords) self.update_warp_schemas() help_text.value = ( 'After placing reference points, you can drag them to define the warp. ' 'You may also double-click any point to remove the point pair.' ) + elif event_type == 'mousemove': + self.handle_drag(coords) if self._map is not None: self._map.on_interaction(handle_interaction) From 965393928284045204577a5798706bf1743a41ed Mon Sep 17 00:00:00 2001 From: Anne Haley Date: Tue, 5 Aug 2025 12:13:15 -0400 Subject: [PATCH 22/29] Fix typing --- large_image/tilesource/jupyter.py | 28 +++++++++++++++------------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/large_image/tilesource/jupyter.py b/large_image/tilesource/jupyter.py index cc746e1ab..1ed0df9d6 100644 --- a/large_image/tilesource/jupyter.py +++ b/large_image/tilesource/jupyter.py @@ -449,19 +449,21 @@ def inverse_warp(self, coord): if len(warp_src) == 0: return coord if len(warp_src) == 1: - inverse_coord = [ - v + warp_src[0][i] - warp_dst[0][i] - for i, v in enumerate(coord) - ] - return inverse_coord + if warp_src[0] is not None and warp_dst[0] is not None: + inverse_coord = [ + v + warp_src[0][i] - warp_dst[0][i] + for i, v in enumerate(coord) + ] + return inverse_coord _lazyImportSkimageTransform() - if len(warp_src) <= 3: - transformer = skimage_transform.AffineTransform() - else: - transformer = skimage_transform.ThinPlateSplineTransform() - transformer.estimate(np.array(warp_dst), np.array(warp_src)) - inverse_coord = transformer([coord])[0] - return [int(v) for v in inverse_coord] + if skimage_transform is not None: + if len(warp_src) <= 3: + transformer = skimage_transform.AffineTransform() + else: + transformer = skimage_transform.ThinPlateSplineTransform() + transformer.estimate(np.array(warp_dst), np.array(warp_src)) + inverse_coord = transformer([coord])[0] + return [int(v) for v in inverse_coord] def get_warp_schema(self): if self._ts is None: @@ -615,7 +617,7 @@ def remove_reference_point_pair(marker): def handle_interaction(**kwargs): event_type = kwargs.get('type') - coords = [round(v) for v in kwargs.get('coordinates')] + coords = [round(v) for v in kwargs.get('coordinates', [])] if event_type == 'click': create_reference_point_pair(coords) self.update_warp_schemas() From 1662f60f80830ea369119c1832c379803f1da554 Mon Sep 17 00:00:00 2001 From: Anne Haley Date: Tue, 5 Aug 2025 17:11:51 -0400 Subject: [PATCH 23/29] Fix schema copying: ensure newline characters are copied --- large_image/tilesource/jupyter.py | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/large_image/tilesource/jupyter.py b/large_image/tilesource/jupyter.py index 1ed0df9d6..31f9b93c1 100644 --- a/large_image/tilesource/jupyter.py +++ b/large_image/tilesource/jupyter.py @@ -468,12 +468,16 @@ def inverse_warp(self, coord): def get_warp_schema(self): if self._ts is None: return None - return dict(sources=[ + schema = dict(sources=[ dict( path=str(self._ts.largeImagePath), # type: ignore[attr-defined] z=0, position=dict(x=0, y=0, warp=self.warp_points), ), ]) + json_content = json.dumps(schema, indent=4) + # convert from json to avoid aliases + yaml_content = yaml.dump(json.loads(json_content)) + return (json_content, yaml_content) def copy_warp_schema(self, button): from IPython.display import Javascript @@ -481,15 +485,16 @@ def copy_warp_schema(self, button): schema_copy_output = self._warp_widgets.get('copy_output') if schema_copy_output is not None: content = '' - schema = self.get_warp_schema() + json_content, yaml_content = self.get_warp_schema() desc = button.description if 'YAML' in desc: - content = yaml.dump(schema) + content = yaml_content elif 'JSON' in desc: - content = json.dumps(schema) - copy_js = Javascript(f"navigator.clipboard.writeText('{re.escape(content)}')") + content = json_content + content = content.replace('\n', '\\n') + command = f"navigator.clipboard.writeText(unescape('{content}'))" schema_copy_output.clear_output() - schema_copy_output.append_display_data(copy_js) + schema_copy_output.append_display_data(Javascript(command)) button.description = 'Copied!' button.icon = 'fa-check' time.sleep(2) @@ -511,9 +516,7 @@ def update_warp_schemas(self): help_text is None ): return - schema = self.get_warp_schema() - json_content = json.dumps(schema, indent=4) - yaml_content = yaml.dump(json.loads(json_content)) # convert from json to avoid aliases + json_content, yaml_content = self.get_warp_schema() yaml_schema.value = f'
{yaml_content}
' json_schema.value = f'
{json_content}
' schema_accordion.layout.display = 'block' From c8f9d7bff45f7f7b076a36fd4667efc9fee5aa56 Mon Sep 17 00:00:00 2001 From: Anne Haley Date: Wed, 6 Aug 2025 09:36:50 -0400 Subject: [PATCH 24/29] Use `SimilarityTransform` for colinear points --- large_image/tilesource/jupyter.py | 28 +++++++++++++------ .../large_image_source_multi/__init__.py | 10 +++++++ 2 files changed, 29 insertions(+), 9 deletions(-) diff --git a/large_image/tilesource/jupyter.py b/large_image/tilesource/jupyter.py index 31f9b93c1..83fa521c1 100644 --- a/large_image/tilesource/jupyter.py +++ b/large_image/tilesource/jupyter.py @@ -444,25 +444,35 @@ def toggle_warp_transform(self, event): self.update_warp(event.get('new')) def inverse_warp(self, coord): - warp_src = self.warp_points['src'] - warp_dst = self.warp_points['dst'] - if len(warp_src) == 0: + _lazyImportSkimageTransform() + + warp_src = np.array(self.warp_points['src']) + warp_dst = np.array(self.warp_points['dst']) + n_points = warp_src.shape[0] + inverse_coord = None + if n_points == 0: return coord - if len(warp_src) == 1: + if n_points == 1: if warp_src[0] is not None and warp_dst[0] is not None: inverse_coord = [ v + warp_src[0][i] - warp_dst[0][i] for i, v in enumerate(coord) ] - return inverse_coord - _lazyImportSkimageTransform() - if skimage_transform is not None: - if len(warp_src) <= 3: + elif skimage_transform is not None: + srcsvd = np.linalg.svd(warp_src - warp_src.mean(axis=0), compute_uv=False) + dstsvd = np.linalg.svd(warp_dst - warp_dst.mean(axis=0), compute_uv=False) + useSimilarity = n_points < 3 or min( + srcsvd[1] / (srcsvd[0] or 1), dstsvd[1] / (dstsvd[0] or 1)) < 1e-3 + + if useSimilarity: + transformer = skimage_transform.SimilarityTransform() + elif n_points <= 3: transformer = skimage_transform.AffineTransform() else: transformer = skimage_transform.ThinPlateSplineTransform() - transformer.estimate(np.array(warp_dst), np.array(warp_src)) + transformer.estimate(warp_dst, warp_src) inverse_coord = transformer([coord])[0] + if inverse_coord is not None: return [int(v) for v in inverse_coord] def get_warp_schema(self): diff --git a/sources/multi/large_image_source_multi/__init__.py b/sources/multi/large_image_source_multi/__init__.py index 1ed2c5321..15c49f485 100644 --- a/sources/multi/large_image_source_multi/__init__.py +++ b/sources/multi/large_image_source_multi/__init__.py @@ -685,11 +685,21 @@ def _getWarp(self, warp, m): warp_dst = np.array(warp_dst or []).astype(float) warp_src = warp_src[:min(warp_src.shape[0], warp_dst.shape[0]), :] warp_dst = warp_dst[:warp_src.shape[0], :] + + srcsvd = np.linalg.svd(warp_src - warp_src.mean(axis=0), compute_uv=False) + dstsvd = np.linalg.svd(warp_dst - warp_dst.mean(axis=0), compute_uv=False) + useSimilarity = warp_src.shape[0] < 3 or min( + srcsvd[1] / (srcsvd[0] or 1), dstsvd[1] / (dstsvd[0] or 1)) < 1e-3 + if warp_src.shape[0] < 1: pass elif warp_src.shape[0] == 1: m[0][2] += warp_dst[0][0] - warp_src[0][0] m[1][2] += warp_dst[0][1] - warp_src[0][1] + elif useSimilarity: + transformer = skimage_transform.SimilarityTransform() + transformer.estimate(warp_src, warp_dst) + m = transformer.params elif warp_src.shape[0] <= 3: transformer = skimage_transform.AffineTransform() transformer.estimate(warp_src, warp_dst) From dbc13689134334337f4e74adc5d5d3e20762f60a Mon Sep 17 00:00:00 2001 From: Anne Haley Date: Wed, 6 Aug 2025 10:22:00 -0400 Subject: [PATCH 25/29] Add basic tests for `editWarp` mode and `reference` mode --- test/test_jupyter.py | 74 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/test/test_jupyter.py b/test/test_jupyter.py index 0c42a5f0b..10ec30bd5 100644 --- a/test/test_jupyter.py +++ b/test/test_jupyter.py @@ -164,3 +164,77 @@ def testJupyterIpyleafletMapGeospatialRegion(): for callback in m._interaction_callbacks.callbacks: callback(type='click', coordinates=[lat, lon]) assert source._map.info_label.value == ''.join([f'
{e}
' for e in expected]) + + +def testJupyterReferenceLayer(): + testDir = os.path.dirname(os.path.realpath(__file__)) + imagePath1 = os.path.join(testDir, 'test_files', 'rgb_geotiff.tiff') + imagePath2 = os.path.join(testDir, 'test_files', 'rgba_geotiff.tiff') + source1 = large_image.open(imagePath1, projection='EPSG:3857', noCache=True) + source2 = large_image.open(imagePath2, projection='EPSG:3857', noCache=True, reference=source1) + display = source2._map.make_map( + source2.metadata, source2.as_leaflet_layer(), source2.getCenter(srs='EPSG:4326'), + ) + assert len(display.children) == 2 + [slider, leafletMap] = display.children + assert slider.description == 'Reference Opacity' + for path in [imagePath1, imagePath2]: + assert any(path in layer.url for layer in leafletMap.layers) + + +def initEditWarp(): + testDir = os.path.dirname(os.path.realpath(__file__)) + imagePath = os.path.join(testDir, 'test_files', 'rgb_geotiff.tiff') + source = large_image.open(imagePath, projection='EPSG:3857', noCache=True, editWarp=True) + display = source._map.make_map( + source.metadata, source.as_leaflet_layer(), source.getCenter(srs='EPSG:4326'), + ) + return source._map, display + + +def testJupyterEditWarp(): + _, display = initEditWarp() + assert len(display.children) == 2 + vbox = display.children[1] + assert len(vbox.children) == 4 + assert vbox.children[0].description == 'Show Transformed' + assert vbox.children[0].layout.display == 'none' + assert vbox.children[1].value == ( + 'To begin editing a warp, click on the image to place reference points.' + ) + assert vbox.children[2].titles == ('YAML', 'JSON') + assert vbox.children[3].layout.display == 'none' + + +def testJupyterEditWarpConvertCoords(): + sourceMap, _ = initEditWarp() + assert sourceMap.convert_coordinate_map_to_warp([10, 12]) == [12, 65526] + assert sourceMap.convert_coordinate_warp_to_map([12, 65526]) == [10, 12] + + +def testJupyterEditWarpInverseWarp(): + sourceMap, _ = initEditWarp() + # inverse from single ref point + sourceMap.warp_points = dict( + src=[[10, 10]], + dst=[[15, 15]], + ) + assert sourceMap.inverse_warp([5, 5]) == [0, 0] + # inverse from two ref points + sourceMap.warp_points = dict( + src=[[10, 10], [5, 5]], + dst=[[15, 15], [3, 4]], + ) + assert sourceMap.inverse_warp([2, 4]) == [4, 4] + # inverse from three ref points + sourceMap.warp_points = dict( + src=[[10, 10], [5, 5], [2, 4]], + dst=[[15, 15], [3, 4], [3, 6]], + ) + assert sourceMap.inverse_warp([6, 9]) == [2, 5] + # inverse from four ref points + sourceMap.warp_points = dict( + src=[[10, 10], [5, 5], [2, 4], [6, 9]], + dst=[[15, 15], [3, 4], [3, 6], [3, 5]], + ) + assert sourceMap.inverse_warp([8, 8]) == [7, 6] From e46ca2df79d7470f681fd6073d3f2bebc129132c Mon Sep 17 00:00:00 2001 From: Anne Haley Date: Wed, 6 Aug 2025 11:31:57 -0400 Subject: [PATCH 26/29] Move more functions outside of `add_warp_editor` function context --- large_image/tilesource/jupyter.py | 97 ++++++++++++++++--------------- 1 file changed, 51 insertions(+), 46 deletions(-) diff --git a/large_image/tilesource/jupyter.py b/large_image/tilesource/jupyter.py index 83fa521c1..bed196bff 100644 --- a/large_image/tilesource/jupyter.py +++ b/large_image/tilesource/jupyter.py @@ -551,16 +551,63 @@ def handle_drag(self, coords): def end_drag(self): self._dragging_marker_id = None - def add_warp_editor(self): + def create_warp_reference_point_pair(self, coord): from ipyleaflet import DivIcon, Marker - from ipywidgets import HTML, Accordion, Button, Checkbox, Label, Output, VBox - self.warp_editor_validate_source() marker_style = ( 'border-radius: 50%; position: relative;' 'height: 16px; width: 16px; top: -8px; left: -8px;' 'text-align: center; font-size: 11px;' ) + converted = self.convert_coordinate_map_to_warp(coord) + locations = dict(src=converted, dst=converted) + transform_checkbox = self._warp_widgets.get('transform') + transform_enabled = transform_checkbox.value if transform_checkbox is not None else True + if transform_enabled: + locations['src'] = self.inverse_warp(locations['src']) + index = len(self.warp_points['src']) + self.warp_points['src'].append(locations['src']) + self.warp_points['dst'].append(locations['dst']) + + for group_name, color in [ + ('src', '#ff6a5e'), ('dst', '#19a7ff'), + ]: + html = f'
{index}
' + icon = DivIcon(html=html, icon_size=[0, 0]) + marker = Marker( + location=self.convert_coordinate_warp_to_map(locations[group_name]), + draggable=True, + icon=icon, + title=f'{group_name} {index}', + visible=(group_name == 'dst' or not transform_enabled), + ) + marker.on_dblclick(lambda m=marker, **e: self.remove_warp_reference_point_pair(m)) + marker.on_mousedown(lambda m=marker, **e: self.start_drag(m)) + marker.on_mouseup(lambda **e: self.end_drag()) + self._warp_markers[group_name].append(marker) + if self._map is not None: + self._map.add(marker) + + def remove_warp_reference_point_pair(self, marker): + from ipyleaflet import DivIcon + + index = int(marker.title[3:]) + for group_name in ['src', 'dst']: + del self.warp_points[group_name][index] + if self._map is not None: + self._map.remove(self._warp_markers[group_name][index]) + del self._warp_markers[group_name][index] + # reset indices of remaining markers + for i, m in enumerate(self._warp_markers[group_name]): + m.title = f'{group_name} {i}' + html = re.sub(r'>\d+<', f'>{i}<', m.icon.html) + m.icon = DivIcon(html=html, icon_size=[0, 0]) + self.update_warp_schemas() + + def add_warp_editor(self): + from ipywidgets import HTML, Accordion, Button, Checkbox, Label, Output, VBox + + self.warp_editor_validate_source() help_text = Label('To begin editing a warp, click on the image to place reference points.') transform_checkbox = Checkbox(description='Show Transformed', value=True) transform_checkbox.layout.display = 'none' @@ -586,53 +633,11 @@ def add_warp_editor(self): copy_output=schema_copy_output, ) - def create_reference_point_pair(coord): - converted = self.convert_coordinate_map_to_warp(coord) - locations = dict(src=converted, dst=converted) - if transform_checkbox.value: - locations['src'] = self.inverse_warp(locations['src']) - index = len(self.warp_points['src']) - self.warp_points['src'].append(locations['src']) - self.warp_points['dst'].append(locations['dst']) - - for group_name, color in [ - ('src', '#ff6a5e'), ('dst', '#19a7ff'), - ]: - html = f'
{index}
' - icon = DivIcon(html=html, icon_size=[0, 0]) - marker = Marker( - location=self.convert_coordinate_warp_to_map(locations[group_name]), - draggable=True, - icon=icon, - title=f'{group_name} {index}', - visible=(group_name == 'dst' or not transform_checkbox.value), - ) - marker.on_dblclick(lambda m=marker, **e: remove_reference_point_pair(m)) - marker.on_mousedown(lambda m=marker, **e: self.start_drag(m)) - marker.on_mouseup(lambda **e: self.end_drag()) - self._warp_markers[group_name].append(marker) - if self._map is not None: - self._map.add(marker) - - def remove_reference_point_pair(marker): - index = int(marker.title[3:]) - for group_name in ['src', 'dst']: - del self.warp_points[group_name][index] - if self._map is not None: - self._map.remove(self._warp_markers[group_name][index]) - del self._warp_markers[group_name][index] - # reset indices of remaining markers - for i, m in enumerate(self._warp_markers[group_name]): - m.title = f'{group_name} {i}' - html = re.sub(r'>\d+<', f'>{i}<', m.icon.html) - m.icon = DivIcon(html=html, icon_size=[0, 0]) - self.update_warp_schemas() - def handle_interaction(**kwargs): event_type = kwargs.get('type') coords = [round(v) for v in kwargs.get('coordinates', [])] if event_type == 'click': - create_reference_point_pair(coords) + self.create_warp_reference_point_pair(coords) self.update_warp_schemas() help_text.value = ( 'After placing reference points, you can drag them to define the warp. ' From 13f9fdcca114c3454b573d56fc7bd5ce2730f583 Mon Sep 17 00:00:00 2001 From: Anne Haley Date: Wed, 6 Aug 2025 11:58:13 -0400 Subject: [PATCH 27/29] Add more tests for better coverage --- test/test_jupyter.py | 49 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/test/test_jupyter.py b/test/test_jupyter.py index 10ec30bd5..3ad87b0a1 100644 --- a/test/test_jupyter.py +++ b/test/test_jupyter.py @@ -1,9 +1,11 @@ import asyncio +import json import os from urllib.parse import parse_qs, urlparse import aiohttp import pytest +import yaml import large_image @@ -212,6 +214,53 @@ def testJupyterEditWarpConvertCoords(): assert sourceMap.convert_coordinate_warp_to_map([12, 65526]) == [10, 12] +def testJupyterEditWarpCreateAndDeletePointPairs(): + sourceMap, _ = initEditWarp() + sourceMap.create_warp_reference_point_pair([10, 10]) + assert len(sourceMap._warp_markers['src']) == 1 + assert len(sourceMap._warp_markers['dst']) == 1 + marker = sourceMap._warp_markers['src'][0] + assert marker.location == [10, 10] + sourceMap.remove_warp_reference_point_pair(marker) + assert len(sourceMap._warp_markers['src']) == 0 + assert len(sourceMap._warp_markers['dst']) == 0 + + +def testJupyterEditWarpSchemas(): + sourceMap, display = initEditWarp() + warp = dict( + src=[[10, 10]], + dst=[[15, 15]], + ) + expected = dict(sources=[ + dict( + path=str(sourceMap._ts.largeImagePath), + z=0, position=dict(x=0, y=0, warp=warp), + ), + ]) + sourceMap.warp_points = warp + sourceMap.update_warp_schemas() + json_schema = sourceMap._warp_widgets.get('json') + assert json_schema.value == f'
{json.dumps(expected, indent=4)}
' + yaml_schema = sourceMap._warp_widgets.get('yaml') + assert yaml_schema.value == f'
{yaml.dump(expected)}
' + + assert len(display.children) == 2 + vbox = display.children[1] + assert len(vbox.children) == 4 + copy_output = vbox.children[3] + accordion = vbox.children[2] + yaml_section = accordion.children[0] + copy_yaml_button = yaml_section.children[0] + sourceMap.copy_warp_schema(copy_yaml_button) + assert len(copy_output.outputs) == 1 + output = copy_output.outputs[0] + assert output['data']['application/javascript'] == ( + "navigator.clipboard.writeText(unescape('%s'))" + % yaml.dump(expected).replace('\n', '\\n') + ) + + def testJupyterEditWarpInverseWarp(): sourceMap, _ = initEditWarp() # inverse from single ref point From 6f4a41fbc37e33ccba9f58e7e93d2f10c6b2aa0e Mon Sep 17 00:00:00 2001 From: Anne Haley Date: Wed, 6 Aug 2025 12:41:47 -0400 Subject: [PATCH 28/29] Protect against zero-length array in `svd` --- sources/multi/large_image_source_multi/__init__.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/sources/multi/large_image_source_multi/__init__.py b/sources/multi/large_image_source_multi/__init__.py index 15c49f485..d19208ea6 100644 --- a/sources/multi/large_image_source_multi/__init__.py +++ b/sources/multi/large_image_source_multi/__init__.py @@ -686,10 +686,12 @@ def _getWarp(self, warp, m): warp_src = warp_src[:min(warp_src.shape[0], warp_dst.shape[0]), :] warp_dst = warp_dst[:warp_src.shape[0], :] - srcsvd = np.linalg.svd(warp_src - warp_src.mean(axis=0), compute_uv=False) - dstsvd = np.linalg.svd(warp_dst - warp_dst.mean(axis=0), compute_uv=False) - useSimilarity = warp_src.shape[0] < 3 or min( - srcsvd[1] / (srcsvd[0] or 1), dstsvd[1] / (dstsvd[0] or 1)) < 1e-3 + useSimilarity = False + if len(warp_src) > 0 and len(warp_dst) > 0: + srcsvd = np.linalg.svd(warp_src - warp_src.mean(axis=0), compute_uv=False) + dstsvd = np.linalg.svd(warp_dst - warp_dst.mean(axis=0), compute_uv=False) + useSimilarity = warp_src.shape[0] < 3 or min( + srcsvd[1] / (srcsvd[0] or 1), dstsvd[1] / (dstsvd[0] or 1)) < 1e-3 if warp_src.shape[0] < 1: pass From d211f2d65cfc7d168a4580211baecc129317d2c0 Mon Sep 17 00:00:00 2001 From: Anne Haley Date: Wed, 27 Aug 2025 14:15:08 -0400 Subject: [PATCH 29/29] For multiframe images, apply warp when switching frames --- large_image/tilesource/jupyter.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/large_image/tilesource/jupyter.py b/large_image/tilesource/jupyter.py index bed196bff..870903fb8 100644 --- a/large_image/tilesource/jupyter.py +++ b/large_image/tilesource/jupyter.py @@ -800,6 +800,15 @@ def update_warp(self, show_warp): self.update_layer_query(frame=current_frame, style=dict()) def update_frame(self, frame, style, **kwargs): + if self._edit_warp: + transform_checkbox = self._warp_widgets.get('transform') + if ( + transform_checkbox is not None and + transform_checkbox.value and + self.warp_points is not None and + len(self.warp_points['src']) + ): + style['warp'] = self.warp_points self.update_layer_query(frame=frame, style=style) def update_layer_query(self, frame, style, **kwargs):