Module ctsimu.scenario

Read, write, handle and manipulate a CTSimU scenario.

Reading and writing scenario files

To read a scenario file, you can pass it to the constructor of a Scenario object:

# -*- coding: UTF-8 -*-
# File: examples/scenario/01_read_scenario.py

from ctsimu.scenario import Scenario
s = Scenario("example.json")

You can also employ the function Scenario.read():

from ctsimu.scenario import Scenario
s = Scenario()
s.read("example.json")

To write a scenario file, use the Scenario.write() function.

from ctsimu.scenario import Scenario
s = Scenario()
s.read("example.json")
s.write("example_new.json")

Scenarios are always written in the currently supported file format version of the CTSimU scenario file format. Any additional information from the originally imported scenario file that was stored in non-standard parameters, such as comments, is lost. However, the "simulation" section of the scenario file, which stores proprietary, simulation-software specific parameters, is completely preserved.

Manipulating scenarios

Parameter getters and setters

A Scenario object provides the same sub-object structure as the JSON structure of a CTSimU scenario.

In the toolbox, each parameter of the scenario is an object of the class Parameter. It provides a function Parameter.get() to get its current value and a function Parameter.set() to set its standard and current value.

The current value is the value that the parameter has in the frame that is currently set (see below). Because parameter values can drift throughout a CT scan, the current value of a parameter depends on the current frame number. The standard value is the parameter value without any drifts considered.

In the following example, we get the value of the environment temperature and then set it to a new value.

# -*- coding: UTF-8 -*-
# File: examples/scenario/02_getters_and_setters.py

from ctsimu.scenario import Scenario
s = Scenario("example.json")

# Get current temperature:
T = s.environment.temperature.get()
print(f"Currently, it's {T}°C in the room.")
# Currently, it's 20°C in the room.

# Set new temperature to 23°C:
s.environment.temperature.set(23)

# Get new temperature:
T = s.environment.temperature.get()
print(f"Now, it's {T}°C in the room.")
# Currently, it's 23°C in the room.

# Native unit and preferred unit:
print(s.environment.temperature.native_unit)
print(s.environment.temperature.preferred_unit)

Units

Within the toolbox, parameter values are always in their native units, which means the getter functions return values in native units and the setter functions expect a value in the parameter's native unit. The following table gives an overview of the native units used in the toolbox.

Quantity Native unit
dimensionless None
Length "mm"
Angle "rad" by default,
"deg" for start_angle and stop_angle of stage
Time "s"
Current "mA
Voltage "kV
Mass density "g/cm^3"
Spatial frequency "lp/mm"
Angular velocity "deg/s"
Temperature "C"
Boolean "bool"
String "string"

There are also further dummy units which the toolbox accepts in scenario descriptions, but handles them like no unit at all (dimensionless): "px", "1/J", "relative".

Additionally, each parameter has a preferred unit. This is the parameter's unit used in the JSON scenario file. When writing a scenario file, the value is converted from the internal native unit to the preferred unit.

Frames

Setting the frame

A scenario defines a CT trajectory, typically a rotation of the sample stage. While the motion takes place, projection images are taken: each projection image represents a scenario frame with its specific CT geometry.

To set the frame number for the scenario, use the function Scenario.set_frame(). The function accepts the frame number (starting at zero) and as second argument a Boolean value that decides if the geometry should be set up as seen by the reconstruction software (True): any drifts and deviations that are not known_to_reconstruction will be ignored in this case.

# -*- coding: UTF-8 -*-
# File: examples/scenario/03_set_frame.py

from ctsimu.scenario import Scenario
s = Scenario("example.json")

s.set_frame(frame=10, reconstruction=False)
geo = s.current_geometry()

print("Stage coordinate system for frame 10:")
print(geo.stage)

"""
Stage coordinate system for frame 10:
Center: [275.   0.   0.]
u:      [-0.93896467  0.34045905 -0.04932528]
v:      [-0.34274809 -0.93813153  0.04932528]
w:      [-0.02948036  0.06322084  0.99756405]
"""

Geometry of the current frame

After the scenario frame is set, the current Geometry can be accessed through the function Scenario.current_geometry(). This can be helpful if you want to calculate projection matrices for each frame or use the coordinate systems of the individual parts for something else. The function Scenario.n_frames() returns the number of frames defined in the scenario.

The following example shows how to iterate through the frames of a scenario, calculate a projection matrix for each frame, and access the coordinate systems of the scenario's parts (X-ray source, stage, detector and samples).

It also shows how to transform the sample's coordinate system into world coordinates. Sample coordinates are given in terms of the stage coordinate system if the sample is attached to the stage (the usual behaviour). Here, the method CoordinateSystem.change_reference_frame() from the geometry sub-module is used to transform the sample coordinate system.

# -*- coding: UTF-8 -*-
# File: examples/scenario/04_frame_geometry.py

from ctsimu.scenario import Scenario
import ctsimu.geometry # for ctsimu_world
s = Scenario("example.json")

for frame_number in range(s.n_frames()):
    # Set frame number:
    s.set_frame(frame=frame_number, reconstruction=False)

    # Calculate projection matrix:
    geo = s.current_geometry()
    pmatrix = geo.projection_matrix(mode="OpenCT")

    # Individual coordinate systems for this frame:
    cs_source   = s.source.coordinate_system
    cs_stage    = s.stage.coordinate_system
    cs_detector = s.detector.coordinate_system

    # First sample in stage coordinate system:
    sample    = s.samples[0]  # get first sample
    cs_sample = sample.coordinate_system.get_copy()

    # Transform sample coordinate system from
    # stage to world coordinates if it is
    # attached to the stage:
    if sample.is_attached_to_stage():
        cs_sample.change_reference_frame(
            cs_from=cs_stage,
            cs_to=ctsimu.geometry.ctsimu_world
        )

    print(f"Frame {frame_number}")
    print("================")
    print("Stage coordinates in world:")
    print(cs_stage)

    print(f"Sample coordinates in world:")
    print(cs_sample)

Reconstruction config files

OpenCT & CERA

The toolbox can generate reconstruction configuration files for SIEMENS CERA and in the OpenCT format (used by VGSTUDIO) directly from a scenario file. The simplest way is to use the functions Scenario.write_CERA_config() and Scenario.write_OpenCT_config():

# -*- coding: UTF-8 -*-
# File: examples/scenario/05_reconstruction_configs.py

from ctsimu.scenario import Scenario
s = Scenario("example.json")

s.write_CERA_config(
    save_dir="cera_recon",
    basename="example",
    create_vgi=True
)

s.write_OpenCT_config(
    save_dir="openct_recon",
    basename="example",
    create_vgi=True,
    variant="free",
    abspaths=True
)

For a detailed explanation of the parameters, please see the documentation of the two functions.

Projection and volume parameters

Reconstructions need to read in projection images and will create a tomogram volume as output. In the example above, information about the projection images and tomogram volume is generated automatically. For example, the number of voxels in the tomogram and the voxel size are calculated automatically from the magnification and detector geometry.

If you want to change these parameters, you must change the scenario's metadata before you create the config files. If you have a metadata file that describes the projections and tomogram volume, you can import this file directly after reading the scenario:

from ctsimu.scenario import Scenario
s = Scenario("example.json")
s.read_metadata("recon_metadata.json")

It is also possible to set up the scenario's metadata manually by following the same structure as defined in the metadata file. In the following example, the projection image files are assumed to be sequentially numbered (four digits), starting at zero.

# -*- coding: UTF-8 -*-
# File: examples/scenario/06_reconstruction_metadata

from ctsimu.scenario import Scenario
s = Scenario("example.json")

# Information about RAW projection images:
s.metadata.output.projections.filename.set("example_%04d.raw")
s.metadata.output.projections.datatype.set("float32")
s.metadata.output.projections.byteorder.set("little")
s.metadata.output.projections.headersize.file.set(1024)

# ...or just TIFF files:
s.metadata.output.projections.filename.set("example_%04d.tif")

# Projection image size:
s.metadata.output.projections.dimensions.x.set(1500) # pixels
s.metadata.output.projections.dimensions.y.set(1000) # pixels
s.metadata.output.projections.pixelsize.x.set(0.1) # mm
s.metadata.output.projections.pixelsize.y.set(0.1) # mm

# Maximum free-beam intensity gray value:
s.metadata.output.projections.max_intensity.set(44000)

# No flat and dark field images; let's assume
# projections are already corrected:
s.metadata.output.projections.dark_field.filename.set(None)
s.metadata.output.projections.flat_field.filename.set(None)

# Information about the tomogram:
s.metadata.output.tomogram.filename.set("example_recon.raw")
s.metadata.output.tomogram.datatype.set("uint16")
s.metadata.output.tomogram.byteorder.set("little")

# Tomogram size:
s.metadata.output.tomogram.dimensions.x.set(1500) # voxels
s.metadata.output.tomogram.dimensions.y.set(1500) # voxels
s.metadata.output.tomogram.dimensions.z.set(1000) # voxels
s.metadata.output.tomogram.voxelsize.x.set(0.05) # mm
s.metadata.output.tomogram.voxelsize.y.set(0.05) # mm
s.metadata.output.tomogram.voxelsize.z.set(0.05) # mm

s.write_CERA_config(
    save_dir="cera_recon",
    basename="example",
    create_vgi=True
)

s.write_OpenCT_config(
    save_dir="openct_recon",
    basename="example",
    create_vgi=True,
    variant="free",
    abspaths=True
)
Expand source code
# -*- coding: UTF-8 -*-
"""Read, write, handle and manipulate a CTSimU scenario.

.. include:: ./documentation.md
"""

import math
import os
from datetime import datetime

from ..helpers import *
from ..geometry import *
from . import *

from .detector import Detector
from .source import Source
from .stage import Stage
from .sample import Sample
from .file import File
from .environment import Environment
from .acquisition import Acquisition
from .material import Material
from .metadata import Metadata

