Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

GeoTiff reprojection from WGS84 to Mercator #15579

Closed
maxschmi opened this issue Feb 22, 2024 · 11 comments
Closed

GeoTiff reprojection from WGS84 to Mercator #15579

maxschmi opened this issue Feb 22, 2024 · 11 comments
Labels

Comments

@maxschmi
Copy link

maxschmi commented Feb 22, 2024

Describe the bug

Openlayers reprojection of a GeoTiff TileLayer from WGS84 (EPS:4326) to Mercator (EPSG:3857) is not working as expected.
The resulting grid has different cell widths. They somehow togle from wide to thin. So one column is wide and the next one is thin.

See the screenshot:

To Reproduce

Steps to reproduce the behavior:

Go to codesandbox of minimal example or checkout the minimal example repository

Expected behavior

The grid cells should have the same width as seen in this screenshot from QGIS of the same layer and the same map projection:

@maxschmi maxschmi added the bug label Feb 22, 2024
@mike-000
Copy link
Contributor

mike-000 commented Feb 22, 2024

You need to prevent excessive overzooming of the original tiles during reprojection by limiting the maxZoom of the reprojected tiles, for example

tif_layer.getSource().setTileGridForProjection('EPSG:3857', createXYZ({maxZoom: 4}))

works in your CodeSandbox.

You could attempt to calculate an appropriate setting based on tif_view.resolutions.

@maxschmi
Copy link
Author

Thanks for the quick response. At first the sugested solution looked good, but having a closer look I realized it interpolates between cells to get the pixel value.
This should absolutly not be the case, as I really want to show the grid cells value in my application.

I forked the minimal example and added @mike-000's sugested solution.
Furthermore I added a hover to show the pixels value on mouse hover. The values in the test GeoTiffs are all integers, but as openalyer interpolates the values, they become float numbers.
See the codeSandbox for the updated version.

Unfortunatly I don't really get how setTileGridForProjection and createXYZ work, so I couldn't fix this problem. I could only play around and change the values, but it never happened to work out as excpected.

You could attempt to calculate an appropriate setting based on tif_view.resolutions.

How could I calculate this?

@mike-000
Copy link
Contributor

The unwanted interpolation is only seen in Firefox, see #14099. It can be fixed with a polyfill such as

/*
 * Polyfill to make Gecko browser `CanvasRenderingContext2D.drawImage()` behavior when 
 * downscaling with `imageSmoothingEnabled` set to `false` consistent with other browsers
 * (no smoothing or interpolation of pixel values) and not as updated in
 * https://github.com/mdn/content/blob/main/files/en-us/mozilla/firefox/releases/56/index.md#canvas-and-webgl
 */
(function () {

    const canvas1 = document.createElement('canvas');
    canvas1.width = 2;
    canvas1.height = 2;
    const ctx1 = canvas1.getContext('2d');
    const imageData = ctx1.createImageData(2, 2);
    imageData.data[0] = 255;
    imageData.data[3] = 255;
    imageData.data[7] = 255;
    imageData.data[11] = 255;
    imageData.data[12] = 255;
    imageData.data[15] = 255;
    ctx1.putImageData(imageData, 0, 0);
    const canvas2 = document.createElement('canvas');
    canvas2.width = 1;
    canvas2.height = 1;
    const ctx2 = canvas2.getContext('2d');
    ctx2.imageSmoothingEnabled = false;
    ctx2.drawImage(canvas1, 0, 0, 2, 2, 0, 0, 1, 1);
    const data = ctx2.getImageData(0, 0, 1, 1).data;

    if (data[0] !== 0 && data[0] !== 255) {
      // browser interpolates, polyfill is needed
      const defaultDrawImage = CanvasRenderingContext2D.prototype.drawImage;

      CanvasRenderingContext2D.prototype.drawImage = function (
        image,
        ...params
      ) {
        // Calculate scales in case workaround is needed
        let scaleX = 1;
        let scaleY = 1;

        let sx = 0;
        let sy = 0;
        let sWidth = image.width;
        let sHeight = image.height;
        let dx;
        let dy;
        let dWidth = sWidth;
        let dHeight = sHeight;

        if (!this.imageSmoothingEnabled) {
          const transform = this.getTransform();
          scaleX = Math.sqrt(
            transform.a * transform.a + transform.b * transform.b
          );
          scaleY = Math.sqrt(
            transform.c * transform.c + transform.d * transform.d
          );

          if (params.length === 2) {
            [dx, dy] = params;
          } else if (params.length === 4) {
            [dx, dy, dWidth, dHeight] = params;
          } else if (params.length === 8) {
            [sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight] = params;
          }

          scaleX *= Math.abs(dWidth / sWidth);
          scaleY *= Math.abs(dHeight / sHeight);
        }

        if (dx !== undefined && dy !== undefined && scaleX < 1 && scaleY < 1) {
          // Image reduction workaround to avoid downscaling by
          // Gecko browsers when interpolation is not required
          let toKeepX = Math.abs(sWidth);
          let toKeepY = Math.abs(sHeight);
          let sWidthFinal = toKeepX;
          let sHeightFinal = toKeepY;
          // Reduce whichever dimension needs less reduction
          // keeping at least one row or column
          if (scaleX > scaleY) {
            toKeepX = Math.max(Math.floor(scaleX * toKeepX), 1);
            sWidthFinal = Math.min(toKeepX, scaleX * sWidthFinal);
          } else {
            toKeepY = Math.max(Math.floor(scaleY * toKeepY), 1);
            sHeightFinal = Math.min(toKeepY, scaleY * sHeightFinal);
          }

          const drawImage = document.createElement('canvas');
          drawImage.width = toKeepX;
          drawImage.height = toKeepY;
          const drawContext = drawImage.getContext('2d');
          drawContext.imageSmoothingEnabled = false;
          defaultDrawImage.call(
            drawContext,
            image,
            sx,
            sy,
            sWidth,
            sHeight,
            0,
            0,
            toKeepX,
            toKeepY
          );
          defaultDrawImage.call(
            this,
            drawImage,
            0,
            0,
            sWidthFinal,
            sHeightFinal,
            dx,
            dy,
            dWidth,
            dHeight
          );

          drawImage.width = 1;
          drawImage.height = 1;
        } else {
          defaultDrawImage.call(this, image, ...params);
        }
      };
    }

})();

