An introduction to SiffPy

One major downside to collecting FLIM (fluorescence lifetime imaging microscopy) data is that it does not naturally conform to the structure of an array: samples with high temporal resolution (like the 5 picoseconds of the PicoQuant MultiHarp) will be very sparse data, with thousands of possible arrival times per pixel and most of those data being zeros. And so instead of exporting a standard .tiff file, ScanImage-FLIM saves data in the .siff format, which uses a .tiff-like format to store FLIM data. But because this is not a standard .tiff file, it needs its own reader. SiffPy exists to extract the data from .siff files and transform them into numpy arrays and Python objects that can be easily piped into standard workflows.

This page contains examples for some simple workflows that are constrained entirely to SiffPy or external pacakges. There are also other packages that exist with the intention of working with SiffPy (e.g. SiffROI, siff-napari) that do this job as well. For this example, we will use a very small .siff file, but all the usual semantics will still hold

File I/O

The first thing we need to do, of course, is read a file! The main tool of SiffPy is the SiffReader object, which provides a basic API for returning ndarray objects. A SiffReader can be initialized with a path to a .siff file, which will be opened automatically, or it can be initialized in isolation and a file can be passed later with the open function:

sr = SiffReader()

# collect some user input, other info
...

sr.open(path_to_file)

but the most common use case is as below. File opening is generally pretty fast (no more than a few seconds for several-GB files), but if you’re reading data from a mounted server that’s not local, I haven’t optimized the reader to maximize bandwidth yet and it can be slow.

Note the skipped frames below – data acquisition goes by color channel, and in this example there are two color channels but only one with signal!

from siffpy import SiffReader
import matplotlib.pyplot as plt

# file_path can be a string or a pathlib.Path object,
# or anything that can be cast to a pathlib.Path object
#file_path = 'path/to/file.siff'
file_path = '/Users/stephen/Desktop/Data/imaging/2024-05/2024-05-27/SS02255_greenCamui_alpha/Fly1/FB_1.siff'

sr = SiffReader(file_path)

# Returns a `numpy` array of the photon count (i.e. intensity) data
# contained in the frames indexed as in the provided `frames`
# argument.
first_few_frame_indices = list(range(8))
first_frames = sr.get_frames(frames = first_few_frame_indices)

f, axes = plt.subplots(1, len(first_frames))
for i, x in enumerate(axes):
    x.axis('off')
    x.set_aspect('equal')
    x.set_adjustable('box')
    x.imshow(first_frames[i])
_images/basic_use_notebook_2_0.png

ImParams and figuring out which frames to load

The SiffReader object will now have a im_params attribute that points to an ImParams object, an interface for accessing the metadata in the .siff file. Printing the ImParams object will report some of this metadata (e.g. the number of frames). Some of it is stored in the metadata of the ScanImage modules, which can be accessed like attributes.

im_par = sr.im_params
print(im_par)
Image parameters:
    ScanImage modules :
            base
            Beams
            CameraManager
            Channels
            ConfigurationSaver
            CoordinateSystems
            CycleManager
            Display
            FastZ
            IntegrationRoiManager
            MotionManager
            Motors
            Photostim
            Pmts
            RoiManager
            Scan2D
            Shutters
            StackManager
            TileManager
            UserFunctions
            WSConnector
            WaveformManager
    _num_frames_from_siffio : 94306
    roi_groups : {'imagingRoiGroup':
        ROI group Default Imaging ROI Group with
        1 ROI(s):


        ROI  with
        13 scanfield(s):

        {'ver': 1, 'classname': 'scanimage.mroi.scanfield.fields.RotatedRectangle', 'name': 'Default Imaging Scanfield', 'UserData': None, 'roiUuid': '9DB51A994047081F', 'roiUuiduint64': 1.136401848e+19, 'centerXY': [0, 0], 'sizeXY': [0.9, 1.8], 'rotationDegrees': 0, 'enable': 1, 'pixelResolutionXY': [128, 256], 'pixelToRefTransform': [[0.00703125, 0, -0.453515625], [0, 0.00703125, -0.903515625], [0, 0, 1]], 'affine': [[0.9, 0, -0.45], [0, 1.8, -0.9], [0, 0, 1]]}

        , 'integrationRoiGroup':
        ROI group  with
        1 ROI(s):


        ROI  with
        1 scanfield(s):



        }
