diff --git a/sch/pydiris.py b/sch/pydiris.py new file mode 100644 index 00000000..406275a2 --- /dev/null +++ b/sch/pydiris.py @@ -0,0 +1,402 @@ +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( + ..., + 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.", + json_schema_extra={"examples": ["127.0.0.1", "0.0.0.0"]}, + ) + 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). 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..8f82a95b --- /dev/null +++ b/tools/iris-config-ui/index.html @@ -0,0 +1,18 @@ + + + + + + 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..d2bdb23d --- /dev/null +++ b/tools/iris-config-ui/package.json @@ -0,0 +1,23 @@ +{ + "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": { + "@rjsf/bootstrap-4": "^5.0.0", + "@rjsf/core": "^5.0.0", + "@rjsf/validator-ajv8": "^5.0.0", + "cors": "^2.8.5", + "express": "^4.18.2", + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "devDependencies": { + "vite": "^7.2.2" + } +} diff --git a/tools/iris-config-ui/public/FILE_HERE_SHOULD_MIRROR_up_up_sch b/tools/iris-config-ui/public/FILE_HERE_SHOULD_MIRROR_up_up_sch new file mode 100644 index 00000000..aa57be88 --- /dev/null +++ b/tools/iris-config-ui/public/FILE_HERE_SHOULD_MIRROR_up_up_sch @@ -0,0 +1 @@ +sldfkjs diff --git a/tools/iris-config-ui/public/irisconfig.json b/tools/iris-config-ui/public/irisconfig.json new file mode 100644 index 00000000..bde40694 --- /dev/null +++ b/tools/iris-config-ui/public/irisconfig.json @@ -0,0 +1,511 @@ +{ + "$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": { + "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", + "shape" + ], + "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.", + "examples": [ + "127.0.0.1", + "0.0.0.0" + ], + "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/public/uischema.json b/tools/iris-config-ui/public/uischema.json new file mode 100644 index 00000000..1674af9e --- /dev/null +++ b/tools/iris-config-ui/public/uischema.json @@ -0,0 +1,145 @@ +{ + "ui:submitButtonOptions": { + "submitText": "Save Configuration", + "norender": false, + "props": { + "className": "iris-button-primary" + } + }, + "name": { + "ui:help": "This will be used as the project identifier" + }, + "port": { + "ui:widget": "updown" + }, + "images": { + "ui:order": [ + "path", + "shape", + "thumbnails", + "metadata" + ], + "path": { + "ui:help": "Use {id} as placeholder for image identifiers", + "ui:options": { "addButtonText": "➕ Add path", "removable": true }, + "items": { + "ui:field": "KeyValue", + "key": { "ui:placeholder": "optional key (e.g. Sentinel2)" }, + "value": { + "ui:placeholder": "images/{id}.tif", + "ui:help": "Full or relative path to set of image files. Must use \"{id}\" placeholder." + } + } + }, + "shape": { + "ui:options": { + "orderable": false + } + }, + "thumbnails": { "ui:widget": "OptionalPath", "ui:placeholder": "thumbnails/{id}.png", "ui:help": "Provide a thumbnail path containing {id} or disable." }, + "metadata": { "ui:widget": "OptionalPath", "ui:placeholder": "metadata/{id}.json", "ui:help": "Optional metadata file (json/yaml)." } + }, + "classes": { + "ui:options": { + "addable": true, + "orderable": true, + "removable": true + }, + "items": { + "colour": { + "ui:help": "RGBA values: Red, Green, Blue, Alpha (0-255)" + }, + "user_colour": { + "ui:help": "Alternative color for user mask overlay" + } + } + }, + "views": { + "ui:options": { + "addable": true, + "removable": true + }, + "additionalProperties": { + "type": { + "ui:widget": "radio", + "ui:options": { + "inline": true + } + }, + "data": { + "ui:help": "Use band expressions like $B1, $B2 or $Sentinel2.B1" + }, + "cmap": { + "ui:widget": "select" + } + } + }, + "view_groups": { + "ui:help": "The 'default' group is required and will be shown first" + }, + "segmentation": { + "ui:order": [ + "path", + "mask_encoding", + "mask_area", + "score", + "prioritise_unmarked_images", + "unverified_threshold", + "test_images", + "ai_model" + ], + "mask_encoding": { + "ui:widget": "radio" + }, + "score": { + "ui:widget": "radio" + }, + "ai_model": { + "ui:order": [ + "bands", + "train_ratio", + "max_train_pixels", + "n_estimators", + "max_depth", + "n_leaves", + "suppression_threshold", + "suppression_filter_size", + "suppression_default_class", + "use_edge_filter", + "use_superpixels", + "use_meshgrid", + "meshgrid_cells" + ], + "train_ratio": { + "ui:widget": "range", + "ui:options": { + "step": 0.1 + } + }, + "max_train_pixels": { + "ui:widget": "range", + "ui:options": { + "step": 1000 + } + }, + "n_estimators": { + "ui:widget": "range" + }, + "max_depth": { + "ui:widget": "range" + }, + "n_leaves": { + "ui:widget": "range" + }, + "suppression_threshold": { + "ui:widget": "range" + }, + "suppression_filter_size": { + "ui:widget": "select" + }, + "meshgrid_cells": { + "ui:widget": "select" + } + } + } +} 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..812edc6f --- /dev/null +++ b/tools/iris-config-ui/src/App.jsx @@ -0,0 +1,907 @@ +import React, { useEffect, useState, useMemo, useCallback } from 'react' +import { resolvePropSchema as resolvePropSchemaFromUtils } from './schema-utils' +import Form, { withTheme } from '@rjsf/core' +import validator from '@rjsf/validator-ajv8' + +// We'll attempt to dynamically load the @rjsf/bootstrap-4 theme at runtime. +// If it's not installed, we fall back to the default Form export from @rjsf/core. +// This avoids a hard build-time dependency that would break the dev server when +// `npm install` hasn't been run yet. +const DefaultForm = Form + +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) + const [success, setSuccess] = useState(null) + + // Note: UI defaults that used to be hard-coded here are moved into + // `public/uischema.json`. The runtime still provides a small custom field + // implementation (`KeyValue`) and registers it via `fields`, but layout, + // placeholders and helpers live in the external uiSchema file so they can be + // edited without changing code. + + const DescriptionField = useCallback(function DescriptionField({ id, description }){ + if(!description) return null + if (typeof description !== 'string') return (
{description}
) + + const codeBlocks = [] + const inlineCodes = [] + + // extract fenced code blocks first + let tmp = description.replace(/```([\s\S]*?)```/g, function(_, m){ + const i = codeBlocks.push(m) - 1 + return `__CODEBLOCK_${i}__` + }) + + // extract existing ... tags (both literal and escaped <code>...</code>) + tmp = tmp.replace(/<code>([\s\S]*?)<\/code>/gi, function(_, m){ + const i = inlineCodes.push(m) - 1 + return `__INLINE_${i}__` + }) + tmp = tmp.replace(/([\s\S]*?)<\/code>/gi, function(_, m){ + const i = inlineCodes.push(m) - 1 + return `__INLINE_${i}__` + }) + + // extract inline backtick spans + tmp = tmp.replace(/`([^`]+)`/g, function(_, m){ + const i = inlineCodes.push(m) - 1 + return `__INLINE_${i}__` + }) + + // escape remaining text + function escapeHtml(s){ + return s.replace(/&/g, '&').replace(//g, '>') + } + + let escaped = escapeHtml(tmp) + + // restore inline codes + escaped = escaped.replace(/__INLINE_(\d+)__/g, function(_, idx){ + const i = Number(idx) + const content = inlineCodes[i] || '' + return `${escapeHtml(content)}` + }) + + // restore fenced code blocks + escaped = escaped.replace(/__CODEBLOCK_(\d+)__/g, function(_, idx){ + const i = Number(idx) + const content = codeBlocks[i] || '' + return `
${escapeHtml(content)}
` + }) + + return
+ }, []) + + // Generic FieldTemplate to ensure we control how descriptions render. + const FieldTemplate = useCallback(function FieldTemplate(props){ + const { id, classNames, label, required, description, errors, help, children, schema } = props + + // description may be a React node or a string. If it's a string, format it. + let descNode = null + if (typeof description === 'string'){ + // reuse DescriptionField formatting + descNode = + } else { + descNode = description + } + + // Only set htmlFor when the field schema indicates a simple input exists (string/number/integer/boolean) + const inputLike = schema && (schema.type === 'string' || schema.type === 'number' || schema.type === 'integer' || schema.type === 'boolean') + + return ( +
+ {label && (inputLike ? :
{label}{required ? ' *' : null}
)} + {descNode} + {children} + {errors} + {help} +
+ ) + }, [DescriptionField]) + + // Custom ArrayFieldTemplate to render a clearer add button and nicer item layout + const ArrayFieldTemplate = useCallback(function ArrayFieldTemplate(props){ + const { items, canAdd, onAddClick, uiSchema, title, description, idSchema } = props + // Special-case layout for `shape` arrays: render the two numeric inputs on a + // single row (side-by-side) and hide per-item Remove buttons. Detect by + // title or idSchema path to avoid relying on uiSchema contents. + const isShapeArray = (title && String(title).toLowerCase() === 'shape') || (idSchema && idSchema.$id && String(idSchema.$id).toLowerCase().includes('shape')) + + if(isShapeArray){ + return ( +
+ {title ?
{title}
: null} + {description ? : null} +
+ {items && items.map((it, idx) => ( +
+ {it.children} +
+ ))} +
+ {canAdd ? ( +
+ +
+ ) : null} +
+ ) + } + + return ( +
+ {title ?
{title}
: null} + {description ? : null} +
+ {items && items.map((it, idx) => ( +
+
+
+ {it.children} +
+
+ {it.hasRemove ? : null} +
+
+
+ ))} +
+ {canAdd ? ( +
+ +
+ ) : null} +
+ ) + }, []) + + // DOM post-processor with MutationObserver: convert inline backticks in rendered + // descriptions to elements and re-run when RJSF updates the DOM. + useEffect(()=>{ + // Run a single, safe pass to convert code spans/blocks in descriptions. + // Previously we used a MutationObserver to re-run on every DOM change; + // that has been observed to interact poorly with RJSF's updates and can + // cause inputs to lose focus. For stability, only process descriptions + // once after the schema renders. + try{ + const wrapper = document.querySelector('.rjsf-wrapper') + if(!wrapper) return + const nodes = wrapper.querySelectorAll('.field-description') + nodes.forEach(n => { + if(n.dataset.codeProcessed === '1') return + const raw = n.textContent || '' + if(!raw.includes('`')) return + const frag = document.createDocumentFragment() + const re = /```([\s\S]*?)```|`([^`]+)`/g + let lastIndex = 0 + let m + while((m = re.exec(raw)) !== null){ + const idx = m.index + if(idx > lastIndex){ frag.appendChild(document.createTextNode(raw.slice(lastIndex, idx))) } + if(m[1] !== undefined){ const pre = document.createElement('pre'); const code = document.createElement('code'); code.textContent = m[1]; pre.appendChild(code); frag.appendChild(pre) } + else if(m[2] !== undefined){ const code = document.createElement('code'); code.textContent = m[2]; frag.appendChild(code) } + lastIndex = re.lastIndex + } + if(lastIndex < raw.length) frag.appendChild(document.createTextNode(raw.slice(lastIndex))) + if(frag.childNodes.length > 0){ n.innerHTML = ''; n.appendChild(frag); n.dataset.codeProcessed = '1' } + }) + }catch(e){ /* ignore description postprocess errors */ } + }, [schema]) + + // Commit current tab's local form data into the global formData + function commitTabToGlobal(tab, attempts = 0){ + try{ + const local = (tabFormDataRef.current && tabFormDataRef.current[tab]) || tabFormData[tab] || {} + if(tab === 'General'){ + const g = {...(local || {})} + if(g.images && g.images.path !== undefined){ + g.images = { ...g.images, path: arrayToPathValue(g.images.path) } + } + // If any image subkeys are still undefined, allow a few short retries + // to give RJSF/widget onChange handlers time to propagate into + // tabFormDataRef.current. This avoids losing values when blur fires + // before onChange completes. + try{ + if(g.images){ + // If the tab-local object lacks an explicit metadata/thumbnails key + // but the UI toggle for that field is unchecked, explicitly set + // the key to null so merging removes any previous value. + try{ + const ensureToggleNull = (suffix, keyName) => { + try{ + // try common enabled-checkbox id we create in the widget + const chk = document.querySelector(`[id*='${suffix}__enabled'], [id*='${suffix}__anyof_select'], [id*='${suffix}__select']`) + if(chk && (chk.type === 'checkbox' || chk.getAttribute('role') === 'switch')){ + if(!chk.checked){ + g.images[keyName] = null + if(tabFormDataRef && tabFormDataRef.current && tabFormDataRef.current['General']){ + try{ const cur = tabFormDataRef.current['General']; if(!cur.images) cur.images = {}; cur.images[keyName] = null; tabFormDataRef.current['General'] = cur }catch(e){} + } + } + } + }catch(e){} + } + ensureToggleNull('images_metadata','metadata') + ensureToggleNull('images_thumbnails','thumbnails') + }catch(e){} + const hasUndef = Object.values(g.images).some(v => v === undefined) + if(hasUndef && attempts < 3){ + try{ console.debug('commitTabToGlobal: detected undefined image keys, retrying', {tab, attempts}) }catch(e){} + setTimeout(()=>commitTabToGlobal(tab, attempts+1), 60) + return + } + } + }catch(e){} + // If toggling widgets didn't propagate their value to tabFormDataRef + // (race or widget onChange edge), attempt to read the corresponding + // inputs from the DOM as a fallback. RJSF typically uses ids like + // 'root_images_thumbnails' for the field; we try a suffix match to be + // resilient to id prefixes. + try{ + if(g.images){ + const findValueFor = (suffix) => { + try{ + // search any element whose id contains the suffix + const nodes = Array.from(document.querySelectorAll(`[id*='${suffix}']`)) + for(const n of nodes){ + const tag = (n.tagName || '').toLowerCase() + if(tag === 'input' || tag === 'textarea'){ + const type = (n.getAttribute && n.getAttribute('type')) || '' + if(type === 'checkbox' || type === 'radio') continue // skip checkboxes (they have value 'on') + if(n.value !== undefined && n.value !== '') return n.value + } + if(tag === 'select'){ + const val = n.value; if(val && val !== '') return val + } + // if container, look for a textual input/select/textarea inside + const input = n.querySelector && (n.querySelector('input[type=text]') || n.querySelector('input[type=url]') || n.querySelector('input[type=search]') || n.querySelector('input[type=email]') || n.querySelector('textarea') || n.querySelector('input')) + if(input && input.value !== undefined){ const itype = (input.getAttribute && input.getAttribute('type')) || ''; if(itype === 'checkbox' || itype === 'radio'){} else { if(input.value !== '') return input.value } } + const sel = n.querySelector && n.querySelector('select') + if(sel && sel.value) return sel.value + } + // also try inputs whose name attribute contains the suffix, but skip checkboxes/radios + const byName = Array.from(document.querySelectorAll(`input[name*='${suffix}'], textarea[name*='${suffix}'], select[name*='${suffix}']`)) + for(const n of byName){ const type = (n.getAttribute && n.getAttribute('type')) || ''; if(type === 'checkbox' || type === 'radio') continue; if(n.value && n.value !== '') return n.value } + }catch(e){} + return undefined + } + try{ + const vThumb = findValueFor('images_thumbnails') + if(vThumb !== undefined && vThumb !== null) g.images.thumbnails = vThumb + }catch(e){} + try{ + const vMeta = findValueFor('images_metadata') + if(vMeta !== undefined && vMeta !== null) g.images.metadata = vMeta + }catch(e){} + } + }catch(e){} + const currentGlobal = JSON.stringify(formData || {}) + // Merge at top-level but deep-merge images so subfields are preserved + const merged = { ...(formData||{}), ...(g||{}) } + if(g.images){ + const prevImgs = (formData && formData.images) ? formData.images : {} + // Only copy keys from g.images that are not `undefined` so we don't + // unintentionally overwrite existing values with undefined. + const entries = Object.entries(g.images || {}).filter(([k,v]) => v !== undefined) + const safeG = Object.fromEntries(entries) + merged.images = { ...prevImgs, ...safeG } + } + const candidate = JSON.stringify(merged) + // DEBUG: print merge details to help trace missing subfields. Use JSON.stringify + try{ + const safe = (v) => { + try{ return JSON.stringify(v) }catch(e){ return String(v) } + } + console.debug('commitTabToGlobal: tabFormDataRef=', safe(tabFormDataRef.current)) + console.debug('commitTabToGlobal: local=', safe(local)) + console.debug('commitTabToGlobal: General g=', safe(g)) + console.debug('commitTabToGlobal: merged=', safe(merged)) + try{ + const ig = g.images || {} + const im = merged.images || {} + console.debug('images keys in g:', Object.keys(ig), 'values:', { thumbnails: ig.thumbnails, metadata: ig.metadata }) + console.debug('images keys in merged:', Object.keys(im), 'values:', { thumbnails: im.thumbnails, metadata: im.metadata }) + }catch(e){} + }catch(e){} + if(currentGlobal !== candidate){ + setFormData(merged) + } + } else { + const currentGlobal = JSON.stringify(formData && formData[tab] ? formData[tab] : {}) + const candidate = JSON.stringify(local || {}) + if(currentGlobal !== candidate){ + setFormData(prev => ({ ...(prev||{}), [tab]: local })) + } + } + }catch(e){ /* commitTabToGlobal error ignored */ } + } + + useEffect(()=>{ + // Load both the main schema and (if present) a persisted uiSchema file. + // The persistence server writes `public/uischema.json` when you save from the UI. + Promise.all([ + fetch('/irisconfig.json').then(r=>{ if(!r.ok) throw new Error('schema fetch failed'); return r.json() }), + fetch('/uischema.json').then(r=>{ if(!r.ok) return '{}'; return r.text() }).catch(()=>'{}') + ]).then(([s, uiText])=>{ + setSchema(s) + const text = JSON.stringify(s, null, 2) + setRawSchemaText(text) + try{ if(uiText && uiText.trim()) setUiSchema(typeof uiText === 'string' ? uiText : JSON.stringify(uiText, null, 2)) }catch(e){} + }).catch(e=>{ + // If the main schema fetch fails, keep previous state and show an error. + 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) + const EDIT_TABS = ['General','classes','views','view_groups','segmentation'] + // allow multiple open panels; keep an array of open panel ids + const [openPanels, setOpenPanels] = useState(['General']) + const [tabFormData, setTabFormData] = useState({}) + const tabFormDataRef = React.useRef({}) + const formRenderCountsRef = React.useRef({}) + + // Helpers to convert the images.path value between the project's schema form and an + // array-of-{key,value} form convenient for RJSF editing. + function pathValueToArray(val){ + if(val == null) return [] + if(typeof val === 'string') return [{ key: '', value: val }] + if(typeof val === 'object'){ + return Object.entries(val).map(([k,v]) => ({ key: k, value: v })) + } + return [] + } + + function arrayToPathValue(arr){ + if(!Array.isArray(arr)) return arr + if(arr.length === 0) return undefined + if(arr.length === 1 && (!arr[0].key || arr[0].key.trim() === '')) return arr[0].value + const out = {} + arr.forEach((it, idx) => { + const k = (it.key && it.key.trim() !== '') ? it.key : String(idx) + out[k] = it.value + }) + return out + } + + // Resolve a property schema for a top-level property. If it's a $ref to $defs, return the referenced def schema. + function resolvePropSchema(propSchema){ + return resolvePropSchemaFromUtils(propSchema, schema && schema.$defs) + } + + // Sanitize schemas for RJSF/AJV: remove empty anyOf arrays which cause AJV validation errors + function sanitizeSchema(s){ + try{ + const copy = JSON.parse(JSON.stringify(s || {})) + const walk = (obj)=>{ + if(!obj || typeof obj !== 'object') return + if(Array.isArray(obj)){ obj.forEach(walk); return } + Object.keys(obj).forEach(k=>{ + const v = obj[k] + if(k === 'anyOf' && Array.isArray(v) && v.length === 0){ + delete obj[k] + return + } + walk(v) + }) + } + walk(copy) + return copy + }catch(e){ return s } + } + + function getGeneralSchema(){ + const keys = ['name','port','host','images'] + const props = {} + const required = [] + keys.forEach(k=>{ + if(schema.properties && schema.properties[k]){ + try{ + // Diagnostic: log the original property and the resolved prop to + // understand why some subproperties might be missing at runtime. + if(k === 'images'){ + // intentionally quiet in production + } + }catch(e){/*ignore*/} + props[k] = resolvePropSchema(schema.properties[k]) + if(k === 'images'){ + // resolved images prop handled silently + } + if(Array.isArray(schema.required) && schema.required.includes(k)) required.push(k) + } + }) + // Override images.path to be an array-of-key/value pairs for easier editing in RJSF + if(props.images && props.images.properties && props.images.properties.path){ + const original = props.images.properties.path + props.images.properties.path = { + type: 'array', + title: original.title || 'Path', + description: original.description || undefined, + items: { + type: 'object', + properties: { + key: { type: 'string', title: 'Key' }, + value: { type: 'string', title: 'Path' } + }, + required: ['value'] + }, + default: [] + } + } + const out = { type: 'object', title: 'General', properties: props } + if(required.length) out.required = required + if(schema && schema.$defs) out.$defs = schema.$defs + return out + } + + function getSchemaForTab(tab){ + if(!schema) return {} + if(tab === 'General') return getGeneralSchema() + const propSchema = schema.properties && schema.properties[tab] + return resolvePropSchema(propSchema) + } + + // Initialize tabFormData from global formData when schema loads + useEffect(()=>{ + if(!schema) return + const next = {} + const imagesVal = formData.images || {} + // Normalize thumbnails/metadata values for the per-tab form so they match + // the nullable-string schema we provide: convert non-string values to null + const imagesForForm = { ...imagesVal, path: pathValueToArray(imagesVal.path) } + imagesForForm.thumbnails = (typeof imagesForForm.thumbnails === 'string') ? imagesForForm.thumbnails : null + imagesForForm.metadata = (typeof imagesForForm.metadata === 'string') ? imagesForForm.metadata : null + if(!Array.isArray(imagesForForm.path) || imagesForForm.path.length === 0){ imagesForForm.path = [{key:'', value:''}] } + + // Only include fields in the per-tab formData when they exist in the global + // formData. If we include keys with `undefined` values, RJSF treats the + // presence of those keys as user-provided data and will not apply schema + // defaults (e.g. `port: 5000`). To allow schema defaults to show up, omit + // absent keys so RJSF may fill them from `schema.default`. + const general = {} + if(formData.name !== undefined) general.name = formData.name + if(formData.port !== undefined) general.port = formData.port + if(formData.host !== undefined) general.host = formData.host + // always include images object for editing (we provide a blank placeholder row) + general.images = imagesForForm + next['General'] = general + EDIT_TABS.forEach(t=>{ if(t !== 'General') next[t] = formData[t] }) + setTabFormData(next) + tabFormDataRef.current = next + }, [schema]) + + 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 */ } + setSuccess('Saved schema to server') + setTimeout(()=>setSuccess(null), 4000) + } + }catch(e){ + setError('Save request failed: ' + String(e)) + }finally{ + setSaving(false) + } + } + + const parsedUi = useMemo(()=>{ + try{ return JSON.parse(uiSchema) }catch(e){ return {} } + }, [uiSchema]) + + // UI defaults moved to `public/uischema.json` (see note above) + + // Custom inline field that renders a two-column Bootstrap row for a {key,value} object + const KeyValueField = useCallback(function KeyValueField(props){ + // props: { schema, uiSchema, idSchema, formData, onChange } + const { formData = {}, onChange, uiSchema = {} } = props + const keyVal = formData.key || '' + const valueVal = formData.value || '' + + return ( +
+
+ onChange({ ...(formData||{}), key: e.target.value })} + /> +
+
+ onChange({ ...(formData||{}), value: e.target.value })} + /> + {uiSchema.value && uiSchema.value['ui:help'] ?
{uiSchema.value['ui:help']}
: null} +
+
+ ) + }, []) + + // Custom optional-path field: renders a toggle and, when enabled, a text input. + // This is useful for schema entries like `thumbnails` or `metadata` which can + // be either a string path or `false`/null. The widget emits either a string + // (the path) or `false` when disabled. + const OptionalPathField = useCallback(function OptionalPathField(props){ + const { id, formData, onChange, schema, uiSchema = {} } = props + const [enabled, setEnabled] = React.useState(typeof formData === 'string') + const [val, setVal] = React.useState(typeof formData === 'string' ? formData : '') + + React.useEffect(()=>{ + setEnabled(typeof formData === 'string') + setVal(typeof formData === 'string' ? formData : '') + }, [formData]) + + const toggle = (now)=>{ + setEnabled(now) + if(now){ + try{ console.debug('OptionalPathField toggle ON id=', id, 'emitting value=', val || '') }catch(e){} + onChange(val || '') + } + else { + try{ console.debug('OptionalPathField toggle OFF id=', id, 'emitting null') }catch(e){} + // clear any lingering textual input in the DOM so our DOM-fallback + // doesn't pick up the previous value when committing + try{ + const el = document.getElementById(id) + if(el && (el.tagName||'').toLowerCase() === 'input'){ + el.value = '' + el.dispatchEvent(new Event('input', { bubbles: true })) + } + }catch(e){} + onChange(null) + } + } + + const onValChange = (v)=>{ setVal(v); onChange(v) } + // attempt to eagerly mirror changes into tabFormDataRef to avoid races + React.useEffect(()=>{ + try{ + const suffix = id && id.toString() + if(!suffix) return + const isThumb = suffix.endsWith('images_thumbnails') || suffix.endsWith('images_thumbnails__enabled') || suffix.includes('images_thumbnails') + const isMeta = suffix.endsWith('images_metadata') || suffix.endsWith('images_metadata__enabled') || suffix.includes('images_metadata') + if(tabFormDataRef && tabFormDataRef.current && tabFormDataRef.current['General'] && (isThumb || isMeta)){ + const cur = tabFormDataRef.current['General'] || {} + if(!cur.images) cur.images = {} + if(isThumb) cur.images.thumbnails = (typeof formData === 'string' ? formData : (typeof val === 'string' ? val : cur.images.thumbnails)) + if(isMeta) cur.images.metadata = (typeof formData === 'string' ? formData : (typeof val === 'string' ? val : cur.images.metadata)) + tabFormDataRef.current['General'] = cur + } + }catch(e){} + }, [val, id]) + + return ( +
+
+ toggle(e.target.checked)} /> + +
+ {enabled ? ( +
+ onValChange(e.target.value)} /> + {uiSchema && uiSchema['ui:help'] ?
{uiSchema['ui:help']}
: null} +
+ ) : null} +
+ ) + }, []) + + // Widget variant for OptionalPath so uiSchema can use `ui:widget` instead + const OptionalPathWidget = useCallback(function OptionalPathWidget(props){ + // widget props: { id, value, onChange, schema, uiSchema } + const { id, value, onChange, schema, uiSchema = {} } = props + const [enabled, setEnabled] = React.useState(typeof value === 'string') + const [val, setVal] = React.useState(typeof value === 'string' ? value : '') + + React.useEffect(()=>{ + setEnabled(typeof value === 'string') + setVal(typeof value === 'string' ? value : '') + }, [value]) + + const toggle = (now) => { + setEnabled(now) + if(now){ + try{ console.debug('OptionalPathWidget toggle ON id=', id, 'emitting value=', val || '') }catch(e){} + onChange(val || '') + try{ + /* mirror into tabFormDataRef */ + if(tabFormDataRef && tabFormDataRef.current){ + const cur = tabFormDataRef.current['General'] || {} + if(!cur.images) cur.images = {} + if(id && (id.includes('images_metadata') || id.endsWith('images_metadata'))) cur.images.metadata = val || '' + if(id && (id.includes('images_thumbnails') || id.endsWith('images_thumbnails'))) cur.images.thumbnails = val || '' + tabFormDataRef.current['General'] = cur + try{ console.debug('OptionalPathWidget mirrored to tabFormDataRef', JSON.stringify(tabFormDataRef.current['General'].images)) }catch(e){} + } + }catch(e){} + } else { + try{ console.debug('OptionalPathWidget toggle OFF id=', id, 'emitting null') }catch(e){} + // clear underlying text input to avoid DOM-fallback reading stale value + try{ + const el = document.getElementById(id) + if(el && (el.tagName||'').toLowerCase() === 'input'){ + el.value = '' + el.dispatchEvent(new Event('input', { bubbles: true })) + } + }catch(e){} + onChange(null) + try{ + if(tabFormDataRef && tabFormDataRef.current){ + const cur = tabFormDataRef.current['General'] || {} + if(!cur.images) cur.images = {} + if(id && (id.includes('images_metadata') || id.endsWith('images_metadata'))) cur.images.metadata = null + if(id && (id.includes('images_thumbnails') || id.endsWith('images_thumbnails'))) cur.images.thumbnails = null + tabFormDataRef.current['General'] = cur + try{ console.debug('OptionalPathWidget mirrored OFF to tabFormDataRef', JSON.stringify(tabFormDataRef.current['General'].images)) }catch(e){} + } + }catch(e){} + } + } + + const onValChange = (v)=>{ setVal(v); onChange(v) } + // mirror keystrokes into tabFormDataRef immediately to avoid blur races + React.useEffect(()=>{ + try{ + if(!id) return + const isThumb = id.includes('images_thumbnails') + const isMeta = id.includes('images_metadata') + if(tabFormDataRef && tabFormDataRef.current && tabFormDataRef.current['General'] && (isThumb || isMeta)){ + const cur = tabFormDataRef.current['General'] + if(!cur.images) cur.images = {} + if(isThumb) cur.images.thumbnails = (typeof value === 'string' ? value : cur.images.thumbnails) + if(isMeta) cur.images.metadata = (typeof value === 'string' ? value : cur.images.metadata) + tabFormDataRef.current['General'] = cur + try{ console.debug('OptionalPathWidget useEffect mirrored value to tabFormDataRef', isMeta?{metadata:cur.images.metadata}:{thumbnails:cur.images.thumbnails}) }catch(e){} + } + }catch(e){} + }, [value, id]) + + return ( +
+
+ toggle(e.target.checked)} /> + +
+ {enabled ? ( +
+ onValChange(e.target.value)} /> + {uiSchema && uiSchema['ui:help'] ?
{uiSchema['ui:help']}
: null} +
+ ) : null} +
+ ) + }, []) + + const fields = useMemo(()=>({ KeyValue: KeyValueField }), [KeyValueField]) + const widgets = useMemo(()=>({ OptionalPath: OptionalPathWidget }), [OptionalPathWidget]) + + // Memoize templates so we pass a stable object reference to RJSF and avoid + // unnecessary internal re-initialization which can cause focus loss. + const templates = useMemo(()=>({ DescriptionField, FieldTemplate, ArrayFieldTemplate }), [DescriptionField, FieldTemplate, ArrayFieldTemplate]) + + // Precompute sanitized schema for each tab and memoize to avoid recreating + // a new schema object on every render (this was causing RJSF to re-init and + // reset input focus). Only recompute when the upstream `schema` changes. + const schemasByTab = useMemo(()=>{ + const map = {} + EDIT_TABS.forEach(tab => { + const tabSchema = getSchemaForTab(tab) + let tabSchemaSanitized = sanitizeSchema(tabSchema) + // For General, force images.path to be the array-of-{key,value} schema + if(tab === 'General'){ + try{ + if(!tabSchemaSanitized.properties) tabSchemaSanitized.properties = {} + if(!tabSchemaSanitized.properties.images) tabSchemaSanitized.properties.images = {} + if(!tabSchemaSanitized.properties.images.properties) tabSchemaSanitized.properties.images.properties = {} + const original = (tabSchemaSanitized.properties.images.properties && tabSchemaSanitized.properties.images.properties.path) || {} + tabSchemaSanitized.properties.images.properties.path = { + type: 'array', + title: original.title || 'Path', + description: original.description || undefined, + items: { + type: 'object', + properties: { + key: { type: 'string', title: 'Key' }, + value: { type: 'string', title: 'Path' } + }, + required: ['value'] + }, + default: [] + } + // Force thumbnails and metadata to a simple nullable-string type so + // RJSF doesn't render an anyOf selector. We replace whatever exists + // with a { type: ['string','null'], ... } form using available + // title/description/default when present. + try{ + const imgProps = tabSchemaSanitized.properties.images.properties || {} + ['thumbnails','metadata'].forEach(pk => { + const p = imgProps[pk] || {} + const normalized = { type: ['string','null'] } + if(p.title) normalized.title = p.title + if(p.description) normalized.description = p.description + normalized.default = (p.default !== undefined) ? p.default : null + imgProps[pk] = normalized + }) + tabSchemaSanitized.properties.images.properties = imgProps + }catch(e){ /* normalization failed, ignore */ } + }catch(e){ /* failed to force array path — ignore */ } + } + + // debug: detect any anyOf with zero items which breaks AJV + try{ + const scan = (obj, path=[])=>{ + if(!obj || typeof obj !== 'object') return + if(Array.isArray(obj)){ + obj.forEach((v,i)=> scan(v, path.concat([`[${i}]`]))) + return + } + Object.keys(obj).forEach(k=>{ + if(k === 'anyOf' && Array.isArray(obj[k]) && obj[k].length === 0){ + console.error('Detected empty anyOf at', path.concat([k]).join('/'), 'for tab', tab, obj) + } + scan(obj[k], path.concat([k])) + }) + } + scan(tabSchema) + }catch(e){ /* schema scan failed, ignore */ } + + // no debug logging here + map[tab] = tabSchemaSanitized + }) + return map + }, [schema]) + + // Precompute uiSchema for each tab (merge defaults only when parsedUi changes) + const uiByTab = useMemo(()=>{ + const map = {} + EDIT_TABS.forEach(tab => { + if(tab === 'General'){ + // The root uiSchema controls fields under the top-level properties such as `images`, + // `host`, `port` etc. Use the parsed uiSchema as-is for the General tab. + map[tab] = (parsedUi || {}) + } else { + map[tab] = (parsedUi && parsedUi[tab]) || {} + } + }) + return map + }, [parsedUi]) + + if(!schema) return
Loading schema...
+ + return ( +
+
+
+ {error &&
{error}
} + {success &&
{success}
} +
+ +
+
+
IRIS JSON Schema (editable)
+
+ +