diff --git a/.gitignore b/.gitignore index 8d7ddf9..5d4576a 100644 --- a/.gitignore +++ b/.gitignore @@ -145,4 +145,21 @@ ui-tests/benchmark-results ui-tests/jlab_root .yarn/ -yarn.lock \ No newline at end of file +yarn.lock + +# Local working files: agent configs, design notes and teaching material that +# live alongside the repo but are not part of it. +.codex +AGENTS.md +docs/ +notebooks/ +sampling-theory/ + +# Artifacts of running the app or the test suite against the bundled sample map. +# aa_test_congo.tif itself IS tracked (it backs the "Sample map" button); GDAL +# writes the .aux.xml statistics sidecar next to whatever raster it opens. +tests/data/*.aux.xml +tests/data/sae_design_aa_test_congo/ + +# Scratch raster for manual testing, 46M and referenced by nothing. +tests/data/hansen_bolivia.tif diff --git a/README.rst b/README.rst index 1f7c74d..92a0921 100644 --- a/README.rst +++ b/README.rst @@ -40,7 +40,7 @@ Features accepts a separate area CSV. Confidence levels of 90/95/99% are supported. Collect Earth Online ingestion and accuracy standard errors are planned for a later release. -- **SEPAL-UI Integration:** Built using ``sepal-ui`` for a seamless experience within the SEPAL environment. +- **pysepal Integration:** Built using ``pysepal`` for a seamless experience within the SEPAL environment. Prerequisites ------------- @@ -49,7 +49,7 @@ Prerequisites - The following Python libraries (and their dependencies) should be available in your SEPAL environment. The application checks for these and prints warnings if they are missing: - ``ipyvuetify`` - - ``sepal_ui`` + - ``pysepal`` - ``pandas`` - ``numpy`` - ``ipywidgets`` @@ -126,7 +126,7 @@ Technology Stack ---------------- - **UI Framework:** ``ipyvuetify`` (for Jupyter-based UI components) -- **SEPAL Integration:** ``sepal-ui`` (for SEPAL-specific widgets and model structure) +- **SEPAL Integration:** ``pysepal`` (for SEPAL-specific widgets and model structure) - **Mapping:** ``ipyleaflet`` (for interactive map display) - **Core Processing:** ``pandas``, ``numpy`` - **Geospatial Libraries:** diff --git a/app.py b/app.py index 52ba4a8..ae17d56 100644 --- a/app.py +++ b/app.py @@ -15,19 +15,30 @@ if os.path.exists(proj_data): os.environ["PROJ_DATA"] = proj_data +# Both tile servers bind 127.0.0.1 inside the kernel, so the browser needs a route +# that reaches them. SEPAL sets LOCALTILESERVER_CLIENT_PREFIX to a +# jupyter-server-proxy route that forwards any port in the sandbox, so it carries +# PMTiles as well as raster tiles -- but vectortileserver never autodetects one, +# and left alone its layers keep a URL the browser cannot reach, so the points +# silently never arrive. Only borrow the generic /proxy/{port} form: it forwards +# any port by construction, while a route namespaced to one server (localtileserver's +# own autodetected prefix) would not serve a vector port. +_raster_prefix = os.environ.get("LOCALTILESERVER_CLIENT_PREFIX") +if _raster_prefix and "/proxy/{port}" in _raster_prefix: + os.environ.setdefault("VECTORTILESERVER_CLIENT_PREFIX", _raster_prefix) + import logging import solara -from sepal_ui.logger import setup_logging -from sepal_ui.sepalwidgets.vue_app import MapApp, ThemeToggle -from sepal_ui.solara import ( - ThemeState, +from pysepal.logger import setup_logging +from pysepal.sepalwidgets.vue_app import MapApp +from pysepal.solara import ( + NotificationProvider, + get_current_theme_state, setup_sessions, setup_solara_server, setup_theme_colors, ) -from sepal_ui.solara.notifications import NotificationProvider -from solara.lab.components.theming import theme from component.model.app_model import AppModel from component.tile.upload import RasterMapWatcher @@ -48,33 +59,6 @@ USE_GEE = False -@solara.component -def _TileLoopbackBridge(): - """Mount jupyter_loopback's comm bridge so localhost tile fetches survive a proxy. - - localtileserver + vectortileserver serve tiles on ``127.0.0.1:``, which - the browser can't reach behind SEPAL / ``run-solara --serve`` (CSP forbids - connecting to localhost). The bridge reroutes those fetches over Solara's - websocket, but must be enabled BEFORE any tile client calls - ``intercept_localhost`` (on layer build) or that shim is a no-op -- hence - mounting it here at Page load. Default on; opt out with - ``LOCALTILESERVER_COMM_BRIDGE=0``. - """ - _flag = os.environ.get("LOCALTILESERVER_COMM_BRIDGE", "1").strip().lower() - enabled = _flag not in ("0", "false", "no", "off") - - def _enable(): - if not enabled: - return None - import jupyter_loopback - - return jupyter_loopback.enable_comm_bridge(display=False) - - bridge = solara.use_memo(_enable, []) - if bridge is not None: - solara.display(bridge) # its ESM installs the browser interceptor - - @solara.lab.on_kernel_start def on_kernel_start(): return setup_sessions() @@ -84,10 +68,7 @@ def on_kernel_start(): # @with_sepal_sessions(module_name="sbae_app") def Page(): """Main SBAE application page using MapApp layout.""" - # pysepal's MapApp requires a per-kernel ThemeState. In a local (non-SEPAL) - # run the session manager is active but has no theme_state component, so - # get_current_theme_state() would raise; provide an explicit one instead. - theme_state = solara.use_memo(ThemeState, []) + theme_state = get_current_theme_state() # Notification system (pysepal): mount the provider once at the app root, # before any component that calls use_notifications(). Kept in the same page @@ -96,14 +77,11 @@ def Page(): # process-local default and the toasts/pill stay light under a dark app. NotificationProvider(theme_state=theme_state) ErrorToastBridge() - _TileLoopbackBridge() app_model = AppModel() setup_theme_colors() - theme_toggle = ThemeToggle() - theme_toggle.observe(lambda e: setattr(theme, "dark", e["new"]), "dark") - sbae_map = SbaeMap(theme_toggle=theme_toggle, gee=USE_GEE) + sbae_map = SbaeMap(theme_state=theme_state, gee=USE_GEE) RasterMapWatcher(sbae_map) # Floating legend overlay for the sample/reference points (bottom-center). @@ -135,7 +113,7 @@ def Page(): # longer appear while the user is on the Analysis tab. right_panel_content = [ { - "content": [SampleConfiguration(sbae_map, theme_toggle=theme_toggle)], + "content": [SampleConfiguration(sbae_map, theme_state=theme_state)], }, ] @@ -146,7 +124,6 @@ def Page(): main_map=[sbae_map], steps_data=steps_data, initial_step=4, - theme_toggle=[theme_toggle], theme_state=theme_state, dialog_width=900, right_panel_config=right_panel_config, diff --git a/component/model/app_model.py b/component/model/app_model.py index 197a396..8499781 100644 --- a/component/model/app_model.py +++ b/component/model/app_model.py @@ -1,4 +1,4 @@ -from sepal_ui.model import Model +from pysepal.model import Model from traitlets import Int diff --git a/component/model/state_manager.py b/component/model/state_manager.py index 604e256..3481fe5 100644 --- a/component/model/state_manager.py +++ b/component/model/state_manager.py @@ -532,6 +532,9 @@ def clear_file_data(self): self.raster_optimization_status.value = "idle" self.raster_optimization_error.value = None self.optimized_raster_path.value = None + # the map layer is gated on this being non-empty, so a stale palette + # would let the next file render in the previous file's colours + self.class_colors.value = {} def clear_aoi_data(self): """Clear all AOI-related data (for simple/systematic sampling). diff --git a/component/scripts/logging_config.py b/component/scripts/logging_config.py index c66373b..1da510e 100644 --- a/component/scripts/logging_config.py +++ b/component/scripts/logging_config.py @@ -17,7 +17,7 @@ # rio-tiler emits ``NoOverviewWarning`` once per tile when a source raster has no # overviews. The raster-add paths now build overviews up front -# (``SbaeMap.add_class_raster`` -> ``prepare_for_tiles``), so this warning is +# (``SepalMap.add_raster`` -> ``prepare_for_tiles``), so this warning is # non-actionable noise here; silence it so any raster that still slips through a # fallback path cannot flood the logs with hundreds of identical lines. try: diff --git a/component/scripts/tiling.py b/component/scripts/tiling.py deleted file mode 100644 index 34f68a7..0000000 --- a/component/scripts/tiling.py +++ /dev/null @@ -1,213 +0,0 @@ -# file: tiling_prepare.py -import hashlib -import os -import pathlib -import shutil -import subprocess - -import rasterio as rio - - -def _hash_for_cache(path: str) -> str: - st = os.stat(path) - h = hashlib.sha1() - h.update(path.encode()) - h.update(str(st.st_size).encode()) - h.update(str(int(st.st_mtime)).encode()) - return h.hexdigest()[:16] - - -def _gdal_ok(): - return ( - shutil.which("gdalinfo") - and shutil.which("gdal_translate") - and shutil.which("gdaladdo") - ) - - -def _is_categorical(ds: rio.io.DatasetReader) -> bool: - # crude heuristic: integer dtype and few unique categories indicated by colormap or QL metadata - if ds.count != 1: - return False - if ds.dtypes[0].startswith(("int8", "uint8", "int16", "uint16", "int32", "uint32")): - return True - return False - - -def _has_overviews(ds): - return any(ds.overviews(i + 1) for i in range(ds.count)) - - -def _is_tiled(ds): - # block_shapes is None on some drivers; treat as not tiled - try: - bs = ds.block_shapes - return bs and all((b[0] > 1 and b[1] > 1) for b in bs) - except Exception: - return False - - -def _needs_reproject(ds, target_epsg: int | None): - if not target_epsg or not ds.crs: - return False - try: - return ds.crs.to_epsg() != target_epsg - except Exception: - return True - - -def _target_overview_levels(width, height, block=512): - # pyramid down to roughly block size - levels = [] - longest = max(width, height) - lvl = 2 - while longest / lvl > block: - levels.append(lvl) - lvl *= 2 - return levels or [2, 4, 8, 16] - - -def analyze_tif(path: str) -> dict: - with rio.open(path) as ds: - return { - "path": path, - "crs": str(ds.crs), - "epsg": (ds.crs.to_epsg() if ds.crs else None), - "width": ds.width, - "height": ds.height, - "bands": ds.count, - "dtype": ds.dtypes[0], - "tiled": _is_tiled(ds), - "overviews": [ds.overviews(i + 1) for i in range(ds.count)], - "categorical_guess": _is_categorical(ds), - } - - -def _build_overviews_inplace(path: str, categorical: bool): - resamp = "NEAREST" if categorical else "AVERAGE" - with rio.open(path) as ds: - levels = _target_overview_levels(ds.width, ds.height) - # Rasterio can build in place too: - try: - with rio.open(path, "r+") as ds: - ds.build_overviews(levels, resampling=resamp.lower()) - ds.update_tags(ns="rio_overview", resampling=resamp.lower()) - except Exception: - # fallback to gdaladdo if rasterio fails - if not shutil.which("gdaladdo"): - raise - cmd = [ - "gdaladdo", - "-r", - resamp, - "--config", - "COMPRESS_OVERVIEW", - "DEFLATE", - "--config", - "PREDICTOR_OVERVIEW", - "2", - path, - *map(str, levels), - ] - subprocess.run(cmd, check=True) - - -def _translate_to_cog(src: str, dst: str, resampling: str, block=512): - cmd = [ - "gdal_translate", - src, - dst, - "-of", - "COG", - "-co", - "COMPRESS=DEFLATE", - "-co", - "LEVEL=6", - "-co", - "PREDICTOR=2", - "-co", - f"BLOCKSIZE={block}", - "-co", - "NUM_THREADS=ALL_CPUS", - "-co", - f"RESAMPLING={resampling}", - ] - subprocess.run(cmd, check=True) - - -def _warp_to_epsg(src: str, dst: str, epsg: int, resampling: str, block=512): - cmd = [ - "gdalwarp", - "-overwrite", - "-t_srs", - f"EPSG:{epsg}", - "-r", - resampling, - "-multi", - "-wo", - "NUM_THREADS=ALL_CPUS", - "-co", - "TILED=YES", - "-co", - f"BLOCKXSIZE={block}", - "-co", - f"BLOCKYSIZE={block}", - "-co", - "COMPRESS=DEFLATE", - "-co", - "PREDICTOR=2", - "-co", - "BIGTIFF=IF_SAFER", - src, - dst, - ] - subprocess.run(cmd, check=True, capture_output=True, text=True) - - -def prepare_for_tiles( - path: str, - cache_dir: str | None = None, - warp_to_3857: bool = False, - force: bool = False, -) -> dict: - """Returns dict: {"path": optimized_path, "report": analysis_dict}.""" - path = os.path.abspath(path) - rep = analyze_tif(path) - categorical = rep["categorical_guess"] - resamp = "NEAREST" if categorical else "AVERAGE" - # Open once (context-managed) instead of leaking a handle per rio.open call. - with rio.open(path) as _ds: - need_reproj = _needs_reproject(_ds, 3857) if warp_to_3857 else False - good_enough = rep["tiled"] and _has_overviews(_ds) and not need_reproj - - if good_enough and not force: - return {"path": path, "report": rep} - - cache_dir = cache_dir or os.path.join(pathlib.Path.home(), ".cache", "localtiles") - os.makedirs(cache_dir, exist_ok=True) - tag = _hash_for_cache(path) - tmp_base = os.path.join(cache_dir, f"{os.path.basename(path)}.{tag}") - - if _gdal_ok(): - # Prefer building a clean COG (and reprojection if requested) - out = tmp_base + (".3857.cog.tif" if warp_to_3857 else ".cog.tif") - if warp_to_3857: - # first warp to an intermediate tiled TIFF, then translate to COG - inter = tmp_base + ".warp.tif" - _warp_to_epsg(path, inter, 3857, resamp) - _translate_to_cog(inter, out, resamp) - try: - os.remove(inter) - except (OSError, PermissionError): - pass - else: - _translate_to_cog(path, out, resamp) - final_rep = analyze_tif(out) - return {"path": out, "report": final_rep} - else: - # No GDAL CLI: do the minimum—build overviews in place or copy to temp and add overviews - dst = tmp_base + ".ovr.tif" - shutil.copy2(path, dst) - _build_overviews_inplace(dst, categorical) - final_rep = analyze_tif(dst) - return {"path": dst, "report": final_rep} diff --git a/component/scripts/vector_tiles.py b/component/scripts/vector_tiles.py index cf8236f..13b8c5e 100644 --- a/component/scripts/vector_tiles.py +++ b/component/scripts/vector_tiles.py @@ -12,7 +12,6 @@ import json import logging import os -import sys from typing import Callable, Optional import pandas as pd @@ -22,24 +21,6 @@ logger = logging.getLogger("sbae.vector_tiles") -def _ensure_tippecanoe_on_path() -> None: - """Put the interpreter's own ``bin/`` on PATH so tippecanoe is found. - - ``vectortileserver`` invokes ``tippecanoe`` by bare name via subprocess, so - it relies on PATH. Under SEPAL the app runs from a micromamba venv launched - by absolute path, so that venv's ``bin/`` -- where the conda-forge tippecanoe - lives, right next to ``sys.executable`` -- is NOT on PATH and the lookup - fails. Prepend it when the binary is actually there; idempotent, and a - harmless no-op when tippecanoe isn't a sibling of the interpreter. - """ - bindir = os.path.dirname(sys.executable) - if not bindir or not os.path.exists(os.path.join(bindir, "tippecanoe")): - return - parts = os.environ.get("PATH", "").split(os.pathsep) - if bindir not in parts: - os.environ["PATH"] = os.pathsep.join([bindir, *parts]) - - # Point styling. Sample/reference points stay a single neutral colour with a # white halo so they read over a colourful classification map; analysis points # encode only agreement (green = map matches reference, red = it doesn't). @@ -197,17 +178,7 @@ async def _default_layer_factory( """ import vectortileserver as vts - # vectortileserver shells out to `tippecanoe` by name -> ensure the venv's - # bin (where the conda tippecanoe sits) is on PATH before it runs. - _ensure_tippecanoe_on_path() - - # Bind the tile server to IPv4 loopback explicitly. The default "localhost" - # can resolve to IPv6 ::1, which some sandboxes (SEPAL) can't assign -> the - # server fails to bind and never starts. 127.0.0.1 is also the exact form - # jupyter_loopback's interceptor matches. - workspace = vts.TileWorkspace( - host="127.0.0.1", allowed_directories=allowed_directories - ) + workspace = vts.TileWorkspace(allowed_directories=allowed_directories) return await workspace.open_async( source, style=style, conversion_options=conversion_options ) diff --git a/component/tile/sample_calculation.py b/component/tile/sample_calculation.py index 74ac7f5..c145746 100644 --- a/component/tile/sample_calculation.py +++ b/component/tile/sample_calculation.py @@ -14,7 +14,7 @@ @solara.component -def SampleCalculationTile(theme_toggle=None): +def SampleCalculationTile(theme_state=None): """Step 3: Calculate Sample Size Dialog.""" with solara.Column(): solara.HTML(tag="h2", unsafe_innerHTML="Calculate Sample Size") @@ -43,15 +43,17 @@ def SampleCalculationTile(theme_toggle=None): if sampling_method == "stratified": sample_allocation_table() if app_state.sample_results.value.get("precision_curve"): - per_class_precision_chart(theme_toggle=theme_toggle) + per_class_precision_chart(theme_state=theme_state) - solara.Success("✅ Sample configuration complete! Ready to generate points.") + solara.Success( + "✅ Sample configuration complete! Ready to generate points." + ) # Display precision curve for all methods if app_state.sample_results.value and app_state.sample_results.value.get( "precision_curve" ): - precision_curve_info(theme_toggle=theme_toggle) + precision_curve_info(theme_state=theme_state) def sample_size_calculator() -> None: @@ -165,7 +167,7 @@ def handle_calculate_samples(): app_state.set_processing_status("") except Exception as e: - app_state.add_error(f"Error calculating samples: {str(e)}") + app_state.add_error(f"Error calculating samples: {e!s}") app_state.set_processing_status("") with solara.Card("Calculate Sample Size"): @@ -333,7 +335,7 @@ def update_samples(samples): ) -def per_class_precision_chart(theme_toggle=None): +def per_class_precision_chart(theme_state=None): """Display per-class precision (MOE) given current allocation.""" sample_results = app_state.sample_results.value if not sample_results: @@ -464,20 +466,18 @@ def per_class_precision_chart(theme_toggle=None): EChartsWidget.element( option=option, style={"height": "500px", "width": "100%"}, - theme_toggle=theme_toggle, + theme_state=theme_state, ) - solara.Info( - """ + solara.Info(""" 💡 **Interpretation**: The bars show the margin of error for each class's user accuracy estimate. Larger bars indicate less precise estimates. If you oversample rare classes, this chart helps verify you achieved the desired per-class precision. - """ - ) + """) -def precision_curve_info(theme_toggle=None) -> None: +def precision_curve_info(theme_state=None) -> None: """Display precision curve information showing MOE vs sample size relationship.""" sample_results = app_state.sample_results.value if not sample_results: @@ -574,14 +574,12 @@ def precision_curve_info(theme_toggle=None) -> None: EChartsWidget.element( option=option, style={"height": "500px", "width": "100%"}, - theme_toggle=theme_toggle, + theme_state=theme_state, ) - solara.Info( - """ + solara.Info(""" 💡 **Key Insight**: Notice how the MOE decreases rapidly at first, but the improvement slows as sample size increases. This is the "diminishing returns" effect - doubling the sample size doesn't halve the error. - """ - ) + """) diff --git a/component/tile/upload.py b/component/tile/upload.py index ce057eb..ecf991c 100644 --- a/component/tile/upload.py +++ b/component/tile/upload.py @@ -1,11 +1,12 @@ import logging import os from pathlib import Path -from typing import Any, Dict +from typing import Any, Dict, Optional import solara -from sepal_ui.sepalwidgets.file_input import FileInputComponent -from sepal_ui.solara.notifications import use_notifications +from pysepal.mapping import prepare_for_tiles +from pysepal.solara import use_notifications +from pysepal.solara.components.inputs import FileInputComponent from component.model import app_state from component.scripts.geospatial import ( @@ -14,7 +15,6 @@ get_file_info, is_raster_file, ) -from component.scripts.tiling import prepare_for_tiles from component.widget.map import SbaeMap logger = logging.getLogger("sbae.upload") @@ -28,17 +28,23 @@ def add_optimized_raster_to_map(): optimized_path = app_state.optimized_raster_path.value status = app_state.raster_optimization_status.value sampling_method = app_state.sampling_method.value + class_colors = app_state.class_colors.value + # The palette and the tiled COG are produced by two independent async + # paths, so wait for the palette: adding the raster without it renders + # every class down the dark end of the default continuous ramp. Holding + # the status here is what re-runs this effect once the palette lands. if ( optimized_path and status == "adding_to_map" and sampling_method == "stratified" + and class_colors ): - sbae_map.add_class_raster( + sbae_map.add_raster( optimized_path, - app_state.class_colors.value, layer_name="Classification Map", key="clas", + class_colors=class_colors, ) app_state.raster_optimization_status.value = "finished" @@ -116,6 +122,23 @@ def clear_file(): ) +def _reject_reason(file_info: dict) -> Optional[str]: + """Why this file cannot serve as the classification map, or ``None``. + + Raster only: the map is served as tiles and the stratified design reads its + classes per pixel, so a vector carries neither. ``get_file_info`` reports + ``"vector"`` for one and ``"unknown"`` for anything it could not open. + """ + if "error" in file_info: + return file_info["error"] + if file_info.get("file_type") != "raster": + return ( + "Unsupported file format. The classification map must be a raster " + "(GeoTIFF, ERDAS Imagine, or another format rasterio can open)." + ) + return None + + def _upload_toast(*, has_file, is_raster, state, value, error): """Decide the terminal upload toast: ``(level, message)`` or ``None``. @@ -192,27 +215,22 @@ def worker(): intrusive_cancel=False, ) - def handle_non_raster_and_layer_removal(): - """Handle non-raster files and layer removal when needed.""" + def handle_layer_removal(): + """Take the classification layer off the map when it no longer applies. + + Adding it is ``RasterMapWatcher``'s job, once the tiled COG and the + class palette are both ready. + """ if sbae_map is None: return sampling_method = app_state.sampling_method.value should_show_layer = has_file and sampling_method == "stratified" - if should_show_layer: - file_path = app_state.file_path.value - is_raster = is_raster_file(file_path) - - if not is_raster: - app_state.raster_optimization_status.value = "idle" - sbae_map.add_raster( - file_path, layer_name="Classification Map", key="clas" - ) - else: + if not should_show_layer: sbae_map.remove_layer("clas", none_ok=True) solara.use_effect( - handle_non_raster_and_layer_removal, + handle_layer_removal, [ has_file, app_state.file_path.value, @@ -353,15 +371,9 @@ def handle_file_selection_from_input(file_path): try: file_info_dict = get_file_info(file_path) - if "error" in file_info_dict: - app_state.file_error.value = file_info_dict["error"] - selected_file_path.value = None - selected_file_info_preview.value = None - is_valid_file.value = False - return - - if file_info_dict.get("file_type") == "unknown": - app_state.file_error.value = "Unsupported file format. Please select a valid geospatial file (GeoTIFF, Shapefile, GeoJSON, or GeoPackage)." + rejection = _reject_reason(file_info_dict) + if rejection: + app_state.file_error.value = rejection selected_file_path.value = None selected_file_info_preview.value = None is_valid_file.value = False diff --git a/component/widget/analysis_chart.py b/component/widget/analysis_chart.py index ac74b89..24d70a9 100644 --- a/component/widget/analysis_chart.py +++ b/component/widget/analysis_chart.py @@ -35,7 +35,7 @@ def _ChartTitle(title: str): @solara.component -def AreaEstimateChart(results: dict, unit: str, theme_toggle=None): +def AreaEstimateChart(results: dict, unit: str, theme_state=None): rows = results.get("class_estimates", []) if not rows: return @@ -91,7 +91,7 @@ def AreaEstimateChart(results: dict, unit: str, theme_toggle=None): EChartsWidget.element( option=option, style={"height": _CHART_H, "width": "100%"}, - theme_toggle=theme_toggle, + theme_state=theme_state, ) @@ -117,7 +117,7 @@ def confusion_heatmap_data(confusion_matrix: dict): @solara.component -def ConfusionMatrixChart(results: dict, theme_toggle=None): +def ConfusionMatrixChart(results: dict, theme_state=None): cm = results.get("confusion_matrix") if not cm or not cm.get("data"): return @@ -174,12 +174,12 @@ def ConfusionMatrixChart(results: dict, theme_toggle=None): RawEChartsWidget.element( option=option, style={"height": _CHART_H, "width": "100%"}, - theme_toggle=theme_toggle, + theme_state=theme_state, ) @solara.component -def AccuracyByClassChart(results: dict, theme_toggle=None): +def AccuracyByClassChart(results: dict, theme_state=None): rows = results.get("accuracy_rows", []) if not rows: return @@ -204,7 +204,7 @@ def AccuracyByClassChart(results: dict, theme_toggle=None): EChartsWidget.element( option=option, style={"height": _CHART_H, "width": "100%"}, - theme_toggle=theme_toggle, + theme_state=theme_state, ) @@ -223,7 +223,7 @@ def AccuracyByClassChart(results: dict, theme_toggle=None): @solara.component def AreaProportionChart( - results: dict, theme_toggle=None, legend_width: int | None = 480, card: bool = True + results: dict, theme_state=None, legend_width: int | None = 480, card: bool = True ): rows = results.get("class_estimates", []) if not rows: @@ -276,7 +276,7 @@ def _body(): EChartsWidget.element( option=option, style={"height": _CHART_H, "width": "100%"}, - theme_toggle=theme_toggle, + theme_state=theme_state, ) # ``card=False`` drops the surface so it sits flush in the right panel. diff --git a/component/widget/analysis_dashboard.py b/component/widget/analysis_dashboard.py index cf66f5f..f86c14b 100644 --- a/component/widget/analysis_dashboard.py +++ b/component/widget/analysis_dashboard.py @@ -113,7 +113,7 @@ def _DashboardKpiCards(results: dict): @solara.component -def AnalysisDashboardModal(open, theme_toggle=None): +def AnalysisDashboardModal(open, theme_state=None): # Hooks must run unconditionally, before the early return, for hook-order # stability across renders (see solara's rules-of-hooks). # @@ -141,10 +141,10 @@ def _resize_on_open(): solara.v.Html(tag="div", children=[resizer], style_="display: none;") _DashboardKpiCards(results) with solara.ColumnsResponsive(6, small=12): - ConfusionMatrixChart(results, theme_toggle=theme_toggle) - AccuracyByClassChart(results, theme_toggle=theme_toggle) - AreaEstimateChart(results, unit, theme_toggle=theme_toggle) - AreaProportionChart(results, theme_toggle=theme_toggle) + ConfusionMatrixChart(results, theme_state=theme_state) + AccuracyByClassChart(results, theme_state=theme_state) + AreaEstimateChart(results, unit, theme_state=theme_state) + AreaProportionChart(results, theme_state=theme_state) with solara.Details("Tables"): # Space the three tables apart so they don't read as one block. with solara.Column(style="gap: 28px; padding-top: 8px;"): @@ -157,7 +157,7 @@ def _resize_on_open(): @solara.component -def AnalysisSummaryCard(theme_toggle=None): +def AnalysisSummaryCard(theme_state=None): results = app_state.analysis_results.value # Hook must run unconditionally, before the early return, for hook-order # stability across renders (see solara's rules-of-hooks). @@ -179,7 +179,7 @@ def AnalysisSummaryCard(theme_toggle=None): small=True, label=True, outlined=True, children=[chip_text] ) AreaProportionChart( - results, theme_toggle=theme_toggle, legend_width=None, card=False + results, theme_state=theme_state, legend_width=None, card=False ) solara.Button( "View dashboard", @@ -189,4 +189,4 @@ def AnalysisSummaryCard(theme_toggle=None): on_click=lambda: open_modal.set(True), ) _Downloads() - AnalysisDashboardModal(open_modal, theme_toggle=theme_toggle) + AnalysisDashboardModal(open_modal, theme_state=theme_state) diff --git a/component/widget/analysis_results.py b/component/widget/analysis_results.py index 5af9ba1..f5b979f 100644 --- a/component/widget/analysis_results.py +++ b/component/widget/analysis_results.py @@ -12,13 +12,13 @@ def _unit_label(unit: str) -> str: @solara.component -def AnalysisResultsView(theme_toggle=None): +def AnalysisResultsView(theme_state=None): results = app_state.analysis_results.value if not results: return from component.widget.analysis_dashboard import AnalysisSummaryCard - AnalysisSummaryCard(theme_toggle=theme_toggle) + AnalysisSummaryCard(theme_state=theme_state) @solara.component diff --git a/component/widget/analysis_tab.py b/component/widget/analysis_tab.py index c04e7e2..d0814e4 100644 --- a/component/widget/analysis_tab.py +++ b/component/widget/analysis_tab.py @@ -6,7 +6,7 @@ import pandas as pd import solara -from sepal_ui.sepalwidgets.file_input import FileInputComponent +from pysepal.solara.components.inputs import FileInputComponent from component.analysis.service import AnalysisService from component.model import app_state @@ -164,7 +164,7 @@ def derive_map_source(state, sbae_map=None): state.analysis_reference_df.value = ref_out # Standalone mode never runs the design-step upload that populates - # class_colors, so it's empty here -- without this, add_class_raster falls + # class_colors, so it's empty here -- without this, add_raster falls # back to a continuous colormap and the map renders near-black. Derive it # from the raster; guarded so a real design-step palette is kept. if not state.class_colors.value: @@ -176,11 +176,11 @@ def derive_map_source(state, sbae_map=None): if sbae_map is not None: try: - sbae_map.add_class_raster( + sbae_map.add_raster( raster, - state.class_colors.value or {}, - "Classification (analysis)", - "clas_an", + layer_name="Classification (analysis)", + key="clas_an", + class_colors=state.class_colors.value or {}, ) # Reference points are drawn by the panel's render thread (from # analysis_reference_df, for every source) on their own layer. @@ -294,7 +294,7 @@ def CurrentTableDisplay(title: str, df, name: str = "", on_clear=None): @solara.component -def AnalysisPanel(sbae_map=None, theme_toggle=None): +def AnalysisPanel(sbae_map=None, theme_state=None): """Full analysis panel filling the Analysis tab.""" reading = solara.use_reactive(False) ref_path = solara.use_reactive(None) @@ -486,7 +486,7 @@ def close_ref_modal_when_loaded(): # stamped with an inputs signature at Calculate time; once any input # changes the dashboard is hidden until the user recalculates. if _results_are_fresh(app_state): - AnalysisResultsView(theme_toggle=theme_toggle) + AnalysisResultsView(theme_state=theme_state) elif not ref_loaded: solara.Info( "Upload a reference table (or load the example data) to run the " diff --git a/component/widget/aoi_upload_selector.py b/component/widget/aoi_upload_selector.py index 024cce6..7474b7e 100644 --- a/component/widget/aoi_upload_selector.py +++ b/component/widget/aoi_upload_selector.py @@ -1,7 +1,7 @@ import logging import solara -from sepal_ui.solara.components.aoi.aoi_view import AoiView +from pysepal.solara.components.aoi.aoi_view import AoiView from component.model import app_state from component.tile.upload import CurrentFileDisplay, UploadTile diff --git a/component/widget/echarts.py b/component/widget/echarts.py index eb460ab..ce14ee9 100644 --- a/component/widget/echarts.py +++ b/component/widget/echarts.py @@ -6,19 +6,19 @@ class _EChartsThemeMixin: """Shared light/dark theming for ipecharts widgets. - Observes an optional ThemeToggle (or the global ``v.theme``) and keeps the + Observes an optional ThemeState (or the global ``v.theme``) and keeps the echarts ``theme`` trait in sync. SVG renderer for crisp static output. """ - def _init_theme(self, theme_toggle): + def _init_theme(self, theme_state): self.renderer = "svg" - self.theme_toggle = theme_toggle + self.theme_state = theme_state self.theme = self.get_theme() - target = self.theme_toggle if self.theme_toggle else v.theme + target = self.theme_state if self.theme_state else v.theme target.observe(self.set_theme, "dark") def get_theme(self): - obj = self.theme_toggle if self.theme_toggle else v.theme + obj = self.theme_state if self.theme_state else v.theme return "dark" if getattr(obj, "dark") else "light" def set_theme(self, _): @@ -26,12 +26,12 @@ def set_theme(self, _): class EChartsWidget(BaseEChartsWidget, _EChartsThemeMixin): - def __init__(self, theme_toggle=None, *args, **kwargs): + def __init__(self, theme_state=None, *args, **kwargs): super().__init__(*args, **kwargs) - self._init_theme(theme_toggle) + self._init_theme(theme_state) class RawEChartsWidget(BaseEChartsRawWidget, _EChartsThemeMixin): - def __init__(self, theme_toggle=None, *args, **kwargs): + def __init__(self, theme_state=None, *args, **kwargs): super().__init__(*args, **kwargs) - self._init_theme(theme_toggle) + self._init_theme(theme_state) diff --git a/component/widget/map.py b/component/widget/map.py index f2e552a..66f726a 100644 --- a/component/widget/map.py +++ b/component/widget/map.py @@ -1,15 +1,12 @@ import logging -import os import shutil import pandas as pd import solara -from localtileserver import TileClient, get_leaflet_tile_layer +from pysepal.mapping import SepalMap from pysepal.scripts.scratch import scratch_dir -from sepal_ui.mapping import SepalMap -from sepal_ui.sepalwidgets.vue_app import ThemeToggle +from pysepal.solara import ThemeState -from component.scripts.logging_config import quiet_tile_server_logs from component.scripts.vector_tiles import ( CORRECT_COLOR, INCORRECT_COLOR, @@ -29,12 +26,6 @@ _REFERENCE_LEGEND_LABEL = "Reference point" -def _hex_to_rgb(hex_color: str) -> tuple: - """Convert '#rrggbb' to an (r, g, b) tuple.""" - h = hex_color.lstrip("#") - return tuple(int(h[i : i + 2], 16) for i in (0, 2, 4)) - - def _points_signature(df): """Cheap content signature of a points DataFrame (``None`` when empty). @@ -71,29 +62,14 @@ def _compose_points_legend( return legend -def _build_class_colormap(class_colors: dict) -> dict: - """Build a discrete {pixel_value: (r, g, b, a)} LUT for a categorical raster. - - Every class present in ``class_colors`` is rendered opaque, including code 0 - and codes above 255 -- area calculation treats those as valid, sampleable - classes, so they must be visible on the map. Values not in ``class_colors`` - render transparent (background). - """ - colormap = {i: (0, 0, 0, 0) for i in range(256)} - for code, hex_color in class_colors.items(): - colormap[int(code)] = (*_hex_to_rgb(hex_color), 255) - return colormap - - class SbaeMap(SepalMap): """SBAE Map class extending SepalMap for map visualization and interactions.""" - def __init__(self, theme_toggle: ThemeToggle, gee: bool = False, min_zoom: int = 5): + def __init__(self, theme_state: ThemeState, gee: bool = False, min_zoom: int = 5): super().__init__( - fullscreen=True, theme_toggle=theme_toggle, gee=gee, min_zoom=min_zoom + fullscreen=True, theme_state=theme_state, gee=gee, min_zoom=min_zoom ) - self.classification_layer = None self.sample_points_layer = None self.sample_points_dir = None self._pending_points_dir = None @@ -109,123 +85,6 @@ def __init__(self, theme_toggle: ThemeToggle, gee: bool = False, min_zoom: int = # re-entry remounts the render task). self._reference_points_sig = None - def _optimize_for_tiles(self, path) -> str: - """Return a tiling-optimized (cached COG with overviews) path. - - rio-tiler reads full-resolution pixels -- and warns ``NoOverviewWarning`` - -- for every tile when the source has no overviews, so low-zoom tiles are - slow. ``prepare_for_tiles`` is a fast no-op when ``path`` is already a - tiled COG with overviews (the design step pre-optimizes off-thread); it - only does real work for raw rasters such as the analysis classification - map, which are added off the UI thread. Best-effort: on failure, serve the - raw raster (tiling still works, just slower). - """ - from component.scripts.tiling import prepare_for_tiles - - try: - return prepare_for_tiles(str(path))["path"] - except Exception as e: - logger.warning( - "Tiling optimization failed for %s (%s); serving the raw raster.", - path, - e, - ) - return str(path) - - def add_class_raster( - self, - path, - class_colors, - layer_name: str = "Classification Map", - key: str = "clas", - opacity: float = 1.0, - fit_bounds: bool = True, - ): - """Add a categorical raster with exact per-class colors. - - Unlike ``add_raster`` (which applies a continuous inferno colormap - stretched across the value range and renders sparse/low-value class - maps as black), this builds a discrete lookup table so each class - value gets its own color. Any value not in ``class_colors`` is rendered - transparent (background); classes 0 and > 255 are colored like any other. - - Args: - path: path to the (optimized) raster file. - class_colors: mapping of class code -> '#rrggbb' hex color. - layer_name: display name of the layer. - key: unequivocal key of the layer (for later removal). - opacity: layer opacity, default 1.0. - fit_bounds: whether to recenter/zoom onto the raster. - """ - # Build (or reuse a cached) COG with overviews before tiling: rio-tiler - # otherwise reads full-res pixels and logs NoOverviewWarning per tile. - tile_path = self._optimize_for_tiles(path) - - if not class_colors: - logger.warning( - "add_class_raster called without class_colors; " - "falling back to add_raster for %s", - path, - ) - return self.add_raster( - tile_path, - layer_name=layer_name, - key=key, - opacity=opacity, - fit_bounds=fit_bounds, - ) - - # Discrete LUT: transparent everywhere unless it's a known class. Every - # class in class_colors is colored, including code 0 and codes > 255. - colormap = _build_class_colormap(class_colors) - - # localtileserver won't accept a raw {value: rgba} dict as `colormap`; - # it must be registered server-side first, which yields a "custom:" - # key. This is the only path that preserves per-class alpha (the - # matplotlib-Colormap path forces alpha=1, losing transparency). - try: - from localtileserver.tiler.palettes import register_colormap - - colormap_arg = register_colormap(colormap) - except Exception: - logger.warning( - "localtileserver register_colormap unavailable; falling back " - "to add_raster (classes may render dark) for %s", - path, - ) - return self.add_raster( - tile_path, - layer_name=layer_name, - key=key, - opacity=opacity, - fit_bounds=fit_bounds, - ) - - # Bind the tile server to a reachable interface when serving the app - # over the network (e.g. Solara --host over Tailscale). Defaults to - # loopback for local dev; set LOCALTILESERVER_HOST=0.0.0.0 (or the - # tailnet IP) plus LOCALTILESERVER_CLIENT_HOST for remote access. - client = TileClient( - tile_path, host=os.environ.get("LOCALTILESERVER_HOST", "127.0.0.1") - ) - quiet_tile_server_logs() - layer = get_leaflet_tile_layer( - client, - colormap=colormap_arg, - name=layer_name, - opacity=opacity, - max_zoom=20, - ) - self.add_layer(layer, key=key) - self.classification_layer = layer - layer.raster = str(path) - - if fit_bounds: - self.center = client.center() - self.zoom = client.default_zoom - - return layer - async def build_sample_points_layer( self, points_data, @@ -289,7 +148,7 @@ def attach_sample_points_layer(self, layer): Mutates the map, so the main thread is preferred. ``add_sample_points`` deliberately calls this off the UI thread anyway, matching the - pre-existing off-thread ``add_class_raster`` mutation in + pre-existing off-thread ``add_raster`` mutation in ``analysis_tab.py``. """ old_dir = getattr(self, "sample_points_dir", None) diff --git a/component/widget/notification_bridge.py b/component/widget/notification_bridge.py index 78a618b..c83fbeb 100644 --- a/component/widget/notification_bridge.py +++ b/component/widget/notification_bridge.py @@ -3,7 +3,7 @@ import logging import solara -from sepal_ui.solara.notifications import use_notifications +from pysepal.solara import use_notifications from component.model import app_state diff --git a/component/widget/sample_configuration.py b/component/widget/sample_configuration.py index af29590..a6403d9 100644 --- a/component/widget/sample_configuration.py +++ b/component/widget/sample_configuration.py @@ -78,7 +78,7 @@ def apply_sample_design_workflow(state, workflow: str): @solara.component -def SampleConfiguration(sbae_map=None, theme_toggle=None): +def SampleConfiguration(sbae_map=None, theme_state=None): """Sample configuration widget for the right panel.""" # Use use_ref to persist value across renders without re-initializing prev_method_ref = solara.use_ref(app_state.sampling_method.value) @@ -153,11 +153,11 @@ def run_calculation(): if active_tab.value == 0: DesignTab( sbae_map, - theme_toggle=theme_toggle, + theme_state=theme_state, point_generation_controller=point_generation_controller, ) else: - AnalysisTab(sbae_map=sbae_map, theme_toggle=theme_toggle) + AnalysisTab(sbae_map=sbae_map, theme_state=theme_state) @solara.component @@ -191,7 +191,7 @@ def MethodologyHelpButton( @solara.component -def DesignTab(sbae_map=None, theme_toggle=None, point_generation_controller=None): +def DesignTab(sbae_map=None, theme_state=None, point_generation_controller=None): """Olofsson accuracy-assessment sample design.""" with solara.Row(style="align-items: center; gap: 4px;"): with solara.Column(style="flex: 1;"): @@ -221,13 +221,13 @@ def DesignTab(sbae_map=None, theme_toggle=None, point_generation_controller=None # Design tab so they no longer leak onto the Analysis tab. DesignOutputs( sbae_map, - theme_toggle=theme_toggle, + theme_state=theme_state, point_generation_controller=point_generation_controller, ) @solara.component -def DesignOutputs(sbae_map=None, theme_toggle=None, point_generation_controller=None): +def DesignOutputs(sbae_map=None, theme_state=None, point_generation_controller=None): """Design-phase outputs, relocated from standalone right-panel sections. Renders the sample-design summary, point generation and export blocks using @@ -236,7 +236,7 @@ def DesignOutputs(sbae_map=None, theme_toggle=None, point_generation_controller= renders without a map). """ Section("Summary", "mdi-progress-check") - Summary(theme_toggle=theme_toggle) + Summary(theme_state=theme_state) Section( "Generate Points", @@ -257,11 +257,11 @@ def DesignOutputs(sbae_map=None, theme_toggle=None, point_generation_controller= @solara.component -def AnalysisTab(sbae_map=None, theme_toggle=None): +def AnalysisTab(sbae_map=None, theme_state=None): """Accuracy-assessment analysis (area estimation + accuracies).""" from component.widget.analysis_tab import AnalysisPanel - AnalysisPanel(sbae_map=sbae_map, theme_toggle=theme_toggle) + AnalysisPanel(sbae_map=sbae_map, theme_state=theme_state) @solara.component diff --git a/component/widget/summary.py b/component/widget/summary.py index 234d2aa..782b980 100644 --- a/component/widget/summary.py +++ b/component/widget/summary.py @@ -14,7 +14,7 @@ @solara.component -def Summary(theme_toggle=None): +def Summary(theme_state=None): """Right panel content with progress and summary.""" sample_results = app_state.sample_results.value sampling_method = app_state.sampling_method.value @@ -28,13 +28,13 @@ def Summary(theme_toggle=None): sampling_method=sampling_method, ) - precision_curve_graph(theme_toggle=theme_toggle) + precision_curve_graph(theme_state=theme_state) # Only show per-class precision for stratified sampling if sampling_method == "stratified": - per_class_precision_graph(theme_toggle=theme_toggle) + per_class_precision_graph(theme_state=theme_state) - area_proportion_pie_chart(theme_toggle=theme_toggle) + area_proportion_pie_chart(theme_state=theme_state) def statistics_summary( @@ -156,7 +156,7 @@ def set_v_on(): ) -def precision_curve_graph(theme_toggle=None) -> None: +def precision_curve_graph(theme_state=None) -> None: """Display precision curve graph showing MOE vs sample size relationship.""" sample_results = app_state.sample_results.value if not sample_results: @@ -226,7 +226,7 @@ def precision_curve_graph(theme_toggle=None) -> None: EChartsWidget.element( option=option, style={"height": "220px", "width": "100%"}, - theme_toggle=theme_toggle, + theme_state=theme_state, ) with solara.Row( @@ -240,7 +240,7 @@ def precision_curve_graph(theme_toggle=None) -> None: ) -def per_class_precision_graph(theme_toggle=None) -> None: +def per_class_precision_graph(theme_state=None) -> None: """Display per-class precision (MOE) given current allocation.""" sample_results = app_state.sample_results.value if not sample_results: @@ -324,7 +324,7 @@ def per_class_precision_graph(theme_toggle=None) -> None: EChartsWidget.element( option=option, style={"height": "280px", "width": "100%"}, - theme_toggle=theme_toggle, + theme_state=theme_state, ) max_moe_row = moe_df.iloc[0] @@ -342,7 +342,7 @@ def per_class_precision_graph(theme_toggle=None) -> None: @solara.component -def area_proportion_pie_chart(theme_toggle=None): +def area_proportion_pie_chart(theme_state=None): """Pie chart showing the proportion of each class by area.""" area_data = app_state.area_data.value class_colors = app_state.class_colors.value @@ -410,5 +410,5 @@ def area_proportion_pie_chart(theme_toggle=None): style={"height": "380px", "width": "100%"}, # width="100%", # height="300px", - theme_toggle=theme_toggle, + theme_state=theme_state, ) diff --git a/sepal_environment.yml b/sepal_environment.yml index 6b993cc..c9c5699 100644 --- a/sepal_environment.yml +++ b/sepal_environment.yml @@ -24,10 +24,16 @@ dependencies: # tippecanoe builds the PMTiles vector tiles for the map sample-points layer. - tippecanoe>=2 - pip: - - pysepal>=3.8.1 + # 4.0 drops the sepal_ui package, moves theme onto ThemeState, and is the + # first release with add_raster(class_colors=...). The floor names the rc + # on purpose: pip only considers a pre-release when the specifier itself + # mentions one, so plain >=4.0.0 resolves to nothing while 4.0.0rc0 is + # the only 4.x on PyPI. + - pysepal>=4.0.0rc0 - git+https://github.com/openforis/earthengine-api.git@v1.6.14#egg=earthengine-api&subdirectory=python - - git+https://github.com/SerafiniJose/jupyter-loopback.git@fix/voila-localhost-proxy-probe - ipecharts>=1.0.0 - voila - # local vector-tile server for the PMTiles map sample-points layer - - vectortileserver>=0.2.1 + # local vector-tile server for the PMTiles map sample-points layer. + # 0.2.2 is the first release that binds 127.0.0.1 and resolves tippecanoe + # next to the interpreter, which sbae used to patch around locally. + - vectortileserver>=0.2.2 diff --git a/tests/test_analysis_charts.py b/tests/test_analysis_charts.py index 0222d16..2a6f57b 100644 --- a/tests/test_analysis_charts.py +++ b/tests/test_analysis_charts.py @@ -66,7 +66,7 @@ def test_confusion_heatmap_data_shapes(): def test_confusion_matrix_chart_renders_raw_widget(): _, rc = solara.render( - ConfusionMatrixChart(_RESULTS, theme_toggle=None), handle_error=False + ConfusionMatrixChart(_RESULTS, theme_state=None), handle_error=False ) widgets = rc.find(RawEChartsWidget).widgets assert len(widgets) == 1 @@ -78,7 +78,7 @@ def test_confusion_matrix_chart_renders_raw_widget(): def test_accuracy_by_class_chart_two_series(): _, rc = solara.render( - AccuracyByClassChart(_RESULTS, theme_toggle=None), handle_error=False + AccuracyByClassChart(_RESULTS, theme_state=None), handle_error=False ) widgets = rc.find(EChartsWidget).widgets assert len(widgets) == 1 @@ -89,7 +89,7 @@ def test_accuracy_by_class_chart_two_series(): def test_area_proportion_chart_pie_sums_to_100(): _, rc = solara.render( - AreaProportionChart(_RESULTS, theme_toggle=None), handle_error=False + AreaProportionChart(_RESULTS, theme_state=None), handle_error=False ) widgets = rc.find(EChartsWidget).widgets assert len(widgets) == 1 @@ -99,7 +99,7 @@ def test_area_proportion_chart_pie_sums_to_100(): def test_area_estimate_chart_has_transparent_background(): _, rc = solara.render( - AreaEstimateChart(_RESULTS, "ha", theme_toggle=None), handle_error=False + AreaEstimateChart(_RESULTS, "ha", theme_state=None), handle_error=False ) widgets = rc.find(EChartsWidget).widgets assert len(widgets) == 1 @@ -109,22 +109,20 @@ def test_area_estimate_chart_has_transparent_background(): def test_confusion_matrix_chart_empty_matrix_renders_nothing(): results = {"confusion_matrix": {"index": [], "columns": [], "data": []}} _, rc = solara.render( - ConfusionMatrixChart(results, theme_toggle=None), handle_error=False + ConfusionMatrixChart(results, theme_state=None), handle_error=False ) assert len(rc.find(RawEChartsWidget).widgets) == 0 def test_accuracy_by_class_chart_empty_renders_nothing(): _, rc = solara.render( - AccuracyByClassChart({}, theme_toggle=None), handle_error=False + AccuracyByClassChart({}, theme_state=None), handle_error=False ) assert len(rc.find(EChartsWidget).widgets) == 0 def test_area_proportion_chart_empty_renders_nothing(): - _, rc = solara.render( - AreaProportionChart({}, theme_toggle=None), handle_error=False - ) + _, rc = solara.render(AreaProportionChart({}, theme_state=None), handle_error=False) assert len(rc.find(EChartsWidget).widgets) == 0 @@ -136,7 +134,7 @@ def test_area_proportion_chart_all_zero_area_does_not_crash(): ] } _, rc = solara.render( - AreaProportionChart(results, theme_toggle=None), handle_error=False + AreaProportionChart(results, theme_state=None), handle_error=False ) assert ( len(rc.find(EChartsWidget).widgets) == 1 diff --git a/tests/test_analysis_dashboard.py b/tests/test_analysis_dashboard.py index 1774fb0..1f59005 100644 --- a/tests/test_analysis_dashboard.py +++ b/tests/test_analysis_dashboard.py @@ -42,7 +42,7 @@ def test_modal_renders_all_charts_when_open(): } open_r = solara.reactive(True) _, rc = solara.render( - AnalysisDashboardModal(open_r, theme_toggle=None), handle_error=False + AnalysisDashboardModal(open_r, theme_state=None), handle_error=False ) # 3 typed charts (accuracy, area, pie) + 1 raw (heatmap) assert len(rc.find(EChartsWidget).widgets) == 3 @@ -55,7 +55,7 @@ def test_summary_card_shows_kpis_and_button(): "overall_accuracy": 0.85, "confidence_level": 95.0, } - _, rc = solara.render(AnalysisResultsView(theme_toggle=None), handle_error=False) + _, rc = solara.render(AnalysisResultsView(theme_state=None), handle_error=False) # the summary shows the KPI values as chips ("Overall 85.0%", ...) text = " ".join(str(c) for w in rc.find(v.Chip).widgets for c in (w.children or [])) assert "85.0%" in text diff --git a/tests/test_analysis_ui_widgets.py b/tests/test_analysis_ui_widgets.py index 9c37034..9df7e4a 100644 --- a/tests/test_analysis_ui_widgets.py +++ b/tests/test_analysis_ui_widgets.py @@ -246,17 +246,17 @@ def test_analysis_panel_accepts_sbae_map(): class _FakeSbaeMap: - """Records add_class_raster/add_sample_points calls instead of a real map.""" + """Records add_raster/add_sample_points calls instead of a real map.""" def __init__(self): self.class_raster_calls = [] self.sample_points_calls = [] self.reference_points_calls = [] - def add_class_raster(self, path, class_colors, layer_name, key): + def add_raster(self, image, layer_name=None, key=None, class_colors=None, **kwargs): self.class_raster_calls.append( { - "path": path, + "path": image, "class_colors": class_colors, "layer_name": layer_name, "key": key, diff --git a/tests/test_app_page_render.py b/tests/test_app_page_render.py index bc9d00f..35185c9 100644 --- a/tests/test_app_page_render.py +++ b/tests/test_app_page_render.py @@ -19,7 +19,7 @@ _RENDER_SCRIPT = """ import solara import solara.server.kernel_context as kc -from sepal_ui.solara import setup_sessions +from pysepal.solara import setup_sessions ctx = kc.create_dummy_context() kc.set_current_context(ctx) diff --git a/tests/test_coordinate_transformation.py b/tests/test_coordinate_transformation.py index 2edd787..11f8338 100644 --- a/tests/test_coordinate_transformation.py +++ b/tests/test_coordinate_transformation.py @@ -74,7 +74,7 @@ def test_raster_coordinate_transformation(): assert bounds[2] <= expected_bounds[2] + 100, "Points outside expected bounds" assert bounds[3] <= expected_bounds[3] + 100, "Points outside expected bounds" - print("✓ Raster coordinate transformation test passed") + print("Raster coordinate transformation test passed") def test_vector_coordinate_transformation(): @@ -127,7 +127,7 @@ def test_vector_coordinate_transformation(): assert bounds[2] <= 510000 + 100, "Points outside expected bounds" assert bounds[3] <= 4503000 + 100, "Points outside expected bounds" - print("✓ Vector coordinate transformation test passed") + print("Vector coordinate transformation test passed") def test_simple_random_coordinate_transformation(): @@ -169,7 +169,7 @@ def test_simple_random_coordinate_transformation(): points_df["latitude"].between(-90, 90).all() ), "Latitude values outside valid range" - print("✓ Simple random coordinate transformation test passed") + print("Simple random coordinate transformation test passed") def test_systematic_coordinate_transformation(): @@ -211,7 +211,7 @@ def test_systematic_coordinate_transformation(): points_df["latitude"].between(-90, 90).all() ), "Latitude values outside valid range" - print("✓ Systematic coordinate transformation test passed") + print("Systematic coordinate transformation test passed") if __name__ == "__main__": @@ -219,4 +219,4 @@ def test_systematic_coordinate_transformation(): test_vector_coordinate_transformation() test_simple_random_coordinate_transformation() test_systematic_coordinate_transformation() - print("\n✅ All coordinate transformation tests passed!") + print("\nAll coordinate transformation tests passed!") diff --git a/tests/test_echarts_theme.py b/tests/test_echarts_theme.py index 0aa36a8..d6ac020 100644 --- a/tests/test_echarts_theme.py +++ b/tests/test_echarts_theme.py @@ -2,23 +2,23 @@ from component.widget.echarts import EChartsWidget, RawEChartsWidget -class _Toggle: +class _State: dark = True def observe(self, *_args, **_kwargs): pass -def test_raw_widget_binds_toggle_and_theme(): - tt = _Toggle() - w = RawEChartsWidget(theme_toggle=tt, option={"series": []}) - assert w.theme_toggle is tt +def test_raw_widget_binds_state_and_theme(): + tt = _State() + w = RawEChartsWidget(theme_state=tt, option={"series": []}) + assert w.theme_state is tt assert w.theme == "dark" assert w.renderer == "svg" def test_typed_widget_still_themes(): - tt = _Toggle() - w = EChartsWidget(theme_toggle=tt) + tt = _State() + w = EChartsWidget(theme_state=tt) assert w.theme == "dark" assert w.renderer == "svg" diff --git a/tests/test_map_colormap.py b/tests/test_map_colormap.py deleted file mode 100644 index 74961cd..0000000 --- a/tests/test_map_colormap.py +++ /dev/null @@ -1,21 +0,0 @@ -"""The categorical colormap must show every sampleable class (incl. 0 and >255).""" - -from component.widget.map import _build_class_colormap - - -def test_colormap_colors_class_zero(): - cm = _build_class_colormap({0: "#ff0000", 5: "#00ff00"}) - assert cm[0] == (255, 0, 0, 255) # class 0 is a real class, not background - assert cm[5] == (0, 255, 0, 255) - - -def test_colormap_colors_codes_above_255(): - cm = _build_class_colormap({300: "#0000ff", 1024: "#ffffff"}) - assert cm[300] == (0, 0, 255, 255) - assert cm[1024] == (255, 255, 255, 255) - - -def test_colormap_leaves_unknown_values_transparent(): - cm = _build_class_colormap({5: "#00ff00"}) - assert cm[7] == (0, 0, 0, 0) - assert cm[5] == (0, 255, 0, 255) diff --git a/tests/test_map_zoom.py b/tests/test_map_zoom.py index 7c26edd..b2d7852 100644 --- a/tests/test_map_zoom.py +++ b/tests/test_map_zoom.py @@ -1,13 +1,13 @@ """SbaeMap floors zoom-out at level 5; SepalMap leaves min_zoom unset (leaflet's 0).""" -from sepal_ui.sepalwidgets.vue_app import ThemeToggle +from pysepal.solara import ThemeState from component.widget.map import SbaeMap def test_map_defaults_to_min_zoom_five(): - assert SbaeMap(theme_toggle=ThemeToggle()).min_zoom == 5 + assert SbaeMap(theme_state=ThemeState()).min_zoom == 5 def test_map_min_zoom_is_overridable(): - assert SbaeMap(theme_toggle=ThemeToggle(), min_zoom=0).min_zoom == 0 + assert SbaeMap(theme_state=ThemeState(), min_zoom=0).min_zoom == 0 diff --git a/tests/test_raster_map_watcher.py b/tests/test_raster_map_watcher.py new file mode 100644 index 0000000..cd71c56 --- /dev/null +++ b/tests/test_raster_map_watcher.py @@ -0,0 +1,88 @@ +"""RasterMapWatcher waits for the class palette before drawing the raster. + +The tiled COG and the class palette come from two independent async paths. When +the COG wins the race the palette is still empty, and adding the raster then +renders every class down the dark end of the default continuous ramp -- with no +error, and permanently, because the layer is never redrawn. +""" + +import pytest +import solara + +from component.model import app_state +from component.tile.upload import RasterMapWatcher + +_COLORS = {1: "#00ff00", 2: "#ff0000"} + + +class _FakeMap: + """Records add_raster calls instead of standing up a tile server.""" + + def __init__(self): + self.calls = [] + + def add_raster(self, image, layer_name=None, key=None, class_colors=None, **kw): + self.calls.append({"image": image, "class_colors": class_colors}) + + def remove_layer(self, *args, **kwargs): + pass + + +@pytest.fixture +def watcher(): + """Render the watcher, then unmount it. + + app_state is a process-wide singleton, so a watcher left mounted keeps + observing it and services the *next* test's state change. + """ + contexts = [] + + def render(colors, map_=None): + app_state.sampling_method.value = "stratified" + app_state.optimized_raster_path.value = "/tmp/whatever.cog.tif" + app_state.class_colors.value = colors + app_state.raster_optimization_status.value = "adding_to_map" + fake = map_ or _FakeMap() + _, rc = solara.render(RasterMapWatcher(fake), handle_error=False) + contexts.append(rc) + return fake + + yield render + for rc in contexts: + rc.close() + app_state.clear_file_data() + + +def test_raster_waits_for_the_palette(watcher): + fake = watcher({}) + + assert fake.calls == [] + # still pending, so the palette arriving re-runs the effect + assert app_state.raster_optimization_status.value == "adding_to_map" + + +def test_raster_is_drawn_once_the_palette_lands(watcher): + fake = watcher(_COLORS) + + assert [c["class_colors"] for c in fake.calls] == [_COLORS] + assert app_state.raster_optimization_status.value == "finished" + + +def test_a_late_palette_still_reaches_the_map(watcher): + # the race as it actually happens: COG first, palette second + fake = watcher({}) + assert fake.calls == [] + + app_state.class_colors.value = _COLORS + + assert [c["class_colors"] for c in fake.calls] == [_COLORS] + + +def test_clearing_a_file_drops_the_palette(): + # otherwise the gate passes on the previous file's colours and the next + # raster is drawn in them + app_state.class_colors.value = _COLORS + + app_state.clear_file_data() + + assert app_state.class_colors.value == {} diff --git a/tests/test_tile_loopback_bridge.py b/tests/test_tile_loopback_bridge.py deleted file mode 100644 index 9b71484..0000000 --- a/tests/test_tile_loopback_bridge.py +++ /dev/null @@ -1,61 +0,0 @@ -"""``_TileLoopbackBridge`` enables jupyter_loopback by default (opt out with =0). - -Each case runs in a subprocess: enabling the bridge mutates process-global -singletons that would otherwise leak across tests. -""" - -import subprocess -import sys -from pathlib import Path - -_REPO_ROOT = Path(__file__).resolve().parent.parent - -_RENDER_SCRIPT = """ -import os -_flag = {flag!r} -if _flag is None: - os.environ.pop("LOCALTILESERVER_COMM_BRIDGE", None) -else: - os.environ["LOCALTILESERVER_COMM_BRIDGE"] = _flag - -import solara -import solara.server.kernel_context as kc -from sepal_ui.solara import setup_sessions - -ctx = kc.create_dummy_context() -kc.set_current_context(ctx) -setup_sessions() - -import app -import jupyter_loopback as jl - -solara.render(app._TileLoopbackBridge(), handle_error=False) -print("ENABLED" if jl.is_comm_bridge_enabled() else "DISABLED") -""" - - -def _bridge_enabled(flag) -> bool: - result = subprocess.run( - [sys.executable, "-c", _RENDER_SCRIPT.format(flag=flag)], - cwd=str(_REPO_ROOT), - capture_output=True, - text=True, - timeout=120, - ) - assert result.returncode == 0, result.stderr[-3000:] - if "ENABLED" in result.stdout: - return True - if "DISABLED" in result.stdout: - return False - raise AssertionError( - f"unexpected output: {result.stdout!r}\n{result.stderr[-2000:]}" - ) - - -def test_bridge_enabled_by_default(): - # the SEPAL case: flag unset, bridge must still mount - assert _bridge_enabled(None) is True - - -def test_bridge_disabled_when_opted_out(): - assert _bridge_enabled("0") is False diff --git a/tests/test_upload_reject.py b/tests/test_upload_reject.py new file mode 100644 index 0000000..85b145a --- /dev/null +++ b/tests/test_upload_reject.py @@ -0,0 +1,28 @@ +"""_reject_reason: only a raster can serve as the classification map. + +The upload used to accept vectors -- areas computed, but the map layer was built +with ``add_raster``, which opens with rasterio and raised ``RasterioIOError`` +straight out of a ``use_effect``. +""" + +from component.tile.upload import _reject_reason + + +def test_a_raster_is_accepted(): + assert _reject_reason({"file_type": "raster"}) is None + + +def test_a_vector_is_rejected(): + reason = _reject_reason({"file_type": "vector"}) + + assert reason is not None + assert "raster" in reason.lower() + + +def test_an_unopenable_file_is_rejected(): + assert _reject_reason({"file_type": "unknown"}) is not None + + +def test_a_read_error_is_reported_verbatim(): + # the reader's own message says more than "unsupported format" would + assert _reject_reason({"error": "boom", "file_type": "raster"}) == "boom" diff --git a/tests/test_vector_tiles.py b/tests/test_vector_tiles.py index 4ea6ab0..c2836d9 100644 --- a/tests/test_vector_tiles.py +++ b/tests/test_vector_tiles.py @@ -1,5 +1,4 @@ import asyncio -import os import sys import pandas as pd @@ -8,7 +7,6 @@ from component.scripts.vector_tiles import ( POINT_CONVERSION_OPTIONS, VectorTileError, - _ensure_tippecanoe_on_path, build_layer_or_notify, build_point_style, build_points_pmtiles_layer, @@ -41,7 +39,7 @@ def test_points_to_geojson_emits_multiple_code_props(): assert props["ref_code"] == 4 and isinstance(props["ref_code"], int) -def test_default_layer_factory_binds_ipv4_loopback(monkeypatch): +def test_default_layer_factory_opens_a_workspace_for_the_dest_dir(monkeypatch): import types from component.scripts import vector_tiles as vt @@ -55,7 +53,6 @@ def __init__(self, **kwargs): async def open_async(self, source, *, style, conversion_options): return "LAYER" - monkeypatch.setattr(vt, "_ensure_tippecanoe_on_path", lambda: None) monkeypatch.setitem( sys.modules, "vectortileserver", @@ -70,8 +67,10 @@ async def open_async(self, source, *, style, conversion_options): ) ) assert layer == "LAYER" - # localhost can resolve to IPv6 ::1, unbindable in some sandboxes (SEPAL) - assert captured["host"] == "127.0.0.1" + assert captured["allowed_directories"] == ["/tmp/x"] + # The IPv4 bind is vectortileserver's own default from 0.2.2 on. Passing a + # host here again would put the band-aid back in the app layer. + assert "host" not in captured def test_points_to_geojson_omits_absent_props(): @@ -85,31 +84,6 @@ def test_points_to_geojson_empty_df(): assert fc["features"] == [] -def test_ensure_tippecanoe_on_path_prepends_bin(monkeypatch, tmp_path): - bindir = tmp_path / "bin" - bindir.mkdir() - (bindir / "tippecanoe").write_text("") # pretend the binary is installed here - monkeypatch.setattr(sys, "executable", str(bindir / "python3")) - monkeypatch.setenv("PATH", "/usr/bin") - - _ensure_tippecanoe_on_path() - parts = os.environ["PATH"].split(os.pathsep) - assert parts[0] == str(bindir) # venv bin prepended so `tippecanoe` resolves - # idempotent: a second call must not duplicate the entry - _ensure_tippecanoe_on_path() - assert os.environ["PATH"].split(os.pathsep).count(str(bindir)) == 1 - - -def test_ensure_tippecanoe_on_path_noop_when_absent(monkeypatch, tmp_path): - bindir = tmp_path / "bin" - bindir.mkdir() # no tippecanoe sibling - monkeypatch.setattr(sys, "executable", str(bindir / "python3")) - monkeypatch.setenv("PATH", "/usr/bin") - - _ensure_tippecanoe_on_path() - assert os.environ["PATH"] == "/usr/bin" # untouched - - def test_build_point_style_emits_flat_layer_per_class(): # protomaps-leaflet (the PMTiles renderer) can't evaluate MapLibre colour # expressions, so each class becomes its own flat-coloured, filtered circle