File Coverage

blib/lib/Geo/OSM/Imager.pm
Criterion Covered Total %
statement 29 159 18.2
branch 0 30 0.0
condition 0 15 0.0
subroutine 10 19 52.6
pod 7 9 77.7
total 46 232 19.8


line stmt bran cond sub pod time code
1             package Geo::OSM::Imager;
2              
3 1     1   590 use 5.008001;
  1         3  
4 1     1   4 use strict;
  1         1  
  1         15  
5 1     1   3 use warnings;
  1         2  
  1         28  
6              
7             our $VERSION = "0.04";
8              
9 1     1   1486 use Math::Trig;
  1         12681  
  1         110  
10 1     1   495 use Geo::Ellipsoid;
  1         4243  
  1         27  
11 1     1   576 use LWP::UserAgent;
  1         37957  
  1         30  
12 1     1   437 use HTTP::Request::Common;
  1         1875  
  1         57  
13 1     1   5 use List::Util qw(max);
  1         2  
  1         98  
14 1     1   476 use List::MoreUtils qw(minmax);
  1         11164  
  1         5  
15 1     1   1630 use Imager;
  1         37164  
  1         6  
16              
17             =encoding utf-8
18              
19             =head1 NAME
20              
21             Geo::OSM::Imager - simplifies plotting onto OpenStreetMap tiles
22              
23             =head1 SYNOPSIS
24              
25             my $g=Geo::OSM::Imager->new(ua => 'MyApplication');
26             my $image=$g->init(\@points);
27             ...
28             my ($x,$y)=$g->latlon2xy($lat,$lon);
29             $image->circle(x=>$x,y=>$y,r=>50,color=>$blue);
30             ...
31             $image->circle($g->latlon2hash($lat,$lon),r=>50,color=>$blue);
32             ...
33             $image->write(file => 'test.png');
34              
35             =head1 DESCRIPTION
36              
37             This module sets up an Imager object made of OpenStreetMap tiles, for
38             drawing of geographic data.
39              
40             Beware of over-using OpenStreetMap tile servers, and see the usage
41             policy at https://operations.osmfoundation.org/policies/tiles/ .
42              
43             Be hesitant about drawing straight lines over long distances, as map
44             projections will cause distortion. Over more than a few hundred
45             metres, the author prefers to break the line into a series of points
46             and plot individual line segments.
47              
48             =cut
49              
50             =head1 USAGE
51              
52             =over
53              
54             =item new()
55              
56             Creates a new Geo::OSM::Imager object. Takes an optional hash of
57             parameters:
58              
59             maxx - maximum X size of the image, in pixels.
60             maxy - maximum Y size of the image, in pixels.
61              
62             The image will generally be between 50% and 100% of this size.
63              
64             margin - fractional margin around bounding points
65             marginlat - fractional latitude margin around bounding point
66             marginlon - fractional longitude margin around bounding points
67              
68             The fraction of the latitude/longitude span to leave as space around
69             the matter to be plotted. With a margin of zero, points will be
70             plotted right at the edges of the image. A margin of 1/7 works well,
71             and is the default. marginlat and marginlon allow you to define this
72             separately for latitude and longitude.
73              
74             tileage - minimum age to expire tiles
75              
76             The number of seconds after which a tile may be considered "old" and
77             re-downloaded. Tileserver usage policy forbids an expiry of less than
78             one week (604800s), which is the default.
79              
80             tiledir - directory for the tile cache
81              
82             The directory in which to store tiles; it must exist.
83              
84             tilesize - size of tiles
85              
86             The pixel size of each tile. Leave at its default of 256 unless you
87             know what you're doing.
88              
89             tileurl - base URL for downloading tiles
90              
91             The base URL for downloading files. If you are using a local
92             tileserver, or a public tileserver other than OpenStreetMap, set it
93             here. (A slash will be added, so don't end with one - usually this
94             shouldn't matter but tile.openstreetmap.org at least cares about the
95             difference.)
96              
97             ua - user-agent
98              
99             Tileserver usage policy requires a "Valid HTTP User-Agent identifying
100             application". As a matter of policy you must set this yourself.
101              
102             =cut
103              
104             sub new {
105 0     0 1   my ($pkg,%p)=@_;
106 0           my $self={margin => 1/7,
107             maxx => 2400,
108             maxy => 2400,
109             tileage => 604800,
110             tiledir => "$ENV{HOME}/Maps/OSM",
111             tilesize => 256,
112             tileurl => 'https://tile.openstreetmap.org',
113             };
114 0 0         if (%p) {
115 0           foreach my $param (qw(margin marginlat marginlon maxx maxy tileage tiledir tilesize tileurl ua)) {
116 0 0         if (exists $p{$param}) {
117 0           $self->{$param}=$p{$param};
118             }
119             }
120             }
121 0   0       $self->{marginlat} ||= $self->{margin};
122 0   0       $self->{marginlon} ||= $self->{margin};
123 0           $self->{lwp}=LWP::UserAgent->new(agent => $self->{ua});
124 0 0         unless (defined $self->{ua}) {
125 0           die "Need a user-agent to access OpenStreetMap tile servers";
126             }
127 0           bless($self,$pkg);
128 0           return $self;
129             }
130              
131             =item init()
132              
133             Checks bounds and sets up the image. Pass an arrayref of points, each
134             of which can be either an arrayref [lat,lon] or a hashref including
135             lat and lon keys (or "latitude", "long", "longitude").
136              
137             These need not be the same points you're going to plot, though that's
138             obviously the easiest approach.
139              
140             Returns the Imager object.
141              
142             =cut
143              
144             sub init {
145 0     0 1   my ($self,$points)=@_;
146 0           my @series;
147 0           foreach my $p (@{$points}) {
  0            
148 0 0         if (ref $p eq 'ARRAY') {
    0          
149 0           map {push @{$series[$_]},$p->[$_]} (0,1);
  0            
  0            
150             } elsif (ref $p eq 'HASH') {
151 0           my $lat;
152 0           foreach my $latname (qw(lat latitude)) {
153 0   0       $lat ||= $p->{$latname};
154             }
155 0           my $lon;
156 0           foreach my $lonname (qw(lon long longitude)) {
157 0   0       $lon ||= $p->{$lonname};
158             }
159 0 0 0       if (defined $lat && defined $lon) {
160 0           push @{$series[0]},$lat;
  0            
161 0           push @{$series[1]},$lon;
  0            
162             }
163             }
164             }
165 0           my @minmax=map {[minmax(@{$_})]} @series;
  0            
  0            
166 0           $self->{geo}=Geo::Ellipsoid->new(units => 'degrees');
167              
168 0           my %bounds=(lon => [undef,undef],
169             lat => [undef,undef]);
170 0           $bounds{lat}[0]=$minmax[0][0]-($minmax[0][1]-$minmax[0][0])*$self->{marginlat};
171 0           $bounds{lat}[1]=$minmax[0][1]+($minmax[0][1]-$minmax[0][0])*$self->{marginlat};
172 0           $bounds{lon}[0]=$minmax[1][0]-($minmax[1][1]-$minmax[1][0])*$self->{marginlon};
173 0           $bounds{lon}[1]=$minmax[1][1]+($minmax[1][1]-$minmax[1][0])*$self->{marginlon};
174              
175             my $longdist=max(
176             $self->{geo}->to($bounds{lat}[0],$bounds{lon}[0],$bounds{lat}[0],$bounds{lon}[1]),
177 0           $self->{geo}->to($bounds{lat}[1],$bounds{lon}[0],$bounds{lat}[1],$bounds{lon}[1]),
178             ); # metres
179 0           my $longscale=$longdist/$self->{maxy}; # metres/pixel
180             my $latdist=max(
181             $self->{geo}->to($bounds{lat}[0],$bounds{lon}[0],$bounds{lat}[1],$bounds{lon}[0]),
182 0           $self->{geo}->to($bounds{lat}[0],$bounds{lon}[1],$bounds{lat}[1],$bounds{lon}[1]),
183             );
184 0           my $latscale=$latdist/$self->{maxx};
185 0           my $scale=max($longscale,$latscale); # make sure it fits, use wider scale
186             $self->{zoomlevel}=int(
187             log(
188             cos(
189             deg2rad(
190 0           ($bounds{lat}[0]+$bounds{lat}[1])/2
191             )
192             )*6378137.0*2*pi/$scale
193             )/log(2)-8
194             );
195              
196 0           while ($self->{zoomlevel} > 18) {
197 0           $self->{zoomlevel}--;
198 0           foreach my $mode (qw(lat lon)) {
199 0           my $mean=($bounds{$mode}[0]+$bounds{$mode}[1])/2;
200 0           foreach my $nn (0,1) {
201 0           $bounds{$mode}[$nn]+=($bounds{$mode}[$nn]-$mean);
202             }
203             }
204             }
205              
206 0           $self->{xmax}=int(($self->getTileNumber($bounds{lat}[1],$bounds{lon}[1]))[0]+.9999999);
207 0           $self->{xmin}=int(($self->getTileNumber($bounds{lat}[0],$bounds{lon}[0]))[0]);
208 0           $self->{ymax}=int(($self->getTileNumber($bounds{lat}[0],$bounds{lon}[0]))[1]+.9999999);
209 0           $self->{ymin}=int(($self->getTileNumber($bounds{lat}[1],$bounds{lon}[1]))[1]);
210              
211 0           my $img=Imager->new(xsize => $self->{tilesize}*($self->{xmax}-$self->{xmin}+1), ysize => $self->{tilesize}*($self->{ymax}-$self->{ymin}+1),
212             channels => 4);
213 0           mkdir "$self->{tiledir}/$self->{zoomlevel}";
214 0           foreach my $x ($self->{xmin}..$self->{xmax}) {
215 0           mkdir "$self->{tiledir}/$self->{zoomlevel}/$x";
216 0           foreach my $y ($self->{ymin}..$self->{ymax}) {
217 0           my $stub="$self->{zoomlevel}/$x/$y.png";
218 0           my $dl=1;
219 0 0         if (-e "$self->{tiledir}/$stub") {
220 0           my $fa=(stat("$self->{tiledir}/$stub"))[9];
221 0 0         if (time-$fa < $self->{tileage}) {
222 0           $dl=0;
223             }
224             }
225 0 0         if ($dl) {
226 0           my $rq=HTTP::Request->new(GET => "$self->{tileurl}/$stub");
227 0           my $rp=$self->{lwp}->request($rq);
228 0 0         if ($rp->is_success) {
229 0 0         open OUT,">$self->{tiledir}/$stub" or die "Can't open $self->{tiledir}/$stub for writing\n";
230 0           binmode OUT;
231 0           print OUT $rp->content;
232 0           close OUT;
233             } else {
234 0           die "Couldn't fetch $self->{tileurl}/$stub\n";
235             }
236             }
237 0           my $i=Imager->new;
238 0           $i->read(file => "$self->{tiledir}/$self->{zoomlevel}/$x/$y.png");
239             $img->rubthrough(left => $self->{tilesize}*($x-$self->{xmin}),
240 0           top => $self->{tilesize}*($y-$self->{ymin}),
241             src => $i);
242             }
243             }
244 0           $self->{offsetx}=$self->{offsety}=$self->{img}=0;
245 0           my $xclipmax=int(($self->latlon2xy($bounds{lat}[1],$bounds{lon}[1]))[0]);
246 0           my $xclipmin=int(($self->latlon2xy($bounds{lat}[0],$bounds{lon}[0]))[0]+.9999999);
247 0           my $yclipmax=int(($self->latlon2xy($bounds{lat}[0],$bounds{lon}[0]))[1]+.9999999);
248 0           my $yclipmin=int(($self->latlon2xy($bounds{lat}[1],$bounds{lon}[1]))[1]);
249 0           $self->{img}=$img->crop(left => $xclipmin,
250             top => $yclipmin,
251             width => $xclipmax-$xclipmin,
252             height => $yclipmax-$yclipmin);
253 0           $self->{offsetx}=-$xclipmin;
254 0           $self->{offsety}=-$yclipmin;
255 0           return $self->{img};
256             }
257              
258             =item image()
259              
260             Returns the Imager object.
261              
262             =cut
263              
264             # let the user plot onto it
265             sub image {
266 0     0 1   my ($self)=@_;
267 0 0         unless (exists $self->{img}) {
268 0           die "Not yet initialised.\n";
269             }
270 0           return $self->{img};
271             }
272              
273             =item zoom()
274              
275             Returns the zoom level of the initialised object. See
276             L and
277             L
278             for more.
279              
280             =cut
281              
282             sub zoom {
283 0     0 1   my ($self)=@_;
284 0 0         unless (exists $self->{img}) {
285 0           die "Not yet initialised.\n";
286             }
287 0           return $self->{zoomlevel};
288             }
289              
290             sub getTileNumber {
291 0     0 0   my ($self,$lat,$lon) = @_;
292 0           my $zoom = $self->{zoomlevel};
293 0           my $xtile = ($lon+180)/360 *2**$zoom;
294 0           my $ytile = (1 - log(tan(deg2rad($lat)) + sec(deg2rad($lat)))/pi)/2 *2**$zoom;
295 0           return ($xtile, $ytile);
296             }
297              
298             =item latlon2xy($lat,$lon)
299              
300             Given a (latitude, longitude) coordinate pair, returns the (x, y)
301             coordinate pair needed to plot onto the Imager object.
302              
303             =cut
304              
305             sub latlon2xy {
306 0     0 1   my ($self,$lat,$lon) = @_;
307 0 0         unless (exists $self->{img}) {
308 0           die "Not yet initialised.\n";
309             }
310 0           my ($x,$y)=$self->getTileNumber($lat,$lon);
311 0           $x=($x-$self->{xmin})*$self->{tilesize}+$self->{offsetx};
312 0           $y=($y-$self->{ymin})*$self->{tilesize}+$self->{offsety};
313 0           return ($x,$y);
314             }
315              
316             =item latlon2hash($lat,$lon)
317              
318             Given a (latitude, longitude) coordinate pair, returns a list of the
319             form ('x', $x, 'y', $y) for use with many Imager plotting functions.
320              
321             =cut
322              
323             sub latlon2hash {
324 0     0 1   my ($self,$lat,$lon) = @_;
325 0           my ($x,$y)=$self->latlon2xy($lat,$lon);
326 0           return ('x',$x,'y',$y);
327             }
328              
329             =item segment($lat1,$lon1,$lat2,$lon2,$step)
330              
331             Given two (latitude, longitude) coordinate pairs and a step value,
332             returns an arrayref of (latitude, longitude) coordinate pairs
333             interpolating the route on a great circle. This is generally worth
334             doing when distances exceed around 100 miles or high precision is
335             wanted.
336              
337             A positive step value is the length of each segment in metres. A
338             negative step value is the number of divisions into which the overall
339             line should be split.
340              
341             =cut
342              
343             sub segment {
344 0     0 1   my ($self,$lat1,$lon1,$lat2,$lon2,$step)=@_;
345 0           my @out=[$lat1,$lon1];
346 0           my ($r,$b)=$self->{geo}->to($lat1,$lon1,$lat2,$lon2);
347 0 0         if ($step<0) {
348 0           $step=-$r/$step;
349             }
350 0           my $ra=0;
351 0           while ($ra<$r) {
352 0           $ra+=$step;
353 0           push @out,[$self->{geo}->at($lat1,$lon1,$ra,$b)];
354 0           $out[-1][1]=$self->constrain($out[-1][1],180);
355             }
356 0           push @out,[$lat2,$lon2];
357 0           return @out;
358             }
359              
360             sub constrain {
361 0     0 0   my ($self,$angle,$range)=@_;
362 0           while ($angle>$range) {
363 0           $angle-=$range*2;
364             }
365 0           while ($angle<-$range) {
366 0           $angle+=$range*2;
367             }
368 0           return $angle;
369             }
370              
371             =back
372              
373             =head1 OTHER CONSIDERATIONS
374              
375             Note that you need not draw directly onto the supplied object: you can
376             create a new transparent image using the width and height of the one
377             provided by the module, draw onto that, and copy the results with a
378             rubthrough or compose command. See L for
379             more.
380              
381             =head1 BUGS
382              
383             Won't work to span +/- 180 degrees longitude.
384              
385             =head1 LICENSE
386              
387             Copyright (C) 2017 Roger Bell_West.
388              
389             This library is free software; you can redistribute it and/or modify
390             it under the same terms as Perl itself.
391              
392             =head1 AUTHOR
393              
394             Roger Bell_West Eroger@firedrake.orgE
395              
396             =head1 SEE ALSO
397              
398             L
399              
400             =cut
401              
402             1;