class Scenario:
    """Representation of a CTSimU scenario. Reads, writes, manages
    and manipulates scenarios.

    Attributes
    ----------
    detector : ctsimu.scenario.detector.Detector
        Detector object of the scenario.

    source : ctsimu.scenario.source.Source
        X-ray source object of the scenario.

    stage : ctsimu.scenario.stage.Stage
        Sample stage object of the scenario.

    samples : list
        List of samples in the scenario, each an object of class
        `ctsimu.scenario.sample.Sample`.

    file : ctsimu.scenario.file.File
        Information about the scenario file.

    environment : ctsimu.scenario.environment.Environment
        Environment properties of the scenario.

    acquisition : ctsimu.scenario.acquisition.Acquisition
        CT acquisition parameters.

    materials : list
        List of materials used in the scenario, each an object of
        class `ctsimu.scenario.material.Material`.

    simulation : dict
        Dictionary of simulation software-specific properties
        from the scenario file.

    metadata : ctsimu.scenario.metadata.Metadata
        Metadata information about projection images and
        the reconstruction volume. This information depends on the output
        of the CT scanner or simulation software, not on the scenario
        itself.
    """
    def __init__(self, filename:str=None, json_dict:dict=None):
        """The scenario can optionally constructed by passing the path to
        a scenario file or by passing a Python dictionary that contains
        scenario information:

        Parameters
        ----------
        filename : str, optional
            Path to a [CTSimU scenario] file.
            [CTSimU scenario]: https://bamresearch.github.io/ctsimu-scenarios

        json_dict : dict, optional
            A Python dictionary containing a CTSimU scenario.
            The dictionary must obey the same structure as a
            CTSimU JSON scenario file.
        """

        self.detector = Detector(_root=self)
        self.source   = Source(_root=self)
        self.stage    = Stage(_root=self)
        self.samples  = list()

        self.file = File(_root=self)
        self.environment = Environment(_root=self)
        self.acquisition = Acquisition(_root=self)
        self.materials = list()
        self.simulation = None # simply imported as dict

        # CTSimU metadata structure:
        # Necessary for generation of reconstruction configs.
        self.metadata = Metadata(_root=self)

        # Subgroups are necessary for the 'get' and 'set' command:
        self.subgroups = [
            self.detector,
            self.source,
            self.stage,
            self.file,
            self.environment,
            self.acquisition,
            self.metadata
        ]

        self.current_frame = 0
        self.current_scenario_path = None
        self.current_scenario_file = None
        self.current_scenario_basename = None
        self.current_scenario_directory = None

        self.current_metadata_path = None
        self.current_metadata_file = None
        self.current_metadata_basename = None
        self.current_metadata_directory = None
        self.metadata_is_set = False

        if filename is not None:
            self.read(filename=filename)
        elif json_dict is not None:
            self.read(json_dict=json_dict)

    def read(self, filename:str=None, json_dict:dict=None):
        """Import a CTSimU scenario from a file or a given
        scenario dictionary.

        Parameters
        ----------
        filename : str
            Path to a CTSimU scenario file.

            Default value: `None`

        json_dict : dict
            Provide a dictionary with a scenario structure instead of a file.

            Default value: `None`
        """
        self.current_scenario_path = None
        self.reset()

        if filename is not None:
            json_dict = read_json_file(filename=filename)
            self.current_scenario_path = filename
            self.current_scenario_directory = os.path.dirname(filename)
            self.current_scenario_file = os.path.basename(filename)
            self.current_scenario_basename, extension = os.path.splitext(self.current_scenario_file)
        elif not isinstance(json_dict, dict):
            raise Exception("Scenario: read() function expects either a filename as a string or a CTSimU JSON dictionary as a Python dict.")
            return False

        # If a file is read, we want to make sure that it is a valid
        # and supported scenario file:
        if isinstance(json_dict, dict):
            file_type = get_value(json_dict, ["file", "file_type"])
            if file_type != "CTSimU Scenario":
                raise Exception(f"Invalid scenario structure: the string 'CTSimU Scenario' was not found in 'file.file_type' in the scenario file {file}.")

            file_format_version = get_value(json_dict, ["file", "file_format_version"])
            if not is_version_supported(ctsimu_supported_scenario_version, file_format_version):
                raise Exception(f"Unsupported or invalid metadata version. Currently supported: up to {ctsimu_supported_metadata_version['major']}.{ctsimu_supported_metadata_version['minor']}.")
        else:
            raise Exception(f"Error when reading the scenario file: {filename}. read_json_file did not return a Python dictionary.")


        self.detector.set_from_json(json_dict)
        self.source.set_from_json(json_dict)
        self.stage.set_from_json(json_dict)

        json_samples = json_extract(json_dict, ["samples"])
        if json_samples is not None:
            for json_sample in json_samples:
                s = Sample(_root=self)
                s.set_from_json(json_sample, self.stage.coordinate_system)
                self.samples.append(s)

        self.file.set_from_json(json_extract(json_dict, [self.file._name]))
        self.environment.set_from_json(json_extract(json_dict, [self.environment._name]))
        self.acquisition.set_from_json(json_extract(json_dict, [self.acquisition._name]))
        self.simulation = json_extract(json_dict, ["simulation"])

        json_materials = json_extract(json_dict, ["materials"])
        for json_material in json_materials:
            m = Material(_root=self)
            m.set_from_json(json_material)
            self.materials.append(m)

        self.set_frame(0, reconstruction=False)

        if not self.metadata_is_set:
            self.create_default_metadata()

    def reset(self):
        """Reset scenario: delete all drifts, deviations and
        sample and material information."""

        for group in self.subgroups:
            group.reset()

    def reset_metadata(self):
        """Reset scenario's metadata information."""
        self.current_metadata_path = None
        self.current_metadata_file = None
        self.current_metadata_basename = None
        self.current_metadata_directory = None
        self.metadata_is_set = False

        # Create new, empty metadata:
        self.metadata = Metadata()

    def read_metadata(self, filename:str=None, json_dict:dict=None, import_referenced_scenario:bool=False):
        """Import metadata from a CTSimU metadata file or a given
        metadata dictionary.

        Parameters
        ----------
        filename : str
            Path to a CTSimU metadata file.

            Default value: `None`

        json_dict : dict
            Provide a dictionary with a metadata structure instead of a file.

            Default value: `None`

        import_referenced_scenario : bool
            Import the scenario JSON file that's referenced in the metadata file?
            Generates a warning if this fails.

            The scenario definition will be searched at two locations in the following order:

            1. Try to read from external file defined by `acquisition_geometry.path_to_CTSimU_JSON`.

            2. Try to read embedded scenario definition from
            `simulation.ctsimu_scenario`. Note that external drift files
            specified in the scenario will likely fail to load. An error
            will be issued in this case.

            Default value: `False`
        """
        if filename is not None:
            json_dict = read_json_file(filename=filename)
            self.current_metadata_path = filename
            self.current_metadata_directory = os.path.dirname(filename)
            self.current_metadata_file = os.path.basename(filename)
            self.current_metadata_basename, extension = os.path.splitext(self.current_metadata_file)

        # If a file is read, we want to make sure that it is a valid
        # and supported metadata file:
        if isinstance(json_dict, dict):
            file_type = get_value(json_dict, ["file", "file_type"])
            if file_type != "CTSimU Metadata":
                raise Exception(f"Invalid metadata structure: the string 'CTSimU Metadata' was not found in 'file.file_type' in the metadata file {file}.")

            fileformatversion = get_value(json_dict, ["file", "file_format_version"])
            if not is_version_supported(ctsimu_supported_metadata_version, fileformatversion):
                raise Exception(f"Unsupported or invalid metadata version. Currently supported: up to {ctsimu_supported_metadata_version['major']}.{ctsimu_supported_metadata_version['minor']}.")
        else:
            raise Exception(f"Error when reading the metadata file: {filename}. read_json_file did not return a Python dictionary.")

        # If we get a `json_dict` as function parameter, we do not
        # test for a valid version because reduced/simplified metadata
        # structures should be supported as well.
        self.metadata.set_from_json(json_dict)
        self.metadata_is_set = True

        if import_referenced_scenario:
            # Import the scenario that's referenced in the metadata file.
            ref_file = self.metadata.get(["acquisition_geometry", "path_to_CTSimU_JSON"])

            import_success = False

            try:
                if (ref_file is not None) and (ref_file != ""):
                    if isinstance(ref_file, str):
                        ref_file = abspath_of_referenced_file(self.current_metadata_path, ref_file)
                    else:
                        raise Exception("read_metadata: path_to_CTSimU_JSON is not a string.")

                    # Try to import scenario:
                    self.read(filename=ref_file)
                    import_success = True
            except Exception as e:
                warnings.warn(str(e))
                import_success = False

            if not import_success:
                # Try to import the embedded scenario structure.
                if json_exists_and_not_null(json_dict, ["simulation", "ctsimu_scenario"]):
                    self.read(json_dict=json_dict["simulation"]["ctsimu_scenario"])
                    import_success = True

            # Create default metadata in case the original
            # metadata file did not contain all information that's needed:
            self.create_default_metadata()
            # Re-import metadata:
            self.metadata.set_from_json(json_dict)


    def create_default_metadata(self):
        """Set default metadata from scenario information,
        to use if no metadata file is available."""
        self.reset_metadata()

        geo = self.current_geometry()
        cera_parameters = geo.get_CERA_standard_circular_parameters()

        # Basename:
        if self.current_scenario_basename is not None:
            self.current_metadata_basename = self.current_scenario_basename

        basename = self.current_metadata_basename
        n_projections = self.acquisition.get("number_of_projections")
        projection_filename = f"{basename}_{counter_format(n_projections)}.raw"

        # Prepare filename for dark fields:
        n_darks = self.acquisition.dark_field.get("number")
        dark_filename = None
        if n_darks is not None:
            if n_darks > 0:
                if n_darks > 1:
                    dark_filename = f"{basename}_dark_{counter_format(n_darks)}.raw"
                else:
                    dark_filename = f"{basename}_dark.raw"

        # Prepare filename for flat fields:
        n_flats = self.acquisition.flat_field.get("number")
        flat_filename = None
        if n_flats is not None:
            if n_flats > 0:
                if n_flats > 1:
                    flat_filename = f"{basename}_flat_{counter_format(n_flats)}.raw"
                else:
                    flat_filename = f"{basename}_flat.raw"


        n_cols = self.detector.get("columns")
        n_rows = self.detector.get("rows")
        pixel_size_u = self.detector.pixel_pitch.get("u")
        pixel_size_v = self.detector.pixel_pitch.get("v")

        voxels_x = n_cols
        voxels_y = n_cols
        voxels_z = n_rows

        voxelsize_x = cera_parameters["voxelsize"]["x"]
        voxelsize_y = cera_parameters["voxelsize"]["y"]
        voxelsize_z = cera_parameters["voxelsize"]["z"]

        now = datetime.now()

        metadata = {
            "file": {
                "name": basename,
                "description": self.file.get("description"),

                "contact": self.file.get("contact"),
                "date_created": now.strftime("%Y-%m-%d"),
                "date_changed": now.strftime("%Y-%m-%d"),

                "file_type": "CTSimU Metadata",
                "file_format_version": {
                    "major": ctsimu_supported_metadata_version["major"],
                    "minor": ctsimu_supported_metadata_version["minor"]
                }
            },

            "output": {
                "system": None,
                "date_measured": None,
                "projections": {
                    "filename": projection_filename,
                    "number": n_projections,
                    "frame_average": self.acquisition.get("frame_average"),
                    "max_intensity": self.detector.get(["gray_value", "imax"]),
                    "datatype": "uint16",
                    "byteorder": "little",
                    "headersize": {
                        "file": 0,
                        "image": 0
                    },
                    "dimensions": {
                        "x": {"value": n_cols, "unit": "px"},
                        "y": {"value": n_rows, "unit": "px"}
                    },
                    "pixelsize": {
                        "x": {"value": pixel_size_u, "unit": "mm"},
                        "y": {"value": pixel_size_v, "unit": "mm"}
                    },
                    "dark_field": {
                        "number": n_darks,
                        "frame_average": self.acquisition.dark_field.get("frame_average"),
                        "filename": dark_filename,
                        "projections_corrected": False
                    },
                    "flat_field": {
                        "number": n_flats,
                        "frame_average": self.acquisition.flat_field.get("frame_average"),
                        "filename": flat_filename,
                        "projections_corrected": False
                    },
                    "bad_pixel_map": {
                        "filename": None,
                        "projections_corrected": False
                    }
                },
                "tomogram":
                {
                    "filename":  f"{basename}_recon.raw",
                    "datatype":  "float32",
                    "byteorder": "little",
                    "headersize": {
                        "file": 0,
                        "image": 0
                    },
                    "dimensions": {
                        "x": {"value": voxels_x, "unit": "px"},
                        "y": {"value": voxels_y, "unit": "px"},
                        "z": {"value": voxels_z, "unit": "px"}
                    },
                    "voxelsize": {
                        "x": {"value": voxelsize_x, "unit": "mm"},
                        "y": {"value": voxelsize_y, "unit": "mm"},
                        "z": {"value": voxelsize_z, "unit": "mm"}
                    }
                }
            },

            "acquisition_geometry": {
                "path_to_CTSimU_JSON": self.current_scenario_path
            },

            "reconstruction": {
                "software": None,
                "settings": { }
            },

            "simulation": {
                "ctsimu_scenario": None
            }
        }

        self.read_metadata(json_dict=metadata, import_referenced_scenario=False)

    def write(self, filename:str):
        """Write a scenario JSON file.

        Parameters
        ----------
        filename : str
            Filename for the scenario file.
        """
        if filename is not None:
            self.file.file_format_version.set("major", ctsimu_supported_scenario_version["major"])
            self.file.file_format_version.set("minor", ctsimu_supported_scenario_version["minor"])

            write_json_file(filename=filename, dictionary=self.json_dict())

    def write_metadata(self, filename:str):
        """Write a metadata JSON file for the scenario.

        Parameters
        ----------
        filename : str
            Filename for the metadata file.
        """
        if filename is not None:
            metadata_dict = self.metadata.json_dict()
            # potentially add simulation.ctsimu_scenario here:
            metadata_dict["simulation"]["ctsimu_scenario"] = self.json_dict()
            write_json_file(filename=filename, dictionary=metadata_dict)

    def get(self, key:list) -> float | str | bool:
        """Get the current value of the parameter identified by a list of keys.

        Parameters
        ----------
        key : list
            List of strings that identify the key of the requested
            parameter within the CTSimU scenario structure.

        Returns
        -------
        value : float or str or bool
            Current value of the requested parameter.
        """
        if isinstance(key, list):
            # Special treatment for the source geometry extras: type or beam_divergence:
            if len(key) > 2:
                if key[0:2] == ["geometry", "source"]:
                    return self.source.source_geometry_extras.get(key[2:])

            # Standard treatment:
            if len(key) > 1:
                for s in self.subgroups:
                    if s._name == key[0]:
                        return s.get(key[1:])

        raise Exception(f"Error in get: key not found: {key}")

    def path_of_external_file(self, filename:str) -> str:
        """Get the path of an external file referenced in the currently
        imported JSON scenario.

        Parameters
        ----------
        filename : str
            Possibly relative file path from JSON scenario file.

        Returns
        -------
        abs_path : str
            Absolute path to the referenced external file.
        """
        if os.path.isabs(filename):
            # Already absolute path?
            return filename

        if self.current_scenario_path is not None:
            if isinstance(self.current_scenario_path, str):
                json_dirname = os.path.dirname(self.current_scenario_path)
                filename = f"{json_dirname}/{filename}"

        # On fail, simply return the filename.
        return filename

    def json_dict(self) -> dict:
        """Create a CTSimU JSON dictionary from the scenario.

        Returns
        -------
        json_dict : dict
        """
        jd = dict()
        jd["file"]        = self.file.json_dict()
        jd["environment"] = self.environment.json_dict()

        jd["geometry"]    = dict()
        jd["geometry"]["detector"] = self.detector.geometry_dict()

        jd["geometry"]["source"] = self.source.geometry_dict()

        jd["geometry"]["stage"] = self.stage.geometry_dict()

        jd["detector"] = self.detector.json_dict()
        jd["source"]   = self.source.json_dict()
        jd["samples"]  = []
        for sample in self.samples:
            jd["samples"].append(sample.json_dict())

        jd["acquisition"] = self.acquisition.json_dict()
        jd["materials"] = []
        for material in self.materials:
            jd["materials"].append(material.json_dict())

        jd["simulation"] = self.simulation

        return jd

    def n_frames(self) -> int:
        """Number of frames in the scenario.

        Returns
        -------
        n_frames : int
            Number of frames in the scenario.
        """

        # 'Frame' is in this context a projection image.
        # However, if we assume frame averaging, the number of
        # frames could also be: n_frames = nProjection * nFrameAverages
        n_frames = self.acquisition.get("number_of_projections")
        return n_frames

    def current_stage_angle(self):
        """Stage rotation angle (in deg) for the current frame.

        Returns
        -------
        stage_rotation_angle : float
            Current stage rotation angle (in deg).
        """

        start_angle = float(self.acquisition.get("start_angle"))
        stop_angle  = float(self.acquisition.get("stop_angle"))
        nPositions  = float(self.n_frames())

        # If the final projection is taken at the stop angle
        # (and not one step before), the number of positions
        # has to be decreased by 1, resulting in one less
        # angular step being performed.
        if self.acquisition.get("include_final_angle") is True:
            if nPositions > 0:
                nPositions -= 1

        angular_range = 0
        if start_angle <= stop_angle:
            angular_range = stop_angle - start_angle
        else:
            raise Exception("The start angle cannot be greater than the stop angle. Scan direction must be specified by the acquisition 'direction' keyword (CCW or CW).")

        angular_position = start_angle
        if nPositions != 0:
            angular_position = start_angle + self.current_frame*angular_range/nPositions

        # Mathematically negative:
        if self.acquisition.get("direction") == "CW":
            angular_position = -angular_position

        return angular_position

    def set_frame(self, frame:float=0, reconstruction:bool=False):
        """Set the current frame representation of the scenario.

        Parameters
        ----------
        frame : float
            Frame number, starting at zero and usually
            going to `{n_projections}-1`.

            Default value: `0`

        reconstruction : bool
            Set the scenario geometry as seen by the reconstruction software?
            If `True`, this will only obey drifts and deviations that
            are `known_to_reconstruction`.

            Default value: `False`
        """

        self.current_frame = frame

        # Number of frames:
        n_frames = self.n_frames()

        stage_deg = self.current_stage_angle()
        stage_rot = math.radians(stage_deg)

        # Update materials:
        for material in self.materials:
            material.set_frame(frame, n_frames, reconstruction)

        # Update stage, source, detector and other parameters:
        self.stage.set_frame(frame, n_frames, stage_rot, None, reconstruction)
        self.source.set_frame(frame, n_frames, 0, None, reconstruction)
        self.detector.set_frame(frame, n_frames, 0, None, reconstruction)

        self.file.set_frame(frame, n_frames, reconstruction)
        self.environment.set_frame(frame, n_frames, reconstruction)
        self.acquisition.set_frame(frame, n_frames, reconstruction)

        # Update samples:
        stage_cs = self.stage.coordinate_system
        for sample in self.samples:
            sample.set_frame(frame, n_frames, 0, stage_cs, reconstruction)

    def current_geometry(self) -> 'ctsimu.geometry.Geometry':
        """Return a `ctsimu.geometry.Geometry` object for the
        current setup of the scenario.

        Returns
        -------
        geometry : ctsimu.geometry.Geometry
            Geometry for current frame.
        """
        geo = Geometry()
        geo.detector.copy_cs(self.detector.coordinate_system)
        geo.source.copy_cs(self.source.coordinate_system)
        geo.stage.copy_cs(self.stage.coordinate_system)

        geo.detector.set_size(
            pixels_u=self.detector.get("columns"),
            pixels_v=self.detector.get("rows"),
            pitch_u=self.detector.pixel_pitch.get("u"),
            pitch_v=self.detector.pixel_pitch.get("v")
        )

        return geo

    def write_recon_VGI(self, name:str="", volume_filename:str="", vgi_filename:str=None):
        """Write a VGI file for the reconstruction volume such that it can be loaded with VGSTUDIO."""

        voxels_x = self.metadata.output.get(["tomogram", "dimensions", "x"])
        voxels_y = self.metadata.output.get(["tomogram", "dimensions", "y"])
        voxels_z = self.metadata.output.get(["tomogram", "dimensions", "z"])

        voxelsize_x = self.metadata.output.get(["tomogram", "voxelsize", "x"])
        voxelsize_y = self.metadata.output.get(["tomogram", "voxelsize", "y"])
        voxelsize_z = self.metadata.output.get(["tomogram", "voxelsize", "z"])

        output_datatype = self.metadata.output.get(["tomogram", "datatype"])
        if output_datatype == "uint16":
            dataTypeOutput = "unsigned integer"
            bits = 16
            datarangelower = 0
            datarangeupper = -1
        else:
            dataTypeOutput = "float"
            bits = 32
            datarangelower = -1
            datarangeupper = 1

        vgi_content = f"""{{volume1}}
[representation]
size = {voxels_x} {voxels_y} {voxels_z}
datatype = {dataTypeOutput}
datarange = {datarangelower} {datarangeupper}
bitsperelement = {bits}
[file1]
SkipHeader = 0
FileFormat = raw
Size = {voxels_x} {voxels_y} {voxels_z}
Name = {volume_filename}
Datatype = {dataTypeOutput}
datarange = {datarangelower} {datarangeupper}
BitsPerElement = {bits}
{{volumeprimitive12}}
[geometry]
resolution = {voxelsize_x} {voxelsize_y} {voxelsize_z}
unit = mm
[volume]
volume = volume1
[description]
text = {name}"""

        if vgi_filename is not None:
            touch_directory(vgi_filename)
            with open(vgi_filename, 'w') as f:
                f.write(vgi_content)
                f.close()

        return vgi_content

    def write_CERA_config(self, save_dir:str=None, basename:str=None, create_vgi:bool=False):
        """Write CERA reconstruction config files.

        Parameters
        ----------
        save_dir : str
            Folder where to place the CERA config files. This is meant to be the
            same directory where the reconstruction metadata file is located,
            such that relative paths will match.

            If `None` is given, a directory will be inferred:

            - If only a JSON scenario file was imported to set up the scenario,
            the config files will be stored in a subdirectory next to the
            JSON scenario file of the following pattern:

                `{json_scenario_basename}/reconstruction`

            - If a reconstruction metadata file was imported, the config files
            will be stored next to the metadata file.

            Default value: `None`

        basename : str
            Base name for the created files. If `None` is given, the base
            name will be inferred from the scenario's metadata.

            Default value: `None`

        create_vgi : bool
            Write VGI file for future reconstruction volume?
        """

        matrices = []

        if basename is None:
            # Extract base name from metadata
            metadata_basename = self.metadata.get(["file", "name"])
            if metadata_basename is not None:
                basename = f"{metadata_basename}_recon_cera"
            else:
                basename = f"recon_cera"

        # Projection files
        n_projections = self.acquisition.get("number_of_projections")
        projection_file_pattern = self.metadata.get(["output", "projections", "filename"])
        projection_datatype = self.metadata.get(["output", "projections", "datatype"])
        projection_file_byteorder = self.metadata.get(["output", "projections", "byteorder"])
        projection_headersize = self.metadata.get(["output", "projections", "headersize", "file"])

        projection_filetype = "tiff"
        if projection_file_pattern.lower().endswith(".raw"):
            projection_filetype = "raw"

        # Acquisition
        start_angle = self.acquisition.get("start_angle")
        stop_angle  = self.acquisition.get("stop_angle")
        total_angle = stop_angle - start_angle

        for p in range(n_projections):
            self.set_frame(frame=p, reconstruction=True)

            # CERA projection matrix for projection p:
            m = self.current_geometry().projection_matrix(mode="CERA")
            matrices.append(m)

        # Go back to frame zero:
        self.set_frame(frame=0, reconstruction=True)

        if create_vgi:
            vgi_filename = join_dir_and_filename(save_dir, f"{basename}.vgi")
            volume_filename = f"{basename}.raw"

            self.write_recon_VGI(vgi_filename=vgi_filename, name=basename, volume_filename=volume_filename)

        create_CERA_config(
            geo=self.current_geometry(),
            projection_file_pattern=projection_file_pattern,
            basename=basename,
            save_dir=save_dir,
            n_projections=n_projections,
            projection_datatype=projection_datatype,
            projection_filetype=projection_filetype,
            projection_byteorder=projection_file_byteorder,
            projection_headersize=projection_headersize,
            start_angle=0,  # do not compensate the scenario start angle in the reconstruction
            total_angle=total_angle,
            scan_direction=self.acquisition.get("direction"),
            voxels_x=self.metadata.output.tomogram.dimensions.x.get(),
            voxels_y=self.metadata.output.tomogram.dimensions.y.get(),
            voxels_z=self.metadata.output.tomogram.dimensions.z.get(),
            voxelsize_x=self.metadata.output.tomogram.voxelsize.x.get(),
            voxelsize_y=self.metadata.output.tomogram.voxelsize.y.get(),
            voxelsize_z=self.metadata.output.tomogram.voxelsize.z.get(),
            i0max=self.metadata.output.get(["projections", "max_intensity"]),
            output_datatype=convert(cera_converter["datatype"], self.metadata.output.get(["tomogram", "datatype"])),
            matrices=matrices
        )

    def write_OpenCT_config(self, save_dir:str=None, basename:str=None, create_vgi:bool=False, variant:str='free', abspaths:bool=False):
        """Write OpenCT reconstruction config files.

        Parameters
        ----------
        save_dir : str
            Folder where to place the CERA config files. This is meant to be the
            same directory where the reconstruction metadata file is located,
            such that relative paths will match.

            If `None` is given, a directory will be inferred:

            - If only a JSON scenario file was imported to set up the scenario,
            the config files will be stored in a subdirectory next to the
            JSON scenario file of the following pattern:

                `{json_scenario_basename}/reconstruction`

            - If a reconstruction metadata file was imported, the config files
            will be stored next to the metadata file.

            Default value: `None`

        basename : str
            Base name for the created config file. If `None` is given, the base
            name will be inferred from the scenario's metadata.

            Default value: `None`

        create_vgi : bool
            Write VGI file for future reconstruction volume?

        variant : str
            Which variant of the OpenCT file format will be created: free trajectory
            or circular trajectory.

            Possible values: `"free"`, `"circular"`

            Default value: `"free"`

        abspaths : bool
            Set to `True` if absolute paths should be used in the OpenCT
            config file.

            Default value: `False` (relative paths)

        Returns
        -------
        openct_dict : dict
            Dictionary with the OpenCT JSON structure.
        """

        matrices = []

        if basename is None:
            # Extract base name from metadata
            metadata_basename = self.metadata.get(["file", "name"])
            if metadata_basename is not None:
                basename = f"{metadata_basename}_recon_openCT"
            else:
                basename = f"recon_openCT"

        # Name of config file:
        openct_config_filename = join_dir_and_filename(save_dir, f"{basename}.json")

        def projections_from_pattern(json_dict:dict):
            n = get_value(json_dict, ["number"]) # number of images
            pattern  = None
            filedir  = None
            filename = None
            filelist = list()

            if n is not None:
                if n > 0:
                    pattern = get_value(json_dict, ["filename"])
                    if abspaths is True:
                        pattern = abspath_of_referenced_file(openct_config_filename, pattern)

                    if pattern is not None:
                        filedir, filename = os.path.split(pattern)

                    # Generate list of projection files:
                    if filename is not None:
                        # Auto-generate projection file list.
                        # List of sequentially numbered projection images,
                        # starting at 0000.
                        for p in range(n):
                            if '%' in filename:
                                try:
                                    filelist.append(filename % p)
                                except Exception as e:
                                    raise Exception(f"Error in sequentially numbered filename pattern. Please give a percentage sign, followed by the number of digits and a 'd' character: 'example_%04d.tif'. You gave: '{filename}'")
                            else:
                                filelist.append(filename)

            return n, filedir, filename, filelist

        # Projection files
        n_projections, projection_filedir, projection_filename, projection_filelist = projections_from_pattern(self.metadata.output.projections.json_dict())

        projection_datatype = self.metadata.get(["output", "projections", "datatype"])
        projection_file_byteorder = self.metadata.get(["output", "projections", "byteorder"])
        projection_headersize = self.metadata.get(["output", "projections", "headersize", "file"])

        projection_filetype = "tiff"
        if projection_filename is not None:
            if projection_filename.lower().endswith(".raw"):
                projection_filetype = "raw"

        # Dark files; only is corrections need to be applied.
        openct_dark_image = None
        if not self.metadata.output.projections.dark_field.projections_corrected.get() is True:
            n_darks, dark_filedir, dark_filename, dark_filelist = projections_from_pattern(self.metadata.output.projections.dark_field.json_dict())
            if isinstance(dark_filelist, list):
                if len(dark_filelist) > 0:
                    openct_dark_image = join_dir_and_filename(dark_filedir, dark_filelist[0])

        # Bright files; only is corrections need to be applied.
        flat_filedir = None
        flat_filelist = None
        if not self.metadata.output.projections.flat_field.projections_corrected.get() is True:
            n_flats, flat_filedir, flat_filename, flat_filelist = projections_from_pattern(self.metadata.output.projections.flat_field.json_dict())

        # Acquisition
        start_angle = self.acquisition.get("start_angle")
        stop_angle  = self.acquisition.get("stop_angle")
        total_angle = stop_angle - start_angle

        for p in range(n_projections):
            self.set_frame(frame=p, reconstruction=True)

            # CERA projection matrix for projection p:
            m = self.current_geometry().projection_matrix(mode="OpenCT")
            matrices.append(m)

        # Go back to frame zero:
        self.set_frame(frame=0, reconstruction=True)

        volume_filename = f"{basename}.img"
        if create_vgi:
            vgi_filename = join_dir_and_filename(save_dir, f"{basename}.vgi")

            self.write_recon_VGI(vgi_filename=vgi_filename, name=basename, volume_filename=volume_filename)

        openct_dict = create_OpenCT_config(
            geo=self.current_geometry(),
            filename=openct_config_filename,
            variant=variant,
            projection_files=projection_filelist,
            projection_dir=projection_filedir,
            projection_datatype=projection_datatype,
            projection_filetype=projection_filetype,
            projection_headersize=projection_headersize,
            projection_byteorder=projection_file_byteorder,
            total_angle=total_angle,
            scan_direction=self.acquisition.get("direction"),
            matrices=matrices,
            volumename=volume_filename,
            voxels_x=self.metadata.output.tomogram.dimensions.x.get(),
            voxels_y=self.metadata.output.tomogram.dimensions.y.get(),
            voxels_z=self.metadata.output.tomogram.dimensions.z.get(),
            voxelsize_x=self.metadata.output.tomogram.voxelsize.x.get(),
            voxelsize_y=self.metadata.output.tomogram.voxelsize.y.get(),
            voxelsize_z=self.metadata.output.tomogram.voxelsize.z.get(),
            bright_image_dir=flat_filedir,
            bright_images=flat_filelist,
            dark_image=openct_dark_image)

        return openct_dict

