Source code for ewokstomo.tasks.online.reconstruct_slice

"""
Online reconstruction tasks for real-time tomography processing.

This module provides tasks and utilities for performing tomographic
reconstruction in real-time as data is acquired from the beamline.
"""

import logging
import numpy as np
import time
from pathlib import Path
import h5py
from ewokscore import Task
from nabu.cuda.utils import __has_cupy__
from nabu.estimation.cor import CenterOfRotation
from nabu.preproc.flatfield import FlatField
from nabu.resources.dataset_inmemory import InMemoryDataset
from blissdata.redis_engine.store import DataStore
from blissdata.beacon.data import BeaconData
from blissdata.redis_engine.scan import ScanState
from ewokstomo.tasks.utils import wait_for_scan_state
from ewokstomo.tasks.utils import _get_technique_from_nabu_config
from ewokstomo.tasks.online.nabu_pipeline import get_nabu_pipeline
from ewokstomo.tasks.online.utils import get_image_stream_name, load_reduced_frames
from ewokscore.model import BaseInputModel
from ewokscore.model import BaseOutputModel
from pydantic import Field

logger = logging.getLogger(__name__)
if __has_cupy__:
    logger.info("Using CUDA for reconstruction")
else:
    logger.info("Using OpenCL for reconstruction")


def _projection_batches(total_nb_projection: int, batch_size: int) -> list[list[int]]:
    if total_nb_projection < 2:
        raise ValueError("FBP reconstruction requires at least 2 projections")

    batch_size = max(2, batch_size)
    batches = []
    proj_idx = 0
    while proj_idx < total_nb_projection:
        end_idx = min(proj_idx + batch_size, total_nb_projection)
        if total_nb_projection - end_idx == 1:
            end_idx = total_nb_projection
        batches.append(list(range(proj_idx, end_idx)))
        proj_idx = end_idx
    return batches