print(im_par.FastZ)
FastZ module:
    submodules : {}
    actuatorLag : 0
    discardFlybackFrames : True
    enable : True
    enableFieldCurveCorr : False
    errorMsg :
    flybackTime : 0.015
    hasFastZ : True
    name : SI FastZ
    numDiscardFlybackFrames : 1
    position : -45
    reserverInfo :
    userInfo :
    volumePeriodAdjustment : -0.0006
    warnMsg :
    waveformType : sawtooth

The most useful thing you’ll likely use the ImParams object to do is call its framelist functions. These use the ScanImage metadata to compute which frames in the .siff file correspond to which parts of the imaging volume / session. This way you don’t need to figure out things like what order frames are in, which frames to skip because they’re flyback, etc. etc. For more information, please check the SiffReader documentation and the ImParams one.

# Get the indices of all frames by timepoint (i.e. across all planes, technically
# slightly separated in time). Note that this example skips frame 6, which
# in this experiment was a flyback frame
im_par.flatten_by_timepoints(timepoint_start = 0, timepoint_end = 10)
[0,
 2,
 4,
 6,
 8,
 10,
 14,
 16,
 18,
 20,
 22,
 24,
 28,
 30,
 32,
 34,
 36,
 38,
 42,
 44,
 46,
 48,
 50,
 52,
 56,
 58,
 60,
 62,
 64,
 66,
 70,
 72,
 74,
 76,
 78,
 80,
 84,
 86,
 88,
 90,
 92,
 94,
 98,
 100,
 102,
 104,
 106,
 108,
 112,
 114,
 116,
 118,
 120,
 122,
 126,
 128,
 130,
 132,
 134,
 136]

You can also ask for just the frames of a specific z plane

im_par.flatten_by_timepoints(timepoint_start = 0, timepoint_end = 10, reference_z = 3)
[6, 20, 34, 48, 62, 76, 90, 104, 118, 132]

If you want all of the frames corresponding to a given slice/color/whatever, use the framelist_by_x methods:

print ("All frames with color channel 0:")
print(im_par.framelist_by_color(color_channel = 0, lower_bound_timepoint = 0, upper_bound_timepoint=10))

print("All frames in timepoint < 5 in the third slice:")
print(im_par.framelist_by_slices(color_channel=0, lower_bound = 0, upper_bound=5, slices = [2]))
All frames with color channel 0:
[0, 2, 4, 6, 8, 10, 14, 16, 18, 20, 22, 24, 28, 30, 32, 34, 36, 38, 42, 44, 46, 48, 50, 52, 56, 58, 60, 62, 64, 66, 70, 72, 74, 76, 78, 80, 84, 86, 88, 90, 92, 94, 98, 100, 102, 104, 106, 108, 112, 114, 116, 118, 120, 122, 126, 128, 130, 132, 134, 136]
All frames in timepoint < 5 in the third slice:
[4, 18, 32, 46, 60]

Now we can get all of the frames from, let’s say, the fourth plane

slice_frames = sr.get_frames(frames = im_par.framelist_by_slices(color_channel=0, slices = [3]))
print(slice_frames.shape)
(6736, 256, 128)

Or we can get the whole imaging series and then reshape it

full_session = (
    sr
    .get_frames(frames=sr.im_params.flatten_by_timepoints(color_channel=None))
    .reshape(sr.im_params.array_shape)
)

print([
    f"{dim_name}: {dim_val}"
    for dim_name, dim_val in zip(("timepoints", "slices", "channels", "rows", "columns"),full_session.shape)
    ]
)
['timepoints: 6736', 'slices: 6', 'channels: 2', 'rows: 256', 'columns: 128']
import matplotlib.pyplot as plt

f, x = plt.subplots(1, full_session.shape[1], figsize=(10, 5))

for plane, ax in zip(range(full_session.shape[1]), x):
    ax.imshow(full_session[:,plane, 0, ...].mean(axis=0).squeeze())
    ax.axis("off")
_images/basic_use_notebook_16_0.png

Registration

Almost all imaging sessions will have some motion artifacts. We need to do some image registration to correct those and align to a template. The template is also usually very useful for drawing ROIs, since it’s generally some form of consensus image across the timeseries. In this section, we will look at the tools for registration built in to SiffPy and explore how to pipe these data into another registration pipeline of our choice. There are native registration tools mainly because: 1) Many pipelines want to take in a .tiff file, which we just don’t have! Even if you do convert the .siff to a .tiff, you’ll lose the photon arrival time data! 2) The SiffIO object will perform rigid registration in-place, rather than duplicating the data, so it accepts a dictionary of pixel shifts and reassigns pixels in frames as it reads them from disk.

