Skip to content

Commit

Permalink
Merge pull request #50 from PiMaV/v1.2.0
Browse files Browse the repository at this point in the history
Version 1.2.1
  • Loading branch information
irkri authored Sep 7, 2024
2 parents 4ff34d7 + 1405138 commit ca34eb9
Show file tree
Hide file tree
Showing 23 changed files with 2,109 additions and 549 deletions.
81 changes: 47 additions & 34 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,50 +1,63 @@
# BLITZ
BLITZ is an open-source image viewer specifically designed for "**B**ulk **L**oading and **I**nteractive **T**ime series **Z**onal analysis," developed at the [INP Greifswald](https://www.inp-greifswald.de). It is optimized for handling extensive image series, a common challenge in diagnostic imaging and process control. Programmed primarily in Python and utilizing QT and PyQTGraph.
BLITZ offers:
- rapid loading of large image series
- efficient performance scaling
- versatile data handling options
- user-friendly GUI
- stable lookup tables for visual consistency
- powerful matrix-based image processing capabilities, allowing for instant statistical calculations and image manipulations.

## Download the Latest Release for Windows
[Most recent
release](https://github.com/CodeSchmiedeHGW/BLITZ/releases/latest)
## TL;DR
**BLITZ** is a matrix-based image viewer designed for handling massive datasets (25+ GB) quickly and efficiently, but also works well for single images. (i.e. it can load 21,000 images (~25GB) in 35 seconds on a gaming laptop).

## Documentation and examples
## Download
[Download the latest release for Windows](https://github.com/CodeSchmiedeHGW/BLITZ/releases/latest)

We provide a short [walkthrough](docs/walkthrough.md) through BLITZ explaining all core functionalities.
## Overview
**BLITZ** (Bulk Loading and Interactive Time series Zonal analysis) is an open-source image viewer developed at [INP Greifswald](https://www.inp-greifswald.de). It is designed for rapid loading and processing of large image datasets, but is equally effective for single-image analysis.

**Key Features:**
- **Fast Data Handling:** Handles very large datasets efficiently (i.e. 21,000 images (~25GB) in just 35 seconds on a standard gaming laptop).
- **Easy Data Handling:** Drag-and-drop functionality for various image and video formats, including NUMPY matrices (*.npy).
- **Easy-to-use:** Automatic resource management for large and small datasets.
- **User-Friendly Interface:** Intuitive GUI with mouse-based navigation and shortcut capabilities.
- **Advanced Image Processing:** Matrix-based processing, with fast statistical calculations (i.e. Mean image of the 21k dataset: 1.7 seconds).
- **Built on Python**, with Qt and PyQtGraph for high performance and flexibility


![BLITZ Interface](docs/images/overview.png)

---
(Click if animation is not playing)
![GIF_Animation](resources/public/BLITZ_Record.gif)
![Quick Feature Overview](resources/public/BLITZ_Record.gif)

---

## Documentation

- [Quick Start Guide](docs/walkthrough.md)
- [Core Functionalities](docs/Tabs_explained.md)


## Compiling and Developing
- Clone this repository
- Create and activate virtual environment
- `pip install poetry` (It is recommended to use [poetry](https://python-poetry.org/) for local development.)
- install all dependencies (`poetry install` ) and run the application.

```shell
$ git clone https://github.com/CodeSchmiedeHGW/BLITZ.git
$ cd BLITZ
$ poetry install
$ poetry run python -m blitz
```
## Development

You can create a binary executable from the python files using `pyinstaller` with the following
options.
To compile and develop locally:

```shell
$ pyinstaller --onefile --noconsole --icon=./resources/icon/blitz.ico blitz_main.py
```
1. Clone the repository:
```bash
git clone https://github.com/CodeSchmiedeHGW/BLITZ.git
cd BLITZ
```
2. Set up a virtual environment and install dependencies:
```bash
pip install poetry
poetry install
poetry run python -m blitz
```
3. To create a binary executable:
```bash
pyinstaller --onefile --noconsole --icon=./resources/icon/blitz.ico blitz_main.py
```

## Additional Resources

- Visit [INPTDAT](https://www.inptdat.de) for additional images or publishing your own.
- You can find the original [Example Dataset](https://www.inptdat.de/dataset/fast-framing-images-kinpen-science-example-set-images-testing-blitz-image-viewer) at INPTDAT as well.
- Example Dataset: [KinPen Science Example Set](https://www.inptdat.de/dataset/fast-framing-images-kinpen-science-example-set-images-testing-blitz-image-viewer)
- Explore more datasets or contribute your own on [INPTDAT](https://www.inptdat.de).

## License

BLITZ is licensed under the terms of the GNU General Public License version 3 (GPL-3.0). Details
can be found in the [LICENSE](LICENSE) file.
BLITZ is licensed under the [GNU General Public License v3.0](LICENSE).
2 changes: 1 addition & 1 deletion blitz/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
__version__ = "1.1.0"
__version__ = "1.2.1"

from . import data, layout
2 changes: 1 addition & 1 deletion blitz/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

def run() -> int:
multiprocessing.freeze_support()
pg.setConfigOptions(useNumba=True)
pg.setConfigOptions(useNumba=False)
exit_code = 0
restart_exit_code = settings.get("app/restart_exit_code")
app = QApplication(sys.argv)
Expand Down
158 changes: 122 additions & 36 deletions blitz/data/image.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
from dataclasses import dataclass
from typing import Literal, Optional
from typing import Any, Literal, Optional

import numpy as np
import pyqtgraph as pg

from .. import settings
from ..tools import log
from .ops import ReduceDict, ReduceOperation, get
from . import ops
from .tools import ensure_4d, sliding_mean_normalization


@dataclass(kw_only=True)
Expand Down Expand Up @@ -33,36 +35,33 @@ def __init__(
image: np.ndarray,
metadata: list[MetaData],
) -> None:
self._image = image
self._image = ensure_4d(image.astype(np.float32))
self._meta = metadata
self._reduced = ReduceDict()
self._reduced = ops.ReduceDict()
self._mask: tuple[slice, slice, slice] | None = None
self._image_mask: np.ndarray | None = None
self._cropped: tuple[int, int] | None = None
self._transposed = False
self._flipped_x = False
self._flipped_y = False
self._redop: ReduceOperation | str | None = None
self._redop: ops.ReduceOperation | str | None = None
self._norm: np.ndarray | None = None
self._norm_operation: Literal["subtract", "divide"] | None = None

def reset(self) -> None:
self._reduced.clear()
self._mask = None
self._transposed = False
self._flipped_x = False
self._flipped_y = False
self._redop = None
self._norm = None
self._norm_operation = None

@property
def image(self) -> np.ndarray:
image: np.ndarray
if self._redop is not None:
image = self._reduced.reduce(self._image, self._redop)
if self._image_mask is not None:
image: np.ndarray = self._image.copy()
else:
image = self._image
image: np.ndarray = self._image
if self._norm is not None:
image = self._norm
if self._redop is not None:
image = self._reduced.reduce(image, self._redop)
if self._cropped is not None:
image = image[self._cropped[0]:self._cropped[1]+1]
if self._image_mask is not None:
image[:, ~self._image_mask] = np.nan
if self._mask is not None:
image = image[self._mask]
if self._transposed:
Expand All @@ -75,6 +74,8 @@ def image(self) -> np.ndarray:

@property
def n_images(self) -> int:
if self._cropped is not None:
return self._image[self._cropped[0]:self._cropped[1]+1].shape[0]
return self._image.shape[0]

@property
Expand All @@ -89,19 +90,33 @@ def is_single_image(self) -> bool:
return self._image.shape[0] == 1

def is_greyscale(self) -> bool:
return self._image.ndim == 3
return self._image.shape[3] == 1

def reduce(self, operation: ReduceOperation | str) -> None:
def reduce(self, operation: ops.ReduceOperation | str) -> None:
self._redop = operation

def crop(self, left: int, right: int, keep: bool = False) -> None:
if keep:
self._cropped = (left, right)
else:
self._cropped = None
self._image = self._image[left:right+1]

def undo_crop(self) -> bool:
if self._cropped is None:
return False
self._cropped = None
return True

def normalize(
self,
operation: Literal["subtract", "divide"],
use: ReduceOperation | str,
use: ops.ReduceOperation | str,
beta: float = 1.0,
left: Optional[int] = None,
right: Optional[int] = None,
gaussian_blur: int = 0,
bounds: Optional[tuple[int, int]] =None,
reference: Optional["ImageData"] = None,
window_lag: Optional[tuple[int, int]] = None,
force_calculation: bool = False,
) -> bool:
if self._redop is not None:
Expand All @@ -114,27 +129,58 @@ def normalize(
if self._norm_operation is not None:
self._norm = None
image = self._image
range_img = reference_img = None
if left is not None and right is not None:
range_img = beta * get(use)(image[left:right+1]).astype(np.double)
range_img = reference_img = window_lag_img = None
if bounds is not None:
range_img = beta * ops.get(use)(
image[bounds[0]:bounds[1]+1]
).astype(np.double)
if gaussian_blur > 0:
range_img = pg.gaussianFilter(
range_img[0, ..., 0],
(gaussian_blur, gaussian_blur),
)[np.newaxis, ..., np.newaxis]
if reference is not None:
if (not reference.is_single_image()
or reference._image.shape[1:] != image.shape[1:]):
log("Error: Background image has incompatible shape")
return False
reference_img = reference._image.astype(np.double)
if left is None and right is None and reference is None:
reference_img = beta * reference._image.astype(np.double)
if gaussian_blur > 0:
reference_img = pg.gaussianFilter(
reference_img[0, ..., 0],
(gaussian_blur, gaussian_blur),
)[np.newaxis, ..., np.newaxis]
if window_lag is not None:
window, lag = window_lag
window_lag_img = beta * (
sliding_mean_normalization(image, window, lag)
)
if gaussian_blur > 0:
window_lag_img = np.array([
pg.gaussianFilter(
window_lag_img[i, ..., 0],
(gaussian_blur, gaussian_blur),
)[..., np.newaxis]
for i in range(window_lag_img.shape[0])
])
if bounds is None and reference is None and window_lag is None:
return False
if operation == "subtract":
if range_img is not None:
self._norm = image - range_img
image = image - range_img
if reference_img is not None:
self._norm = image - reference_img
image = image - reference_img
if window_lag_img is not None:
image = image[:window_lag_img.shape[0]] - window_lag_img
self._norm = image
if operation == "divide":
if range_img is not None:
self._norm = image / range_img
image = image / range_img
if reference_img is not None:
self._norm = image / reference_img
image = image / reference_img
if window_lag_img is not None:
image = image[:window_lag_img.shape[0]] / window_lag_img
self._norm = image
self._norm_operation = operation # type: ignore
return True

Expand All @@ -157,13 +203,32 @@ def mask(self, roi: pg.ROI) -> None:
x_stop += self._mask[1].start
y_start += self._mask[2].start
y_stop += self._mask[2].start
op = self._redop
self.reset()
self.reduce(op) # type: ignore
self._transposed = False
self._flipped_x = False
self._flipped_y = False
self._mask = (
slice(None, None), slice(x_start, x_stop), slice(y_start, y_stop),
)

def mask_range(self, range_: tuple[int, int, int, int]) -> None:
self._mask = (
slice(None, None),
slice(range_[0], range_[1]),
slice(range_[2], range_[3]),
)

def image_mask(self, mask: "ImageData") -> None:
if (not mask.is_single_image()
or not mask.is_greyscale()
or mask.shape != self.shape):
log("Error: Mask not applicable", color="red")
else:
self._image_mask = mask.image[0].astype(bool)

def reset_mask(self) -> None:
self._mask = None
self._image_mask = None

def transpose(self) -> None:
self._transposed = not self._transposed

Expand All @@ -172,3 +237,24 @@ def flip_x(self) -> None:

def flip_y(self) -> None:
self._flipped_y = not self._flipped_y

def save_options(self) -> None:
settings.set("data/flipped_x", self._flipped_x)
settings.set("data/flipped_x", self._flipped_x)
settings.set("data/transposed", self._transposed)
if self._mask is not None:
settings.set("data/mask", self._mask)
if self._cropped is not None:
settings.set("data/cropped", self._cropped)

def load_options(self) -> None:
if settings.get("data/flipped_x"):
self.flip_x()
if settings.get("data/flipped_x"):
self.flip_y()
if settings.get("data/transposed"):
self.transpose()
if mask := settings.get("data/mask"):
self._mask = mask
if cropped := settings.get("data/cropped"):
self._cropped = cropped
10 changes: 5 additions & 5 deletions blitz/data/load.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ def __init__(

def load(self, path: Optional[Path] = None) -> ImageData:
if path is None:
return DataLoader.from_text("No data")
return DataLoader.from_text(" Load data", 50, 100)

if path.is_dir():
return self._load_folder(path)
Expand Down Expand Up @@ -122,9 +122,9 @@ def _load_folder(self, path: Path) -> ImageData:
content = content[::int(np.ceil(1 / ratio))]
log(f"Loading {len(content)}/{full_dataset_size} files", color="green")

if (len(content) > settings.get("data/multicore_files_threshold")
if (len(content) > settings.get("default/multicore_files_threshold")
or total_size_estimate >
settings.get("data/multicore_size_threshold")):
settings.get("default/multicore_size_threshold")):
with Pool(cpu_count()) as pool:
results = pool.starmap(
load_function,
Expand Down Expand Up @@ -277,9 +277,9 @@ def _load_array(self, path: Path) -> ImageData:
self.convert_to_8_bit,
)
total_size_estimate = array[0].nbytes * array.shape[0]
if (array.shape[0] > settings.get("data/multicore_files_threshold")
if (array.shape[0] > settings.get("default/multicore_files_threshold")
or total_size_estimate >
settings.get("data/multicore_size_threshold")):
settings.get("default/multicore_size_threshold")):
with Pool(cpu_count()) as pool:
matrices = pool.starmap(
function_,
Expand Down
Loading

0 comments on commit ca34eb9

Please sign in to comment.