diff --git a/src/main/java/com/glencoesoftware/bioformats2raw/Converter.java b/src/main/java/com/glencoesoftware/bioformats2raw/Converter.java index ca54f63..a37f8c4 100644 --- a/src/main/java/com/glencoesoftware/bioformats2raw/Converter.java +++ b/src/main/java/com/glencoesoftware/bioformats2raw/Converter.java @@ -539,7 +539,8 @@ public void setCompressionProperties(Map properties) { "com.glencoesoftware.bioformats2raw.BioTekReader," + "com.glencoesoftware.bioformats2raw.ND2PlateReader," + "com.glencoesoftware.bioformats2raw.MetaxpressReader," + - "com.glencoesoftware.bioformats2raw.MCDReader" + "com.glencoesoftware.bioformats2raw.MCDReader," + + "com.glencoesoftware.bioformats2raw.PhenixReader" ) public void setExtraReaders(Class[] extraReaderList) { if (extraReaderList != null) { diff --git a/src/main/java/com/glencoesoftware/bioformats2raw/PhenixImage.java b/src/main/java/com/glencoesoftware/bioformats2raw/PhenixImage.java new file mode 100644 index 0000000..4395f0c --- /dev/null +++ b/src/main/java/com/glencoesoftware/bioformats2raw/PhenixImage.java @@ -0,0 +1,135 @@ +/** + * Copyright (c) 2024 Glencoe Software, Inc. All rights reserved. + * + * This software is distributed under the terms described by the LICENSE.txt + * file you can find at the root of the distribution bundle. If the file is + * missing please request a copy by contacting info@glencoesoftware.com + */ +package com.glencoesoftware.bioformats2raw; + +import loci.common.DataTools; +import loci.common.IniTable; + +/** + * Represents a single image (one 2D plane) in an Opera Phenix database plate. + */ +public class PhenixImage { + + /** + * Unique index of this plane. + * This is for sorting purposes only, and may not correspond to the + * well, field, series, plane, etc. + */ + public int index; + + /** + * Non-unique Image ID. + * All images in the plate will have the same ID, so this should not be + * used for sorting or lookups. + */ + public String id; + + /** Well row, indexed from 1. */ + public int row; + + /** Well column, indexed from 1. */ + public int col; + + /** Field, indexed from 1. */ + public int field; + + /** Calculated series index, from 0. */ + public int series; + + /** Plane, indexed from 1. */ + public int plane; + + /** Channel, indexed from 1. */ + public int channel; + + /** Channel name. */ + public String channelName; + + /** Exposure time in seconds. */ + public Double exposureTime; + + /** Filter wavelength in nm. */ + public Double filterWavelength; + + /** Laser wavelength in nm. */ + public Double wavelength; + + /** Image acquisition date. */ + public String acquisitionDate; + + /** Absolute Z position. */ + public Double zPosition; + + /** Temperature at acquisition. */ + public Double temperature; + + /** Absolute file path. */ + public String filename; + + /** + * Construct an empty PhenixImage. + */ + public PhenixImage() { + } + + /** + * Construct a PhenixImage representing a single plane, + * using the given INI data. + * + * @param table INI table containing image metadata + */ + public PhenixImage(IniTable table) { + id = table.get("id"); + index = Integer.parseInt(table.get("index")); + row = Integer.parseInt(table.get("row")); + col = Integer.parseInt(table.get("col")); + field = Integer.parseInt(table.get("field")); + series = Integer.parseInt(table.get("series")); + plane = Integer.parseInt(table.get("plane")); + channel = Integer.parseInt(table.get("channel")); + acquisitionDate = table.get("acquisitionDate"); + zPosition = DataTools.parseDouble(table.get("zPosition")); + temperature = DataTools.parseDouble(table.get("temperature")); + filename = table.get("filename"); + channelName = table.get("channelName"); + exposureTime = DataTools.parseDouble(table.get("exposureTime")); + wavelength = DataTools.parseDouble(table.get("laserWavelength")); + filterWavelength = DataTools.parseDouble(table.get("filterWavelength")); + } + + /** + * @return populated INI table representing this image + */ + public IniTable getIniTable() { + IniTable table = new IniTable(); + table.put(IniTable.HEADER_KEY, "Image " + index); + table.put("id", id); + table.put("index", String.valueOf(index)); + table.put("row", String.valueOf(row)); + table.put("col", String.valueOf(col)); + table.put("field", String.valueOf(field)); + table.put("series", String.valueOf(series)); + table.put("plane", String.valueOf(plane)); + table.put("channel", String.valueOf(channel)); + table.put("acquisitionDate", acquisitionDate); + table.put("zPosition", String.valueOf(zPosition)); + table.put("temperature", String.valueOf(temperature)); + table.put("filename", filename); + table.put("channelName", channelName); + table.put("exposureTime", String.valueOf(exposureTime)); + table.put("laserWavelength", String.valueOf(wavelength)); + table.put("filterWavelength", String.valueOf(filterWavelength)); + return table; + } + + @Override + public String toString() { + return String.format("ID %d: %s", id, filename); + } + +} diff --git a/src/main/java/com/glencoesoftware/bioformats2raw/PhenixReader.java b/src/main/java/com/glencoesoftware/bioformats2raw/PhenixReader.java new file mode 100644 index 0000000..e4ad0eb --- /dev/null +++ b/src/main/java/com/glencoesoftware/bioformats2raw/PhenixReader.java @@ -0,0 +1,462 @@ +/** + * Copyright (c) 2024 Glencoe Software, Inc. All rights reserved. + * + * This software is distributed under the terms described by the LICENSE.txt + * file you can find at the root of the distribution bundle. If the file is + * missing please request a copy by contacting info@glencoesoftware.com + */ +package com.glencoesoftware.bioformats2raw; + +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; + +import loci.common.DataTools; +import loci.common.DateTools; +import loci.common.IniList; +import loci.common.IniParser; +import loci.common.IniTable; +import loci.common.RandomAccessInputStream; +import loci.formats.CoreMetadata; +import loci.formats.FormatException; +import loci.formats.FormatReader; +import loci.formats.FormatTools; +import loci.formats.MetadataTools; +import loci.formats.in.DynamicMetadataOptions; +import loci.formats.in.MetadataOptions; +import loci.formats.in.TiffReader; +import loci.formats.meta.MetadataStore; +import ome.units.quantity.Time; +import ome.xml.model.primitives.Color; +import ome.xml.model.primitives.NonNegativeInteger; +import ome.xml.model.primitives.PositiveInteger; +import ome.xml.model.primitives.Timestamp; + +import org.perf4j.StopWatch; +import org.perf4j.slf4j.Slf4JStopWatch; + +/** + * PhenixReader is the file format reader for Opera Phenix database plates. + */ +public class PhenixReader extends FormatReader { + + // -- Constants -- + + public static final String MAGIC_STRING = "#PHENIX FILE"; + + public static final String INCLUDE_TIFFS_KEY = "phenix.include_tiffs"; + public static final boolean INCLUDE_TIFFS_DEFAULT = false; + + private static final String[] DATE_FORMATS = new String[] { + DateTools.TIMESTAMP_FORMAT + ".SSS", + DateTools.TIMESTAMP_FORMAT, + DateTools.ISO8601_FORMAT_MS, + DateTools.ISO8601_FORMAT, + }; + + // -- Fields -- + + private ArrayList images = new ArrayList(); + + private transient TiffReader planeReader = new TiffReader(); + + // -- Constructor -- + + /** Constructs a new Opera Phenix reader. */ + public PhenixReader() { + super("Opera Phenix", "phenix"); + domains = new String[] {FormatTools.HCS_DOMAIN}; + } + + // -- Phenix-specific methods -- + + /** + * Check reader options to determine if TIFFs should be included + * in used files list. + * + * @return true if TIFFS will be included in the used files list + */ + public boolean canIncludeTIFFs() { + MetadataOptions options = getMetadataOptions(); + if (options instanceof DynamicMetadataOptions) { + return ((DynamicMetadataOptions) options).getBoolean( + INCLUDE_TIFFS_KEY, INCLUDE_TIFFS_DEFAULT); + } + return INCLUDE_TIFFS_DEFAULT; + } + + // -- IFormatReader API methods -- + + /* @see loci.formats.IFormatReader#isThisType(RandomAccessInputStream) */ + @Override + public boolean isThisType(RandomAccessInputStream stream) throws IOException { + final int blockLen = 16; + if (!FormatTools.validStream(stream, blockLen, false)) { + return false; + } + return MAGIC_STRING.equals(stream.readString(blockLen)); + } + + /** + * @see loci.formats.IFormatReader#openBytes(int, byte[], int, int, int, int) + */ + @Override + public byte[] openBytes(int no, byte[] buf, int x, int y, int w, int h) + throws FormatException, IOException + { + FormatTools.checkPlaneParameters(this, no, buf.length, x, y, w, h); + + StopWatch s = stopWatch(); + PhenixImage image = getImage(getSeries(), no); + s.stop("file lookup for [" + getSeries() + ", " + no + "]"); + if (image != null && image.filename != null) { + LOGGER.debug("series: {}, no: {}, image.channel: {}, " + + "well row: {}, well column: {}, file: {}", + getSeries(), no, image.channel, image.row, image.col, image.filename); + s = stopWatch(); + if (planeReader == null) { + planeReader = new TiffReader(); + } + try { + planeReader.setId(image.filename); + s.stop("setId on " + image.filename); + LOGGER.debug(" IFD: {}", planeReader.getIFDs().get(0)); + s = stopWatch(); + planeReader.openBytes(0, buf, x, y, w, h); + } + catch (IOException e) { + Arrays.fill(buf, getFillColor()); + } + s.stop("openBytes(0) on " + image.filename); + } + else { + Arrays.fill(buf, getFillColor()); + } + + return buf; + } + + /* @see loci.formats.IFormatReader#getSeriesUsedFiles(boolean) */ + @Override + public String[] getSeriesUsedFiles(boolean noPixels) { + ArrayList files = new ArrayList(); + files.add(currentId); + if (canIncludeTIFFs() && !noPixels) { + List seriesImages = getImages(getSeries()); + for (PhenixImage img : seriesImages) { + if (img != null && img.filename != null) { + files.add(img.filename); + } + } + } + return files.toArray(new String[files.size()]); + } + + /* @see loci.formats.IFormatReader#close(boolean) */ + @Override + public void close(boolean fileOnly) throws IOException { + super.close(fileOnly); + planeReader.close(fileOnly); + if (!fileOnly) { + images.clear(); + } + } + + // -- Internal FormatReader API methods -- + + /* @see loci.formats.FormatReader#initFile(String) */ + protected void initFile(String id) throws FormatException, IOException { + super.initFile(id); + + LOGGER.info("Parsing metadata file"); + StopWatch watch = stopWatch(); + + IniList plateMetadata = new IniParser().parseINI(new File(currentId)); + watch.stop("parsed metadata file"); + + watch = stopWatch(); + IniTable plate = plateMetadata.getTable("Plate"); + + for (IniTable table : plateMetadata) { + String tableName = table.get(IniTable.HEADER_KEY); + if (tableName.startsWith("Image")) { + images.add(new PhenixImage(table)); + } + } + images.sort(new Comparator() { + public int compare(PhenixImage a, PhenixImage b) { + if (a.series != b.series) { + return a.series - b.series; + } + if (a.plane != b.plane) { + return a.plane - b.plane; + } + return a.channel - b.channel; + } + }); + LOGGER.debug("Found {} individual image planes", images.size()); + + core = new ArrayList(); + + String objective = plate.get("Objective"); + Double magnification = DataTools.parseDouble(plate.get("Magnification")); + + Double physicalSizeX = DataTools.parseDouble(plate.get("PhysicalSizeX")); + Double physicalSizeY = DataTools.parseDouble(plate.get("PhysicalSizeY")); + planeReader.setOriginalMetadataPopulated(isOriginalMetadataPopulated()); + planeReader.setMetadataFiltered(isMetadataFiltered()); + watch.stop("set up reader and image list"); + + watch = stopWatch(); + CoreMetadata ms = null; + CoreMetadata currentCore = null; + for (int i=0; i validWells = new HashMap(); + for (PhenixImage img : images) { + int well = (img.row - 1) * wellsX + (img.col - 1); + validWells.put(well, img.field); + } + LOGGER.trace("validWells = {}", validWells); + + // the field count may vary between wells, so find the + // maximum field count across all wells + int nFields = 0; + for (Integer f : validWells.values()) { + if (f > nFields) { + nFields = f; + } + } + LOGGER.debug("field count = {}", nFields); + + store.setPlateAcquisitionMaximumFieldCount( + new PositiveInteger(nFields), 0, 0); + Timestamp plateDate = getTimestamp(plate.get("Date")); + if (plateDate != null) { + store.setPlateAcquisitionStartTime(plateDate, 0, 0); + } + + String instrument = MetadataTools.createLSID("Instrument", 0); + store.setInstrumentID(instrument, 0); + String objectiveID = MetadataTools.createLSID("Objective", 0, 0); + store.setObjectiveID(objectiveID, 0, 0); + store.setObjectiveModel(objective, 0, 0); + if (magnification != null) { + store.setObjectiveNominalMagnification(magnification, 0, 0); + } + + for (int c=0; c= images.size()) { + break; + } + PhenixImage img = getImage(image, 0); + if (img.col - 1 != col || img.row - 1 != row) { + // make sure that this site's well lines up with the + // well that we're processing + // this should catch the case when a well has fewer + // fields than expected + break; + } + + LOGGER.debug("Using image {} for row = {}, col = {}, field = {}", + image, row, col, field); + + String wellSampleID = + MetadataTools.createLSID("WellSample", 0, well, field); + store.setWellSampleID(wellSampleID, 0, well, field); + + String imageID = MetadataTools.createLSID("Image", image); + store.setImageID(imageID, image); + store.setImageName("Well " + FormatTools.getWellName(row, col) + + ", Field #" + (field + 1), image); + + Timestamp imageDate = getTimestamp(img.acquisitionDate); + if (imageDate != null) { + store.setImageAcquisitionDate(imageDate, image); + } + store.setImageInstrumentRef(instrument, image); + store.setObjectiveSettingsID(objectiveID, image); + + store.setWellSampleImageRef(imageID, 0, well, field); + store.setWellSampleIndex( + new NonNegativeInteger(image), 0, well, field); + + store.setPlateAcquisitionWellSampleRef(wellSampleID, 0, 0, image); + + store.setPixelsPhysicalSizeX( + FormatTools.getPhysicalSizeX(physicalSizeX), image); + store.setPixelsPhysicalSizeY( + FormatTools.getPhysicalSizeY(physicalSizeY), image); + + for (int c=0; c getImages(int series) { + List imgs = new ArrayList(); + for (int i=0; i