For more info please refer to {eval-rst} :ref:`registration`

# SiffPy as a registration method is probably one of the worse ones,
# but it doesn't require any additional dependencies! If you have `suite2p`
# installed, you can use that instead as below.
registration_dict = sr.register(registration_method='siffpy', alignment_color_channel=0)

# On my Macbook Pro, this takes about 2 minutes for our 7,000 volume 256x128 frame data

#registration_dict is also stored in the siffreader as sr.registration_dict, but more info
# is in the RegistrationInfo object
sr.registration_info

The registration_dict is also stored in the siffreader as sr.registration_dict, but more info is in the RegistrationInfo object

reg_info = sr.registration_info
print(reg_info)
print(reg_info.yx_shifts)

f, axes = plt.subplots(1, len(reg_info.reference_frames), figsize=(10, 5))

for i, x in enumerate(axes):
    x.axis("off")
    x.imshow(
        reg_info.reference_frames[i].squeeze()
    )
RegistrationType.Siffpy RegistrationInfo for /Users/stephen/Desktop/Data/imaging/2024-05/2024-05-27/SS02255_greenCamui_alpha/Fly1/FB_1.siff
{1722: (255, 127), 32676: (255, 0), 28980: (0, 0), 67130: (255, 127), 50120: (255, 0), 24696: (0, 0), 26642: (255, 127), 69496: (255, 0), 39144: (255, 127), 75908: (0, 127), 6468: (0, 0), 14364: (255, 0), 78162: (255, 0), 84042: (0, 0), 42742: (0, 0), 24808: (255, 0), 63770: (255, 0), 36344: (255, 127), 70602: (255, 127), 31920: (255, 0), 84980: (0, 127), 74998: (0, 1), 32998: (255, 0), 70742: (255, 0), 4270: (255, 0), 61516: (0, 0), 36848: (255, 0), 90650: (255, 127), 36316: (255, 127), 92190: (0, 0), 93520: (255, 127), 53536: (255, 0), 86128: (0, 0), 88802: (255, 127), 72842: (255, 127), 89964: (255, 0), 24304: (255, 0), 25410: (0, 0), 45752: (255, 0), 41300: (255, 0), 24570: (0, 1), 15358: (255, 1), 28280: (255, 0), 2562: (0, 0), 63616: (0, 0), 14686: (0, 0), 29694: (255, 0), 80850: (255, 127), 65520: (255, 0), 75012: (0, 0), 8792: (255, 0), 72828: (255, 127), 32186: (0, 0), 88270: (255, 0), 70322: (0, 1), 85372: (255, 0), 50162: (0, 0), 88606: (0, 0), 47516: (255, 0), 44030: (0, 0), 93324: (255, 1), 35308: (0, 0), 75796: (255, 0), 79618: (0, 0), 10892: (0, 0), 44842: (0, 0), 54306: (255, 127), 87626: (0, 0), 92974: (255, 0), 56224: (0, 0), 63224: (255, 127), 46158: (255, 0), 52612: (255, 127), 37688: (255, 0), 25564: (0, 0), 35196: (255, 0), 57876: (255, 0), 37072: (255, 127), 53466: (0, 1), 41930: (0, 0), 13692: (0, 0), 64274: (0, 0), 74004: (255, 1), 27202: (0, 0), 53382: (0, 0), 64708: (255, 0), 77854: (255, 0), 64918: (255, 127), 47180: (0, 0), 54586: (0, 0), 40838: (255, 0), 37016: (0, 127), 54782: (255, 0), 35630: (255, 127), 28406: (255, 0), 54110: (0, 0), 14476: (255, 0), 67900: (255, 127), 82950: (0, 0), 56420: (0, 0), 92330: (0, 127), 91154: (255, 0), 14840: (0, 127), 82922: (0, 0), 91756: (255, 0), 10290: (255, 0), 28490: (255, 0), 86618: (255, 127), 51870: (0, 0), 69468: (0, 127), 64344: (0, 0), 34356: (255, 127), 21238: (255, 127), 41832: (255, 0), 4690: (0, 0), 56350: (255, 127), 90510: (0, 1), 88256: (255, 0), 31528: (0, 127), 63630: (0, 0), 91210: (0, 127), 11130: (0, 0), 92344: (0, 127), 27426: (0, 0), 64414: (0, 1), 68278: (0, 127), 16324: (0, 0), 36708: (255, 0), 73150: (0, 0), 38444: (0, 0), 33782: (255, 0), 21406: (255, 0), 78260: (0, 0), 94248: (254, 0), 66654: (0, 0), 57764: (255, 127), 64106: (0, 0), 1708: (255, 127), 78596: (0, 0), 28588: (255, 1), 93576: (255, 0), 30324: (255, 0), 29750: (255, 0), 17570: (0, 0), 10402: (255, 0), 77756: (0, 0), 79268: (255, 0), 49014: (0, 0), 78820: (0, 0), 92064: (255, 0), 75124: (0, 0), 45836: (255, 0), 73570: (0, 127), 44436: (255, 1), 1092: (0, 127), 33586: (255, 0), 1386: (255, 0), 60032: (255, 0), 60130: (255, 0), 85246: (0, 0), 19838: (0, 0), 24066: (0, 0), 53284: (255, 127), 49056: (255, 0), 93772: (253, 0), 49560: (255, 0), 72072: (0, 1), 42854: (255, 0), 91392: (255, 2), 11900: (255, 0), 54796: (255, 0), 72940: (255, 0), 58016: (255, 0), 54152: (0, 127), 6118: (255, 0), 51800: (0, 0), 64400: (0, 0), 55636: (255, 0), 51996: (255, 0), 9716: (255, 0), 86492: (0, 127), 2926: (255, 1), 9534: (0, 127), 47628: (255, 0), 93562: (255, 0), 26684: (255, 127), 1190: (0, 0), 31752: (255, 0), 35588: (255, 0), 53704: (255, 0), 49728: (0, 0), 46032: (0, 0), 18438: (0, 0), 980: (255, 0), 25648: (255, 0), 86604: (255, 127), 3108: (0, 0), 0: (252, 126), 14: (254, 127), 28: (231, 0), 42: (244, 127), 56: (199, 125), 70: (221, 126), 84: (11, 118), 98: (4, 125), 112: (229, 124), 126: (11, 0), 140: (228, 125), 154: (78, 106), 168: (55, 103), 182: (55, 117), 196: (52, 125), 210: (239, 125), 224: (6, 124), 238: (6, 127), 252: (254, 1), 266: (216, 123), 280: (195, 122), 294: (246, 126), 308: (253, 127), 322: (240, 125), 336: (211, 127), 350: (28, 118), 364: (225, 124), 378: (253, 126), 392: (14, 122), 406: (14, 126), 420: (44, 118), 434: (11, 123), 448: (250, 121), 462: (176, 103), 476: (220, 124), 490: (227, 120), 504: (219, 126), 518: (250, 125), 532: (213, 121), 546: (10, 118), 560: (25, 119), 574: (80, 110), 588: (254, 124), 602: (30, 123), 616: (81, 116), 630: (9, 121), 644: (18, 122), 658: (34, 116), 672: (223, 125), 686: (198, 105), 700: (11, 110), 714: (14, 119), 728: (27, 110), 742: (14, 115), 756: (0, 125), 770: (246, 124), 784: (223, 115), 798: (234, 119), 812: (241, 122), 826: (252, 126), 840: (254, 127), 854: (255, 127), 868: (252, 126), 882: (247, 123), 896: (245, 124), 910: (252, 127), 924: (254, 127), 938: (254, 127), 952: (254, 127), 966: (255, 127), 994: (255, 0), 1008: (255, 0), 1022: (0, 0), 1036: (0, 0), 1050: (255, 0), 1064: (0, 0), 1078: (0, 0), 1106: (0, 127), 1120: (0, 127), 1134: (0, 0), 1148: (0, 0), 1162: (0, 0), 1176: (0, 0), 1204: (0, 0), 1218: (0, 0), 1232: (0, 127), 1246: (0, 127), 1260: (255, 0), 1274: (0, 0), 1288: (0, 0), 1302: (255, 0), 1316: (255, 0), 1330: (255, 1), 1344: (255, 0), 1358: (255, 0), 1372: (255, 0), 1400: (255, 0), 1414: (255, 0), 1428: (255, 0), 1442: (255, 0), 1456: (255, 0), 1470: (255, 0), 1484: (255, 0), 1498: (255, 0), 1512: (255, 0), 1526: (255, 0), 1540: (255, 127)}
_images/basic_use_notebook_21_1.png
# If your siffreader performed the registration, it will also automatically use it unless you override the
# registration_dict keyword argument, but
# I'm spelling out its use here for clarity
registered_frames = sr.get_frames(frames = first_few_frame_indices, registration_dict = reg_info.yx_shifts)
print(registered_frames.shape)

