openlayers icon indicating copy to clipboard operation
openlayers copied to clipboard

COG with JPEG Compression has descending order

Open undefinedSolutions opened this issue 4 years ago • 21 comments

Viewing a COG, if I don't use comp or just DEFLATE it works, however I would like to reduce the file size of the aerial image significantly and therefore use JPEG. However, when I do this I get the following error message: GeoTIFF.js:392 AssertionError: Assertion failed. See https://openlayers.org/en/v6.12.0/doc/errors/#17 for details.

My "Workflow": I converted my GeoTIFF with GDAL: gdal_translate ./../rawData/ortho_lindenrain.tif ./cog/ortho_lindenrain_JPEG.tif -of COG -co COMPRESS=JPEG -co NUM_THREADS=ALL_CPUS I am using GDAL 3.4.1

The validation was looking good:

python validate_cloud_optimized_geotiff.py ./cog/ortho_lindenrain_JPEG.tif
./cog/ortho_lindenrain_JPEG.tif is a valid cloud optimized GeoTIFF

The size of all IFD headers is 307812 bytes

But when I add the COG to the layers of my Map:

const cog = new TileLayer({
  source: new GeoTIFF({
    sources: [
      {
        url: 'https://masterarbeit-cog.s3.eu-central-1.amazonaws.com/cog/ortho_lindenrain_JPEG.tif'
      },
    ],
  })
})
....
layers: [
  new TileLayer({source: new OSM()}),
  cog
],,

I got the error: GeoTIFF.js:392 AssertionError: Assertion failed. See https://openlayers.org/en/v6.12.0/doc/errors/#17 for details. what is describing that: resolutions must be sorted in descending order, what it should be...

You can find the inputData here: https://masterarbeit-cog.s3.eu-central-1.amazonaws.com/inputData/ortho_lindenrain.tif The Working on is here: https://masterarbeit-cog.s3.eu-central-1.amazonaws.com/cog/ortho_lindenrain.tif The JPEG-Compressed is here: https://masterarbeit-cog.s3.eu-central-1.amazonaws.com/cog/ortho_lindenrain_JPEG.tif

undefinedSolutions avatar Feb 08 '22 08:02 undefinedSolutions

I got the error: GeoTIFF.js:392 AssertionError: Assertion failed. See https://openlayers.org/en/v6.12.0/doc/errors/#17 for details. what is describing that: resolutions must be sorted in descending order, what it should be...

Looking at what the TileGrid constructor gets, I see 16 resolutions, and only the first 7 are in descending order. Then they are repeated, and the last resolution is duplicated:

0 2.564791666666618
1 1.281061394380829
2 0.640197607904304
3 0.32001559656874884
4 0.16000779828437442
5 0.08000389914218721
6 0.040000649835915676
7 2.564791666666618
8 1.281061394380829
9 0.640197607904304
10 0.32001559656874884
11 0.16000779828437442
12 0.08000389914218721
13 0.040000649835915676
14 0.019999999999999622
15 0.019999999999999622

Also, when I execute the gdal_translate command you posted above, I get several errors

ERROR 5: GDALDataset::GetRasterBand(2) - Illegal band #
ERROR 5: GDALDataset::GetRasterBand(3) - Illegal band #

So I assume there is something wrong in the way you generate the JPEG version of the COG.

ahocevar avatar Feb 08 '22 09:02 ahocevar

As described in https://trac.osgeo.org/gdal/wiki/CloudOptimizedGeoTIFF#HowtogenerateitwithGDAL, I do get a working JPEG COG with

gdal_translate ortho_lindenrain.tif ortho_lindenrain_JPEG.tif -co TILED=YES -co COPY_SRC_OVERVIEWS=YES -co COMPRESS=JPEG

But that may not be what you're looking for.

ahocevar avatar Feb 08 '22 10:02 ahocevar

Thanks for the support, it really seems to be a bug in the COG driver?

