Leaflet: project map coordinates to pixel coordinates - leaflet

What I am trying to achieve is when a rectangle is drawn on the map, I want to project this rectangles map coordinates into the coordinate space of the original image so that I can crop the original image and provide a download link to the user.
However I am having problems projecting the rectangles map coordinate's into accurate pixel coordinates in the original image.
I thought the following would work, however its producing pixel coordinates that are incorrect.
map.on('draw:created', function(e){
var type = e.layerType,
layer = e.layer;
if(type == 'rectangle'){
if(rectangle){
drawnItems.removeLayer(rectangle);
}
rectangle = layer;
drawnItems.addLayer(rectangle);
var north_west = rectangle.getBounds().getNorthWest();
var south_east = rectangle.getBounds().getSouthEast();
var top_left_pixel = map.project([north_west.lat, north_west.lng], map.getMaxZoom());
var bottom_right_pixel = map.project([south_east.lat, south_east.lng], map.getMaxZoom());
alert("top_left_pixel: " + (top_left_pixel.x / 4) + ", " + (top_left_pixel.y / 4) + " bottom_right_pixel: " + (bottom_right_pixel.x / 4) + ", " + (bottom_right_pixel.y / 4));
}
});
Here is an example of the in accurate projection from map coordinates (left image) into pixel coordinates (right image).
What am I doing wrong?

Actually it turns out that I was doing nothing wrong, the above idea works perfectly.
I was using the width and height of the original image, however during processing MapTiler changed the resolution of the base image. I figured this out by regenerating the tiles and inspecting the example leaflet.html thats generated by MapTiler. I was surprised to see the bounds were set to 2*(width of original base image) and 1.99*(height of original base image).
Taking this information into account fixed my inaccurate projection.

Related

Manually write world file (jgw) from Leaflet.js map

I have the need to export georeferenced images from Leaflet.js on the client side. Exporting an image from Leaflet is not a problem as there are plenty of existing plugins for this, but I'd like to include a world file with the export so the resulting image can be read into GIS software. I have a working script fort his, but I can't seem to nail down the correct parameters for my world file such that the resulting georeferenced image is located exactly correctly.
Here's my current script
// map is a Leaflet map object
let bounds = map.getBounds(); // Leaflet LatLngBounds
let topLeft = bounds.getNorthWest();
let bottomRight = bounds.getSouthEast();
let width_deg = bottomRight.lng - topLeft.lng;
let height_deg = topLeft.lat - bottomRight.lat;
let width_px = $(map._container).width() // Width of the map in px
let height_px = $(map._container).height() // Height of the map in px
let scaleX = width_deg / width_px;
let scaleY = height_deg / height_px;
let jgwText = `${scaleX}
0
0
-${scaleY}
${topLeft.lng}
${topLeft.lat}`
This seems to work well at large scales (ie zoomed in to city-level or so), but at smaller scales there is some distortion along the y-axis. One thing I noticed is that all examples of world files I can find (and those produced from QGIS or ArcMap) all have the x-scale and y-scale parameters being exactly equal (oppositely signed). In my calculations, these terms are different unless you are sitting right on the equator.
Example world file produced from QGIS
0.08984380916303301 // x-scale (size of px in x direction)
0 // rotation parameter 1
0 // rotation parameter 2
-0.08984380916303301 // y-scale (size of px in y direction)
-130.8723208723141056 // x-coord of top left px
51.73651369984968085 // y-coord of top left px
Example world file produced from my calcs
0.021972656250000017
0
0
-0.015362443783773333
-130.91308593750003
51.781435604431195
Example of produced image using my calcs with correct state boundaries overlaid:
Does anyone have any idea what I'm doing wrong here?
Problem was solved by using EPSG:3857 for the worldfile, and ensuring the width and height of the map bounds was also measured in this coordinate system. I had tried using EPSG:3857 for the worldfile, but measured the width and height of the map bounds using Leaflet's L.map.distance() function. To solve the problem, I instead projected corner points of the map bounds to EPSG:3857 using L.CRS.EPSG3857.project(), the simply subtracted the X,Y values.
Corrected code is shown below, where map is a Leaflet map object (L.map)
// Get map bounds and corner points in 4326
let bounds = map.getBounds();
let topLeft = bounds.getNorthWest();
let bottomRight = bounds.getSouthEast();
let topRight = bounds.getNorthEast();
// get width and height in px of the map container
let width_px = $(map._container).width()
let height_px = $(map._container).height()
// project corner points to 3857
let topLeft_3857 = L.CRS.EPSG3857.project(topLeft)
let topRight_3857 = L.CRS.EPSG3857.project(topRight)
let bottomRight_3857 = L.CRS.EPSG3857.project(bottomRight)
// calculate width and height in meters using epsg:3857
let width_m = topRight_3857.x - topLeft_3857.x
let height_m = topRight_3857.y - bottomRight_3857.y
// calculate the scale in x and y directions in meters (this is the width and height of a single pixel in the output image)
let scaleX_m = width_m / width_px
let scaleY_m = height_m / height_px
// worldfiles need the CENTRE of the top left px, what we currently have is the TOPLEFT point of the px.
// Adjust by subtracting half a pixel width and height from the x,y
let topLeftCenterPxX = topLeft_3857.x - (scaleX / 2)
let topLeftCenterPxY = topLeft_3857.y - (scaleY / 2)
// format the text of the worldfile
let jgwText = `
${scaleX_m}
0
0
-${scaleY_m}
${topLeftCenterPxX}
${topLeftCenterPxY}
`
For anyone else with this problem, you'll know things are correct when your scale-x and scale-y values are exactly equal (but oppositely signed)!
Thanks #IvanSanchez for pointing me in the right direction :)