Sub-modules

ctsimu.scenario.acquisition

Collection of parameters that describe the scan acquisition.

ctsimu.scenario.detector

A CTSimU detector: position, orientation, size, and other parameters.

ctsimu.scenario.deviation

Geometrical deviation of a coordinate system, i.e. a translation or a rotation with respect to an arbitrary axis.

ctsimu.scenario.drift

Drift structure for a Parameter.

ctsimu.scenario.environment

Collection of parameters that describe the environment.

ctsimu.scenario.file

Collection of parameters that describe the scenario file.

ctsimu.scenario.group

Groups are collections of parameters.

ctsimu.scenario.material

Material component: formula and mass fraction. Material: composition, density, and their drifts

ctsimu.scenario.metadata

CTSimU metadata of a CT scan.

ctsimu.scenario.parameter

Parameter value, includes unit conversion and handling of parameter drifts.

ctsimu.scenario.part

Parts are objects in the scene: detector, source, stage and samples.

ctsimu.scenario.sample

Generic sample: position and orientation, size and material properties.

ctsimu.scenario.scenevector

A scene vector is a 3D vector that knows its reference coordinate system. It can be converted between these coordinate systems and handles drifts.

ctsimu.scenario.source

A CTSimU X-ray source: position, orientation, size, and other parameters.

