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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,34 @@ All notable chagnes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

---

## [0.4.6] - 2026-07-22

### Added

#### MLflow auto logging enhancements (`virtual_stain_flow/vsf_logging/`):
- The logger now records model optimizer parameters.

#### Channel-specific normalization/transformation specification (`virtual_stain_flow/transforms/`):
- Allows separate normalizations applied to different channels.

#### Support for visualization of multi-channel input/target/prediction (`virtual_stain_flow/evaluation/`):
- Displays channels as additional columns in visualization grid.
- Allows selection of which channels to display vias indexing.

### Refactored

#### Crop generation module as subpackage (`virtual_stain_flow/datasets/ds_engine/crop_generators/`):
- Reduce size of module by breaking into subpackage.
- Added tiling crop.

#### Logger auto-logging functionalities isolated (`virtual_stain_flow/transforms/`):
- Moved auto-logging of optimizer, model and loss group configs outside to reduce size of main logger module and facilitate testing.

#### Data access during plotting (`virtual_stain_flow/evaluation/`):
- Now images are retrieved from dataset and uniformly normalized as (N, C, H, W) before passed to plotting functions.


---

Expand Down
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@ dependencies = [
"notebook",
"tifffile",
"pandera[pandas]",
"boto3"
"boto3",
"imagecodecs"
]

[project.optional-dependencies]
Expand Down
31 changes: 27 additions & 4 deletions src/virtual_stain_flow/datasets/ds_engine/manifest.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@

import numpy as np
import pandas as pd
import tifffile as tiff
from PIL import Image
from pandera.errors import SchemaError, SchemaErrors

Expand Down Expand Up @@ -123,12 +124,24 @@ def read_image(self, path: Path) -> np.ndarray:
"""
if not path.exists():
raise FileNotFoundError(f"File not found: {path}")

try:
with Image.open(path) as img:
img = img.convert(self.pil_image_mode)
arr = np.asarray(img)
except Exception as e:
raise RuntimeError(f"Error reading image from {path}: {e}") from e

except Exception as e_pil:

try:
# fallback to tifffile for potentially more robust reading of TIFF
arr = tiff.imread(path)

except Exception:

raise RuntimeError(
f"Error reading image from {path}: {e_pil}"
) from e_pil


if arr.ndim not in (2, 3):
raise ValueError(f"Unsupported image shape {arr.shape} from {path}")
Expand All @@ -149,8 +162,18 @@ def get_image_dimensions(
if not Path(path).exists():
dims.append(None)
else:
with Image.open(path) as img:
dims.append(img.size)
try:
with Image.open(path) as img:
dims.append(img.size)
except Exception as e_pil:
try:
with tiff.TiffFile(path) as tif:
page = tif.pages[0]
dims.append((page.imagelength, page.imagewidth))
except Exception:
raise RuntimeError(
f"Error reading image dimensions from {path}: {e_pil}"
) from e_pil

return tuple(dims)

Expand Down
91 changes: 7 additions & 84 deletions src/virtual_stain_flow/evaluation/evaluation_utils.py
Original file line number Diff line number Diff line change
@@ -1,92 +1,15 @@
from typing import List, Optional, Tuple, Union
"""
evaluation_utils.py

Utilities for evaluating model predictions.
"""

from typing import List, Optional

import numpy as np
import pandas as pd
import torch
from torch.nn import Module

from ..datasets.base_dataset import BaseImageDataset
from ..datasets.crop_dataset import CropImageDataset
from virtual_stain_flow.datasets.base_wrapper_dataset import BaseWrapperDataset


def extract_samples_from_dataset(
dataset: Union[BaseImageDataset, CropImageDataset, BaseWrapperDataset],
indices: List[int],
) -> Tuple[
List[np.ndarray],
List[np.ndarray],
Optional[List[np.ndarray]],
Optional[List[Tuple[int, int]]],
]:
"""
Extract input/target samples and optional raw images with patch coordinates from a dataset.

For CropImageDataset, also extracts the original (uncropped) input images and the
(x, y) coordinates of each crop for visualization with bounding boxes.

:param dataset: A BaseImageDataset or CropImageDataset instance.
:param indices: List of dataset indices to extract.
:return: Tuple of (inputs, targets, raw_images, patch_coords).
- inputs: List of numpy arrays, each with shape (C, H, W) or (H, W).
- targets: List of numpy arrays, each with shape (C, H, W) or (H, W).
- raw_images: List of numpy arrays for CropImageDataset (original uncropped images),
or None for BaseImageDataset.
- patch_coords: List of (x, y) tuples for CropImageDataset, or None for BaseImageDataset.
"""
is_wrapper_dataset = False
if isinstance(dataset, BaseWrapperDataset):
is_crop_dataset = isinstance(dataset.original, CropImageDataset)
is_wrapper_dataset = True
elif isinstance(dataset, CropImageDataset):
is_crop_dataset = True
elif isinstance(dataset, BaseImageDataset):
is_crop_dataset = False
else:
raise ValueError(
"Unsupported dataset type. Expected BaseImageDataset, CropImageDataset, or BaseWrapperDataset.")

if not indices:
raise ValueError("Indices list cannot be empty.")

if max(indices) >= len(dataset):
raise IndexError(
f"Index out of range. Dataset length: {len(dataset)}, "
f"max index requested: {max(indices)}"
)

inputs: List[np.ndarray] = []
targets: List[np.ndarray] = []
raw_images: Optional[List[np.ndarray]] = [] if is_crop_dataset else None
patch_coords: Optional[List[Tuple[int, int]]] = [] if is_crop_dataset else None

for idx in indices:
# Access dataset item to trigger lazy loading and state update
input_tensor, target_tensor = dataset[idx]

# Convert to numpy - handle both Tensor and ndarray inputs
if isinstance(input_tensor, torch.Tensor):
inputs.append(input_tensor.numpy())
else:
inputs.append(np.asarray(input_tensor))

if isinstance(target_tensor, torch.Tensor):
targets.append(target_tensor.numpy())
else:
targets.append(np.asarray(target_tensor))

if is_crop_dataset:
# Access the original uncropped image and crop coordinates
# These are populated after __getitem__ is called
if is_wrapper_dataset:
raw_images.append(dataset.original.original_input_image[0])
patch_coords.append((dataset.original.crop_info.x, dataset.original.crop_info.y))
else:
raw_images.append(dataset.original_input_image[0])
patch_coords.append((dataset.crop_info.x, dataset.crop_info.y))

return inputs, targets, raw_images, patch_coords


def evaluate_per_image_metric(
predictions: torch.Tensor,
Expand Down
Loading
Loading