From 1d85bf045b9ac21da2daf8f40ffcffbdb623d941 Mon Sep 17 00:00:00 2001 From: Lori Burns Date: Mon, 10 Nov 2025 11:19:57 -0500 Subject: [PATCH 01/12] schema to date --- sch/irisconfig.json | 507 ++++++++++++++++++++++++++++++++++++++++++++ sch/pydiris.py | 401 +++++++++++++++++++++++++++++++++++ 2 files changed, 908 insertions(+) create mode 100644 sch/irisconfig.json create mode 100644 sch/pydiris.py diff --git a/sch/irisconfig.json b/sch/irisconfig.json new file mode 100644 index 00000000..a8f62b71 --- /dev/null +++ b/sch/irisconfig.json @@ -0,0 +1,507 @@ +{ + "$defs": { + "IrisBingView": { + "properties": { + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Further description which explains what the user can see in this view. Examples: \n\"views\": {\n ...\n \"Bing\": {\n \"description\": \"Aerial Imagery\",\n \"type\": \"bingmap\"\n }\n}\n```\n", + "title": "Description" + }, + "type": { + "const": "bingmap", + "default": "bingmap", + "title": "Type", + "type": "string" + } + }, + "title": "IrisBingView", + "type": "object" + }, + "IrisImages": { + "properties": { + "path": { + "anyOf": [ + { + "type": "string" + }, + { + "additionalProperties": { + "type": "string" + }, + "type": "object" + } + ], + "description": "The input path to the images. Can be either a string containing an existing path with the\nplaceholder `{id}` or a dictionary of paths with the placeholder `{id}` (see examples below). The placeholder will be replaced by the unique id of\nthe current image. IRIS can load standard image formats (like *png* or *tif*), theoretically all kind of files that can be opened by GDAL/rasterio\n(such as *geotiff* or *vrt*) and numpy files (*npy*). The arrays inside the numpy files should have the shape HxWxC. Examples:\n\nWhen you have one folder `images` containing your images in *tif* format:\n```\n\"path\": \"images/{id}.tif\"\n```\n\nWhen you have one folder `images` containing subfolders with your images in *tif* format:\n```\n\"path\": \"images/{id}/image.tif\"\n```\n\nWhen you have your data distributed over multiple files (e.g. coming from Sentinel-1 and Sentinel-2), you can use a dictionary for each file type. The keys of the dictionary are file identifiers which are important for the [views](#views) configuration.\n```\n\"path\": {\n \"Sentinel1\": \"images/{id}/S1.tif\",\n \"Sentinel2\": \"images/{id}/S2.tif\"\n}\n```\n", + "title": "Path" + }, + "shape": { + "default": null, + "description": "The shape of the images. Must be a list of width and height. Example: `[512, 512]`", + "items": { + "type": "integer" + }, + "maxItems": 2, + "minItems": 2, + "title": "Shape", + "type": "array" + }, + "thumbnails": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": false, + "description": "Optional thumbnail files for the images. Path must contain a placeholder `{id}`. If you cannot provide any thumbnail, just leave it out or set it to `False`. Example: `thumbnails/{id}.png`", + "title": "Thumbnails" + }, + "metadata": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": false, + "description": "Optional metadata for the images. Path must contain a placeholder `{id}`. Metadata files\ncan be in json, yaml or another text file format. json and yaml files will be parsed and made accessible via the GUI. If the metadata contains the\nkey `location` with a list of two floats (longitude and latitude), it can be used for a bingmap view. If you cannot provide any metadata, just\nleave it out or set it to `false`. Example field: `metadata/{id}.json` . Example file contents:\n```\n{\n \"spacecraft_id\": \"Sentinel2\",\n \"scene_id\": \"coast\",\n \"location\": [-26.3981, 113.3077],\n \"resolution\": 20.0\n}\n```\n", + "title": "Metadata" + } + }, + "required": [ + "path" + ], + "title": "IrisImages", + "type": "object" + }, + "IrisMonochromeView": { + "properties": { + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Further description which explains what the user can see in this view. Examples:\n```\n\"views\": {\n ...\n \"Cirrus\": {\n \"description\": \"Cirrus and high clouds are red.\",\n \"type\": \"image\",\n \"data\": \"$Sentinel2.B11**0.8*5\",\n \"cmap\": \"jet\"\n },\n \"Cirrus-Edges\": {\n \"description\": \"Edges in the cirrus band\",\n \"type\": \"image\",\n \"data\": \"edges($Sentinel2.B11**0.8*5)*1.5\",\n \"cmap\": \"gray\"\n },\n \"Superpixels\": {\n \"description\": \"Superpixels in the panchromatic bands\",\n \"type\": \"image\",\n \"data\": \"superpixels($Sentinel2.B2+$Sentinel2.B3+$Sentinel2.B4, sigma=4, min_size=100)\",\n \"cmap\": \"jet\"\n },\n}\n```\n", + "title": "Description" + }, + "type": { + "const": "image", + "default": "image", + "title": "Type", + "type": "string" + }, + "data": { + "description": "Expression for a monochrome image built from one or more valid band arrays referenced as\n`$B` (simple string for image:path) or `$.$B` (dictionary supplied for image:path). Examples: `$B1`, `$Sentinel2.B1`. It can contain mathematical expressions, band combinations, or calls to specific numpy or skimage functions like `mean` (`np.mean`), `edges` (`skimage.sobel`) or `superpixels` (`skimage.felzenszwalb`). Aliased list: ['max', 'max', 'mean', 'median', 'log', 'exp', 'sin', 'cos', 'PI', 'edges', 'superpixels'] . It can contain mathematical expressions, band combinations, or calls to specific numpy or skimage functions like `mean` (`np.mean`), `edges` (`skimage.sobel`) or `superpixels` (`skimage.felzenszwalb`). Aliased list: ['max', 'max', 'mean', 'median', 'log', 'exp', 'sin', 'cos', 'PI', 'edges', 'superpixels'] .", + "title": "Data", + "type": "string" + }, + "cmap": { + "default": "jet", + "description": "Matplotlib colormap name to render monochrome image.", + "title": "Cmap", + "type": "string" + }, + "clip": { + "anyOf": [ + { + "description": "By default, bands are stretched between 0 and 1, relative to their\nminimum and maximum values. By setting a value for clip, you control the percentile of pixels that are saturated at 0 and 1, which can be helpful\nif there are some extreme pixel values that reduce the contrast in other parts of the image.", + "maximum": 100, + "minimum": 0, + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Clip" + }, + "vmin": { + "anyOf": [ + { + "description": "If you know the precise low value you would like to clip the pixel values to, (rather than a percentile), then you can specify this with vmin (optionally used with `vmax`). This cannot be used for the same view as `clip`.", + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Vmin" + }, + "vmax": { + "anyOf": [ + { + "description": "If you know the precise high value you would like to clip the pixel values to, (rather than a percentile), then you can specify this with vmax (optionally used with `vmin`). This cannot be used for the same view as `clip`.", + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Vmax" + } + }, + "required": [ + "data" + ], + "title": "IrisMonochromeView", + "type": "object" + }, + "IrisRGBView": { + "properties": { + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Further description which explains what the user can see in this view. Examples:\n```\n\"views\": {\n ...\n \"RGB\": {\n \"description\": \"Normal RGB image.\",\n \"type\": \"image\",\n \"data\": [\"$Sentinel2.B5\", \"$Sentinel2.B3\", \"$Sentinel2.B2\"]\n },\n \"Sentinel-1\": {\n \"description\": \"RGB of VH, VV and VH-VV.\",\n \"type\": \"image\",\n \"data\": [\"$Sentinel1.B1\", \"$Sentinel1.B2\", \"$Sentinel1.B1-$Sentinel1.B2\"]\n },\n}\n```\n", + "title": "Description" + }, + "type": { + "const": "image", + "default": "image", + "title": "Type", + "type": "string" + }, + "data": { + "description": "Expression for the three tracks for an RGB image built from one or more valid band arrays referenced as {_view_band_dat a_description} {_aliased_view_fn_description}", + "items": { + "type": "string" + }, + "maxItems": 3, + "minItems": 3, + "title": "Data", + "type": "array" + }, + "clip": { + "anyOf": [ + { + "description": "By default, bands are stretched between 0 and 1, relative to\ntheir minimum and maximum values. By setting a value for clip, you control the percentile of pixels that are saturated at 0 and 1, which can be\nhelpful if there are some extreme pixel values that reduce the contrast in other parts of the image.", + "maximum": 100, + "minimum": 0, + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Clip" + }, + "vmin": { + "anyOf": [ + { + "description": "If you know the precise low value you would like to clip the pixel values to, (rather than a percentile), then you can specify this with vmin (optionally used with `vmax`). This cannot be used for the same view as `clip`.", + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Vmin" + }, + "vmax": { + "anyOf": [ + { + "description": "If you know the precise high value you would like to clip the pixel values to,\n(rather than a percentile), then you can specify this with vmax (optionally used with `vmin`). This cannot be used for the same view as `clip`.", + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Vmax" + } + }, + "required": [ + "data" + ], + "title": "IrisRGBView", + "type": "object" + }, + "IrisSegAIModel": { + "properties": { + "bands": { + "title": "Bands", + "type": "null" + }, + "train_ratio": { + "default": 0.8, + "title": "Train Ratio", + "type": "number" + }, + "max_train_pixels": { + "default": 20000, + "title": "Max Train Pixels", + "type": "integer" + }, + "n_estimators": { + "default": 20, + "title": "N Estimators", + "type": "integer" + }, + "max_depth": { + "default": 10, + "title": "Max Depth", + "type": "integer" + }, + "n_leaves": { + "default": 10, + "title": "N Leaves", + "type": "integer" + }, + "suppression_threshold": { + "default": 0, + "title": "Suppression Threshold", + "type": "integer" + }, + "suppression_filter_size": { + "default": 5, + "title": "Suppression Filter Size", + "type": "integer" + }, + "suppression_default_class": { + "default": 0, + "title": "Suppression Default Class", + "type": "integer" + }, + "use_edge_filter": { + "default": false, + "title": "Use Edge Filter", + "type": "boolean" + }, + "use_superpixels": { + "default": false, + "title": "Use Superpixels", + "type": "boolean" + }, + "use_meshgrid": { + "default": false, + "title": "Use Meshgrid", + "type": "boolean" + }, + "meshgrid_cells": { + "default": "3x3", + "title": "Meshgrid Cells", + "type": "string" + } + }, + "required": [ + "bands" + ], + "title": "IrisSegAIModel", + "type": "object" + }, + "IrisSegClass": { + "properties": { + "name": { + "description": "Name of the class.", + "title": "Name", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Further description which explains the user more about the class (e.g. why is it different from another class, etc.)", + "title": "Description" + } + }, + "required": [ + "name" + ], + "title": "IrisSegClass", + "type": "object" + }, + "IrisSegmentationConfig": { + "properties": { + "path": { + "description": "This directory will contain the mask files from the segmentation. Four different mask formats are\nallowed: *npy*, *tif*, *png* or *jpeg*. Example:\n\nThis will create a folder next to the project file called `masks` containing the mask files in *png* format.\n```\n\"path\": \"masks/{id}.png\"\n```\n", + "title": "Path", + "type": "string" + }, + "mask_encoding": { + "$ref": "#/$defs/SegMaskEnum", + "default": "rgb", + "description": "The encodings of the final masks. Can be `integer`, `binary`, `rgb` or `rgba`. Note:\nnot all mask formats support all encodings. Example: `\"mask_encoding\": \"rgb\"`." + }, + "mask_area": { + "anyOf": [ + { + "items": { + "type": "integer" + }, + "maxItems": 4, + "minItems": 4, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "In case you don't want to allow the user to label the complete image, you can\nlimit the segmentation area. Example: `\"mask_area\": [100, 100, 400, 400]`", + "title": "Mask Area" + }, + "score": { + "$ref": "#/$defs/SegScoreEnum", + "default": "f1", + "description": "Defines how to measure the score achieved by the user for each mask. Can be `f1`, `jaccard`\nor `accuracy`. Example: `\"score\": \"f1\"`." + }, + "prioritise_unmarked_images": { + "default": true, + "description": "Mode to serve up images with the lowest number of annotations when user asks for\nnext image.", + "title": "Prioritise Unmarked Images", + "type": "boolean" + }, + "unverified_threshold": { + "default": 1, + "description": "TODO Number of unverified users contributing masks above which to tag an image\n\"unverified\".", + "minimum": 0, + "title": "Unverified Threshold", + "type": "integer" + }, + "ai_model": { + "$ref": "#/$defs/IrisSegAIModel" + } + }, + "required": [ + "path", + "ai_model" + ], + "title": "IrisSegmentationConfig", + "type": "object" + }, + "SegMaskEnum": { + "description": "Allowed encodings for final masks. Not all mask formats support all encodings.", + "enum": [ + "integer", + "binary", + "rgb", + "rgba" + ], + "title": "SegMaskEnum", + "type": "string" + }, + "SegScoreEnum": { + "description": "Allowed score measure.", + "enum": [ + "f1", + "jaccard", + "accuracy" + ], + "title": "SegScoreEnum", + "type": "string" + } + }, + "properties": { + "name": { + "description": "Optional name for this project. (e.g., `cloud-segmentation`)", + "title": "Name", + "type": "string" + }, + "port": { + "default": 5000, + "description": "Set the port on which IRIS is served. Example: `6060`.", + "maximum": 65535, + "minimum": 0, + "title": "Port", + "type": "integer" + }, + "host": { + "default": "127.0.0.1", + "description": "Set the host IP address for IRIS. The default value 127.0.0.1 means IRIS will only be visible on the local machine. If you want to expose IRIS publicly as a web application, we recommend setting the host to 0.0.0.0 and adjusting your router / consulting with your network administrators accordingly.", + "title": "Host", + "type": "string" + }, + "images": { + "$ref": "#/$defs/IrisImages", + "description": "A dictionary which defines the inputs. Example:\n```\n\"images\": {\n \"path\": \"images/{id}/image.tif\",\n \"shape\": [512, 512],\n \"thumbnails\": \"images/{id}/thumbnail.png\",\n \"metadata\": \"images/{id}/metadata.json\"\n }\n```\n" + }, + "classes": { + "description": "This is a list of classes that you want to allow the user to label.\nExamples:\n\n {\n \"name\": \"Clear\",\n \"description\": \"All clear pixels.\",\n \"colour\": [255,255,255,0],\n \"user_colour\": [0,255,255,70]\n },\n {\n \"name\": \"Cloud\",\n \"description\": \"All cloudy pixels.\",\n \"colour\": [255,255,0,70]\n }\n]\n", + "items": { + "$ref": "#/$defs/IrisSegClass" + }, + "minItems": 1, + "title": "Classes", + "type": "array" + }, + "views": { + "additionalProperties": { + "anyOf": [ + { + "$ref": "#/$defs/IrisMonochromeView" + }, + { + "$ref": "#/$defs/IrisRGBView" + }, + { + "$ref": "#/$defs/IrisBingView" + } + ] + }, + "description": "Since this app was developed for\nmulti-spectral satellite data (i.e. images with more than just three channels), you can decide how to present the images to the user. This\noption must be a dictionary where each key is the name of the view and the value another dictionary containing properties for the view.", + "minProperties": 1, + "title": "Views", + "type": "object" + }, + "view_groups": { + "additionalProperties": { + "items": { + "type": "string" + }, + "type": "array" + }, + "description": "Views are displayed in groups. In the GUI of IRIS, you will be able to switch\nbetween different groups quickly. The group `default` must always be set, further groups are optional. Examples:\n```\n\"view_groups\": {\n \"default\": [\"Cirrus\", \"RGB\", \"Bing\"],\n \"clouds\": [\"Cirrus\"],\n \"radar\": [\"Sentinel1\"]\n }\n```\n", + "title": "View Groups", + "type": "object" + }, + "segmentation": { + "$ref": "#/$defs/IrisSegmentationConfig", + "description": "Define the parameters for the segmentation mode." + } + }, + "required": [ + "name", + "images", + "classes", + "views", + "view_groups", + "segmentation" + ], + "title": "IrisConfig", + "type": "object" +} diff --git a/sch/pydiris.py b/sch/pydiris.py new file mode 100644 index 00000000..4301961e --- /dev/null +++ b/sch/pydiris.py @@ -0,0 +1,401 @@ +import json +from enum import Enum +from typing import Annotated, Dict, List, Literal, Optional, Union + +from pydantic import BaseModel, ConfigDict, Field + + +class IrisImages(BaseModel): + path: Union[str, Dict[str, str]] = Field( + ..., + description="""The input path to the images. Can be either a string containing an existing path with the +placeholder `{id}` or a dictionary of paths with the placeholder `{id}` (see examples below). The placeholder will be replaced by the unique id of +the current image. IRIS can load standard image formats (like *png* or *tif*), theoretically all kind of files that can be opened by GDAL/rasterio +(such as *geotiff* or *vrt*) and numpy files (*npy*). The arrays inside the numpy files should have the shape HxWxC. Examples: + +When you have one folder `images` containing your images in *tif* format: +``` +"path": "images/{id}.tif" +``` + +When you have one folder `images` containing subfolders with your images in *tif* format: +``` +"path": "images/{id}/image.tif" +``` + +When you have your data distributed over multiple files (e.g. coming from Sentinel-1 and Sentinel-2), you can use a dictionary for each file type. The keys of the dictionary are file identifiers which are important for the [views](#views) configuration. +``` +"path": { + "Sentinel1": "images/{id}/S1.tif", + "Sentinel2": "images/{id}/S2.tif" +} +``` +""", + ) + # maybe not needed for launch, but neede dfor working, right? + shape: List[int] = Field( + None, + min_length=2, + max_length=2, + description="The shape of the images. Must be a list of width and height. Example: `[512, 512]`", + ) + # code accustommed to "false", not None, but that would be more complicated here. False or None? + thumbnails: Optional[str] = Field( + False, + description="Optional thumbnail files for the images. Path must contain a placeholder `{id}`. If you cannot provide any thumbnail, just leave it out or set it to `False`. Example: `thumbnails/{id}.png`", + ) + metadata: Optional[str] = Field( + False, + description="""Optional metadata for the images. Path must contain a placeholder `{id}`. Metadata files +can be in json, yaml or another text file format. json and yaml files will be parsed and made accessible via the GUI. If the metadata contains the +key `location` with a list of two floats (longitude and latitude), it can be used for a bingmap view. If you cannot provide any metadata, just +leave it out or set it to `false`. Example field: `metadata/{id}.json` . Example file contents: +``` +{ + "spacecraft_id": "Sentinel2", + "scene_id": "coast", + "location": [-26.3981, 113.3077], + "resolution": 20.0 +} +``` +""", + ) + + +# from iris/project Project._get_render_environment +_aliased_view_fns = [ + "max", + "max", + "mean", + "median", + "log", + "exp", + "sin", + "cos", + "PI", + "edges", + "superpixels", +] +_aliased_view_fn_description = f"""It can contain mathematical expressions, band combinations, or calls to specific numpy or skimage functions like `mean` (`np.mean`), `edges` (`skimage.sobel`) or `superpixels` (`skimage.felzenszwalb`). Aliased list: {_aliased_view_fns} .""" +_view_band_data_description = f"""`$B` (simple string for image:path) or `$.$B` (dictionary supplied for image:path). Examples: `$B1`, `$Sentinel2.B1`. {_aliased_view_fn_description}""" + + +class IrisMonochromeView(BaseModel): + description: Optional[str] = Field( + None, + description="""Further description which explains what the user can see in this view. Examples: +``` +"views": { + ... + "Cirrus": { + "description": "Cirrus and high clouds are red.", + "type": "image", + "data": "$Sentinel2.B11**0.8*5", + "cmap": "jet" + }, + "Cirrus-Edges": { + "description": "Edges in the cirrus band", + "type": "image", + "data": "edges($Sentinel2.B11**0.8*5)*1.5", + "cmap": "gray" + }, + "Superpixels": { + "description": "Superpixels in the panchromatic bands", + "type": "image", + "data": "superpixels($Sentinel2.B2+$Sentinel2.B3+$Sentinel2.B4, sigma=4, min_size=100)", + "cmap": "jet" + }, +} +``` +""", + ) + type: Literal["image"] = Field("image") + data: str = Field( + ..., + description=f"""Expression for a monochrome image built from one or more valid band arrays referenced as +{_view_band_data_description} {_aliased_view_fn_description}""", + ) + cmap: str = Field("jet", description="Matplotlib colormap name to render monochrome image.") + clip: Optional[ + Annotated[ + float, + Field( + strict=True, + ge=0, + le=100, + description="""By default, bands are stretched between 0 and 1, relative to their +minimum and maximum values. By setting a value for clip, you control the percentile of pixels that are saturated at 0 and 1, which can be helpful +if there are some extreme pixel values that reduce the contrast in other parts of the image.""", + ), + ] + ] = None + vmin: Optional[ + Annotated[ + float, + Field( + strict=True, + description="""If you know the precise low value you would like to clip the pixel values to, (rather than a percentile), then you can specify this with vmin (optionally used with `vmax`). This cannot be used for the same view as `clip`.""", + ), + ] + ] = None + vmax: Optional[ + Annotated[ + float, + Field( + strict=True, + description="""If you know the precise high value you would like to clip the pixel values to, (rather than a percentile), then you can specify this with vmax (optionally used with `vmin`). This cannot be used for the same view as `clip`.""", + ), + ] + ] = None + + +class IrisRGBView(BaseModel): + description: Optional[str] = Field( + None, + description="""Further description which explains what the user can see in this view. Examples: +``` +"views": { + ... + "RGB": { + "description": "Normal RGB image.", + "type": "image", + "data": ["$Sentinel2.B5", "$Sentinel2.B3", "$Sentinel2.B2"] + }, + "Sentinel-1": { + "description": "RGB of VH, VV and VH-VV.", + "type": "image", + "data": ["$Sentinel1.B1", "$Sentinel1.B2", "$Sentinel1.B1-$Sentinel1.B2"] + }, +} +``` +""", + ) + type: Literal["image"] = Field("image") + data: List[str] = Field( + ..., + min_length=3, + max_length=3, + description="""Expression for the three tracks for an RGB image built from one or more valid band arrays referenced as {_view_band_dat a_description} {_aliased_view_fn_description}""", + ) + # cmap invalid + clip: Optional[ + Annotated[ + float, + Field( + strict=True, + ge=0, + le=100, + description="""By default, bands are stretched between 0 and 1, relative to +their minimum and maximum values. By setting a value for clip, you control the percentile of pixels that are saturated at 0 and 1, which can be +helpful if there are some extreme pixel values that reduce the contrast in other parts of the image.""", + ), + ] + ] = None + vmin: Optional[ + Annotated[ + float, + Field( + strict=True, + description="""If you know the precise low value you would like to clip the pixel values to, (rather than a percentile), then you can specify this with vmin (optionally used with `vmax`). This cannot be used for the same view as `clip`.""", + ), + ] + ] = None + vmax: Optional[ + Annotated[ + float, + Field( + strict=True, + description="""If you know the precise high value you would like to clip the pixel values to, +(rather than a percentile), then you can specify this with vmax (optionally used with `vmin`). This cannot be used for the same view as `clip`.""", + ), + ] + ] = None + + +class IrisBingView(BaseModel): + description: Optional[str] = Field( + None, + description="""Further description which explains what the user can see in this view. Examples: +"views": { + ... + "Bing": { + "description": "Aerial Imagery", + "type": "bingmap" + } +} +``` +""", + ) + type: Literal["bingmap"] = Field("bingmap") + # data, cmap, clip, vmin, vmax invalid + + +class IrisSegClass(BaseModel): + name: str = Field(..., description="Name of the class.") + description: Optional[str] = Field( + None, + description="Further description which explains the user more about the class (e.g. why is it different from another class, etc.)", + ) + # colour: RGBA = Field(..., description="Colour for this class. Must be a list of 4 integers (RGBA) from 0 to 255.") + # user_colour: Optional[RGBA] = Field(None, description="Colour for this class when user mask is activated in the interface. Useful for background classes which are normally transparent.") + + model_config = ConfigDict(arbitrary_types_allowed=True) + + +class SegMaskEnum(str, Enum): + """Allowed encodings for final masks. Not all mask formats support all encodings.""" + + integer = "integer" + binary = "binary" + rgb = "rgb" + rgba = "rgba" + + +class SegScoreEnum(str, Enum): + """Allowed score measure.""" + + f1 = "f1" + jaccard = "jaccard" + accuracy = "accuracy" + + +# after checking config is the right place for this class, fill in descriptions TODO +class IrisSegAIModel(BaseModel): + bands: None + train_ratio: float = Field(0.8) + max_train_pixels: int = Field(20000) + n_estimators: int = Field(20) + max_depth: int = Field(10) + n_leaves: int = Field(10) + suppression_threshold: int = Field(0) + suppression_filter_size: int = Field(5) + suppression_default_class: int = Field(0) + use_edge_filter: bool = Field(False) + use_superpixels: bool = Field(False) + use_meshgrid: bool = Field(False) + meshgrid_cells: str = Field("3x3") + + +## segmentation +class IrisSegmentationConfig(BaseModel): + path: str = Field( + ..., + description="""This directory will contain the mask files from the segmentation. Four different mask formats are +allowed: *npy*, *tif*, *png* or *jpeg*. Example: + +This will create a folder next to the project file called `masks` containing the mask files in *png* format. +``` +"path": "masks/{id}.png" +``` +""", + ) + mask_encoding: SegMaskEnum = Field( + "rgb", + description="""The encodings of the final masks. Can be `integer`, `binary`, `rgb` or `rgba`. Note: +not all mask formats support all encodings. Example: `"mask_encoding": "rgb"`.""", + ) + mask_area: Optional[List[int]] = Field( + None, + min_length=4, + max_length=4, + description="""In case you don't want to allow the user to label the complete image, you can +limit the segmentation area. Example: `"mask_area": [100, 100, 400, 400]`""", + ) + score: SegScoreEnum = Field( + "f1", + description="""Defines how to measure the score achieved by the user for each mask. Can be `f1`, `jaccard` +or `accuracy`. Example: `"score": "f1"`.""", + ) + prioritise_unmarked_images: bool = Field( + True, + description="""Mode to serve up images with the lowest number of annotations when user asks for +next image.""", + ) + unverified_threshold: int = Field( + 1, + ge=0, + description="""TODO Number of unverified users contributing masks above which to tag an image +"unverified".""", + ) + # Unused? "test_images": null, + ai_model: IrisSegAIModel + + +class IrisConfig(BaseModel): + # creator: str = Field(..., description="The creator of the object.") + # version: Optional[str] = Field(None, description="The version of the creator.") + # routine: Optional[str] = Field(None, description="The routine of the creator.") + + # LAB: docs say optional for name, but I question this + name: str = Field(..., description="Optional name for this project. (e.g., `cloud-segmentation`)") + port: Annotated[ + int, + Field(5000, strict=True, ge=0, le=65535, description="Set the port on which IRIS is served. Example: `6060`."), + ] + host: str = Field( + "127.0.0.1", + description="Set the host IP address for IRIS. The default value 127.0.0.1 means IRIS will only be visible on the local machine. If you want to expose IRIS publicly as a web application, we recommend setting the host to 0.0.0.0 and adjusting your router / consulting with your network administrators accordingly.", + ) + images: IrisImages = Field( + ..., + description="""A dictionary which defines the inputs. Example: +``` +"images": { + "path": "images/{id}/image.tif", + "shape": [512, 512], + "thumbnails": "images/{id}/thumbnail.png", + "metadata": "images/{id}/metadata.json" + } +``` +""", + ) + classes: List[IrisSegClass] = Field( + ..., + min_length=1, + description="""This is a list of classes that you want to allow the user to label. +Examples: + + { + "name": "Clear", + "description": "All clear pixels.", + "colour": [255,255,255,0], + "user_colour": [0,255,255,70] + }, + { + "name": "Cloud", + "description": "All cloudy pixels.", + "colour": [255,255,0,70] + } +] +""", + ) + views: Dict[str, Union[IrisMonochromeView | IrisRGBView | IrisBingView]] = Field( + ..., + min_length=1, + description="""Since this app was developed for +multi-spectral satellite data (i.e. images with more than just three channels), you can decide how to present the images to the user. This +option must be a dictionary where each key is the name of the view and the value another dictionary containing properties for the view.""", + ) + view_groups: Dict[str, List[str]] = Field( + ..., + description="""Views are displayed in groups. In the GUI of IRIS, you will be able to switch +between different groups quickly. The group `default` must always be set, further groups are optional. Examples: +``` +"view_groups": { + "default": ["Cirrus", "RGB", "Bing"], + "clouds": ["Cirrus"], + "radar": ["Sentinel1"] + } +``` +""", + ) + segmentation: IrisSegmentationConfig = Field( + ..., description="""Define the parameters for the segmentation mode.""" + ) + + +json_schema = IrisConfig.model_json_schema() +print(json.dumps(json_schema, indent=2)) + + +# To use IRIS, you need to define a project file in JSON or YAML format. A full-working example can be found [here](../demo/cloud-segmentation.json). The following will outline each of the fields one can use to change the behaviour of IRIS. If fields are not explicitly given in a project's configuration file, then they will take the values found in the [default configuration file](../iris/default_config.json). From aa4058a4aa7908c9b79f63dd635cb810b2be42b9 Mon Sep 17 00:00:00 2001 From: Lori Burns Date: Mon, 10 Nov 2025 11:24:51 -0500 Subject: [PATCH 02/12] basic in-code playground --- tools/iris-config-ui/README.md | 22 + tools/iris-config-ui/index.html | 12 + tools/iris-config-ui/package.json | 22 + tools/iris-config-ui/public/irisconfig.json | 507 ++++++++++++++++++++ tools/iris-config-ui/server.js | 55 +++ tools/iris-config-ui/src/App.jsx | 115 +++++ tools/iris-config-ui/src/main.jsx | 10 + tools/iris-config-ui/src/styles.css | 3 + 8 files changed, 746 insertions(+) create mode 100644 tools/iris-config-ui/README.md create mode 100644 tools/iris-config-ui/index.html create mode 100644 tools/iris-config-ui/package.json create mode 100644 tools/iris-config-ui/public/irisconfig.json create mode 100644 tools/iris-config-ui/server.js create mode 100644 tools/iris-config-ui/src/App.jsx create mode 100644 tools/iris-config-ui/src/main.jsx create mode 100644 tools/iris-config-ui/src/styles.css diff --git a/tools/iris-config-ui/README.md b/tools/iris-config-ui/README.md new file mode 100644 index 00000000..c8878624 --- /dev/null +++ b/tools/iris-config-ui/README.md @@ -0,0 +1,22 @@ +IRIS Config Editor (minimal) + +This is a small Vite + React app that uses react-jsonschema-form (rjsf) to render +an editable form driven by the `irisconfig.json` schema. + +Quick start + +1. cd tools/iris-config-ui +2. npm install +3. npm run dev + +4. Start the persistence server (runs on port 5174 by default): npm run start:server + + +Open http://localhost:5173 and you should see the form preview on the right and +editable JSON schema + uiSchema editors on the left. + +Notes + +- `public/irisconfig.json` was copied from the repository's top-level `irisconfig.json`. +- The left column lets you edit the raw JSON Schema and apply changes interactively. +- The uiSchema textarea accepts a JSON object to customize widgets/layout. diff --git a/tools/iris-config-ui/index.html b/tools/iris-config-ui/index.html new file mode 100644 index 00000000..9dba1792 --- /dev/null +++ b/tools/iris-config-ui/index.html @@ -0,0 +1,12 @@ + + + + + + IRIS Config Editor + + +
+ + + diff --git a/tools/iris-config-ui/package.json b/tools/iris-config-ui/package.json new file mode 100644 index 00000000..ba4bb5b4 --- /dev/null +++ b/tools/iris-config-ui/package.json @@ -0,0 +1,22 @@ +{ + "name": "iris-config-ui", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "start:server": "node server.js" + }, + "dependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0", + "@rjsf/core": "^5.0.0", + "@rjsf/validator-ajv8": "^5.0.0", + "express": "^4.18.2", + "cors": "^2.8.5" + }, + "devDependencies": { + "vite": "^5.0.0" + } +} diff --git a/tools/iris-config-ui/public/irisconfig.json b/tools/iris-config-ui/public/irisconfig.json new file mode 100644 index 00000000..a8f62b71 --- /dev/null +++ b/tools/iris-config-ui/public/irisconfig.json @@ -0,0 +1,507 @@ +{ + "$defs": { + "IrisBingView": { + "properties": { + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Further description which explains what the user can see in this view. Examples: \n\"views\": {\n ...\n \"Bing\": {\n \"description\": \"Aerial Imagery\",\n \"type\": \"bingmap\"\n }\n}\n```\n", + "title": "Description" + }, + "type": { + "const": "bingmap", + "default": "bingmap", + "title": "Type", + "type": "string" + } + }, + "title": "IrisBingView", + "type": "object" + }, + "IrisImages": { + "properties": { + "path": { + "anyOf": [ + { + "type": "string" + }, + { + "additionalProperties": { + "type": "string" + }, + "type": "object" + } + ], + "description": "The input path to the images. Can be either a string containing an existing path with the\nplaceholder `{id}` or a dictionary of paths with the placeholder `{id}` (see examples below). The placeholder will be replaced by the unique id of\nthe current image. IRIS can load standard image formats (like *png* or *tif*), theoretically all kind of files that can be opened by GDAL/rasterio\n(such as *geotiff* or *vrt*) and numpy files (*npy*). The arrays inside the numpy files should have the shape HxWxC. Examples:\n\nWhen you have one folder `images` containing your images in *tif* format:\n```\n\"path\": \"images/{id}.tif\"\n```\n\nWhen you have one folder `images` containing subfolders with your images in *tif* format:\n```\n\"path\": \"images/{id}/image.tif\"\n```\n\nWhen you have your data distributed over multiple files (e.g. coming from Sentinel-1 and Sentinel-2), you can use a dictionary for each file type. The keys of the dictionary are file identifiers which are important for the [views](#views) configuration.\n```\n\"path\": {\n \"Sentinel1\": \"images/{id}/S1.tif\",\n \"Sentinel2\": \"images/{id}/S2.tif\"\n}\n```\n", + "title": "Path" + }, + "shape": { + "default": null, + "description": "The shape of the images. Must be a list of width and height. Example: `[512, 512]`", + "items": { + "type": "integer" + }, + "maxItems": 2, + "minItems": 2, + "title": "Shape", + "type": "array" + }, + "thumbnails": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": false, + "description": "Optional thumbnail files for the images. Path must contain a placeholder `{id}`. If you cannot provide any thumbnail, just leave it out or set it to `False`. Example: `thumbnails/{id}.png`", + "title": "Thumbnails" + }, + "metadata": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": false, + "description": "Optional metadata for the images. Path must contain a placeholder `{id}`. Metadata files\ncan be in json, yaml or another text file format. json and yaml files will be parsed and made accessible via the GUI. If the metadata contains the\nkey `location` with a list of two floats (longitude and latitude), it can be used for a bingmap view. If you cannot provide any metadata, just\nleave it out or set it to `false`. Example field: `metadata/{id}.json` . Example file contents:\n```\n{\n \"spacecraft_id\": \"Sentinel2\",\n \"scene_id\": \"coast\",\n \"location\": [-26.3981, 113.3077],\n \"resolution\": 20.0\n}\n```\n", + "title": "Metadata" + } + }, + "required": [ + "path" + ], + "title": "IrisImages", + "type": "object" + }, + "IrisMonochromeView": { + "properties": { + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Further description which explains what the user can see in this view. Examples:\n```\n\"views\": {\n ...\n \"Cirrus\": {\n \"description\": \"Cirrus and high clouds are red.\",\n \"type\": \"image\",\n \"data\": \"$Sentinel2.B11**0.8*5\",\n \"cmap\": \"jet\"\n },\n \"Cirrus-Edges\": {\n \"description\": \"Edges in the cirrus band\",\n \"type\": \"image\",\n \"data\": \"edges($Sentinel2.B11**0.8*5)*1.5\",\n \"cmap\": \"gray\"\n },\n \"Superpixels\": {\n \"description\": \"Superpixels in the panchromatic bands\",\n \"type\": \"image\",\n \"data\": \"superpixels($Sentinel2.B2+$Sentinel2.B3+$Sentinel2.B4, sigma=4, min_size=100)\",\n \"cmap\": \"jet\"\n },\n}\n```\n", + "title": "Description" + }, + "type": { + "const": "image", + "default": "image", + "title": "Type", + "type": "string" + }, + "data": { + "description": "Expression for a monochrome image built from one or more valid band arrays referenced as\n`$B` (simple string for image:path) or `$.$B` (dictionary supplied for image:path). Examples: `$B1`, `$Sentinel2.B1`. It can contain mathematical expressions, band combinations, or calls to specific numpy or skimage functions like `mean` (`np.mean`), `edges` (`skimage.sobel`) or `superpixels` (`skimage.felzenszwalb`). Aliased list: ['max', 'max', 'mean', 'median', 'log', 'exp', 'sin', 'cos', 'PI', 'edges', 'superpixels'] . It can contain mathematical expressions, band combinations, or calls to specific numpy or skimage functions like `mean` (`np.mean`), `edges` (`skimage.sobel`) or `superpixels` (`skimage.felzenszwalb`). Aliased list: ['max', 'max', 'mean', 'median', 'log', 'exp', 'sin', 'cos', 'PI', 'edges', 'superpixels'] .", + "title": "Data", + "type": "string" + }, + "cmap": { + "default": "jet", + "description": "Matplotlib colormap name to render monochrome image.", + "title": "Cmap", + "type": "string" + }, + "clip": { + "anyOf": [ + { + "description": "By default, bands are stretched between 0 and 1, relative to their\nminimum and maximum values. By setting a value for clip, you control the percentile of pixels that are saturated at 0 and 1, which can be helpful\nif there are some extreme pixel values that reduce the contrast in other parts of the image.", + "maximum": 100, + "minimum": 0, + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Clip" + }, + "vmin": { + "anyOf": [ + { + "description": "If you know the precise low value you would like to clip the pixel values to, (rather than a percentile), then you can specify this with vmin (optionally used with `vmax`). This cannot be used for the same view as `clip`.", + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Vmin" + }, + "vmax": { + "anyOf": [ + { + "description": "If you know the precise high value you would like to clip the pixel values to, (rather than a percentile), then you can specify this with vmax (optionally used with `vmin`). This cannot be used for the same view as `clip`.", + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Vmax" + } + }, + "required": [ + "data" + ], + "title": "IrisMonochromeView", + "type": "object" + }, + "IrisRGBView": { + "properties": { + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Further description which explains what the user can see in this view. Examples:\n```\n\"views\": {\n ...\n \"RGB\": {\n \"description\": \"Normal RGB image.\",\n \"type\": \"image\",\n \"data\": [\"$Sentinel2.B5\", \"$Sentinel2.B3\", \"$Sentinel2.B2\"]\n },\n \"Sentinel-1\": {\n \"description\": \"RGB of VH, VV and VH-VV.\",\n \"type\": \"image\",\n \"data\": [\"$Sentinel1.B1\", \"$Sentinel1.B2\", \"$Sentinel1.B1-$Sentinel1.B2\"]\n },\n}\n```\n", + "title": "Description" + }, + "type": { + "const": "image", + "default": "image", + "title": "Type", + "type": "string" + }, + "data": { + "description": "Expression for the three tracks for an RGB image built from one or more valid band arrays referenced as {_view_band_dat a_description} {_aliased_view_fn_description}", + "items": { + "type": "string" + }, + "maxItems": 3, + "minItems": 3, + "title": "Data", + "type": "array" + }, + "clip": { + "anyOf": [ + { + "description": "By default, bands are stretched between 0 and 1, relative to\ntheir minimum and maximum values. By setting a value for clip, you control the percentile of pixels that are saturated at 0 and 1, which can be\nhelpful if there are some extreme pixel values that reduce the contrast in other parts of the image.", + "maximum": 100, + "minimum": 0, + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Clip" + }, + "vmin": { + "anyOf": [ + { + "description": "If you know the precise low value you would like to clip the pixel values to, (rather than a percentile), then you can specify this with vmin (optionally used with `vmax`). This cannot be used for the same view as `clip`.", + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Vmin" + }, + "vmax": { + "anyOf": [ + { + "description": "If you know the precise high value you would like to clip the pixel values to,\n(rather than a percentile), then you can specify this with vmax (optionally used with `vmin`). This cannot be used for the same view as `clip`.", + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Vmax" + } + }, + "required": [ + "data" + ], + "title": "IrisRGBView", + "type": "object" + }, + "IrisSegAIModel": { + "properties": { + "bands": { + "title": "Bands", + "type": "null" + }, + "train_ratio": { + "default": 0.8, + "title": "Train Ratio", + "type": "number" + }, + "max_train_pixels": { + "default": 20000, + "title": "Max Train Pixels", + "type": "integer" + }, + "n_estimators": { + "default": 20, + "title": "N Estimators", + "type": "integer" + }, + "max_depth": { + "default": 10, + "title": "Max Depth", + "type": "integer" + }, + "n_leaves": { + "default": 10, + "title": "N Leaves", + "type": "integer" + }, + "suppression_threshold": { + "default": 0, + "title": "Suppression Threshold", + "type": "integer" + }, + "suppression_filter_size": { + "default": 5, + "title": "Suppression Filter Size", + "type": "integer" + }, + "suppression_default_class": { + "default": 0, + "title": "Suppression Default Class", + "type": "integer" + }, + "use_edge_filter": { + "default": false, + "title": "Use Edge Filter", + "type": "boolean" + }, + "use_superpixels": { + "default": false, + "title": "Use Superpixels", + "type": "boolean" + }, + "use_meshgrid": { + "default": false, + "title": "Use Meshgrid", + "type": "boolean" + }, + "meshgrid_cells": { + "default": "3x3", + "title": "Meshgrid Cells", + "type": "string" + } + }, + "required": [ + "bands" + ], + "title": "IrisSegAIModel", + "type": "object" + }, + "IrisSegClass": { + "properties": { + "name": { + "description": "Name of the class.", + "title": "Name", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Further description which explains the user more about the class (e.g. why is it different from another class, etc.)", + "title": "Description" + } + }, + "required": [ + "name" + ], + "title": "IrisSegClass", + "type": "object" + }, + "IrisSegmentationConfig": { + "properties": { + "path": { + "description": "This directory will contain the mask files from the segmentation. Four different mask formats are\nallowed: *npy*, *tif*, *png* or *jpeg*. Example:\n\nThis will create a folder next to the project file called `masks` containing the mask files in *png* format.\n```\n\"path\": \"masks/{id}.png\"\n```\n", + "title": "Path", + "type": "string" + }, + "mask_encoding": { + "$ref": "#/$defs/SegMaskEnum", + "default": "rgb", + "description": "The encodings of the final masks. Can be `integer`, `binary`, `rgb` or `rgba`. Note:\nnot all mask formats support all encodings. Example: `\"mask_encoding\": \"rgb\"`." + }, + "mask_area": { + "anyOf": [ + { + "items": { + "type": "integer" + }, + "maxItems": 4, + "minItems": 4, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "In case you don't want to allow the user to label the complete image, you can\nlimit the segmentation area. Example: `\"mask_area\": [100, 100, 400, 400]`", + "title": "Mask Area" + }, + "score": { + "$ref": "#/$defs/SegScoreEnum", + "default": "f1", + "description": "Defines how to measure the score achieved by the user for each mask. Can be `f1`, `jaccard`\nor `accuracy`. Example: `\"score\": \"f1\"`." + }, + "prioritise_unmarked_images": { + "default": true, + "description": "Mode to serve up images with the lowest number of annotations when user asks for\nnext image.", + "title": "Prioritise Unmarked Images", + "type": "boolean" + }, + "unverified_threshold": { + "default": 1, + "description": "TODO Number of unverified users contributing masks above which to tag an image\n\"unverified\".", + "minimum": 0, + "title": "Unverified Threshold", + "type": "integer" + }, + "ai_model": { + "$ref": "#/$defs/IrisSegAIModel" + } + }, + "required": [ + "path", + "ai_model" + ], + "title": "IrisSegmentationConfig", + "type": "object" + }, + "SegMaskEnum": { + "description": "Allowed encodings for final masks. Not all mask formats support all encodings.", + "enum": [ + "integer", + "binary", + "rgb", + "rgba" + ], + "title": "SegMaskEnum", + "type": "string" + }, + "SegScoreEnum": { + "description": "Allowed score measure.", + "enum": [ + "f1", + "jaccard", + "accuracy" + ], + "title": "SegScoreEnum", + "type": "string" + } + }, + "properties": { + "name": { + "description": "Optional name for this project. (e.g., `cloud-segmentation`)", + "title": "Name", + "type": "string" + }, + "port": { + "default": 5000, + "description": "Set the port on which IRIS is served. Example: `6060`.", + "maximum": 65535, + "minimum": 0, + "title": "Port", + "type": "integer" + }, + "host": { + "default": "127.0.0.1", + "description": "Set the host IP address for IRIS. The default value 127.0.0.1 means IRIS will only be visible on the local machine. If you want to expose IRIS publicly as a web application, we recommend setting the host to 0.0.0.0 and adjusting your router / consulting with your network administrators accordingly.", + "title": "Host", + "type": "string" + }, + "images": { + "$ref": "#/$defs/IrisImages", + "description": "A dictionary which defines the inputs. Example:\n```\n\"images\": {\n \"path\": \"images/{id}/image.tif\",\n \"shape\": [512, 512],\n \"thumbnails\": \"images/{id}/thumbnail.png\",\n \"metadata\": \"images/{id}/metadata.json\"\n }\n```\n" + }, + "classes": { + "description": "This is a list of classes that you want to allow the user to label.\nExamples:\n\n {\n \"name\": \"Clear\",\n \"description\": \"All clear pixels.\",\n \"colour\": [255,255,255,0],\n \"user_colour\": [0,255,255,70]\n },\n {\n \"name\": \"Cloud\",\n \"description\": \"All cloudy pixels.\",\n \"colour\": [255,255,0,70]\n }\n]\n", + "items": { + "$ref": "#/$defs/IrisSegClass" + }, + "minItems": 1, + "title": "Classes", + "type": "array" + }, + "views": { + "additionalProperties": { + "anyOf": [ + { + "$ref": "#/$defs/IrisMonochromeView" + }, + { + "$ref": "#/$defs/IrisRGBView" + }, + { + "$ref": "#/$defs/IrisBingView" + } + ] + }, + "description": "Since this app was developed for\nmulti-spectral satellite data (i.e. images with more than just three channels), you can decide how to present the images to the user. This\noption must be a dictionary where each key is the name of the view and the value another dictionary containing properties for the view.", + "minProperties": 1, + "title": "Views", + "type": "object" + }, + "view_groups": { + "additionalProperties": { + "items": { + "type": "string" + }, + "type": "array" + }, + "description": "Views are displayed in groups. In the GUI of IRIS, you will be able to switch\nbetween different groups quickly. The group `default` must always be set, further groups are optional. Examples:\n```\n\"view_groups\": {\n \"default\": [\"Cirrus\", \"RGB\", \"Bing\"],\n \"clouds\": [\"Cirrus\"],\n \"radar\": [\"Sentinel1\"]\n }\n```\n", + "title": "View Groups", + "type": "object" + }, + "segmentation": { + "$ref": "#/$defs/IrisSegmentationConfig", + "description": "Define the parameters for the segmentation mode." + } + }, + "required": [ + "name", + "images", + "classes", + "views", + "view_groups", + "segmentation" + ], + "title": "IrisConfig", + "type": "object" +} diff --git a/tools/iris-config-ui/server.js b/tools/iris-config-ui/server.js new file mode 100644 index 00000000..1eb727de --- /dev/null +++ b/tools/iris-config-ui/server.js @@ -0,0 +1,55 @@ +const express = require('express') +const cors = require('cors') +const fs = require('fs') +const path = require('path') + +const app = express() +app.use(cors()) +app.use(express.json({ limit: '5mb' })) + +const PUBLIC_DIR = path.join(__dirname, 'public') +const SCHEMA_PATH = path.join(PUBLIC_DIR, 'irisconfig.json') +const UISCHEMA_PATH = path.join(PUBLIC_DIR, 'uischema.json') + +app.get('/health', (req, res) => res.json({ ok: true })) + +// Serve static assets from the public directory (so GET / works in browser) +if (fs.existsSync(PUBLIC_DIR)) { + app.use(express.static(PUBLIC_DIR)) +} else { + console.warn('public directory does not exist:', PUBLIC_DIR) +} + +// Root: return index.html if present or a small informative page +app.get('/', (req, res) => { + const indexPath = path.join(PUBLIC_DIR, 'index.html') + if (fs.existsSync(indexPath)) return res.sendFile(indexPath) + res.send('iris-config-ui persistence server. POST /save-schema to save the schema file.') +}) + +app.post('/save-schema', async (req, res) => { + try { + const { schema, uiSchema } = req.body + if (typeof schema !== 'string') return res.status(400).json({ error: 'schema must be a string (raw JSON text)' }) + + // validate JSON parseability + JSON.parse(schema) + + // ensure public dir exists + if (!fs.existsSync(PUBLIC_DIR)) fs.mkdirSync(PUBLIC_DIR, { recursive: true }) + + fs.writeFileSync(SCHEMA_PATH, schema, 'utf8') + + if (uiSchema && typeof uiSchema === 'string') { + fs.writeFileSync(UISCHEMA_PATH, uiSchema, 'utf8') + } + + res.json({ ok: true, path: SCHEMA_PATH }) + } catch (err) { + console.error('save-schema error', err) + res.status(500).json({ error: String(err) }) + } +}) + +const PORT = process.env.IRIS_CONFIG_UI_PORT || 5174 +app.listen(PORT, () => console.log(`iris-config-ui server listening on http://localhost:${PORT}`)) diff --git a/tools/iris-config-ui/src/App.jsx b/tools/iris-config-ui/src/App.jsx new file mode 100644 index 00000000..05996684 --- /dev/null +++ b/tools/iris-config-ui/src/App.jsx @@ -0,0 +1,115 @@ +import React, { useEffect, useState } from 'react' +import Form from '@rjsf/core' +import validator from '@rjsf/validator-ajv8' + +function prettyJson(v){ + try{ return JSON.stringify(JSON.parse(v), null, 2)}catch(e){ return v } +} + +export default function App(){ + const [schema, setSchema] = useState(null) + const [uiSchema, setUiSchema] = useState('{}') + const [formData, setFormData] = useState({}) + const [rawSchemaText, setRawSchemaText] = useState('') + const [error, setError] = useState(null) + + useEffect(()=>{ + fetch('/irisconfig.json') + .then(r=>r.json()) + .then(s=>{ + setSchema(s) + const text = JSON.stringify(s, null, 2) + setRawSchemaText(text) + }) + .catch(e=>setError(String(e))) + },[]) + + function applySchemaText(){ + try{ + const parsed = JSON.parse(rawSchemaText) + setSchema(parsed) + setError(null) + }catch(e){ + setError('Invalid JSON: ' + e.message) + } + } + + function downloadConfig(){ + const blob = new Blob([JSON.stringify(formData, null, 2)], {type: 'application/json'}) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url; a.download = 'project-config.json'; a.click() + URL.revokeObjectURL(url) + } + + const [saving, setSaving] = useState(false) + + async function saveSchemaToServer(){ + // send rawSchemaText and uiSchema to the persistence endpoint + setSaving(true) + try{ + const res = await fetch('http://localhost:5174/save-schema', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ schema: rawSchemaText, uiSchema }) + }) + const j = await res.json().catch(()=>({})) + if(!res.ok){ + setError('Save failed: ' + (j.error || res.statusText)) + } else { + // success — update schema state from saved text + try{ setSchema(JSON.parse(rawSchemaText)); setError(null) }catch(e){ /* ignore */ } + alert('Saved schema to server') + } + }catch(e){ + setError('Save request failed: ' + String(e)) + }finally{ + setSaving(false) + } + } + + if(error) return (

Error

{error}
) + if(!schema) return
Loading schema...
+ + let parsedUi = {} + try{ parsedUi = JSON.parse(uiSchema) }catch(e){ parsedUi = {} } + + return ( +
+
+

IRIS JSON Schema (editable)

+