diff --git a/nrt/validate/interface.py b/nrt/validate/interface.py index cb89c69..fd4a924 100644 --- a/nrt/validate/interface.py +++ b/nrt/validate/interface.py @@ -8,7 +8,7 @@ from IPython.display import display import ipywidgets as ipw from ipyevents import Event -from ipyleaflet import GeoJSON +from ipyleaflet import GeoJSON, LayersControl, TileLayer import numpy as np from bqplot import Scatter, Lines, LinearScale, DateScale, Axis, Figure from shapely.geometry import shape, mapping, Point @@ -25,11 +25,220 @@ from nrt.validate.loader import BaseLoader from ipyleaflet import Map +class ResizableSplitter(ipw.HBox): + _splitter_counter = 0 + + def __init__(self, left_widget, right_widget, orientation='horizontal', + initial_left_size='50%', min_left_size='10%', min_right_size='10%'): + + self.orientation = orientation + self.initial_left_size = initial_left_size + self.min_left_size = min_left_size + self.min_right_size = min_right_size + self.left_widget = left_widget + self.right_widget = right_widget + + ResizableSplitter._splitter_counter += 1 + self.splitter_id = f'splitter-{ResizableSplitter._splitter_counter}' + + splitter_width = '8px' if orientation == 'horizontal' else '100%' + splitter_height = '100%' if orientation == 'horizontal' else '8px' + + splitter_html = f""" +
+
+
+
+ + """ + + self.splitter = ipw.HTML( + value=splitter_html, + layout=ipw.Layout( + width=splitter_width, + height=splitter_height, + padding='0', + margin='0', + flex='0 0 auto', + overflow='hidden' + ) + ) + + left_layout = ipw.Layout( + width=initial_left_size if orientation == 'horizontal' else '100%', + height='100%' if orientation == 'horizontal' else initial_left_size, + overflow='auto', + padding='0', + margin='0', + flex='0 0 auto' + ) + left_widget.layout = left_layout + + right_layout = ipw.Layout( + width='auto' if orientation == 'horizontal' else '100%', + height='100%' if orientation == 'horizontal' else 'auto', + flex='1 1 auto', + overflow='auto', + padding='0', + margin='0' + ) + right_widget.layout = right_layout + + container_layout = ipw.Layout( + width='100%', + height='100%', + display='flex', + flex_flow='row' if orientation == 'horizontal' else 'column', + align_items='stretch', + padding='0', + margin='0' + ) + + super().__init__( + [left_widget, self.splitter, right_widget], + layout=container_layout + ) + + self._inject_js() + + def _inject_js(self): + + js_code = f""" + (function() {{ + const splitterId = '{self.splitter_id}'; + const orientation = '{self.orientation}'; + + console.log('Script loaded for splitter:', splitterId); + + function initSplitter() {{ + const splitterEl = document.getElementById(splitterId); + if (!splitterEl) {{ + setTimeout(initSplitter, 200); + return; + }} + + let widgetWrapper = splitterEl; + while (widgetWrapper && + !widgetWrapper.classList.contains('jupyter-widget') && + !widgetWrapper.classList.contains('widget-inline-hbox') && + !widgetWrapper.classList.contains('widget-inline-vbox')) {{ + widgetWrapper = widgetWrapper.parentElement; + }} + + if (!widgetWrapper) widgetWrapper = splitterEl.parentElement; + + if (!widgetWrapper || !widgetWrapper.parentElement) {{ + console.error('Widget wrapper or parent not found'); + return; + }} + + const container = widgetWrapper.parentElement; + const siblings = Array.from(container.children); + const splitterIndex = siblings.indexOf(widgetWrapper); + + console.log('Splitter found at index:', splitterIndex, 'of', siblings.length); + + if (splitterIndex <= 0 || splitterIndex >= siblings.length - 1) {{ + console.error('Splitter is not in the middle of widgets'); + return; + }} + + const leftWidgetWrapper = siblings[splitterIndex - 1]; + const rightWidgetWrapper = siblings[splitterIndex + 1]; + + let isResizing = false; + let startX, startY, startSize; + + splitterEl.addEventListener('mousedown', function(e) {{ + console.log('Mousedown on splitter'); + isResizing = true; + startX = e.clientX; + startY = e.clientY; + + const rect = leftWidgetWrapper.getBoundingClientRect(); + startSize = (orientation === 'horizontal') ? rect.width : rect.height; + + document.body.style.cursor = (orientation === 'horizontal') ? 'col-resize' : 'row-resize'; + document.body.style.userSelect = 'none'; + + e.preventDefault(); + e.stopPropagation(); + }}); + + window.addEventListener('mousemove', function(e) {{ + if (!isResizing) return; + + e.preventDefault(); + + const containerRect = container.getBoundingClientRect(); + const totalSize = (orientation === 'horizontal') ? containerRect.width : containerRect.height; + + let delta = (orientation === 'horizontal') ? (e.clientX - startX) : (e.clientY - startY); + let newSize = startSize + delta; + + const minSize = 50; + const maxSize = totalSize - 50; + + if (newSize >= minSize && newSize <= maxSize) {{ + if (orientation === 'horizontal') {{ + leftWidgetWrapper.style.flex = '0 0 ' + newSize + 'px'; + leftWidgetWrapper.style.width = newSize + 'px'; + leftWidgetWrapper.style.minWidth = newSize + 'px'; + leftWidgetWrapper.style.maxWidth = newSize + 'px'; + }} else {{ + leftWidgetWrapper.style.flex = '0 0 ' + newSize + 'px'; + leftWidgetWrapper.style.height = newSize + 'px'; + leftWidgetWrapper.style.minHeight = newSize + 'px'; + leftWidgetWrapper.style.maxHeight = newSize + 'px'; + }} + window.dispatchEvent(new Event('resize')); + }} + }}); + + window.addEventListener('mouseup', function(e) {{ + if (isResizing) {{ + console.log('Mouseup, resizing stopped'); + isResizing = false; + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + }} + }}); + + console.log('✓ Splitter initialized successfully:', splitterId); + }} + + setTimeout(initSplitter, 100); + setTimeout(initSplitter, 500); + setTimeout(initSplitter, 1000); + }})(); + """ + + self._js_code = js_code class Chips(HasTraits): breakpoints = List() highlight = Int(allow_none=True) + selected_indices = List() + on_select_change = None """A container with observable traits and many elementary methods to host image chips Examples: @@ -47,34 +256,133 @@ class Chips(HasTraits): >>> # Add breakpoint either by clicking on a chip, or running the following method >>> chips.add_or_remove_breakpoint(33) """ - def __init__(self, dates, images, breakpoints=[]): + def __init__(self, dates, images, breakpoints=[], chip_size='150px', sample_box_size=10): self.dates = dates self.images = images self.breakpoints = breakpoints + self.selected_indices = [] + self.on_select_change = None + self.chip_size = chip_size + self.sample_box_size = sample_box_size + + self.wrapped_widgets = self._create_wrapped_widgets() + box_layout = ipw.Layout( display='flex', - flex_flow='row wrap', - align_items='stretch', - width='70%', - height='100%', # Set a fixed height (modify as needed) - overflow='auto' # Add scrollability + flex_flow='row wrap', + align_items='flex-start', + align_content='flex-start', + width='100%', + height='100%', + overflow='auto' ) + self.box_layout = box_layout - self.widget = ipw.Box(children=self.images, + + self.widget = ipw.Box(children=self.wrapped_widgets, layout=box_layout) - self.highlight = None # This is a trait that changes when individual chips are hovered - # Add event handler to each chip - for idx, image in enumerate(self.images): - event = Event(source=image, + self.highlight = None + + fix_flex_js = ''' + + ''' + + js_widget = ipw.HTML(value=fix_flex_js, layout=ipw.Layout(display='none')) + self.widget = ipw.VBox([self.widget, js_widget], layout=box_layout) + + for idx, wrapper in enumerate(self.wrapped_widgets): + event = Event(source=wrapper, watched_events = ['mouseenter', 'mouseleave', 'click']) event.on_dom_event(functools.partial(self._handle_chip_event, idx)) - # Add border around chips for breakpoints present at instantiation for bp in self.breakpoints: idx = np.where(self.dates == bp)[0][0] self.images[idx].layout.border = '2px solid blue' - @classmethod + def _create_wrapped_widgets(self): + wrapped = [] + try: + chip_size_val = int(str(self.chip_size).replace('px', '')) + except ValueError: + chip_size_val = 150 + + size_str = f"{chip_size_val}px" + + show_boxes = True + + base_size = getattr(self, 'base_chip_size', 150) + + s_pct = (self.sample_box_size / base_size) * 100 + + b_pct = s_pct * 3 + + for image in self.images: + if image.layout is None: + image.layout = ipw.Layout() + + image.layout.width = '100%' + image.layout.height = '100%' + image.layout.min_width = '100%' + image.layout.min_height = '100%' + image.layout.object_fit = 'fill' + image.layout.margin = '0px' + image.layout.flex = '1 1 auto' + + overlay_html = '' + if show_boxes: + s_top = (100 - s_pct) / 2 + s_left = (100 - s_pct) / 2 + + b_top = (100 - b_pct) / 2 + b_left = (100 - b_pct) / 2 + + overlay_html = f''' +
+
+
+
+ ''' + else: + overlay_html = '
' + + overlay = ipw.HTML( + value=overlay_html, + layout=ipw.Layout(width='100%', height='100%') + ) + + container = ipw.GridBox( + children=[image, overlay], + layout=ipw.Layout( + width=size_str, + height=size_str, + grid_template_areas='"content"', + grid_template_columns='1fr', + grid_template_rows='1fr', + margin='2px', + flex='0 0 auto', + overflow='hidden' + ) + ) + + image.layout.grid_area = 'content' + overlay.layout.grid_area = 'content' + + wrapped.append(container) + return wrapped + def from_cube_and_geom(cls, ds, geom, breakpoints=[], compositor=SimpleComposite(), window_size=500, @@ -111,7 +419,48 @@ def _handle_chip_event(self, idx, event): if event['type'] == 'mouseleave': self.highlight = None if event['type'] == 'click': - self.add_or_remove_breakpoint(idx) + # self.add_or_remove_breakpoint(idx) + if idx in self.selected_indices: + self._unselect_index(idx) + else: + self._select_index(idx) + + def _select_index(self, idx): + + if idx not in self.selected_indices: + self.selected_indices.append(idx) + + self.images[idx].layout.border = '2px solid red' + + if self.on_select_change: + self.on_select_change(self.selected_indices) + + def _unselect_index(self, idx): + + if idx in self.selected_indices: + self.selected_indices.remove(idx) + + if self.dates[idx] in self.breakpoints: + self.images[idx].layout.border = '2px solid blue' + else: + self.images[idx].layout.border = '' + + if self.on_select_change: + self.on_select_change(self.selected_indices) + + + def sync_selected_from_vits(self, selected_indices): + + for idx in self.selected_indices: + if self.dates[idx] in self.breakpoints: + self.images[idx].layout.border = '2px solid blue' + else: + self.images[idx].layout.border = '' + + self.selected_indices = selected_indices.copy() + for idx in self.selected_indices: + + self.images[idx].layout.border = '2px solid red' def add_or_remove_breakpoint(self, idx): date = self.dates[idx] @@ -124,14 +473,53 @@ def add_or_remove_breakpoint(self, idx): self.images[idx].layout.border = '2px solid blue' self.breakpoints = bp + def update_data(self, dates, images, breakpoints, selected_indices=None): + """Update chips data without recreating the widget container""" + self.dates = dates + self.images = images + self.breakpoints = breakpoints + + if selected_indices is not None: + self.selected_indices = selected_indices + else: + self.selected_indices = [] + + # Re-create wrapped widgets with current settings + self.wrapped_widgets = self._create_wrapped_widgets() + + # Add event handler to each chip container + for idx, wrapper in enumerate(self.wrapped_widgets): + event = Event(source=wrapper, + watched_events = ['mouseenter', 'mouseleave', 'click']) + event.on_dom_event(functools.partial(self._handle_chip_event, idx)) + + # Add border around chips for breakpoints + for bp in self.breakpoints: + # Check if bp is in dates to avoid errors if breakpoints don't match dates + indices = np.where(self.dates == bp)[0] + if len(indices) > 0: + idx = indices[0] + self.images[idx].layout.border = '2px solid blue' + + for idx in self.selected_indices: + self.images[idx].layout.border = '2px solid red' + + # Update the children of the image container (the Box inside the VBox) + # self.widget is VBox([box, js]) + if len(self.widget.children) > 0: + image_box = self.widget.children[0] + image_box.children = tuple(self.wrapped_widgets) + def display(self): display(self.widget) class Vits(HasTraits): breakpoints = List() - order = Int(1) # HArmonic order + order = Int(1) current_vi = Unicode('NDVI') + selected_indices = List() + on_select_change = None """Handle and display the vegetation index time-series """ def __init__(self, dates, values, @@ -141,18 +529,90 @@ def __init__(self, dates, values, self.y_sc = LinearScale(min=float(np.nanmin(values[default_vi])), max=float(np.nanmax(values[default_vi]))) self.dates = dates - self.values = values # Let's say this is a dict + self.values = values self.default_vi = default_vi self.colors = ['blue'] * len(self.dates) - self.vi_values = Scatter(x=self.dates, y=self.values[self.default_vi], - scales={'x': self.x_sc, 'y': self.y_sc}, colors=self.colors) + self.selected_indices = [] + self.on_select_change = None + + self.vi_values = Scatter( + x=self.dates, y=self.values[self.default_vi], + scales={'x': self.x_sc, 'y': self.y_sc}, + colors=self.colors, + enable_hover=True, + ) + self.vi_values.on_element_click(self._handle_point_click) + self.vlines = [self._create_vline(bp) for bp in self.breakpoints] - # Smooth fit lines + self.model = PartitionedHarmonicTrendModel(dates) self.fitted_lines = self._create_fit_lines() self.plot = self._create_plot() self.breakpoints = breakpoints + def _handle_point_click(self, element, event): + + if not event.get('data') or 'index' not in event.get('data', {}): + return + + idx = event['data']['index'] + if idx is None: + return + if idx in self.selected_indices: + self._unselect_index(idx) + else: + self._select_index(idx) + + def _select_index(self, idx): + if idx not in self.selected_indices: + self.selected_indices.append(idx) + self._update_selected_colors() + self._update_vlines_and_fit() + if self.on_select_change: + self.on_select_change(self.selected_indices) + + def _unselect_index(self, idx): + if idx in self.selected_indices: + self.selected_indices.remove(idx) + self._update_selected_colors() + self._update_vlines_and_fit() + if self.on_select_change: + self.on_select_change(self.selected_indices) + + def _update_selected_colors(self): + self.colors = ['blue'] * len(self.dates) + for idx in self.selected_indices: + self.colors[idx] = 'red' + self.vi_values.colors = self.colors + + def _update_vlines_and_fit(self): + selected_dates = [self.dates[idx] for idx in sorted(self.selected_indices)] + self.vlines = [self._create_vline(date) for date in selected_dates] + + self.fitted_lines = self._create_fit_lines_with_selected() + + self.figure.marks = [self.vi_values, + *self.vlines, + *self.fitted_lines] + + def _create_fit_lines_with_selected(self): + if len(self.selected_indices) > 0: + selected_dates = [self.dates[idx] for idx in sorted(self.selected_indices)] + dates, predictions = self.model.fit_predict(self.values[self.current_vi], + selected_dates, + self.order) + return [Lines(x=d, y=p, scales={'x': self.x_sc, 'y': self.y_sc}, + colors=['grey']) + for d, p in zip(dates, predictions)] + else: + return [] + + def sync_selected_from_chips(self, selected_indices): + + self.selected_indices = selected_indices.copy() + self._update_selected_colors() + self._update_vlines_and_fit() + @classmethod def from_cube_and_geom(cls, ds, geom, breakpoints=[], vis={'NDVI': NDVI(), @@ -200,7 +660,8 @@ def _create_plot(self): axes=[x_ax, y_ax], title='Sample temporal profile', animation_duration=500, - fig_margin={'top': 50, 'bottom': 50, 'left': 50, 'right': 50}) + fig_margin={'top': 50, 'bottom': 50, 'left': 50, 'right': 50}, + layout=ipw.Layout(flex='1 1 auto', width='100%', height='100%')) # Add a dropdown widget to select VI dropdown_vi = ipw.Dropdown(options=self.values.keys(), @@ -224,7 +685,7 @@ def update_order(change): return ipw.VBox([ipw.HBox([dropdown_vi, dropdown_order], layout=ipw.Layout(overflow='visible')), self.figure], - layout=ipw.Layout(height='100%', width='70%')) + layout=ipw.Layout(height='100%', width='100%')) def update_highlighted_point(self, idx): """Update the color of the highlighted point based on idx. @@ -232,9 +693,13 @@ def update_highlighted_point(self, idx): Args: idx (int or None): Index of the point to highlight or None. """ + self.colors = ['blue'] * len(self.colors) - if idx is not None: - self.colors[idx] = 'red' + for selected_idx in self.selected_indices: + self.colors[selected_idx] = 'red' + + if idx is not None and idx not in self.selected_indices: + self.colors[idx] = 'orange' self.vi_values.colors = self.colors def _create_fit_lines(self): @@ -262,6 +727,32 @@ def redraw_vlines(self, change): *self.vlines, *self.fitted_lines] + def update_data(self, dates, values, breakpoints): + self.dates = dates + self.values = values + self.breakpoints = breakpoints + + self.model = PartitionedHarmonicTrendModel(dates) + + self.x_sc.min = None + self.x_sc.max = None + + current_data = self.values[self.current_vi] + self.y_sc.min = float(np.nanmin(current_data)) + self.y_sc.max = float(np.nanmax(current_data)) + + self.vi_values.x = self.dates + self.vi_values.y = current_data + + self.colors = ['blue'] * len(self.dates) + self.vi_values.colors = self.colors + + self.selected_indices = [] + self.colors = ['blue'] * len(self.dates) + self.vi_values.colors = self.colors + + self.redraw_vlines(None) + def display(self): display(self.plot) @@ -270,21 +761,63 @@ class SegmentsLabellingInterface(HasTraits): current_idx = Int() def __init__(self, loader: 'BaseLoader', webmap: 'Map', res: float, - labels: list, db_path: str = ':memory:'): + labels: list, db_path: str = ':memory:', sample_box_size: int = 10): self.current_idx = 0 + self.sample_layer = None + self.buffer_layer = None self.conn = sqlite3.connect(db_path) self.loader = loader self.webmap = webmap self.res = res self.labels = labels + self.sample_box_size = sample_box_size + + for lyr in self.webmap.layers: + if hasattr(lyr, 'base'): + lyr.base = True + + # Check if vector layer already exists or just add OSM + has_osm = any(getattr(l, 'name', '') == 'OpenStreetMap' for l in self.webmap.layers) + if not has_osm: + self.osm_layer = TileLayer(url='https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', + name='OpenStreetMap', + base=True) + self.webmap.add_layer(self.osm_layer) + else: + # Find existing layer + for l in self.webmap.layers: + if getattr(l, 'name', '') == 'OpenStreetMap': + self.osm_layer = l + self.osm_layer.base = True + break + + # Add LayersControl if not present + has_control = any(isinstance(c, LayersControl) for c in self.webmap.controls) + if not has_control: + control = LayersControl(position='topright') + self.webmap.add_control(control) + + # Chip size configuration input + initial_chip_size = 150 + min_required_size = int(6.1 * self.sample_box_size) + if initial_chip_size < min_required_size: + initial_chip_size = min_required_size + + self.base_chip_size = initial_chip_size + + self.chip_size_input = ipw.IntText(value=initial_chip_size, description='Chip Size:', layout=ipw.Layout(width='200px')) + self.chip_size_input.observe(self._on_chip_size_change, names='value') + # Layouts self.webmap_layout = ipw.Layout(width='30%', height='100%') self.sidebar_layout = ipw.Layout(width='100%', - height='90%', + min_height='400px', # Set minimum height + height='100%', # Fill parent container overflow='auto', - align_items='center') - self.sample_container_layout = ipw.Layout(width='200px', - overflow_y='scroll', + align_items='stretch') + self.sample_container_layout = ipw.Layout(width='100%', + height='100%', + overflow_y='auto', border='1px solid black') # self.present_in_db, self.not_present_in_db = self.get_fids() @@ -292,60 +825,148 @@ def __init__(self, loader: 'BaseLoader', webmap: 'Map', 'lightcoral') self.not_interpreted_list = self.create_interactive_list(self.not_present_in_db, 'darkgreen') + + container_style = ipw.Layout(flex='1 1 auto', width='50%', height='100%', margin='0 5px') + self.interpreted_container = ipw.VBox([ - ipw.HTML('

Interpreted Samples

'), + ipw.HTML('

Interpreted Samples

'), ipw.VBox([self.interpreted_list], layout=self.sample_container_layout) - ]) + ], layout=container_style) + self.not_interpreted_container = ipw.VBox([ - ipw.HTML('

To Interpret

'), + ipw.HTML('

To Interpret

'), ipw.VBox([self.not_interpreted_list], layout=self.sample_container_layout) - ]) + ], layout=container_style) + self.navigation_menu = ipw.HBox([self.not_interpreted_container, self.interpreted_container], - layout=ipw.Layout(justify_content='center', + layout=ipw.Layout(width='100%', min_height='250px', - height='300px')) + flex='1 1 auto', + align_items='stretch')) self.save_button = ipw.Button(description="Save", layout=ipw.Layout(width='80%', - min_height='30px'), + max_width='300px', + min_height='30px', + align_self='center'), style={'button_color': 'blue'}) self.logo = ipw.Image(value=open(os.path.join(os.path.dirname(__file__), 'static', 'ec-logo.png'), 'rb').read(), format='png', layout=ipw.Layout( width='90%', + max_width='300px', height='50px', - object_fit='contain' + object_fit='contain', + align_self='center' )) self.save_button.on_click(self.save_to_db) # Get data of first sample and build interface self.fid, dates, images, values, geom, crs = self.loader[self.current_idx] + self.dates = dates self.seg = Segmentation.from_db_or_datelist( feature_id=self.fid, conn=self.conn, dates=dates, labels=self.labels) - self.chips = Chips(dates, images, self.seg.breakpoints) - self.vits = Vits(dates, values, self.seg.breakpoints) + + self.window_size = getattr(self.loader, 'window_size', None) + + self.chips = Chips(self.dates, images, self.seg.breakpoints, + chip_size=self.chip_size_input.value, + sample_box_size=self.sample_box_size) + self.vits = Vits(self.dates, values, self.seg.breakpoints) + + self.vits.on_select_change = self._handle_selection_change + self.chips.on_select_change = self._handle_selection_change + + selected_indices = [] + for bp in self.seg.breakpoints: + indices = np.where(self.dates == bp)[0] + if len(indices) > 0: + selected_indices.append(indices[0]) + + if not selected_indices and len(self.dates) > 0: + first_idx = 0 + last_idx = len(self.dates) - 1 + selected_indices.append(first_idx) + if last_idx != first_idx: + selected_indices.append(last_idx) + self.seg.breakpoints = sorted([self.dates[idx] for idx in selected_indices]) + + if selected_indices: + for idx in selected_indices: + if idx not in self.chips.selected_indices: + self.chips.selected_indices.append(idx) + self.chips.images[idx].layout.border = '2px solid red' + + self.vits.selected_indices = self.chips.selected_indices.copy() + self.vits._update_selected_colors() + self.vits._update_vlines_and_fit() + + chip_size = '150px' + for image in self.chips.images: + if image.layout is None: + image.layout = ipw.Layout() + image.layout.width = chip_size + image.layout.height = chip_size + image.layout.object_fit = 'contain' + image.layout.margin = '2px' + + self.chips.widget.layout.align_items = 'flex-start' + self.chips.widget.layout.align_content = 'flex-start' + self.chips.widget.layout.overflow = 'auto' + self.draw_webmap(geom=geom, res=self.res, crs=crs) # interface - self.sidebar = ipw.VBox([self.navigation_menu, + self.sidebar = ipw.VBox([ + self.chip_size_input, # Add config to sidebar + self.navigation_menu, self.seg.segment_widgets, + ipw.Box(layout=ipw.Layout(height='20px')), self.save_button], - layout=self.sidebar_layout) + layout=self.sidebar_layout) self.sidebar_with_logo = ipw.VBox( [self.sidebar, self.logo], - layout=ipw.Layout(height='calc(96vh - 400px)', - width='30%', - align_items='center') + layout=ipw.Layout(height='100%', + width='100%', + align_items='stretch', + overflow='hidden') ) - self.interface = ipw.VBox([ - ipw.HBox([self.vits.plot, self.webmap], - layout=ipw.Layout(height='400px', overflow='visible')), - ipw.HBox([self.chips.widget, self.sidebar_with_logo], - layout=ipw.Layout(height='calc(96vh - 400px)', overflow='hidden')) - ], layout=ipw.Layout(height='96vh', overflow='hidden')) + + self.top_splitter = ResizableSplitter( + left_widget=self.vits.plot, + right_widget=self.webmap, + orientation='horizontal', + initial_left_size='70%', + min_left_size='20%', + min_right_size='20%' + ) + self.top_splitter.layout.height = '45vh' + self.top_splitter.layout.overflow = 'visible' + + self.bottom_splitter = ResizableSplitter( + left_widget=self.chips.widget, + right_widget=self.sidebar_with_logo, + orientation='horizontal', + initial_left_size='70%', + min_left_size='20%', + min_right_size='20%' + ) + self.bottom_splitter.layout.height = '55vh' + self.bottom_splitter.layout.overflow = 'auto' + + self.interface = ResizableSplitter( + left_widget=self.top_splitter, + right_widget=self.bottom_splitter, + orientation='vertical', + initial_left_size='400px', + min_left_size='200px', + min_right_size='200px' + ) + self.interface.layout.height = '96vh' + self.interface.layout.overflow = 'visible' # Connections between elements self.chips.observe(self._on_chip_hover, names=['highlight']) self.chips.observe(self._on_chip_click, names=['breakpoints']) @@ -354,36 +975,81 @@ def __init__(self, loader: 'BaseLoader', webmap: 'Map', def _on_idx_change(self, change): self.current_idx = change['new'] + def _handle_selection_change(self, selected_indices): + if self.chips.selected_indices != selected_indices: + self.chips.sync_selected_from_vits(selected_indices) + if self.vits.selected_indices != selected_indices: + self.vits.sync_selected_from_chips(selected_indices) + + selected_dates = sorted([self.dates[idx] for idx in selected_indices]) + + if list(self.seg.breakpoints) != selected_dates: + self.seg.breakpoints = selected_dates + else: + pass + @observe('current_idx') def update_interface(self, change): """Current idx just changed, new data need to be loaded and the displayed elements updated accordingly """ - self.fid, dates, images, values, geom, crs = self.loader[change['new']] - self.seg = Segmentation.from_db_or_datelist( - feature_id=self.fid, - conn=self.conn, - dates=dates, - labels=self.labels) - self.chips = Chips(dates, images, self.seg.breakpoints) - self.vits = Vits(dates, values, self.seg.breakpoints) - # Update elements of the interface (oroginally immutable) - first_row = list(self.interface.children[0].children) # vits, webmap - second_row = list(self.interface.children[1].children) # chips, sidebar - first_row[0] = self.vits.plot - second_row[0] = self.chips.widget - self.interface.children[0].children = tuple(first_row) - self.interface.children[1].children = tuple(second_row) - sidebar = list(self.sidebar.children) - sidebar[1] = self.seg.segment_widgets - self.sidebar.children = tuple(sidebar) - # Re-set callbacks (is that actually necessary) - self.chips.observe(self._on_chip_hover, names=['highlight']) - self.chips.observe(self._on_chip_click, names=['breakpoints']) - # Update webmap - self.update_webmap(geom=geom, - res=self.res, - crs=crs) + try: + self.fid, dates, images, values, geom, crs = self.loader[change['new']] + self.dates = dates + + self.seg = Segmentation.from_db_or_datelist( + feature_id=self.fid, + conn=self.conn, + dates=self.dates, + labels=self.labels) + + # Optimize: update data instead of recreating widgets + self.chips.update_data(self.dates, images, self.seg.breakpoints) + self.vits.update_data(self.dates, values, self.seg.breakpoints) + + self.vits.on_select_change = self._handle_selection_change + self.chips.on_select_change = self._handle_selection_change + + selected_indices = [] + for bp in self.seg.breakpoints: + indices = np.where(self.dates == bp)[0] + if len(indices) > 0: + selected_indices.append(indices[0]) + + if not selected_indices and len(self.dates) > 0: + first_idx = 0 + last_idx = len(self.dates) - 1 + selected_indices.append(first_idx) + if last_idx != first_idx: + selected_indices.append(last_idx) + self.seg.breakpoints = sorted([self.dates[idx] for idx in selected_indices]) + + if selected_indices: + + self.chips.selected_indices = [] + for idx in selected_indices: + self.chips.selected_indices.append(idx) + self.chips.images[idx].layout.border = '2px solid red' + + self.vits.selected_indices = self.chips.selected_indices.copy() + self.vits._update_selected_colors() + self.vits._update_vlines_and_fit() + + # Update sidebar + sidebar = list(self.sidebar.children) + # Index needs adjustment because we inserted chip_size_input at 0 + # [chip_size_input, navigation_menu, segment_widgets, spacer, save_button] + sidebar[2] = self.seg.segment_widgets + self.sidebar.children = tuple(sidebar) + + # Update webmap + self.update_webmap(geom=geom, + res=self.res, + crs=crs) + except Exception as e: + import traceback + traceback.print_exc() + print(f"Error updating interface: {e}") def _on_chip_hover(self, change): self.vits.update_highlighted_point(change['new']) @@ -393,6 +1059,30 @@ def _on_chip_click(self, change): self.seg.breakpoints = copy.deepcopy(change['new']) def display(self): + js_parts = [] + def collect_js_code(widget): + if isinstance(widget, ResizableSplitter): + if hasattr(widget, '_js_code'): + js_parts.append(widget._js_code) + if hasattr(widget, 'children'): + for child in widget.children: + collect_js_code(child) + + collect_js_code(self.interface) + + if js_parts: + all_js = '\n'.join(js_parts) + + try: + from IPython.display import Javascript + from IPython import get_ipython + ipython = get_ipython() + + if ipython is not None: + display(Javascript(all_js)) + except Exception as e: + pass + return self.interface def load_sample(self, idx): @@ -400,26 +1090,108 @@ def load_sample(self, idx): # Check if sample already exist in the database and build breakpoints accordingly pass + def _on_chip_size_change(self, change): + """Handle chip size change""" + new_size = change['new'] + + min_required = int(6.1 * self.sample_box_size) + + min_limit = max(50, min_required) + + if new_size < min_limit: + new_size = min_limit + if self.chip_size_input.value != new_size: + self.chip_size_input.value = new_size + return # set value will trigger this again + elif new_size > 1000: + new_size = 1000 + if self.chip_size_input.value != new_size: + self.chip_size_input.value = new_size + return + + self.chips.chip_size = new_size + # Force update layout, preserving selected indices + self.chips.update_data(self.chips.dates, self.chips.images, self.chips.breakpoints, + selected_indices=self.chips.selected_indices) + def draw_webmap(self, geom, res, crs): - # Simply creates a geometry, add it to the map and center the map on it + # Creates geometries for sample and buffer, add them to the map and center current_shape = shape(geom) + + # Calculate geospatial sizes based on chip pixel sizes + # sample_box_size (pixels) -> geo size + # window_size (geo) / chip_size (pixels) = geo_per_pixel + + try: + chip_pixels = int(str(self.chip_size_input.value).replace('px', '')) + except: + chip_pixels = 150 + + if self.window_size and chip_pixels > 0: + geo_per_pixel = self.window_size / chip_pixels + # sample box width (geo) + sample_geo_width = self.sample_box_size * geo_per_pixel + # buffer box width (geo) = 3 * sample box width + buffer_geo_width = 3 * sample_geo_width + + sample_radius = sample_geo_width / 2 + buffer_radius = buffer_geo_width / 2 + else: + # Fallback if window_size missing (should not happen per requirements) + sample_radius = res / 2 + buffer_radius = res + + if isinstance(current_shape, Point): + sample_shape = current_shape.buffer(sample_radius, cap_style=3) + else: + sample_shape = current_shape + if isinstance(current_shape, Point): - current_shape = current_shape.buffer(res/2, cap_style=3) - # TODO: use shapely ops + pyproj here instead of rasterio - current_geom = warp.transform_geom(src_crs = crs, - dst_crs = CRS.from_epsg(4326), - geom=mapping(current_shape)) - centroid = shape(current_geom).centroid - webmap_geom = GeoJSON(data=current_geom, - style = {'opacity': 1, 'fillOpacity': 0, - 'weight': 1, 'color': 'magenta'}) - self.webmap.add(webmap_geom) + buffer_shape = current_shape.buffer(buffer_radius, cap_style=3) + else: + buffer_shape = sample_shape.buffer(sample_radius, cap_style=3) + + # Transform geometries + sample_geom = warp.transform_geom(src_crs=crs, + dst_crs=CRS.from_epsg(4326), + geom=mapping(sample_shape)) + buffer_geom = warp.transform_geom(src_crs=crs, + dst_crs=CRS.from_epsg(4326), + geom=mapping(buffer_shape)) + + centroid = shape(sample_geom).centroid + + # Helper to update or create layer + def update_layer(layer_attr, data, style): + layer = getattr(self, layer_attr) + if layer is not None and layer in self.webmap.layers: + layer.data = data + else: + if layer is not None: + try: + self.webmap.remove_layer(layer) + except Exception: + pass + + new_layer = GeoJSON(data=data, style=style) + self.webmap.add_layer(new_layer) + setattr(self, layer_attr, new_layer) + + # Update layers + # Buffer first (bottom) + update_layer('buffer_layer', buffer_geom, + {'opacity': 1, 'fillOpacity': 0, 'weight': 1, 'color': 'yellow'}) + if self.buffer_layer: self.buffer_layer.name = 'Buffer' + + # Sample on top + update_layer('sample_layer', sample_geom, + {'opacity': 1, 'fillOpacity': 0, 'weight': 2, 'color': 'magenta'}) + if self.sample_layer: self.sample_layer.name = 'Sample' + self.webmap.center = [centroid.y, centroid.x] self.webmap.zoom = 17 def update_webmap(self, geom, res, crs): - # Remove the last layer (that's normally where the GeoJSON layer is; to be improved) - self.webmap.layers = self.webmap.layers[:-1] self.draw_webmap(geom, res, crs) def get_fids(self): @@ -445,30 +1217,34 @@ def create_interactive_list(self, samples, color): Agrs: samples (list): List of (idx, feature_id) tuples """ - buttons = [] - for idx, feature_id in samples: - button = ipw.Button(description=f"Sample {feature_id}", - layout=ipw.Layout(width='90%', flex='0 0 auto')) - button.style.button_color = color - button.idx = idx - button.on_click(self.on_sample_click) - buttons.append(button) - return ipw.VBox(buttons, layout=ipw.Layout(align_items='center')) + buttons = [self.create_button(idx, feature_id, color) + for idx, feature_id in samples] + return ipw.VBox(buttons, layout=ipw.Layout(align_items='center', width='100%')) def on_sample_click(self, button): self.current_idx = button.idx - self.update_lists() + self.refresh_button_styles() def create_button(self, idx, feature_id, color): """Create a button related to a sample""" - outline_style = '2px solid white' if idx == self.current_idx else 'none' + is_active = (idx == self.current_idx) + if is_active: + border_style = '3px solid #FFD700' + bg_color = 'orange' + else: + border_style = 'none' + bg_color = color + + # outline_style = '2px solid white' if idx == self.current_idx else 'none' button = ipw.Button(description=f"Sample {feature_id}", - layout=ipw.Layout(width='90%', + layout=ipw.Layout(width='95%', flex='0 0 auto', - border=outline_style)) - button.style.button_color = color - button.on_click(self.on_sample_click) + border=border_style, + align_self='center')) + button.style.button_color = bg_color button.idx = idx + button.original_color = color + button.on_click(self.on_sample_click) return button def update_lists(self): @@ -488,3 +1264,19 @@ def save_to_db(self, button): """Save current segmentation to database""" self.seg.to_db(self.fid) self.update_lists() + + def refresh_button_styles(self): + all_buttons = list(self.interpreted_list.children) + \ + list(self.not_interpreted_list.children) + + for btn in all_buttons: + if btn.idx == self.current_idx: + btn.style.button_color = 'orange' + btn.layout.border = '3px solid #FFD700' + else: + if hasattr(btn, 'original_color'): + btn.style.button_color = btn.original_color + else: + btn.style.button_color = 'lightcoral' if btn in self.interpreted_list.children else 'darkgreen' + + btn.layout.border = 'none' \ No newline at end of file diff --git a/nrt/validate/notebooks/demo_notebook.ipynb b/nrt/validate/notebooks/demo_notebook.ipynb index 64e18ad..1b9022b 100644 --- a/nrt/validate/notebooks/demo_notebook.ipynb +++ b/nrt/validate/notebooks/demo_notebook.ipynb @@ -36,8 +36,12 @@ " 'CR-SWIR': utils.combine_transforms(S2CloudMasking(), CR_SWIR(nir='B08')),\n", " 'NCDI': utils.combine_transforms(S2CloudMasking(), NCDI(nir='B08'))}\n", "\n", + "sample_box_size = 10\n", + "\n", "# Chip size in CRS unit\n", - "window_size = 500\n", + "# Logic optimization: Ensure window_size covers at least 6 * sample_box_size\n", + "min_window = sample_box_size * 6\n", + "window_size = max(1000, min_window)\n", "\n", "# Color composite\n", "compositor = S2CIR(r='B08', blim=[0.025, 0.13], glim=[0.0150, 0.1700], rlim=[0.1500, 0.4000])\n", @@ -68,17 +72,20 @@ " window_size=window_size,\n", " compositor=compositor,\n", " xr_transform=drop_nan_slices,\n", - " res=res)\n", + " res=res,\n", + " outline_color='#00000000')\n", "\n", "\n", "# Interface\n", + "\n", "i = SegmentsLabellingInterface(loader=loader,\n", " webmap=webmap,\n", " res=res,\n", " labels=['Non-treed',\n", " 'Stable tree cover',\n", " 'Dieback',\n", - " 'Regrowth'])" + " 'Regrowth'],\n", + " sample_box_size=sample_box_size)" ] }, { diff --git a/nrt/validate/segments.py b/nrt/validate/segments.py index 63945e9..bdadf55 100644 --- a/nrt/validate/segments.py +++ b/nrt/validate/segments.py @@ -62,10 +62,11 @@ class Segment(object): >>> print(row) (1, 6, 18262, 18263, 'forest dieback') """ - def __init__(self, begin: np.datetime64, end: np.datetime64, label=None): + def __init__(self, begin: np.datetime64, end: np.datetime64, label=None, validation=None): self.begin = begin self.end = end self.label = label + self.validation = validation @property def breakpoints(self): @@ -84,14 +85,26 @@ def from_db(cls, idx, conn): Segment: An instance of the Segment class. """ cur = conn.cursor() - cur.execute("SELECT id, begin, end, label FROM segments WHERE id = ?", (idx,)) - row = cur.fetchone() - if row: - begin = np.datetime64('1970-01-01') + np.timedelta64(row[1], 'D') - end = np.datetime64('1970-01-01') + np.timedelta64(row[2], 'D') - return cls(begin, end, row[3]) - else: - return None + # Check if validation column exists (for backward compatibility during dev) + try: + cur.execute("SELECT id, begin, end, label, validation FROM segments WHERE id = ?", (idx,)) + row = cur.fetchone() + if row: + begin = np.datetime64('1970-01-01') + np.timedelta64(row[1], 'D') + end = np.datetime64('1970-01-01') + np.timedelta64(row[2], 'D') + label = row[3].strip() if row[3] else None + return cls(begin, end, label, row[4]) + except sqlite3.OperationalError: + # Fallback if validation column doesn't exist yet + cur.execute("SELECT id, begin, end, label FROM segments WHERE id = ?", (idx,)) + row = cur.fetchone() + if row: + begin = np.datetime64('1970-01-01') + np.timedelta64(row[1], 'D') + end = np.datetime64('1970-01-01') + np.timedelta64(row[2], 'D') + label = row[3].strip() if row[3] else None + return cls(begin, end, label) + + return None def to_db(self, conn, feature_id): """ @@ -102,12 +115,20 @@ def to_db(self, conn, feature_id): feature_id (int): The feature ID to associate with the segment. """ cur = conn.cursor() + # Ensure table exists with new schema cur.execute('''CREATE TABLE IF NOT EXISTS segments - (id INTEGER PRIMARY KEY, begin INTEGER, end INTEGER, label TEXT, feature_id INTEGER)''') + (id INTEGER PRIMARY KEY, begin INTEGER, end INTEGER, label TEXT, feature_id INTEGER, validation TEXT)''') + + # Try to add validation column if it doesn't exist (migration for existing DBs) + try: + cur.execute("ALTER TABLE segments ADD COLUMN validation TEXT") + except sqlite3.OperationalError: + pass # Column likely already exists + begin = self.begin.astype('datetime64[D]').astype(int).item() end = self.end.astype('datetime64[D]').astype(int).item() - cur.execute("INSERT INTO segments (begin, end, label, feature_id) VALUES (?, ?, ?, ?)", - (begin, end, self.label, feature_id)) + cur.execute("INSERT INTO segments (begin, end, label, feature_id, validation) VALUES (?, ?, ?, ?, ?)", + (begin, end, self.label, feature_id, self.validation)) conn.commit() cur.close() @@ -124,13 +145,87 @@ def on_change(change): if change['type'] == 'change' and change['name'] == 'value': self.label = change['new'] - segment_info = ipw.Label(value=f"Segment from {self.begin.astype('datetime64[D]')} to {self.end.astype('datetime64[D]')}") - dropdown = ipw.Dropdown(options=labels, description='Label:', - value=self.label) + date_range_text = f"{self.begin.astype('datetime64[D]')} - {self.end.astype('datetime64[D]')}" + segment_info = ipw.HTML( + value=f"
{date_range_text}
", + layout=ipw.Layout(width='auto', flex='0 0 auto') + ) + + current_options = list(labels) + if self.label and self.label not in current_options: + current_options.append(self.label) + + dropdown = ipw.Dropdown( + options=current_options, + value=self.label, + layout=ipw.Layout(width='150px', flex='1 1 auto') + ) dropdown.observe(on_change) - # Use VBox to vertically stack the label and dropdown - widget_box = ipw.VBox([segment_info, dropdown], - layout=ipw.Layout(min_height='60px')) + + label_html = ipw.HTML( + value="
Label:
", + layout=ipw.Layout(width='auto', flex='0 0 auto') + ) + + top_box = ipw.HBox( + [ + segment_info, + ipw.Box(layout=ipw.Layout(width='10px')), + label_html, + dropdown + ], + layout=ipw.Layout( + min_height='40px', + width='100%', + align_items='center', + justify_content='space-between', + overflow='hidden', + padding='0 5px' + ) + ) + + + question_label = ipw.Label("Is the LC the same for the box majority?") + btn_same = ipw.Button(description="The same", layout=ipw.Layout(width='auto'), icon='check' if self.validation == 'The same' else '') + btn_diff = ipw.Button(description="Different", layout=ipw.Layout(width='auto'), icon='check' if self.validation == 'Different' else '') + + + def on_btn_click(b): + btn_same.icon = '' + btn_diff.icon = '' + b.icon = 'check' + self.validation = b.description + + btn_same.on_click(on_btn_click) + btn_diff.on_click(on_btn_click) + + interaction_box = ipw.VBox( + [ + ipw.HBox([question_label], layout=ipw.Layout(justify_content='center')), + ipw.HBox([btn_same, btn_diff], layout=ipw.Layout(justify_content='center')) + ], + layout=ipw.Layout( + display='flex' if self.label else 'none', + padding='5px 20px', + visibility='visible', + align_items='center', + justify_content='center', + width='100%' + ) + ) + + def on_change_wrapper(change): + on_change(change) + if change['type'] == 'change' and change['name'] == 'value': + should_display = bool(change['new']) + interaction_box.layout.display = 'flex' if should_display else 'none' + + dropdown.observe(on_change_wrapper) + + if self.label: + interaction_box.layout.display = 'flex' + + widget_box = ipw.VBox([top_box, interaction_box], layout=ipw.Layout(width='100%')) return widget_box @@ -195,7 +290,9 @@ def __init__(self, conn, breakpoints=None, segments=None, self.segment_widgets = ipw.VBox([], layout=ipw.Layout(overflow='visible', flex='1 1 auto', - height='auto')) + height='auto', + width='100%', + align_items='center')) self._update_segment_widgets() @classmethod