File Coverage

lib/Panotools/Photos.pm
Criterion Covered Total %
statement 119 145 82.0
branch 35 62 56.4
condition 13 36 36.1
subroutine 15 18 83.3
pod 3 13 23.0
total 185 274 67.5


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