skip to Main Content

I’ve been using Leaflet to display raster images lately.

What I would like to do for a particular project is be able to zoom in to an image so the pixels become magnified on the screen in a sharply delineated way, such as you would get when zooming in to an image in Photoshop or the like. I would also like to retain, at some zoom level before maximum, a 1:1 correspondence between image pixel and screen pixel.

I tried going beyond maxNativeZoom as described here and here, which works but the interpolation results in pixel blurring.

I thought of an alternative which is to make the source image much larger using ‘nearest neighbour’ interpolation to expand each pixel into a larger square: when zoomed to maxNativeZoom the squares then look like sharply magnified pixels even though they aren’t.

Problems with this are:

  • image size and tile count get out of hand quickly (original image is 4096 x 4096)
  • you never get the ‘pop’ of a 1:1 correspondence between image pixel and screen pixel

I have thought about using two tile sets: the first from the original image up to it’s maxNativeZoom, and then the larger ‘nearest neighbour’ interpolated image past that, following something like this.

But, this is more complex, doesn’t avoid the problem of large tile count, and just seems inelegant.

So:

  • Can Leaflet do what I need it to and if so how?
  • If not can you point me in the right direction to something that can (for example, it would be interesting to know how this is achieved)?

Many thanks

2

Answers


  1. One approach is to leverage the image-rendering CSS property. This can hint the browser to use nearest-neighbour interpolation on <img> elements, such as Leaflet map tiles.

    e.g.:

    img.leaflet-tile {
        image-rendering: pixelated;
    }
    

    See a working demo. Beware of incomplete browser support.

    Login or Signup to reply.
  2. A more complicated approach (but one that works across more browsers) is to leverage WebGL; in particular Leaflet.TileLayer.GL.

    This involves some internal changes to Leaflet.TileLayer.GL to support a per-tile uniform, most critically setting the uniform value to the tile coordinate in each tile render…

    gl.uniform3f(this._uTileCoordsPosition, coords.x, coords.y, coords.z);
    

    …having a L.TileLayer that "displays" a non-overzoomed tile for overzoomed tile coordinates (instead of just skipping the non-existent tiles)…

    var hackishTilelayer = new L.TileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
        'attribution': 'Map data © <a href="http://openstreetmap.org">OpenStreetMap</a> contributors',
        maxNonPixelatedZoom: 3
    });
    
    hackishTilelayer.getTileUrl = function(coords) {
        if (coords.z > this.options.maxNonPixelatedZoom) {
            return this.getTileUrl({
                x: Math.floor(coords.x / 2),
                y: Math.floor(coords.y / 2),
                z: coords.z - 1
            });
        }
    
        // Skip L.TileLayer.prototype.getTileUrl.call(this, coords), instead
        // apply the URL template directly to avoid maxNativeZoom shenanigans
        var data = {
            r: L.Browser.retina ? '@2x' : '',
            s: this._getSubdomain(coords),
            x: coords.x,
            y: coords.y,
            z: coords.z // *not* this._getZoomForUrl() !
        };
        var url = L.Util.template(this._url, L.Util.extend(data, this.options));
        return url;
    }
    

    … plus a fragment shader that rounds down texel coordinates prior to texel fetches (plus a tile-coordinate-modulo-dependant offset), to actually perform the nearest-neighbour oversampling…

    var fragmentShader = `
    highp float factor = max(1., pow(2., uTileCoords.z - uPixelatedZoomLevel));
    vec2 subtileOffset = mod(uTileCoords.xy, factor);
    
    void main(void) {
        vec2 texelCoord = floor(vTextureCoords.st * uTileSize / factor ) / uTileSize;
    
        texelCoord.xy += subtileOffset / factor;
    
        vec4 texelColour = texture2D(uTexture0, texelCoord);
    
        // This would output the image colours "as is"
        gl_FragColor = texelColour;
    }
    `;
    

    …all tied together in an instance of L.TileLayer.GL (which syncs some numbers for the uniforms around):

    var pixelated = L.tileLayer.gl({
        fragmentShader: fragmentShader,
        tileLayers: [hackishTilelayer],
        uniforms: {
            // The shader will need the zoom level as a uniform...
            uPixelatedZoomLevel: hackishTilelayer.options.maxNonPixelatedZoom,
            // ...as well as the tile size in pixels.
            uTileSize: [hackishTilelayer.getTileSize().x, hackishTilelayer.getTileSize().y]
        }
    }).addTo(map);
    

    You can see everything working together in this demo.

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search