Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Bioformat image server reader pool extraction #1287

Closed
wants to merge 0 commits into from
Closed

Conversation

Rylern
Copy link
Contributor

@Rylern Rylern commented Aug 9, 2023

Initial refactoring of the bio-format image server to extract code that could be reused in the OMERO extension.
Basically, the bio-format image server uses a pool of readers to retrieve pixel values in parallel. This pull request extracted this behavior from the bio-format image server.

4 classes were created:

  • ReaderPool: abstract class that can read pixel values of an image using several readers in parallel. A class extending this class must define how to fetch pixel values, but the rest is handled by ReaderPool.
  • BioFormatReaderPool: implementation of ReaderPool with specific code to open bio-format images.
  • ReaderWrapper: interface which is a wrapper around an image reader. It is suited for readers that return arrays of bytes when reading pixel values.
  • BioFormatReaderWrapper: implementation of ReaderWrapper with specific code to open bio-format images.

The BioFormatImageServer and BioFormatServerBuilder were slightly changed to adapt to the new classes.

The unit tests passed with these changes.

This pull request should not be merged now because I still have to address a few things:

  • Where should we place the ReaderPool and ReaderWrapper classes? Currently there are in the servers.bioformats package, but they are not specific to bio-format.
  • Should I refactor the BioFormatImageServer to use the best practises we have been discussing? I see this file has a few warnings and the constructor takes 500 lines.
  • I will now try to use ReaderPool and ReaderWrapper in the OMERO extension, so I may have to change a few things if I realize that theses classes are not completely generic.

I also have a question:

  • When does the getAssociatedImage(String) function of qupath.lib.images.servers.ImageServer is used? I don't think I was able to test it

@petebankhead
Copy link
Member

Thanks!

  • Where should we place the ReaderPool and ReaderWrapper classes? Currently there are in the servers.bioformats package, but they are not specific to bio-format.

ReaderPool seems to be currently - it still has quite a lot of loci.* imports, which would prevent it from moving to a more core QuPath module.

I think that's fine because it makes sense for the OMERO extension to depend upon the Bio-Formats one - at least for raw pixel access via ICE, since many other dependencies are shared. And if we follow the advice of accessing pixels by Zarr then we might still have a Bio-Formats dependency via OMEZarrReader as described here.

  • Should I refactor the BioFormatImageServer to use the best practises we have been discussing? I see this file has a few warnings and the constructor takes 500 lines.

Yes, that would be good. But we can merge sooner if it helps.

  • I will now try to use ReaderPool and ReaderWrapper in the OMERO extension, so I may have to change a few things if I realize that theses classes are not completely generic.

I don't think you need to worry too much about making them very generic - just to work well enough for Bio-Formats and OMERO. They both have a quite different way of returning pixel arrays that I haven't seen elsewhere.

Based on the recent forum discussion, I have the impression that the current working Zarr support for Java uses n5-zarr, which in turn relates to (I think...) imglib2.

Since we already plan to explore imglib2, there's a chance that a lot of QuPath's ImageServer and image reading code may be replaced if we find better approaches with imglib2.

@petebankhead
Copy link
Member

When does the getAssociatedImage(String) function of qupath.lib.images.servers.ImageServer is used? I don't think I was able to test it

It is used with View → Show slide label - but is really only relevant for some file formats (although useful when relevant).

It's inspired by the 'associated images' provided by OpenSlide here - since otherwise QuPath would have had no way to provide access to the label etc. But it doesn't map so easily to images from other readers, including Bio-Formats, which doesn't identify label images as being different.

@Rylern
Copy link
Contributor Author

Rylern commented Sep 5, 2023

When does the getAssociatedImage(String) function of qupath.lib.images.servers.ImageServer is used? I don't think I was able to test it

It is used with View → Show slide label - but is really only relevant for some file formats (although useful when relevant).

It's inspired by the 'associated images' provided by OpenSlide here - since otherwise QuPath would have had no way to provide access to the label etc. But it doesn't map so easily to images from other readers, including Bio-Formats, which doesn't identify label images as being different.

Do you know a way to test it? This Show slide label window always indicates "No label available" with the images I have.

Apart from that, I think this pull request can be merged. The bio-format and omero ice image servers seem to be working with these new changes. I may still have to clean the code a bit but I think having the OMERO extension working properly is more important for now.

