Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found
Select Git revision
  • 3D_UNet
  • 3d_watershed
  • conv_zarr_tiff_folders
  • convert_tiff_folders
  • layered_surface_segmentation
  • main
  • memmap_txrm
  • notebook_update
  • notebooks
  • notebooksv1
  • optimize_scaleZYXdask
  • save_files_function
  • scaleZYX_mean
  • test
  • threshold-exploration
  • tr_val_te_splits
  • v0.2.0
  • v0.3.0
  • v0.3.1
  • v0.3.2
  • v0.3.3
  • v0.3.9
  • v0.4.0
  • v0.4.1
24 results

Target

Select target project
  • QIM/tools/qim3d
1 result
Select Git revision
  • 3D_UNet
  • 3d_watershed
  • conv_zarr_tiff_folders
  • convert_tiff_folders
  • layered_surface_segmentation
  • main
  • memmap_txrm
  • notebook_update
  • notebooks
  • notebooksv1
  • optimize_scaleZYXdask
  • save_files_function
  • scaleZYX_mean
  • test
  • threshold-exploration
  • tr_val_te_splits
  • v0.2.0
  • v0.3.0
  • v0.3.1
  • v0.3.2
  • v0.3.3
  • v0.3.9
  • v0.4.0
  • v0.4.1
24 results
Show changes
Showing
with 2007 additions and 1226 deletions
# Data visualization
The `qim3d` library aims to provide easy ways to explore and get insights from volumetric data.
::: qim3d.viz
options:
members:
- slices
- slicer
- orthogonal
- vol
- local_thickness
- vectors
- plot_cc
- colormaps
- interactive_fade_mask
::: qim3d.viz.colormaps
options:
members:
- objects
- qim
site_name: qim3d documentation
site_url: https://platform.qim.dk/qim3d/
site_author: Qim3d contributors
site_description: Documentation for the Qim3d python library
repo_url: https://lab.compute.dtu.dk/QIM/tools/qim3d
repo_name: Gitlab
nav:
- qim3d: index.md
- Input & Output: io.md
- Data Generation: generate.md
- Processing: processing.md
- Visualization: viz.md
- GUIs: gui.md
- ML Models: models.md
- CLI: cli.md
- Release history: releases.md
theme:
language: en
name: material
nav_position: top
logo: assets/qim3d-icon.svg
favicon: assets/qim3d-icon.svg
features:
- navigation.tabs
- navigation.tabs.sticky
- navigation.sections
- navigation.expand
- navigation.instant
# - navigation.tracking
- toc.integrate
# - toc.follow
- search.suggest
# - content.tabs.link
# - content.code.annotation
- content.code.copy
- content.code.annotate
- header.autohide
palette:
primary: white
accent: orange
extra_css:
- stylesheets/extra.css
extra:
social:
- icon: fontawesome/brands/x-twitter
link: https://twitter.com/QIMCenter
markdown_extensions:
- pymdownx.highlight:
anchor_linenums: true
line_spans: __span
pygments_lang_class: true
- admonition
- attr_list
- md_in_html
- tables
- pymdownx.inlinehilite
- pymdownx.snippets
- pymdownx.details
- pymdownx.superfences
- pymdownx.tabbed:
alternate_style: true
- pymdownx.emoji:
emoji_index: !!python/name:material.extensions.emoji.twemoji
emoji_generator: !!python/name:material.extensions.emoji.to_svg
plugins:
- tags
- search
- mkdocstrings:
handlers:
python:
options:
show_root_heading: true
show_source: true
show_root_full_path: true
show_object_full_path: true
show_symbol_type_heading: true
show_symbol_type_toc: false
separate_signature: true
\ No newline at end of file
import qim3d.io as io
import qim3d.gui as gui
import qim3d.viz as viz
import qim3d.utils as utils
import qim3d.models as models
import logging
examples = io.ImgExamples()
downloader = io.Downloader()
"""qim3d: A Python package for 3D image processing and visualization.
The qim3d library is designed to make it easier to work with 3D imaging data in Python.
It offers a range of features, including data loading and manipulation,
image processing and filtering, visualization of 3D data, and analysis of imaging results.
Documentation available at https://platform.qim.dk/qim3d/
"""
__version__ = "0.4.1"
import importlib as _importlib
class _LazyLoader:
"""Lazy loader to load submodules only when they are accessed"""
def __init__(self, module_name):
self.module_name = module_name
self.module = None
def _load(self):
if self.module is None:
self.module = _importlib.import_module(self.module_name)
return self.module
def __getattr__(self, item):
module = self._load()
return getattr(module, item)
# List of submodules
_submodules = [
"examples",
"generate",
"gui",
"io",
"models",
"processing",
"tests",
"utils",
"viz",
"cli",
]
# Creating lazy loaders for each submodule
for submodule in _submodules:
globals()[submodule] = _LazyLoader(f"qim3d.{submodule}")
import argparse
import webbrowser
import outputformat as ouf
import qim3d
QIM_TITLE = ouf.rainbow(
f"\n _ _____ __ \n ____ _(_)___ ___ |__ /____/ / \n / __ `/ / __ `__ \ /_ </ __ / \n/ /_/ / / / / / / /__/ / /_/ / \n\__, /_/_/ /_/ /_/____/\__,_/ \n /_/ v{qim3d.__version__}\n\n",
return_str=True,
cmap="hot",
)
def parse_tuple(arg):
# Remove parentheses if they are included and split by comma
return tuple(map(int, arg.strip("()").split(",")))
def main():
parser = argparse.ArgumentParser(description="Qim3d command-line interface.")
subparsers = parser.add_subparsers(title="Subcommands", dest="subcommand")
# GUIs
gui_parser = subparsers.add_parser("gui", help="Graphical User Interfaces.")
gui_parser.add_argument(
"--data-explorer", action="store_true", help="Run data explorer."
)
gui_parser.add_argument("--iso3d", action="store_true", help="Run iso3d.")
gui_parser.add_argument(
"--annotation-tool", action="store_true", help="Run annotation tool."
)
gui_parser.add_argument(
"--local-thickness", action="store_true", help="Run local thickness tool."
)
gui_parser.add_argument("--host", default="0.0.0.0", help="Desired host.")
gui_parser.add_argument(
"--platform", action="store_true", help="Use QIM platform address"
)
gui_parser.add_argument(
"--no-browser", action="store_true", help="Do not launch browser."
)
# Viz
viz_parser = subparsers.add_parser("viz", help="Volumetric visualization.")
viz_parser.add_argument("filename", default=False, help="Path to the image file")
viz_parser.add_argument(
"--destination", default="k3d.html", help="Path to save html file."
)
viz_parser.add_argument(
"--no-browser", action="store_true", help="Do not launch browser."
)
# Preview
preview_parser = subparsers.add_parser(
"preview", help="Preview of the image in CLI"
)
preview_parser.add_argument(
"filename",
type=str,
metavar="FILENAME",
help="Path to image that will be displayed",
)
preview_parser.add_argument(
"--slice",
type=int,
metavar="S",
default=None,
help="Specifies which slice of the image will be displayed.\nDefaults to middle slice. If number exceeds number of slices, last slice will be displayed.",
)
preview_parser.add_argument(
"--axis",
type=int,
metavar="AX",
default=0,
help="Specifies from which axis will be the slice taken. Defaults to 0.",
)
preview_parser.add_argument(
"--resolution",
type=int,
metavar="RES",
default=80,
help="Resolution of displayed image. Defaults to 80.",
)
preview_parser.add_argument(
"--absolute_values",
action="store_false",
help="By default set the maximum value to be 255 so the contrast is strong. This turns it off.",
)
# File Convert
convert_parser = subparsers.add_parser(
"convert",
help="Convert files to different formats without loading the entire file into memory",
)
convert_parser.add_argument(
"input_path",
type=str,
metavar="Input path",
help="Path to image that will be converted",
)
convert_parser.add_argument(
"output_path",
type=str,
metavar="Output path",
help="Path to save converted image",
)
convert_parser.add_argument(
"--chunks",
type=parse_tuple,
metavar="Chunk shape",
default=(64, 64, 64),
help="Chunk size for the zarr file. Defaults to (64, 64, 64).",
)
args = parser.parse_args()
if args.subcommand == "gui":
arghost = args.host
inbrowser = not args.no_browser # Should automatically open in browser
interface = None
if args.data_explorer:
interface_class = qim3d.gui.data_explorer.Interface
elif args.iso3d:
interface_class = qim3d.gui.iso3d.Interface
elif args.annotation_tool:
interface_class = qim3d.gui.annotation_tool.Interface
elif args.local_thickness:
interface_class = qim3d.gui.local_thickness.Interface
else:
print(
"Please select a tool by choosing one of the following flags:\n\t--data-explorer\n\t--iso3d\n\t--annotation-tool\n\t--local-thickness"
)
return
interface = (
interface_class()
) # called here if we add another arguments to initialize
if args.platform:
interface.run_interface(host=arghost)
else:
interface.launch(inbrowser=inbrowser, force_light_mode=False)
elif args.subcommand == "viz":
# Load the data
print(f"Loading data from {args.filename}")
volume = qim3d.io.load(str(args.filename))
print(f"Done, volume shape: {volume.shape}")
# Make k3d plot
print("\nGenerating k3d plot...")
qim3d.viz.vol(volume, show=False, save=str(args.destination))
print(f"Done, plot available at <{args.destination}>")
if not args.no_browser:
print("Opening in default browser...")
webbrowser.open_new_tab(args.destination)
elif args.subcommand == "preview":
image = qim3d.io.load(args.filename)
qim3d.viz.image_preview(
image,
image_width=args.resolution,
axis=args.axis,
slice=args.slice,
relative_intensity=args.absolute_values,
)
elif args.subcommand == "convert":
qim3d.io.convert(args.input_path, args.output_path, chunk_shape=args.chunks)
elif args.subcommand is None:
print(QIM_TITLE)
welcome_text = (
"\nqim3d is a Python package for 3D image processing and visualization.\n"
f"For more information, please visit {ouf.c('https://platform.qim.dk/qim3d/', color='orange', return_str=True)}\n"
" \n"
"For more information on each subcommand, type 'qim3d <subcommand> --help'.\n"
)
print(welcome_text)
parser.print_help()
print("\n")
if __name__ == "__main__":
main()
/* When we created the annotation tool, gradio.ImageEditor did not work as it was supposed to. There was an argument height, width which
applied to the wrapper around it. Argument canvas_size worked only if you did not upload any image. If you did the canvas would resize
itself to the size of the image. This way we set the width. If you only set the parameter 'width' insted of 'min-width', it doesn'ŧ work.
Canceling set max-heigth (the important is necessary) will allow the canvas to be as high as needed according to the picture.*/
canvas{
min-width: 600px;
max-height: none !important; /* The '!important' has to be here*/
}
......@@ -58,14 +58,17 @@
--font: 'Source Sans Pro', 'ui-sans-serif', 'system-ui', sans-serif;
--font-mono: 'IBM Plex Mono', 'ui-monospace', 'Consolas', monospace;
--body-background-fill: var(--background-fill-primary);
--body-text-color: var(--neutral-800);
--body-text-color: var(--neutral-300);
/*Headers*/
--body-text-size: var(--text-md);
--body-text-weight: 400;
--embed-radius: var(--radius-lg);
--color-accent: var(--primary-500);
--color-accent-soft: var(--primary-50);
--background-fill-primary: white;
--background-fill-secondary: var(--neutral-50);
--background-fill-primary: var(--neutral-900);
/*File explorer background*/
--background-fill-secondary: var(--neutral-500);
/*Dropdown hover*/
--border-color-accent: var(--primary-300);
--border-color-primary: var(--neutral-200);
--link-text-color: var(--secondary-600);
......@@ -87,7 +90,8 @@
--block-label-border-color: var(--border-color-primary);
--block-label-border-width: 1px;
--block-label-shadow: var(--block-shadow);
--block-label-text-color: var(--neutral-500);
--block-label-text-color: var(--neutral-50);
/*the small labels in corners*/
--block-label-margin: 0;
--block-label-padding: var(--spacing-sm) var(--spacing-lg);
--block-label-radius: calc(var(--radius-lg) - 1px) 0 calc(var(--radius-lg) - 1px) 0;
......@@ -100,7 +104,7 @@
--block-title-background-fill: none;
--block-title-border-color: none;
--block-title-border-width: 0px;
--block-title-text-color: var(--neutral-500);
--block-title-text-color: var(--neutral-50);
--block-title-padding: 0;
--block-title-radius: none;
--block-title-text-size: var(--text-md);
......@@ -115,7 +119,7 @@
--section-header-text-weight: 400;
--border-color-accent-subdued: var(--primary-200);
--chatbot-code-background-color: var(--neutral-100);
--checkbox-background-color: var(--background-fill-primary);
--checkbox-background-color: white;
--checkbox-background-color-focus: var(--checkbox-background-color);
--checkbox-background-color-hover: var(--checkbox-background-color);
--checkbox-background-color-selected: var(--secondary-600);
......@@ -125,8 +129,10 @@
--checkbox-border-color-selected: var(--secondary-600);
--checkbox-border-radius: var(--radius-sm);
--checkbox-border-width: var(--input-border-width);
--checkbox-label-background-fill: linear-gradient(to top, var(--neutral-50), white);
--checkbox-label-background-fill-hover: linear-gradient(to top, var(--neutral-100), white);
--checkbox-label-background-fill: var(--background-fill-primary);
/*linear-gradient(to top, var(--neutral-50), white);*/
--checkbox-label-background-fill-hover: var(--neutral-500);
/*linear-gradient(to top, var(--neutral-100), white);*/
--checkbox-label-background-fill-selected: var(--checkbox-label-background-fill);
--checkbox-label-border-color: var(--border-color-primary);
--checkbox-label-border-color-hover: var(--checkbox-label-border-color);
......@@ -146,7 +152,7 @@
--error-border-width: 1px;
--error-text-color: #b91c1c;
--error-icon-color: #b91c1c;
--input-background-fill: white;
--input-background-fill: var(--background-fill-primary);
--input-background-fill-focus: var(--secondary-500);
--input-background-fill-hover: var(--input-background-fill);
--input-border-color: var(--border-color-primary);
......@@ -158,7 +164,7 @@
--input-radius: var(--radius-lg);
--input-shadow: 0 0 0 var(--shadow-spread) transparent, var(--shadow-inset);
--input-shadow-focus: 0 0 0 var(--shadow-spread) var(--secondary-50), var(--shadow-inset);
--input-text-size: var(--text-md);
--input-text-size: var(--text-lg);
--input-text-weight: 400;
--loader-color: var(--color-accent);
--prose-text-size: var(--text-md);
......@@ -203,6 +209,7 @@
--button-small-text-weight: 400;
--button-transition: none;
}
/* Up here is the override for dark mode */
/* Now comes our custom CSS */
......@@ -231,6 +238,7 @@ h2 {
.no-border {
border-width: 0px !important;
}
/* Hides Gradio footer */
footer {
visibility: hidden;
......@@ -343,7 +351,7 @@ input[type="range"]::-webkit-slider-thumb {
/* Buttons */
.btn-html {
position: sticky;
display:flex;
/* display: flex; */
justify-content: center;
background: white !important;
border-color: #6c757d !important;
......@@ -620,3 +628,60 @@ div.svelte-1frtwj3 {
.wrap.default {
visibility: hidden !important;
}
/* Annotation tool CSS */
.no-interpolation {
image-rendering: pixelated !important;
}
.annotation-tool {
image-rendering: pixelated !important;
}
.annotation-tool:not(.no-img) .source-wrap {
display: none !important;
}
.annotation-tool button[title="Clear canvas"] {
display: none !important;
}
/* For backward and forward buttons in Annotation tool CSS */
.annotation-tool .row-wrap {
justify-content: center !important;
}
.annotation-tool .controls-wrap {
justify-content: center !important;
top: -8px !important;
left: 0px !important;
}
.annotation-tool .controls-wrap .small {
width: 20px !important;
height: 20px !important;
}
.annotation-tool .controls-wrap .padded {
padding: 8px !important;
border: 0px !important;
background-color: rgba(255, 255, 255, 0.8);
}
/*controls gr.File in Annotation tool CSS */
.annotation-tool .download {
min-width: 4rem !important;
width: 10%;
white-space: nowrap;
text-align: right;
}
.annotation-tool .stage-wrap canvas {
min-width: 512px !important;
max-height: 100% !important;
}
\ No newline at end of file
""" Example images for testing and demonstration purposes. """
from pathlib import Path as _Path
from qim3d.utils.logger import log as _log
from qim3d.io import load as _load
# Save the original log level and set to ERROR
# to suppress the log messages during loading
_original_log_level = _log.level
_log.setLevel("ERROR")
# Load image examples
for _file_path in _Path(__file__).resolve().parent.glob("*.tif"):
globals().update({_file_path.stem: _load(_file_path, progress_bar=False)})
# Restore the original log level
_log.setLevel(_original_log_level)
from .blob_ import blob
from .collection_ import collection
\ No newline at end of file
import numpy as np
import scipy.ndimage
from noise import pnoise3
def blob(
base_shape: tuple = (128, 128, 128),
final_shape: tuple = (128, 128, 128),
noise_scale: float = 0.05,
order: int = 1,
gamma: int = 1.0,
max_value: int = 255,
threshold: float = 0.5,
smooth_borders: bool = False,
dtype: str = "uint8",
) -> np.ndarray:
"""
Generate a 3D volume with Perlin noise, spherical gradient, and optional scaling and gamma correction.
Args:
base_shape (tuple, optional): Shape of the initial volume to generate. Defaults to (128, 128, 128).
final_shape (tuple, optional): Desired shape of the final volume. Defaults to (128, 128, 128).
noise_scale (float, optional): Scale factor for Perlin noise. Defaults to 0.05.
order (int, optional): Order of the spline interpolation used in resizing. Defaults to 1.
gamma (float, optional): Gamma correction factor. Defaults to 1.0.
max_value (int, optional): Maximum value for the volume intensity. Defaults to 255.
threshold (float, optional): Threshold value for clipping low intensity values. Defaults to 0.5.
smooth_borders (bool, optional): Flag for automatic computation of the threshold value to ensure a blob with no straight edges. If True, the `threshold` parameter is ignored. Defaults to False.
dtype (str, optional): Desired data type of the output volume. Defaults to "uint8".
Returns:
synthetic_blob (numpy.ndarray): Generated 3D volume with specified parameters.
Raises:
TypeError: If `final_shape` is not a tuple or does not have three elements.
ValueError: If `dtype` is not a valid numpy number type.
Example:
```python
import qim3d
# Generate synthetic blob
synthetic_blob = qim3d.generate.blob(noise_scale = 0.05)
# Visualize slices
qim3d.viz.slices(synthetic_blob, vmin = 0, vmax = 255, n_slices = 15)
```
![synthetic_blob](assets/screenshots/synthetic_blob_slices.png)
```python
# Visualize 3D volume
qim3d.viz.vol(synthetic_blob)
```
<iframe src="https://platform.qim.dk/k3d/synthetic_blob.html" width="100%" height="500" frameborder="0"></iframe>
"""
if not isinstance(final_shape, tuple) or len(final_shape) != 3:
raise TypeError("Size must be a tuple of 3 dimensions")
if not np.issubdtype(dtype, np.number):
raise ValueError("Invalid data type")
# Initialize the 3D array for the shape
volume = np.empty((base_shape[0], base_shape[1], base_shape[2]), dtype=np.float32)
# Generate grid of coordinates
z, y, x = np.indices(base_shape)
# Calculate the distance from the center of the shape
center = np.array(base_shape) / 2
dist = np.sqrt((z - center[0])**2 +
(y - center[1])**2 +
(x - center[2])**2)
dist /= np.sqrt(3 * (center[0]**2))
# Generate Perlin noise and adjust the values based on the distance from the center
vectorized_pnoise3 = np.vectorize(pnoise3) # Vectorize pnoise3, since it only takes scalar input
noise = vectorized_pnoise3(z.flatten() * noise_scale,
y.flatten() * noise_scale,
x.flatten() * noise_scale
).reshape(base_shape)
volume = (1 + noise) * (1 - dist)
# Normalize
volume = (volume - np.min(volume)) / (np.max(volume) - np.min(volume))
# Gamma correction
volume = np.power(volume, gamma)
# Scale the volume to the maximum value
volume = volume * max_value
if smooth_borders:
# Maximum value among the six sides of the 3D volume
max_border_value = np.max([
np.max(volume[0, :, :]), np.max(volume[-1, :, :]),
np.max(volume[:, 0, :]), np.max(volume[:, -1, :]),
np.max(volume[:, :, 0]), np.max(volume[:, :, -1])
])
# Compute threshold such that there will be no straight cuts in the blob
threshold = max_border_value / max_value
# Clip the low values of the volume to create a coherent volume
volume[volume < threshold * max_value] = 0
# Clip high values
volume[volume > max_value] = max_value
# Scale up the volume of volume to size
volume = scipy.ndimage.zoom(
volume, np.array(final_shape) / np.array(base_shape), order=order
)
return volume.astype(dtype)
import numpy as np
import scipy.ndimage
from tqdm.notebook import tqdm
import qim3d.generate
from qim3d.utils.logger import log
def random_placement(
collection: np.ndarray,
blob: np.ndarray,
rng: np.random.Generator,
) -> tuple[np.ndarray, bool]:
"""
Place blob at random available position in collection.
Args:
collection (numpy.ndarray): 3D volume of the collection.
blob (numpy.ndarray): 3D volume of the blob.
rng (numpy.random.Generator): Random number generator.
Returns:
collection (numpy.ndarray): 3D volume of the collection with the blob placed.
placed (bool): Flag for placement success.
"""
# Find available (zero) elements in collection
available_z, available_y, available_x = np.where(collection == 0)
# Flag for placement success
placed = False
# Attempt counter
j = 1
while (not placed) and (j < 200_000):
# Select a random available position in collection
idx = rng.choice(len(available_z))
z, y, x = available_z[idx], available_y[idx], available_x[idx]
start = np.array([z, y, x]) # Start position of blob placement
end = start + np.array(blob.shape) # End position of blob placement
# Check if the blob fits in the selected region (without overlap)
if np.all(
collection[start[0] : end[0], start[1] : end[1], start[2] : end[2]] == 0
):
# Check if placement is within bounds (bool)
within_bounds = np.all(start >= 0) and np.all(
end <= np.array(collection.shape)
)
if within_bounds:
# Place blob
collection[start[0] : end[0], start[1] : end[1], start[2] : end[2]] = (
blob
)
placed = True
# Increment attempt counter
j += 1
return collection, placed
def specific_placement(
collection: np.ndarray,
blob: np.ndarray,
positions: list[tuple],
) -> tuple[np.ndarray, bool]:
"""
Place blob at one of the specified positions in the collection.
Args:
collection (numpy.ndarray): 3D volume of the collection.
blob (numpy.ndarray): 3D volume of the blob.
positions (list[tuple]): List of specified positions as (z, y, x) coordinates for the blobs.
Returns:
collection (numpy.ndarray): 3D volume of the collection with the blob placed.
placed (bool): Flag for placement success.
positions (list[tuple]): List of remaining positions to place blobs.
"""
# Flag for placement success
placed = False
for position in positions:
# Get coordinates of next position
z, y, x = position
# Place blob with center at specified position
start = (
np.array([z, y, x]) - np.array(blob.shape) // 2
) # Start position of blob placement
end = start + np.array(blob.shape) # End position of blob placement
# Check if the blob fits in the selected region (without overlap)
if np.all(
collection[start[0] : end[0], start[1] : end[1], start[2] : end[2]] == 0
):
# Check if placement is within bounds (bool)
within_bounds = np.all(start >= 0) and np.all(
end <= np.array(collection.shape)
)
if within_bounds:
# Place blob
collection[start[0] : end[0], start[1] : end[1], start[2] : end[2]] = (
blob
)
placed = True
# Remove position from list
positions.remove(position)
break
return collection, placed, positions
def collection(
collection_shape: tuple = (200, 200, 200),
num_objects: int = 15,
positions: list[tuple] = None,
min_shape: tuple = (40, 40, 40),
max_shape: tuple = (60, 60, 60),
object_shape_zoom: tuple = (1.0, 1.0, 1.0),
min_object_noise: float = 0.02,
max_object_noise: float = 0.05,
min_rotation_degrees: int = 0,
max_rotation_degrees: int = 360,
rotation_axes: list[tuple] = [(0, 1), (0, 2), (1, 2)],
min_gamma: float = 0.8,
max_gamma: float = 1.2,
min_high_value: int = 128,
max_high_value: int = 255,
min_threshold: float = 0.5,
max_threshold: float = 0.6,
smooth_borders: bool = False,
seed: int = 0,
verbose: bool = False,
) -> tuple[np.ndarray, object]:
"""
Generate a 3D volume of multiple synthetic objects using Perlin noise.
Args:
collection_shape (tuple, optional): Shape of the final collection volume to generate. Defaults to (200, 200, 200).
num_objects (int, optional): Number of synthetic objects to include in the collection. Defaults to 15.
positions (list[tuple], optional): List of specific positions as (z, y, x) coordinates for the objects. If not provided, they are placed randomly into the collection. Defaults to None.
min_shape (tuple, optional): Minimum shape of the objects. Defaults to (40, 40, 40).
max_shape (tuple, optional): Maximum shape of the objects. Defaults to (60, 60, 60).
object_shape_zoom (tuple, optional): Scaling factors for each dimension of each object. Defaults to (1.0, 1.0, 1.0).
min_object_noise (float, optional): Minimum scale factor for Perlin noise. Defaults to 0.02.
max_object_noise (float, optional): Maximum scale factor for Perlin noise. Defaults to 0.05.
min_rotation_degrees (int, optional): Minimum rotation angle in degrees. Defaults to 0.
max_rotation_degrees (int, optional): Maximum rotation angle in degrees. Defaults to 360.
rotation_axes (list[tuple], optional): List of axis pairs that will be randomly chosen to rotate around. Defaults to [(0, 1), (0, 2), (1, 2)].
min_gamma (float, optional): Minimum gamma correction factor. Defaults to 0.8.
max_gamma (float, optional): Maximum gamma correction factor. Defaults to 1.2.
min_high_value (int, optional): Minimum maximum value for the volume intensity. Defaults to 128.
max_high_value (int, optional): Maximum maximum value for the volume intensity. Defaults to 255.
min_threshold (float, optional): Minimum threshold value for clipping low intensity values. Defaults to 0.5.
max_threshold (float, optional): Maximum threshold value for clipping low intensity values. Defaults to 0.6.
smooth_borders (bool, optional): Flag for smoothing blob borders to avoid straight edges in the objects. If True, the `min_threshold` and `max_threshold` parameters are ignored. Defaults to False.
seed (int, optional): Seed for reproducibility. Defaults to 0.
verbose (bool, optional): Flag to enable verbose logging. Defaults to False.
Returns:
synthetic_collection (numpy.ndarray): 3D volume of the generated collection of synthetic objects with specified parameters.
labels (numpy.ndarray): Array with labels for each voxel, same shape as synthetic_collection.
Raises:
TypeError: If `collection_shape` is not 3D.
ValueError: If blob parameters are invalid.
Note:
- The function places objects without overlap.
- The function can either place objects at random positions in the collection (if `positions = None`) or at specific positions provided in the `positions` argument. If specific positions are provided, the number of blobs must match the number of positions (e.g. `num_objects = 2` with `positions = [(12, 8, 10), (24, 20, 18)]`).
- If not all `num_objects` can be placed, the function returns the `synthetic_collection` volume with as many blobs as possible in it, and logs an error.
- Labels for all objects are returned, even if they are not a sigle connected component.
Example:
```python
import qim3d
# Generate synthetic collection of blobs
num_objects = 15
synthetic_collection, labels = qim3d.generate.collection(num_objects = num_objects)
# Visualize synthetic collection
qim3d.viz.vol(synthetic_collection)
```
<iframe src="https://platform.qim.dk/k3d/synthetic_collection_default.html" width="100%" height="500" frameborder="0"></iframe>
```python
qim3d.viz.slicer(synthetic_collection)
```
![synthetic_collection](assets/screenshots/synthetic_collection_default.gif)
```python
# Visualize labels
cmap = qim3d.viz.colormaps.objects(nlabels=num_objects)
qim3d.viz.slicer(labels, cmap=cmap, vmax=num_objects)
```
![synthetic_collection](assets/screenshots/synthetic_collection_default_labels.gif)
Example:
```python
import qim3d
# Generate synthetic collection of dense blobs
synthetic_collection, labels = qim3d.generate.collection(
min_high_value = 255,
max_high_value = 255,
min_object_noise = 0.05,
max_object_noise = 0.05,
min_threshold = 0.99,
max_threshold = 0.99,
min_gamma = 0.02,
max_gamma = 0.02)
# Visualize synthetic collection
qim3d.viz.vol(synthetic_collection)
```
<iframe src="https://platform.qim.dk/k3d/synthetic_collection_dense.html" width="100%" height="500" frameborder="0"></iframe>
Example:
```python
import qim3d
# Generate synthetic collection of tubular structures
synthetic_collection, labels = qim3d.generate.collection(
num_objects=10,
collection_shape=(200,100,100),
min_shape = (190, 50, 50),
max_shape = (200, 60, 60),
object_shape_zoom = (1, 0.2, 0.2),
min_object_noise = 0.01,
max_object_noise = 0.02,
max_rotation_degrees=10,
min_threshold = 0.95,
max_threshold = 0.98,
min_gamma = 0.02,
max_gamma = 0.03
)
# Visualize synthetic collection
qim3d.viz.vol(synthetic_collection)
```
<iframe src="https://platform.qim.dk/k3d/synthetic_collection_tubular.html" width="100%" height="500" frameborder="0"></iframe>
"""
if verbose:
original_log_level = log.getEffectiveLevel()
log.setLevel("DEBUG")
# Check valid input types
if not isinstance(collection_shape, tuple) or len(collection_shape) != 3:
raise TypeError(
"Shape of collection must be a tuple with three dimensions (z, y, x)"
)
if len(min_shape) != len(max_shape):
raise ValueError("Object shapes must be tuples of the same length")
# if not isinstance(blob_shapes, list) or \
# len(blob_shapes) != 2 or len(blob_shapes[0]) != 3 or len(blob_shapes[1]) != 3:
# raise TypeError("Blob shapes must be a list of two tuples with three dimensions (z, y, x)")
if (positions is not None) and (len(positions) != num_objects):
raise ValueError(
"Number of objects must match number of positions, otherwise set positions = None"
)
# Set seed for random number generator
rng = np.random.default_rng(seed)
# Initialize the 3D array for the shape
collection_array = np.zeros(
(collection_shape[0], collection_shape[1], collection_shape[2]), dtype=np.uint8
)
labels = np.zeros_like(collection_array)
# Fill the 3D array with synthetic blobs
for i in tqdm(range(num_objects), desc="Objects placed"):
log.debug(f"\nObject #{i+1}")
# Sample from blob parameter ranges
if min_shape == max_shape:
blob_shape = min_shape
else:
blob_shape = tuple(
rng.integers(low=min_shape[i], high=max_shape[i]) for i in range(3)
)
log.debug(f"- Blob shape: {blob_shape}")
# Sample noise scale
noise_scale = rng.uniform(low=min_object_noise, high=max_object_noise)
log.debug(f"- Object noise scale: {noise_scale:.4f}")
gamma = rng.uniform(low=min_gamma, high=max_gamma)
log.debug(f"- Gamma correction: {gamma:.3f}")
if max_high_value > min_high_value:
max_value = rng.integers(low=min_high_value, high=max_high_value)
else:
max_value = min_high_value
log.debug(f"- Max value: {max_value}")
threshold = rng.uniform(low=min_threshold, high=max_threshold)
log.debug(f"- Threshold: {threshold:.3f}")
# Generate synthetic blob
blob = qim3d.generate.blob(
base_shape=blob_shape,
final_shape=tuple(l * r for l, r in zip(blob_shape, object_shape_zoom)),
noise_scale=noise_scale,
gamma=gamma,
max_value=max_value,
threshold=threshold,
smooth_borders=smooth_borders,
)
# Rotate object
if max_rotation_degrees > 0:
angle = rng.uniform(
low=min_rotation_degrees, high=max_rotation_degrees
) # Sample rotation angle
axes = rng.choice(rotation_axes) # Sample the two axes to rotate around
log.debug(f"- Rotation angle: {angle:.2f} at axes: {axes}")
blob = scipy.ndimage.rotate(blob, angle, axes, order=0)
# Place synthetic object into the collection
# If positions are specified, place blob at one of the specified positions
collection_before = collection_array.copy()
if positions:
collection_array, placed, positions = specific_placement(
collection_array, blob, positions
)
# Otherwise, place blob at a random available position
else:
collection_array, placed = random_placement(collection_array, blob, rng)
# Break if blob could not be placed
if not placed:
break
# Update labels
new_labels = np.where(collection_array != collection_before, i + 1, 0).astype(
labels.dtype
)
labels += new_labels
if not placed:
# Log error if not all num_objects could be placed (this line of code has to be here, otherwise it will interfere with tqdm progress bar)
log.error(
f"Object #{i+1} could not be placed in the collection, no space found. Collection contains {i}/{num_objects} objects."
)
if verbose:
log.setLevel(original_log_level)
return collection_array, labels
from fastapi import FastAPI
import qim3d.utils
from . import data_explorer
from . import iso3d
from . import local_thickness
from . import annotation_tool
from .qim_theme import QimTheme
def run_gradio_app(gradio_interface, host="0.0.0.0"):
import gradio as gr
import uvicorn
# Get port using the QIM API
port_dict = qim3d.utils.get_port_dict()
if "gradio_port" in port_dict:
port = port_dict["gradio_port"]
elif "port" in port_dict:
port = port_dict["port"]
else:
raise Exception("Port not specified from QIM API")
qim3d.utils.gradio_header(gradio_interface.title, port)
# Create FastAPI with mounted gradio interface
app = FastAPI()
path = f"/gui/{port_dict['username']}/{port}/"
app = gr.mount_gradio_app(app, gradio_interface, path=path)
# Full path
print(f"http://{host}:{port}{path}")
# Run the FastAPI server usign uvicorn
uvicorn.run(app, host=host, port=int(port))
import tifffile
import os
import numpy as np
import gradio as gr
from qim3d.io import load # load or DataLoader?
from qim3d.utils import internal_tools
class Interface:
def __init__(self):
self.verbose = False
self.title = "Annotation tool"
# self.plot_height = 768
self.height = 1024
# self.width = 960
self.max_masks = 3
self.mask_opacity = 0.5
self.cmy_hex = ["#00ffff", "#ff00ff", "#ffff00"] # Colors for max_masks>3?
"""
The GUI can be launched directly from the command line:
# CSS path
current_dir = os.path.dirname(os.path.abspath(__file__))
self.css_path = os.path.join(current_dir, "..", "css", "gradio.css")
```bash
qim3d gui --annotation-tool
```
def launch(self, img=None, **kwargs):
# Create gradio interfaces
self.interface = self.create_interface(img=img)
Or launched from a python script
# Set gradio verbose level
if self.verbose:
quiet = False
else:
quiet = True
```python
import qim3d
self.interface.launch(
quiet=quiet,
height=self.height,
# width=self.width,
show_tips=False,
**kwargs,
)
return
def get_result(self):
# Get the temporary files from gradio
temp_sets = self.interface.temp_file_sets
for temp_set in temp_sets:
if "mask" in str(temp_set):
# Get the list of the temporary files
temp_path_list = list(temp_set)
# Files are not in creation order,
# so we need to get find the latest
creation_time_list = []
for path in temp_path_list:
creation_time_list.append(os.path.getctime(path))
# Get index for the latest file
file_idx = np.argmax(creation_time_list)
# Load the temporary file
mask = load(temp_path_list[file_idx])
return mask
def create_interface(self, img=None):
with gr.Blocks(css=self.css_path) as gradio_interface:
masks_state = gr.State(value={})
counts = gr.Number(value=1, visible=False)
with gr.Row():
with gr.Column(scale=1, min_width=320):
upload_img_btn = gr.UploadButton(
label="Upload image",
file_types=["image"],
interactive=True if img is None else False,
)
clear_img_btn = gr.Button(
value="Clear image", interactive=False if img is None else True
)
vol = qim3d.examples.NT_128x128x128
annotation_tool = qim3d.gui.annotation_tool.Interface()
with gr.Row():
with gr.Column(scale=2, min_width=32):
selected_mask = gr.Radio(
choices=["Mask 1"],
value="Mask 1",
label="Choose which mask to draw",
scale=1,
)
with gr.Column(scale=1, min_width=64):
add_mask_btn = gr.Button(
value="Add mask",
scale=2,
)
with gr.Row():
prep_dl_btn = gr.Button(
value="Prepare mask for download",
visible=False if img is None else True,
)
with gr.Row():
save_output = gr.File(
show_label=True,
label="Output file",
visible=False,
)
# We can directly pass the image we loaded to the interface
app = annotation_tool.launch(vol[0])
```
![gui-annotation_tool](assets/screenshots/gui-annotation_tool.gif)
with gr.Column(scale=4):
with gr.Row():
input_img = gr.Image(
label="Input",
tool="sketch",
value=img,
height=600,
width=600,
brush_color="#00ffff",
mask_opacity=self.mask_opacity,
interactive=False if img is None else True,
)
"""
output_masks = []
for mask_idx in range(self.max_masks):
with gr.Row(): # make a new row for every mask
output_mask = gr.Image(
label=f"Mask {mask_idx+1}",
visible=True if mask_idx == 0 else False,
image_mode="L",
height=600,
width=600,
interactive=False
if img is None
else True, # If statement added bc of bug after Gradio 3.44.x
show_download_button=False,
)
output_masks.append(output_mask)
import getpass
import os
import tempfile
# Operations
operations = Operations(max_masks=self.max_masks, cmy_hex=self.cmy_hex)
import gradio as gr
import numpy as np
from PIL import Image
# Update component configuration when image is uploaded
upload_img_btn.upload(
fn=operations.upload_img_update,
inputs=upload_img_btn,
outputs=[input_img, clear_img_btn, upload_img_btn, prep_dl_btn]
+ output_masks,
)
from qim3d.io import load, save
from qim3d.processing.operations import overlay_rgb_images
from qim3d.gui.interface import BaseInterface
# Add mask below when 'add mask' button is clicked
add_mask_btn.click(
fn=operations.increment_mask,
inputs=counts,
outputs=[counts, selected_mask] + output_masks,
)
# TODO: img in launch should be self.img
# Draw mask when input image is edited
input_img.edit(
fn=operations.update_masks,
inputs=[input_img, selected_mask, masks_state, upload_img_btn],
outputs=output_masks,
)
# Update brush color according to radio setting
selected_mask.change(
fn=operations.update_brush_color,
inputs=selected_mask,
outputs=input_img,
class Interface(BaseInterface):
def __init__(self, name_suffix: str = "", verbose: bool = False, img=None):
super().__init__(
title="Annotation Tool",
height=768,
width="100%",
verbose=verbose,
custom_css="annotation_tool.css",
)
# Make file download visible
prep_dl_btn.click(
fn=operations.save_mask,
inputs=output_masks,
outputs=[save_output, save_output],
).success(
fn=lambda: os.remove('mask.tif')
) # Remove mask file from working directory immediately after sending it to /tmp/gradio
self.username = getpass.getuser()
self.temp_dir = os.path.join(tempfile.gettempdir(), f"qim-{self.username}")
self.name_suffix = name_suffix
self.img = img
# Update 'Add mask' button interactivit according to the current count
counts.change(
fn=operations.set_add_mask_btn_interactivity,
inputs=counts,
outputs=add_mask_btn,
)
self.n_masks = 3
self.img_editor = None
self.masks_rgb = None
self.temp_files = []
# Reset component configuration when image is cleared
clear_img_btn.click(
fn=operations.clear_img_update,
inputs=None,
outputs=[
selected_mask,
prep_dl_btn,
save_output,
counts,
input_img,
upload_img_btn,
clear_img_btn,
def get_result(self):
# Get the temporary files from gradio
temp_path_list = []
for filename in os.listdir(self.temp_dir):
if "mask" and self.name_suffix in str(filename):
# Get the list of the temporary files
temp_path_list.append(os.path.join(self.temp_dir, filename))
# Make dictionary of maks
masks = {}
for temp_file in temp_path_list:
mask_file = os.path.basename(temp_file)
mask_name = os.path.splitext(mask_file)[0]
masks[mask_name] = load(temp_file)
return masks
def clear_files(self):
"""
Should be moved up to __init__ function, but given how is this interface implemented in some files
this is safer and backwards compatible (should be)
"""
self.mask_names = [
f"red{self.name_suffix}",
f"green{self.name_suffix}",
f"blue{self.name_suffix}",
]
+ output_masks,
)
return gradio_interface
class Operations:
def __init__(self, max_masks, cmy_hex):
self.max_masks = max_masks
self.cmy_hex = cmy_hex
def update_masks(self, input_img, selected_mask, masks_state, file):
# Binarize mask (it is not per default due to anti-aliasing)
input_mask = input_img["mask"]
input_mask[input_mask > 0] = 255
# Clean up old files
try:
file_name = file.name
except AttributeError:
file_name = "nb_img"
# Add new file to state dictionary when this function sees it first time
if file_name not in masks_state.keys():
masks_state[file_name] = [[] for _ in range(self.max_masks)]
# Get index of currently selected and non-selected masks
sel_mask_idx = int(selected_mask[-1]) - 1
nonsel_mask_idxs = [
mask_idx
for mask_idx in list(range(self.max_masks))
if mask_idx != sel_mask_idx
]
# Add background to state first time function is invoked in current session
if len(masks_state[file_name][0]) == 0:
for i in range(len(masks_state[file_name])):
masks_state[file_name][i].append(input_mask)
# Check for discrepancy between what is drawn and what is shown as output masks
masks_state_combined = 0
for i in range(len(masks_state[file_name])):
masks_state_combined += masks_state[file_name][i][-1]
discrepancy = masks_state_combined != input_mask
if np.any(discrepancy): # Correct discrepancy in output masks
for i in range(self.max_masks):
masks_state[file_name][i][-1][discrepancy] = 0
# Add most recent change in input to currently selected mask
mask2append = input_mask
for mask_idx in nonsel_mask_idxs:
mask2append -= masks_state[file_name][mask_idx][-1]
masks_state[file_name][sel_mask_idx].append(mask2append)
return [masks_state[file_name][i][-1] for i in range(self.max_masks)]
def save_mask(self, *masks):
# Go from multi-channel to single-channel mask
stacked_masks = np.stack(masks, axis=-1)
final_mask = np.zeros_like(masks[0])
final_mask[np.where(stacked_masks == 255)[:2]] = (
np.where(stacked_masks == 255)[-1] + 1
)
# Save output image in a temp space (and to current directory which is a bug)
filename = "mask.tif"
tifffile.imwrite(filename, final_mask)
save_output_update = gr.File(visible=True)
return save_output_update, filename
def increment_mask(self, counts):
# increment count by 1
counts += 1
counts = int(counts)
counts_update = gr.Number(value=counts)
selected_mask_update = gr.Radio(
value=f"Mask {counts}", choices=[f"Mask {i+1}" for i in range(counts)]
files = os.listdir(self.temp_dir)
for filename in files:
# Check if "mask" is in the filename
if ("mask" in filename) and (self.name_suffix in filename):
file_path = os.path.join(self.temp_dir, filename)
os.remove(file_path)
except FileNotFoundError:
files = None
def create_preview(self, img_editor):
background = img_editor["background"]
masks = img_editor["layers"][0]
overlay_image = overlay_rgb_images(background, masks)
return overlay_image
def cerate_download_list(self, img_editor):
masks_rgb = img_editor["layers"][0]
mask_threshold = 200 # This value is based
mask_list = []
files_list = []
# Go through each channel
for idx in range(self.n_masks):
mask_grayscale = masks_rgb[:, :, idx]
mask = mask_grayscale > mask_threshold
# Save only if we have a mask
if np.sum(mask) > 0:
mask_list.append(mask)
filename = f"mask_{self.mask_names[idx]}.tif"
if not os.path.exists(self.temp_dir):
os.makedirs(self.temp_dir)
filepath = os.path.join(self.temp_dir, filename)
files_list.append(filepath)
save(filepath, mask, replace=True)
self.temp_files.append(filepath)
return files_list
def define_interface(self, **kwargs):
brush = gr.Brush(
colors=[
"rgb(255,50,100)",
"rgb(50,250,100)",
"rgb(50,100,255)",
],
color_mode="fixed",
default_size=10,
)
output_masks_update = [gr.Image(visible=True)] * counts + [
gr.Image(visible=False)
] * (self.max_masks - counts)
return [counts_update, selected_mask_update] + output_masks_update
def update_brush_color(self, selected_mask):
sel_mask_idx = int(selected_mask[-1]) - 1
if sel_mask_idx < len(self.cmy_hex):
input_img_update = gr.Image(brush_color=self.cmy_hex[sel_mask_idx])
else:
input_img_update = gr.Image(brush_color="#000000") # Return black brush
return input_img_update
with gr.Row():
with gr.Column(
scale=6,
):
img_editor = gr.ImageEditor(
value=(
{
"background": self.img,
"layers": [Image.new("RGBA", self.img.shape, (0, 0, 0, 0))],
"composite": None,
}
if self.img is not None
else None
),
type="numpy",
image_mode="RGB",
brush=brush,
sources="upload",
interactive=True,
show_download_button=True,
container=False,
transforms=["crop"],
layers=False,
)
with gr.Column(scale=1, min_width=256):
def set_add_mask_btn_interactivity(self, counts):
add_mask_btn_update = (
gr.Button(interactive=True)
if counts < self.max_masks
else gr.Button(interactive=False)
with gr.Row():
overlay_img = gr.Image(
show_download_button=False,
show_label=False,
visible=False,
)
return add_mask_btn_update
def clear_img_update(self):
selected_mask_update = gr.Radio(
choices=["Mask 1"], value="Mask 1"
) # Reset radio component to only show 'Mask 1'
prep_dl_btn_update = gr.Button(
visible=False
) # Make 'Prepare mask for download' button invisible
save_output_update = gr.File(visible=False) # Make File save box invisible
counts_update = gr.Number(value=1) # Reset invisible counter to 1
input_img_update = gr.Image(
value=None, interactive=False
) # Set input image component to non-interactive (so a new image cannot be uploaded directly in the component)
upload_img_btn_update = gr.Button(
interactive=True
) # Make 'Upload image' button interactive
clear_img_btn_update = gr.Button(
interactive=False
) # Make 'Clear image' button non-interactive
output_masks_update = [
gr.Image(value=None, visible=True if i == 0 else False, interactive=False)
for i in range(self.max_masks)
] # Remove drawn masks and set as invisible except mask 1. 'interactive=False' added bc of bug after Gradio 3.44.x
return [
selected_mask_update,
prep_dl_btn_update,
save_output_update,
counts_update,
input_img_update,
upload_img_btn_update,
clear_img_btn_update,
] + output_masks_update
def upload_img_update(self, file):
input_img_update = gr.Image(
value=load(file.name), interactive=True
) # Upload image from button to Image components
clear_img_btn_update = gr.Button(
interactive=True
) # Make 'Clear image' button interactive
upload_img_btn_update = gr.Button(
interactive=False
) # Make 'Upload image' button non-interactive
prep_dl_btn_update = gr.Button(
visible=True
) # Make 'Prepare mask for download' button visible
output_masks_update = [
gr.Image(interactive=True)
] * self.max_masks # This line is added bc of bug in Gradio after 3.44.x
return [
input_img_update,
clear_img_btn_update,
upload_img_btn_update,
prep_dl_btn_update,
] + output_masks_update
def run_interface(host = "0.0.0.0"):
gradio_interface = Interface().create_interface()
internal_tools.run_gradio_app(gradio_interface,host)
if __name__ == "__main__":
# Creates interface
run_interface()
\ No newline at end of file
with gr.Row():
masks_download = gr.File(label="Download masks", visible=False)
# fmt: off
img_editor.change(
fn = self.clear_files, inputs = None , outputs = None).then( # Prepares for handling the new update
fn = self.create_preview, inputs = img_editor, outputs = overlay_img).then( # Create the preview in top right corner
fn = self.set_visible, inputs = None, outputs = overlay_img).then( # Makes the preview visible
fn = self.cerate_download_list, inputs = img_editor, outputs = masks_download).then(# Separates the color mask and put them into file list
fn = self.set_visible, inputs = None, outputs = masks_download) # Displays the download file list
import gradio as gr
import numpy as np
import os
from qim3d.utils import internal_tools
from qim3d.io import load
from qim3d.io.logger import log
import tifffile
import outputformat as ouf
"""
The GUI can be launched directly from the command line:
```bash
qim3d gui --data-explorer
```
Or launched from a python script
```python
import qim3d
app = qim3d.gui.data_explorer.Interface()
app.launch()
```
"""
import datetime
import matplotlib
import os
import re
# matplotlib.use("Agg")
import gradio as gr
import matplotlib.pyplot as plt
import numpy as np
import outputformat as ouf
class Interface:
def __init__(self):
self.show_header = False
self.verbose = False
self.title = "Data Explorer"
self.height = 1024
self.width = 900
self.operations = [
from qim3d.io import load
from qim3d.utils.logger import log
from qim3d.utils import misc
from qim3d.gui.interface import BaseInterface
class Interface(BaseInterface):
def __init__(self,
verbose:bool = False,
figsize:int = 8,
display_saturation_percentile:int = 99,
nbins:int = 32):
"""
Parameters:
-----------
show_header (bool, optional): If true, prints starting info into terminal. Default is False
verbose (bool, optional): If true, prints info during session into terminal. Defualt is False.
figsize (int, optional): Sets the size of plots displaying the slices. Default is 8.
"""
super().__init__(
title = "Data Explorer",
height = 1024,
width = 900,
verbose = verbose
)
self.axis_dict = {"Z":0, "Y":1, "X":2}
self.all_operations = [
"Z Slicer",
"Y Slicer",
"X Slicer",
......@@ -28,61 +59,28 @@ class Interface:
"Z min projection",
"Intensity histogram",
"Data summary",
]
# CSS path
current_dir = os.path.dirname(os.path.abspath(__file__))
self.css_path = os.path.join(current_dir, "..", "css", "gradio.css")
def clear(self):
"""Used to reset outputs with the clear button"""
return None
def update_explorer(self, new_path):
new_path = os.path.expanduser(new_path)
# In case we have a directory
if os.path.isdir(new_path):
return gr.update(root=new_path, label=new_path)
elif os.path.isfile(new_path):
parent_dir = os.path.dirname(new_path)
file_name = str(os.path.basename(new_path))
return gr.update(root=parent_dir, label=parent_dir, value=file_name)
self.calculated_operations = [] # For changing the visibility of results, we keep track what was calculated and thus will be displayed
else:
raise ValueError("Invalid path")
def set_visible(self):
return gr.update(visible=True)
self.vol = None # The loaded volume
def set_spinner(self, message):
return gr.update(
elem_classes="btn btn-spinner",
value=f"{message}",
interactive=False,
)
def set_relaunch_button(self):
return gr.update(
elem_classes="btn btn-run",
value=f"Relaunch",
interactive=True,
)
# Plotting
self.figsize = figsize
def show_results(self, operations):
update_list = []
for operation in self.operations:
if operation in operations:
update_list.append(gr.update(visible=True))
else:
update_list.append(gr.update(visible=False))
return update_list
# Projections and histogram
self.min_percentile = None
self.min_percentile = None
self.display_saturation_percentile = display_saturation_percentile
self.nbins = nbins
self.projections_calculated = False
def create_interface(self):
with gr.Blocks(css=self.css_path) as gradio_interface:
gr.Markdown("# Data Explorer")
# Spinner state - what phase after clicking run button are we in
self.spinner_state = -1
self.spinner_messages = ["Starting session...", "Loading data...", "Running pipeline...", "Relaunch"]
# Error message that we want to show, for more details look inside function check error state
self.error_message = None
def define_interface(self, **kwargs):
# File selection and parameters
with gr.Row():
with gr.Column(scale=2):
......@@ -93,65 +91,89 @@ class Interface:
max_lines=1,
container=False,
label="Base path",
elem_classes="h-36",
value=os.getcwd(),
)
with gr.Column(scale=1, min_width=36):
reload_base_path = gr.Button(
value="", elem_classes="btn-html h-36"
value=""
)
explorer = gr.FileExplorer(
glob="{*/,}{*.*}",
root=os.getcwd(),
ignore_glob="*/.*", # ignores hidden files
root_dir=os.getcwd(),
label=os.getcwd(),
render=True,
file_count="single",
interactive=True,
elem_classes="h-256 hide-overflow",
height = 320,
)
with gr.Column(scale=1):
gr.Markdown("### Parameters")
virtual_stack = gr.Checkbox(value=False, label="Virtual stack")
cmap = gr.Dropdown(
value="viridis",
choices=plt.colormaps(),
label="Colormap",
interactive=True,
)
virtual_stack = gr.Checkbox(
value=False,
label="Virtual stack",
info="If checked, will use less memory by loading the images on demand.",
)
load_series = gr.Checkbox(
value=False,
label="Load series",
info="If checked, will load the whole series of images in the same folder as the selected file.",
)
series_contains = gr.Textbox(
label="Specify common part of file names for series",
value="",
visible=False,
)
dataset_name = gr.Textbox(
label="Dataset name (in case of H5 files, for example)",
value="exchange/data",
)
def toggle_show(checkbox):
return (
gr.update(visible=True)
if checkbox
else gr.update(visible=False)
)
# Show series_contains only if load_series is checked
load_series.change(toggle_show, load_series, series_contains)
with gr.Column(scale=1):
gr.Markdown("### Operations")
operations = gr.CheckboxGroup(
choices=self.operations,
value=[self.operations[0], self.operations[-1]],
choices=self.all_operations,
value=[self.all_operations[0], self.all_operations[-1]],
label=None,
container=False,
interactive=True,
)
with gr.Row():
btn_run = gr.Button(
value="Load & Run", elem_classes="btn btn-html btn-run"
value="Load & Run", variant = "primary",
)
# Visualization and results
with gr.Row(elem_classes="mt-64"):
with gr.Row():
# Z Slicer
with gr.Column(visible=False) as result_z_slicer:
zslice_plot = gr.Plot(label="Z slice", elem_classes="rounded")
zslice_plot = gr.Plot(label="Z slice")
zpos = gr.Slider(
minimum=0, maximum=1, value=0.5, step=0.01, label="Z position"
)
# Y Slicer
with gr.Column(visible=False) as result_y_slicer:
yslice_plot = gr.Plot(label="Y slice", elem_classes="rounded")
yslice_plot = gr.Plot(label="Y slice")
ypos = gr.Slider(
minimum=0, maximum=1, value=0.5, step=0.01, label="Y position"
......@@ -159,7 +181,7 @@ class Interface:
# X Slicer
with gr.Column(visible=False) as result_x_slicer:
xslice_plot = gr.Plot(label="X slice", elem_classes="rounded")
xslice_plot = gr.Plot(label="X slice")
xpos = gr.Slider(
minimum=0, maximum=1, value=0.5, step=0.01, label="X position"
......@@ -167,13 +189,13 @@ class Interface:
# Z Max projection
with gr.Column(visible=False) as result_z_max_projection:
max_projection_plot = gr.Plot(
label="Z max projection", elem_classes="rounded"
label="Z max projection",
)
# Z Min projection
with gr.Column(visible=False) as result_z_min_projection:
min_projection_plot = gr.Plot(
label="Z min projection", elem_classes="rounded"
label="Z min projection",
)
# Intensity histogram
......@@ -186,38 +208,18 @@ class Interface:
lines=24,
label=None,
show_label=False,
elem_classes="monospace-box",
value="Data summary",
)
### Gradio objects lists
session = gr.State([])
pipeline = Pipeline()
# Results
results = [
result_z_slicer,
result_y_slicer,
result_x_slicer,
result_z_max_projection,
result_z_min_projection,
result_intensity_histogram,
result_data_summary,
]
# Inputs
inputs = [
operations,
base_path,
explorer,
zpos,
ypos,
xpos,
cmap,
dataset_name,
virtual_stack,
]
# Outputs
outputs = [
####################################
# EVENT LISTENERS
###################################
pipeline_inputs = [operations, zpos, ypos, xpos, cmap]
pipeline_outputs = [
zslice_plot,
yslice_plot,
xslice_plot,
......@@ -225,415 +227,342 @@ class Interface:
min_projection_plot,
hist_plot,
data_summary,
]
results = [
result_z_slicer,
result_y_slicer,
result_x_slicer,
result_z_max_projection,
result_z_min_projection,
result_intensity_histogram,
result_data_summary,
]
### Listeners
spinner_session = gr.Text("Starting session...", visible=False)
spinner_loading = gr.Text("Loading data...", visible=False)
spinner_operations = gr.Text("Running pipeline...", visible=False)
# fmt: off
reload_base_path.click(fn=self.update_explorer,inputs=base_path, outputs=explorer)
btn_run.click(
fn=self.set_spinner, inputs=spinner_session, outputs=btn_run).then(
fn=self.start_session, inputs=inputs, outputs=session).then(
fn=self.set_spinner, inputs=spinner_loading, outputs=btn_run).then(
fn=pipeline.load_data, inputs=session, outputs=session).then(
fn=self.set_spinner, inputs=spinner_operations, outputs=btn_run).then(
fn=pipeline.run_pipeline, inputs=session, outputs=outputs).then(
fn=self.show_results, inputs=operations, outputs=results).then(
fn=self.set_relaunch_button, inputs=[], outputs=btn_run)
zpos.change(
fn=self.update_zpos, inputs=[session, zpos], outputs=[session, zslice_plot]).success(
fn=pipeline.create_zslice_fig, inputs=[], outputs=zslice_plot,show_progress="hidden")
ypos.change(
fn=self.update_ypos, inputs=[session, ypos], outputs=[session, yslice_plot]).success(
fn=pipeline.create_yslice_fig, inputs=[], outputs=yslice_plot,show_progress="hidden")
xpos.change(
fn=self.update_xpos, inputs=[session, xpos], outputs=[session, xslice_plot]).success(
fn=pipeline.create_xslice_fig, inputs=[], outputs=xslice_plot,show_progress="hidden")
# fmt: on
return gradio_interface
def start_session(self, *args):
# Starts a new session dictionary
session = Session()
session.all_operations = Interface().operations
session.operations = args[0]
session.base_path = args[1]
session.explorer = args[2]
session.zpos = args[3]
session.ypos = args[4]
session.xpos = args[5]
session.cmap = args[6]
session.dataset_name = args[7]
session.virtual_stack = args[8]
# Get the file path from the explorer or base path
if session.base_path and os.path.isfile(session.base_path):
session.file_path = session.base_path
elif session.explorer and os.path.isfile(session.explorer):
session.file_path = session.explorer
else:
raise ValueError("Invalid file path")
return session
fn=self.update_run_btn, inputs = [], outputs = btn_run).then(
fn=self.start_session, inputs = [load_series, series_contains, explorer, base_path], outputs = []).then(
fn=self.update_run_btn, inputs = [], outputs = btn_run).then(
fn=self.check_error_state, inputs = [], outputs = []).success(
fn=self.load_data, inputs= [virtual_stack, dataset_name, series_contains], outputs= []).then(
fn=self.update_run_btn, inputs = [], outputs = btn_run).then(
fn=self.check_error_state, inputs = [], outputs = []).success(
fn=self.run_operations, inputs = pipeline_inputs, outputs = pipeline_outputs).then(
fn=self.update_run_btn, inputs = [], outputs = btn_run).then(
fn=self.check_error_state, inputs = [], outputs = []).success(
fn=self.show_results, inputs = operations, outputs = results) # results are columns of images and other component, not just the components
"""
Gradio passes only the value to the function, not the whole component.
That means we have no information about what slider out of those 3 was
updated. This way, 3 different functions are created, one for each slider.
The self.update_slice_wrapper returns a function.
"""
sliders = [xpos, ypos, zpos]
letters = ["X", "Y", "Z"]
plots = [xslice_plot, yslice_plot, zslice_plot]
for slider, letter, plot in zip(sliders, letters, plots):
slider.change(fn = self.update_slice_wrapper(letter), inputs = [slider, cmap], outputs = plot, show_progress="hidden")
# Immediate change without the need of pressing the relaunch button
operations.change(fn=self.show_results, inputs = operations, outputs = results)
cmap.change(fn=self.run_operations, inputs = pipeline_inputs, outputs = pipeline_outputs)
def update_zpos(self, session, zpos):
session.zpos = zpos
session.zslice_from_zpos()
return session, gr.update(label=f"Z slice: {session.zslice}")
def update_ypos(self, session, ypos):
session.ypos = ypos
session.yslice_from_ypos()
return session, gr.update(label=f"Y slice: {session.yslice}")
def update_xpos(self, session, xpos):
session.xpos = xpos
session.xslice_from_xpos()
return session, gr.update(label=f"X slice: {session.xslice}")
def update_explorer(self, new_path):
new_path = os.path.expanduser(new_path)
def launch(self, **kwargs):
# Show header
if self.show_header:
internal_tools.gradio_header(self.title, self.port)
# In case we have a directory
if os.path.isdir(new_path):
return gr.update(root_dir=new_path, label=new_path)
# Create gradio interfaces
interface = self.create_interface()
elif os.path.isfile(new_path):
parent_dir = os.path.dirname(new_path)
file_name = str(os.path.basename(new_path))
return gr.update(root_dir=parent_dir, label=parent_dir, value=file_name)
# Set gradio verbose level
if self.verbose:
quiet = False
else:
quiet = True
raise ValueError("Invalid path")
interface.launch(
quiet=quiet,
height=self.height,
width=self.width,
**kwargs,
def update_run_btn(self):
"""
When run_btn is clicked, it becomes uninteractive and displays which operation is now in progress
When all operations are done, it becomes interactive again with 'Relaunch' label
"""
self.spinner_state = (self.spinner_state + 1) % len(self.spinner_messages) if self.error_message is None else len(self.spinner_messages) - 1
message = self.spinner_messages[self.spinner_state]
interactive = (self.spinner_state == len(self.spinner_messages) - 1)
return gr.update(
value=f"{message}",
interactive=interactive,
)
class Session:
def __init__(self):
self.virtual_stack = False
self.file_path = None
self.vol = None
self.zpos = 0.5
self.ypos = 0.5
self.xpos = 0.5
self.cmap = "viridis"
self.dataset_name = None
def check_error_state(self):
"""
Raising gr.Error doesn't allow us to return anything and thus we can not update the Run button with
progress messages. We have to first update the button and then raise an Error so the button is interactive
"""
if self.error_message is not None:
error_message = self.error_message
self.error_message = None
self.file_path = None
self.max_projection = None
self.min_projection = None
self.projections_calculated = False
# Volume info
self.zsize = None
self.ysize = None
self.xsize = None
self.data_type = None
self.axes = None
self.last_modified = None
self.file_size = None
self.min_percentile = None
self.max_percentile = None
self.min_value = None
self.max_value = None
self.intensity_sum = None
self.mean_intensity = None
raise gr.Error(error_message)
# Histogram
self.nbins = 32
#######################################################
#
# THE PIPELINE
#
#######################################################
def zslice_from_zpos(self):
self.zslice = int(self.zpos * (self.zsize - 1))
def start_session(self, load_series:bool, series_contains:str, explorer:str, base_path:str):
self.projections_calculated = False # Probably new file was loaded, we would need new projections
return self.zslice
if load_series and series_contains == "":
# Try to guess the common part of the file names
try:
filename = explorer.split("/")[-1] # Extract filename from path
series_contains = re.search(r"[^0-9]+", filename).group()
gr.Info(f"Using '{series_contains}' as common file name part for loading.")
self.series_contains = series_contains
except:
self.error_message = "For series, common part of file name must be provided in 'series_contains' field."
def yslice_from_ypos(self):
self.yslice = int(self.ypos * (self.ysize - 1))
return self.yslice
# Get the file path from the explorer or base path
# priority is given to the explorer if file is selected
# else the base path is used
if explorer and (os.path.isfile(explorer) or load_series):
self.file_path = explorer
def xslice_from_xpos(self):
self.xslice = int(self.xpos * (self.xsize - 1))
elif base_path and (os.path.isfile(base_path) or load_series):
self.file_path = base_path
return self.xslice
else:
self.error_message = "Invalid file path"
# If we are loading a series, we need to get the directory
if load_series:
self.file_path = os.path.dirname(self.file_path)
class Pipeline:
def __init__(self):
self.figsize = 8 # Used for matplotlig figure size
self.display_saturation_percentile = 99
self.verbose = False
self.session = None
def load_data(self, session):
def load_data(self, virtual_stack:bool, dataset_name:str, contains:str):
try:
session.vol = load(
session.file_path,
virtual_stack=session.virtual_stack,
dataset_name=session.dataset_name,
self.vol = load(
path = self.file_path,
virtual_stack = virtual_stack,
dataset_name = dataset_name,
contains = contains
)
except Exception as error_message:
raise ValueError(
f"Failed to load the image: {error_message}"
) from error_message
session = self.get_data_info(session)
return session
def get_data_info(self, session):
first_slice = session.vol[0]
# Incase the data is 4D (RGB for example), we take the mean of the last dimension
if self.vol.ndim == 4:
self.vol = np.mean(self.vol, axis=-1)
# Get info
session.zsize = len(session.vol)
session.ysize, session.xsize = first_slice.shape
session.data_type = str(first_slice.dtype)
session.last_modified = datetime.datetime.fromtimestamp(
os.path.getmtime(session.file_path)
).strftime("%Y-%m-%d %H:%M")
session.file_size = os.path.getsize(session.file_path)
# The rest of the pipeline expects 3D data
if self.vol.ndim != 3:
self.error_message = F"Invalid data shape should be 3 dimensional, not shape: {self.vol.shape}"
return session
except Exception as error_message:
self.error_message = F"Error when loading data: {error_message}"
def run_pipeline(self, session):
self.session = session
def run_operations(self, operations, *args):
outputs = []
log.info(session.all_operations)
for operation in session.all_operations:
if operation in session.operations:
outputs.append(self.run_operation(operation))
self.calculated_operations = []
for operation in self.all_operations:
if operation in operations:
log.info(f"Running {operation}")
try:
outputs.append(self.run_operation(operation, *args))
self.calculated_operations.append(operation)
except Exception as err:
self.error_message = F"Error while running operation '{operation}': {err}"
log.info(self.error_message)
outputs.append(gr.update())
else:
log.info(f"Skipping {operation}")
outputs.append(None)
outputs.append(gr.update())
return outputs
def run_operation(self, operation):
log.info(f"Running {operation}")
if operation == "Data summary":
return self.show_data_summary()
if operation == "Z Slicer":
return self.create_zslice_fig()
if operation == "Y Slicer":
return self.create_yslice_fig()
if operation == "X Slicer":
return self.create_xslice_fig()
if operation == "Z max projection":
def run_operation(self, operation:list, zpos:float, ypos:float, xpos:float, cmap:str, *args):
match operation:
case "Z Slicer":
return self.update_slice_wrapper("Z")(zpos, cmap)
case "Y Slicer":
return self.update_slice_wrapper("Y")(ypos, cmap)
case "X Slicer":
return self.update_slice_wrapper("X")(xpos, cmap)
case "Z max projection":
return self.create_projections_figs()[0]
if operation == "Z min projection":
case "Z min projection":
return self.create_projections_figs()[1]
case "Intensity histogram":
# If the operations are run with the run_button, spinner_state == 2,
# If we just changed cmap, spinner state would be 3
# and we don't have to calculate histogram again
# That saves a lot of time as the histogram takes the most time to calculate
return self.plot_histogram() if self.spinner_state == 2 else gr.update()
case "Data summary":
return self.show_data_summary()
case _:
raise NotImplementedError(F"Operation '{operation} is not defined")
if operation == "Intensity histogram":
return self.plot_vol_histogram()
# In case nothing was triggered, raise error
raise ValueError("Unknown operation")
def show_data_summary(self):
# Get info from Tiff file
summary_dict = {
"Last modified": self.session.last_modified,
"File size": internal_tools.sizeof(self.session.file_size),
"Z-size": str(self.session.zsize),
"Y-size": str(self.session.ysize),
"X-size": str(self.session.xsize),
"Data type": self.session.data_type,
"Min value": self.session.min_value,
"Mean value": self.session.mean_intensity,
"Max value": self.session.max_value,
}
display_dict = {k: v for k, v in summary_dict.items() if v is not None}
return ouf.showdict(display_dict, return_str=True, title="Data summary")
def show_results(self, operations):
update_list = []
for operation in self.all_operations:
if operation in operations and operation in self.calculated_operations:
update_list.append(gr.update(visible=True))
else:
update_list.append(gr.update(visible=False))
return update_list
def create_zslice_fig(self):
slice_fig = self.create_slice_fig("z")
#######################################################
#
# CALCULATION OF IMAGES
#
#######################################################
return slice_fig
def create_img_fig(self, img, **kwargs):
fig, ax = plt.subplots(figsize=(self.figsize, self.figsize))
def create_yslice_fig(self):
slice_fig = self.create_slice_fig("y")
ax.imshow(img, interpolation="nearest", **kwargs)
return slice_fig
# Adjustments
ax.axis("off")
fig.subplots_adjust(left=0, right=1, bottom=0, top=1)
def create_xslice_fig(self):
slice_fig = self.create_slice_fig("x")
return fig
return slice_fig
def update_slice_wrapper(self, letter):
def update_slice(position_slider:float, cmap:str):
"""
position_slider: float from gradio slider, saying which relative slice we want to see
cmap: string gradio drop down menu, saying what cmap we want to use for display
"""
axis = self.axis_dict[letter]
slice_index = int(position_slider * (self.vol.shape[axis] - 1))
def create_slice_fig(self, axis):
plt.close()
vol = self.session.vol
plt.set_cmap(self.session.cmap)
zslice = self.session.zslice_from_zpos()
yslice = self.session.yslice_from_ypos()
xslice = self.session.xslice_from_xpos()
plt.set_cmap(cmap)
# Check if we something to use as vmin and vmax
if self.session.min_percentile and self.session.max_percentile:
vmin = self.session.min_percentile
vmax = self.session.max_percentile
if self.min_percentile and self.max_percentile:
vmin = self.min_percentile
vmax = self.max_percentile
else:
vmin = None
vmax = None
if axis == "z":
slice_fig = self._zslice_fig(vol, zslice, vmin=vmin, vmax=vmax)
if axis == "y":
slice_fig = self._yslice_fig(vol, yslice, vmin=vmin, vmax=vmax)
if axis == "x":
slice_fig = self._xslice_fig(vol, xslice, vmin=vmin, vmax=vmax)
# The axis we want to slice along is moved to be the last one, could also be the first one, it doesn't matter
# Then we take out the slice defined in self.position for this axis
slice_img = np.moveaxis(self.vol, axis, -1)[:,:,slice_index]
return slice_fig
fig_img = self.create_img_fig(slice_img, vmin = vmin, vmax = vmax)
def _zslice_fig(self, vol, slice, **kwargs):
fig = self.create_img_fig(vol[slice, :, :], **kwargs)
return gr.update(value = fig_img, label = f"{letter} Slice: {slice_index}", visible = True)
return update_slice
return fig
def vol_histogram(self, nbins, min_value, max_value):
# Start histogram
vol_hist = np.zeros(nbins)
def _yslice_fig(self, vol, slice, **kwargs):
fig = self.create_img_fig(vol[:, slice, :], **kwargs)
# Iterate over slices
for zslice in self.vol:
hist, bin_edges = np.histogram(
zslice, bins=nbins, range=(min_value, max_value)
)
vol_hist += hist
return fig
return vol_hist, bin_edges
def _xslice_fig(self, vol, slice, **kwargs):
fig = self.create_img_fig(vol[:, :, slice], **kwargs)
def plot_histogram(self):
# The Histogram needs results from the projections
if not self.projections_calculated:
_ = self.get_projections()
return fig
vol_hist, bin_edges = self.vol_histogram(self.nbins, self.min_value, self.max_value)
def create_img_fig(self, img, **kwargs):
fig, ax = plt.subplots(figsize=(self.figsize, self.figsize))
fig, ax = plt.subplots(figsize=(6, 4))
ax.imshow(img, interpolation="nearest", **kwargs)
ax.bar(bin_edges[:-1], vol_hist, width=np.diff(bin_edges), ec="white", align="edge")
# Adjustments
ax.axis("off")
fig.subplots_adjust(left=0, right=1, bottom=0, top=1)
ax.spines["right"].set_visible(False)
ax.spines["top"].set_visible(False)
ax.spines["left"].set_visible(True)
ax.spines["bottom"].set_visible(True)
ax.set_yscale("log")
return fig
def create_projections_figs(self):
vol = self.session.vol
if not self.session.projections_calculated:
projections = self.get_projections(vol)
self.session.max_projection = projections[0]
self.session.min_projection = projections[1]
if not self.projections_calculated:
projections = self.get_projections()
self.max_projection = projections[0]
self.min_projection = projections[1]
# Generate figures
max_projection_fig = self.create_img_fig(
self.session.max_projection,
vmin=self.session.min_percentile,
vmax=self.session.max_percentile,
self.max_projection,
vmin=self.min_percentile,
vmax=self.max_percentile,
)
min_projection_fig = self.create_img_fig(
self.session.min_projection,
vmin=self.session.min_percentile,
vmax=self.session.max_percentile,
self.min_projection,
vmin=self.min_percentile,
vmax=self.max_percentile,
)
self.session.projections_calculated = True
self.projections_calculated = True
return max_projection_fig, min_projection_fig
def get_projections(self, vol):
def get_projections(self):
# Create arrays for iteration
max_projection = np.zeros(np.shape(vol[0]))
min_projection = np.ones(np.shape(vol[0])) * float("inf")
max_projection = np.zeros(np.shape(self.vol[0]))
min_projection = np.ones(np.shape(self.vol[0])) * float("inf")
intensity_sum = 0
# Iterate over slices. This is needed in case of virtual stacks.
for zslice in vol:
for zslice in self.vol:
max_projection = np.maximum(max_projection, zslice)
min_projection = np.minimum(min_projection, zslice)
intensity_sum += np.sum(zslice)
self.session.min_value = np.min(min_projection)
self.session.min_percentile = np.percentile(
self.min_value = np.min(min_projection)
self.min_percentile = np.percentile(
min_projection, 100 - self.display_saturation_percentile
)
self.session.max_value = np.max(max_projection)
self.session.max_percentile = np.percentile(
self.max_value = np.max(max_projection)
self.max_percentile = np.percentile(
max_projection, self.display_saturation_percentile
)
self.session.intensity_sum = intensity_sum
self.intensity_sum = intensity_sum
nvoxels = self.session.zsize * self.session.ysize * self.session.xsize
self.session.mean_intensity = intensity_sum / nvoxels
nvoxels = self.vol.shape[0] * self.vol.shape[1] * self.vol.shape[2]
self.mean_intensity = intensity_sum / nvoxels
return max_projection, min_projection
def plot_vol_histogram(self):
# The Histogram needs results from the projections
if not self.session.projections_calculated:
_ = self.get_projections(self.session.vol)
vol_hist, bin_edges = self.vol_histogram(
self.session.vol, self.session.nbins, self.session.min_value, self.session.max_value
)
fig, ax = plt.subplots(figsize=(6, 4))
ax.bar(
bin_edges[:-1], vol_hist, width=np.diff(bin_edges), ec="white", align="edge"
)
# Adjustments
ax.spines["right"].set_visible(False)
ax.spines["top"].set_visible(False)
ax.spines["left"].set_visible(True)
ax.spines["bottom"].set_visible(True)
ax.set_yscale("log")
# fig.subplots_adjust(left=0, right=1, bottom=0, top=1)
return fig
def vol_histogram(self, vol, nbins, min_value, max_value):
# Start histogram
vol_hist = np.zeros(nbins)
# Iterate over slices
for zslice in vol:
hist, bin_edges = np.histogram(
zslice, bins=nbins, range=(min_value, max_value)
)
vol_hist += hist
return vol_hist, bin_edges
def show_data_summary(self):
summary_dict = {
"Last modified": datetime.datetime.fromtimestamp(os.path.getmtime(self.file_path)).strftime("%Y-%m-%d %H:%M"),
"File size": misc.sizeof(os.path.getsize(self.file_path)),
"Z-size": str(self.vol.shape[self.axis_dict["Z"]]),
"Y-size": str(self.vol.shape[self.axis_dict["Y"]]),
"X-size": str(self.vol.shape[self.axis_dict["X"]]),
"Data type": str(self.vol.dtype),
"Min value": str(self.vol.min()),
"Mean value": str(np.mean(self.vol)),
"Max value": str(self.vol.max()),
}
def run_interface(host = "0.0.0.0"):
gradio_interface = Interface().create_interface()
internal_tools.run_gradio_app(gradio_interface,host)
display_dict = {k: v for k, v in summary_dict.items() if v is not None}
return ouf.showdict(display_dict, return_str=True, title="Data summary")
if __name__ == "__main__":
# Creates interface
run_interface()
\ No newline at end of file
Interface().run_interface()
from pathlib import Path
from abc import abstractmethod, ABC
from os import path, listdir
import gradio as gr
from .qim_theme import QimTheme
import qim3d.gui
# TODO: when offline it throws an error in cli
class BaseInterface(ABC):
"""
Annotation tool and Data explorer as those don't need any examples.
"""
def __init__(
self,
title: str,
height: int,
width: int = "100%",
verbose: bool = False,
custom_css: str = None,
):
"""
title: Is displayed in tab
height, width: If inline in launch method is True, sets the paramters of the widget. Inline defaults to True in py notebooks, otherwise is False
verbose: If True, updates are printed into terminal
custom_css: Only the name of the file in the css folder.
"""
self.title = title
self.height = height
self.width = width
self.verbose = bool(verbose)
self.interface = None
self.qim_dir = Path(qim3d.__file__).parents[0]
self.custom_css = (
path.join(self.qim_dir, "css", custom_css)
if custom_css is not None
else None
)
def set_visible(self):
return gr.update(visible=True)
def set_invisible(self):
return gr.update(visible=False)
def launch(self, img=None, force_light_mode: bool = True, **kwargs):
"""
img: If None, user can upload image after the interface is launched.
If defined, the interface will be launched with the image already there
This argument is used especially in jupyter notebooks, where you can launch
interface in loop with different picture every step
force_light_mode: The qim platform doesn't have night mode. The qim_theme thus
has option to display only light mode so it corresponds with the website. Preferably
will be removed as we add night mode to the website.
"""
# Create gradio interface
if img is not None:
self.img = img
self.interface = self.create_interface(force_light_mode=force_light_mode)
self.interface.launch(
quiet=not self.verbose,
height=self.height,
width=self.width,
favicon_path=Path(qim3d.__file__).parents[0]
/ "../docs/assets/qim3d-icon.svg",
**kwargs,
)
def clear(self):
"""Used to reset outputs with the clear button"""
return None
def create_interface(self, force_light_mode: bool = True, **kwargs):
# kwargs["img"] = self.img
with gr.Blocks(
theme=qim3d.gui.QimTheme(force_light_mode=force_light_mode),
title=self.title,
css=self.custom_css,
) as gradio_interface:
gr.Markdown(f"# {self.title}")
self.define_interface(**kwargs)
return gradio_interface
@abstractmethod
def define_interface(self, **kwargs):
pass
def run_interface(self, host: str = "0.0.0.0"):
qim3d.gui.run_gradio_app(self.create_interface(), host)
class InterfaceWithExamples(BaseInterface):
"""
For Iso3D and Local Thickness
"""
def __init__(
self,
title: str,
height: int,
width: int,
verbose: bool = False,
custom_css: str = None,
):
super().__init__(title, height, width, verbose, custom_css)
self._set_examples_list()
def _set_examples_list(self):
valid_sufixes = (".tif", ".tiff", ".h5", ".nii", ".gz", ".dcm", ".DCM", ".vol", ".vgi", ".txrm", ".txm", ".xrm")
examples_folder = path.join(self.qim_dir, 'examples')
self.img_examples = [path.join(examples_folder, example) for example in listdir(examples_folder) if example.endswith(valid_sufixes)]
"""
The GUI can be launched directly from the command line:
```bash
qim3d gui --iso3d
```
Or launched from a python script
```python
import qim3d
app = qim3d.gui.iso3d.Interface()
app.launch()
```
"""
import os
import gradio as gr
import numpy as np
import os
from qim3d.utils import internal_tools
from qim3d.io import DataLoader
from qim3d.io.logger import log
import plotly.graph_objects as go
from scipy import ndimage
from qim3d.io import load
from qim3d.utils.logger import log
class Interface:
def __init__(self):
self.show_header = False
self.verbose = False
self.title = "Isosurfaces for 3D visualization"
self.interface = None
self.plot_height = 768
self.height = 1024
self.width = 960
# Data examples
current_dir = os.path.dirname(os.path.abspath(__file__))
examples_dir = ["..", "img_examples"]
examples = [
"blobs_256x256x256.tif",
"fly_150x256x256.tif",
"cement_128x128x128.tif",
"NT_10x200x100.tif",
"NT_128x128x128.tif",
"shell_225x128x128.tif",
"bone_128x128x128.tif",
]
self.img_examples = []
for example in examples:
self.img_examples.append(
[os.path.join(current_dir, *examples_dir, example)]
)
from qim3d.gui.interface import InterfaceWithExamples
# CSS path
self.css_path = os.path.join(current_dir, "..", "css", "gradio.css")
def clear(self):
"""Used to reset the plot with the clear button"""
return None
#TODO img in launch should be self.img
class Interface(InterfaceWithExamples):
def __init__(self,
verbose:bool = False,
plot_height:int = 768,
img = None):
def load_data(self, filepath):
# TODO: Add support for multiple files
self.vol = DataLoader().load_tiff(filepath)
super().__init__(title = "Isosurfaces for 3D visualization",
height = 1024,
width = 960,
verbose = verbose)
def resize_vol(self):
self.interface = None
self.img = img
self.plot_height = plot_height
def load_data(self, gradiofile):
try:
self.vol = load(gradiofile.name)
assert self.vol.ndim == 3
except AttributeError:
raise gr.Error("You have to select a file")
except ValueError:
raise gr.Error("Unsupported file format")
except AssertionError:
raise gr.Error(F"File has to be 3D structure. Your structure has {self.vol.ndim} dimension{'' if self.vol.ndim == 1 else 's'}")
def resize_vol(self, display_size):
"""Resizes the loaded volume to the display size"""
# Get original size
......@@ -59,7 +67,7 @@ class Interface:
# Resize for display
self.vol = ndimage.zoom(
input=self.vol,
zoom=self.display_size / max_size,
zoom = display_size / max_size,
order=0,
prefilter=False,
)
......@@ -76,15 +84,37 @@ class Interface:
# Write Plotly figure to disk
fig.write_html(filename)
def create_fig(self):
def create_fig(self,
gradio_file,
display_size,
opacity,
opacityscale,
only_wireframe,
min_value,
max_value,
surface_count,
colormap,
show_colorbar,
reversescale,
flip_z,
show_axis,
show_ticks,
show_caps,
show_z_slice,
slice_z_location,
show_y_slice,
slice_y_location,
show_x_slice,
slice_x_location,):
# Load volume
self.load_data(self.gradio_file.name)
self.load_data(gradio_file)
# Resize data for display size
self.resize_vol()
self.resize_vol(display_size)
# Flip Z
if self.flip_z:
if flip_z:
self.vol = np.flip(self.vol, axis=0)
# Create 3D grid
......@@ -92,7 +122,7 @@ class Interface:
0 : self.display_size_z, 0 : self.display_size_y, 0 : self.display_size_x
]
if self.only_wireframe:
if only_wireframe:
surface_fill = 0.2
else:
surface_fill = 1.0
......@@ -103,103 +133,68 @@ class Interface:
y = Y.flatten(),
x = X.flatten(),
value = self.vol.flatten(),
isomin=self.min_value * np.max(self.vol),
isomax=self.max_value * np.max(self.vol),
isomin = min_value * np.max(self.vol),
isomax = max_value * np.max(self.vol),
cmin = np.min(self.vol),
cmax = np.max(self.vol),
opacity=self.opacity,
opacityscale=self.opacityscale,
surface_count=self.surface_count,
colorscale=self.colormap,
opacity = opacity,
opacityscale = opacityscale,
surface_count = surface_count,
colorscale = colormap,
slices_z = dict(
show=self.show_z_slice,
locations=[int(self.display_size_z * self.slice_z_location)],
show = show_z_slice,
locations = [int(self.display_size_z * slice_z_location)],
),
slices_y = dict(
show=self.show_y_slice,
locations=[int(self.display_size_y * self.slice_y_location)],
show = show_y_slice,
locations=[int(self.display_size_y * slice_y_location)],
),
slices_x = dict(
show=self.show_x_slice,
locations=[int(self.display_size_x * self.slice_x_location)],
show = show_x_slice,
locations = [int(self.display_size_x * slice_x_location)],
),
surface = dict(fill=surface_fill),
caps = dict(
x_show=self.show_caps,
y_show=self.show_caps,
z_show=self.show_caps,
x_show = show_caps,
y_show = show_caps,
z_show = show_caps,
),
showscale=self.show_colorbar,
showscale = show_colorbar,
colorbar=dict(
thickness=8, outlinecolor="#fff", len=0.5, orientation="h"
),
reversescale=self.reversescale,
reversescale = reversescale,
hoverinfo = "skip",
)
)
fig.update_layout(
scene_xaxis_showticklabels=self.show_ticks,
scene_yaxis_showticklabels=self.show_ticks,
scene_zaxis_showticklabels=self.show_ticks,
scene_xaxis_visible=self.show_axis,
scene_yaxis_visible=self.show_axis,
scene_zaxis_visible=self.show_axis,
scene_xaxis_showticklabels = show_ticks,
scene_yaxis_showticklabels = show_ticks,
scene_zaxis_showticklabels = show_ticks,
scene_xaxis_visible = show_axis,
scene_yaxis_visible = show_axis,
scene_zaxis_visible = show_axis,
scene_aspectmode="data",
height=self.plot_height,
hovermode=False,
scene_camera_eye=dict(x=2.0, y=-2.0, z=1.5),
)
return fig
def process(self, *args):
# Get args passed by Gradio
# TODO: solve this in an automated way
# Could Gradio pass kwargs instead of args?
self.gradio_file = args[0]
self.display_size = args[1]
self.opacity = args[2]
self.opacityscale = args[3]
self.only_wireframe = args[4]
self.min_value = args[5]
self.max_value = args[6]
self.surface_count = args[7]
self.colormap = args[8]
self.show_colorbar = args[9]
self.reversescale = args[10]
self.flip_z = args[11]
self.show_axis = args[12]
self.show_ticks = args[13]
self.show_caps = args[14]
self.show_z_slice = args[15]
self.slice_z_location = args[16]
self.show_y_slice = args[17]
self.slice_y_location = args[18]
self.show_x_slice = args[19]
self.slice_x_location = args[20]
# Create output figure
fig = self.create_fig()
# Save it to disk
self.save_fig(fig, "iso3d.html")
return fig, "iso3d.html"
filename = "iso3d.html"
self.save_fig(fig, filename)
return fig, filename
def remove_unused_file(self):
# Remove localthickness.tif file from working directory
# as it otherwise is not deleted
os.remove("iso3d.html")
def create_interface(self):
# Create gradio app
def define_interface(self, **kwargs):
with gr.Blocks(css=self.css_path) as gradio_interface:
if self.show_header:
gr.Markdown(
"""
# 3D Visualization (isosurfaces)
This tool uses Plotly Volume (https://plotly.com/python/3d-volume-plots/) to create iso surfaces from voxels based on their intensity levels.
To optimize performance when generating visualizations, set the number of voxels (_display resolution_) and isosurfaces (_total surfaces_) to lower levels.
"""
......@@ -211,7 +206,7 @@ class Interface:
with gr.Tab("Input"):
# File loader
gradio_file = gr.File(
show_label=False, elem_classes="file-input h-128"
show_label=False
)
with gr.Tab("Examples"):
gr.Examples(examples=self.img_examples, inputs=gradio_file)
......@@ -220,11 +215,11 @@ class Interface:
with gr.Row():
with gr.Column(scale=3, min_width=64):
btn_run = gr.Button(
value="Run 3D visualization", elem_classes="btn btn-run"
value="Run 3D visualization", variant = "primary"
)
with gr.Column(scale=1, min_width=64):
btn_clear = gr.Button(
value="Clear", elem_classes="btn btn-clear"
value="Clear", variant = "stop"
)
with gr.Tab("Display"):
......@@ -237,7 +232,6 @@ class Interface:
label="Display resolution",
info="Number of voxels for the largest dimension",
value=64,
elem_classes="",
)
surface_count = gr.Slider(
2, 16, step=1, label="Total iso-surfaces", value=6
......@@ -264,7 +258,7 @@ class Interface:
0.0, 1.0, step=0.05, label="Max value", value=1
)
with gr.Tab("Slices"):
with gr.Tab("Slices") as slices:
show_z_slice = gr.Checkbox(value=False, label="Show Z slice")
slice_z_location = gr.Slider(
0.0, 1.0, step=0.05, value=0.5, label="Position"
......@@ -355,15 +349,13 @@ class Interface:
label="Download interactive plot",
show_label=True,
visible=False,
elem_classes="w-256",
)
outputs = [volvizplot, plot_download]
# Session for user data
session = gr.State([])
#####################################
# Listeners
#####################################
# Clear button
for gr_obj in outputs:
......@@ -371,43 +363,9 @@ class Interface:
# Run button
# fmt: off
btn_run.click(
fn=self.process, inputs=inputs, outputs=outputs).success(
fn=self.create_fig, inputs = inputs, outputs = outputs).success(
fn=self.remove_unused_file).success(
fn=self.make_visible, inputs=None, outputs=plot_download)
# fmt: on
return gradio_interface
def make_visible(self):
return gr.update(visible=True)
def launch(self, **kwargs):
# Show header
if self.show_header:
internal_tools.gradio_header(self.title, self.port)
# Create gradio interface
self.interface = self.create_interface()
# Set gradio verbose level
if self.verbose:
quiet = False
else:
quiet = True
self.interface.launch(
quiet=quiet,
height=self.height,
width=self.width,
**kwargs,
)
def run_interface(host = "0.0.0.0"):
gradio_interface = Interface().create_interface()
internal_tools.run_gradio_app(gradio_interface,host)
fn=self.set_visible, inputs=None, outputs=plot_download)
if __name__ == "__main__":
# Creates interface
run_interface()
\ No newline at end of file
Interface().run_interface()
\ No newline at end of file