ctsimu.scenario.stage

A CTSimU sample stage: position, orientation.

Classes

class Scenario (filename: str = None, json_dict: dict = None)

Representation of a CTSimU scenario. Reads, writes, manages and manipulates scenarios.

Attributes

detector : Detector
Detector object of the scenario.
source : Source
X-ray source object of the scenario.
stage : Stage
Sample stage object of the scenario.
samples : list
List of samples in the scenario, each an object of class Sample.
file : File
Information about the scenario file.
environment : Environment
Environment properties of the scenario.
acquisition : Acquisition
CT acquisition parameters.
materials : list
List of materials used in the scenario, each an object of class Material.
simulation : dict
Dictionary of simulation software-specific properties from the scenario file.
metadata : Metadata
Metadata information about projection images and the reconstruction volume. This information depends on the output of the CT scanner or simulation software, not on the scenario itself.

The scenario can optionally constructed by passing the path to a scenario file or by passing a Python dictionary that contains scenario information:

Parameters

filename : str, optional
Path to a CTSimU scenario file.
json_dict : dict, optional
A Python dictionary containing a CTSimU scenario. The dictionary must obey the same structure as a CTSimU JSON scenario file.
Expand source code
class Scenario:
    """Representation of a CTSimU scenario. Reads, writes, manages
    and manipulates scenarios.

    Attributes
    ----------
    detector : ctsimu.scenario.detector.Detector
        Detector object of the scenario.

    source : ctsimu.scenario.source.Source
        X-ray source object of the scenario.

    stage : ctsimu.scenario.stage.Stage
        Sample stage object of the scenario.

    samples : list
        List of samples in the scenario, each an object of class
        `ctsimu.scenario.sample.Sample`.

    file : ctsimu.scenario.file.File
        Information about the scenario file.

    environment : ctsimu.scenario.environment.Environment
        Environment properties of the scenario.

    acquisition : ctsimu.scenario.acquisition.Acquisition
        CT acquisition parameters.

    materials : list
        List of materials used in the scenario, each an object of
        class `ctsimu.scenario.material.Material`.

    simulation : dict
        Dictionary of simulation software-specific properties
        from the scenario file.

    metadata : ctsimu.scenario.metadata.Metadata
        Metadata information about projection images and
        the reconstruction volume. This information depends on the output
        of the CT scanner or simulation software, not on the scenario
        itself.
    """
    def __init__(self, filename:str=None, json_dict:dict=None):
        """The scenario can optionally constructed by passing the path to
        a scenario file or by passing a Python dictionary that contains
        scenario information:

        Parameters
        ----------
        filename : str, optional
            Path to a [CTSimU scenario] file.
            [CTSimU scenario]: https://bamresearch.github.io/ctsimu-scenarios

        json_dict : dict, optional
            A Python dictionary containing a CTSimU scenario.
            The dictionary must obey the same structure as a
            CTSimU JSON scenario file.
        """

        self.detector = Detector(_root=self)
        self.source   = Source(_root=self)
        self.stage    = Stage(_root=self)
        self.samples  = list()

        self.file = File(_root=self)
        self.environment = Environment(_root=self)
        self.acquisition = Acquisition(_root=self)
        self.materials = list()
        self.simulation = None # simply imported as dict

        # CTSimU metadata structure:
        # Necessary for generation of reconstruction configs.
        self.metadata = Metadata(_root=self)

        # Subgroups are necessary for the 'get' and 'set' command:
        self.subgroups = [
            self.detector,
            self.source,
            self.stage,
            self.file,
            self.environment,
            self.acquisition,
            self.metadata
        ]

        self.current_frame = 0
        self.current_scenario_path = None
        self.current_scenario_file = None
        self.current_scenario_basename = None
        self.current_scenario_directory = None

        self.current_metadata_path = None
        self.current_metadata_file = None
        self.current_metadata_basename = None
        self.current_metadata_directory = None
        self.metadata_is_set = False

        if filename is not None:
            self.read(filename=filename)
        elif json_dict is not None:
            self.read(json_dict=json_dict)

    def read(self, filename:str=None, json_dict:dict=None):
        """Import a CTSimU scenario from a file or a given
        scenario dictionary.

        Parameters
        ----------
        filename : str
            Path to a CTSimU scenario file.

            Default value: `None`

        json_dict : dict
            Provide a dictionary with a scenario structure instead of a file.

            Default value: `None`
        """
        self.current_scenario_path = None
        self.reset()

        if filename is not None:
            json_dict = read_json_file(filename=filename)
            self.current_scenario_path = filename
            self.current_scenario_directory = os.path.dirname(filename)
            self.current_scenario_file = os.path.basename(filename)
            self.current_scenario_basename, extension = os.path.splitext(self.current_scenario_file)
        elif not isinstance(json_dict, dict):
            raise Exception("Scenario: read() function expects either a filename as a string or a CTSimU JSON dictionary as a Python dict.")
            return False

        # If a file is read, we want to make sure that it is a valid
        # and supported scenario file:
        if isinstance(json_dict, dict):
            file_type = get_value(json_dict, ["file", "file_type"])
            if file_type != "CTSimU Scenario":
                raise Exception(f"Invalid scenario structure: the string 'CTSimU Scenario' was not found in 'file.file_type' in the scenario file {file}.")

            file_format_version = get_value(json_dict, ["file", "file_format_version"])
            if not is_version_supported(ctsimu_supported_scenario_version, file_format_version):
                raise Exception(f"Unsupported or invalid metadata version. Currently supported: up to {ctsimu_supported_metadata_version['major']}.{ctsimu_supported_metadata_version['minor']}.")
        else:
            raise Exception(f"Error when reading the scenario file: {filename}. read_json_file did not return a Python dictionary.")


        self.detector.set_from_json(json_dict)
        self.source.set_from_json(json_dict)
        self.stage.set_from_json(json_dict)

        json_samples = json_extract(json_dict, ["samples"])
        if json_samples is not None:
            for json_sample in json_samples:
                s = Sample(_root=self)
                s.set_from_json(json_sample, self.stage.coordinate_system)
                self.samples.append(s)

        self.file.set_from_json(json_extract(json_dict, [self.file._name]))
        self.environment.set_from_json(json_extract(json_dict, [self.environment._name]))
        self.acquisition.set_from_json(json_extract(json_dict, [self.acquisition._name]))
        self.simulation = json_extract(json_dict, ["simulation"])

        json_materials = json_extract(json_dict, ["materials"])
        for json_material in json_materials:
            m = Material(_root=self)
            m.set_from_json(json_material)
            self.materials.append(m)

        self.set_frame(0, reconstruction=False)

        if not self.metadata_is_set:
            self.create_default_metadata()

    def reset(self):
        """Reset scenario: delete all drifts, deviations and
        sample and material information."""

        for group in self.subgroups:
            group.reset()

    def reset_metadata(self):
        """Reset scenario's metadata information."""
        self.current_metadata_path = None
        self.current_metadata_file = None
        self.current_metadata_basename = None
        self.current_metadata_directory = None
        self.metadata_is_set = False

        # Create new, empty metadata:
        self.metadata = Metadata()

    def read_metadata(self, filename:str=None, json_dict:dict=None, import_referenced_scenario:bool=False):
        """Import metadata from a CTSimU metadata file or a given
        metadata dictionary.

        Parameters
        ----------
        filename : str
            Path to a CTSimU metadata file.

            Default value: `None`

        json_dict : dict
            Provide a dictionary with a metadata structure instead of a file.

            Default value: `None`

        import_referenced_scenario : bool
            Import the scenario JSON file that's referenced in the metadata file?
            Generates a warning if this fails.

            The scenario definition will be searched at two locations in the following order:

            1. Try to read from external file defined by `acquisition_geometry.path_to_CTSimU_JSON`.

            2. Try to read embedded scenario definition from
            `simulation.ctsimu_scenario`. Note that external drift files
            specified in the scenario will likely fail to load. An error
            will be issued in this case.

            Default value: `False`
        """
        if filename is not None:
            json_dict = read_json_file(filename=filename)
            self.current_metadata_path = filename
            self.current_metadata_directory = os.path.dirname(filename)
            self.current_metadata_file = os.path.basename(filename)
            self.current_metadata_basename, extension = os.path.splitext(self.current_metadata_file)

        # If a file is read, we want to make sure that it is a valid
        # and supported metadata file:
        if isinstance(json_dict, dict):
            file_type = get_value(json_dict, ["file", "file_type"])
            if file_type != "CTSimU Metadata":
                raise Exception(f"Invalid metadata structure: the string 'CTSimU Metadata' was not found in 'file.file_type' in the metadata file {file}.")

            fileformatversion = get_value(json_dict, ["file", "file_format_version"])
            if not is_version_supported(ctsimu_supported_metadata_version, fileformatversion):
                raise Exception(f"Unsupported or invalid metadata version. Currently supported: up to {ctsimu_supported_metadata_version['major']}.{ctsimu_supported_metadata_version['minor']}.")
        else:
            raise Exception(f"Error when reading the metadata file: {filename}. read_json_file did not return a Python dictionary.")

        # If we get a `json_dict` as function parameter, we do not
        # test for a valid version because reduced/simplified metadata
        # structures should be supported as well.
        self.metadata.set_from_json(json_dict)
        self.metadata_is_set = True

        if import_referenced_scenario:
            # Import the scenario that's referenced in the metadata file.
            ref_file = self.metadata.get(["acquisition_geometry", "path_to_CTSimU_JSON"])

            import_success = False

            try:
                if (ref_file is not None) and (ref_file != ""):
                    if isinstance(ref_file, str):
                        ref_file = abspath_of_referenced_file(self.current_metadata_path, ref_file)
                    else:
                        raise Exception("read_metadata: path_to_CTSimU_JSON is not a string.")

                    # Try to import scenario:
                    self.read(filename=ref_file)
                    import_success = True
            except Exception as e:
                warnings.warn(str(e))
                import_success = False

            if not import_success:
                # Try to import the embedded scenario structure.
                if json_exists_and_not_null(json_dict, ["simulation", "ctsimu_scenario"]):
                    self.read(json_dict=json_dict["simulation"]["ctsimu_scenario"])
                    import_success = True

            # Create default metadata in case the original
            # metadata file did not contain all information that's needed:
            self.create_default_metadata()
            # Re-import metadata:
            self.metadata.set_from_json(json_dict)


    def create_default_metadata(self):
        """Set default metadata from scenario information,
        to use if no metadata file is available."""
        self.reset_metadata()

        geo = self.current_geometry()
        cera_parameters = geo.get_CERA_standard_circular_parameters()

        # Basename:
        if self.current_scenario_basename is not None:
            self.current_metadata_basename = self.current_scenario_basename

        basename = self.current_metadata_basename
        n_projections = self.acquisition.get("number_of_projections")
        projection_filename = f"{basename}_{counter_format(n_projections)}.raw"

        # Prepare filename for dark fields:
        n_darks = self.acquisition.dark_field.get("number")
        dark_filename = None
        if n_darks is not None:
            if n_darks > 0:
                if n_darks > 1:
                    dark_filename = f"{basename}_dark_{counter_format(n_darks)}.raw"
                else:
                    dark_filename = f"{basename}_dark.raw"

        # Prepare filename for flat fields:
        n_flats = self.acquisition.flat_field.get("number")
        flat_filename = None
        if n_flats is not None:
            if n_flats > 0:
                if n_flats > 1:
                    flat_filename = f"{basename}_flat_{counter_format(n_flats)}.raw"
                else:
                    flat_filename = f"{basename}_flat.raw"


        n_cols = self.detector.get("columns")
        n_rows = self.detector.get("rows")
        pixel_size_u = self.detector.pixel_pitch.get("u")
        pixel_size_v = self.detector.pixel_pitch.get("v")

        voxels_x = n_cols
        voxels_y = n_cols
        voxels_z = n_rows

        voxelsize_x = cera_parameters["voxelsize"]["x"]
        voxelsize_y = cera_parameters["voxelsize"]["y"]
        voxelsize_z = cera_parameters["voxelsize"]["z"]

        now = datetime.now()

        metadata = {
            "file": {
                "name": basename,
                "description": self.file.get("description"),

                "contact": self.file.get("contact"),
                "date_created": now.strftime("%Y-%m-%d"),
                "date_changed": now.strftime("%Y-%m-%d"),

                "file_type": "CTSimU Metadata",
                "file_format_version": {
                    "major": ctsimu_supported_metadata_version["major"],
                    "minor": ctsimu_supported_metadata_version["minor"]
                }
            },

            "output": {
                "system": None,
                "date_measured": None,
                "projections": {
                    "filename": projection_filename,
                    "number": n_projections,
                    "frame_average": self.acquisition.get("frame_average"),
                    "max_intensity": self.detector.get(["gray_value", "imax"]),
                    "datatype": "uint16",
                    "byteorder": "little",
                    "headersize": {
                        "file": 0,
                        "image": 0
                    },
                    "dimensions": {
                        "x": {"value": n_cols, "unit": "px"},
                        "y": {"value": n_rows, "unit": "px"}
                    },
                    "pixelsize": {
                        "x": {"value": pixel_size_u, "unit": "mm"},
                        "y": {"value": pixel_size_v, "unit": "mm"}
                    },
                    "dark_field": {
                        "number": n_darks,
                        "frame_average": self.acquisition.dark_field.get("frame_average"),
                        "filename": dark_filename,
                        "projections_corrected": False
                    },
                    "flat_field": {
                        "number": n_flats,
                        "frame_average": self.acquisition.flat_field.get("frame_average"),
                        "filename": flat_filename,
                        "projections_corrected": False
                    },
                    "bad_pixel_map": {
                        "filename": None,
                        "projections_corrected": False
                    }
                },
                "tomogram":
                {
                    "filename":  f"{basename}_recon.raw",
                    "datatype":  "float32",
                    "byteorder": "little",
                    "headersize": {
                        "file": 0,
                        "image": 0
                    },
                    "dimensions": {
                        "x": {"value": voxels_x, "unit": "px"},
                        "y": {"value": voxels_y, "unit": "px"},
                        "z": {"value": voxels_z, "unit": "px"}
                    },
                    "voxelsize": {
                        "x": {"value": voxelsize_x, "unit": "mm"},
                        "y": {"value": voxelsize_y, "unit": "mm"},
                        "z": {"value": voxelsize_z, "unit": "mm"}
                    }
                }
            },

            "acquisition_geometry": {
                "path_to_CTSimU_JSON": self.current_scenario_path
            },

            "reconstruction": {
                "software": None,
                "settings": { }
            },

            "simulation": {
                "ctsimu_scenario": None
            }
        }

        self.read_metadata(json_dict=metadata, import_referenced_scenario=False)

    def write(self, filename:str):
        """Write a scenario JSON file.

        Parameters
        ----------
        filename : str
            Filename for the scenario file.
        """
        if filename is not None:
            self.file.file_format_version.set("major", ctsimu_supported_scenario_version["major"])
            self.file.file_format_version.set("minor", ctsimu_supported_scenario_version["minor"])

            write_json_file(filename=filename, dictionary=self.json_dict())

    def write_metadata(self, filename:str):
        """Write a metadata JSON file for the scenario.

        Parameters
        ----------
        filename : str
            Filename for the metadata file.
        """
        if filename is not None:
            metadata_dict = self.metadata.json_dict()
            # potentially add simulation.ctsimu_scenario here:
            metadata_dict["simulation"]["ctsimu_scenario"] = self.json_dict()
            write_json_file(filename=filename, dictionary=metadata_dict)

    def get(self, key:list) -> float | str | bool:
        """Get the current value of the parameter identified by a list of keys.

        Parameters
        ----------
        key : list
            List of strings that identify the key of the requested
            parameter within the CTSimU scenario structure.

        Returns
        -------
        value : float or str or bool
            Current value of the requested parameter.
        """
        if isinstance(key, list):
            # Special treatment for the source geometry extras: type or beam_divergence:
            if len(key) > 2:
                if key[0:2] == ["geometry", "source"]:
                    return self.source.source_geometry_extras.get(key[2:])

            # Standard treatment:
            if len(key) > 1:
                for s in self.subgroups:
                    if s._name == key[0]:
                        return s.get(key[1:])

        raise Exception(f"Error in get: key not found: {key}")

    def path_of_external_file(self, filename:str) -> str:
        """Get the path of an external file referenced in the currently
        imported JSON scenario.

        Parameters
        ----------
        filename : str
            Possibly relative file path from JSON scenario file.

        Returns
        -------
        abs_path : str
            Absolute path to the referenced external file.
        """
        if os.path.isabs(filename):
            # Already absolute path?
            return filename

        if self.current_scenario_path is not None:
            if isinstance(self.current_scenario_path, str):
                json_dirname = os.path.dirname(self.current_scenario_path)
                filename = f"{json_dirname}/{filename}"

        # On fail, simply return the filename.
        return filename

    def json_dict(self) -> dict:
        """Create a CTSimU JSON dictionary from the scenario.

        Returns
        -------
        json_dict : dict
        """
        jd = dict()
        jd["file"]        = self.file.json_dict()
        jd["environment"] = self.environment.json_dict()

        jd["geometry"]    = dict()
        jd["geometry"]["detector"] = self.detector.geometry_dict()

        jd["geometry"]["source"] = self.source.geometry_dict()

        jd["geometry"]["stage"] = self.stage.geometry_dict()

        jd["detector"] = self.detector.json_dict()
        jd["source"]   = self.source.json_dict()
        jd["samples"]  = []
        for sample in self.samples:
            jd["samples"].append(sample.json_dict())

        jd["acquisition"] = self.acquisition.json_dict()
        jd["materials"] = []
        for material in self.materials:
            jd["materials"].append(material.json_dict())

        jd["simulation"] = self.simulation

        return jd

    def n_frames(self) -> int:
        """Number of frames in the scenario.

        Returns
        -------
        n_frames : int
            Number of frames in the scenario.
        """

        # 'Frame' is in this context a projection image.
        # However, if we assume frame averaging, the number of
        # frames could also be: n_frames = nProjection * nFrameAverages
        n_frames = self.acquisition.get("number_of_projections")
        return n_frames

    def current_stage_angle(self):
        """Stage rotation angle (in deg) for the current frame.

        Returns
        -------
        stage_rotation_angle : float
            Current stage rotation angle (in deg).
        """

        start_angle = float(self.acquisition.get("start_angle"))
        stop_angle  = float(self.acquisition.get("stop_angle"))
        nPositions  = float(self.n_frames())

        # If the final projection is taken at the stop angle
        # (and not one step before), the number of positions
        # has to be decreased by 1, resulting in one less
        # angular step being performed.
        if self.acquisition.get("include_final_angle") is True:
            if nPositions > 0:
                nPositions -= 1

        angular_range = 0
        if start_angle <= stop_angle:
            angular_range = stop_angle - start_angle
        else:
            raise Exception("The start angle cannot be greater than the stop angle. Scan direction must be specified by the acquisition 'direction' keyword (CCW or CW).")

        angular_position = start_angle
        if nPositions != 0:
            angular_position = start_angle + self.current_frame*angular_range/nPositions

        # Mathematically negative:
        if self.acquisition.get("direction") == "CW":
            angular_position = -angular_position

        return angular_position

    def set_frame(self, frame:float=0, reconstruction:bool=False):
        """Set the current frame representation of the scenario.

        Parameters
        ----------
        frame : float
            Frame number, starting at zero and usually
            going to `{n_projections}-1`.

            Default value: `0`

        reconstruction : bool
            Set the scenario geometry as seen by the reconstruction software?
            If `True`, this will only obey drifts and deviations that
            are `known_to_reconstruction`.

            Default value: `False`
        """

        self.current_frame = frame

        # Number of frames:
        n_frames = self.n_frames()

        stage_deg = self.current_stage_angle()
        stage_rot = math.radians(stage_deg)

        # Update materials:
        for material in self.materials:
            material.set_frame(frame, n_frames, reconstruction)

        # Update stage, source, detector and other parameters:
        self.stage.set_frame(frame, n_frames, stage_rot, None, reconstruction)
        self.source.set_frame(frame, n_frames, 0, None, reconstruction)
        self.detector.set_frame(frame, n_frames, 0, None, reconstruction)

        self.file.set_frame(frame, n_frames, reconstruction)
        self.environment.set_frame(frame, n_frames, reconstruction)
        self.acquisition.set_frame(frame, n_frames, reconstruction)

        # Update samples:
        stage_cs = self.stage.coordinate_system
        for sample in self.samples:
            sample.set_frame(frame, n_frames, 0, stage_cs, reconstruction)

    def current_geometry(self) -> 'ctsimu.geometry.Geometry':
        """Return a `ctsimu.geometry.Geometry` object for the
        current setup of the scenario.

        Returns
        -------
        geometry : ctsimu.geometry.Geometry
            Geometry for current frame.
        """
        geo = Geometry()
        geo.detector.copy_cs(self.detector.coordinate_system)
        geo.source.copy_cs(self.source.coordinate_system)
        geo.stage.copy_cs(self.stage.coordinate_system)

        geo.detector.set_size(
            pixels_u=self.detector.get("columns"),
            pixels_v=self.detector.get("rows"),
            pitch_u=self.detector.pixel_pitch.get("u"),
            pitch_v=self.detector.pixel_pitch.get("v")
        )

        return geo

    def write_recon_VGI(self, name:str="", volume_filename:str="", vgi_filename:str=None):
        """Write a VGI file for the reconstruction volume such that it can be loaded with VGSTUDIO."""

        voxels_x = self.metadata.output.get(["tomogram", "dimensions", "x"])
        voxels_y = self.metadata.output.get(["tomogram", "dimensions", "y"])
        voxels_z = self.metadata.output.get(["tomogram", "dimensions", "z"])

        voxelsize_x = self.metadata.output.get(["tomogram", "voxelsize", "x"])
        voxelsize_y = self.metadata.output.get(["tomogram", "voxelsize", "y"])
        voxelsize_z = self.metadata.output.get(["tomogram", "voxelsize", "z"])

        output_datatype = self.metadata.output.get(["tomogram", "datatype"])
        if output_datatype == "uint16":
            dataTypeOutput = "unsigned integer"
            bits = 16
            datarangelower = 0
            datarangeupper = -1
        else:
            dataTypeOutput = "float"
            bits = 32
            datarangelower = -1
            datarangeupper = 1

        vgi_content = f"""{{volume1}}
[representation]
size = {voxels_x} {voxels_y} {voxels_z}
datatype = {dataTypeOutput}
datarange = {datarangelower} {datarangeupper}
bitsperelement = {bits}
[file1]
SkipHeader = 0
FileFormat = raw
Size = {voxels_x} {voxels_y} {voxels_z}
Name = {volume_filename}
Datatype = {dataTypeOutput}
datarange = {datarangelower} {datarangeupper}
BitsPerElement = {bits}
{{volumeprimitive12}}
[geometry]
resolution = {voxelsize_x} {voxelsize_y} {voxelsize_z}
unit = mm
[volume]
volume = volume1
[description]
text = {name}"""

        if vgi_filename is not None:
            touch_directory(vgi_filename)
            with open(vgi_filename, 'w') as f:
                f.write(vgi_content)
                f.close()

        return vgi_content

    def write_CERA_config(self, save_dir:str=None, basename:str=None, create_vgi:bool=False):
        """Write CERA reconstruction config files.

        Parameters
        ----------
        save_dir : str
            Folder where to place the CERA config files. This is meant to be the
            same directory where the reconstruction metadata file is located,
            such that relative paths will match.

            If `None` is given, a directory will be inferred:

            - If only a JSON scenario file was imported to set up the scenario,
            the config files will be stored in a subdirectory next to the
            JSON scenario file of the following pattern:

                `{json_scenario_basename}/reconstruction`

            - If a reconstruction metadata file was imported, the config files
            will be stored next to the metadata file.

            Default value: `None`

        basename : str
            Base name for the created files. If `None` is given, the base
            name will be inferred from the scenario's metadata.

            Default value: `None`

        create_vgi : bool
            Write VGI file for future reconstruction volume?
        """

        matrices = []

        if basename is None:
            # Extract base name from metadata
            metadata_basename = self.metadata.get(["file", "name"])
            if metadata_basename is not None:
                basename = f"{metadata_basename}_recon_cera"
            else:
                basename = f"recon_cera"

        # Projection files
        n_projections = self.acquisition.get("number_of_projections")
        projection_file_pattern = self.metadata.get(["output", "projections", "filename"])
        projection_datatype = self.metadata.get(["output", "projections", "datatype"])
        projection_file_byteorder = self.metadata.get(["output", "projections", "byteorder"])
        projection_headersize = self.metadata.get(["output", "projections", "headersize", "file"])

        projection_filetype = "tiff"
        if projection_file_pattern.lower().endswith(".raw"):
            projection_filetype = "raw"

        # Acquisition
        start_angle = self.acquisition.get("start_angle")
        stop_angle  = self.acquisition.get("stop_angle")
        total_angle = stop_angle - start_angle

        for p in range(n_projections):
            self.set_frame(frame=p, reconstruction=True)

            # CERA projection matrix for projection p:
            m = self.current_geometry().projection_matrix(mode="CERA")
            matrices.append(m)

        # Go back to frame zero:
        self.set_frame(frame=0, reconstruction=True)

        if create_vgi:
            vgi_filename = join_dir_and_filename(save_dir, f"{basename}.vgi")
            volume_filename = f"{basename}.raw"

            self.write_recon_VGI(vgi_filename=vgi_filename, name=basename, volume_filename=volume_filename)

        create_CERA_config(
            geo=self.current_geometry(),
            projection_file_pattern=projection_file_pattern,
            basename=basename,
            save_dir=save_dir,
            n_projections=n_projections,
            projection_datatype=projection_datatype,
            projection_filetype=projection_filetype,
            projection_byteorder=projection_file_byteorder,
            projection_headersize=projection_headersize,
            start_angle=0,  # do not compensate the scenario start angle in the reconstruction
            total_angle=total_angle,
            scan_direction=self.acquisition.get("direction"),
            voxels_x=self.metadata.output.tomogram.dimensions.x.get(),
            voxels_y=self.metadata.output.tomogram.dimensions.y.get(),
            voxels_z=self.metadata.output.tomogram.dimensions.z.get(),
            voxelsize_x=self.metadata.output.tomogram.voxelsize.x.get(),
            voxelsize_y=self.metadata.output.tomogram.voxelsize.y.get(),
            voxelsize_z=self.metadata.output.tomogram.voxelsize.z.get(),
            i0max=self.metadata.output.get(["projections", "max_intensity"]),
            output_datatype=convert(cera_converter["datatype"], self.metadata.output.get(["tomogram", "datatype"])),
            matrices=matrices
        )

    def write_OpenCT_config(self, save_dir:str=None, basename:str=None, create_vgi:bool=False, variant:str='free', abspaths:bool=False):
        """Write OpenCT reconstruction config files.

        Parameters
        ----------
        save_dir : str
            Folder where to place the CERA config files. This is meant to be the
            same directory where the reconstruction metadata file is located,
            such that relative paths will match.

            If `None` is given, a directory will be inferred:

            - If only a JSON scenario file was imported to set up the scenario,
            the config files will be stored in a subdirectory next to the
            JSON scenario file of the following pattern:

                `{json_scenario_basename}/reconstruction`

            - If a reconstruction metadata file was imported, the config files
            will be stored next to the metadata file.

            Default value: `None`

        basename : str
            Base name for the created config file. If `None` is given, the base
            name will be inferred from the scenario's metadata.

            Default value: `None`

        create_vgi : bool
            Write VGI file for future reconstruction volume?

        variant : str
            Which variant of the OpenCT file format will be created: free trajectory
            or circular trajectory.

            Possible values: `"free"`, `"circular"`

            Default value: `"free"`

        abspaths : bool
            Set to `True` if absolute paths should be used in the OpenCT
            config file.

            Default value: `False` (relative paths)

        Returns
        -------
        openct_dict : dict
            Dictionary with the OpenCT JSON structure.
        """

        matrices = []

        if basename is None:
            # Extract base name from metadata
            metadata_basename = self.metadata.get(["file", "name"])
            if metadata_basename is not None:
                basename = f"{metadata_basename}_recon_openCT"
            else:
                basename = f"recon_openCT"

        # Name of config file:
        openct_config_filename = join_dir_and_filename(save_dir, f"{basename}.json")

        def projections_from_pattern(json_dict:dict):
            n = get_value(json_dict, ["number"]) # number of images
            pattern  = None
            filedir  = None
            filename = None
            filelist = list()

            if n is not None:
                if n > 0:
                    pattern = get_value(json_dict, ["filename"])
                    if abspaths is True:
                        pattern = abspath_of_referenced_file(openct_config_filename, pattern)

                    if pattern is not None:
                        filedir, filename = os.path.split(pattern)

                    # Generate list of projection files:
                    if filename is not None:
                        # Auto-generate projection file list.
                        # List of sequentially numbered projection images,
                        # starting at 0000.
                        for p in range(n):
                            if '%' in filename:
                                try:
                                    filelist.append(filename % p)
                                except Exception as e:
                                    raise Exception(f"Error in sequentially numbered filename pattern. Please give a percentage sign, followed by the number of digits and a 'd' character: 'example_%04d.tif'. You gave: '{filename}'")
                            else:
                                filelist.append(filename)

            return n, filedir, filename, filelist

        # Projection files
        n_projections, projection_filedir, projection_filename, projection_filelist = projections_from_pattern(self.metadata.output.projections.json_dict())

        projection_datatype = self.metadata.get(["output", "projections", "datatype"])
        projection_file_byteorder = self.metadata.get(["output", "projections", "byteorder"])
        projection_headersize = self.metadata.get(["output", "projections", "headersize", "file"])

        projection_filetype = "tiff"
        if projection_filename is not None:
            if projection_filename.lower().endswith(".raw"):
                projection_filetype = "raw"

        # Dark files; only is corrections need to be applied.
        openct_dark_image = None
        if not self.metadata.output.projections.dark_field.projections_corrected.get() is True:
            n_darks, dark_filedir, dark_filename, dark_filelist = projections_from_pattern(self.metadata.output.projections.dark_field.json_dict())
            if isinstance(dark_filelist, list):
                if len(dark_filelist) > 0:
                    openct_dark_image = join_dir_and_filename(dark_filedir, dark_filelist[0])

        # Bright files; only is corrections need to be applied.
        flat_filedir = None
        flat_filelist = None
        if not self.metadata.output.projections.flat_field.projections_corrected.get() is True:
            n_flats, flat_filedir, flat_filename, flat_filelist = projections_from_pattern(self.metadata.output.projections.flat_field.json_dict())

        # Acquisition
        start_angle = self.acquisition.get("start_angle")
        stop_angle  = self.acquisition.get("stop_angle")
        total_angle = stop_angle - start_angle

        for p in range(n_projections):
            self.set_frame(frame=p, reconstruction=True)

            # CERA projection matrix for projection p:
            m = self.current_geometry().projection_matrix(mode="OpenCT")
            matrices.append(m)

        # Go back to frame zero:
        self.set_frame(frame=0, reconstruction=True)

        volume_filename = f"{basename}.img"
        if create_vgi:
            vgi_filename = join_dir_and_filename(save_dir, f"{basename}.vgi")

            self.write_recon_VGI(vgi_filename=vgi_filename, name=basename, volume_filename=volume_filename)

        openct_dict = create_OpenCT_config(
            geo=self.current_geometry(),
            filename=openct_config_filename,
            variant=variant,
            projection_files=projection_filelist,
            projection_dir=projection_filedir,
            projection_datatype=projection_datatype,
            projection_filetype=projection_filetype,
            projection_headersize=projection_headersize,
            projection_byteorder=projection_file_byteorder,
            total_angle=total_angle,
            scan_direction=self.acquisition.get("direction"),
            matrices=matrices,
            volumename=volume_filename,
            voxels_x=self.metadata.output.tomogram.dimensions.x.get(),
            voxels_y=self.metadata.output.tomogram.dimensions.y.get(),
            voxels_z=self.metadata.output.tomogram.dimensions.z.get(),
            voxelsize_x=self.metadata.output.tomogram.voxelsize.x.get(),
            voxelsize_y=self.metadata.output.tomogram.voxelsize.y.get(),
            voxelsize_z=self.metadata.output.tomogram.voxelsize.z.get(),
            bright_image_dir=flat_filedir,
            bright_images=flat_filelist,
            dark_image=openct_dark_image)

        return openct_dict