Copy link
Member

@petebankhead petebankhead left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, I've added some comments.

The main question (maybe to discuss with @alanocallaghan and @finglis) is whether we should use Optional instead of just returning null. I am slightly in favor of using it sometimes - especially when the return really is optional - but here it seems to be used where throwing an exception would be preferable.

Returning null can be informative, inasmuch as it suggests we have a sparse image without pixels for every location - and shouldn't happen whenever there has been an exception.

Apart from that, I'm not sure if the ReaderWrapper interface and ReaderPool abstract classes have quite the right design for ease of interpretation and reuse.

ReaderWrapper looks very tied to the 'OME' way of doing things (Bio-Formats and OMERO); I'd expect a general image reader to return something more informative than a byte[][], which can only be interpreted with a lot of other return values and Bio-Formats logic. So it isn't very easy to use in a standalone way.

That isn't in itself a problem, but if writing a general image reader for use with the OMERO web API or IIIPImage Server (for example) I imagine it would be far harder to return a byte[][] than a BufferedImage.

So I think it should either 1) embrace being Bio-Formats/OMERO-specific, and prioritise simplicity, or 2) incorporate more of the processing logic that converts the byte[][] into a BufferedImage, and prioritise reusability. If the goal is for ReaderWrapper and ReaderPool to be reusable, it needs to be easy to generate and work with their return values.

ReaderPool then is abstract, but has very few abstract methods. One is to create a ReaderWrapper. This probably isn't helpful outside the class. It could also be handled with composition rather than inheritance by passing a Supplier<ReaderWrapper> as an argument to its constructor if really necessary, like when creating a ThreadLocal. If this change is made, ReaderPool could still be subclassed, but wouldn't have to be subclassed.

Elsewhere ReaderPool contains a lot of logic for image reading, which feels like it belongs in the reader itself - not the pool for managing readers. And it's also quite Bio-Formats-focussed, since the idea of a series within an image is quite Bio-Formats-specific.

So overall I don't have a clear idea of the logical separation between ReaderWrapper and ReaderPool. It feels like the logic of image reading is now more split across more classes + Bio-Formats itself, and it's quite hard to trace what is happening.

Finally, reading this reminds me that there were two important limitations in the original design:

  • It only supports returning all pixels for all channels simultaneously. In preparation for the future, it would be beneficial to have an API that optionally supports returning individual channels.
    • This isn't needed if the refactoring is minor. But any major refactoring has a chance of regression (in terms of some obscure images failing), so we should try to avoid doing it multiple times.
  • Associated images can sometimes be very big - even pyramidal or with multiple channels. So the logic for reading them doesn't have to be fundamentally different to the logic for reading other images. From a Bio-Formats perspective, you might just request the image for a different series.

What do you think?

* <p>A {@link ReaderPool} to use with Bio-Format images.</p>
* <p>It uses the {@link BioFormatReaderWrapper}.</p>
*/
class BioFormatReaderPool extends ReaderPool {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note it should be BioFormats and not BioFormat (also elsewhere).

Also: please leave a blank line after the class definition and before the first private fields are defined.

Usually there should be a logger first as well - here, I think it would make sense to log at the debug level when createReaderWrapper is called.

(I realise that's not always the case in other classes, but we have a better log viewer now :) )

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Usually there should be a logger first as well - here, I think it would make sense to log at the debug level when createReaderWrapper is called.

Until now I've never used logger.debug, when should I use it?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd say whenever there's info that could be useful to track down an error, but we wouldn't want logged routinely (since info level is the default).

Then logger.trace helps for really fine-grained information (e.g. it might be used to record rendering times in the viewer). Logs at trace level should almost never be turned on, because they will result in enormous logs.

Logging levels aren't used consistently enough in QuPath, but the new log viewer makes them far more useful.

}
}