@maxschmi
Copy link
Author

Thanks a lot this did the deal.

@mike-000
Copy link
Contributor

You could attempt to calculate an appropriate setting based on tif_view.resolutions.

How could I calculate this?

The aim would be to avoid having target (reprojected) tile grid resolutions which would result in source resolutions calculated by https://github.com/openlayers/openlayers/blob/main/src/ol/reproj.js#L104 being smaller than those available in the source.

Maybe this could/should be attempted for all reprojections? However as in https://openlayers.org/en/latest/examples/geotiff-reprojection.html what might work well at the equator may not be suitable for polar regions or vice versa.

@maxschmi
Copy link
Author

Hmm even after hours of playing around with createXYZ I couldn't get the right parameters to prevent having columns where 2 grid cells get merged. So I allways end up having some merged columns or overzooming efects as in the beginning.

grafik

Here is my actual setup: codesandbox

I tried setting maxResolution, tileSize, minZoom, maxZoom, extent parameters of the createXYZ function, but it feels more like guessing around and maybe being lucky one day. Isn't there a more systematic approach on how to calculate this? Especially as the horizontal difference in the cell width between Mercator and WGS84 in my region is not very large I think that this should somehow be possible.

@maxschmi maxschmi reopened this Feb 23, 2024
@mike-000
Copy link
Contributor

mike-000 commented Feb 24, 2024

In your case your source has non-square-pixels i.e. the rendered aspect ratio of tiles in the tile grid does not match the 256 : 256 ratio of their data. Reprojection only produces tiles with square pixels and scales up one dimension to achieve that.

This code will maintain alignment of columns as in the unreprojected source, but the row alignment is not precise

const source = tif_layer.getSource();
const tileGrid = source.getTileGrid();
const resolutions = tileGrid.getResolutions();
const maxZoom = resolutions.length - 1;
const resolution =
  (resolutions[maxZoom] * getWidth(map_proj.getExtent())) /
  getWidth(tif_view.projection.getExtent());

const reprojTilePixelRatio = Math.max.apply(
  null,
  tileGrid.getResolutions().map((r, z) => {
    const tileSize = toSize(tileGrid.getTileSize(z));
    const textureSize = source.getTileSize(z);
    return Math.max(textureSize[0] / tileSize[0], textureSize[1] / tileSize[1]);
  })
);

source.setTileGridForProjection(
  map_proj,
  createXYZ({
    extent: transformExtent(tif_view.extent, tif_view.projection, map_proj),
    maxResolution: resolution * reprojTilePixelRatio,
    maxZoom: maxZoom,
  })
);

@maxschmi
Copy link
Author

Thanks a lot @mike-000
I think now I get the problem, that the tiles have to be somehow squares. I tested your code but the alignment offset is too big for my project. So with my tiff-layer the tiles can't be splitted into squares, unless they would be very small -> resulting in a huge TileGrid.

Thats a bit disapointing, but then maybe openlayer was not the tool to go. :-(

maxschmi added a commit to Hydrology-IFH/HydroApps that referenced this issue Feb 26, 2024
The problem is that the tiles can't be reprojected to mercator without having non-square tiles, resulting in strange effects with openlayer.
see openlayers/openlayers#15579
maxschmi added a commit to Hydrology-IFH/HydroApps that referenced this issue Feb 26, 2024
@maxschmi
Copy link
Author

Just to show the resulting problem, I added some Polygons of the Grid shapes, where there border should be to the CodeSandbox.
As you can see the Borders ar not well aligned:
grafik

@mike-000
Copy link
Contributor

Yes, that is to be expected as your non-square pixels are being drawn as square pixels in a canvas stitch context optimised for one dimension only before reprojection. Ideally your GeoTIFF should have better resolution, e.g. instead of one tile with 256 x 256 pixels have 2560 x 2560 pixels in 100 tiles.

While OpenLayers could potentially take an option to scale up before reprojecting (e.g. drawing 256 x 256 pixels as 2560 x 2560 pixels in a larger stitch context) that would use a lot of system resources compared with tiling for which GeoTIFFs can be optimised.

@maxschmi
Copy link
Author

Oh wow. I'm not completly sure, why this works now, but this was the missing tip. Thanks @mike-000
After changing the Tiffs tiling and pixel resolution I found one setup that works perfectly: CodeSandbox

Interestingly it did only work with this specific pixel resolution (used now 4 times as many pixels as before). If I went up It sstoped working again.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

No branches or pull requests

2 participants