Can map.getBounds be executed for a different coordinate system?

I execute the following code in my leaflet webmap
map.getBounds().getWest() + "&y1=" +
map.getBounds().getSouth() + "&x2=" +
map.getBounds().getEast() + "&y2=" +
map.getBounds().getNorth()
This results in a result showing me four coordinates in the WGS84 (standard) coordinate system.
Is there any way to alter this so it will output 28992 coordinates instead?
I guess that by "28992 coordinates" you're referring to the EPSG:28992 Coordinate Reference System.
The canonical way to use "non-standard" CRSs in Leaflet is to leverage proj4leaflet. This answer assumes that you're already doing so.
So the getBounds() method of L.Map always returns a L.LatLngBounds instance, which refer to unprojected WGS84 coordinates. However, we can use the map's CRS to project a L.LatLng into a L.Point with the projected coordinates, in the map's display CRS; e.g.
var map = L.map('containerId`, { crs: crsForEpsg28992 });
var foo = map.options.crs.project(L.latLng([60.3,21.1]));
var qux = map.options.crs.project(map.getCenter());
Because of how map projections work (they rotate and bend the coordinate spaces), and because of how proj4js is implemented, it's not possible to project a bounding box into a bounding box. (In most cases, the projection of a bounding box would be a curved polygon!). This image from an article by Gregor Aisch illustrates the issue:
We can, however, do an approximation: project the four corners of the bounding box, e.g.:
var mapBounds = map.getBounds();
var crs = map.options.crs;
var nw = crs.project(mapBounds.getNorthWest());
var ne = crs.project(mapBounds.getNorthEast());
var sw = crs.project(mapBounds.getSouthWest());
var se = crs.project(mapBounds.getSouthEast());
We can even create a L.Bounds (but not a L.LatLngBounds!) from those projected coordinates; that'll be a bbox in the specified CRS that contains all corners, e.g.:
var bbox = L.bounds([nw, ne, sw, se]);
It's not gonna be perfect, but that approximation should be enough for most use cases.
See also this working example (based off on one of the proj4leaflet examples), which should further illustrate the issue.

Leaflet.js (or other solution) zoom to magnified pixels without blur

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
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.
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 © OpenStreetMap 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.

How to set correct image dimensions by LatLngBounds using ImageOverlay?

I want to use ImageOverlays as markers, because I want the images to scale with zoom. Markers icons always resize to keep their size the same when you zoom.
My problem is that I can't figure out how to transform pixels to cords, so my image isn't stretched.
For instance, I decided my south-west LatLng to be [50, 50]. My image dimensions are 24px/24px.
How do I calculate the north-east LatLng based on the image pixels?
You are probably looking for map conversion methods.
In particular, you could use:
latLngToContainerPoint: Given a geographical coordinate, returns the corresponding pixel coordinate relative to the map container.
containerPointToLatLng: Given a pixel coordinate relative to the map container, returns the corresponding geographical coordinate (for the current zoom level).
// 1) Convert LatLng into container pixel position.
var originPoint = map.latLngToContainerPoint(originLatLng);
// 2) Add the image pixel dimensions.
// Positive x to go right (East).
// Negative y to go up (North).
var nextCornerPoint = originPoint.add({x: 24, y: -24});
// 3) Convert back into LatLng.
var nextCornerLatLng = map.containerPointToLatLng(nextCornerPoint);
var imageOverlay = L.imageOverlay(
'path/to/image',
[originLatLng, nextCornerLatLng]
).addTo(map);
Demo: http://playground-leaflet.rhcloud.com/tehi/1/edit?html,output

Converting from world coordinates to tile location

me and a friend are trying to build an android app for class that uses google maps and we have been spending days on this one error.
Ideally the app receives updates of the user's location, stores them, and paints over any stored coordinate. That turned out to be a nightmare to implement so right now we're just trying to paint over the entire tile if that tile contains a coordinate that the user has visited. However we think something's wrong with our conversion from coordinate to tile location because the higher the zoom the more our painted tile moves north and a little west from the actual coordinate.
private boolean isCoordInTileForZoom(int tileX, int tileY, int zoom)
{
//coordinate to check if they are in the current tile
float lon = WILF_TEST_COOR.x;
float lat = WILF_TEST_COOR.y;
//coordinates in terms of map length and map height
float mapX = lon + 180;
float mapY = (lat * -1) + 150;
//number of tiles in a row or column (2^zoom)
int tiles = (int) Math.pow(2,zoom);
//the height and width of a single tile
double tileWidth = 360.0/tiles;
double tileHeight = 180.0/tiles;
int mapCol = (int) (mapX/tileWidth);
int mapRow = (int) (mapY/tileHeight);
Log.d("Minimap.mapGridDetails"," \n" + "Zoom level: " + zoom + "\nMap Rows: " + tiles + "\nTile Width: " + tileWidth + "\nTile Height: " + tileHeight);
if (mapCol == tileX && mapRow == tileY)
{
return true;
}
else
{
return false;
}
}
Now we're assuming the world is 360 degrees across and 180 degrees tall which means, at zoom level 2, each tile should be 90x45 (a 4x4 grid). And it works at zoom 2 and I think 3 but at zoom 4 and beyond the painted tile jumps north of the expected spot.
My feeling is that the problem lies in our assumption of how coordinate conversion works (we're assuming Google's world map is a nicely laid out flat surface which is perhaps exceptionally naive of us) or maybe the google map is actually taller than 180 degrees. Either way, this thing is due in a few days so we thank you in advance for any advice.
Have you seen this: https://developers.google.com/maps/documentation/javascript/examples/map-coordinates ?
Although its JavaScript, it shows the principle of the correct coordinate conversion.
You may also check my answer to this SO question. The class TileProjection I provided there should answer your question. You may e.g. use its method getTileBounds, to check whether your coordinates are inside the tile, or you may use the method latLngToPoint and check, whether x and y of the point are both in the range 0 - TILE_SIZE.