/**
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This logic seems substantially different from the original implementation - are the changes required for any specific purpose?

I'd like to understand the changes since the original version evolved over many years, with bugs were fixed in response to user reports for individual images that failed. But it could still be changed if there is a better way to do things. The code here worked for all images I tried.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What logic is different from the original implementation? I moved code from BioFormatsImageServer but I tried to keep the same logic. Maybe it's clearer now with the last version which removes the confusion between ReaderPool and ReaderWrapper

}

/**
* Create an image from the supplied parameters (adapted from
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you use AWTImageTools directly, rather than needing to adapt code from it?

I imagine it's not reusable from OMERO, but might it be possible to create a minimal IFormatReader that wraps around your code for requesting images from OMERO?

(Just an idea, may not be workable or wise...)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I found it easier like this, because the creation of a IFormatReader seemed to be complicated to me (by looking at the constructor of BioFormatsReaderWrapper). I removed some parameters of the function in the last version

@Rylern
Copy link
Contributor Author

Rylern commented Sep 7, 2023

Thanks for the feedback!

The main question (maybe to discuss with @alanocallaghan and @finglis) is whether we should use Optional instead of just returning null. I am slightly in favor of using it sometimes - especially when the return really is optional - but here it seems to be used where throwing an exception would be preferable.

Returning null can be informative, inasmuch as it suggests we have a sparse image without pixels for every location - and shouldn't happen whenever there has been an exception.

You're right, I was overusing Optional. Now, each time an error occurs, an exception is thrown. I kept Optional only when a function is not guaranteed to return a result AND no error occurred during its execution.

ReaderWrapper looks very tied to the 'OME' way of doing things (Bio-Formats and OMERO); I'd expect a general image reader to return something more informative than a byte[][], which can only be interpreted with a lot of other return values and Bio-Formats logic. So it isn't very easy to use in a standalone way.

That isn't in itself a problem, but if writing a general image reader for use with the OMERO web API or IIIPImage Server (for example) I imagine it would be far harder to return a byte[][] than a BufferedImage.

So I think it should either 1) embrace being Bio-Formats/OMERO-specific, and prioritise simplicity, or 2) incorporate more of the processing logic that converts the byte[][] into a BufferedImage, and prioritise reusability. If the goal is for ReaderWrapper and ReaderPool to be reusable, it needs to be easy to generate and work with their return values.

I changed ReaderWrapper to be as generic as possible (its read function now returns a BufferedImage). However, I wanted a ReaderWrapper class common to Bio-Formats and OMERO (because they have a lot of code in common), so I created the OMEReaderWrapper class (not sure of the name) that is a child of ReaderWrapper, and a parent of BioFormatsReaderWrapper and IceOmeroReaderWrapper.

ReaderPool then is abstract, but has very few abstract methods. One is to create a ReaderWrapper. This probably isn't helpful outside the class. It could also be handled with composition rather than inheritance by passing a Supplier as an argument to its constructor if really necessary, like when creating a ThreadLocal. If this change is made, ReaderPool could still be subclassed, but wouldn't have to be subclassed.

I made ReaderPool a concrete class by using a Supplier like you suggested (well, not exactly a Supplier but a Callable because I needed to throw exceptions). I removed the child classes of ReaderPool.

Elsewhere ReaderPool contains a lot of logic for image reading, which feels like it belongs in the reader itself - not the pool for managing readers. And it's also quite Bio-Formats-focussed, since the idea of a series within an image is quite Bio-Formats-specific.

So overall I don't have a clear idea of the logical separation between ReaderWrapper and ReaderPool. It feels like the logic of image reading is now more split across more classes + Bio-Formats itself, and it's quite hard to trace what is happening.

I moved the image reading logic from ReaderPool to ReaderWrapper.

It only supports returning all pixels for all channels simultaneously. In preparation for the future, it would be beneficial to have an API that optionally supports returning individual channels.

  • This isn't needed if the refactoring is minor. But any major refactoring has a chance of regression (in terms of some obscure images failing), so we should try to avoid doing it multiple times.

Should I add a openImage(TileRequest tileRequest, int series, int channel, boolean isRGB, ColorModel colorModel) function to ReaderPool?

Associated images can sometimes be very big - even pyramidal or with multiple channels. So the logic for reading them doesn't have to be fundamentally different to the logic for reading other images. From a Bio-Formats perspective, you might just request the image for a different series.

I'm not sure I understood this point.

Copy link
Member

@petebankhead petebankhead left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks! I just looked quickly - first impressions are good, I'll try to check out the code in more detail and run it later.

I changed ReaderWrapper to be as generic as possible (its read function now returns a BufferedImage).

That sounds better for now. You could go a step further and define ReaderWrapper<T> where <T> is a generic parameter that may be BufferedImage.

That's the case with QuPath's ImageServer implementations. In practice it was probably not needed, since we always use BufferedImage. In the distant future, we might get rid of that entirely to avoid BufferedImage (which I think isn't available on android, for example) - but that would be a massive change.

Therefore keeping BufferedImage is fine, but switching to <T> would give you flexibility if you thought that something other than BufferedImage would be meaningful for Bio-Formats or OMERO.

* @return the image corresponding to these parameters
* @throws IOException when a reading error occurs
*/
BufferedImage getImage(TileRequest tileRequest, int series, int numberOfChannels, boolean isRGB, ColorModel colorModel) throws IOException;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rather than int numberOfChannels, we probably want either int channel (if we support only all channels for -1 or individual channels to be returned), or int[] channels (if we support arbitrary combinations of channels).

