package gdsc.smlm.ij.plugins; import java.awt.Color; import java.awt.Frame; import java.awt.Point; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.Comparator; import java.util.List; import org.apache.commons.math3.analysis.polynomials.PolynomialFunction; import org.apache.commons.math3.fitting.PolynomialCurveFitter; import org.apache.commons.math3.fitting.WeightedObservedPoints; import org.apache.commons.math3.util.MathArrays; import gdsc.core.ij.Utils; import gdsc.core.utils.Maths; import gdsc.core.utils.Statistics; import gdsc.core.utils.StoredDataStatistics; /*----------------------------------------------------------------------------- * GDSC SMLM Software * * Copyright (C) 2013 Alex Herbert * Genome Damage and Stability Centre * University of Sussex, UK * * This program is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation; either version 3 of the License, or * (at your option) any later version. *---------------------------------------------------------------------------*/ import gdsc.smlm.ij.utils.SeriesOpener; import ij.IJ; import ij.ImagePlus; import ij.ImageStack; import ij.WindowManager; import ij.gui.GenericDialog; import ij.gui.Plot2; import ij.gui.PlotWindow; import ij.plugin.PlugIn; import ij.text.TextWindow; /** * Opens a folder of images and computes a Mean-Variance Test. * <p> * Each image must contain a 2-slice stack of white light images. The image filename must contain the exposure time * separated by whitespace, e.g. 'MVT 30.tif' for 30 milliseconds. * <p> * Gain calculations for standard CCD and EM-CCD cameras are based on the paper: Hirsch M, Wareham RJ, Martin-Fernandez * ML, Hobson MP, Rolfe DJ (2013) A Stochastic Model for Electron Multiplication Charge-Coupled Devices – From Theory to * Practice. PLoS ONE 8(1): e53671. doi:10.1371/journal.pone.0053671 */ public class MeanVarianceTest implements PlugIn { private static final String TITLE = "Mean Variance Test"; private static double cameraGain = 0; private static double _bias = 500; private static boolean showTable = true, showCharts = true; private int exposureCounter = 0; private boolean singleImage; private class PairSample { int slice1, slice2; double mean1, mean2, variance; public PairSample(int slice1, int slice2, double mean1, double mean2, double variance) { this.slice1 = slice1; this.slice2 = slice2; this.mean1 = mean1; this.mean2 = mean2; this.variance = variance; } public double getMean() { return (mean1 + mean2) * 0.5; } } private class ImageSample { String title; float[][] slices; double exposure; double[] means; List<PairSample> samples; public ImageSample(ImagePlus imp, double start, double end) { // Check stack has two slices if (imp.getStackSize() < 2) throw new IllegalArgumentException("Image must have at least 2-slices: " + imp.getTitle()); // Count all the valid input images exposureCounter++; // Extract the exposure time exposure = -1; String[] tokens = imp.getTitle().split("[ .]"); for (String token : tokens) { try { exposure = Double.parseDouble(token); if (exposure >= 0) break; } catch (NumberFormatException e) { // Ignore } } if (exposure < 0) { //throw new IllegalArgumentException("Image must have exposure time in the filename: " + imp.getTitle()); // If no exposure was found: assume exposure 0 for first input image otherwise set an arbitrary exposure exposure = (exposureCounter == 1) ? 0 : 9999; } title = imp.getTitle(); // Get all the pixels into a float stack. // Look for saturated pixels that will invalidate the test. final int size = imp.getStackSize(); slices = new float[size][]; final float saturated = getSaturation(imp); ImageStack stack = imp.getImageStack(); final double step = (end - start) / size; for (int slice = 1, c = 0; slice <= size; slice++) { if (c++ % 16 == 0) IJ.showProgress(start + c * step); final float[] thisSlice = slices[slice - 1] = (float[]) stack.getProcessor(slice).toFloat(0, null) .getPixels(); checkSaturation(slice, thisSlice, saturated); } } private float getSaturation(ImagePlus imp) { switch (imp.getBitDepth()) { case 8: case 24: return 255f; case 16: return 65535f; case 32: // float images cannot be saturated return Float.NaN; } throw new IllegalArgumentException("Cannot determine saturation level for image: " + imp.getTitle()); } private void checkSaturation(int i, float[] data, float saturated) { if (saturated == Float.NaN) return; for (float f : data) if (f >= saturated) throw new IllegalArgumentException("Image " + title + " has saturated pixels in slice: " + i); } public void compute(boolean consecutive, double start, double end) { final int size = slices.length; final int nSamples = (consecutive) ? size - 1 : ((size - 1) * size) / 2; samples = new ArrayList<PairSample>(nSamples); // Cache data means = new double[size]; for (int slice1 = 0; slice1 < size; slice1++) { means[slice1] = new Statistics(slices[slice1]).getMean(); } // Compute mean and variance. // See http://www.photometrics.com/resources/whitepapers/mean-variance.php final double step = (end - start) / nSamples; for (int slice1 = 0, c = 0; slice1 < size; slice1++) { float[] data1 = slices[slice1]; for (int slice2 = slice1 + 1; slice2 < size; slice2++) { if (c++ % 16 == 0) IJ.showProgress(start + c * step); float[] data2 = slices[slice2]; Statistics s = new Statistics(); for (int i = 0; i < data1.length; i++) s.add(data1[i] - data2[i]); double variance = s.getVariance() / 2.0; samples.add(new PairSample(slice1 + 1, slice2 + 1, means[slice1], means[slice2], variance)); if (consecutive) break; } slices[slice1] = null; // Allow garbage collection } } } /* * (non-Javadoc) * * @see ij.plugin.PlugIn#run(java.lang.String) */ public void run(String arg) { SMLMUsageTracker.recordPlugin(this.getClass(), arg); if (Utils.isExtraOptions()) { ImagePlus imp = WindowManager.getCurrentImage(); if (imp.getStackSize() > 1) { GenericDialog gd = new GenericDialog(TITLE); gd.addMessage("Perform single image analysis on the current image?"); gd.addNumericField("Bias", _bias, 0); gd.showDialog(); if (gd.wasCanceled()) return; singleImage = true; _bias = Math.abs(gd.getNextNumber()); } else { IJ.error(TITLE, "Single-image mode requires a stack"); return; } } List<ImageSample> images; String inputDirectory = ""; if (singleImage) { IJ.showStatus("Loading images..."); images = getImages(); if (images.size() == 0) { IJ.error(TITLE, "Not enough images for analysis"); return; } } else { inputDirectory = IJ.getDirectory("Select image series ..."); if (inputDirectory == null) return; SeriesOpener series = new SeriesOpener(inputDirectory, false, 0); series.setVariableSize(true); if (series.getNumberOfImages() < 3) { IJ.error(TITLE, "Not enough images in the selected directory"); return; } if (!IJ.showMessageWithCancel( TITLE, String.format("Analyse %d images, first image:\n%s", series.getNumberOfImages(), series.getImageList()[0]))) { return; } IJ.showStatus("Loading images"); images = getImages(series); if (images.size() < 3) { IJ.error(TITLE, "Not enough images for analysis"); return; } if (images.get(0).exposure != 0) { IJ.error(TITLE, "First image in series must have exposure 0 (Bias image)"); return; } } boolean emMode = (arg != null && arg.contains("em")); GenericDialog gd = new GenericDialog(TITLE); gd.addMessage("Set the output options:"); gd.addCheckbox("Show_table", showTable); gd.addCheckbox("Show_charts", showCharts); if (emMode) { // Ask the user for the camera gain ... gd.addMessage("Estimating the EM-gain requires the camera gain without EM readout enabled"); gd.addNumericField("Camera_gain (ADU/e-)", cameraGain, 4); } gd.showDialog(); if (gd.wasCanceled()) return; showTable = gd.getNextBoolean(); showCharts = gd.getNextBoolean(); if (emMode) { cameraGain = gd.getNextNumber(); } IJ.showStatus("Computing mean & variance"); final double nImages = images.size(); for (int i = 0; i < images.size(); i++) { IJ.showStatus(String.format("Computing mean & variance %d/%d", i + 1, images.size())); images.get(i).compute(singleImage, i / nImages, (i + 1) / nImages); } IJ.showProgress(1); IJ.showStatus("Computing results"); // Allow user to input multiple bias images int start = 0; Statistics biasStats = new Statistics(); Statistics noiseStats = new Statistics(); final double bias; if (singleImage) { bias = _bias; } else { while (start < images.size()) { ImageSample sample = images.get(start); if (sample.exposure == 0) { biasStats.add(sample.means); for (PairSample pair : sample.samples) { noiseStats.add(pair.variance); } start++; } else break; } bias = biasStats.getMean(); } // Get the mean-variance data int total = 0; for (int i = start; i < images.size(); i++) total += images.get(i).samples.size(); if (showTable && total > 2000) { gd = new GenericDialog(TITLE); gd.addMessage("Table output requires "+total+" entries.\n \nYou may want to disable the table."); gd.addCheckbox("Show_table", showTable); gd.showDialog(); if (gd.wasCanceled()) return; showTable = gd.getNextBoolean(); } TextWindow results = (showTable) ? createResultsWindow() : null; double[] mean = new double[total]; double[] variance = new double[mean.length]; Statistics gainStats = (singleImage) ? new StoredDataStatistics(total) : new Statistics(); final WeightedObservedPoints obs = new WeightedObservedPoints(); for (int i = (singleImage) ? 0 : start, j = 0; i < images.size(); i++) { StringBuilder sb = (showTable) ? new StringBuilder() : null; ImageSample sample = images.get(i); for (PairSample pair : sample.samples) { if (j % 16 == 0) IJ.showProgress(j, total); mean[j] = pair.getMean(); variance[j] = pair.variance; // Gain is in ADU / e double gain = variance[j] / (mean[j] - bias); gainStats.add(gain); obs.add(mean[j], variance[j]); if (emMode) { gain /= (2 * cameraGain); } if (showTable) { sb.append(sample.title).append("\t"); sb.append(sample.exposure).append("\t"); sb.append(pair.slice1).append("\t"); sb.append(pair.slice2).append("\t"); sb.append(IJ.d2s(pair.mean1, 2)).append("\t"); sb.append(IJ.d2s(pair.mean2, 2)).append("\t"); sb.append(IJ.d2s(mean[j], 2)).append("\t"); sb.append(IJ.d2s(variance[j], 2)).append("\t"); sb.append(Utils.rounded(gain, 4)).append("\n"); } j++; } if (showTable) results.append(sb.toString()); } IJ.showProgress(1); if (singleImage) { StoredDataStatistics stats = (StoredDataStatistics) gainStats; Utils.log(TITLE); if (emMode) { double[] values = stats.getValues(); MathArrays.scaleInPlace(0.5, values); stats = new StoredDataStatistics(values); } if (showCharts) { // Plot the gain over time String title = TITLE + " Gain vs Frame"; Plot2 plot = new Plot2(title, "Slice", "Gain", Utils.newArray(gainStats.getN(), 1, 1.0), stats.getValues()); PlotWindow pw = Utils.display(title, plot); // Show a histogram String label = String.format("Mean = %s, Median = %s", Utils.rounded(stats.getMean()), Utils.rounded(stats.getMedian())); int id = Utils.showHistogram(TITLE, stats, "Gain", 0, 1, 100, true, label); if (Utils.isNewWindow()) { Point point = pw.getLocation(); point.x = pw.getLocation().x; point.y += pw.getHeight(); WindowManager.getImage(id).getWindow().setLocation(point); } } Utils.log("Single-image mode: %s camera", (emMode) ? "EM-CCD" : "Standard"); final double gain = stats.getMedian(); if (emMode) { final double totalGain = gain; final double emGain = totalGain / cameraGain; Utils.log(" Gain = 1 / %s (ADU/e-)", Utils.rounded(cameraGain, 4)); Utils.log(" EM-Gain = %s", Utils.rounded(emGain, 4)); Utils.log(" Total Gain = %s (ADU/e-)", Utils.rounded(totalGain, 4)); } else { cameraGain = gain; Utils.log(" Gain = 1 / %s (ADU/e-)", Utils.rounded(cameraGain, 4)); } } else { IJ.showStatus("Computing fit"); // Sort int[] indices = rank(mean); mean = reorder(mean, indices); variance = reorder(variance, indices); // Compute optimal coefficients. final double[] init = { 0, 1 / gainStats.getMean() }; // a - b x final PolynomialCurveFitter fitter = PolynomialCurveFitter.create(2).withStartPoint(init); final double[] best = fitter.fit(obs.toList()); // Construct the polynomial that best fits the data. final PolynomialFunction fitted = new PolynomialFunction(best); if (showCharts) { // Plot mean verses variance. Gradient is gain in ADU/e. String title = TITLE + " results"; Plot2 plot = new Plot2(title, "Mean", "Variance"); double[] xlimits = Maths.limits(mean); double[] ylimits = Maths.limits(variance); double xrange = (xlimits[1] - xlimits[0]) * 0.05; if (xrange == 0) xrange = 0.05; double yrange = (ylimits[1] - ylimits[0]) * 0.05; if (yrange == 0) yrange = 0.05; plot.setLimits(xlimits[0] - xrange, xlimits[1] + xrange, ylimits[0] - yrange, ylimits[1] + yrange); plot.setColor(Color.blue); plot.addPoints(mean, variance, Plot2.CROSS); plot.setColor(Color.red); plot.addPoints(new double[] { mean[0], mean[mean.length - 1] }, new double[] { fitted.value(mean[0]), fitted.value(mean[mean.length - 1]) }, Plot2.LINE); Utils.display(title, plot); } final double avBiasNoise = Math.sqrt(noiseStats.getMean()); Utils.log(TITLE); Utils.log(" Directory = %s", inputDirectory); Utils.log(" Bias = %s +/- %s (ADU)", Utils.rounded(bias, 4), Utils.rounded(avBiasNoise, 4)); Utils.log(" Variance = %s + %s * mean", Utils.rounded(best[0], 4), Utils.rounded(best[1], 4)); if (emMode) { final double emGain = best[1] / (2 * cameraGain); // Noise is standard deviation of the bias image divided by the total gain (in ADU/e-) final double totalGain = emGain * cameraGain; Utils.log(" Read Noise = %s (e-) [%s (ADU)]", Utils.rounded(avBiasNoise / totalGain, 4), Utils.rounded(avBiasNoise, 4)); Utils.log(" Gain = 1 / %s (ADU/e-)", Utils.rounded(1 / cameraGain, 4)); Utils.log(" EM-Gain = %s", Utils.rounded(emGain, 4)); Utils.log(" Total Gain = %s (ADU/e-)", Utils.rounded(totalGain, 4)); } else { // Noise is standard deviation of the bias image divided by the gain (in ADU/e-) cameraGain = best[1]; final double readNoise = avBiasNoise / cameraGain; Utils.log(" Read Noise = %s (e-) [%s (ADU)]", Utils.rounded(readNoise, 4), Utils.rounded(readNoise * cameraGain, 4)); Utils.log(" Gain = 1 / %s (ADU/e-)", Utils.rounded(1 / cameraGain, 4)); } } IJ.showStatus(""); } private TextWindow createResultsWindow() { Frame f = WindowManager.getFrame(TITLE); if (f instanceof TextWindow) { return (TextWindow) f; } return new TextWindow(TITLE, "Image\tExposure\tSlice1\tSlice2\tMean1\tMean2\tMean\tVariance\tGain", "", 800, 500); } private List<ImageSample> getImages(SeriesOpener series) { final double nImages = series.getNumberOfImages(); List<ImageSample> images = new ArrayList<ImageSample>((int) nImages); ImagePlus imp = series.nextImage(); int c = 0; while (imp != null) { try { images.add(new ImageSample(imp, c / nImages, (c + 1) / nImages)); } catch (IllegalArgumentException e) { Utils.log(e.getMessage()); } c++; imp.close(); imp = series.nextImage(); } IJ.showProgress(1); // Sort to ensure all 0 exposure images are first, the remaining order is arbitrary Collections.sort(images, new Comparator<ImageSample>() { public int compare(ImageSample o1, ImageSample o2) { if (o1.exposure < o2.exposure) return -1; if (o1.exposure > o2.exposure) return 1; return 0; } }); return images; } private List<ImageSample> getImages() { List<ImageSample> images = new ArrayList<ImageSample>(1); ImagePlus imp = WindowManager.getCurrentImage(); if (imp != null) { try { images.add(new ImageSample(imp, 0, 1)); } catch (IllegalArgumentException e) { Utils.log(e.getMessage()); } } IJ.showProgress(1); return images; } /** * Returns a sorted list of indices of the specified double array. * Modified from: http://stackoverflow.com/questions/951848 by N.Vischer. * Copied from ImageJ 1.48 for backwards compatibility */ public static int[] rank(double[] values) { int n = values.length; final Integer[] indexes = new Integer[n]; final Double[] data = new Double[n]; for (int i = 0; i < n; i++) { indexes[i] = new Integer(i); data[i] = new Double(values[i]); } Arrays.sort(indexes, new Comparator<Integer>() { public int compare(final Integer o1, final Integer o2) { return data[o1].compareTo(data[o2]); } }); int[] indexes2 = new int[n]; for (int i = 0; i < n; i++) indexes2[i] = indexes[i].intValue(); return indexes2; } private double[] reorder(double[] data, int[] indices) { double[] array = new double[indices.length]; for (int i = 0; i < array.length; i++) { array[i] = data[indices[i]]; } return array; } }