Methods

def create_default_metadata(self)

Set default metadata from scenario information, to use if no metadata file is available.

Expand source code
def create_default_metadata(self):
    """Set default metadata from scenario information,
    to use if no metadata file is available."""
    self.reset_metadata()

    geo = self.current_geometry()
    cera_parameters = geo.get_CERA_standard_circular_parameters()

    # Basename:
    if self.current_scenario_basename is not None:
        self.current_metadata_basename = self.current_scenario_basename

    basename = self.current_metadata_basename
    n_projections = self.acquisition.get("number_of_projections")
    projection_filename = f"{basename}_{counter_format(n_projections)}.raw"

    # Prepare filename for dark fields:
    n_darks = self.acquisition.dark_field.get("number")
    dark_filename = None
    if n_darks is not None:
        if n_darks > 0:
            if n_darks > 1:
                dark_filename = f"{basename}_dark_{counter_format(n_darks)}.raw"
            else:
                dark_filename = f"{basename}_dark.raw"

    # Prepare filename for flat fields:
    n_flats = self.acquisition.flat_field.get("number")
    flat_filename = None
    if n_flats is not None:
        if n_flats > 0:
            if n_flats > 1:
                flat_filename = f"{basename}_flat_{counter_format(n_flats)}.raw"
            else:
                flat_filename = f"{basename}_flat.raw"


    n_cols = self.detector.get("columns")
    n_rows = self.detector.get("rows")
    pixel_size_u = self.detector.pixel_pitch.get("u")
    pixel_size_v = self.detector.pixel_pitch.get("v")

    voxels_x = n_cols
    voxels_y = n_cols
    voxels_z = n_rows

    voxelsize_x = cera_parameters["voxelsize"]["x"]
    voxelsize_y = cera_parameters["voxelsize"]["y"]
    voxelsize_z = cera_parameters["voxelsize"]["z"]

    now = datetime.now()

    metadata = {
        "file": {
            "name": basename,
            "description": self.file.get("description"),

            "contact": self.file.get("contact"),
            "date_created": now.strftime("%Y-%m-%d"),
            "date_changed": now.strftime("%Y-%m-%d"),

            "file_type": "CTSimU Metadata",
            "file_format_version": {
                "major": ctsimu_supported_metadata_version["major"],
                "minor": ctsimu_supported_metadata_version["minor"]
            }
        },

        "output": {
            "system": None,
            "date_measured": None,
            "projections": {
                "filename": projection_filename,
                "number": n_projections,
                "frame_average": self.acquisition.get("frame_average"),
                "max_intensity": self.detector.get(["gray_value", "imax"]),
                "datatype": "uint16",
                "byteorder": "little",
                "headersize": {
                    "file": 0,
                    "image": 0
                },
                "dimensions": {
                    "x": {"value": n_cols, "unit": "px"},
                    "y": {"value": n_rows, "unit": "px"}
                },
                "pixelsize": {
                    "x": {"value": pixel_size_u, "unit": "mm"},
                    "y": {"value": pixel_size_v, "unit": "mm"}
                },
                "dark_field": {
                    "number": n_darks,
                    "frame_average": self.acquisition.dark_field.get("frame_average"),
                    "filename": dark_filename,
                    "projections_corrected": False
                },
                "flat_field": {
                    "number": n_flats,
                    "frame_average": self.acquisition.flat_field.get("frame_average"),
                    "filename": flat_filename,
                    "projections_corrected": False
                },
                "bad_pixel_map": {
                    "filename": None,
                    "projections_corrected": False
                }
            },
            "tomogram":
            {
                "filename":  f"{basename}_recon.raw",
                "datatype":  "float32",
                "byteorder": "little",
                "headersize": {
                    "file": 0,
                    "image": 0
                },
                "dimensions": {
                    "x": {"value": voxels_x, "unit": "px"},
                    "y": {"value": voxels_y, "unit": "px"},
                    "z": {"value": voxels_z, "unit": "px"}
                },
                "voxelsize": {
                    "x": {"value": voxelsize_x, "unit": "mm"},
                    "y": {"value": voxelsize_y, "unit": "mm"},
                    "z": {"value": voxelsize_z, "unit": "mm"}
                }
            }
        },

        "acquisition_geometry": {
            "path_to_CTSimU_JSON": self.current_scenario_path
        },

        "reconstruction": {
            "software": None,
            "settings": { }
        },

        "simulation": {
            "ctsimu_scenario": None
        }
    }

    self.read_metadata(json_dict=metadata, import_referenced_scenario=False)
