Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions iris/api/routes/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,11 @@ def get_project_config():
'name': project.config.get('name'),
'host': project.config.get('host', '127.0.0.1'),
'port': project.config.get('port', 5000),
# Expose whether the server/project was started in debug mode.
# This is set by start_server(..., debug=True) and stored on the
# project object, so the frontend can decide to enable verbose
# debug behavior such as pretty-printing the saved config.
'debug': getattr(project, 'debug', False),
'images': {
'path': deepcopy(project.config['images']['path']),
'shape': deepcopy(project.config['images']['shape']),
Expand Down Expand Up @@ -320,6 +325,10 @@ def validate_project_config():
elif isinstance(config_data['images']['path'], str):
if '{id}' not in config_data['images']['path'] and config_data['images']['path'] != '':
warnings.append('images.path should contain {id} placeholder')
elif isinstance(config_data['images']['path'], dict):
for k, v in config_data['images']['path'].items():
if '{id}' not in v and v != '':
warnings.append(f'images.path {k} should contain {{id}} placeholder')

if 'shape' not in config_data['images']:
errors.append('images.shape is required')
Expand Down
4 changes: 3 additions & 1 deletion iris/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,8 @@ def label(
@app.command()
def launch(
folder: Annotated[str, typer.Argument(help="Project folder name to create or launch")],
debug: Annotated[bool, typer.Option("--debug", "-d", help="Start in debug mode")] = False,
production: Annotated[bool, typer.Option("--production", "-p", help="Use production WSGI server")] = False,
):
"""
Create a new project from the demo template or launch an existing one.
Expand All @@ -101,7 +103,7 @@ def launch(
try:
config_file = handle_launch_command(folder)
typer.echo(f"Launching project '{folder}' with config '{config_file}'...")
start_server(project_file=str(config_file), debug=False, production=False)
start_server(project_file=str(config_file), debug=debug, production=production)
except (ValueError, FileNotFoundError, RuntimeError) as e:
typer.echo(f"Error: {e}", err=True)
raise typer.Exit(code=1)
Expand Down
59 changes: 31 additions & 28 deletions iris/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,6 @@ def load_from(self, filename):
self.config['images']['path'] = { 'pictures': self.config['images']['path'] }
except Exception:
Comment thread
loriab marked this conversation as resolved.
# Non-fatal; leave config as-is on error
logger.warning(f"Failed to normalize images.path: {e}")
pass
Comment thread
loriab marked this conversation as resolved.

self._init_paths_and_files(filename)
Expand Down Expand Up @@ -172,35 +171,39 @@ def _init_paths_and_files(self, filename):
)

if isinstance(self['images']['path'], dict):
image_paths = list(self['images']['path'].values())[0]
image_paths = list(self['images']['path'].values())
else:
image_paths = self['images']['path']

# We will need to extract the image id by using regex. Compile it here
# to get a better perfomance:
before, id_str, after = image_paths.partition("{id}")
if not id_str:
raise Exception('[CONFIG] images:path must contain exactly one placeholder "{id}"!')
escaped_path = re.escape(before)
escaped_path += "(?P<id>.+)"
escaped_path += re.escape(after)
regex_images = re.compile(escaped_path)

images = glob(image_paths.format(id="*"))
if not images:
raise Exception(
f"[CONFIG] No images found in '{image_paths.format(id='*')}'.\n"
"Did you set images:path to a valid, existing path?")
image_paths = [self['images']['path']]

for ip_idx, image_path in enumerate(image_paths):
# We will need to extract the image id by using regex. Compile it here
# to get a better performance:
before, id_str, after = image_path.partition("{id}")
if not id_str:
raise Exception(f'[CONFIG] images:path {ip_idx+1} must contain exactly one placeholder "{{id}}"!')
escaped_path = re.escape(before)
escaped_path += "(?P<id>.+)"
escaped_path += re.escape(after)
regex_images = re.compile(escaped_path)

images = glob(image_path.format(id="*"))
if not images:
raise Exception(
f"[CONFIG] No images found in '{image_path.format(id='*')}'.\n"
"Did you set images:path to a valid, existing path?")

try:
self.image_ids = list(sorted([
regex_images.match(image_path).groups()[0]
for image_path in images
]))
except Exception as error:
raise Exception(
f'[ERROR] Could not extract id\nfrom path"{image_paths}"\nwith regex "{regex_images}"!'
)
try:
image_ids = list(sorted([
regex_images.match(image_path).groups()[0]
for image_path in images
Comment on lines +197 to +198
Copy link

Copilot AI Dec 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Variable shadowing issue: the loop variable 'image_path' on line 178 is being reused inside the loop at lines 197-198 for the list comprehension iteration variable. This shadows the outer loop variable, which means the exception message on line 202 will reference the last item from the 'images' list instead of the original 'image_path' from the outer loop. Consider renaming the inner loop variable to something like 'img_file' to avoid confusion and ensure error messages are accurate.

Suggested change
regex_images.match(image_path).groups()[0]
for image_path in images
regex_images.match(img_file).groups()[0]
for img_file in images

Copilot uses AI. Check for mistakes.
]))
except Exception as error:
raise Exception(
f'[ERROR] Could not extract id\nfrom path"{image_path}"\nwith regex "{regex_images}"!'
)
else:
if ip_idx == 0:
Comment thread
rfievet marked this conversation as resolved.
self.image_ids = image_ids


def make_absolute(self, path):
Expand Down
11 changes: 11 additions & 0 deletions iris/tests/test_api_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -400,6 +400,17 @@ def test_put_config_requires_admin(app, client):
assert response.status_code == 403


def test_api_project_includes_debug(logged_in_admin):
"""API should return a 'debug' flag reflecting server start mode"""
response = logged_in_admin.get('/api/config/project')
assert response.status_code == 200
data = response.json
assert 'config' in data
config = data['config']
assert 'debug' in config, "Expected 'debug' key in config payload"
assert isinstance(config['debug'], bool)


def test_load_from_normalizes_images_path(tmp_path, sample_valid_config):
"""Ensure Project.load_from() converts single-string images.path into a dict"""
# Prepare a temporary project directory with an images subfolder and a dummy file
Expand Down
19 changes: 19 additions & 0 deletions iris/tests/test_api_config_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,25 @@ def test_validate_warns_missing_id_placeholder(logged_in_admin, sample_valid_con
assert any('{id}' in warn.lower() or 'placeholder' in warn.lower() for warn in data['warnings'])


def test_validate_warns_missing_id_placeholder_dict(logged_in_admin, sample_valid_config):
"""Test that validation warns when images.path lacks {id} placeholder"""
sample_valid_config['images']['path'] = {
'Sent1': 'test/{id}/image.tif',
'Sent2': 'test/image.tif', # No {id}
}

response = logged_in_admin.post('/api/config/project/validate',
json=sample_valid_config,
content_type='application/json'
)

assert response.status_code == 200
data = response.json
# Should be valid but with warning
assert any('{id}' in warn.lower() or 'placeholder' in warn.lower() for warn in data['warnings'])
assert any('sent2' in warn.lower() for warn in data['warnings'])


def test_validate_detects_colour_value_out_of_range(logged_in_admin, sample_valid_config):
"""Test that validation detects colour values outside 0-255 range"""
sample_valid_config['classes'][0]['colour'] = [256, 0, 0, 255] # 256 is too high
Expand Down
28 changes: 26 additions & 2 deletions src/components/preferences/ProjectConfigTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -264,10 +264,20 @@ const ProjectConfigTab: React.FC<ProjectConfigTabProps> = ({ onStateChange }) =>
const validationResult = await validateProjectConfig(config);

if (!validationResult.valid) {
setError(`Validation failed: ${validationResult.errors.join(', ')}`);
const msg = `Validation failed: ${validationResult.errors.join('\n')}`;
setError(msg);
Comment on lines +267 to +268
Copy link

Copilot AI Dec 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error message uses '\n' for line breaks, but these won't be rendered as line breaks in the HTML div element. To display multi-line error messages properly, either add a CSS style like 'whiteSpace: "pre-wrap"' to the error div elements (lines 316-326 and 352-361), or split the error string by '\n' and render each line as a separate element with
tags between them.

Copilot uses AI. Check for mistakes.
return;
}

// Only log the full configuration if the project was started in debug mode
if (loadedConfig && (loadedConfig as any).debug) {
try {
console.log('Save Complete Configuration - full config:', JSON.stringify(config, null, 2));
} catch (e) {
console.log('Save Complete Configuration - full config (object):', config);
}
}

// Save to backend
const response = await updateProjectConfig(config);

Expand All @@ -281,7 +291,8 @@ const ProjectConfigTab: React.FC<ProjectConfigTabProps> = ({ onStateChange }) =>

} catch (err: any) {
console.error('[ProjectConfigTab] Failed to save configuration:', err);
setError(err.message || 'Failed to save configuration');
const msg = err?.message || 'Failed to save configuration';
setError(msg);
} finally {
setSaving(false);
}
Expand Down Expand Up @@ -336,6 +347,19 @@ const ProjectConfigTab: React.FC<ProjectConfigTabProps> = ({ onStateChange }) =>
<SegmentationSection ref={segmentationRef} />

<div style={{ padding: '20px', borderTop: '2px solid #ddd', marginTop: '20px', background: '#f8f9fa' }}>
{/* Duplicate error banner near the Save button for better visibility */}
{error && (
<div style={{
padding: '10px 14px',
margin: '0 0 12px 0',
background: '#f8d7da',
color: '#721c24',
border: '1px solid #f5c6cb',
borderRadius: '4px',
}}>
<strong>Error:</strong> {error}
</div>
)}
<button
onClick={handleSaveAll}
disabled={saving}
Expand Down
59 changes: 33 additions & 26 deletions src/components/preferences/config/GeneralSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ const GeneralSection = forwardRef<any, {}>((_props, ref) => {
<strong>Name</strong>
<br />
<small style={{ color: '#666' }}>
Optional name for this project. (e.g., <code style={{ color: '#d63384' }}>cloud-segmentation</code>)
Optional name for this project. Example: <code style={{ color: '#d63384' }}>cloud-segmentation</code>
</small>
</td>
<td style={{ width: '300px', paddingRight: '20px' }}>
Expand Down Expand Up @@ -147,7 +147,7 @@ const GeneralSection = forwardRef<any, {}>((_props, ref) => {
<small style={{ color: '#666' }}>
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.
to 0.0.0.0 and adjusting your router or consulting with your network administrators accordingly.
</small>
</td>
<td style={{ paddingRight: '20px' }}>
Expand All @@ -169,23 +169,29 @@ const GeneralSection = forwardRef<any, {}>((_props, ref) => {
<td colSpan={2}>
<strong>Path *</strong>
<br />
<small style={{ color: '#666', display: 'block', marginTop: '4px', lineHeight: '1.5' }}>
The input path(s) to the images. Paths should use the placeholder{' '}
<code style={{ color: '#d63384' }}>{'{id}'}</code>, which will be
replaced by the unique id of the current image (see example below).
<strong>Note:</strong> In the UI, the path must be provided as a <code>dictionary</code>.
Legacy single-string paths are automatically converted when loading from JSON files.
IRIS can load standard image formats (like *png* or
*tif*), theoretically all kind of files that can be opened by GDAL/rasterio (like *geotiff* or *vrt*)
and numpy files (*.npy*). The arrays inside the numpy files should have the shape HxWxC.
If you used a single file path for images, it has been assigned key 'pictures' here.
<small style={{ color: '#666', display: 'block', marginTop: '4px', lineHeight: '1.5', textAlign: 'left' }}>
Provide input image paths using the placeholder <code style={{ color: '#d63384' }}>{'{id}'}</code>.
Use a dictionary to map single or multiple dataset identifiers or file types to their path templates.
<ul style={{ marginTop: '8px', paddingLeft: '18px', textAlign: 'left' }}>
<li style={{ marginBottom: '6px' }}>
<strong>Placeholder:</strong> Include <code style={{ color: '#d63384' }}>{'{id}'}</code> in
each path; it will be replaced by the current image id.
</li>
<li style={{ marginBottom: '6px' }}>
<strong>Dictionary keys:</strong> Use meaningful identifiers (e.g. <code>Sentinel2</code>)
as these are referenced to define Views (see Views panel).
</li>
<li style={{ marginBottom: '6px' }}>
<strong>Supported formats:</strong> common raster formats (png, tif, GeoTIFF, VRT) and
NumPy (*.npy*) arrays (HxWxC) are supported.
</li>
<li>
<strong>Legacy behaviour:</strong> If you provided a single string path in JSON, it will appear
Comment thread
loriab marked this conversation as resolved.
as the <code style={{ color: '#d63384' }}>"pictures"</code> entry after loading.
</li>
</ul>
</small>
<div style={{ marginBottom: '12px' }}>
<small style={{ color: '#666' }}>
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.
</small>
<pre
style={{
background: '#f5f5f5',
Expand All @@ -198,7 +204,7 @@ const GeneralSection = forwardRef<any, {}>((_props, ref) => {
>
{`"path": {
"Sentinel1": "images/{id}/S1.tif",
"Sentinel2": "images/S2-{id}.tif"
"Sentinel2": "images/S2-{id}.png"
}`}
</pre>
</div>
Expand Down Expand Up @@ -226,7 +232,7 @@ const GeneralSection = forwardRef<any, {}>((_props, ref) => {
<div style={{ display: 'flex', gap: '32px', maxWidth: '400px' }}>
<div style={{ flex: 1 }}>
<label style={{ display: 'block', marginBottom: '4px' }}>
<strong>Shape-1 *</strong>
<strong>Width *</strong>
</label>
<input
type="number"
Expand All @@ -238,7 +244,7 @@ const GeneralSection = forwardRef<any, {}>((_props, ref) => {
</div>
<div style={{ flex: 1 }}>
<label style={{ display: 'block', marginBottom: '4px' }}>
<strong>Shape-2 *</strong>
<strong>Height *</strong>
</label>
<input
type="number"
Expand All @@ -255,10 +261,10 @@ const GeneralSection = forwardRef<any, {}>((_props, ref) => {
<tr>
<td colSpan={2} style={{ paddingRight: '20px' }}>
<h4 style={{ marginTop: '20px', marginBottom: '8px' }}>Thumbnails</h4>
<small style={{ color: '#666', display: 'block', marginBottom: '12px' }}>
<small style={{ color: '#666', display: 'block', marginBottom: '12px', textAlign: 'left' }}>
Optional thumbnail files for the images. Path must contain a placeholder{' '}
<code style={{ color: '#d63384' }}>{'{id}'}</code>. If you cannot provide any thumbnail, just leave
it out or set it to <code style={{ color: '#d63384' }}>False</code>. Example:{' '}
<code style={{ color: '#d63384' }}>{'{id}'}</code>. If you cannot provide any thumbnail, uncheck to disable,
or set it to <code style={{ color: '#d63384' }}>False</code> in JSON. Example:{' '}
<code style={{ color: '#d63384' }}>thumbnails/{'{id}'}.png</code>
</small>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '8px' }}>
Expand Down Expand Up @@ -291,13 +297,13 @@ const GeneralSection = forwardRef<any, {}>((_props, ref) => {
<tr>
<td colSpan={2} style={{ paddingRight: '20px' }}>
<h4 style={{ marginTop: '20px', marginBottom: '8px' }}>Metadata</h4>
<small style={{ color: '#666', display: 'block', marginBottom: '12px', lineHeight: '1.5' }}>
<small style={{ color: '#666', display: 'block', marginBottom: '12px', lineHeight: '1.5', textAlign: 'left' }}>
Optional metadata for the images. Path must contain a placeholder{' '}
<code style={{ color: '#d63384' }}>{'{id}'}</code>. Metadata files can be in json, yaml or another
text file format. json and yaml files will be parsed and made available via the GUI. If the metadata
contains the key <code style={{ color: '#d63384' }}>location</code> 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 <code style={{ color: '#d63384' }}>false</code>. Example field:{' '}
(longitude and latitude), it can be used for a bingmap view. If you cannot provide any metadata, uncheck
to disable or set it to <code style={{ color: '#d63384' }}>false</code> in JSON. Example field:{' '}
<code style={{ color: '#d63384' }}>metadata/{'{id}'}.json</code>. Example file contents:
</small>
<pre
Expand All @@ -307,6 +313,7 @@ const GeneralSection = forwardRef<any, {}>((_props, ref) => {
borderRadius: '4px',
fontSize: '12px',
marginBottom: '12px',
textAlign: 'left',
}}
>
{`{
Expand Down
4 changes: 2 additions & 2 deletions src/components/preferences/config/PathListEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ const PathListEditor = forwardRef<any, {}>((_props, ref) => {
placeholder="optional key (e.g. Sentinel2)"
value={path.key}
onChange={(e) => updatePath(path.id, 'key', e.target.value)}
style={{ flex: '0 0 300px', padding: '6px' }}
style={{ flex: '0 0 30%', maxWidth: '30%', padding: '6px' }}
/>
<input
type="text"
Expand All @@ -126,7 +126,7 @@ const PathListEditor = forwardRef<any, {}>((_props, ref) => {
style={{ flex: 1, padding: '6px' }}
/>
</div>
<small style={{ display: 'block', color: '#666', marginLeft: '308px' }}>
<small style={{ display: 'block', color: '#666', marginLeft: 'calc(30% + 8px)' }}>
Full or relative path to set of image files. Must use "{'{id}'}" placeholder.
</small>
</div>
Expand Down
Loading