There are multiple registration packages available. Let’s try the same with suite2p. It goes faster and tends to look better! In this case, it took 80 seconds, a 30% speedup compared to my crappy implementation

sr.register(registration_method='suite2p', alignment_color_channel=0)
reg_info = sr.registration_info
print(reg_info)
print(reg_info.yx_shifts)

f, axes = plt.subplots(1, len(reg_info.reference_frames), figsize=(10, 5))

for i, x in enumerate(axes):
    x.axis("off")
    x.imshow(
        reg_info.reference_frames[i].squeeze()
    )

The reference frames look nicer too..

Masking methods

Often we want to do more than just read a file and make pretty pictures. We generally want to analyze certain subregions of the image, often called masks or ROIs. SiffPy provides some methods for this type of analysis: the mask methods.

Mask objects are numpy arrays of dtype=bool. Because SiffPy reads frames lazily rather than making one giant image, it is often easier and faster to ask it only to read the portions corresponding to the mask. Let’s start by creating a simple mask that only grabs the top half of each frame, and compare the speed and numerical output of each of the various ways of calculating the intensity within that ROI across the dataset.

import numpy as np
# Make a mask that is just the top half of the frame
mask = np.ones(sr.im_params.shape, dtype=bool)
mask[mask.shape[0]//2:, ...] = False
# Produces a raw sum of the photon counts in the masked region
# across all timepoints, then collapses the volume sum into a
# single value, this is used for multi-plane masks spanning a
# volume. This method takes the same amount of time -- 5.5 seconds
collapsed = sr.sum_mask(mask)
print(collapsed.shape, collapsed)
%%timeit
# Just plain ol' summing the photon counts in the masked region.
# Takes 3.41 seconds for me
framewise = sr.sum_mask(mask)
3.37 s ± 47.5 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
%%timeit
# By comparison, doing the same operation from the raw intensity data takes 4.39 seconds!
# This seems to argue there's some benefit to using the `SiffReader` mask method even
# for something this trivial

from_raw = (
    sr.get_frames(
        frames = sr.im_params.flatten_by_timepoints(),
        registration_dict = sr.registration_dict
    )
    * mask
).sum(axis=(-1,-2))
4.23 s ± 46 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
# Of course, they agree:
(from_raw == framewise).all()
True

This method works well with multiple ROIs if you call the sum_masks method. If you were to use sum_mask over and over, you’d have to read the image file every time – making it tremendously slow. But if you apply all of the masks during one read with sum_masks, you can apply all of the masks during the first read. This way you don’t have to load the whole (sometimes many 100,000s of frames) into memory first as you would if you start with get_frames – you can do it lazily during the read.

This ends up performing comparably when the overall image is kind of small, and very (comparatively) efficiently in more memory-limited applications

base_mask = np.zeros(sr.im_params.single_channel_volume, dtype=bool)
# A vertical stripe on the left edge of the image in each plane
base_mask[..., :base_mask.shape[-1]//10] = True

# The masks will shift the position of the vertical stripe to the right
masks = [
    np.roll(base_mask, i*base_mask.shape[-1]//10, axis=-1)
    for i in range(10)
]
%%timeit
# 9 to 10 seconds -- not so shabby for 10 masks considering a single mask took about 3 or 4 seconds.
sr.sum_masks(masks)
9.63 s ± 63.8 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
%%timeit
# Get the frames once so we don't repeat the read -- but still requires loading
# the entire array into memory. This also takes about 10 seconds!
frames = (
    sr.get_frames(frames = sr.im_params.flatten_by_timepoints())
    .reshape(-1, *sr.im_params.single_channel_volume)
)

np.array(
    [
        frames[:, submask]
        .sum(axis=(-1,-2))
        for submask in masks
    ]
)
8.53 s ± 78.8 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)