With your GDAL command it works but I get 1.5 GB instead of 509.8 MB with the COG driver, but still better than with DEFLATE which is 5.2 GB...

What makes me wonder is that validate_cloud_optimized_geotiff.py doesn't give an error here....

undefinedSolutions avatar Feb 08 '22 11:02 undefinedSolutions

I don't think it's a bug in GDAL's cog driver, but given the error messages I get when running your command, I think there is a usage error. You may want to clarify that with the GDAL community. If it turns out that the resulting GeoTIFF is correct, we can continue looking into the problem here. Regarding validate_cloud_optimized_geotiff.py, I don't know what exactly that tool validates. Reading https://www.cogeo.org/developers-guide.html, I would assume it only checks that the file structure is valid, which I guess is true, because geotiff.js is able to read the file. But still, the contents may be wrong.

ahocevar avatar Feb 08 '22 11:02 ahocevar

I'm experiencing the same issue with the command: gdal_translate input.tif output.tif -of COG -co TILING_SCHEME=GoogleMapsCompatible -co NUM_THREADS=ALL_CPUS -co COMPRESS=JPEG -co BIGTIFF=YES

I'm gonna try your suggestion @ahocevar, but what I find a bit strange is that for this command is an example on the COG driver: gdal_translate world.tif world_webmerc_cog.tif -of COG -co TILING_SCHEME=GoogleMapsCompatible -co COMPRESS=JPEG (https://gdal.org/drivers/raster/cog.html)

Also when served from a remote server, the resulting COG works fine with QGIS without errors.

vikingandrobot avatar Jun 24 '22 20:06 vikingandrobot

In my experience, the problem is the internal mask. If your COG has an alpha channel it works fine but if you have an internal mask you get the AssertionError.

You can remove the internal mask with: gdal_translate -b 1 -b 2 -b 3 -mask none -of COG <your COG> <output COG with no mask>

You may also want to set the nodata if you need transparancy: -a_nodata 0

If you are using JPEG compression and YCBCR colorspace, the transparency will look bad around the edges because it comes from the nodata, and with the JPEG resampling you get artifacts around the edges. I haven't found a way to keep an alpha layer for a YCBRC JPEG while using the COG driver.

If you use JPEG with RGB (or any of the TIF compressions), it will let you have an alpha channel.

But if you have a mask, you get the error.

achmed13 avatar Jun 24 '22 20:06 achmed13

Thanks @achmed13, I can confirm that removing the mask removes the OpenLayers error, however the result is not satisfactory enough (because of the artifacts you predicted) so I will need to find another solution.

Thanks for the pointers about the color spaces and alpha channels, I'll look into it.

vikingandrobot avatar Jun 27 '22 09:06 vikingandrobot

I encountered the same issue, that was indeed related to the internal mask, as @achmed13 noted. Switching to RGB colour space worked, but not via the GDAL COG driver, as it does not accept the PHOTOMETRIC option. WEBP compression is an interesting alternative, but not supported by geotiff.js. For now I settled with the JPEG artefacts, as trade off for the ~3× storage saving compared to 'DEFLATE RGB'.

For those interested in a hackish solution to get that done: gdal_translate -b 1 -b 2 -b 3 -mask none -scale 0 255 0 254 -a_nodata 255 ..., scales the data from 0-255 to 0-254 and uses 255 as no data value.

fwrite avatar Jul 18 '22 14:07 fwrite

I ended up using an external alpha mask, and then using WebGLTileLayer with a GeoTIFF with multiple sources.

TLDR: I wish OpenLayers/GeoTIFF.js supported internal masks :)

My process could probably be streamlined, but here's what I do, starting with my original IMAGE.tif which has an alpha channel and is usually either DEFLATE or UNCOMPRESSED (from OpenDroneMap), and converting to COG:

# convert IMAGE to COG
gdal_translate -b 1 -b 2 -b 3 -mask none -a_nodata 0 -co COMPRESS=JPEG -co OVERVIEW_COMPRESS=JPEG -co SPARSE_OK=TRUE -of COG "IMAGE.tif" "COG.tif" 

