diff --git a/gapic/src/main/com/google/gapid/image/ArrayImage.java b/gapic/src/main/com/google/gapid/image/ArrayImage.java index b05c3fcce2..d3c812058e 100644 --- a/gapic/src/main/com/google/gapid/image/ArrayImage.java +++ b/gapic/src/main/com/google/gapid/image/ArrayImage.java @@ -20,13 +20,11 @@ import static com.google.gapid.util.Colors.clamp; import com.google.common.primitives.UnsignedBytes; -import com.google.common.collect.Sets; import com.google.gapid.glviewer.gl.Texture; -import com.google.gapid.proto.stream.Stream.Channel; +import com.google.gapid.image.Histogram.Binner; +import com.google.gapid.proto.stream.Stream; import com.google.gapid.util.Colors; -import java.nio.DoubleBuffer; -import java.util.Set; import org.eclipse.swt.graphics.ImageData; import org.lwjgl.BufferUtils; import org.lwjgl.opengl.GL11; @@ -36,6 +34,7 @@ import java.nio.ByteOrder; import java.nio.FloatBuffer; import java.util.Arrays; +import java.util.Set; /** * An {@link Image} backed by a byte array. @@ -107,10 +106,10 @@ public PixelValue getPixel(int x, int y, int z) { if (x < 0 || y < 0 || z < 0 || x >= width || y >= height || z > depth) { return PixelValue.NULL_PIXEL; } - return getPixel(x, y, data); + return getPixel(x, y); } - protected abstract PixelValue getPixel(int x, int y, byte[] src); + protected abstract PixelValue getPixel(int x, int y); protected static ByteBuffer buffer(byte[] data) { return ByteBuffer.wrap(data).order(ByteOrder.LITTLE_ENDIAN); @@ -176,13 +175,17 @@ public static class RGBA8Image extends ArrayImage { private final PixelInfo info; public RGBA8Image(int width, int height, int depth, byte[] data) { + this(width, height, depth, data, IntPixelInfo.compute(data, true)); + } + + private RGBA8Image(int width, int height, int depth, byte[] data, PixelInfo info) { super(width, height, depth, 4, data, GL11.GL_RGBA8, GL11.GL_RGBA, GL11.GL_UNSIGNED_BYTE); - this.info = IntPixelInfo.compute(data); + this.info = info; } @Override protected Image create(int w, int h, int d, byte[] pixels) { - return new RGBA8Image(w, h, d, pixels); + return new RGBA8Image(w, h, d, pixels, info); } @Override @@ -199,7 +202,7 @@ protected void convert2D(byte[] src, byte[] dst, byte[] alpha, int stride) { } @Override - protected PixelValue getPixel(int x, int y, byte[] data) { + protected PixelValue getPixel(int x, int y) { int i = 4 * (y * width + x); return new Pixel( ((data[i + 3] & 0xFF) << 24) | @@ -209,41 +212,23 @@ protected PixelValue getPixel(int x, int y, byte[] data) { } @Override - public Set getChannels() { - return Sets.immutableEnumSet(Channel.Red, Channel.Green, Channel.Blue, Channel.Alpha); + public Set getChannels() { + return Images.RGB_CHANNELS; } @Override - public DoubleBuffer getChannel(Channel channel) { - int count = width * height * depth; - DoubleBuffer out = DoubleBuffer.allocate(count); - int offset = 0; - switch (channel) { - case Red: - offset = 0; - break; - case Green: - offset = 1; - break; - case Blue: - offset = 2; - break; - case Alpha: - offset = 3; - break; - default: - return out; - } - for (int i = 0; i < count; i++) { - out.put((data[i * 4 + offset] & 0xFF) / 255.0); - } - out.rewind(); - return out; + public boolean isHDR() { + return false; } @Override - public boolean isHDR() { - return false; + public void bin(Binner binner) { + for (int i = 0, end = data.length - 3; i < end; ) { + binner.bin(UnsignedBytes.toInt(data[i++]) / 255f, Stream.Channel.Red); + binner.bin(UnsignedBytes.toInt(data[i++]) / 255f, Stream.Channel.Green); + binner.bin(UnsignedBytes.toInt(data[i++]) / 255f, Stream.Channel.Blue); + i++; // skip alpha + } } @Override @@ -283,9 +268,15 @@ public RGBAFloatImage(int width, int height, int depth, byte[] data) { this.info = FloatPixelInfo.compute(buffer, true); } + private RGBAFloatImage(int width, int height, int depth, byte[] data, PixelInfo info) { + super(width, height, depth, 16, data, GL30.GL_RGBA32F, GL11.GL_RGBA, GL11.GL_FLOAT); + this.buffer = buffer(data).asFloatBuffer(); + this.info = info; + } + @Override protected Image create(int w, int h, int d, byte[] pixels) { - return new RGBAFloatImage(w, h, d, pixels); + return new RGBAFloatImage(w, h, d, pixels, info); } @Override @@ -302,47 +293,38 @@ protected void convert2D(byte[] src, byte[] dst, byte[] alpha, int stride) { } @Override - protected PixelValue getPixel(int x, int y, byte[] data) { + protected PixelValue getPixel(int x, int y) { int i = 4 * (y * width + x); return new Pixel(buffer.get(i + 0), buffer.get(i + 1), buffer.get(i + 2), buffer.get(i + 3)); } @Override - public Set getChannels() { - return Sets.immutableEnumSet(Channel.Red, Channel.Green, Channel.Blue, Channel.Alpha); + public Set getChannels() { + return Images.RGB_CHANNELS; } @Override - public DoubleBuffer getChannel(Channel channel) { - int count = width * height * depth; - DoubleBuffer out = DoubleBuffer.allocate(count); - int offset = 0; - switch (channel) { - case Red: - offset = 0; - break; - case Green: - offset = 1; - break; - case Blue: - offset = 2; - break; - case Alpha: - offset = 3; - break; - default: - return out; - } - for (int i = 0; i < count; i++) { - out.put(buffer.get(i * 4 + offset)); - } - out.rewind(); - return out; + public boolean isHDR() { + return true; } @Override - public boolean isHDR() { - return true; + public void bin(Histogram.Binner binner) { + for (int i = 0, end = buffer.remaining() - 3; i <= end; ) { + float value = buffer.get(i++); + if (!Float.isNaN(value) && !Float.isInfinite(value)) { + binner.bin(value, Stream.Channel.Red); + } + value = buffer.get(i++); + if (!Float.isNaN(value) && !Float.isInfinite(value)) { + binner.bin(value, Stream.Channel.Green); + } + value = buffer.get(i++); + if (!Float.isNaN(value) && !Float.isInfinite(value)) { + binner.bin(value, Stream.Channel.Blue); + } + i++; // Skip alpha. + } } @Override @@ -377,13 +359,20 @@ public boolean isDark() { */ // TODO: The client may not actually need to distinguish between luminance and RGBA public static class Luminance8Image extends ArrayImage { + private final PixelInfo info; + public Luminance8Image(int width, int height, int depth, byte[] data) { + this(width, height, depth, data, IntPixelInfo.compute(data, false)); + } + + private Luminance8Image(int width, int height, int depth, byte[] data, PixelInfo info) { super(width, height, depth, 1, data, GL11.GL_RGB8, GL11.GL_RED, GL11.GL_UNSIGNED_BYTE); + this.info = info; } @Override protected Image create(int w, int h, int d, byte[] pixels) { - return new Luminance8Image(w, h, d, pixels); + return new Luminance8Image(w, h, d, pixels, info); } @Override @@ -393,25 +382,20 @@ public void uploadToTexture(Texture texture) { } @Override - public Set getChannels() { - return Sets.immutableEnumSet(Channel.Luminance); + public Set getChannels() { + return Images.LUMINANCE_CHANNELS; } @Override - public DoubleBuffer getChannel(Channel channel) { - DoubleBuffer out = DoubleBuffer.allocate(data.length); - if (channel == Channel.Luminance) { - for (byte value : data) { - out.put((value & 0xFF) / 255.0); - } - out.rewind(); - } - return out; + public boolean isHDR() { + return false; } @Override - public boolean isHDR() { - return false; + public void bin(Binner binner) { + for (int i = 0; i < data.length; i++) { + binner.bin(UnsignedBytes.toInt(data[i]) / 255.0f, Stream.Channel.Luminance); + } } @Override @@ -428,13 +412,13 @@ protected void convert2D(byte[] src, byte[] dst, byte[] alpha, int stride) { } @Override - protected PixelValue getPixel(int x, int y, byte[] src) { - return new Pixel(src[y * width + x]); + protected PixelValue getPixel(int x, int y) { + return new Pixel(data[y * width + x]); } @Override public PixelInfo getInfo() { - return PixelInfo.NULL_INFO; + return info; } private static class Pixel implements PixelValue { @@ -470,9 +454,15 @@ public LuminanceFloatImage(int width, int height, int depth, byte[] data) { this.info = FloatPixelInfo.compute(buffer, false); } + private LuminanceFloatImage(int width, int height, int depth, byte[] data, PixelInfo info) { + super(width, height, depth, 4, data, GL30.GL_RGB32F, GL11.GL_RED, GL11.GL_FLOAT); + this.buffer = buffer(data).asFloatBuffer(); + this.info = info; + } + @Override protected Image create(int w, int h, int d, byte[] pixels) { - return new LuminanceFloatImage(w, h, d, pixels); + return new LuminanceFloatImage(w, h, d, pixels, info); } @Override @@ -482,26 +472,23 @@ public void uploadToTexture(Texture texture) { } @Override - public Set getChannels() { - return Sets.immutableEnumSet(Channel.Luminance); + public Set getChannels() { + return Images.LUMINANCE_CHANNELS; } @Override - public DoubleBuffer getChannel(Channel channel) { - int count = width * height * depth; - DoubleBuffer out = DoubleBuffer.allocate(count); - if (channel == Channel.Luminance) { - for (int i = 0; i < count; i++) { - out.put(buffer.get(i)); - } - out.rewind(); - } - return out; + public boolean isHDR() { + return true; } @Override - public boolean isHDR() { - return true; + public void bin(Binner binner) { + for (int i = 0; i < buffer.remaining(); i++) { + float value = buffer.get(i); + if (!Float.isNaN(value) && !Float.isInfinite(value)) { + binner.bin(value, Stream.Channel.Luminance); + } + } } @Override @@ -519,7 +506,7 @@ protected void convert2D(byte[] src, byte[] dst, byte[] alpha, int stride) { } @Override - protected PixelValue getPixel(int x, int y, byte[] data) { + protected PixelValue getPixel(int x, int y) { return new Pixel(buffer.get(y * width + x)); } @@ -548,12 +535,14 @@ public boolean isDark() { } private static class FloatPixelInfo implements PixelInfo { - private final float min, max; - private final float alphaMin, alphaMax; + private final double min, max, average; + private final double alphaMin, alphaMax; - private FloatPixelInfo(float min, float max, float alphaMin, float alphaMax) { + private FloatPixelInfo( + double min, double max, double average, double alphaMin, double alphaMax) { this.min = min; this.max = max; + this.average = average; this.alphaMin = alphaMin; this.alphaMax = alphaMax; } @@ -563,8 +552,9 @@ public static PixelInfo compute(FloatBuffer buffer, boolean isRGBA) { return PixelInfo.NULL_INFO; } - float min = Float.POSITIVE_INFINITY, max = Float.NEGATIVE_INFINITY; - float alphaMin, alphaMax; + double min = Double.POSITIVE_INFINITY, max = Double.NEGATIVE_INFINITY, alphaMin, alphaMax; + double average = 0; + long count = 0; if (isRGBA) { alphaMin = Float.POSITIVE_INFINITY; alphaMax = Float.NEGATIVE_INFINITY; @@ -573,21 +563,29 @@ public static PixelInfo compute(FloatBuffer buffer, boolean isRGBA) { if (!Float.isNaN(value) && !Float.isInfinite(value)) { min = Math.min(min, value); max = Math.max(max, value); + average += value; + count++; } value = buffer.get(i++); if (!Float.isNaN(value) && !Float.isInfinite(value)) { min = Math.min(min, value); max = Math.max(max, value); + average += value; + count++; } value = buffer.get(i++); if (!Float.isNaN(value) && !Float.isInfinite(value)) { min = Math.min(min, value); max = Math.max(max, value); + average += value; + count++; } value = buffer.get(i++); if (!Float.isNaN(value) && !Float.isInfinite(value)) { alphaMin = Math.min(alphaMin, value); alphaMax = Math.max(alphaMax, value); + average += value; + count++; } } } else { @@ -597,72 +595,119 @@ public static PixelInfo compute(FloatBuffer buffer, boolean isRGBA) { if (!Float.isNaN(value) && !Float.isInfinite(value)) { min = Math.min(min, value); max = Math.max(max, value); + average += value; + count++; } } } - return new FloatPixelInfo(min, max, alphaMin, alphaMax); + return new FloatPixelInfo( + min, max, (count == 0) ? 0.5 : (average / count), alphaMin, alphaMax); } @Override - public float getMin() { + public double getMin() { return min; } @Override - public float getMax() { + public double getMax() { return max; } @Override - public float getAlphaMin() { + public double getAverage() { + return average; + } + + @Override + public double getAlphaMin() { return alphaMin; } @Override - public float getAlphaMax() { + public double getAlphaMax() { return alphaMax; } } private static class IntPixelInfo implements PixelInfo { - private final float alphaMin, alphaMax; + private final double min, max, average; + private final double alphaMin, alphaMax; - private IntPixelInfo(float alphaMin, float alphaMax) { + private IntPixelInfo(double min, double max, double average, double alphaMin, double alphaMax) { + this.min = min; + this.max = max; + this.average = average; this.alphaMin = alphaMin; this.alphaMax = alphaMax; } - public static PixelInfo compute(byte[] rgba) { - if (rgba.length == 0) { + public static PixelInfo compute(byte[] data, boolean isRGBA) { + if (data.length == 0) { return PixelInfo.NULL_INFO; } - int alphaMin = Integer.MAX_VALUE, alphaMax = Integer.MIN_VALUE; - for (int i = 3; i < rgba.length; i += 4) { - int value = UnsignedBytes.toInt(rgba[i]); - alphaMin = Math.min(alphaMin, value); - alphaMax = Math.max(alphaMax, value); + int min = 255, max = 0, alphaMin, alphaMax; + double average = 0; + if (isRGBA) { + alphaMin = 255; alphaMax = 0; + for (int i = 0, end = data.length - 3; i < end; ) { + int value = UnsignedBytes.toInt(data[i++]); + min = Math.min(min, value); + max = Math.max(max, value); + average += value; + + value = UnsignedBytes.toInt(data[i++]); + min = Math.min(min, value); + max = Math.max(max, value); + average += value; + + value = UnsignedBytes.toInt(data[i++]); + min = Math.min(min, value); + max = Math.max(max, value); + average += value; + + value = UnsignedBytes.toInt(data[i++]); + alphaMin = Math.min(alphaMin, value); + alphaMax = Math.max(alphaMax, value); + } + average /= ((data.length / 4) * 3); // Truncate-divide first on purpose. + } else { + alphaMin = alphaMax = 255; + for (int i = 0; i < data.length; i++) { + int value = UnsignedBytes.toInt(data[i]); + min = Math.min(min, value); + max = Math.max(max, value); + average += value; + } + average /= data.length; } - return new IntPixelInfo(alphaMin / 255f, alphaMax / 255f); + return new IntPixelInfo( + min / 255.0, max / 255.0, average, alphaMin / 255.0, alphaMax / 255.0); } @Override - public float getMin() { - return 0; // Disable automatic tone-mapping. + public double getMin() { + return min; + } + + @Override + public double getMax() { + return max; } @Override - public float getMax() { - return 1; // Disable automatic tone-mapping. + public double getAverage() { + return average; } @Override - public float getAlphaMin() { + public double getAlphaMin() { return alphaMin; } @Override - public float getAlphaMax() { + public double getAlphaMax() { return alphaMax; } } diff --git a/gapic/src/main/com/google/gapid/image/Histogram.java b/gapic/src/main/com/google/gapid/image/Histogram.java index 8ad188963d..d2098eca1e 100644 --- a/gapic/src/main/com/google/gapid/image/Histogram.java +++ b/gapic/src/main/com/google/gapid/image/Histogram.java @@ -15,15 +15,18 @@ */ package com.google.gapid.image; -import com.google.common.collect.Lists; -import com.google.common.collect.Maps; +import static java.util.Arrays.stream; +import static java.util.stream.Collectors.toSet; + import com.google.common.collect.Sets; +import com.google.gapid.image.Image.PixelInfo; +import com.google.gapid.proto.stream.Stream; import com.google.gapid.proto.stream.Stream.Channel; -import java.nio.DoubleBuffer; -import java.util.List; -import java.util.Map; -import java.util.Map.Entry; +import com.google.gapid.util.Range; + import java.util.Set; +import java.util.stream.DoubleStream; +import java.util.stream.IntStream; /** * Histogram calculates the number of pixel components across a list of images that land into a @@ -34,255 +37,271 @@ * magnitude higher than the average value, the histogram supports non-linear bin ranges. */ public class Histogram { + private final Set channels; + private final Mapper mapper; + private final Bins bins; + + public Histogram(Image[] images, int numBins, boolean logFit) { + this.channels = getChannels(images); + this.mapper = Mapper.get(images, logFit); + this.bins = Bins.get(images, mapper, numBins); + } + + private static Set getChannels(Image[] images) { + return Sets.immutableEnumSet(stream(images) + .flatMap(i -> i.getChannels().stream()) + .collect(toSet())); + } + + public Range getInitialRange(double snapThreshold) { + if (isLinear()) { + return Range.IDENTITY; + } + + double rangeMin = getPercentile(1, false); + double rangeMax = getPercentile(99, true); + + // Snap the range to the limits if they're close enough. + if (mapper.normalize(rangeMin) < snapThreshold) { + rangeMin = mapper.limits.min; + } + if (mapper.normalize(rangeMax) > 1.0 - snapThreshold) { + rangeMax = mapper.limits.max; + } + + return new Range(rangeMin, rangeMax); + } + /** - * The minimum and maximum values across all images and their components. + * @param percentile the percentile value ranging from 0 to 100. + * @param high if true, return the upper limit on the percentile's bin, otherwise the lower limit. + * @return the absolute pixel value at the specified percentile in the histogram. */ - public final Range limits; + private double getPercentile(int percentile, boolean high) { + int bin = bins.getPercentileBin(percentile, channels); + return (bin < 0) ? mapper.limits.max : + getValueFromNormalizedX((bin + (high ? 1 : 0)) / bins.count()); + } /** - * The exponential power used to transform a normalized linear [0, 1] range where 0 represents - * {@code limits.min}, and 1 represents {@code limits.max} to a normalized bin range [0, 1] where - * 0 is the first and 1 is the last bin. + * @return the absolute value as a normalized [0, 1] point on the (possibly) non-linear histogram. */ - public final double power; - - private final Bin[] bins; - private final Set channels; - private final Map highestCounts; // Across all bins and channels. + public double getNormalizedXFromValue(double value) { + return Range.IDENTITY.clamp(mapper.map(value)); + } /** - * Bin holds the number of pixel values that fall within a fixed range. + * @return the absolute value from a normalized [0, 1] point on the (possibly) non-linear + * histogram. */ - public static class Bin { - private final Map channels = Maps.newHashMap(); + public double getValueFromNormalizedX(double normalizedX) { + return mapper.unmap(Range.IDENTITY.clamp(normalizedX)); + } - /** - * @return the bin count for the given channel. - */ - public int get(Channel channel) { - return channels.getOrDefault(channel, 0); - } + /** + * @return the normalized channel value for the given channel and bin. + */ + public float get(Channel channel, int bin) { + return bins.getNormalized(channel, bin); + } - /** - * Increments the bin count by one for the given channel. - * @return the new bin count. - */ - protected int inc(Channel channel) { - int count = channels.getOrDefault(channel, 0); - count++; - channels.put(channel, count); - return count; - } + /** + * @return the union of all channels across all images. + */ + public Set getChannels() { + return channels; } /** - * Range defines an immutable min-max interval of doubles. + * Returns the number of bins. */ - public static class Range { - public static final Range IDENTITY = new Range(0.0, 1.0); + public int getNumBins() { + return bins.count(); + } - public final double min; - public final double max; + public boolean isLinear() { + return !(mapper instanceof ExpMapper); + } - public Range(double min, double max) { - this.min = min; - this.max = max; - } + public DoubleStream range(int count) { + return mapper.range(count); + } - /** - * @return the value limited to the min and max values of this range. - */ - public double clamp(double value) { - return Math.max(Math.min(value, max), min); - } + public static class Binner { + private Mapper mapper; + private final int numBins; + private final int[][] bins; - /** - * @return the linear interpolated value between min and max by frac. - */ - public double lerp(double frac) { - return min + (max - min) * frac; + public Binner(Mapper mapper, int numBins) { + this.mapper = mapper; + this.numBins = numBins; + this.bins = new int[numBins][Stream.Channel.values().length]; } - /** - * @return the inverse of {@link #lerp}, where X = frac(lerp(X)). - */ - public double frac(double value) { - return (value - min) / (max - min); + public void bin(float value, Stream.Channel channel) { + int binIdx = (int)(mapper.map(value) * (numBins - 1)); + binIdx = Math.max(0, Math.min(numBins - 1, binIdx)); + bins[binIdx][channel.ordinal()]++; } - /** - * @return the size of the range interval. - */ - public double range() { - return max - min; + public Bins getBins() { + return new Bins(bins); } } - public Histogram(Image[] images, int numBins, boolean logFit) { - bins = new Bin[numBins]; + private static class Mapper { + protected final Range limits; - for (int i = 0; i < numBins; i++) { - bins[i] = new Bin(); + public Mapper(Range limits) { + this.limits = limits; } - Map highestCounts = Maps.newHashMap(); - for (Image image : images) { - for (Channel channel : image.getChannels()) { - if (!highestCounts.containsKey(channel)) { - highestCounts.put(channel, 0); + public static Mapper get(Image[] images, boolean logFit) { + // Get the limits and average value. + double min = Double.POSITIVE_INFINITY; + double max = Double.NEGATIVE_INFINITY; + double average = 0.0; + for (Image image : images) { + PixelInfo info = image.getInfo(); + min = Math.min(info.getMin(), min); + max = Math.max(info.getMax(), max); + average += info.getAverage(); + } + Range limits = new Range(min, max); + + // This is an average-of-averages, which is OK, because we only ever compute a histogram across + // multiple images that have the same size. The only reasons the weights of the averages would + // be different is because we skip the infinite and NaN values when computing the average. + // These are, though, the exception and would technically make the average be undefined anyways. + average /= images.length; + + double exponent = 1.0; + if (logFit) { + // We want the average in the middle of the histogram. + // Calculate the non-linear power from this. + // limits.frac(average) ^ P == 0.5 + // P * log(limits.frac(average)) == log(0.5) + // P = log(0.5) / log(limits.frac(average)) + exponent = Math.log(0.5) / Math.log(limits.frac(average)); + + // Don't go non-linear if it isn't necessary. + if (exponent > 0.95 && exponent < 1.05) { + exponent = 1.0; } } + return (exponent == 1) ? new Mapper(limits) : new ExpMapper(limits, exponent); } - // Gather all the pixel values. - Map allValues = Maps.newHashMap(); - for (Channel channel : highestCounts.keySet()) { - List buffers = Lists.newArrayList(); - int size = 0; - for (Image image : images) { - DoubleBuffer buffer = image.getChannel(channel); - buffers.add(buffer); - size += buffer.limit(); - } - DoubleBuffer buffer = DoubleBuffer.allocate(size); - for (DoubleBuffer image : buffers) { - buffer.put(image); - } - allValues.put(channel, buffer); + public double normalize(double value) { + return limits.frac(value); } - // Get the limits and average value. - double min = Double.POSITIVE_INFINITY; - double max = Double.NEGATIVE_INFINITY; - double average = 0.0; - long numValues = 0; - for (DoubleBuffer values : allValues.values()) { - for (double value : values.array()) { - if (!Double.isNaN(value) && !Double.isInfinite(value)) { - min = Math.min(min, value); - max = Math.max(max, value); - average += value; - numValues++; - } - } + public double map(double value) { + return limits.frac(value); } - limits = new Range(min, max); + public double unmap(double value) { + return limits.lerp(value); + } - if (numValues > 0) { - average /= numValues; + public DoubleStream range(int count) { + return IntStream.range(1, count).mapToDouble(i -> (double)i / (count - 1)); } + } - double P = 1.0; - if (logFit) { - // We want the average in the middle of the histogram. - // Calculate the non-linear power from this. - // limits.frac(average) ^ P == 0.5 - // P * log(limits.frac(average)) == log(0.5) - // P = log(0.5) / log(limits.frac(average)) - P = Math.log(0.5) / Math.log(limits.frac(average)); - - // Don't go non-linear if it isn't necessary. - if (P > 0.95 && P < 1.05) { - P = 1.0; - } + private static class ExpMapper extends Mapper { + /** + * The exponential power used to transform a normalized linear [0, 1] range where 0 represents + * {@code limits.min}, and 1 represents {@code limits.max} to a normalized bin range [0, 1] + * where 0 is the first and 1 is the last bin. + */ + private final double power; + + public ExpMapper(Range limits, double power) { + super(limits); + this.power = power; } + @Override + public double map(double value) { + return Math.pow(limits.frac(value), power); + } - // Bucket each of the values into the bins. - for (Map.Entry entry : allValues.entrySet()) { - Channel channel = entry.getKey(); - for (double value : entry.getValue().array()) { - if (!Double.isNaN(value) && !Double.isInfinite(value)) { - int binIdx = (int) (Math.pow(limits.frac(value), P) * (numBins - 1)); - binIdx = Math.max(0, binIdx); - binIdx = Math.min(binIdx, numBins - 1); - int count = bins[binIdx].inc(channel); - highestCounts.put(channel, Math.max(count, highestCounts.get(channel))); - } - } + @Override + public double unmap(double value) { + return limits.lerp(Math.pow(value, 1 / power)); } - this.highestCounts = highestCounts; - this.channels = Sets.immutableEnumSet(allValues.keySet()); - this.power = P; + @Override + public DoubleStream range(int count) { + return IntStream.range(1, count).mapToDouble(i -> Math.pow((double)i / (count - 1), power)); + } } - /** - * @param percentile the percentile value ranging from 0 to 100. - * @param high if true, return the upper limit on the percentile's bin, otherwise the lower limit. - * @param ignore a list of channels to ignore in the calculation. - * @return the absolute pixel value at the specified percentile in the histogram. - */ - public double getPercentile(int percentile, boolean high, Channel ... ignore) { - Set toIgnore = Sets.newHashSet(ignore); - - int highestCount = 0; - Map cumulative = Maps.newHashMap(); - for (int i = 0; i < bins.length; i++) { - Bin bin = bins[i]; - for (Entry entry : bin.channels.entrySet()) { - Channel channel = entry.getKey(); - if (!toIgnore.contains(channel)) { - int total = cumulative.getOrDefault(channel, 0) + entry.getValue(); - cumulative.put(channel, total); - highestCount = Math.max(total, highestCount); - } - } + private static class Bins { + private final int[][] bins; + private final int[] max, total; + + public Bins(int[][] bins) { + this.bins = bins; + this.max = new int[Stream.Channel.values().length]; + this.total = new int[Stream.Channel.values().length]; + computeMaxAndTotals(); } - cumulative.clear(); - - int threshold = percentile * highestCount / 100; - for (int i = 0; i < bins.length; i++) { - Bin bin = bins[i]; - for (Entry entry : bin.channels.entrySet()) { - Channel channel = entry.getKey(); - if (!toIgnore.contains(channel)) { - int sum = cumulative.getOrDefault(channel, 0) + entry.getValue(); - if (sum >= threshold) { - return getValueFromNormalizedX((i + (high ? 1 : 0)) / (float)bins.length); - } - cumulative.put(channel, sum); + private void computeMaxAndTotals() { + for (int channel = 0; channel < max.length; channel++) { + int curMax = 0; + for (int bin = 0; bin < bins.length; bin++) { + int value = bins[bin][channel]; + total[channel] += value; + curMax = Math.max(curMax, value); } + max[channel] = curMax; } } - return limits.max; - } - /** - * @return the absolute value as a normalized [0, 1] point on the (possibly) non-linear histogram. - */ - public double getNormalizedXFromValue(double value) { - return Range.IDENTITY.clamp(Math.pow(limits.frac(value), power)); - } + public static Bins get(Image[] images, Mapper mapper, int numBins) { + Binner binner = new Binner(mapper, numBins); + for (Image image : images) { + image.bin(binner); + } + return binner.getBins(); + } - /** - * @return the absolute value from a normalized [0, 1] point on the (possibly) non-linear - * histogram. - */ - public double getValueFromNormalizedX(double normalizedX) { - return limits.lerp(Math.pow(Range.IDENTITY.clamp(normalizedX), 1.0 / power)); - } + /** + * Returns the count for the given channel in the given bin, normalized to a [0, 1] range. + */ + public float getNormalized(Stream.Channel channel, int bin) { + int cIdx = channel.ordinal(); + return (float)bins[bin][cIdx] / max[cIdx]; + } - /** - * @return the normalized channel value for the given channel and bin. - */ - public float get(Channel channel, int bin) { - return bins[bin].get(channel) / (float)highestCounts.getOrDefault(channel, 0); - } + public int count() { + return bins.length; + } - /** - * @return the union of all channels across all images. - */ - public Set getChannels() { - return channels; - } + /** + * Returns the index of the bin which matches the given percentile, or -1. + */ + public int getPercentileBin(int percentile, Set channels) { + int highestCount = 0; + for (Stream.Channel c : channels) { + highestCount = Math.max(highestCount, total[c.ordinal()]); + } - /** - * @return the number of bins. - */ - public int getNumBins() { - return bins.length; + int threshold = percentile * highestCount / 100; + int[] sum = new int[Stream.Channel.values().length]; + for (int b = 0; b < bins.length; b++) { + for (Stream.Channel c : channels) { + int cIdx = c.ordinal(); + int s = sum[cIdx] += bins[b][cIdx]; + if (s >= threshold) { + return b; + } + } + } + return -1; + } } } diff --git a/gapic/src/main/com/google/gapid/image/Image.java b/gapic/src/main/com/google/gapid/image/Image.java index bb8f3069b6..3e8900374a 100644 --- a/gapic/src/main/com/google/gapid/image/Image.java +++ b/gapic/src/main/com/google/gapid/image/Image.java @@ -15,16 +15,18 @@ */ package com.google.gapid.image; -import com.google.common.collect.Sets; import com.google.gapid.glviewer.gl.Texture; -import com.google.gapid.proto.stream.Stream.Channel; -import java.nio.DoubleBuffer; -import java.util.Set; +import com.google.gapid.image.Histogram.Binner; +import com.google.gapid.proto.stream.Stream; + import org.eclipse.swt.graphics.ImageData; import org.eclipse.swt.graphics.PaletteData; import org.eclipse.swt.graphics.RGB; import org.lwjgl.opengl.GL11; +import java.util.Collections; +import java.util.Set; + /** * Image pixel data of a texture, framebuffer, etc. */ @@ -67,17 +69,17 @@ public interface Image { /** * @return all the channels of this image. */ - public Set getChannels(); + public Set getChannels(); /** - * @return all the pixel values of the given channel. + * @return true if this image contains high-dynamic-range data. */ - public DoubleBuffer getChannel(Channel channel); + public boolean isHDR(); /** - * @return true if this image contains high-dynamic-range data. + * Bins this image's channel data with the given {@link Histogram.Binner}. */ - public boolean isHDR(); + public void bin(Histogram.Binner binner); /** * @return the {@link PixelInfo} for this buffer. @@ -124,18 +126,18 @@ public PixelValue getPixel(int x, int y, int z) { } @Override - public Set getChannels() { - return Sets.newIdentityHashSet(); + public Set getChannels() { + return Collections.emptySet(); } @Override - public DoubleBuffer getChannel(Channel channel) { - return DoubleBuffer.allocate(0); + public boolean isHDR() { + return false; } @Override - public boolean isHDR() { - return false; + public void bin(Binner binner) { + // Do nothing. } @Override @@ -178,44 +180,54 @@ public String toString() { public static interface PixelInfo { public static final PixelInfo NULL_INFO = new PixelInfo() { @Override - public float getMin() { + public double getMin() { return 0; } @Override - public float getMax() { + public double getMax() { return 1; } @Override - public float getAlphaMin() { + public double getAverage() { + return 0.5f; + } + + @Override + public double getAlphaMin() { return 1; } @Override - public float getAlphaMax() { + public double getAlphaMax() { return 1; } }; /** - * @return the minimum value across all channels of the image data. Used for tone mapping. + * Returns the minimum value across all channels of the image data. Used for tone mapping. + */ + public double getMin(); + + /** + * Returns the maximum value across all channels of the image data. Used for tone mapping. */ - public float getMin(); + public double getMax(); /** - * @return the maximum value across all channels of the image data. Used for tone mapping. + * Returns the average value accross all channels of the image date. Used for tone mapping. */ - public float getMax(); + public double getAverage(); /** * @return the minimum alpha value of the image data. */ - public float getAlphaMin(); + public double getAlphaMin(); /** * @return the maximum alpha value of the image data. */ - public float getAlphaMax(); + public double getAlphaMax(); } } diff --git a/gapic/src/main/com/google/gapid/image/Images.java b/gapic/src/main/com/google/gapid/image/Images.java index 68946bd863..165abaa0b3 100644 --- a/gapic/src/main/com/google/gapid/image/Images.java +++ b/gapic/src/main/com/google/gapid/image/Images.java @@ -15,7 +15,7 @@ */ package com.google.gapid.image; -import com.google.common.collect.ImmutableSet; +import com.google.common.collect.Sets; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; import com.google.gapid.proto.image.Image; @@ -53,11 +53,18 @@ public class Images { .setUncompressed(Image.FmtUncompressed.newBuilder().setFormat(Streams.FMT_DEPTH_FLOAT)) .build(); - public static final Set COLOR_CHANNELS = ImmutableSet.of( + public static final Set COLOR_CHANNELS = Sets.immutableEnumSet( Stream.Channel.Red, Stream.Channel.Green, Stream.Channel.Blue, Stream.Channel.Alpha, Stream.Channel.Luminance, Stream.Channel.ChromaU, Stream.Channel.ChromaV); - public static final Set DEPTH_CHANNELS = ImmutableSet.of(Stream.Channel.Depth); + public static final Set DEPTH_CHANNELS = Sets.immutableEnumSet( + Stream.Channel.Depth); + + public static final Set RGB_CHANNELS = Sets.immutableEnumSet( + Stream.Channel.Red, Stream.Channel.Green, Stream.Channel.Blue); + + public static final Set LUMINANCE_CHANNELS = Sets.immutableEnumSet( + Stream.Channel.Luminance); private Images() { } diff --git a/gapic/src/main/com/google/gapid/util/Range.java b/gapic/src/main/com/google/gapid/util/Range.java new file mode 100644 index 0000000000..bd399f3532 --- /dev/null +++ b/gapic/src/main/com/google/gapid/util/Range.java @@ -0,0 +1,59 @@ +/* + * Copyright (C) 2017 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.gapid.util; + +/** + * Range defines an immutable min-max interval of doubles. + */ +public class Range { + public static final Range IDENTITY = new Range(0.0, 1.0); + + public final double min; + public final double max; + + public Range(double min, double max) { + this.min = min; + this.max = max; + } + + /** + * @return the value limited to the min and max values of this range. + */ + public double clamp(double value) { + return Math.max(Math.min(value, max), min); + } + + /** + * @return the linear interpolated value between min and max by frac. + */ + public double lerp(double frac) { + return min + (max - min) * frac; + } + + /** + * @return the inverse of {@link #lerp}, where X = frac(lerp(X)). + */ + public double frac(double value) { + return (value - min) / (max - min); + } + + /** + * @return the size of the range interval. + */ + public double range() { + return max - min; + } +} \ No newline at end of file diff --git a/gapic/src/main/com/google/gapid/widgets/ImagePanel.java b/gapic/src/main/com/google/gapid/widgets/ImagePanel.java index 80b505a17f..4114b1ea45 100644 --- a/gapic/src/main/com/google/gapid/widgets/ImagePanel.java +++ b/gapic/src/main/com/google/gapid/widgets/ImagePanel.java @@ -40,7 +40,6 @@ import com.google.gapid.glviewer.vec.MatD; import com.google.gapid.glviewer.vec.VecD; import com.google.gapid.image.Histogram; -import com.google.gapid.image.Histogram.Range; import com.google.gapid.image.Image; import com.google.gapid.image.Image.PixelInfo; import com.google.gapid.image.Image.PixelValue; @@ -54,6 +53,7 @@ import com.google.gapid.util.Loadable; import com.google.gapid.util.Messages; import com.google.gapid.util.MouseAdapter; +import com.google.gapid.util.Range; import org.eclipse.swt.SWT; import org.eclipse.swt.events.MouseEvent; @@ -91,6 +91,7 @@ import java.util.Set; import java.util.concurrent.ExecutionException; import java.util.function.Consumer; +import java.util.function.DoubleConsumer; import java.util.function.IntConsumer; import java.util.logging.Logger; @@ -109,7 +110,7 @@ public class ImagePanel extends Composite { private static final int CHANNEL_RED = 0, CHANNEL_GREEN = 1, CHANNEL_BLUE = 2, CHANNEL_ALPHA = 3; private static final float ALPHA_WARNING_THRESHOLD = 2 / 255f; - private static final Image[] NO_LAYERS = new Image[] { Image.EMPTY }; + protected static final Image[] NO_LAYERS = new Image[] { Image.EMPTY }; private final Widgets widgets; private final SingleInFlight imageRequestController = new SingleInFlight(); @@ -157,10 +158,9 @@ public void mouseDown(MouseEvent e) { mouseDownPoint = last; if (inHistogram(e)) { - int handle = getHistogramRangeHandle(e); - if (handle != NO_HANDLE) { - int mode = (handle == LOW_HANDLE) ? MODE_DRAGGING_LOW_HANDLE : MODE_DRAGGING_HIGH_HANDLE; - setMode(mode, e); + int h = getHistogramRangeHandle(e); + if (h != NO_HANDLE) { + setMode((h == LOW_HANDLE) ? MODE_DRAGGING_LOW_HANDLE : MODE_DRAGGING_HIGH_HANDLE, e); } else { setMode(MODE_MAYBE_DRAGGING_RANGE, e); } @@ -215,8 +215,8 @@ public void mouseMove(MouseEvent e) { break; default: if (inHistogram(e)) { - int handle = getHistogramRangeHandle(e); - setCursor(handle != NO_HANDLE ? getDisplay().getSystemCursor(SWT.CURSOR_SIZEWE) : null); + int h = getHistogramRangeHandle(e); + setCursor(h != NO_HANDLE ? getDisplay().getSystemCursor(SWT.CURSOR_SIZEWE) : null); } else { setPreviewPixel(imageComponent.getPixel(getPoint(e))); setCursor(null); @@ -460,8 +460,8 @@ private void loadLevel(int level) { Image[] images = imageList.toArray(new Image[imageList.size()]); boolean isHDR = false; - for (Image image : images) { - if (image.isHDR()) { + for (Image i : images) { + if (i.isHDR()) { isHDR = true; } } @@ -498,33 +498,44 @@ protected void onUiThreadError(Loadable.Message message) { } protected void updateLayers(LevelData data) { - boolean valid = data != null && data.layers.length > 0; - if (valid) { - layers = data.layers; + layers = data.layers; + if (data.valid) { status.setLevelSize(layers[0].getWidth(), layers[0].getHeight()); - } else { - layers = NO_LAYERS; } loading.stopLoading(); + if (saveItem != null) { - saveItem.setEnabled(valid); + saveItem.setEnabled(data.valid); } - List images = new ArrayList<>(layers.length); - for (Image layer : layers) { - for (int i = 0, c = layer.getDepth(); i < c; i++) { - images.add(layer.getSlice(i)); - } - } - imageComponent.setImages(images.toArray(new Image[images.size()])); + imageComponent.setImages(data.images); imageComponent.setHistogram(data.histogram); } private static final class LevelData { + public final boolean valid; public final Image[] layers; + public final Image[] images; public final Histogram histogram; + public LevelData(Image[] layers, Histogram histogram) { - this.layers = layers; + this.valid = layers != null && layers.length > 0; + this.layers = valid ? layers : NO_LAYERS; this.histogram = histogram; + this.images = valid ? getImages(layers) : NO_LAYERS; + } + + private static Image[] getImages(Image[] layers) { + List images = new ArrayList<>(layers.length); + for (Image layer : layers) { + if (layer.getDepth() == 1) { + images.add(layer); + } else { + for (int i = 0, c = layer.getDepth(); i < c; i++) { + images.add(layer.getSlice(i)); + } + } + } + return images.toArray(new Image[images.size()]); } } @@ -533,7 +544,7 @@ private static final class SceneData { public MatD[] transforms = {}; public final boolean channels[] = { true, true, true, true }; public Histogram histogram; - public Histogram.Range displayRange = Range.IDENTITY; + public Range displayRange = Range.IDENTITY; public boolean histogramVisible; public int histogramX, histogramY; public int histogramW, histogramH; @@ -598,7 +609,7 @@ public int getHistogramLowX() { } public void setHistogramLowX(int x) { - displayRange = new Histogram.Range( + displayRange = new Range( histogram.getValueFromNormalizedX((x - histogramX) / (double)histogramW), displayRange.max); } @@ -608,7 +619,7 @@ public int getHistogramHighX() { } public void setHistogramHighX(int x) { - displayRange = new Histogram.Range( + displayRange = new Range( displayRange.min, histogram.getValueFromNormalizedX((x - histogramX) / (double)histogramW)); } @@ -621,13 +632,14 @@ private static class ImageComponent extends Composite { private static final VecD BORDER_SIZE = new VecD(2, 2, 0); private static final double MAX_ZOOM_FACTOR = 8; private static final VecD MIN_ZOOM_SIZE = new VecD(100, 100, 0); + private static final double HISTOGRAM_SNAP_THRESHOLD = 0.1; private final Consumer showAlphaWarning; private final boolean naturallyFlipped; private final ScrollBar scrollbars[]; private final ScenePanel canvas; - private final SceneData data; + protected final SceneData data; private Image[] images = {}; private double scaleGridToViewMin = 0; @@ -737,12 +749,11 @@ public void setImages(Image[] images) { public void setHistogram(Histogram histogram) { data.histogram = histogram; - data.displayRange = histogram.power != 1.0 ? - calcHistogramRange(data.histogram) : Range.IDENTITY; + data.displayRange = histogram.getInitialRange(HISTOGRAM_SNAP_THRESHOLD); refresh(); } - private void refresh() { + protected void refresh() { data.images = images; data.transforms = calcTransforms(); canvas.setSceneData(data.copy()); @@ -775,22 +786,6 @@ private void refresh() { } } - private Histogram.Range calcHistogramRange(Histogram histogram) { - double rangeMin = histogram.getPercentile(1, false, Channel.Alpha); - double rangeMax = histogram.getPercentile(99, true, Channel.Alpha); - - // Snap the range to the limits if they're close enough. - final double SNAP_THRESHOLD = 0.1; - if (histogram.limits.frac(rangeMin) < SNAP_THRESHOLD) { - rangeMin = histogram.limits.min; - } - if (histogram.limits.frac(rangeMax) > 1.0 - SNAP_THRESHOLD) { - rangeMax = histogram.limits.max; - } - - return new Histogram.Range(rangeMin, rangeMax); - } - public void setPreviewPixel(Pixel previewPixel) { data.previewPixel = previewPixel; refresh(); @@ -995,13 +990,13 @@ private void updateScrollbars() { scrollbar.setEnabled(false); scrollbar.setValues(0, 0, 1, 1, 1, 1); } else { - int view = (int)this.viewSize.get(i); + int size = (int)this.viewSize.get(i); scrollbar.setEnabled(true); scrollbar.setValues( val - min, // selection 0, // min - view + rng, // max - view, // thumb + size + rng, // max + size, // thumb (rng + 99) / 100, // increment (rng + 9) / 10 // page increment ); @@ -1031,7 +1026,7 @@ private static class ImageScene implements Scene { private Shader shader; private Texture[] textures; - private SceneData data; + protected SceneData data; private final float[] uChannels = new float[] { 1, 1, 1, 1 }; @@ -1187,20 +1182,23 @@ private void drawHistogram(Renderer renderer) { // Draw the background. GL11.glBlendFunc(GL11.GL_SRC_ALPHA, GL11.GL_ONE_MINUS_SRC_ALPHA); - if (data.histogram.power != 1.0) { - // Non-linear image. Draw log-scale. - int gridLines = w / 10; - int lastX = x; - for (int i = 0; i < gridLines; i++) { - int lineX = x + (int)(w * Math.pow(i / (gridLines - 1.0), data.histogram.power)); - if (i > 0) { - renderer.drawSolid(lastX, y, lineX - lastX, h, ((i & 1) == 0) ? data.histogramBackgroundLight : data.histogramBackgroundDark); - } - lastX = lineX; - } - } else { + if (data.histogram.isLinear()) { // Linear (typically non-HDR image) is a solid color. renderer.drawSolid(x, y, w, h, data.histogramBackgroundDark); + } else { + // Non-linear image. Draw log-scale. + data.histogram.range(w / 10).forEachOrdered(new DoubleConsumer() { + private int lastX = x; + private int i = 1; + + @Override + public void accept(double value) { + int lineX = x + (int)(w * value); + renderer.drawSolid(lastX, y, lineX - lastX, h, + ((i++ & 1) == 0) ? data.histogramBackgroundLight : data.histogramBackgroundDark); + lastX = lineX; + } + }); } // Draw the histogram content. @@ -1263,8 +1261,8 @@ private void drawHistogram(Renderer renderer) { // Draw the handle arrows. float arrowW = data.histogramArrowW / (float)w; float arrowH = data.histogramArrowH / (float)h; - float handleLowX = 2.0f * windowLeftW / (float)w - 1.0f; - float handleHighX = -2.0f * windowRightW / (float)w + 1.0f; + float handleLowX = 2.0f * windowLeftW / w - 1.0f; + float handleHighX = -2.0f * windowRightW / w + 1.0f; float[] arrowTriangles = { handleLowX, 1.0f,