From 1661ec6b7f1dff1d05daee4263dc555427b4b615 Mon Sep 17 00:00:00 2001 From: bmatthieu3 Date: Tue, 23 Jul 2024 18:48:16 +0200 Subject: [PATCH] wip: image format inference, avm parser based on https://www.strudel.org.uk/avm/js/, wcs creation from avm tags --- examples/al-hips-local.html | 2 +- examples/al-image-with-AVM-tags.html | 28 +++ examples/al-image-with-WCS.html | 1 - src/core/src/app.rs | 5 +- src/core/src/lib.rs | 8 +- src/glsl/webgl2/image/sampler.frag | 2 +- src/js/Image.js | 255 ++++++++++++++++++--------- src/js/Utils.ts | 2 + src/js/libs/avm.js | 255 +++++++++++++++++++++++++++ 9 files changed, 468 insertions(+), 90 deletions(-) create mode 100644 examples/al-image-with-AVM-tags.html create mode 100644 src/js/libs/avm.js diff --git a/examples/al-hips-local.html b/examples/al-hips-local.html index c7e40d18..5ca30de8 100644 --- a/examples/al-hips-local.html +++ b/examples/al-hips-local.html @@ -14,7 +14,7 @@ A.init.then(() => { aladin = A.aladin('#aladin-lite-div', {target: "05 40 59.12 -02 27 04.1", fov: 2}); - let survey = aladin.createImageSurvey('DSS2 local', "local hips", "hips/CDS_P_DSS2_color"); + let survey = aladin.createImageSurvey('DSS2 local', "local hips", "./hips/CDS_P_DSS2_color"); aladin.setBaseImageLayer(survey); }); diff --git a/examples/al-image-with-AVM-tags.html b/examples/al-image-with-AVM-tags.html new file mode 100644 index 00000000..a219001d --- /dev/null +++ b/examples/al-image-with-AVM-tags.html @@ -0,0 +1,28 @@ + + + + + +
+ + + \ No newline at end of file diff --git a/examples/al-image-with-WCS.html b/examples/al-image-with-WCS.html index 1ae709b8..2520c405 100644 --- a/examples/al-image-with-WCS.html +++ b/examples/al-image-with-WCS.html @@ -13,7 +13,6 @@ "https://nova.astrometry.net/image/25038473?filename=M61.jpg", { name: "M61", - imgFormat: 'jpeg', wcs: { NAXIS: 0, // Minimal header CTYPE1: 'RA---TAN', // TAN (gnomic) projection diff --git a/src/core/src/app.rs b/src/core/src/app.rs index a03ddd94..c6723db7 100644 --- a/src/core/src/app.rs +++ b/src/core/src/app.rs @@ -1115,7 +1115,6 @@ impl App { pub(crate) fn add_image_fits( &mut self, - id: String, stream: web_sys::ReadableStream, meta: ImageMetadata, layer: String, @@ -1245,8 +1244,9 @@ impl App { } else { let fits = ImageLayer { images, + id: layer.clone(), + layer, - id, meta, }; @@ -1301,6 +1301,7 @@ impl App { ) -> Result<(), JsValue> { let old_meta = self.layers.get_layer_cfg(&layer)?; // Set the new meta + // keep the old meta data let new_img_fmt = meta.img_format; self.layers .set_layer_cfg(layer.clone(), meta, &mut self.camera, &self.projection)?; diff --git a/src/core/src/lib.rs b/src/core/src/lib.rs index 4da56537..85be3dff 100644 --- a/src/core/src/lib.rs +++ b/src/core/src/lib.rs @@ -382,21 +382,19 @@ impl WebClient { #[wasm_bindgen(js_name = addImageFITS)] pub fn add_image_fits( &mut self, - id: String, stream: web_sys::ReadableStream, cfg: JsValue, layer: String, ) -> Result { let cfg: ImageMetadata = serde_wasm_bindgen::from_value(cfg)?; - //let wcs: Option = serde_wasm_bindgen::from_value(wcs)?; - self.app.add_image_fits(id, stream, cfg, layer) + self.app.add_image_fits(stream, cfg, layer) } #[wasm_bindgen(js_name = addImageWithWCS)] pub fn add_image_with_wcs( &mut self, - data: web_sys::ReadableStream, + stream: web_sys::ReadableStream, wcs: JsValue, cfg: JsValue, layer: String, @@ -406,7 +404,7 @@ impl WebClient { let wcs_params: WCSParams = serde_wasm_bindgen::from_value(wcs)?; let wcs = WCS::new(&wcs_params).map_err(|e| JsValue::from_str(&format!("{:?}", e)))?; - self.app.add_image_from_blob_and_wcs(layer, data, wcs, cfg) + self.app.add_image_from_blob_and_wcs(layer, stream, wcs, cfg) } #[wasm_bindgen(js_name = removeLayer)] diff --git a/src/glsl/webgl2/image/sampler.frag b/src/glsl/webgl2/image/sampler.frag index 4005d66d..5e0ab3b4 100644 --- a/src/glsl/webgl2/image/sampler.frag +++ b/src/glsl/webgl2/image/sampler.frag @@ -11,6 +11,6 @@ uniform float opacity; #include ../hips/color.glsl; void main() { - out_frag_color = texture(tex, frag_uv); + out_frag_color = texture(tex, vec2(frag_uv.x, 1.0 - frag_uv.y)); out_frag_color.a = out_frag_color.a * opacity; } \ No newline at end of file diff --git a/src/js/Image.js b/src/js/Image.js index 0e707bc7..c89c6cd9 100644 --- a/src/js/Image.js +++ b/src/js/Image.js @@ -28,6 +28,9 @@ import { ALEvent } from "./events/ALEvent.js"; import { ColorCfg } from "./ColorCfg.js"; import { Aladin } from "./Aladin.js"; +import { Utils } from "./Utils"; +import { AVM } from "./libs/avm.js"; + /** * @typedef {Object} ImageOptions @@ -48,7 +51,7 @@ import { Aladin } from "./Aladin.js"; * @property {number} [contrast=0.0] - The contrast value for the color configuration. * @property {Object} [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 {number} [imgFormat='fits'] - The image format of the image to load. + * @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'. * * @example * @@ -56,7 +59,6 @@ import { Aladin } from "./Aladin.js"; * "https://nova.astrometry.net/image/25038473?filename=M61.jpg", * { * name: "M61", - * imgFormat: 'jpeg', * wcs: { * NAXIS: 0, // Minimal header * CTYPE1: 'RA---TAN', // TAN (gnomic) projection + SIP distortions @@ -104,8 +106,8 @@ export let Image = (function () { this.url = url; this.id = url; this.name = (options && options.name) || this.url; - this.imgFormat = (options && options.imgFormat) || "fits"; - this.formats = [this.imgFormat]; + this.imgFormat = options && options.imgFormat; + //this.formats = [this.imgFormat]; // callbacks this.successCallback = options && options.successCallback; this.errorCallback = options && options.errorCallback; @@ -116,7 +118,7 @@ export let Image = (function () { }*/ this.colorCfg = new ColorCfg(options); - this.options = options; + this.options = options || {}; let self = this; @@ -219,92 +221,185 @@ export let Image = (function () { } }; + 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: false, + 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); + + console.log(self.options, stream) + + + resolve(stream) + }; + + if (!self.options.wcs) { + /* look for avm tags if no wcs is given */ + let avm = new AVM(img); + console.log(img.complete) + 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.avmdata (Boolean) = If AVM tags have been loaded + // obj.tags (object) = An array containing all the loaded tags e.g. obj.tags['Headline'] + console.log(obj) + + if (obj.avmdata) { + let wcs = {}; + wcs.CTYPE1 = obj.tags['Spatial.CoordinateFrame'] === 'ICRS' ? 'RA---' : 'GLON-'; + wcs.CTYPE1 += obj.tags['Spatial.CoordsystemProjection']; + wcs.CTYPE2 = obj.tags['Spatial.CoordinateFrame'] === 'ICRS' ? 'DEC--' : 'GLAT-'; + wcs.CTYPE2 += obj.tags['Spatial.CoordsystemProjection']; + + if (obj.tags['Spatial.Equinox']) + wcs.EQUINOX = +obj.tags['Spatial.Equinox']; + + wcs.NAXIS1 = +obj.tags['Spatial.ReferenceDimension'][0]; + wcs.NAXIS2 = +obj.tags['Spatial.ReferenceDimension'][1]; + + wcs.CDELT1 = +obj.tags['Spatial.Scale'][0]; + wcs.CDELT2 = +obj.tags['Spatial.Scale'][1]; + + wcs.CRPIX1 = +obj.tags['Spatial.ReferencePixel'][0]; + wcs.CRPIX2 = +obj.tags['Spatial.ReferencePixel'][1]; + + wcs.CRVAL1 = +obj.tags['Spatial.ReferenceValue'][0]; + wcs.CRVAL2 = +obj.tags['Spatial.ReferenceValue'][1]; + + if (obj.tags['Spatial.Rotation'] !== undefined) + wcs.CROTA2 = +obj.tags['Spatial.Rotation']; + + self.options.wcs = wcs; + + img2Blob() + } else { + // no tags found + reject('No AVM tags have been found in the image') + return; + } + + }) + } else { + img2Blob() + } + } + + let proxyUsed = false; + img.onerror = () => { + // use proxy + if (proxyUsed) { + reject('Error parsing img ' + 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: false, + imgFormat: 'jpeg', + }, + layer + ) + }) + .then((imageParams) => { + self.imgFormat = 'jpeg'; + return Promise.resolve(imageParams); + }) + .finally(() => { + img.remove(); + }); + } + Image.prototype.add = function (layer) { this.layer = layer; let self = this; - let promise; - if (this.imgFormat === 'fits') { - let id = this.url; - promise = fetch(this.url) - .then((resp) => resp.body) - .then((readableStream) => { - return self.view.wasm - .addImageFITS( - id, - readableStream, - { - ...self.colorCfg.get(), - longitudeReversed: false, - imgFormat: self.imgFormat, - }, - layer - ) - }) - } else if (this.imgFormat === 'jpg' || this.imgFormat === 'jpeg') { - let img = document.createElement('img'); - - promise = - new Promise((resolve, reject) => { - img.src = this.url; - img.crossOrigin = "Anonymous"; - img.onload = () => { - 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) - } - - let proxyUsed = false; - img.onerror = () => { - // use proxy - if (proxyUsed) { - reject('Error parsing img ' + 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: false, - imgFormat: self.imgFormat, - }, - layer - ) + 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) }) - .finally(() => { - img.remove(); + } 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 { - console.warn(`Image format: ${this.imgFormat} not supported`); - promise = Promise.reject(); - }; + // imgformat not defined we will try first supposing it is a fits file and then use the jpg heuristic + promise = this._addFITS(layer) + .catch(_ => { + console.warn(`Image located at ${self.url} could not be parsed as fits file. Try as a jpg/png image...`) + return self._addJPGOrPNG(layer) + .catch(e => { + console.error(`Image located at ${self.url} could not be parsed as jpg/png 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); diff --git a/src/js/Utils.ts b/src/js/Utils.ts index a414639a..412518b8 100644 --- a/src/js/Utils.ts +++ b/src/js/Utils.ts @@ -381,6 +381,8 @@ Utils.fetch = function(params) { return resp.json() } else if (params.dataType && params.dataType.includes('blob')) { return resp.blob() + } else if (params.dataType && params.dataType.includes('readableStream')) { + return resp.body; } else { return resp.text() } diff --git a/src/js/libs/avm.js b/src/js/libs/avm.js new file mode 100644 index 00000000..8986ea64 --- /dev/null +++ b/src/js/libs/avm.js @@ -0,0 +1,255 @@ +/* + * Javascript AVM/XMP Reader 0.1.3 + * Copyright (c) 2010 Stuart Lowe http://www.strudel.org.uk/ + * Astronomy Visualization Metadata (AVM) is defined at: + * http://www.virtualastronomy.org/avm_metadata.php + * + * Licensed under the MPL http://www.mozilla.org/MPL/MPL-1.1.txt + * + */ + +export let AVM = (function() { + + function AVM(input) { + if (input instanceof HTMLImageElement) { + this.img = input; + } else { + // suppose that input is a string + this.id = (input) ? input : ""; + this.img = { complete: false }; + } + + this.xmp = ""; // Will hold the XMP string (for test purposes) + this.avmdata = false; + this.tags = {} + this.AVMdefinedTags = { + 'Creator':'photoshop:Source', + 'CreatorURL':'Iptc4xmpCore:CiUrlWork', + 'Contact.Name':'dc:creator', + 'Contact.Email':'Iptc4xmpCore:CiEmailWork', + 'Contact.Telephone':'Iptc4xmpCore:CiTelWork', + 'Contact.Address':'Iptc4xmpCore:CiAdrExtadr', + 'Contact.City':'Iptc4xmpCore:CiAdrCity', + 'Contact.StateProvince':'Iptc4xmpCore:CiAdrRegion', + 'Contact.PostalCode':'Iptc4xmpCore:CiAdrPcode', + 'Contact.Country':'Iptc4xmpCore:CiAdrCtry', + 'Rights':'xapRights:UsageTerms', + 'Title':'dc:title', + 'Headline':'photoshop:Headline', + 'Description':'dc:description', + 'Subject.Category':'avm:Subject.Category', + 'Subject.Name':'dc:subject', + 'Distance':'avm:Distance', + 'Distance.Notes':'avm:Distance.Notes', + 'ReferenceURL':'avm:ReferenceURL', + 'Credit':'photoshop:Credit', + 'Date':'photoshop:DateCreated', + 'ID':'avm:ID', + 'Type':'avm:Type', + 'Image.ProductQuality':'avm:Image.ProductQuality', + 'Facility':'avm:Facility', + 'Instrument':'avm:Instrument', + 'Spectral.ColorAssignment':'avm:Spectral.ColorAssignment', + 'Spectral.Band':'avm:Spectral.Band', + 'Spectral.Bandpass':'avm:Spectral.Bandpass', + 'Spectral.CentralWavelength':'avm:Spectral.CentralWavelength', + 'Spectral.Notes':'avm:Spectral.Notes', + 'Temporal.StartTime':'avm:Temporal.StartTime', + 'Temporal.IntegrationTime':'avm:Temporal.IntegrationTime', + 'DatasetID':'avm:DatasetID', + 'Spatial.CoordinateFrame':'avm:Spatial.CoordinateFrame', + 'Spatial.Equinox':'avm:Spatial.Equinox', + 'Spatial.ReferenceValue':'avm:Spatial.ReferenceValue', + 'Spatial.ReferenceDimension':'avm:Spatial.ReferenceDimension', + 'Spatial.ReferencePixel':'avm:Spatial.ReferencePixel', + 'Spatial.Scale':'avm:Spatial.Scale', + 'Spatial.Rotation':'avm:Spatial.Rotation', + 'Spatial.CoordsystemProjection':'avm:Spatial.CoordsystemProjection', + 'Spatial.Quality':'avm:Spatial.Quality', + 'Spatial.Notes':'avm:Spatial.Notes', + 'Spatial.FITSheader':'avm:Spatial.FITSheader', + 'Spatial.CDMatrix':'avm:Spatial.CDMatrix', + 'Publisher':'avm:Publisher', + 'PublisherID':'avm:PublisherID', + 'ResourceID':'avm:ResourceID', + 'ResourceURL':'avm:ResourceURL', + 'RelatedResources':'avm:RelatedResources', + 'MetadataDate':'avm:MetadataDate', + 'MetadataVersion':'avm:MetadataVersion' + } + } + + AVM.prototype.load = function(fnCallback) { + if(!this.img && this.id) { + this.img = document.getElementById(this.id); + } + + if(!this.img.complete) { + var _obj = this; + addEvent(this.img, "load", + function() { + _obj.getData(fnCallback); + } + ); + }else{ + this.getData(fnCallback); + } + } + + AVM.prototype.getData = function(fnCallback){ + if(!this.imageHasData()){ + this.getImageData(this.img, fnCallback); + }else{ + if(typeof fnCallback=="function") fnCallback(this); + } + return true; + } + + AVM.prototype.getImageData = function(oImg, fnCallback) { + var _obj = this; + let reqwst = new Request(oImg.src, { + method: 'GET', + }) + fetch(reqwst) + .then((resp) => resp.arrayBuffer()) + .then(arrayBuffer => { + const view = new DataView(arrayBuffer); + var oAVM = _obj.findAVMinJPEG(view); + _obj.avmdata = true; + _obj.tags = oAVM || {}; + if (typeof fnCallback=="function") fnCallback(_obj); + }) + } + + function addEvent(oElement, strEvent, fncHandler){ + if (oElement.addEventListener) oElement.addEventListener(strEvent, fncHandler, false); + else if (oElement.attachEvent) oElement.attachEvent("on" + strEvent, fncHandler); + } + + AVM.prototype.imageHasData = function() { + return (this.img.avmdata); + } + + AVM.prototype.findAVMinJPEG = function(oFile) { + if (oFile.getUint8(0) != 0xFF || oFile.getUint8(1) != 0xD8) return false; // not a valid jpeg + + var iOffset = 2; + var iLength = oFile.byteLength; + while (iOffset < iLength) { + if (oFile.getUint8(iOffset) != 0xFF) return false; // not a valid marker, something is wrong + var iMarker = oFile.getUint8(iOffset+1)+1; + + + // we could implement handling for other markers here, + // but we're only looking for 0xFFE1 for AVM data + if (iMarker == 22400) { + return this.readAVMData(oFile, iOffset + 4, oFile.getUint16(iOffset+2, false)-2); + iOffset += 2 + oFile.getUint16(iOffset+2, false); + + } else if (iMarker == 225) { + // 0xE1 = Application-specific 1 (for AVM) + console.log("jkjk") + var oTags = this.readAVMData(oFile, iOffset + 4, oFile.getUint16(iOffset+2, false)-2); + return oTags; + } else { + iOffset += 2 + oFile.getUint16(iOffset+2, false); + } + } + } + + AVM.prototype.readAVMData = function(oFile, iStart, iLength){ + var oTags = {}; + this.xmp = this.readXMP(oFile); + if (this.xmp) oTags = this.readAVM(this.xmp); + return oTags; + } + + AVM.prototype.readXMP = function(oFile) { + var iEntries = oFile.byteLength; + var bBigEnd = false; + var prev_n_hex = ''; + var record = false; + var recordn = 0; + // Find the XMP packet - 8 bit encoding (UTF-8) + // see page 34 of http://www.adobe.com/devnet/xmp/pdfs/xmp_specification.pdf + var xmpStr = '0x3C 0x3F 0x78 0x70 0x61 0x63 0x6B 0x65 0x74 0x20 0x62 0x65 0x67 0x69 0x6E 0x3D 0x22 0xEF '; + var xmpBytes = 16; + var byteStr = ''; + var iEntryOffset = -1; + console.log(iEntryOffset) + + // Here we want to search for the XMP packet starting string + // There is probably a more efficient way to search for a byte string + for (var i=0;i= 0){ + var str = ''; + var i = iEntryOffset; + while(str.indexOf('') < 0 && i < (iEntryOffset+20000)){ + str += String.fromCharCode(oFile.getUint8(i)); + i++; + } + return str; + } + } + + AVM.prototype.readAVM = function(str) { + var oTags = {}; + if(str.indexOf('xmlns:avm') >= 0){ + for (var key in this.AVMdefinedTags) { + var keyname = key; + key = this.AVMdefinedTags[key]; + key.toLowerCase(); + var start = str.indexOf(key)+key.length+2; + var final = str.indexOf('"',start); + // Find out what the character is after the key + var char = str.substring(start-2,start-1); + if(char == "="){ + oTags[keyname] = str.substring(start,final); + }else if(char == ">"){ + final = str.indexOf('',start); + // Parse out the HTML tags and build an array of the resulting values + var tmpstr = str.substring(start,final); + var tmparr = new Array(0); + tmpstr = tmpstr.replace(/[\n\r]/g,""); + tmpstr = tmpstr.replace(/ +/g," "); + tmparr = tmpstr.split(/ ?<\/?[^>]+> ?/g); + var newarr = new Array(0); + for(var i = 0;i 0) newarr.push(tmparr[i]); + } + oTags[keyname] = newarr; + } + } + } + return oTags; + } + + return AVM; +})(); \ No newline at end of file