# export ALPHA/MASK from IMAGE
gdal_translate -b 4 -of GTiff -co SPARSE_OK=TRUE -co TILED=yes -co COMPRESS=NONE "IMAGE.tif" "IMAGE.msk.tif" 

# convert Mask to COG
gdal_translate -co COMPRESS=DEFLATE -co OVERVIEW_COMPRESS=DEFLATE -co SPARSE_OK=TRUE -of COG "IMAGE.msk.tif" "COG.msk.tif" 

Then, in OpenLayers, I point to both COG.tif and it's mask in the sources, and convertToRGB since the COG is YCBCR. OpenLayers/geotiff.js reads the first source and gets 3 channels, RGB, and then reads the second source and gets the Alpha.

const layer = new WebGLTileLayer( {
    source: new GeoTIFF( {
        sources: [
             {url: 'COG.tif'},
             {url: 'COG.msk.tif'},
        ],
        convertToRGB: true,
    } ),
} );

In my case, the file sizes looked like this:

IMAGE (uncompressed original, before adding overviews): 7.7 GB COG (DEFLATE): 6.4 GB COG (JPEG): 500 MB COG mask: 4 MB

achmed13 avatar Jul 18 '22 16:07 achmed13

Looking at what the TileGrid constructor gets, I see 16 resolutions, and only the first 7 are in descending order. Then they are repeated, and the last resolution is duplicated:

There's likely an issue with the file in the inital post (that can no longer be accessed), but what should be said is that the GDAL COG generator while do a special processing when its input is a RGBA file and the output compression is JPEG. Given that JPEG compression doesn't support alpha, GDAL will compress the RGB bands as YCbCr JPEG, and will separately compress the alpha band as a 1-bit mask in a separate IFD. This is one of the most advanced COG configuration admittedly, but intended to be valid as it only uses standard TIFF mechanisms.

For example if doing

$ gdal_translate autotest/gcore/data/stefan_full_rgba.tif out.tif -of COG -co COMPRESS=JPEG -outsize 1024 0

