Image.js

// Copyright 2013 - UDS/CNRS
// The Aladin Lite program is distributed under the terms
// of the GNU General Public License version 3.
//
// This file is part of Aladin Lite.
//
//    Aladin Lite 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, version 3 of the License.
//
//    Aladin Lite is distributed in the hope that it will be useful,
//    but WITHOUT ANY WARRANTY; without even the implied warranty of
//    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
//    GNU General Public License for more details.
//
//    The GNU General Public License is available in COPYING file
//    along with Aladin Lite.
//

/******************************************************************************
 * Aladin Lite project
 *
 * File Image
 *
 * Authors: Matthieu Baumann [CDS]
 *
 *****************************************************************************/
import { ColorCfg } from "./ColorCfg.js";
import { Aladin } from "./Aladin.js";
import { Utils } from "./Utils";
import { AVM } from "./libs/avm.js";
import { HiPS } from "./HiPS.js";

/**
 * @typedef {Object} WCS
 * 
 * {@link https://ui.adsabs.harvard.edu/abs/2002A%26A...395.1077C/abstract|FITS (Paper II)}, Calabretta, M. R., and Greisen, E. W., Astronomy & Astrophysics, 395, 1077-1122, 2002
 * 
 * @property {number} [NAXIS]
 * @property {string} CTYPE1 
 * @property {string} [CTYPE2]
 * @property {number} [LONPOLE]
 * @property {number} [LATPOLE]
 * @property {number} [CRVAL1]
 * @property {number} [CRVAL2]
 * @property {number} [CRPIX1]
 * @property {number} [CRPIX2]
 * @property {string} [CUNIT1] - e.g. 'deg'
 * @property {string} [CUNIT2] - e.g. 'deg'
 * @property {number} [CD1_1]
 * @property {number} [CD1_2]
 * @property {number} [CD2_1]
 * @property {number} [CD2_2]
 * @property {number} [PC1_1]
 * @property {number} [PC1_2]
 * @property {number} [PC2_1]
 * @property {number} [PC2_2]
 * @property {number} [CDELT1]
 * @property {number} [CDELT2]
 * @property {number} [NAXIS1]
 * @property {number} [NAXIS2]
 */

/**
 * @typedef {Object} ImageOptions
 *
 * @property {string} [name] - A human-readable name for the FITS image
 * @property {Function} [successCallback] - A callback executed when the FITS has been loaded
 * @property {Function} [errorCallback] - A callback executed when the FITS could not be loaded
 * @property {number} [opacity=1.0] - Opacity of the survey or image (value between 0 and 1).
 * @property {string} [colormap="native"] - The colormap configuration for the survey or image.
 * @property {string} [stretch="linear"] - The stretch configuration for the survey or image.
 * @property {boolean} [reversed=false] - If true, the colormap is reversed; otherwise, it is not reversed.
 * @property {number} [minCut=0.0] - The minimum cut value for the color configuration. If not given, 0.0 is chosen
 * @property {number} [maxCut=1.0] - The maximum cut value for the color configuration. If not given, 1.0 is chosen
 * @property {boolean} [additive=false] - If true, additive blending is applied; otherwise, it is not applied.
 * @property {number} [gamma=1.0] - The gamma correction value for the color configuration.
 * @property {number} [saturation=0.0] - The saturation value for the color configuration.
 * @property {number} [brightness=0.0] - The brightness value for the color configuration.
 * @property {number} [contrast=0.0] - The contrast value for the color configuration.
 * @property {WCS} [wcs] - an object describing the WCS of the image. In case of a fits image
 * this property will be ignored as the WCS taken will be the one present in the fits file.
 * @property {string} [imgFormat] - Optional image format. Giving it will prevent the auto extension determination algorithm to be triggered. Possible values are 'jpeg', 'png' or 'fits'. tiff files are not supported. You can convert your tiff files to jpg ones by using the fantastic image magick suite.
 * 
 * @example
 * 
 *  aladin.setOverlayImageLayer(A.image(
 *       "https://nova.astrometry.net/image/25038473?filename=M61.jpg",
 *       {
 *           name: "M61",
 *           wcs: {
                NAXIS: 0, // Minimal header
                CTYPE1: 'RA---TAN', // TAN (gnomic) projection
                CTYPE2: 'DEC--TAN', // TAN (gnomic) projection
                EQUINOX: 2000.0, // Equatorial coordinates definition (yr)
                LONPOLE: 180.0, // no comment
                LATPOLE: 0.0, // no comment
                CRVAL1: 185.445488837, // RA of reference point
                CRVAL2: 4.47896032431, // DEC of reference point
                CRPIX1: 588.995094299, // X reference pixel
                CRPIX2: 308.307905197, // Y reference pixel
                CUNIT1: 'deg', // X pixel scale units
                CUNIT2: 'deg', // Y pixel scale units
                CD1_1: -0.000223666022989, // Transformation matrix
                CD1_2: -0.000296578064584, // no comment
                CD2_1: -0.000296427555509, // no comment
                CD2_2: 0.000223774308964, // no comment
                NAXIS1: 1080, // Image width, in pixels.
                NAXIS2: 705 // Image height, in pixels.
 *           },
 *           successCallback: (ra, dec, fov, image) => {
 *               aladin.gotoRaDec(ra, dec);
 *               aladin.setFoV(fov * 5)
 *           }
 *       },
 *   ));
 */

