Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
8206754
Streamline test suite: shared fixtures, markers, and per-notebook tests
animmosmith Jul 30, 2026
eeeab7d
Raise notebook test timeout based on real CI evidence
animmosmith Jul 30, 2026
f341b78
Auto-retry notebook tests on failure
animmosmith Jul 30, 2026
6ef82c1
Fix NumPy 2.5 shape-assignment deprecation in holo reconstruction kernel
animmosmith Jul 30, 2026
ab815d2
Fix get_j() feeding uninitialized memory into the Junge slope fit
animmosmith Jul 30, 2026
52b92da
Make multi-chunk logging queue-based and surface auxiliary data error…
animmosmith Jul 31, 2026
68bb706
Fix README: broken docs-build command, broken link, typos, stale content
animmosmith Jul 31, 2026
42a9c13
Migrate docs build from requirements.txt/pip to uv
animmosmith Jul 31, 2026
cb92b6f
Ensure the queue log listener is always stopped on early validation e…
animmosmith Jul 31, 2026
9772f87
Add automated license-compliance check for dependencies
animmosmith Aug 5, 2026
1aba960
Fully close the multiprocessing.Queue after stopping the log listener
animmosmith Aug 12, 2026
2674332
Update local docs-build instructions for uv
animmosmith Aug 20, 2026
078d59c
Fix print_steps() referencing a non-existent attribute
animmosmith Aug 20, 2026
93f3947
bump version
animmosmith Aug 20, 2026
97bce17
Add Releases and Using AI tools sections to README
animmosmith Aug 20, 2026
a75c651
Cap DINOv2 training-notebook validation and run it in routine CI
animmosmith Aug 20, 2026
444c9ae
Move DINOv2 notebook's torch/timm/scikit-learn deps into classificati…
animmosmith Aug 20, 2026
bbb4a25
Add AGENTS.md; remove the now-unused training pytest marker
animmosmith Aug 20, 2026
658bd20
Pin timm exactly so DINOv2 weights don't silently drift in license terms
animmosmith Aug 21, 2026
26fdad7
Add scaled circular montage for fair density comparison across sample…
animmosmith Jul 30, 2026
0a74f5c
Attempt every particle in make_montage_scaled instead of truncating
animmosmith Aug 5, 2026
5439189
Add optional progress/ETA logging to Pipeline
animmosmith Aug 20, 2026
9e17ad0
Add fixed-pixel ROI padding, complementing bbox_expansion
animmosmith Aug 5, 2026
3f91d60
Add ImageToDisc pipeline step for saving processed images to file
Copilot Mar 13, 2026
693e835
Address code review: improve memory efficiency and move imports
Copilot Mar 13, 2026
d73bde0
Clean up redundant if/else in _prepare_image
Copilot Mar 13, 2026
d099aa0
Add documentation for ImageToDisc with instructive usage examples
Copilot Mar 13, 2026
efcd52d
bump version
animmosmith Aug 20, 2026
8c324a3
Bound the realtime processing queue, dropping oldest images under bac…
animmosmith Aug 20, 2026
65757ad
Merge remote-tracking branch 'origin/main' into summer26-features
animmosmith Sep 4, 2026
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 docs/notebooks/processing_raw_data.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,34 @@
"```"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## (Optional) Save intermediate images to disc\n",
"\n",
"During processing, it can be very helpful to save intermediate images for visual quality control or for sharing with collaborators.\n",
"For example, you may want to inspect the background-corrected images to ensure the correction is working properly.\n",
"\n",
"To do this, add a `[steps.saveimages]` step to your `config.toml` file. This uses {class}`pyopia.io.ImageToDisc`, which saves one or more pipeline images to a specified output folder.\n",
"\n",
"Here is an example that saves the raw, background, and corrected images at half resolution:\n",
"\n",
"```toml\n",
" [steps.saveimages]\n",
" pipeline_class = 'pyopia.io.ImageToDisc'\n",
" output_folder = 'processed_images'\n",
" image_keys = ['imraw', 'imbg', 'im_corrected']\n",
" scale_factor = 0.5\n",
"```\n",
"\n",
"Place this step **after** the steps that produce the images you want to save (e.g. after `correctbackground` for background-corrected images, or after `segmentation` to also include the binary segmentation mask `imbw`).\n",
"\n",
"You can also save a single **collage** image per input file, by setting `collage = true`.\n",
"\n",
"See {ref}`toml-config` for full configuration details and more examples."
]
},
{
"cell_type": "markdown",
"metadata": {},
Expand Down
79 changes: 79 additions & 0 deletions docs/notebooks/toml_config.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,85 @@
"\n",
"```"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Saving processed images to disc\n",
"\n",
"It is often useful to save intermediate pipeline images to disc for visual inspection, quality control,\n",
"or sharing with collaborators. For example, you may want to visually check that the background correction\n",
"is working correctly, or provide corrected images for manual review.\n",
"\n",
"The {class}`pyopia.io.ImageToDisc` pipeline step enables this. It can be inserted at any point in the pipeline\n",
"to save the current state of one or more images from the pipeline data. Common use cases include:\n",
"\n",
"- Saving the **background-corrected image** (`im_corrected`) for visual quality control\n",
"- Saving the **raw image** (`imraw`) and **background image** (`imbg`) alongside the corrected image for comparison\n",
"- Saving the **segmented image** (`imbw`) to verify that particle detection is working as expected\n",
"- Creating a **collage** of all processing stages for a quick overview of each image\n",
"\n",
"### Configuration options\n",
"\n",
"| Option | Description | Default |\n",
"| --- | --- | --- |\n",
"| `output_folder` | Path to folder where images will be saved (created if it does not exist) | `'processed_images'` |\n",
"| `image_keys` | List of pipeline data keys to save | `['imraw', 'imbg', 'im_corrected', 'imbw']` |\n",
"| `scale_factor` | Factor to downscale images before saving (e.g. `0.5` halves the resolution) | `1.0` |\n",
"| `collage` | If `true`, combine all images into a single vertically-stacked collage per input image | `false` |\n",
"| `image_format` | Output image format | `'png'` |\n",
"\n",
"### Example: Save separate images at half resolution\n",
"\n",
"Add this step after background correction (or after segmentation, depending on which images you want to capture):\n",
"\n",
"```toml\n",
" [steps.saveimages]\n",
" pipeline_class = 'pyopia.io.ImageToDisc'\n",
" output_folder = 'processed_images'\n",
" image_keys = ['imraw', 'imbg', 'im_corrected']\n",
" scale_factor = 0.5\n",
"```\n",
"\n",
"This will create one PNG file per image key, per input image, in the `processed_images/` folder.\n",
"For example, processing an image called `image_001.silc` would produce:\n",
"```\n",
"processed_images/\n",
"├── image_001_imraw.png\n",
"├── image_001_imbg.png\n",
"└── image_001_im_corrected.png\n",
"```\n",
"\n",
"### Example: Save a collage of all processing stages\n",
"\n",
"To get a single overview image showing all stages of processing for each input image:\n",
"\n",
"```toml\n",
" [steps.saveimages]\n",
" pipeline_class = 'pyopia.io.ImageToDisc'\n",
" output_folder = 'processed_images'\n",
" image_keys = ['imraw', 'im_corrected', 'imbw']\n",
" collage = true\n",
" scale_factor = 0.5\n",
"```\n",
"\n",
"This produces a single `image_001_collage.png` per input image, with the raw, corrected and segmented\n",
"images stacked vertically.\n",
"\n",
"### Placement in the pipeline\n",
"\n",
"The `saveimages` step should be placed **after** the processing steps that produce the images you want to save.\n",
"For instance, to save background-corrected images, place it after `correctbackground`.\n",
"To also include the segmented image (`imbw`), place it after the `segmentation` step.\n",
"You can also include multiple `saveimages` steps at different points in the pipeline if needed, e.g.\n",
"one right after background correction and another after segmentation.\n",
"\n",
"```{note}\n",
"Images that are not yet available in the pipeline data at the point where `saveimages` runs will be\n",
"silently skipped. So it is safe to request keys that may not exist for all configurations.\n",
"```"
]
}
],
"metadata": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@
"metadata": {},
"source": [
"## Install extra dependencies\n",
"In addition to PyOPIA (for testing at the end), PyTorch, this notebook requires [TIMM](https://pypi.org/project/timm/) and scikit-learn. "
"In addition to PyOPIA (for testing at the end), PyTorch, this notebook requires [TIMM](https://pypi.org/project/timm/) and scikit-learn - already present if installed via `pyopia[classification-torch]`, otherwise installed automatically below. "
]
},
{
Expand All @@ -52,7 +52,22 @@
"metadata": {},
"outputs": [],
"source": [
"!uv pip install -q torch timm scikit-learn"
"import importlib.util\n",
"\n",
"# timm is pinned to match pyproject.toml's classification-torch extra: it resolves the\n",
"# DINOv2 backbone weights below (pretrained=True), and DINOv2's pretrained weights\n",
"# started under a non-commercial license before later moving to Apache 2.0 - an\n",
"# unpinned install could silently track a future timm release onto different\n",
"# weights/licensing terms. Bump only as a deliberate, reviewed change (both here and\n",
"# in pyproject.toml).\n",
"PINNED = {\"timm\": \"timm==1.0.28\"}\n",
"\n",
"missing = [pkg for pkg, mod in [(\"torch\", \"torch\"), (\"timm\", \"timm\"), (\"scikit-learn\", \"sklearn\")]\n",
" if importlib.util.find_spec(mod) is None]\n",
"if missing:\n",
" import subprocess\n",
" to_install = [PINNED.get(pkg, pkg) for pkg in missing]\n",
" subprocess.run([\"uv\", \"pip\", \"install\", \"-q\", *to_install], check=True)"
]
},
{
Expand All @@ -78,9 +93,15 @@
"# Option B: your own labelled data (set USE_EXAMPLE_DATABASE = False)\n",
"DATA_DIR = \"\"\n",
"\n",
"# Training images per class (remaining images used for validation)\n",
"# Training images per class\n",
"IMAGES_PER_CLASS = 50\n",
"\n",
"# Validation images per class, capped rather than using every remaining image - this\n",
"# dataset is large and unbalanced (thousands of images in some classes), so validating\n",
"# on \"everything left over\" is far more expensive than training itself for little\n",
"# extra signal.\n",
"VAL_PER_CLASS = 20\n",
"\n",
"# Training epochs. 20–50 is typical; more rarely helps.\n",
"EPOCHS = 30\n",
"\n",
Expand Down Expand Up @@ -178,9 +199,10 @@
" indices = np.arange(len(cls_samples))\n",
" rng.shuffle(indices)\n",
" n_train = min(IMAGES_PER_CLASS, len(cls_samples) - 1)\n",
" n_val = min(VAL_PER_CLASS, len(cls_samples) - n_train)\n",
" for i in indices[:n_train]:\n",
" train_samples.append(cls_samples[i])\n",
" for i in indices[n_train:]:\n",
" for i in indices[n_train:n_train + n_val]:\n",
" val_samples.append(cls_samples[i])\n",
" if n_train < IMAGES_PER_CLASS:\n",
" print(f\" Warning: {classes[cls_idx]} has only {len(cls_samples)} images \"\n",
Expand Down
2 changes: 1 addition & 1 deletion pyopia/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "2.16.16"
__version__ = "2.17.0"
10 changes: 8 additions & 2 deletions pyopia/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -375,7 +375,7 @@ def process(config_filename: str, num_chunks: int = 1, strategy: str = "block"):


@app.command()
def process_realtime(config_filename: str, watch_folder: str = None):
def process_realtime(config_filename: str, watch_folder: str = None, queue_size: int = 10):
"""Run a PyOPIA processing pipeline in realtime by watching a folder.

Parameters
Expand All @@ -386,6 +386,12 @@ def process_realtime(config_filename: str, watch_folder: str = None):
watch_folder : str, optional
Folder to monitor. If not provided, inferred from `general.raw_files` in config.

queue_size : int, optional
Maximum number of queued images retained when processing falls behind
acquisition; older images are dropped to stay close to realtime. A bigger
queue only delays which images get dropped - it doesn't fix an underlying
backlog where processing is slower than acquisition. Defaults to 10.

Notes
-----
- Single-core only: files are processed sequentially by a single worker thread.
Expand All @@ -412,7 +418,7 @@ def process_realtime(config_filename: str, watch_folder: str = None):
output_datafile = pipeline_config["steps"]["output"]["output_datafile"]
os.makedirs(os.path.split(output_datafile)[:-1][0], exist_ok=True)

pyopia.realtime.run_realtime(pipeline_config, watch_folder=watch_folder)
pyopia.realtime.run_realtime(pipeline_config, watch_folder=watch_folder, queue_size=queue_size)
finally:
stop_queue_logging(listener, log_queue)

Expand Down
173 changes: 173 additions & 0 deletions pyopia/io.py
Original file line number Diff line number Diff line change
Expand Up @@ -661,6 +661,179 @@ def __call__(self, data):
return data


class ImageToDisc:
'''Pipeline-compatible class for saving processed images to disc.

Saves specified pipeline images (e.g. raw, background corrected, segmented)
to an output folder. Can optionally downscale images and/or combine them
into a single collage per input image.

Required keys in :class:`pyopia.pipeline.Data`:
- :attr:`pyopia.pipeline.Data.filename`
- At least one of the image keys specified in ``image_keys``

Parameters
----------
output_folder : str
Path to the output folder where images will be saved.
Created automatically if it does not exist.
image_keys : list of str, optional
List of pipeline data keys to save as images.
Defaults to ``['imraw', 'imbg', 'im_corrected', 'imbw']``.
Keys that are not present in the pipeline data for a given image
will be silently skipped.
scale_factor : float, optional
Factor to downscale images before saving. E.g. 0.5 halves the
resolution. Defaults to 1.0 (no scaling).
collage : bool, optional
If True, all specified images are combined into a single collage
image (one row per image key) rather than saved as separate files.
Defaults to False.
image_format : str, optional
Image file format extension. Defaults to ``'png'``.

Returns
-------
data : :class:`pyopia.pipeline.Data`
Unmodified pipeline data.

Examples
--------
Save background-corrected and segmented images to a folder:

.. code-block:: toml

[steps.saveimages]
pipeline_class = 'pyopia.io.ImageToDisc'
output_folder = 'processed_images'
image_keys = ['imraw', 'im_corrected', 'imbw']
scale_factor = 0.5

Save a collage of all processing stages:

.. code-block:: toml

[steps.saveimages]
pipeline_class = 'pyopia.io.ImageToDisc'
output_folder = 'processed_images'
collage = true
'''

def __init__(self, output_folder='processed_images',
image_keys=None,
scale_factor=1.0,
collage=False,
image_format='png'):
if image_keys is None:
image_keys = ['imraw', 'imbg', 'im_corrected', 'imbw']
self.output_folder = output_folder
self.image_keys = image_keys
self.scale_factor = scale_factor
self.collage = collage
self.image_format = image_format

def __call__(self, data):
os.makedirs(self.output_folder, exist_ok=True)

source_filename = data.get('filename', 'unknown')
base_name = Path(source_filename).stem

# Collect available images (keep original dtypes for efficiency)
available_images = []
for key in self.image_keys:
if key in data and data[key] is not None:
available_images.append((key, np.asarray(data[key])))

if not available_images:
logger.warning('ImageToDisc: No images found in pipeline data for the specified keys.')
return data

if self.collage:
self._save_collage(available_images, base_name)
else:
self._save_separate(available_images, base_name)

return data

def _prepare_image(self, img):
'''Prepare an image for saving: handle scaling and normalisation.

Parameters
----------
img : ndarray
Image array (2D or 3D, float or bool).

Returns
-------
img : ndarray
Prepared image array clipped to [0, 1].
'''
from skimage.transform import rescale

# Convert to float64 for saving
img = img.astype(np.float64)

if self.scale_factor != 1.0:
multichannel = img.ndim == 3
img = rescale(img, self.scale_factor,
channel_axis=2 if multichannel else None,
anti_aliasing=True,
preserve_range=True)
# Clip to valid range for plt.imsave
img = np.clip(img, 0, 1)
return img

def _save_separate(self, available_images, base_name):
'''Save each image key as a separate file.'''
import matplotlib.pyplot as plt

for key, img in available_images:
img = self._prepare_image(img)
out_path = Path(self.output_folder) / f'{base_name}_{key}.{self.image_format}'
if img.ndim == 2:
plt.imsave(str(out_path), img, cmap='gray')
else:
plt.imsave(str(out_path), img)
logger.debug(f'ImageToDisc: Saved {key} to {out_path}')

def _save_collage(self, available_images, base_name):
'''Save all images combined into a single collage image.'''
import matplotlib.pyplot as plt
from skimage.transform import resize

# Determine target width (use first image width, after scale)
first_img = available_images[0][1]
if first_img.ndim == 2:
target_h, target_w = first_img.shape
else:
target_h, target_w = first_img.shape[:2]

if self.scale_factor != 1.0:
target_h = int(target_h * self.scale_factor)
target_w = int(target_w * self.scale_factor)

# Resize all images to the same dimensions and convert to 3-channel
panels = []
for key, img in available_images:
if img.dtype == bool:
img = img.astype(np.float64)

if img.ndim == 2:
img = resize(img, (target_h, target_w), anti_aliasing=True, preserve_range=True)
# Convert grayscale to RGB for stacking
img = np.stack([img, img, img], axis=-1)
else:
img = resize(img, (target_h, target_w, img.shape[2]), anti_aliasing=True, preserve_range=True)

img = np.clip(img, 0, 1)
panels.append(img)

collage = np.concatenate(panels, axis=0)
out_path = Path(self.output_folder) / f'{base_name}_collage.{self.image_format}'
plt.imsave(str(out_path), collage)
logger.debug(f'ImageToDisc: Saved collage to {out_path}')


def load_toml(toml_file):
"""Load a TOML settings file from file

Expand Down
Loading
Loading