(where autotest/gcore/data/stefan_full_rgba.tif is https://github.com/OSGeo/gdal/blob/master/autotest/gcore/data/stefan_full_rgba.tif)

(-outsize 1024 0 is just here to make sure we have at least one overview for the sake of having a complete enough example)

With tiffinfo one can see that:

  • the first IFD is the full resolution of the RGB bands
  • the second IFD is the full resolution of the alpha band, exposed as a transparency mask ( Subfile Type: transparency mask (4 = 0x4) )
  • the third IFD is the half resolution of the RGB bands ( Subfile Type: reduced-resolution image (1 = 0x1) )
  • the fourth IFD is the half resolution of the alpha band ( Subfile Type: reduced-resolution image/transparency mask (5 = 0x5) )
$ tiffinfo out.tif
TIFF Directory at offset 0xe2 (226)
  Image Width: 1024 Image Length: 948
  Tile Width: 512 Tile Length: 512
  Bits/Sample: 8
  Sample Format: unsigned integer
  Compression Scheme: JPEG
  Photometric Interpretation: YCbCr
  YCbCr Subsampling: 2, 2
  Samples/Pixel: 3
  Planar Configuration: single image plane
  Reference Black/White:
     0:     0   255
     1:   128   255
     2:   128   255
  JPEG Tables: (142 bytes)
TIFF Directory at offset 0x266 (614)
  Subfile Type: transparency mask (4 = 0x4)
  Image Width: 1024 Image Length: 948
  Tile Width: 512 Tile Length: 512
  Bits/Sample: 1
  Sample Format: unsigned integer
  Compression Scheme: AdobeDeflate
  Photometric Interpretation: transparency mask
  Samples/Pixel: 1
  Planar Configuration: single image plane
  Predictor: none 1 (0x1)
TIFF Directory at offset 0x314 (788)
  Subfile Type: reduced-resolution image (1 = 0x1)
  Image Width: 512 Image Length: 474
  Tile Width: 512 Tile Length: 512
  Bits/Sample: 8
  Sample Format: unsigned integer
  Compression Scheme: JPEG
  Photometric Interpretation: YCbCr
  YCbCr Subsampling: 2, 2
  Samples/Pixel: 3
  Planar Configuration: single image plane
  Reference Black/White:
     0:     0   255
     1:   128   255
     2:   128   255
  JPEG Tables: (142 bytes)
TIFF Directory at offset 0x4a4 (1188)
  Subfile Type: reduced-resolution image/transparency mask (5 = 0x5)
  Image Width: 512 Image Length: 474
  Tile Width: 512 Tile Length: 512
  Bits/Sample: 1
  Sample Format: unsigned integer
  Compression Scheme: AdobeDeflate
  Photometric Interpretation: transparency mask
  Samples/Pixel: 1
  Planar Configuration: single image plane
  Predictor: none 1 (0x1)

rouault avatar Jul 19 '22 14:07 rouault

Thank you @rouault for the explanation.

@ahocevar is it possible for OpenLayers to support this kind of masks out of the box?

vikingandrobot avatar Jul 19 '22 16:07 vikingandrobot

@vikingandrobot If you can share some example code where you make use of that mask directly with geotiff.js, I can take a look and see how this could be implemented in OpenLayers.

ahocevar avatar Jul 19 '22 16:07 ahocevar

@ahocevar thank you for your response. I'm sorry, I am not sure I understand what i can do to help. I am only working with OpenLayers' API at the moment.

I don't have capacity to work on that in the coming weeks, but if I can get to it, can you please explain a bit more what code you would need? How to read the mask from the cog file?

vikingandrobot avatar Jul 20 '22 12:07 vikingandrobot

@vikingandrobot Yeah, something like that. I'm not sure what you're asking for either.

ahocevar avatar Jul 20 '22 13:07 ahocevar

Maybe to clarify the issue and the end goal: I am using the new WebGLTileLayer, in a similar way as the COG example in OpenLayers website:

import GeoTIFF from 'ol/source/GeoTIFF';
import Map from 'ol/Map';
import WebGLTileLayer from 'ol/layer/WebGLTile';


const source = new GeoTIFF({
  sources: [{ url: 'https://example.com/my_cog_with_a_jpg_compression.tif' }],
});

const map = new Map({
  target: 'map',
  layers: [
    new WebGLTileLayer({ source }),
  ],
  view: source.getView(),
});

When using gdal COG driver to generate a JPEG compressed COG from a geotiff with an alpha band, it results on a COG with a transparency mask (as described in @rouault's message above).

At the moment, OpenLayers throws an assertion error about order of resolutions when using such a file as a source and the COG is not displayed. I would ideally like OpenLayers not to throw that exception and to support this kind of file (with the correct transparency ofc).

vikingandrobot avatar Jul 20 '22 13:07 vikingandrobot

In my interpretation of @ahocevar's question, the question is whether the bug is in geotiff.js, or in OpenLayers. Noteworthy is that @undefinedSolutions reported it there first, and was sent here.

As a quick test I made a heavily subsampled Copernicus Sentinel-2 image of the Netherlands with four bands (RGBA). GeoTiff.io is based on Leaflet (and geotiff.js), and if it works there, the bug is in OpenLayers. If it does not work there either, we need a cleaner example.

First test: a four band image, without compression, regular GeoTiff, WGS84.

gdalwarp -t_srs EPSG:4326 -tr 0.01 0.01 -r average -multi -of GTiff -co COMPRESS=NONE -co BIGTIFF=IF_NEEDED -wm 8000 /tmp/adriaan-ahn/SentinelNL/L2A_COG/20181013T105029_R051B_TCI.vrt /tmp/S2_RAW.tiff

It works on GeoTiff.io and in OpenLayers (in an adaptation of this example).

Second test: a COG, without compression, WGS84.

gdal_translate -of COG -co COMPRESS=NONE /tmp/S2_RAW.tiff /tmp/S2_COG.tiff

It works on GeoTiff.io and in OpenLayers.

Third test: a COG, with JPEG compression, WGS84.

gdal_translate -of COG -co COMPRESS=JPEG /tmp/S2_RAW.tiff /tmp/S2_COG_JPEG.tiff

It renders on GeoTiff.io, but not as it should. This could be due to missing normalisation, however, the alpha mask is not captured either. image In OpenLayers in triggers the aforementioned assertion error #17. The silly part: this image is so small there are no overviews in the COG and therefore no resolutions to be in the wrong order. In QGIS the image renders without issue.

Preliminary conclusion: the issue is not limited to OpenLayers alone.

The GeoTiffs used: S2_COG.zip. (GDAL 3.4.1)

fwrite avatar Jul 20 '22 15:07 fwrite

@fwrite Thanks for the detailed write-up and the example data. This should help a great deal to understand, identify and eventually fix the problem in the right places (OpenLayers and/or geotiff.js).

ahocevar avatar Jul 20 '22 15:07 ahocevar

With my complete lack of knowledge of modern Javascript, I tried to create a geotiff.js test case by dumbing down the COG example in the sandbox linked in the documentation (i.e. replacing all code).

import {fromUrl} from 'geotiff'

async function test_image(url) {
  const tiff = await fromUrl(url);

  const image = await tiff.getImage();

  const bands = await tiff.readRasters();

  console.debug(bands.length)

}

test_image('https://eu2.contabostorage.com/05d6beb9e6b74f478ed68e4e8c715a4a:geotiles/S2_RAW.tiff')

test_image('https://eu2.contabostorage.com/05d6beb9e6b74f478ed68e4e8c715a4a:geotiles/S2_COG_JPEG.tiff')

test_image('https://eu2.contabostorage.com/05d6beb9e6b74f478ed68e4e8c715a4a:geotiles/S2_COG_JPEG_NOMASK.tiff')

The first will yield 4 bands (RGBA), the second 3 (note that the log is not ordered!). I would expect four bands, YCbCr + A. To test that theory, I created a second JPEG compressed image, that does not contain a mask (S2_COG_JPEG_NOMASK.zip).

gdal_translate -b 1 -b 2 -b 3 -mask none -of COG -co COMPRESS=JPEG /tmp/S2_RAW.tiff /tmp/S2_COG_JPEG_NOMASK.tiff

Again, three bands (not two).

A second test shows the data is equal between the two.

import {fromUrl} from 'geotiff'

(async function() {
  const a = await fromUrl('https://eu2.contabostorage.com/05d6beb9e6b74f478ed68e4e8c715a4a:geotiles/S2_COG_JPEG.tiff')
  const b = await fromUrl('https://eu2.contabostorage.com/05d6beb9e6b74f478ed68e4e8c715a4a:geotiles/S2_COG_JPEG_NOMASK.tiff')

  const a_image = await a.getImage();
  const b_image = await b.getImage();

  const a_bands = await a_image.readRasters();
  const b_bands = await b_image.readRasters();

  // If it is stupid and it works ...
  console.log(JSON.stringify(b_bands) === JSON.stringify(a_bands));
})()

Returns true (string representation is equal). It looks like the mask is ignored? Part of the issue appears to be in geotiff.js, with the viewers attempting to extract an alpha layer that is not there (or hidden elsewhere)?

I think I used geotiff.js 2.0.5 in the sandbox.

fwrite avatar Jul 20 '22 16:07 fwrite

@fwrite about not having any overview, @rouault had a workaround for the gdal_translate command explained above: (-outsize 1024 0 is just here to make sure we have at least one overview for the sake of having a complete enough example)

jjimenezshaw avatar Jul 20 '22 16:07 jjimenezshaw

To me the analysis of @achmed13, that it it a JPEG+mask decoding issue, sounds much more likely.

For completeness I have rendered a series of example images at higher resolution (containing two overviews each): S2_COG_large.zip.

gdalwarp -t_srs EPSG:4326 -tr 0.0025 0.0025 -r average -multi -of GTiff -co COMPRESS=NONE -co BIGTIFF=IF_NEEDED -wm 8000 /tmp/adriaan-ahn/SentinelNL/L2A_COG/20181013T105029_R051B_TCI.vrt /tmp/S2_RAW.tiff
gdal_translate -of COG -co COMPRESS=NONE /tmp/S2_RAW.tiff /tmp/S2_COG.tiff
gdal_translate -of COG -co COMPRESS=JPEG /tmp/S2_RAW.tiff /tmp/S2_COG_JPEG.tiff
gdal_translate -b 1 -b 2 -b 3 -mask none -of COG -co COMPRESS=JPEG /tmp/S2_RAW.tiff /tmp/S2_COG_JPEG_NOMASK.tiff

The results are the same on GeoTiff.io. I now tested the 'NOMASK' version as well, that renders the same as the version including a mask on GeoTiff.io (strange colors), so that is likely due to the (lack of) necessary colour normalization. Both render without the mask, closer to the results of my experiment directly interfacing geotiff.js.

fwrite avatar Jul 20 '22 16:07 fwrite

Thanks for the experiment @fwrite, that's very interesting.

I copied your code to this sandbox https://codesandbox.io/s/cog-forked-sce0ml?file=/main.js and dug a bit into what geotiff.js can tell us, focusing only on the cog with a mask.

Here are the main interesting points:

  • geotiff.js is able to detect that this cog contains 2 images (equivalent to 2 sub-files I guess)
  • 1st image has 3 bands and the 2nd one has 1 band.
  • when reading its file directory, the 2nd image has the property NewSubfileType set to the value 4, while the 1st image does not have this property in the file directory.

My suspicion is that the 1st image is the 3-bands color information (probably in YCbCr) and that the 2nd image is the 1-band transparency mask (it seems to contain values that are either 0 or 1).

Now I have good feeling that this value 4 correspond to the Sub-file type 0x4 mentioned above, and that it could be a way of detecting if an image is a transparency mask, and then work from there. (I'm basing this assumption on the doc here for example https://www.awaresystems.be/imaging/tiff/tifftags/newsubfiletype.html)

I'll try to look into what other info comes up when a COG has more overviews.

The most interesting part of the geotiff.js code would be:

const numberOfImages = await tiff.getImageCount();

for (let i = 0; i < numberOfImages; i++) {
  const image = await tiff.getImage(i);
  const fileDirectory = await image.getFileDirectory();
  const bands = await image.readRasters();
}

vikingandrobot avatar Jul 20 '22 22:07 vikingandrobot

I've got a branch going that respects the mask and renders the https://eu2.contabostorage.com/05d6beb9e6b74f478ed68e4e8c715a4a:geotiles/S2_COG_JPEG.tiff GeoTIFF like this:

image

I'll clean this up and submit a PR in the next couple days.

tschaub avatar Aug 26 '22 20:08 tschaub

For future readers, note that you need to set convertToRGB: true to render the YCbCr images in the way you'd expect.

const source = new GeoTIFF({
  convertToRGB: true,
  sources: [
    {
      url: 'https://eu2.contabostorage.com/05d6beb9e6b74f478ed68e4e8c715a4a:geotiles/S2_COG_JPEG.tiff',
    },
  ],
});

In the next breaking release, we should probably consider making convertToRGB the default if there are 3 samples per pixel and the photometric interpretation is CMYK, YCbCr, CIELab, or ICCLab. I'll ticket this separately (see #14065).

tschaub avatar Aug 28 '22 15:08 tschaub

That's awesome, thanks a lot everyone! Looking forward to try it out!

vikingandrobot avatar Aug 28 '22 19:08 vikingandrobot