[docs] class OnlineReconstructSliceInputModel(BaseInputModel): scan_key: str = Field( ..., description="Blissdata scan key for accessing the live stream" ) output_path: str = Field( ..., description="Directory path where reconstructed slice files will be saved." ) rotation_motor: str = Field( ..., description="Name of the rotation axis motor in the scan" ) batch_size: int = Field( 100, description="Number of projections to process in each batch" ) reduced_dark_path: str = Field( ..., description="Path to HDF5 file containing reduced dark frames" ) reduced_flat_path: str = Field( ..., description="Path to HDF5 file containing reduced flat frames" ) pixel_size_m: float = Field(..., description="Detector pixel size in meters") distance_m: float = Field( ..., description="Effective propagation distance in meters" ) energy_keV: float = Field(..., description="Energy in keV") config_dict: dict = Field( ..., description="Configuration dictionary for reconstruction parameters" ) extra_options: dict | None = Field( default_factory=lambda: {"centered_axis": True}, description=( "Additional backprojector options. Default is {'centered_axis': True}." ), ) slice_index: int | str | None = Field( "middle", description=( "Slice index to reconstruct. Can be an integer index or " "'middle', 'first', 'last'. Default is 'middle'." ), )
[docs] class OnlineReconstructSliceOutputModel(BaseOutputModel): reconstructed_slices_directory: str = Field( ..., description="Path to the saved reconstructed slice files" )
[docs] class OnlineReconstructSlice( # type: ignore[call-arg] Task, input_model=OnlineReconstructSliceInputModel, output_model=OnlineReconstructSliceOutputModel, ): """ (ESRF-only) Perform real-time tomography reconstruction on streaming data. This task connects to a live scan stream and performs incremental reconstruction by processing projections in batches. It includes flat field correction, phase retrieval, and FBP reconstruction. The reconstruction is performed on a single slice (middle by default) to minimize computational cost while providing real-time feedback. """ STREAM_TIMEOUT = 5 # seconds to wait for new data after scan stops WAITING_INTERVAL = 1 # seconds between checks for new data
[docs] def run(self): """Execute the online reconstruction pipeline.""" # Get required inputs scan_key = self.get_input_value("scan_key") output_path = self.get_input_value("output_path") rotation_motor = self.get_input_value("rotation_motor") batch_size = int(self.get_input_value("batch_size", 100)) reduced_dark_path = self.get_input_value("reduced_dark_path") reduced_flat_path = self.get_input_value("reduced_flat_path") pixel_size_m = float(self.get_input_value("pixel_size_m")) distance_m = float(self.get_input_value("distance_m")) energy_keV = float(self.get_input_value("energy_keV")) conf_dict = self.get_input_value("config_dict", {}) conf_dict.setdefault("reconstruction", {}) # Get optional parameters extra_options = self.get_input_value("extra_options", {"centered_axis": True}) slice_index = self.get_input_value("slice_index", "middle") # Connect to beacon and load scan logger.info(f"Connecting to scan: {scan_key}") beacon_client = BeaconData() redis_url = beacon_client.get_redis_data_db() data_store = DataStore(redis_url) scan = data_store.load_scan(scan_key) # Get number of projections total_nb_projection = int(scan.info["technique"]["proj"]["proj_n"]) # Wait for scan to start wait_for_scan_state(scan, ScanState.STARTED) # Prepare output directory self.outputs.reconstructed_slices_directory = output_path Path(output_path).mkdir(parents=True, exist_ok=True) # Populate dataset_info structure dataset_info = InMemoryDataset("") dataset_info.energy = energy_keV dataset_info.distance = distance_m dataset_info.pixel_size = pixel_size_m * 1e6 dataset_info.is_halftomo = conf_dict["reconstruction"].get( "enable_halftomo", False ) dataset_info.radio_dims = tuple( scan.streams[get_image_stream_name(scan)].info["shape"][::-1] ) dataset_info.current_projections_indices = np.arange( batch_size, dtype=np.uint64 ) dataset_info.n_angles = batch_size logger.info("Loading flat field correction data") dataset_info.darks = load_reduced_frames(reduced_dark_path, "darks") dataset_info.flats = load_reduced_frames(reduced_flat_path, "flats") dataset_info.n_frames = ( total_nb_projection + len(dataset_info.darks) + len(dataset_info.flats) ) conf_dict["reconstruction"]["method"] = "FBP" conf_dict["reconstruction"]["start_z"] = slice_index conf_dict["reconstruction"]["end_z"] = slice_index conf_dict["reconstruction"]["centered_axis"] = extra_options.get( "centered_axis", True ) conf_dict["output"]["location"] = output_path conf_dict["output"]["file_prefix"] = "rec" technique = _get_technique_from_nabu_config(conf_dict) sample_name = scan.info["sample"]["name"] # Estimate rotation axis position if not furnished rotation_axis_position = conf_dict["reconstruction"].get( "rotation_axis_position", None ) if rotation_axis_position is None: idx_180 = self._wait_for_180_degrees_projection(scan, rotation_motor) stream_name = self._get_image_stream_name(scan) detector_stream = scan.streams[stream_name] self._wait_for_stream_length( scan, detector_stream, idx_180 + 1, stream_name ) projections = np.array( [detector_stream[0], detector_stream[idx_180]], dtype=np.float32 ) projections = self._flat_field_correct( projections, dataset_info, radios_indices=[0, idx_180] ) rotation_axis_position = self._estimate_cor(projections[0], projections[1]) conf_dict["reconstruction"]["rotation_axis_position"] = ( rotation_axis_position ) reco_pipeline = get_nabu_pipeline( conf_dict, dataset_info, batch_size, pipeline_backend="cuda" ) logger.info("Starting online reconstruction...") # Process batches projection_batches = _projection_batches(total_nb_projection, batch_size) for batch_index, projections_indices in enumerate(projection_batches, start=1): logger.info(f"Processing batch {batch_index}/{len(projection_batches)}") try: proj_idx = projections_indices[0] end_idx = projections_indices[-1] + 1 projections = self._get_projections_from_stream( scan, projections_indices, output_dtype=np.float32 ) angles = self._get_angles_from_stream( scan, rotation_motor, projections_indices, output_dtype=np.float32 ) # Pad last batch if it's smaller than batch_size to avoid issues actual_batch_size = end_idx - proj_idx if actual_batch_size < batch_size: pad_width = batch_size - actual_batch_size projections = np.pad( projections, ((0, pad_width), (0, 0), (0, 0)), mode="edge" ) angles = np.pad(angles, (0, pad_width), mode="edge") projections_indices = ( list(projections_indices) + [projections_indices[-1]] * pad_width ) dataset_info.data = projections dataset_info.rotation_angles = angles dataset_info.n_angles = len(projections_indices) dataset_info.current_projections_indices = np.array(projections_indices) reco_pipeline.process_config.processing_options["save"]["location"] = ( str(output_path) ) reco_pipeline.process_config.processing_options["save"][ "file_prefix" ] = f"{sample_name}_{technique}_xy_online_{proj_idx}_{end_idx - 1}" reco_pipeline.process_chunk(reco_pipeline.target_sub_region) reco_pipeline.save_reconstruction() except RuntimeError as e: logger.error(f"Stopping reconstruction due to error: {e}") break # Final flush: persist the last accumulated reconstruction once all # batches have been processed (the in-loop call saves per batch). reco_pipeline.save_reconstruction() logger.info("Online reconstruction completed")
[docs] def save_reconstructed_slice( self, output_path: str, reconstructed_slice: np.ndarray ): """Save the reconstructed slice to an HDF5 file. Parameters ---------- output_path : str Path to save the reconstructed slice reconstructed_slice : np.ndarray The reconstructed slice data """ try: with h5py.File(output_path, "w") as h5f: h5f.create_dataset("reconstructed_slice", data=reconstructed_slice) logger.info(f"Reconstructed slice saved to {output_path}") except Exception as e: logger.error(f"Error saving reconstructed slice: {e}") raise
def _wait_for_180_degrees_projection(self, scan, rotation_motor: str) -> int: """Wait until the rotation motor reaches a difference of 180 degrees from start.""" logger.info("Waiting to reach 180 degrees for COR estimation...") motor_stream = scan.streams[self._get_motor_stream_name(scan, rotation_motor)] waited_time = 0 while True: if len(motor_stream) > 0: angles = np.array(motor_stream[:], dtype=np.float32) diffs = np.abs(angles - angles[0]) if np.any(diffs >= 180.0): idx_180 = int(np.argmax(diffs >= 180.0)) logger.info( f"Reached 180 degrees at index {idx_180} (angle: {angles[idx_180]})" ) return idx_180 if scan.state >= ScanState.STOPPED: waited_time += 1 if waited_time > self.STREAM_TIMEOUT: if len(motor_stream) > 0: idx_180 = int(np.argmax(diffs)) logger.warning( f"Scan stopped early. Using maximum difference index {idx_180} (angle: {angles[idx_180]})" ) return idx_180 raise RuntimeError( "Scan stopped before any motor positions were acquired." ) time.sleep(self.WAITING_INTERVAL) scan.update(block=False) def _flat_field_correct(self, projections, dataset_info, radios_indices): """Apply Nabu flat-field correction in place and return the projections.""" flat_field = FlatField( projections.shape, flats=dataset_info.flats, darks=dataset_info.darks, radios_indices=radios_indices, ) return flat_field.normalize_radios(projections) def _estimate_cor(self, proj_0, proj_180) -> float: """Estimate the center of rotation with Nabu.""" logger.info("Estimating center of rotation using Nabu...") try: cor = CenterOfRotation().find_shift( proj_0, np.fliplr(proj_180), return_relative_to_middle=False, ) logger.info(f"Estimated center of rotation: {cor}") return float(cor) except Exception as e: logger.error(f"Failed to estimate center of rotation: {e}.") raise RuntimeError("Center of rotation estimation failed.") def _get_image_stream_name(self, scan) -> str: """Get the image stream name from the scan.""" return get_image_stream_name(scan) def _get_motor_stream_name(self, scan, motor_name: str) -> str: """Get the motor stream name from the scan.""" for name in scan.streams: if motor_name in name and "axis:" in name: return name raise ValueError(f"No motor stream found for {motor_name} in scan") def _wait_for_stream_length(self, scan, stream, idx_end, stream_name): """Block until ``stream`` holds at least ``idx_end`` items. Polls the scan for new data and raises ``TimeoutError`` if the scan stops before enough items arrive, so a missing or short stream can never hang the task indefinitely. """ waited_time = 0 while len(stream) < idx_end: logger.debug( f"Waiting for data in stream {stream_name} ({len(stream)}/{idx_end})..." ) time.sleep(self.WAITING_INTERVAL) scan.update(block=False) # Only start the timeout counter after the scan has stopped. if scan.state >= ScanState.STOPPED: waited_time += 1 if waited_time > self.STREAM_TIMEOUT: raise TimeoutError( f"Not enough data in stream {stream_name} before scan " f"stopped. Got {len(stream)}, needed {idx_end}" ) def _get_projections_from_stream( self, scan, projections_indices, output_dtype=np.float32 ) -> np.ndarray: """Get projections from the image stream.""" stream_name = get_image_stream_name(scan) detector_stream = scan.streams[stream_name] idx_start, idx_end = projections_indices[0], projections_indices[-1] + 1 self._wait_for_stream_length(scan, detector_stream, idx_end, stream_name) return np.array(detector_stream[idx_start:idx_end], dtype=output_dtype) def _get_angles_from_stream( self, scan, rotation_motor: str, projections_indices, output_dtype=np.float32 ) -> np.ndarray: """Get angles from the motor stream.""" motor_stream_name = self._get_motor_stream_name(scan, rotation_motor) motor_stream = scan.streams[motor_stream_name] idx_start, idx_end = projections_indices[0], projections_indices[-1] + 1 self._wait_for_stream_length(scan, motor_stream, idx_end, motor_stream_name) angles_deg = np.array(motor_stream[idx_start:idx_end], dtype=output_dtype) angles_rad = np.deg2rad(angles_deg) return angles_rad