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