File Coverage

lib/Panotools/Photos.pm
Criterion Covered Total %
statement 119 145 82.0
branch 35 62 56.4
condition 12 36 33.3
subroutine 15 18 83.3
pod 12 13 92.3
total 193 274 70.4


line stmt bran cond sub pod time code
1             package Panotools::Photos;
2              
3             =head1 NAME
4              
5             Panotools::Photos - Photo sets
6              
7             =head1 SYNOPSIS
8              
9             Query sets of photos
10              
11             =head1 DESCRIPTION
12              
13             A collection of photos has possibilities, it could be one or more panoramas or a
14             bracketed set. This module provides some methods for describing groups of
15             photos based on available metadata
16              
17             =cut
18              
19 1     1   68258 use strict;
  1         8  
  1         29  
20 1     1   4 use warnings;
  1         2  
  1         22  
21              
22 1     1   5 use File::Spec;
  1         1  
  1         21  
23 1     1   1804 use Image::ExifTool;
  1         55577  
  1         1703  
24              
25             =head1 USAGE
26              
27             Create a new object like so:
28              
29             my $photos = new Panotools::Photos;
30              
31             Alternatively supply some filenames:
32              
33             my $photos = new Panotools::Photos ('DSC_0001.JPG', 'DSC_0002.JPG');
34              
35             =over
36              
37             =cut
38              
39             sub new
40             {
41 14     14 0 563 my $class = shift;
42 14   33     47 $class = ref $class || $class;
43 14         25 my $self = bless [], $class;
44 14         35 $self->Paths (@_);
45 14         160 return $self;
46             }
47              
48             =item Paths
49              
50             Add to or get the list of image filenames:
51              
52             $photos->Paths ('DSC_0003.JPG', 'DSC_0004.JPG');
53             my @paths = $photos->Paths;
54              
55             =cut
56              
57             sub Paths
58             {
59 17     17 1 26 my $self = shift;
60 17         31 for my $path (@_)
61             {
62 6         203637 push @{$self}, {path => $path, exif => Image::ExifTool::ImageInfo ($path)};
  6         27  
63             }
64 17         92948 return map ($_->{path}, @{$self});
  17         57  
65             }
66              
67             =item Stub
68              
69             Construct a stub filename from the names of the first and last images in the
70             list.
71              
72             my $stub = $photos->Stub;
73              
74             e.g. DSC_0001.JPG DSC_0002.JPG DSC_0003.JPG -> DSC_0001-DSC_0003
75              
76             =cut
77              
78             sub Stub
79             {
80 1     1 1 3 my $self = shift;
81 1         4 my $path_a = $self->[0]->{path};
82 1         3 my $path_b = $self->[-1]->{path};
83             # strip any suffixes
84 1         8 $path_a =~ s/\.[[:alnum:]]+$//;
85 1         5 $path_b =~ s/\.[[:alnum:]]+$//;
86             # strip all but filename
87 1         5 $path_b =~ s/.*[\/\\]//;
88 1         6 return $path_a .'-'. $path_b;
89             }
90              
91             =item Bracketed
92              
93             Query to discover if this is a likely bracketed set. i.e. is the total number
94             of photos divisible by the number of different exposures:
95              
96             &do_stuff if ($photos->Bracketed);
97              
98             =cut
99              
100             sub Bracketed
101             {
102 10     10 1 776 my $self = shift;
103             # bracketed photos are not shot on 'Auto Exposure'
104 10         18 for my $index (0 .. scalar @{$self} -1)
  10         37  
105             {
106 80 100       150 next unless defined $self->[$index]->{exif}->{ExposureMode};
107 1 50       9 return 0 if $self->[$index]->{exif}->{ExposureMode} eq 'Auto';
108             }
109 9         14 my $brackets = scalar (@{$self->Speeds});
  9         19  
110             # require equal numbers of each exposure level
111 9 100       17 return 0 if (scalar (@{$self}) % $brackets);
  9         25  
112             # require more than one exposure level
113 7 50       14 return 0 if ($brackets < 2);
114             # require bracketing order repeats: 1/50 1/100 1/200 1/50 1/100 1/200 etc...
115 7         9 for my $index ($brackets .. scalar @{$self} -1)
  7         17  
116             {
117 34 100       80 return 0 unless $self->[$index]->{exif}->{ExposureTime} eq $self->[$index - $brackets]->{exif}->{ExposureTime};
118             }
119              
120 3         15 return 1;
121             }
122              
123             =item Layered
124              
125             Query to discover if this is a layered set, i.e. there is a large exposure
126             difference in the set, but it isn't bracketed.
127              
128             &do_stuff if ($photos->Layered);
129              
130             By default the threshold is 4, e.g. exposures varying between 2 and 1/2
131             seconds indicate layers. Vary this threshold like so:
132              
133             &do_stuff if ($photos->Layered (2));
134              
135             =cut
136              
137             sub Layered
138             {
139 5     5 1 11 my $self = shift;
140 5   100     17 my $factor = shift || 4;
141 5 100       10 return 0 if $self->Bracketed;
142 4         10 my $longest = $self->Speeds->[0];
143 4         11 my $shortest = $self->Speeds->[-1];
144 4 50       13 if ($longest =~ /^1\/([0-9]+)$/) {$longest = 1 / $1};
  0         0  
145 4 50       15 if ($shortest =~ /^1\/([0-9]+)$/) {$shortest = 1 / $1};
  4         9  
146 4 50 33     10 return 0 unless $longest or $shortest;
147 4 50       9 return 0 if $shortest == 0;
148 4 100       15 return 0 if $longest / $shortest < $factor;
149 3         13 return 1;
150             }
151              
152             =item Speeds
153              
154             Get a list of exposure times sorted with longest exposure first
155              
156             @speeds = @{$photos->Speeds};
157              
158             =cut
159              
160             sub Speeds
161             {
162 20     20 1 105 my $self = shift;
163 20         34 my $speeds = {};
164 20         23 for my $image (@{$self})
  20         28  
165             {
166 175   0     453 my $et = $image->{exif}->{ShutterSpeedValue} || $image->{exif}->{ExposureTime} || $image->{exif}->{ShutterSpeed} || 0;
167 175         242 $speeds->{$et} = 'TRUE';
168             }
169 20         27 return [sort {_normalise ($b) <=> _normalise ($a)} keys %{$speeds}];
  51         79  
  20         75  
170             }
171              
172             sub _normalise
173             {
174 105     105   119 my $number = shift;
175 105 100       246 if ($number =~ /^1\/([0-9]+)$/) {$number = 1 / $1};
  73         125  
176 105         247 return $number;
177             }
178              
179             =item SplitInterval
180              
181             Given a set of photos, split it into a one or more sets by looking at the
182             variation of time interval between shots. e.g. typically the interval between
183             shots in a panorama varies by less than 15 seconds. A variation greater than
184             that indicates the start of the next panorama:
185              
186             my @sets = $photos->SplitInterval (15);
187              
188             Sets with an average interval greater than 4x this variation are not considered
189             panoramas at all and discarded.
190              
191             =cut
192              
193             sub SplitInterval
194             {
195 6     6 1 13 my $self = shift;
196 6   50     16 my $d_inc = shift || 15;
197 6         9 my $max_inc = $d_inc * 4;
198 6         9 my @groups;
199            
200 6         23 my $group_tmp = new Panotools::Photos;
201 6         6 my $previous_time;
202 6         8 my $previous_inc = 0;
203 6         9 for my $image (@{$self})
  6         9  
204             {
205 26   66     64 my $datetime = $image->{exif}->{'DateTimeOriginal'} || $image->{exif}->{'FileModifyDate'};
206 26         53 my $time_unix = Image::ExifTool::GetUnixTime ($datetime);
207 26 100       4158 $previous_time = $time_unix unless (defined $previous_time);
208 26         33 my $inc = $time_unix - $previous_time;
209            
210 26 100       50 if (($inc - $previous_inc) > $d_inc)
211             {
212 5 50       13 push @groups, $group_tmp if ($group_tmp->AverageInterval < $max_inc);
213 5         14 $group_tmp = new Panotools::Photos;
214             }
215 26         27 push @{$group_tmp}, $image;
  26         34  
216            
217 26         27 $previous_time = $time_unix;
218 26         40 $previous_inc = $inc;
219             }
220 6 50       14 push @groups, $group_tmp if ($group_tmp->AverageInterval < $max_inc);
221 6         72 return @groups;
222             }
223              
224             =item AverageInterval
225              
226             Get the average time between shots:
227              
228             $average = $photos->AverageInterval;
229              
230             =cut
231              
232             sub AverageInterval
233             {
234 11     11 1 14 my $self = shift;
235 11 100       12 return 0 unless (scalar @{$self} > 1);
  11         33  
236              
237 5   33     19 my $start = $self->[0]->{exif}->{'DateTimeOriginal'} || $self->[0]->{exif}->{'FileModifyDate'};
238 5   33     13 my $end = $self->[-1]->{exif}->{'DateTimeOriginal'} || $self->[-1]->{exif}->{'FileModifyDate'};
239              
240 5         10 my $totaltime = Image::ExifTool::GetUnixTime ($end) - Image::ExifTool::GetUnixTime ($start);
241 5         392 return $totaltime / (scalar @{$self} -1);
  5         20  
242             }
243              
244             =item FOV FocalLength Rotation
245              
246             Get the Angle of View in degrees of the first photo:
247              
248             $photos->FOV;
249              
250             ..or any other photo (-1 is last):
251              
252             $photos->FOV (123);
253              
254             Returns undef if the FOV can't be calculated.
255              
256             =cut
257              
258             sub FOV
259             {
260 4     4 1 13 my $self = shift;
261 4         6 my $index = 0;
262 4 100       14 $index = shift if @_;
263 4         10 my $fov = $self->[$index]->{exif}->{'FOV'};
264 4 100       24 $fov =~ s/ .*$// if defined $fov;
265 4         34 return $fov;
266             }
267              
268             sub FocalLength
269             {
270 0     0 1 0 my $self = shift;
271 0         0 my $index = 0;
272 0 0       0 $index = shift if @_;
273 0         0 my $fl = $self->[$index]->{exif}->{'FocalLengthIn35mmFormat'};
274 0 0       0 $fl =~ s/ .*$// if defined $fl;
275 0         0 return $fl;
276             }
277              
278             sub Rotation
279             {
280 0     0 1 0 my $self = shift;
281 0   0     0 my $index = shift || 0;
282 0   0     0 my $rotation = $self->[$index]->{exif}->{'Rotation'} || undef;
283 0 0       0 return 0 unless $rotation;
284 0 0       0 return 0 if $rotation =~ /Mirror/;
285             return 0 if ($self->[$index]->{exif}->{'ImageWidth'}
286 0 0       0 < $self->[$index]->{exif}->{'ImageHeight'});
287 0 0       0 return 90 if $rotation =~ /Rotate 90 CW/;
288 0 0       0 return 180 if $rotation =~ /Rotate 180/;
289 0 0       0 return -90 if $rotation =~ /Rotate 270 CW/;
290 0         0 return 0;
291              
292             # 1 => 'Horizontal (normal)',
293             # 3 => 'Rotate 180',
294             # 6 => 'Rotate 90 CW',
295             # 8 => 'Rotate 270 CW',
296              
297             # 2 => 'Mirror horizontal',
298             # 4 => 'Mirror vertical',
299             # 5 => 'Mirror horizontal and rotate 270 CW',
300             # 7 => 'Mirror horizontal and rotate 90 CW',
301              
302             }
303              
304             =item Eev
305              
306             Get an EV value for a photo, this will be guessed from partial EXIF data:
307              
308             $photos->Eev ($index);
309              
310             =cut
311              
312             sub Eev
313             {
314 0     0 1 0 my $self = shift;
315 0   0     0 my $index = shift || 0;
316 0         0 my $exif = $self->[$index]->{exif};
317 0   0     0 my $aperture = $exif->{Aperture} || 1.0;
318 0   0     0 my $et = $exif->{ExposureTime} || $exif->{ShutterSpeed} || 1.0;
319 0 0       0 if ($et =~ /^1\/([0-9]+)$/) {$et = 1 / $1};
  0         0  
320 0   0     0 my $iso = $exif->{ISO} || 100;
321             # (A light value of 0 is defined as f/1.0 at 1 second with ISO 100)
322 0         0 return sprintf ('%.3f', (2*log ($aperture) - log($et) - log($iso/100)) / log(2));
323             }
324              
325             =item AverageRGB
326              
327             Find the mean average colour balance for all photos:
328              
329             ($red, $green, $blue) = $photos->AverageRGB;
330              
331             =back
332              
333             =cut
334              
335             sub AverageRGB
336             {
337 3     3 1 7 my $self = shift;
338 3         6 my $RedBalance = 0;
339 3         5 my $GreenBalance = 0;
340 3         4 my $BlueBalance = 0;
341 3         4 my $count = 0;
342 3         4 for my $image (@{$self})
  3         5  
343             {
344 24 100 100     59 next unless ($image->{exif}->{'RedBalance'} and $image->{exif}->{'BlueBalance'});
345 2         4 $RedBalance += $image->{exif}->{'RedBalance'};
346 2 50       8 $GreenBalance += $image->{exif}->{'GreenBalance'} if defined $image->{exif}->{'GreenBalance'};
347 2         28 $BlueBalance += $image->{exif}->{'BlueBalance'};
348 2         4 $count++;
349             }
350 3 100       17 return (1,1,1) unless $count;
351 1         20 return ($RedBalance / $count, $GreenBalance / $count, $BlueBalance / $count);
352             }
353              
354             1;