def current_geometry(self) ‑> Geometry

Return a Geometry object for the current setup of the scenario.

Returns

geometry : Geometry
Geometry for current frame.
Expand source code
def current_geometry(self) -> 'ctsimu.geometry.Geometry':
    """Return a `ctsimu.geometry.Geometry` object for the
    current setup of the scenario.

    Returns
    -------
    geometry : ctsimu.geometry.Geometry
        Geometry for current frame.
    """
    geo = Geometry()
    geo.detector.copy_cs(self.detector.coordinate_system)
    geo.source.copy_cs(self.source.coordinate_system)
    geo.stage.copy_cs(self.stage.coordinate_system)

    geo.detector.set_size(
        pixels_u=self.detector.get("columns"),
        pixels_v=self.detector.get("rows"),
        pitch_u=self.detector.pixel_pitch.get("u"),
        pitch_v=self.detector.pixel_pitch.get("v")
    )

    return geo
def current_stage_angle(self)

Stage rotation angle (in deg) for the current frame.

Returns

stage_rotation_angle : float
Current stage rotation angle (in deg).
Expand source code
def current_stage_angle(self):
    """Stage rotation angle (in deg) for the current frame.

    Returns
    -------
    stage_rotation_angle : float
        Current stage rotation angle (in deg).
    """

    start_angle = float(self.acquisition.get("start_angle"))
    stop_angle  = float(self.acquisition.get("stop_angle"))
    nPositions  = float(self.n_frames())

    # If the final projection is taken at the stop angle
    # (and not one step before), the number of positions
    # has to be decreased by 1, resulting in one less
    # angular step being performed.
    if self.acquisition.get("include_final_angle") is True:
        if nPositions > 0:
            nPositions -= 1

    angular_range = 0
    if start_angle <= stop_angle:
        angular_range = stop_angle - start_angle
    else:
        raise Exception("The start angle cannot be greater than the stop angle. Scan direction must be specified by the acquisition 'direction' keyword (CCW or CW).")

    angular_position = start_angle
    if nPositions != 0:
        angular_position = start_angle + self.current_frame*angular_range/nPositions

    # Mathematically negative:
    if self.acquisition.get("direction") == "CW":
        angular_position = -angular_position

    return angular_position
