001    /*
002    Copyright (c) 1996-2012, Damon Hart-Davis
003    All rights reserved.
004    
005    Redistribution and use in source and binary forms, with or without
006    modification, are permitted provided that the following conditions are
007    met:
008    
009      * Redistributions of source code must retain the above copyright
010        notice, this list of conditions and the following disclaimer.
011    
012      * Redistributions in binary form must reproduce the above copyright
013        notice, this list of conditions and the following disclaimer in the
014        documentation and/or other materials provided with the
015        distribution.
016    
017    THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS
018    IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED
019    TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
020    PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
021    OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
022    SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
023    LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
024    DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
025    THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
026    (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
027    OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
028    */
029    
030    package org.hd.d.pg2k.svrCore;
031    
032    import java.awt.image.BufferedImage;
033    import java.awt.image.ColorModel;
034    import java.awt.image.DataBuffer;
035    import java.awt.image.IndexColorModel;
036    import java.awt.image.RenderedImage;
037    import java.awt.image.WritableRaster;
038    import java.util.HashSet;
039    import java.util.Hashtable;
040    import java.util.Set;
041    import java.util.TreeSet;
042    
043    import org.hd.d.pg2k.svrCore.MIME.ExhibitMIME;
044    import org.hd.d.pg2k.svrCore.MIME.Quantize;
045    
046    /**
047     * Created by IntelliJ IDEA.
048     * User: Damon Hart-Davis
049     * Date: 30-Aug-2003
050     * Time: 23:46:46
051     */
052    
053    /**Basic image-handling utilities.
054     */
055    public final class ImageUtils
056        {
057        /**Prevent instantiation. */
058        private ImageUtils() { }
059    
060    
061        /**Count distinct colours in 24-bit RGB colour space.
062         * Counts the number of distinct colours in the sRGB colour model
063         * in the given image, ignoring alpha, with each the depth of each
064         * band being 8 bits, for a total of 24 bits for each pixel.
065         * <p>
066         * If the source image is not in RGB format then
067         * colour conversion will take place.
068         * <p>
069         * Is slow but tries to be reasonably memory efficient.
070         * <p>
071         * The input image is not altered.
072         */
073        public static int countDistinct24BitRGBColours(final BufferedImage im)
074            {
075            final int height = im.getHeight();
076            final int width = im.getWidth();
077            final Set<Integer> distinctColours = new HashSet<Integer>();
078    
079            for(int y = height; --y >= 0; )
080                {
081                for(int x = width; --x >= 0; )
082                    {
083                    distinctColours.add(new Integer(im.getRGB(x, y) & 0xffffff));
084                    }
085                }
086    
087            return(distinctColours.size());
088            }
089    
090    
091        /**Method to convert a RenderedImage to a BufferedImage.
092         * Thanks to <a href="http://www.magelang.com/faq/view.jsp?EID=114602">Jim Moore (06 Apr 2003)</a>
093         * for his routine to "do it right"!  Very slightly adjusted by DHD.
094         *
095         * @param ri  the image to be converted
096         *     (if already a BufferedImage then it is returned intact);
097         *     must not be null
098         *
099         * @return the original if already a BufferedImage,
100         *     else a copy of the data
101         */
102        public static BufferedImage convertRenderedImageToBufferedImage(final RenderedImage ri)
103            {
104            // Return as-is if already a BufferedImage.
105            if(ri instanceof BufferedImage)
106                { return (BufferedImage) ri; }
107    
108            // Extract basic metadata.
109            final ColorModel cm = ri.getColorModel();
110            final int width = ri.getWidth();
111            final int height = ri.getHeight();
112            final boolean isAlphaPremultiplied = cm.isAlphaPremultiplied();
113    
114            // Extract properties if any.
115            final String[] keys = ri.getPropertyNames();
116            Hashtable<String, Object> properties = null;
117            if(keys != null)
118                {
119                properties = new Hashtable<String, Object>(keys.length * 2 + 1);
120                for(int i = 0; i < keys.length; i++)
121                    { properties.put(keys[i], ri.getProperty(keys[i])); }
122                }
123    
124            // Construct BufferedImage and copy (raster) data in.
125            final WritableRaster raster = cm.createCompatibleWritableRaster(width, height);
126            final BufferedImage result = new BufferedImage(cm, raster, isAlphaPremultiplied, properties);
127            ri.copyData(raster);
128    
129            return(result);
130            }
131    
132        /**Create colour reduced image; never null
133         * A new image is created that is potentially colour-reduced from the
134         * original; no new colours should be introduced unless during the
135         * conversion to or from the RGB colour space in which reduction is done.
136         * The R, G and B quantities are each 8 bits.
137         * <p>
138         * By default the new image is returned in the same ColorModel as
139         * the original, but forcing the result to an indexed
140         * (palette-based) model such as used by GIF can requested,
141         * in which case the palette size is capped appropriately,
142         * and the bits-per-pixel may be chosen to be 1, 2, 4, or 8
143         * depending on the palette size.
144         * <p>
145         * Any input image that can be converted to RGB should be acceptable.
146         * <p>
147         * If no colour reduction is achieved and no ColorModel change is requested
148         * then the original image is returned;
149         * in no case is the original image altered.
150         * <p>
151         * This may return a single-colour image if the input image is too tricky
152         * and the maximum number of colours it can return is too small,
153         * but that will be avoided if possible.
154         *
155         * @param in  the original image; must not be null
156         * @param maxColours  maximum number of distinct colours allowed in the
157         *     result image (as measured in a 24-bit (8,8,8) sRGB colour space);
158         *     must be at least 2 and will be capped to 256 if an indexed model
159         *     is forced for the result
160         * @param forceByteIndexModel  if true, force the result to be a
161         *     byte-indexed palette-based color model (maximum 256 colours)
162         *     that may be (a) suitable for formats such as GIF and
163         *     (b) more compressable
164         *
165         * @return image same dimensions as the input image,
166         *     possibly with fewer colours; never null though might be single colour
167         */
168        public static BufferedImage makeColourReducedBufferedImage(
169                                                        final BufferedImage in,
170                                                        int maxColours,
171                                                        final boolean forceByteIndexModel)
172            {
173    //System.out.println("makeColourReducedBufferedImage("+maxColours+"): START *****************");
174    
175            if(in == null)
176                { throw new IllegalArgumentException("input image cannot be null"); }
177    
178            if(maxColours < 2)
179                { throw new IllegalArgumentException("at least two colours must be allowed in the result"); }
180    
181            if(forceByteIndexModel && (maxColours > 256))
182                { maxColours = 256; }
183    
184            // Extract copy of pixels from source image in TYPE_INT_ARGB format.
185            // Set scansize to be width to efficiently pack the result.
186            final int width = in.getWidth();
187            final int height = in.getHeight();
188            final ColorModel cm = ImageUtils.extractColorModelOrRGB(in);
189            //final boolean isAlphaPremultiplied = in.isAlphaPremultiplied();
190    
191            // If the input already has a byte-indexed palette,
192            // and maxColours is no smaller than the palette already in the image,
193            // then return the original.
194            if((cm instanceof IndexColorModel) &&
195               (((IndexColorModel) cm).getMapSize() <= maxColours) &&
196               (cm.getTransferType() == DataBuffer.TYPE_BYTE))
197                {
198    //System.out.println("makeColourReducedBufferedImage(): input no more colours than max and already byte indexed: returning original");
199                return(in); // Input already a byte-indexed colour model.
200                }
201    
202            // Extract RGB pixels from source image.
203            final int pixels[] = in.getRGB(0, 0, width, height, null, 0, width);
204    
205    //System.out.print("makeColourReducedBufferedImage(): input pixels = ");
206    //            for(int i = Math.min(20, pixels.length); --i >= 0; ) { System.out.print(Integer.toHexString(pixels[i]) + ", "); }
207    //System.out.println();
208    
209            // Count the colours in the original image (ignore any alpha).
210            final Set<Integer> originalDistinctColours = new TreeSet<Integer>();
211            for(int i = pixels.length; --i >= 0; )
212                { originalDistinctColours.add(new Integer(pixels[i] & 0xffffff)); }
213    //System.out.println("makeColourReducedBufferedImage(): original colour count: " + originalDistinctColours.size());
214    
215            // Now do the colour reduction...
216            // Note that each RGB pixel value is replaced with a palete *index*.
217            final int[] palette = Quantize.quantizeImage(
218                new int[][]{pixels}, maxColours);
219    //System.out.println("makeColourReducedBufferedImage(): new colour count: " + palette.length);
220    
221            // If we did not reduce the colours, consider returning the original.
222            final int origCols = originalDistinctColours.size();
223            if(palette.length >= origCols)
224                {
225                // If did not try to force an indexed output,
226                // then return the original.
227                if(!forceByteIndexModel)
228                    {
229    //System.out.println("makeColourReducedBufferedImage(): did not manage to reduce number of colors: returning original");
230                    return(in); // Didn't reduce the image at all.
231                    }
232    
233                // If the input already had a byte-indexed palette,
234                // and the new palette is no smaller than the old
235                //    (ie there were no redundant/duplicate colours in the original)
236                // then return the original.
237                if((cm instanceof IndexColorModel) &&
238                   (((IndexColorModel) cm).getMapSize() <= palette.length) &&
239                   (cm.getTransferType() == DataBuffer.TYPE_BYTE))
240                    {
241    //System.out.println("makeColourReducedBufferedImage(): did not manage to reduce number of colors and already byte indexed: returning original");
242                    return(in); // Input already a byte-indexed colour model.
243                    }
244    
245                // Need to generate byte-indexed form, so fall through...
246                }
247    
248    //System.out.print("makeColourReducedBufferedImage(): palette = ");
249    //            for(int i = palette.length; --i >= 0; ) { System.out.print(Integer.toHexString(palette[i]) + ", "); }
250    //System.out.println();
251    //System.out.print("makeColourReducedBufferedImage(): pixels = ");
252    //            for(int i = Math.min(20, pixels.length); --i >= 0; ) { System.out.print(Integer.toHexString(pixels[i]) + ", "); }
253    //System.out.println();
254    
255    
256            // Replace palette index values with RGB values.
257            // Force all pixels to be opaque before setting them in the image.
258            for(int i = pixels.length; --i >= 0; )
259                { pixels[i] = palette[pixels[i]] | 0xff000000; }
260    
261            // Handle creation of a new byte-indexed colour map specially,
262            // though we don't really need to.
263            BufferedImage result;
264            if(forceByteIndexModel)
265                {
266                // Force all palette entries to be opaque.
267                for(int i = palette.length; --i >= 0; )
268                    { palette[i] |= 0xff000000; }
269    
270                final IndexColorModel outputColourModel =
271                    new IndexColorModel(computeIndexBitsForByteIndexColorMap(palette.length),
272                                        palette.length,
273                                        palette,
274                                        0, // Start at offset zero.
275                                        false, // No alpha.
276                                        -1, // No transparent colour.
277                                        DataBuffer.TYPE_BYTE); // 8-bit palette
278    
279                result = new BufferedImage(width, height,
280                                           BufferedImage.TYPE_BYTE_INDEXED,
281                                           outputColourModel);
282                }
283            else
284                {
285                // Coerce data into original colour model.
286                // Discard any properties of the original.
287                final ColorModel outputColourModel = cm;
288    
289                // Handle other images in default way.
290                final WritableRaster raster =
291                    outputColourModel.createCompatibleWritableRaster(width, height);
292                result = new BufferedImage(outputColourModel, raster, false, null);
293                }
294    
295            // Actually store the pixels...
296            result.setRGB(0, 0, width, height, pixels, 0, width);
297    
298    //System.err.println("makeColourReducedBufferedImage(): palette.length = " + palette.length + ", number of colours in result = " + countDistinct24BitRGBColours(result));
299    
300            return(result);
301            }
302    
303        /**Compute the number of bits to store the pixel of an indexed image given the size of the palette; strictly positive power of two up to 8.
304         * The default behaviour is to return 8, for a byte-indexed palette,
305         * but this may return 4 if the palette has no more than 16 entries,
306         * 2 if the palette has no more than 4 entries, or
307         * 1 if the palette has no more than 2 entries,
308         * ie widths that will pack neatly into a byte.
309         *
310         * @return  number of pits needed to store palette,
311         *     as submultiple of 8
312         */
313        private static int computeIndexBitsForByteIndexColorMap(final int paletteSize)
314            {
315            if((paletteSize < 0) || (paletteSize > 256))
316                { throw new IllegalArgumentException(); }
317    
318            if(paletteSize <= 2) { return(1); }
319            if(paletteSize <= 4) { return(2); }
320            if(paletteSize <= 16) { return(4); }
321    
322            // Default is to use a whole byte for each pixel.
323            return(8);
324            }
325    
326        /**Return true-colour ARGB format image if src is indexed, else return original.
327         * The returned image should be easy to draw on.
328         * <p>
329         * The src image is not altered.
330         *
331         * @param src  input image, is not altered; must not be null
332         * @return non-indexed image
333         */
334        public static BufferedImage convertToTrueColourARGB(final BufferedImage src,
335                                                            final boolean forceCopy)
336            {
337            if(src == null)
338                { throw new IllegalArgumentException(); }
339    
340            // If image is indexed, expand to full true-colour format.
341            // Else if copy is forced, do it anyway to get a separate copy.
342            if(forceCopy ||
343               (src.getColorModel() instanceof IndexColorModel))
344                {
345                final int width = src.getWidth();
346                final int height = src.getHeight();
347    
348                final BufferedImage rgbBi = new BufferedImage(
349                    width, height, BufferedImage.TYPE_INT_ARGB);
350                rgbBi.setRGB(0, 0, width, height,
351                             src.getRGB(0, 0, width, height, null, 0, width),
352                             0, width);
353    
354                return(rgbBi);
355                }
356    
357            // Return original.
358            return(src);
359            }
360    
361        /**Extract the ColorModel of the inpuit image, or return the default RGB model; never null.
362         * This is needed because BufferedImage.getColorModel() can return null.
363         *
364         * @param imageIn  input image from which to extract the ColorModel;
365         *     must not be null
366         * @return a non-null ColorModel
367         */
368        public static ColorModel extractColorModelOrRGB(final BufferedImage imageIn)
369            {
370            // Extract just the map fragment that we need.
371            // Coerce data into original colour model.
372            // Discard any properties of the original.
373            final ColorModel rcm = imageIn.getColorModel();
374    
375            // If the source image does not have a colour model,
376            // create an appropriate default one (RGB).
377            final ColorModel cm = (rcm != null) ?
378                rcm : ColorModel.getRGBdefault();
379    
380            return(cm);
381            }
382    
383        /**Returns true if exhibit might be used as own thumbnail (in an HTML page for example).
384         * If true then we don't count downloads of this exhibit
385         * (and may adjust computation of "goodness" to take account of this).
386         * <p>
387         * An exhibit is potentially inlinable if:
388         * <ul>
389         * <li>It is small enough (smaller than the maximum size of a thumbnail).
390         * <li>It is a type which can typically be inlined, eg a GIF image.
391         * </ul>
392         *
393         * @param esa  the exhibit details; never null
394         *
395         * @return true  if the exhibit is potentially inlinable
396         */
397        public static boolean canBeOwnThumbnail(final ExhibitStaticAttr esa)
398            {
399            return((esa.length <= ExhibitThumbnails.STD_ABS_MAX_BYTES) &&
400                   canInlineInHTMLPageSimple((ExhibitMIME.getInputFileType(esa.getCharSequence()))));
401            }
402    
403        /**Returns true if the given MIME-type can always be inlined in an HTML page.
404         * This only tests for very common and widely-supported image types.
405         * <p>
406         * This does not dynamically test the capabilities of the user agent
407         * that content is to be shown to.
408         * <p>
409         * If the exhibitType argument is null, this returns false.
410         */
411        public static boolean canInlineInHTMLPageSimple(final ExhibitMIME.ExhibitTypeParameters exhibitType)
412            {
413            if(exhibitType == null) { return(false); }
414            switch(exhibitType.type)
415                {
416                case ExhibitMIME.ET_JPEG:
417                case ExhibitMIME.ET_GIF:
418                case ExhibitMIME.ET_PNG: // Most HTML browsers will accept PNG now.
419                    { return(true); }
420                }
421            return(false);
422            }
423        }