export let Image = (function () {
    /**
     * The object describing a FITS image
     *
     * @class
     * @constructs Image
     *
     * @param {string} url - Mandatory unique identifier for the layer. Can be an arbitrary name
     * @param {ImageOptions} [options] - The option for the survey
     *
     */
    function Image(url, options) {
        // Name of the layer
        this.layer = null;
        this.added = false;
        // Set it to a default value
        this.url = url;
        this.id = url;
        this.name = (options && options.name) || this.url;
        this.imgFormat = options && options.imgFormat;
        //this.formats = [this.imgFormat];
        // callbacks
        this.successCallback = options && options.successCallback;
        this.errorCallback = options && options.errorCallback;

        this.longitudeReversed = false;

        this.colorCfg = new ColorCfg(options);
        this.options = options || {};

        let self = this;

        this.query = Promise.resolve(self);
    };


        /**
         * Returns the low and high cuts under the form of a 2 element array
         *
         * @memberof Image
         * @method
         *
         * @returns {number[]} The low and high cut values.
         */
        Image.prototype.getCuts = HiPS.prototype.getCuts;

        /**
         * Sets the opacity factor
         *
         * @memberof Image
         * @method
         * @param {number} opacity - Opacity of the survey to set. Between 0 and 1
         */
        Image.prototype.setOpacity = HiPS.prototype.setOpacity;

       /**
        * Set color options generic method for changing colormap, opacity, ...
        *
        * @memberof Image
        * @method
        * @param {Object} options
        * @param {number} [options.opacity=1.0] - Opacity of the survey or image (value between 0 and 1).
        * @param {string} [options.colormap="native"] - The colormap configuration for the survey or image.
        * @param {string} [options.stretch="linear"] - The stretch configuration for the survey or image.
        * @param {boolean} [options.reversed=false] - If true, the colormap is reversed; otherwise, it is not reversed.
        * @param {number} [options.minCut] - The minimum cut value for the color configuration. If not given, 0.0 for JPEG/PNG surveys, the value of the property file for FITS surveys
        * @param {number} [options.maxCut] - The maximum cut value for the color configuration. If not given, 1.0 for JPEG/PNG surveys, the value of the property file for FITS surveys
        * @param {boolean} [options.additive=false] - If true, additive blending is applied; otherwise, it is not applied.
        * @param {number} [options.gamma=1.0] - The gamma correction value for the color configuration.
        * @param {number} [options.saturation=0.0] - The saturation value for the color configuration.
        * @param {number} [options.brightness=0.0] - The brightness value for the color configuration.
        * @param {number} [options.contrast=0.0] - The contrast value for the color configuration.
        */
        Image.prototype.setOptions = HiPS.prototype.setOptions;
        // @api
        Image.prototype.setBlendingConfig = HiPS.prototype.setBlendingConfig;

        /**
         * Set the colormap of an image
         *
         * @memberof Image
         * @method
        * @param {string} [colormap="grayscale"] - The colormap label to use. See {@link https://matplotlib.org/stable/users/explain/colors/colormaps.html|here} for more info about colormaps.
        *      Possible values are:
        * <br>"blues"
        * <br>"cividis"
        * <br>"cubehelix"
        * <br>"eosb"
        * <br>"grayscale"
        * <br>"inferno"
        * <br>"magma"
        * <br>"native"
        * <br>"parula"
        * <br>"plasma"
        * <br>"rainbow"
        * <br>"rdbu"
        * <br>"rdylbu"
        * <br>"redtemperature"
        * <br>"sinebow"
        * <br>"spectral"
        * <br>"summer"
        * <br>"viridis"
        * <br>"ylgnbu"
        * <br>"ylorbr"
        * <br>"red"
        * <br>"green"
        * <br>"blue"
        * @param {Object} [options] - Options for the colormap
        * @param {string} [options.stretch] - Stretching function of the colormap. Possible values are 'linear', 'asinh', 'log', 'sqrt', 'pow'. If no given, will not change it.
        * @param {boolean} [options.reversed=false] - Reverse the colormap axis.
         */
        Image.prototype.setColormap = HiPS.prototype.setColormap;

        /**
         * Set the cuts of the image
         *
         * @memberof Image
         * @method
         * @param {number} minCut - The low cut value.
         * @param {number} maxCut - The high cut value.
         */
        Image.prototype.setCuts = HiPS.prototype.setCuts;

        /**
         * Sets the gamma correction factor.
         *
         * This method updates the gamma.
         *
         * @memberof Image
         * @method
         * @param {number} gamma - The saturation value to set for the image. Between 0.1 and 10
         */
        Image.prototype.setGamma = HiPS.prototype.setGamma;

        /**
         * Sets the saturation.
         *
         * This method updates the saturation.
         *
         * @memberof Image
         * @method
         * @param {number} saturation - The saturation value. Between 0 and 1
         */
        Image.prototype.setSaturation = HiPS.prototype.setSaturation;

        /**
         * Sets the brightness.
         *
         * This method updates the brightness.
         *
         * @memberof Image
         * @method
         * @param {number} brightness - The brightness value. Between 0 and 1
         */
        Image.prototype.setBrightness = HiPS.prototype.setBrightness;

         /**
         * Sets the contrast.
         *
         * This method updates the contrast and triggers the update of metadata.
         *
         * @memberof Image
         * @method
         * @param {number} contrast - The contrast value. Between 0 and 1
         */
        Image.prototype.setContrast = HiPS.prototype.setContrast;

        /**
        * Toggle the image turning its opacity to 0 back and forth
        *
        * @memberof Image
        * @method
        */
        Image.prototype.toggle = HiPS.prototype.toggle;
        /**
         * Old method for setting the opacity use {@link Image#setOpacity} instead
         * 
         * @memberof Image
         * @deprecated
         */
        Image.prototype.setAlpha = HiPS.prototype.setOpacity;
    
        Image.prototype.getColorCfg = HiPS.prototype.getColorCfg;
    
        /**
         * Get the opacity of the image layer
         * 
         * @memberof HiPS
         * 
         * @returns {number} The opacity of the layer
        */
        Image.prototype.getOpacity = HiPS.prototype.getOpacity;
        /**
         * Use {@link Image#getOpacity}
         * 
         * @memberof Image
         * @method
         * @deprecated
         */
        Image.prototype.getAlpha = HiPS.prototype.getOpacity;
    
        /**
         * Read a specific screen pixel value
         * 
         * @todo This has not yet been implemented
         * @memberof Image
         * @method
         * @param {number} x - x axis in screen pixels to probe
         * @param {number} y - y axis in screen pixels to probe
         * @returns {number} the value of that pixel
         */
        Image.prototype.readPixel = HiPS.prototype.readPixel;

       
        /** PRIVATE METHODS **/
        Image.prototype._setView = function (view) {
            this.view = view;
            this._saveInCache();
        };

        // FITS images does not mean to be used for storing planetary data
        Image.prototype.isPlanetaryBody = function () {
            return false;
        };

        // @api
        Image.prototype.focusOn =  function () {
            // ensure the fits have been parsed
            if (this.added) {
                this.view.aladin.gotoRaDec(this.ra, this.dec);
                this.view.aladin.setFoV(this.fov);
            }
        };

        /* Private method view is already attached */
        Image.prototype._saveInCache = HiPS.prototype._saveInCache;

        // Private method for updating the view with the new meta
        Image.prototype._updateMetadata = HiPS.prototype._updateMetadata;

        Image.prototype._add = function (layer) {
            this.layer = layer;

            let self = this;
            let promise;

            if (this.imgFormat === 'fits') {
                promise = this._addFITS(layer)
                    .catch(e => {
                        console.error(`Image located at ${this.url} could not be parsed as fits file. Is the imgFormat specified correct?`)
                        return Promise.reject(e)
                    })
            } else if (this.imgFormat === 'jpeg' || this.imgFormat === 'png') {
                promise = this._addJPGOrPNG(layer)
                    .catch(e => {
                        console.error(`Image located at ${this.url} could not be parsed as a ${this.imgFormat} file. Is the imgFormat specified correct?`);
                        return Promise.reject(e)
                    })
            } else {
                // imgformat not defined we will try first supposing it is a fits file and then use the jpg heuristic
                promise = self._addFITS(layer)
                    .catch(e => {
                        return self._addJPGOrPNG(layer)
                            .catch(e => {
                                console.error(`Image located at ${self.url} could not be parsed as jpg/png/tif image file. Aborting...`)
                                return Promise.reject(e);
                            })
                    })
            }

            promise = promise.then((imageParams) => {
                self.formats = [self.imgFormat];

                // There is at least one entry in imageParams
                self.added = true;
                self._setView(self.view);

                // Set the automatic computed cuts
                let [minCut, maxCut] = self.getCuts();
                minCut = minCut || imageParams.min_cut;
                maxCut = maxCut || imageParams.max_cut;
                self.setCuts(
                    minCut,
                    maxCut
                );

                self.ra = imageParams.centered_fov.ra;
                self.dec = imageParams.centered_fov.dec;
                self.fov = imageParams.centered_fov.fov;

                // Call the success callback on the first HDU image parsed
                if (self.successCallback) {
                    self.successCallback(
                        self.ra,
                        self.dec,
                        self.fov,
                        self
                    );
                }

                return self;
            })
            .catch((e) => {
                // This error result from a promise
                // If I throw it, it will not be catched because
                // it is run async
                self.view.removeImageLayer(layer);

                return Promise.reject(e);
            });

            return promise;
        };

        Image.prototype._addFITS = function(layer) {
            let self = this;

            return Utils.fetch({
                url: this.url,
                dataType: 'readableStream',
                success: (stream) => {
                    return self.view.wasm.addImageFITS(
                        stream,
                        {
                            ...self.colorCfg.get(),
                            longitudeReversed: this.longitudeReversed,
                            imgFormat: 'fits',
                        },
                        layer
                    )
                },
                error: (e) => {
                    // try as cors 
                    const url = Aladin.JSONP_PROXY + '?url=' + self.url;

                    return Utils.fetch({
                        url: url,
                        dataType: 'readableStream',
                        success: (stream) => {
                            return self.view.wasm.addImageFITS(
                                stream,
                                {
                                    ...self.colorCfg.get(),
                                    longitudeReversed: this.longitudeReversed,
                                    imgFormat: 'fits',
                                },
                                layer
                            )
                        },
                    });
                }
            })
            .then((imageParams) => {
                self.imgFormat = 'fits';

                return Promise.resolve(imageParams);
            })
        };

        Image.prototype._addJPGOrPNG = function(layer) {
            let self = this;
            let img = document.createElement('img');

            return new Promise((resolve, reject) => {
                img.src = this.url;
                img.crossOrigin = "Anonymous";
                img.onload = () => {
                    const img2Blob = () => {
                        var canvas = document.createElement("canvas");

                        canvas.width = img.width;
                        canvas.height = img.height;
                    
                        // Copy the image contents to the canvas
                        var ctx = canvas.getContext("2d");
                        ctx.drawImage(img, 0, 0, img.width, img.height);
        
                        const imageData = ctx.getImageData(0, 0, img.width, img.height);
        
                        const blob = new Blob([imageData.data]);
                        const stream = blob.stream(1024);
        
                        resolve(stream)
                    };

                    if (!self.options.wcs) {
                        /* look for avm tags if no wcs is given */
                        let avm = new AVM(img);

                        avm.load((obj) => {
                            // obj contains the following information:
                            // obj.id (string) = The ID provided for the image
                            // obj.img (object) = The image object
                            // obj.xmp (string) = The raw XMP header
                            // obj.wcsdata (Boolean) = If WCS have been loaded
                            // obj.tags (object) = An array containing all the loaded tags e.g. obj.tags['Headline']
                            // obj.wcs (object) = The wcs parsed from the image
                            if (obj.wcsdata) {
                                if (img.width !== obj.wcs.NAXIS1) {
                                    obj.wcs.NAXIS1 = img.width;
                                }

                                if (img.height !== obj.wcs.NAXIS2) {
                                    obj.wcs.NAXIS2 = img.height;
                                }

                                self.options.wcs = obj.wcs;

                                img2Blob()
                            } else {
                                // no tags found
                                reject('No WCS have been found in the image')
                                return;
                            }
                        })
                    } else {
                        img2Blob()
                    }
                }

                let proxyUsed = false;
                img.onerror = (e) => {
                    // use proxy
                    if (proxyUsed) {
                        console.error(e);

                        reject('Error parsing image located at: ' + self.url)
                        return;
                    }

                    proxyUsed = true;
                    img.src = Aladin.JSONP_PROXY + '?url=' + self.url;
                }
            })
            .then((readableStream) => {
                let wcs = self.options && self.options.wcs;
                wcs.NAXIS1 = wcs.NAXIS1 || img.width;
                wcs.NAXIS2 = wcs.NAXIS2 || img.height;

                return self.view.wasm
                    .addImageWithWCS(
                        readableStream,
                        wcs,
                        {
                            ...self.colorCfg.get(),
                            longitudeReversed: this.longitudeReversed,
                            imgFormat: 'jpeg',
                        },
                        layer
                    )
            })
            .then((imageParams) => {
                self.imgFormat = 'jpeg';
                return Promise.resolve(imageParams);
            })
            .finally(() => {
                img.remove();
            });
        };

    return Image;
})();