def get(self, key: list) ‑> float | str | bool

Get the current value of the parameter identified by a list of keys.

Parameters

key : list
List of strings that identify the key of the requested parameter within the CTSimU scenario structure.

Returns

value : float or str or bool
Current value of the requested parameter.
Expand source code
def get(self, key:list) -> float | str | bool:
    """Get the current value of the parameter identified by a list of keys.

    Parameters
    ----------
    key : list
        List of strings that identify the key of the requested
        parameter within the CTSimU scenario structure.

    Returns
    -------
    value : float or str or bool
        Current value of the requested parameter.
    """
    if isinstance(key, list):
        # Special treatment for the source geometry extras: type or beam_divergence:
        if len(key) > 2:
            if key[0:2] == ["geometry", "source"]:
                return self.source.source_geometry_extras.get(key[2:])

        # Standard treatment:
        if len(key) > 1:
            for s in self.subgroups:
                if s._name == key[0]:
                    return s.get(key[1:])

    raise Exception(f"Error in get: key not found: {key}")
def json_dict(self) ‑> dict

Create a CTSimU JSON dictionary from the scenario.

Returns

json_dict : dict
 
Expand source code
def json_dict(self) -> dict:
    """Create a CTSimU JSON dictionary from the scenario.

    Returns
    -------
    json_dict : dict
    """
    jd = dict()
    jd["file"]        = self.file.json_dict()
    jd["environment"] = self.environment.json_dict()

    jd["geometry"]    = dict()
    jd["geometry"]["detector"] = self.detector.geometry_dict()

    jd["geometry"]["source"] = self.source.geometry_dict()

    jd["geometry"]["stage"] = self.stage.geometry_dict()

    jd["detector"] = self.detector.json_dict()
    jd["source"]   = self.source.json_dict()
    jd["samples"]  = []
    for sample in self.samples:
        jd["samples"].append(sample.json_dict())

    jd["acquisition"] = self.acquisition.json_dict()
    jd["materials"] = []
    for material in self.materials:
        jd["materials"].append(material.json_dict())

    jd["simulation"] = self.simulation

    return jd
def n_frames(self) ‑> int

Number of frames in the scenario.

Returns

n_frames : int
Number of frames in the scenario.
Expand source code
def n_frames(self) -> int:
    """Number of frames in the scenario.

    Returns
    -------
    n_frames : int
        Number of frames in the scenario.
    """

    # 'Frame' is in this context a projection image.
    # However, if we assume frame averaging, the number of
    # frames could also be: n_frames = nProjection * nFrameAverages
    n_frames = self.acquisition.get("number_of_projections")
    return n_frames
def path_of_external_file(self, filename: str) ‑> str

Get the path of an external file referenced in the currently imported JSON scenario.

Parameters

filename : str
Possibly relative file path from JSON scenario file.

Returns

abs_path : str
Absolute path to the referenced external file.
Expand source code
def path_of_external_file(self, filename:str) -> str:
    """Get the path of an external file referenced in the currently
    imported JSON scenario.

    Parameters
    ----------
    filename : str
        Possibly relative file path from JSON scenario file.

    Returns
    -------
    abs_path : str
        Absolute path to the referenced external file.
    """
    if os.path.isabs(filename):
        # Already absolute path?
        return filename

    if self.current_scenario_path is not None:
        if isinstance(self.current_scenario_path, str):
            json_dirname = os.path.dirname(self.current_scenario_path)
            filename = f"{json_dirname}/{filename}"

    # On fail, simply return the filename.
    return filename
def read(self, filename: str = None, json_dict: dict = None)

Import a CTSimU scenario from a file or a given scenario dictionary.

Parameters

filename : str

Path to a CTSimU scenario file.

Default value: None

json_dict : dict

Provide a dictionary with a scenario structure instead of a file.

Default value: None

Expand source code
def read(self, filename:str=None, json_dict:dict=None):
    """Import a CTSimU scenario from a file or a given
    scenario dictionary.

    Parameters
    ----------
    filename : str
        Path to a CTSimU scenario file.

        Default value: `None`

    json_dict : dict
        Provide a dictionary with a scenario structure instead of a file.

        Default value: `None`
    """
    self.current_scenario_path = None
    self.reset()

    if filename is not None:
        json_dict = read_json_file(filename=filename)
        self.current_scenario_path = filename
        self.current_scenario_directory = os.path.dirname(filename)
        self.current_scenario_file = os.path.basename(filename)
        self.current_scenario_basename, extension = os.path.splitext(self.current_scenario_file)
    elif not isinstance(json_dict, dict):
        raise Exception("Scenario: read() function expects either a filename as a string or a CTSimU JSON dictionary as a Python dict.")
        return False

    # If a file is read, we want to make sure that it is a valid
    # and supported scenario file:
    if isinstance(json_dict, dict):
        file_type = get_value(json_dict, ["file", "file_type"])
        if file_type != "CTSimU Scenario":
            raise Exception(f"Invalid scenario structure: the string 'CTSimU Scenario' was not found in 'file.file_type' in the scenario file {file}.")

        file_format_version = get_value(json_dict, ["file", "file_format_version"])
        if not is_version_supported(ctsimu_supported_scenario_version, file_format_version):
            raise Exception(f"Unsupported or invalid metadata version. Currently supported: up to {ctsimu_supported_metadata_version['major']}.{ctsimu_supported_metadata_version['minor']}.")
    else:
        raise Exception(f"Error when reading the scenario file: {filename}. read_json_file did not return a Python dictionary.")


    self.detector.set_from_json(json_dict)
    self.source.set_from_json(json_dict)
    self.stage.set_from_json(json_dict)

    json_samples = json_extract(json_dict, ["samples"])
    if json_samples is not None:
        for json_sample in json_samples:
            s = Sample(_root=self)
            s.set_from_json(json_sample, self.stage.coordinate_system)
            self.samples.append(s)

    self.file.set_from_json(json_extract(json_dict, [self.file._name]))
    self.environment.set_from_json(json_extract(json_dict, [self.environment._name]))
    self.acquisition.set_from_json(json_extract(json_dict, [self.acquisition._name]))
    self.simulation = json_extract(json_dict, ["simulation"])

    json_materials = json_extract(json_dict, ["materials"])
    for json_material in json_materials:
        m = Material(_root=self)
        m.set_from_json(json_material)
        self.materials.append(m)

    self.set_frame(0, reconstruction=False)

    if not self.metadata_is_set:
        self.create_default_metadata()
def read_metadata(self, filename: str = None, json_dict: dict = None, import_referenced_scenario: bool = False)

Import metadata from a CTSimU metadata file or a given metadata dictionary.

Parameters

filename : str

Path to a CTSimU metadata file.

Default value: None

json_dict : dict

Provide a dictionary with a metadata structure instead of a file.

Default value: None

import_referenced_scenario : bool

Import the scenario JSON file that's referenced in the metadata file? Generates a warning if this fails.

The scenario definition will be searched at two locations in the following order:

  1. Try to read from external file defined by acquisition_geometry.path_to_CTSimU_JSON.

  2. Try to read embedded scenario definition from simulation.ctsimu_scenario. Note that external drift files specified in the scenario will likely fail to load. An error will be issued in this case.

Default value: False

Expand source code
def read_metadata(self, filename:str=None, json_dict:dict=None, import_referenced_scenario:bool=False):
    """Import metadata from a CTSimU metadata file or a given
    metadata dictionary.

    Parameters
    ----------
    filename : str
        Path to a CTSimU metadata file.

        Default value: `None`

    json_dict : dict
        Provide a dictionary with a metadata structure instead of a file.

        Default value: `None`

    import_referenced_scenario : bool
        Import the scenario JSON file that's referenced in the metadata file?
        Generates a warning if this fails.

        The scenario definition will be searched at two locations in the following order:

        1. Try to read from external file defined by `acquisition_geometry.path_to_CTSimU_JSON`.

        2. Try to read embedded scenario definition from
        `simulation.ctsimu_scenario`. Note that external drift files
        specified in the scenario will likely fail to load. An error
        will be issued in this case.

        Default value: `False`
    """
    if filename is not None:
        json_dict = read_json_file(filename=filename)
        self.current_metadata_path = filename
        self.current_metadata_directory = os.path.dirname(filename)
        self.current_metadata_file = os.path.basename(filename)
        self.current_metadata_basename, extension = os.path.splitext(self.current_metadata_file)

    # If a file is read, we want to make sure that it is a valid
    # and supported metadata file:
    if isinstance(json_dict, dict):
        file_type = get_value(json_dict, ["file", "file_type"])
        if file_type != "CTSimU Metadata":
            raise Exception(f"Invalid metadata structure: the string 'CTSimU Metadata' was not found in 'file.file_type' in the metadata file {file}.")

        fileformatversion = get_value(json_dict, ["file", "file_format_version"])
        if not is_version_supported(ctsimu_supported_metadata_version, fileformatversion):
            raise Exception(f"Unsupported or invalid metadata version. Currently supported: up to {ctsimu_supported_metadata_version['major']}.{ctsimu_supported_metadata_version['minor']}.")
    else:
        raise Exception(f"Error when reading the metadata file: {filename}. read_json_file did not return a Python dictionary.")

    # If we get a `json_dict` as function parameter, we do not
    # test for a valid version because reduced/simplified metadata
    # structures should be supported as well.
    self.metadata.set_from_json(json_dict)
    self.metadata_is_set = True

    if import_referenced_scenario:
        # Import the scenario that's referenced in the metadata file.
        ref_file = self.metadata.get(["acquisition_geometry", "path_to_CTSimU_JSON"])

        import_success = False

        try:
            if (ref_file is not None) and (ref_file != ""):
                if isinstance(ref_file, str):
                    ref_file = abspath_of_referenced_file(self.current_metadata_path, ref_file)
                else:
                    raise Exception("read_metadata: path_to_CTSimU_JSON is not a string.")

                # Try to import scenario:
                self.read(filename=ref_file)
                import_success = True
        except Exception as e:
            warnings.warn(str(e))
            import_success = False

        if not import_success:
            # Try to import the embedded scenario structure.
            if json_exists_and_not_null(json_dict, ["simulation", "ctsimu_scenario"]):
                self.read(json_dict=json_dict["simulation"]["ctsimu_scenario"])
                import_success = True

        # Create default metadata in case the original
        # metadata file did not contain all information that's needed:
        self.create_default_metadata()
        # Re-import metadata:
        self.metadata.set_from_json(json_dict)
def reset(self)

Reset scenario: delete all drifts, deviations and sample and material information.

Expand source code
def reset(self):
    """Reset scenario: delete all drifts, deviations and
    sample and material information."""

    for group in self.subgroups:
        group.reset()
def reset_metadata(self)

Reset scenario's metadata information.

Expand source code
def reset_metadata(self):
    """Reset scenario's metadata information."""
    self.current_metadata_path = None
    self.current_metadata_file = None
    self.current_metadata_basename = None
    self.current_metadata_directory = None
    self.metadata_is_set = False

    # Create new, empty metadata:
    self.metadata = Metadata()
def set_frame(self, frame: float = 0, reconstruction: bool = False)

Set the current frame representation of the scenario.

Parameters

frame : float

Frame number, starting at zero and usually going to {n_projections}-1.

Default value: 0

reconstruction : bool

Set the scenario geometry as seen by the reconstruction software? If True, this will only obey drifts and deviations that are known_to_reconstruction.

Default value: False

Expand source code
def set_frame(self, frame:float=0, reconstruction:bool=False):
    """Set the current frame representation of the scenario.

    Parameters
    ----------
    frame : float
        Frame number, starting at zero and usually
        going to `{n_projections}-1`.

        Default value: `0`

    reconstruction : bool
        Set the scenario geometry as seen by the reconstruction software?
        If `True`, this will only obey drifts and deviations that
        are `known_to_reconstruction`.

        Default value: `False`
    """

    self.current_frame = frame

    # Number of frames:
    n_frames = self.n_frames()

    stage_deg = self.current_stage_angle()
    stage_rot = math.radians(stage_deg)

    # Update materials:
    for material in self.materials:
        material.set_frame(frame, n_frames, reconstruction)

    # Update stage, source, detector and other parameters:
    self.stage.set_frame(frame, n_frames, stage_rot, None, reconstruction)
    self.source.set_frame(frame, n_frames, 0, None, reconstruction)
    self.detector.set_frame(frame, n_frames, 0, None, reconstruction)

    self.file.set_frame(frame, n_frames, reconstruction)
    self.environment.set_frame(frame, n_frames, reconstruction)
    self.acquisition.set_frame(frame, n_frames, reconstruction)

    # Update samples:
    stage_cs = self.stage.coordinate_system
    for sample in self.samples:
        sample.set_frame(frame, n_frames, 0, stage_cs, reconstruction)
def write(self, filename: str)

Write a scenario JSON file.

Parameters

filename : str
Filename for the scenario file.
Expand source code
def write(self, filename:str):
    """Write a scenario JSON file.

    Parameters
    ----------
    filename : str
        Filename for the scenario file.
    """
    if filename is not None:
        self.file.file_format_version.set("major", ctsimu_supported_scenario_version["major"])
        self.file.file_format_version.set("minor", ctsimu_supported_scenario_version["minor"])

        write_json_file(filename=filename, dictionary=self.json_dict())
def write_CERA_config(self, save_dir: str = None, basename: str = None, create_vgi: bool = False)

Write CERA reconstruction config files.

Parameters

save_dir : str

Folder where to place the CERA config files. This is meant to be the same directory where the reconstruction metadata file is located, such that relative paths will match.