Either option would give more flexibility than the current API, but must support a parameter value that will return all channels (since that's the current default behavior, which should remain the default).

The javadoc for series should specify that it's ignored if not relevant for the image reader (since most readers only support one image).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you remember the logic behind the OMEReaderWrapper.createSampleModel() function? Supporting arbitrary combinations of channels will require to edit this function, and I'm a bit confused with this part:

int[] offsets = new int[numberOfChannels];
int[] bandInds = new int[numberOfChannels];
int ind = 0;

int channelCount = getChannelCount(series);
for (int cInd = 0; cInd < channelCount; cInd++) {
    int nSamples = getChannelSamplesPerPixel(series, cInd);
    for (int s = 0; s < nSamples; s++) {
        bandInds[ind] = cInd;
        if (isInterleaved()) {
            offsets[ind] = s;
        } else {
            offsets[ind] = s * tileRequest.getTileWidth() * tileRequest.getTileHeight();
        }
        ind++;
    }
}
// TODO: Check this! It works for the only test image I have... (2 channels with 3 samples each)
// I would guess it fails if pixelStride does not equal nSamples, and if nSamples is different for different 'channels' -
// but I don't know if this occurs in practice.
// If it does, I don't see a way to use a ComponentSampleModel... which could complicate things quite a bit
int pixelStride = numberOfChannels / effectiveC;
int scanlineStride = pixelStride * tileRequest.getTileWidth();
return new ComponentSampleModel(
        dataBuffer.getDataType(),
        tileRequest.getTileWidth(),
        tileRequest.getTileHeight(),
        pixelStride,
        scanlineStride,
        bandInds,
        offsets
);

In particular, what is the difference between numberOfChannels, effectiveC, and channelCount?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I struggle to remember the logic exactly - only that it was really difficult to get it right...

The general idea is that there are three confusingly inter-related terms that I will attempt to define (at least according to my best understanding):

  • Channels are essentially as described here; different 2D images that are usually viewed using different colors that are mixed together (e.g. red, green & blue)
  • Bands are arrays of pixels, which can correspond to channels - i.e. each channel has a band - but don't have to, because
  • Samples provide a way to store multiple channels in a single band

In the end, if you store your channels in a single band then the values are interleaved (e.g. RGBRGBRGB... - this would be 1 band with 3 samples per pixel).

Alternatively, you might store your channels in separate bands, which can be referred to as planar storage (RRRR.... GGGG.... BBB... - 3 bands with 1 sample per pixel).

So to construct a BufferedImage, you need a SampleModel in order to be able to make sense of what is actually contained within the bands.

As far as I recall, the scanlineStride is generally the width of the image - but might not be. The main time when it isn't is if you have a subimage that is backed by the same data as the original, larger image.

QuPath refers to channels as the 'intuitive' concept, but the actual technical storage relies upon bands and samples.

You don't necessarily have to bother with this now - especially if there is a chance of regression, since it seems to be working ok. If the API supports arbitrary channels, I think it's ok to throw an UnsupportedOperationException if it's actually called (and document this) - since it can just be a placeholder for the future. QuPath itself doesn't have an API to request fewer channels in ImageServer anyway, but it will likely need one in the future because it's inefficient to request 50 channels all at once if they aren't needed.

If you do change this logic at all, one way to test it is working is to export multichannel images using code like this, with channelsInterleaved() turned on and off.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just to add - you'll see bands and samples in the Java documentation, but I think Bio-Formats tends to refer to channels and samples. Unfortunately, in this case channels doesn't necessarily match which QuPath's idea of channels.

I think you should probably consider a Bio-Formats channel to be like a BufferedImage band.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK, thanks for the explanation.

If that's fine with you, I can change only the public API for now (and throw UnsupportedOperationException like you mentioned) and will come back to this later (like when a first version of the omero extension will be ready)

@Rylern
Copy link
Contributor Author

Rylern commented Sep 7, 2023

I added a commit with the changes you requested.

Since ReaderPool and ReaderWrapper are now generic, shouldn't they be placed outside the Bio-Formats extension?

Copy link
Member

@petebankhead petebankhead left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this design is better, but I think it's worth trying to simplify even further.

The Bio-Formats API is very complex - justifiably, since it has to handle so many complex formats - and not thread-safe, so there is a high risk of bugs when we try to use it efficiently and in a parallelized way.

One of the biggest dangers is that an ImageReader can have its ID set (effectively choosing a different input file), and also its series set (effectively choosing a different image within a file). Any metadata we request from an ImageReader is only consistent if the ID and series have not been changed, which makes lazily requesting metadata risky - and is where I think problems could occur here.

Therefore an important question is where exactly the metadata should be parsed - and ideally this happens just once.

I think it would be useful to define the preferred public API to support Bio-Formats and OMERO first - identifying where code reuse makes sense.

One idea might be to decouple the conversion of byte arrays into BufferedImage objects. Here's a rough sketch of a class, which could be reused across all readers for a single image and threadsafe (caching color and sample models for reuse).

class OMEPixelParser<WritableRaster> {

   // Method to convert byte arrays according to the known metadata
   BufferedImage parse(byte[][] pixels, int width, int height) {
       // ...
   }

   // We might want to treat this as a special case, because if we create a BufferedImage 
   // using a ColorModel, Raster and SampleModel, then it may not have the correct 
   // RGB image type set
   BufferedImage parseRGB(byte[][] pixels, int width, int height) {
      // ...
   }

   // Optional method that makes it possible to handle arbitrary channel combinations, 
   // at the cost of requiring color and sample models to also be created
   WritableRaster parseRaster(byte[][] pixels, int width, int height) {
       // ...
   }

   // Create a sample model for the specified number of channels
   SampleModel createSampleModel(int nChannels) { ... }

   // Create a color model for arbitrary channel combination (if empty, assume all channels)
   ColorModel createColorModel(int... channels) { ... }

   // Builder to set all necessary metadata values once - 
   // and these cannot be changed
   static class Builder {
  
       Builder isInterleaved(boolean isInterleaved) { ... }

       Builder isRGB(boolean isRGB) { ... }

       // We will need the default channel names & colors for anything other than RGB
       Builder channels(List<ImageChannel> channels) { ... }

       Builder pixelType(PixelType pixelType) { ... }

       OMEPixelParser build() {
            return new OMEPixelParser(...);
       }

   }

}

I don't know if this is a workable design - it's really just a suggestion for consideration and discussion.

* Wrapper suited for readers that return arrays of bytes when reading pixel values.
* </p>
*/
public abstract class OMEReaderWrapper implements ReaderWrapper<BufferedImage> {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can see the logic in this class for supporting both Bio-Formats and OMERO, but it means that it must go beyond the ReaderWrapper implementation.

I have the impression that the metadata parsing then becomes fragmented.

It is already slightly fragmented within Bio-Formats itself, since you can request some overlapping information from the ImageReader/IFormatReader or from the OMEPyramidStore.

QuPath needs metadata values to be parsed into the ImageServerMetadata object used by the ImageServer, and the logic for this is inside the BioFormatsImageServer class. But then information is also provided from OMEReaderWrapper directly, independent of whatever was parsed by BioFormatsImageServer. It seems that they could be inconsistent.

And, in fact, the metadata probably will be inconsistent sometimes in BioFormatsReaderWrapper - at least if this class is used different from how it currently is - because of an awkward fact of image files that contain multiple series: they don't necessarily have the same metadata. For example, they can be different sizes, have different channel counts etc.

So when this returns a value for getPixelType() (for example), then that may only be correct for one particular series - and the API doesn't tell us which one. In fact, the returned value might change if there are calls to reader.setSeries(int).

Often this may be unnoticed because different series happen to share enough similar metadata, but it isn't guaranteed.

If returning metadata is necessary in this class, then a way to overcome this is to forbid taking the series as a parameter to getImage, and instead passing it during construction. This means that a new instance would need to be created to access a different series, but it would help reduce the risk of subtle bugs in the future.

Sidenote: this replicates some of IFormatReader, but with different naming (e.g. getEffectiveChannelCount() rather than getEffectiveSizeC()) - but you still need to know the Bio-Formats API to use it, and to make sense of the name shift from series to imageIndex in some method parameters.

*/
T getImage(TileRequest tileRequest, int[] channels, boolean isRGB, ColorModel colorModel, int series) throws IOException;

/**
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It may be possible to remove this method, and rely entirely on the other getImage() method - but it would require some thought.

Basically, getAssociatedImage() makes complete sense when using OpenSlide: an associated image is guaranteed to be 'small' and RGB - at least, that's what the official Python API seems to assume here, and OpenSlide is inherently limited to RGB.

It also makes sense at the level of the ImageServer API, because users will often want to see the label or overview image associated with their whole slide image. Initially, ImageServer was also designed with OpenSlide in mind.

I'm not sure it makes sense at the Bio-Formats API level, because the concept doesn't exist there: every image is a series. QuPath attempts to figure out which series in a file are likely to be labels or macro images based upon their names, but (annoyingly) these 'probably associated' images are not guaranteed to be small (see here) or RGB (see here). They could be pyramidal and non-RGB. Which is largely a consequence of Bio-Formats supporting different file formats to OpenSlide, and some of them do more awkward things.

This means that the pixel requests don't have to be different from requesting any other series. The logic for converting them into a manageable size could exist within the ImageServer, and doesn't have to exist here.

Relying on getImage(int series) for associated images effectively means that we have no way to request a low-resolution version if the image happens to be large and pyramidal, because there's no way to pass TileRequest info.

* @return the image corresponding to these parameters
* @throws IOException when a reading error occurs
*/
T getImage(TileRequest tileRequest, int[] channels, boolean isRGB, ColorModel colorModel, int series) throws IOException;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Extending some of my other comments, I guess there are two options:

  1. this interface assumes that any implementing reader can handle multiple images, and so requires a series parameter. This assumes that the images are accessed by index (rather than name).
  2. this interface assumes that the internal reader already knows exactly which image it refers to (i.e. if a series is required, it is stored internally as a field)

The ImageServer interface takes the second approach: you don't need to specify an series when requesting pixels or metadata, because the server returns pixels and metadata only for one specific series. If you want a different series, you need a different ImageServer.

The argument for the first approach is that you can then reuse the same Bio-Formats ImageReader multiple times: setting the series and/or ID, and not creating a new reader every time. These readers would then be managed by the ReaderPool.

Care would need to be taken because Bio-Formats isn't threadsafe, and it's especially important to avoid setting the ID or series on a reader when it is still in use by another thread.

@Rylern
Copy link
Contributor Author

Rylern commented Sep 11, 2023

I marked the comments that I completely understood as resolved.

About the rest, am I correct if a recap of what you ask is:

  • Metadata parsing is fragmented between BioFormatsImageServer and OMEReaderWrapper / OMETileReader. This should not happen. Metadata should be requested only once and not lazily.

  • One tile reader should support accessing only one series.

  • The T getImage(int series); function should be removed, and the T getImage(TileRequest tileRequest, int[] channels, boolean isRGB, ColorModel colorModel, int series) function should be used instead.

However I didn't understand where the OMEPixelParser class would be in all of this.

@petebankhead
Copy link
Member

About the rest, am I correct if a recap of what you ask is:

  • Metadata parsing is fragmented between BioFormatsImageServer and OMEReaderWrapper / OMETileReader. This should not happen. Metadata should be requested only once and not lazily.

Yes. See https://downloads.openmicroscopy.org/images/Vectra-QPTIFF/perkinelmer/PKI_fields/ and HnE_3_1x1component_data.tif for an example where it is a problem.

This contains a 32-bit float image, along with an 8-bit thumbnail.

If I try to open the thumbnail with this PR it fails, I believe because it is using a mixture of metadata (i.e. assuming that it has enough bytes for 32-bit data, and failing with an ArrayIndexOutOfBoundsException

  • One tile reader should support accessing only one series.

Possibly - it is one option to overcome the issue.

Currently, the implementation of BioFormatsReaderWrapper in this PR has two getPixelValues() methods. One of them sets the series and then resets it back to its original value, the other sets it but doesn't reset it.

Without the reset, then the reader has changed into a different state - and the values returned by any call that requests metadata from the reader are subject to giving different results (example at the end of this post).

Additionally, both methods are potentially broken in a multithreading context because there is no synchronization done on the reader.

Excessive synchronization could harm performance. Forbidding the series and ID to be changed anywhere inside the class - and forbidding the reader from being accessed outside (i.e. not providing a getReader() option) - would reduce the need for synchronization, but probably not eliminate it because I am not sure that Bio-Formats guarantees that pixels can be accessed simultaneously from different threads even if the series and ID aren't changed.

The alternative is to synchronize everything that uses the reader, and then taking care to design the class in such a way that it's not possible to get around the synchronization. To do that, the getReader() option should again be removed.

A third option is to make the class really minimal and keep the getReader() option - but document that it is entirely up to the caller what they do with the reader, and they must take care of synchronization etc.

The third option puts much more responsibility on the caller, but has the advantage of allowing the same reader to be reused for different images / series. This might have some small improvements in performance (especially if initializing a reader is slow), but could be brittle and easy to get wrong.

  • The T getImage(int series); function should be removed, and the T getImage(TileRequest tileRequest, int[] channels, boolean isRGB, ColorModel colorModel, int series) function should be used instead.

Ideally yes. As the HnE_3_1x1component_data.tif example, shows, we don't know what kind of image will be returned by getImage(int series), and so having a separate API that assumes a single-resolution, non-pyramidal, 2D image seems to add (rather than reduce) complexity.

However I didn't understand where the OMEPixelParser class would be in all of this. As seen with the HnE_3_1x1component_data.tif example, we don't know for sure what

I think we should go back to thinking about the ideal design here, based upon what needs to be reusable - and also what are the simplest and safest changes that can be made before the v0.5.0 release.

My understanding of the original requirements is

  1. Essential The OMERO Gateway returns byte arrays in a format very similar to Bio-Formats, and the logic convert these into a BufferedImage (with suitable ColorModel etc.) is complex. This should be extracted from BioFormatsImageServer for reuse.
  2. Nice to have The BioFormatsImageServer also has a reader pool concept, which might be beneficial for the OMERO image server as well.

Achieving 1. requires a class to do the parsing, but doesn't necessarily require reader wrappers and reader pools at all.

These seem to be where the main dangers lie, because Bio-Formats is complex to use in a multithreaded context.

On the other hand, the parsing doesn't need to know anything about an IFormatReader - it just needs the minimal, immutable info required to convert bytes-to-BufferedImage.

If you can extract the bytes-to-BufferedImage logic in an entirely threadsafe way, and leave as much as possible of BioFormatsImageServer as it is, then that could be the safest way to make a v0.5.0 update and help with the OMERO extension.

In general, I am cautious about about extensive refactoring of BioFormatsImageServer because I've written so many subtly broken versions of it myself throughout in QuPath's history :) It's really hard to get 'right'; the previous version was messy, but the code seemed to work pretty reliably (well, except for this...).


I realise it's incredibly hard (/ impossible) to write this without failing examples, and most public examples won't fail because we get lucky with the different series types.

My guess is that .czi is one of the more awkward formats. Based on that, I found another failing example here: https://zenodo.org/record/7149674

Specifically, check out the label and macro images with the PR vs. in QuPath v0.4.4. In this case, the problem is related to the 'interleaved' interpretation: there's no exception thrown, but the image obtained via the PR is incorrect.

If you open the image in QuPath, this Groovy script shows that the isInterleaved() status changes for different series, which I think is the explanation:

def wrapper = getCurrentServer().readerPool.getDedicatedReaderWrapper()

println "Original interleaved: " + wrapper.isInterleaved()
wrapper.getReader().setSeries(0)
println "Series 0 interleaved: " + wrapper.isInterleaved()
wrapper.getReader().setSeries(1)
println "Series 1 interleaved: " + wrapper.isInterleaved()
wrapper.getReader().setSeries(2)
println "Series 2 interleaved: " + wrapper.isInterleaved()

@Rylern
Copy link
Contributor Author

Rylern commented Sep 12, 2023

OK, I just created another pull request only with the OMEPixelParser class (see #1327)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

2 participants