If None is given, a directory will be inferred:

  • If only a JSON scenario file was imported to set up the scenario, the config files will be stored in a subdirectory next to the JSON scenario file of the following pattern:

    {json_scenario_basename}/reconstruction

  • If a reconstruction metadata file was imported, the config files will be stored next to the metadata file.

Default value: None

basename : str

Base name for the created files. If None is given, the base name will be inferred from the scenario's metadata.

Default value: None

create_vgi : bool
Write VGI file for future reconstruction volume?
Expand source code
def write_CERA_config(self, save_dir:str=None, basename:str=None, create_vgi:bool=False):
    """Write CERA reconstruction config files.

    Parameters
    ----------
    save_dir : str
        Folder where to place the CERA config files. This is meant to be the
        same directory where the reconstruction metadata file is located,
        such that relative paths will match.

        If `None` is given, a directory will be inferred:

        - If only a JSON scenario file was imported to set up the scenario,
        the config files will be stored in a subdirectory next to the
        JSON scenario file of the following pattern:

            `{json_scenario_basename}/reconstruction`

        - If a reconstruction metadata file was imported, the config files
        will be stored next to the metadata file.

        Default value: `None`

    basename : str
        Base name for the created files. If `None` is given, the base
        name will be inferred from the scenario's metadata.

        Default value: `None`

    create_vgi : bool
        Write VGI file for future reconstruction volume?
    """

    matrices = []

    if basename is None:
        # Extract base name from metadata
        metadata_basename = self.metadata.get(["file", "name"])
        if metadata_basename is not None:
            basename = f"{metadata_basename}_recon_cera"
        else:
            basename = f"recon_cera"

    # Projection files
    n_projections = self.acquisition.get("number_of_projections")
    projection_file_pattern = self.metadata.get(["output", "projections", "filename"])
    projection_datatype = self.metadata.get(["output", "projections", "datatype"])
    projection_file_byteorder = self.metadata.get(["output", "projections", "byteorder"])
    projection_headersize = self.metadata.get(["output", "projections", "headersize", "file"])

    projection_filetype = "tiff"
    if projection_file_pattern.lower().endswith(".raw"):
        projection_filetype = "raw"

    # Acquisition
    start_angle = self.acquisition.get("start_angle")
    stop_angle  = self.acquisition.get("stop_angle")
    total_angle = stop_angle - start_angle

    for p in range(n_projections):
        self.set_frame(frame=p, reconstruction=True)

        # CERA projection matrix for projection p:
        m = self.current_geometry().projection_matrix(mode="CERA")
        matrices.append(m)

    # Go back to frame zero:
    self.set_frame(frame=0, reconstruction=True)

    if create_vgi:
        vgi_filename = join_dir_and_filename(save_dir, f"{basename}.vgi")
        volume_filename = f"{basename}.raw"

        self.write_recon_VGI(vgi_filename=vgi_filename, name=basename, volume_filename=volume_filename)

    create_CERA_config(
        geo=self.current_geometry(),
        projection_file_pattern=projection_file_pattern,
        basename=basename,
        save_dir=save_dir,
        n_projections=n_projections,
        projection_datatype=projection_datatype,
        projection_filetype=projection_filetype,
        projection_byteorder=projection_file_byteorder,
        projection_headersize=projection_headersize,
        start_angle=0,  # do not compensate the scenario start angle in the reconstruction
        total_angle=total_angle,
        scan_direction=self.acquisition.get("direction"),
        voxels_x=self.metadata.output.tomogram.dimensions.x.get(),
        voxels_y=self.metadata.output.tomogram.dimensions.y.get(),
        voxels_z=self.metadata.output.tomogram.dimensions.z.get(),
        voxelsize_x=self.metadata.output.tomogram.voxelsize.x.get(),
        voxelsize_y=self.metadata.output.tomogram.voxelsize.y.get(),
        voxelsize_z=self.metadata.output.tomogram.voxelsize.z.get(),
        i0max=self.metadata.output.get(["projections", "max_intensity"]),
        output_datatype=convert(cera_converter["datatype"], self.metadata.output.get(["tomogram", "datatype"])),
        matrices=matrices
    )
def write_OpenCT_config(self, save_dir: str = None, basename: str = None, create_vgi: bool = False, variant: str = 'free', abspaths: bool = False)

Write OpenCT reconstruction config files.

Parameters

save_dir : str

Folder where to place the CERA config files. This is meant to be the same directory where the reconstruction metadata file is located, such that relative paths will match.

If None is given, a directory will be inferred:

  • If only a JSON scenario file was imported to set up the scenario, the config files will be stored in a subdirectory next to the JSON scenario file of the following pattern:

    {json_scenario_basename}/reconstruction

  • If a reconstruction metadata file was imported, the config files will be stored next to the metadata file.

Default value: None

basename : str

Base name for the created config file. If None is given, the base name will be inferred from the scenario's metadata.

Default value: None

create_vgi : bool
Write VGI file for future reconstruction volume?
variant : str

Which variant of the OpenCT file format will be created: free trajectory or circular trajectory.

Possible values: "free", "circular"

Default value: "free"

abspaths : bool

Set to True if absolute paths should be used in the OpenCT config file.

Default value: False (relative paths)

Returns

openct_dict : dict
Dictionary with the OpenCT JSON structure.
Expand source code
def write_OpenCT_config(self, save_dir:str=None, basename:str=None, create_vgi:bool=False, variant:str='free', abspaths:bool=False):
    """Write OpenCT reconstruction config files.

    Parameters
    ----------
    save_dir : str
        Folder where to place the CERA config files. This is meant to be the
        same directory where the reconstruction metadata file is located,
        such that relative paths will match.

        If `None` is given, a directory will be inferred:

        - If only a JSON scenario file was imported to set up the scenario,
        the config files will be stored in a subdirectory next to the
        JSON scenario file of the following pattern:

            `{json_scenario_basename}/reconstruction`

        - If a reconstruction metadata file was imported, the config files
        will be stored next to the metadata file.

        Default value: `None`

    basename : str
        Base name for the created config file. If `None` is given, the base
        name will be inferred from the scenario's metadata.

        Default value: `None`

    create_vgi : bool
        Write VGI file for future reconstruction volume?

    variant : str
        Which variant of the OpenCT file format will be created: free trajectory
        or circular trajectory.

        Possible values: `"free"`, `"circular"`

        Default value: `"free"`

    abspaths : bool
        Set to `True` if absolute paths should be used in the OpenCT
        config file.

        Default value: `False` (relative paths)

    Returns
    -------
    openct_dict : dict
        Dictionary with the OpenCT JSON structure.
    """

    matrices = []

    if basename is None:
        # Extract base name from metadata
        metadata_basename = self.metadata.get(["file", "name"])
        if metadata_basename is not None:
            basename = f"{metadata_basename}_recon_openCT"
        else:
            basename = f"recon_openCT"

    # Name of config file:
    openct_config_filename = join_dir_and_filename(save_dir, f"{basename}.json")

    def projections_from_pattern(json_dict:dict):
        n = get_value(json_dict, ["number"]) # number of images
        pattern  = None
        filedir  = None
        filename = None
        filelist = list()

        if n is not None:
            if n > 0:
                pattern = get_value(json_dict, ["filename"])
                if abspaths is True:
                    pattern = abspath_of_referenced_file(openct_config_filename, pattern)

                if pattern is not None:
                    filedir, filename = os.path.split(pattern)

                # Generate list of projection files:
                if filename is not None:
                    # Auto-generate projection file list.
                    # List of sequentially numbered projection images,
                    # starting at 0000.
                    for p in range(n):
                        if '%' in filename:
                            try:
                                filelist.append(filename % p)
                            except Exception as e:
                                raise Exception(f"Error in sequentially numbered filename pattern. Please give a percentage sign, followed by the number of digits and a 'd' character: 'example_%04d.tif'. You gave: '{filename}'")
                        else:
                            filelist.append(filename)

        return n, filedir, filename, filelist

    # Projection files
    n_projections, projection_filedir, projection_filename, projection_filelist = projections_from_pattern(self.metadata.output.projections.json_dict())

    projection_datatype = self.metadata.get(["output", "projections", "datatype"])
    projection_file_byteorder = self.metadata.get(["output", "projections", "byteorder"])
    projection_headersize = self.metadata.get(["output", "projections", "headersize", "file"])

    projection_filetype = "tiff"
    if projection_filename is not None:
        if projection_filename.lower().endswith(".raw"):
            projection_filetype = "raw"

    # Dark files; only is corrections need to be applied.
    openct_dark_image = None
    if not self.metadata.output.projections.dark_field.projections_corrected.get() is True:
        n_darks, dark_filedir, dark_filename, dark_filelist = projections_from_pattern(self.metadata.output.projections.dark_field.json_dict())
        if isinstance(dark_filelist, list):
            if len(dark_filelist) > 0:
                openct_dark_image = join_dir_and_filename(dark_filedir, dark_filelist[0])

    # Bright files; only is corrections need to be applied.
    flat_filedir = None
    flat_filelist = None
    if not self.metadata.output.projections.flat_field.projections_corrected.get() is True:
        n_flats, flat_filedir, flat_filename, flat_filelist = projections_from_pattern(self.metadata.output.projections.flat_field.json_dict())

    # Acquisition
    start_angle = self.acquisition.get("start_angle")
    stop_angle  = self.acquisition.get("stop_angle")
    total_angle = stop_angle - start_angle

    for p in range(n_projections):
        self.set_frame(frame=p, reconstruction=True)

        # CERA projection matrix for projection p:
        m = self.current_geometry().projection_matrix(mode="OpenCT")
        matrices.append(m)

    # Go back to frame zero:
    self.set_frame(frame=0, reconstruction=True)

    volume_filename = f"{basename}.img"
    if create_vgi:
        vgi_filename = join_dir_and_filename(save_dir, f"{basename}.vgi")

        self.write_recon_VGI(vgi_filename=vgi_filename, name=basename, volume_filename=volume_filename)

    openct_dict = create_OpenCT_config(
        geo=self.current_geometry(),
        filename=openct_config_filename,
        variant=variant,
        projection_files=projection_filelist,
        projection_dir=projection_filedir,
        projection_datatype=projection_datatype,
        projection_filetype=projection_filetype,
        projection_headersize=projection_headersize,
        projection_byteorder=projection_file_byteorder,
        total_angle=total_angle,
        scan_direction=self.acquisition.get("direction"),
        matrices=matrices,
        volumename=volume_filename,
        voxels_x=self.metadata.output.tomogram.dimensions.x.get(),
        voxels_y=self.metadata.output.tomogram.dimensions.y.get(),
        voxels_z=self.metadata.output.tomogram.dimensions.z.get(),
        voxelsize_x=self.metadata.output.tomogram.voxelsize.x.get(),
        voxelsize_y=self.metadata.output.tomogram.voxelsize.y.get(),
        voxelsize_z=self.metadata.output.tomogram.voxelsize.z.get(),
        bright_image_dir=flat_filedir,
        bright_images=flat_filelist,
        dark_image=openct_dark_image)

    return openct_dict
def write_metadata(self, filename: str)

Write a metadata JSON file for the scenario.

Parameters

filename : str
Filename for the metadata file.
Expand source code
def write_metadata(self, filename:str):
    """Write a metadata JSON file for the scenario.

    Parameters
    ----------
    filename : str
        Filename for the metadata file.
    """
    if filename is not None:
        metadata_dict = self.metadata.json_dict()
        # potentially add simulation.ctsimu_scenario here:
        metadata_dict["simulation"]["ctsimu_scenario"] = self.json_dict()
        write_json_file(filename=filename, dictionary=metadata_dict)
def write_recon_VGI(self, name: str = '', volume_filename: str = '', vgi_filename: str = None)

Write a VGI file for the reconstruction volume such that it can be loaded with VGSTUDIO.

Expand source code
    def write_recon_VGI(self, name:str="", volume_filename:str="", vgi_filename:str=None):
        """Write a VGI file for the reconstruction volume such that it can be loaded with VGSTUDIO."""

        voxels_x = self.metadata.output.get(["tomogram", "dimensions", "x"])
        voxels_y = self.metadata.output.get(["tomogram", "dimensions", "y"])
        voxels_z = self.metadata.output.get(["tomogram", "dimensions", "z"])

        voxelsize_x = self.metadata.output.get(["tomogram", "voxelsize", "x"])
        voxelsize_y = self.metadata.output.get(["tomogram", "voxelsize", "y"])
        voxelsize_z = self.metadata.output.get(["tomogram", "voxelsize", "z"])

        output_datatype = self.metadata.output.get(["tomogram", "datatype"])
        if output_datatype == "uint16":
            dataTypeOutput = "unsigned integer"
            bits = 16
            datarangelower = 0
            datarangeupper = -1
        else:
            dataTypeOutput = "float"
            bits = 32
            datarangelower = -1
            datarangeupper = 1

        vgi_content = f"""{{volume1}}
[representation]
size = {voxels_x} {voxels_y} {voxels_z}
datatype = {dataTypeOutput}
datarange = {datarangelower} {datarangeupper}
bitsperelement = {bits}
[file1]
SkipHeader = 0
FileFormat = raw
Size = {voxels_x} {voxels_y} {voxels_z}
Name = {volume_filename}
Datatype = {dataTypeOutput}
datarange = {datarangelower} {datarangeupper}
BitsPerElement = {bits}
{{volumeprimitive12}}
[geometry]
resolution = {voxelsize_x} {voxelsize_y} {voxelsize_z}
unit = mm
[volume]
volume = volume1
[description]
text = {name}"""

        if vgi_filename is not None:
            touch_directory(vgi_filename)
            with open(vgi_filename, 'w') as f:
                f.write(vgi_content)
                f.close()

        return vgi_content