source: josm/trunk/src/org/openstreetmap/josm/gui/layer/geoimage/GeoImageLayer.java@ 2627

Last change on this file since 2627 was 2627, checked in by bastiK, 14 years ago

geoimage: new button 'delelet image from disk' (shortcut: Ctrl+Shift+Del)

  • geoimage: shortcut for 'remove from layer' (Shift+Del)
  • ExtendedDialog: • option to choose the default button (reacts to ENTER)
    • option to better handle 'don't show again'
  • Property svn:eol-style set to native
File size: 24.7 KB
Line 
1// License: GPL. See LICENSE file for details.
2// Copyright 2007 by Christian Gallioz (aka khris78)
3// Parts of code from Geotagged plugin (by Rob Neild)
4// and the core JOSM source code (by Immanuel Scholz and others)
5
6package org.openstreetmap.josm.gui.layer.geoimage;
7
8import static org.openstreetmap.josm.tools.I18n.tr;
9import static org.openstreetmap.josm.tools.I18n.trn;
10
11import java.awt.AlphaComposite;
12import java.awt.BorderLayout;
13import java.awt.Color;
14import java.awt.Component;
15import java.awt.Composite;
16import java.awt.Dimension;
17import java.awt.Graphics2D;
18import java.awt.Image;
19import java.awt.Point;
20import java.awt.Rectangle;
21import java.awt.event.MouseAdapter;
22import java.awt.event.MouseEvent;
23import java.awt.image.BufferedImage;
24import java.beans.PropertyChangeEvent;
25import java.beans.PropertyChangeListener;
26import java.io.File;
27import java.io.IOException;
28import java.text.ParseException;
29import java.util.ArrayList;
30import java.util.Arrays;
31import java.util.Collection;
32import java.util.Collections;
33import java.util.Date;
34import java.util.HashSet;
35import java.util.LinkedHashSet;
36import java.util.List;
37
38import javax.swing.Icon;
39import javax.swing.JLabel;
40import javax.swing.JMenuItem;
41import javax.swing.JOptionPane;
42import javax.swing.JPanel;
43import javax.swing.JSeparator;
44import javax.swing.SwingConstants;
45
46import org.openstreetmap.josm.Main;
47import org.openstreetmap.josm.actions.RenameLayerAction;
48import org.openstreetmap.josm.data.Bounds;
49import org.openstreetmap.josm.data.coor.CachedLatLon;
50import org.openstreetmap.josm.data.coor.LatLon;
51import org.openstreetmap.josm.data.osm.visitor.BoundingXYVisitor;
52import org.openstreetmap.josm.gui.ExtendedDialog;
53import org.openstreetmap.josm.gui.MapView;
54import org.openstreetmap.josm.gui.PleaseWaitRunnable;
55import org.openstreetmap.josm.gui.MapView.LayerChangeListener;
56import org.openstreetmap.josm.gui.dialogs.LayerListDialog;
57import org.openstreetmap.josm.gui.layer.GpxLayer;
58import org.openstreetmap.josm.gui.layer.Layer;
59import org.openstreetmap.josm.tools.ExifReader;
60import org.openstreetmap.josm.tools.ImageProvider;
61
62import com.drew.imaging.jpeg.JpegMetadataReader;
63import com.drew.lang.Rational;
64import com.drew.metadata.Directory;
65import com.drew.metadata.Metadata;
66import com.drew.metadata.exif.GpsDirectory;
67
68public class GeoImageLayer extends Layer implements PropertyChangeListener {
69
70 List<ImageEntry> data;
71
72 private Icon icon = ImageProvider.get("dialogs/geoimage/photo-marker");
73 private Icon selectedIcon = ImageProvider.get("dialogs/geoimage/photo-marker-selected");
74
75 private int currentPhoto = -1;
76
77 // These are used by the auto-guess function to store the result,
78 // so when the dialig is re-opened the users modifications don't
79 // get overwritten
80 public boolean hasTimeoffset = false;
81 public long timeoffset = 0;
82
83 boolean useThumbs = false;
84 ThumbsLoader thumbsloader;
85 private BufferedImage offscreenBuffer;
86 boolean updateOffscreenBuffer = true;
87
88 /*
89 * Stores info about each image
90 */
91
92 static final class ImageEntry implements Comparable<ImageEntry> {
93 File file;
94 Date time;
95 LatLon exifCoor;
96 CachedLatLon pos;
97 Image thumbnail;
98 /** Speed in kilometer per second */
99 Double speed;
100 /** Elevation (altitude) in meters */
101 Double elevation;
102
103 public void setCoor(LatLon latlon)
104 {
105 pos = new CachedLatLon(latlon);
106 }
107 public int compareTo(ImageEntry image) {
108 if (time != null && image.time != null)
109 return time.compareTo(image.time);
110 else if (time == null && image.time == null)
111 return 0;
112 else if (time == null)
113 return -1;
114 else
115 return 1;
116 }
117 }
118
119 /** Loads a set of images, while displaying a dialog that indicates what the plugin is currently doing.
120 * In facts, this object is instantiated with a list of files. These files may be JPEG files or
121 * directories. In case of directories, they are scanned to find all the images they contain.
122 * Then all the images that have be found are loaded as ImageEntry instances.
123 */
124 private static final class Loader extends PleaseWaitRunnable {
125
126 private boolean cancelled = false;
127 private GeoImageLayer layer;
128 private Collection<File> selection;
129 private HashSet<String> loadedDirectories = new HashSet<String>();
130 private LinkedHashSet<String> errorMessages;
131
132 protected void rememberError(String message) {
133 this.errorMessages.add(message);
134 }
135
136 public Loader(Collection<File> selection, GpxLayer gpxLayer) {
137 super(tr("Extracting GPS locations from EXIF"));
138 this.selection = selection;
139 errorMessages = new LinkedHashSet<String>();
140 }
141
142 @Override protected void realRun() throws IOException {
143
144 progressMonitor.subTask(tr("Starting directory scan"));
145 Collection<File> files = new ArrayList<File>();
146 try {
147 addRecursiveFiles(files, selection);
148 } catch(NullPointerException npe) {
149 rememberError(tr("One of the selected files was null"));
150 }
151
152 if (cancelled)
153 return;
154 progressMonitor.subTask(tr("Read photos..."));
155 progressMonitor.setTicksCount(files.size());
156
157 progressMonitor.subTask(tr("Read photos..."));
158 progressMonitor.setTicksCount(files.size());
159
160 // read the image files
161 List<ImageEntry> data = new ArrayList<ImageEntry>(files.size());
162
163 for (File f : files) {
164
165 if (cancelled) {
166 break;
167 }
168
169 progressMonitor.subTask(tr("Reading {0}...", f.getName()));
170 progressMonitor.worked(1);
171
172 ImageEntry e = new ImageEntry();
173
174 // Changed to silently cope with no time info in exif. One case
175 // of person having time that couldn't be parsed, but valid GPS info
176
177 try {
178 e.time = ExifReader.readTime(f);
179 } catch (ParseException e1) {
180 e.time = null;
181 }
182 e.file = f;
183 extractExif(e);
184 data.add(e);
185 }
186 layer = new GeoImageLayer(data);
187 files.clear();
188 }
189
190 private void addRecursiveFiles(Collection<File> files, Collection<File> sel) {
191 boolean nullFile = false;
192
193 for (File f : sel) {
194
195 if(cancelled) {
196 break;
197 }
198
199 if (f == null) {
200 nullFile = true;
201
202 } else if (f.isDirectory()) {
203 String canonical = null;
204 try {
205 canonical = f.getCanonicalPath();
206 } catch (IOException e) {
207 e.printStackTrace();
208 rememberError(tr("Unable to get canonical path for directory {0}\n",
209 f.getAbsolutePath()));
210 }
211
212 if (canonical == null || loadedDirectories.contains(canonical)) {
213 continue;
214 } else {
215 loadedDirectories.add(canonical);
216 }
217
218 Collection<File> children = Arrays.asList(f.listFiles(JpegFileFilter.getInstance()));
219 if (children != null) {
220 progressMonitor.subTask(tr("Scanning directory {0}", f.getPath()));
221 try {
222 addRecursiveFiles(files, children);
223 } catch(NullPointerException npe) {
224 npe.printStackTrace();
225 rememberError(tr("Found null file in directory {0}\n", f.getPath()));
226 }
227 } else {
228 rememberError(tr("Error while getting files from directory {0}\n", f.getPath()));
229 }
230
231 } else {
232 files.add(f);
233 }
234 }
235
236 if (nullFile)
237 throw new NullPointerException();
238 }
239
240 protected String formatErrorMessages() {
241 StringBuffer sb = new StringBuffer();
242 sb.append("<html>");
243 if (errorMessages.size() == 1) {
244 sb.append(errorMessages.iterator().next());
245 } else {
246 sb.append("<ul>");
247 for (String msg: errorMessages) {
248 sb.append("<li>").append(msg).append("</li>");
249 }
250 sb.append("/ul>");
251 }
252 sb.append("</html>");
253 return sb.toString();
254 }
255
256 @Override protected void finish() {
257 if (!errorMessages.isEmpty()) {
258 JOptionPane.showMessageDialog(
259 Main.parent,
260 formatErrorMessages(),
261 tr("Error"),
262 JOptionPane.ERROR_MESSAGE
263 );
264 }
265 if (layer != null) {
266 Main.main.addLayer(layer);
267 layer.hook_up_mouse_events(); // Main.map.mapView should exist
268 // now. Can add mouse listener
269
270 if (! cancelled && layer.data.size() > 0) {
271 boolean noGeotagFound = true;
272 for (ImageEntry e : layer.data) {
273 if (e.pos != null) {
274 noGeotagFound = false;
275 }
276 }
277 if (noGeotagFound) {
278 new CorrelateGpxWithImages(layer).actionPerformed(null);
279 }
280 }
281 }
282 }
283
284 @Override protected void cancel() {
285 cancelled = true;
286 }
287 }
288
289 private static boolean addedToggleDialog = false;
290
291 public static void create(Collection<File> files, GpxLayer gpxLayer) {
292 Loader loader = new Loader(files, gpxLayer);
293 Main.worker.execute(loader);
294 if (!addedToggleDialog) {
295 Main.map.addToggleDialog(ImageViewerDialog.getInstance());
296 addedToggleDialog = true;
297 }
298 }
299
300 private GeoImageLayer(final List<ImageEntry> data) {
301
302 super(tr("Geotagged Images"));
303
304 Collections.sort(data);
305 this.data = data;
306 Main.map.mapView.addPropertyChangeListener(this);
307 }
308
309 @Override
310 public Icon getIcon() {
311 return ImageProvider.get("dialogs/geoimage");
312 }
313
314 @Override
315 public Object getInfoComponent() {
316 // TODO Auto-generated method stub
317 return null;
318 }
319
320 @Override
321 public Component[] getMenuEntries() {
322
323 JMenuItem correlateItem = new JMenuItem(tr("Correlate to GPX"), ImageProvider.get("dialogs/geoimage/gpx2img"));
324 correlateItem.addActionListener(new CorrelateGpxWithImages(this));
325
326 return new Component[] {
327 new JMenuItem(LayerListDialog.getInstance().createShowHideLayerAction(this)),
328 new JMenuItem(LayerListDialog.getInstance().createDeleteLayerAction(this)),
329 new JMenuItem(new RenameLayerAction(null, this)),
330 new JSeparator(),
331 correlateItem
332 };
333 }
334
335 @Override
336 public String getToolTipText() {
337 int i = 0;
338 for (ImageEntry e : data)
339 if (e.pos != null) {
340 i++;
341 }
342 return data.size() + " " + trn("image", "images", data.size())
343 + " loaded. " + tr("{0} were found to be gps tagged.", i);
344 }
345
346 @Override
347 public boolean isMergable(Layer other) {
348 return other instanceof GeoImageLayer;
349 }
350
351 @Override
352 public void mergeFrom(Layer from) {
353 GeoImageLayer l = (GeoImageLayer) from;
354
355 ImageEntry selected = null;
356 if (l.currentPhoto >= 0) {
357 selected = l.data.get(l.currentPhoto);
358 }
359
360 data.addAll(l.data);
361 Collections.sort(data);
362
363 // Supress the double photos.
364 if (data.size() > 1) {
365 ImageEntry cur;
366 ImageEntry prev = data.get(data.size() - 1);
367 for (int i = data.size() - 2; i >= 0; i--) {
368 cur = data.get(i);
369 if (cur.file.equals(prev.file)) {
370 data.remove(i);
371 } else {
372 prev = cur;
373 }
374 }
375 }
376
377 if (selected != null) {
378 for (int i = 0; i < data.size() ; i++) {
379 if (data.get(i) == selected) {
380 currentPhoto = i;
381 ImageViewerDialog.showImage(GeoImageLayer.this, data.get(i));
382 break;
383 }
384 }
385 }
386
387 setName(l.getName());
388 }
389
390 private Dimension scaledDimension(Image thumb) {
391 final double d = Main.map.mapView.getDist100Pixel();
392 final double size = 40 /*meter*/; /* size of the photo on the map */
393 double s = size * 100 /*px*/ / d;
394
395 final double sMin = ThumbsLoader.minSize;
396 final double sMax = ThumbsLoader.maxSize;
397
398 if (s < sMin) {
399 s = sMin;
400 }
401 if (s > sMax) {
402 s = sMax;
403 }
404 final double f = s / sMax; /* scale factor */
405
406 if (thumb == null)
407 return null;
408
409 return new Dimension(
410 (int) Math.round(f * thumb.getWidth(null)),
411 (int) Math.round(f * thumb.getHeight(null)));
412 }
413
414 @Override
415 public void paint(Graphics2D g, MapView mv, Bounds bounds) {
416 int width = Main.map.mapView.getWidth();
417 int height = Main.map.mapView.getHeight();
418 Rectangle clip = g.getClipBounds();
419 if (useThumbs) {
420 if (null == offscreenBuffer || offscreenBuffer.getWidth() != width // reuse the old buffer if possible
421 || offscreenBuffer.getHeight() != height) {
422 offscreenBuffer = new BufferedImage(width, height,
423 BufferedImage.TYPE_INT_ARGB);
424 updateOffscreenBuffer = true;
425 }
426
427 if (updateOffscreenBuffer) {
428 Graphics2D tempG = offscreenBuffer.createGraphics();
429 tempG.setColor(new Color(0,0,0,0));
430 Composite saveComp = tempG.getComposite();
431 tempG.setComposite(AlphaComposite.Clear); // remove the old images
432 tempG.fillRect(0, 0, width, height);
433 tempG.setComposite(saveComp);
434
435 for (ImageEntry e : data) {
436 if (e.pos == null) {
437 continue;
438 }
439 Point p = mv.getPoint(e.pos);
440 if (e.thumbnail != null) {
441 Dimension d = scaledDimension(e.thumbnail);
442 Rectangle target = new Rectangle(p.x - d.width / 2, p.y - d.height / 2, d.width, d.height);
443 if (clip.intersects(target)) {
444 tempG.drawImage(e.thumbnail, target.x, target.y, target.width, target.height, null);
445 }
446 }
447 else { // thumbnail not loaded yet
448 icon.paintIcon(mv, tempG,
449 p.x - icon.getIconWidth() / 2,
450 p.y - icon.getIconHeight() / 2);
451 }
452 }
453 updateOffscreenBuffer = false;
454 }
455 g.drawImage(offscreenBuffer, 0, 0, null);
456 }
457 else {
458 for (ImageEntry e : data) {
459 if (e.pos == null) {
460 continue;
461 }
462 Point p = mv.getPoint(e.pos);
463 icon.paintIcon(mv, g,
464 p.x - icon.getIconWidth() / 2,
465 p.y - icon.getIconHeight() / 2);
466 }
467 }
468
469 if (currentPhoto >= 0 && currentPhoto < data.size()) {
470 ImageEntry e = data.get(currentPhoto);
471
472 if (e.pos != null) {
473 Point p = mv.getPoint(e.pos);
474
475 if (e.thumbnail != null) {
476 Dimension d = scaledDimension(e.thumbnail);
477 g.setColor(new Color(128, 0, 0, 122));
478 g.fillRect(p.x - d.width / 2, p.y - d.height / 2, d.width, d.height);
479 } else {
480 selectedIcon.paintIcon(mv, g,
481 p.x - selectedIcon.getIconWidth() / 2,
482 p.y - selectedIcon.getIconHeight() / 2);
483 }
484 }
485 }
486 }
487
488 @Override
489 public void visitBoundingBox(BoundingXYVisitor v) {
490 for (ImageEntry e : data) {
491 v.visit(e.pos);
492 }
493 }
494
495 /*
496 * Extract gps from image exif
497 *
498 * If successful, fills in the LatLon and EastNorth attributes of passed in
499 * image;
500 */
501
502 private static void extractExif(ImageEntry e) {
503
504 try {
505 int deg;
506 float min, sec;
507 double lon, lat;
508
509 Metadata metadata = JpegMetadataReader.readMetadata(e.file);
510 Directory dir = metadata.getDirectory(GpsDirectory.class);
511
512 // longitude
513
514 Rational[] components = dir
515 .getRationalArray(GpsDirectory.TAG_GPS_LONGITUDE);
516
517 deg = components[0].intValue();
518 min = components[1].floatValue();
519 sec = components[2].floatValue();
520
521 lon = (deg + (min / 60) + (sec / 3600));
522
523 if (dir.getString(GpsDirectory.TAG_GPS_LONGITUDE_REF).charAt(0) == 'W') {
524 lon = -lon;
525 }
526
527 // latitude
528
529 components = dir.getRationalArray(GpsDirectory.TAG_GPS_LATITUDE);
530
531 deg = components[0].intValue();
532 min = components[1].floatValue();
533 sec = components[2].floatValue();
534
535 lat = (deg + (min / 60) + (sec / 3600));
536
537 if (dir.getString(GpsDirectory.TAG_GPS_LATITUDE_REF).charAt(0) == 'S') {
538 lat = -lat;
539 }
540
541 // Store values
542
543 e.setCoor(new LatLon(lat, lon));
544 e.exifCoor = e.pos;
545
546 } catch (Exception p) {
547 e.pos = null;
548 }
549 }
550
551 public void showNextPhoto() {
552 if (data != null && data.size() > 0) {
553 currentPhoto++;
554 if (currentPhoto >= data.size()) {
555 currentPhoto = data.size() - 1;
556 }
557 ImageViewerDialog.showImage(this, data.get(currentPhoto));
558 } else {
559 currentPhoto = -1;
560 }
561 Main.main.map.repaint();
562 }
563
564 public void showPreviousPhoto() {
565 if (data != null && data.size() > 0) {
566 currentPhoto--;
567 if (currentPhoto < 0) {
568 currentPhoto = 0;
569 }
570 ImageViewerDialog.showImage(this, data.get(currentPhoto));
571 } else {
572 currentPhoto = -1;
573 }
574 Main.main.map.repaint();
575 }
576
577 public void checkPreviousNextButtons() {
578 // System.err.println("showing image " + currentPhoto);
579 ImageViewerDialog.setNextEnabled(currentPhoto < data.size() - 1);
580 ImageViewerDialog.setPreviousEnabled(currentPhoto > 0);
581 }
582
583 public void removeCurrentPhoto() {
584 if (data != null && data.size() > 0 && currentPhoto >= 0 && currentPhoto < data.size()) {
585 data.remove(currentPhoto);
586 if (currentPhoto >= data.size()) {
587 currentPhoto = data.size() - 1;
588 }
589 if (currentPhoto >= 0) {
590 ImageViewerDialog.showImage(this, data.get(currentPhoto));
591 } else {
592 ImageViewerDialog.showImage(this, null);
593 }
594 updateOffscreenBuffer = true;
595 Main.main.map.repaint();
596 }
597 }
598
599 public void removeCurrentPhotoFromDisk() {
600 ImageEntry toDelete = null;
601 if (data != null && data.size() > 0 && currentPhoto >= 0 && currentPhoto < data.size()) {
602 toDelete = data.get(currentPhoto);
603
604 int result = new ExtendedDialog(
605 Main.parent,
606 tr("Delete image file from disk"),
607 new String[] {tr("Cancel"), tr("Delete")})
608 .setButtonIcons(new String[] {"cancel.png", "dialogs/delete.png"})
609 .setContent(new JLabel(tr("<html><h3>Delete the file {0} from the disk?<p>The image file will be permanently lost!"
610 ,toDelete.file.getName()), ImageProvider.get("dialogs/geoimage/deletefromdisk"),SwingConstants.LEFT))
611 .toggleEnable("geoimage.deleteimagefromdisk")
612 .setToggleCheckboxText(tr("Always delete and don't show this dialog again"))
613 .setCancelButton(1)
614 .setDefaultButton(2)
615 .showDialog()
616 .getValue();
617
618 if(result == 2 || result == ExtendedDialog.DialogNotShown)
619 {
620 data.remove(currentPhoto);
621 if (currentPhoto >= data.size()) {
622 currentPhoto = data.size() - 1;
623 }
624 if (currentPhoto >= 0) {
625 ImageViewerDialog.showImage(this, data.get(currentPhoto));
626 } else {
627 ImageViewerDialog.showImage(this, null);
628 }
629
630 if (toDelete.file.delete()) {
631 System.out.println("File "+toDelete.file.toString()+" deleted. ");
632 } else {
633 JOptionPane.showMessageDialog(
634 Main.parent,
635 tr("Image file could not be deleted."),
636 tr("Error"),
637 JOptionPane.ERROR_MESSAGE
638 );
639 }
640
641 updateOffscreenBuffer = true;
642 Main.main.map.repaint();
643 }
644 }
645 }
646
647 private MouseAdapter mouseAdapter = null;
648
649 private void hook_up_mouse_events() {
650 mouseAdapter = new MouseAdapter() {
651 @Override public void mousePressed(MouseEvent e) {
652
653 if (e.getButton() != MouseEvent.BUTTON1)
654 return;
655 if (isVisible()) {
656 Main.map.mapView.repaint();
657 }
658 }
659
660 @Override public void mouseReleased(MouseEvent ev) {
661 if (ev.getButton() != MouseEvent.BUTTON1)
662 return;
663 if (!isVisible())
664 return;
665
666 for (int i = data.size() - 1; i >= 0; --i) {
667 ImageEntry e = data.get(i);
668 if (e.pos == null) {
669 continue;
670 }
671 Point p = Main.map.mapView.getPoint(e.pos);
672 Rectangle r;
673 if (e.thumbnail != null) {
674 Dimension d = scaledDimension(e.thumbnail);
675 r = new Rectangle(p.x - d.width / 2, p.y - d.height / 2, d.width, d.height);
676 } else {
677 r = new Rectangle(p.x - icon.getIconWidth() / 2,
678 p.y - icon.getIconHeight() / 2,
679 icon.getIconWidth(),
680 icon.getIconHeight());
681 }
682 if (r.contains(ev.getPoint())) {
683 currentPhoto = i;
684 ImageViewerDialog.showImage(GeoImageLayer.this, e);
685 Main.main.map.repaint();
686 break;
687 }
688 }
689 }
690 };
691 Main.map.mapView.addMouseListener(mouseAdapter);
692 MapView.addLayerChangeListener(new LayerChangeListener() {
693 public void activeLayerChange(Layer oldLayer, Layer newLayer) {
694 if (newLayer == GeoImageLayer.this && currentPhoto >= 0) {
695 Main.main.map.repaint();
696 ImageViewerDialog.showImage(GeoImageLayer.this, data.get(currentPhoto));
697 }
698 }
699
700 public void layerAdded(Layer newLayer) {
701 }
702
703 public void layerRemoved(Layer oldLayer) {
704 if (oldLayer == GeoImageLayer.this) {
705 if (thumbsloader != null) {
706 thumbsloader.stop = true;
707 }
708 Main.map.mapView.removeMouseListener(mouseAdapter);
709 currentPhoto = -1;
710 data.clear();
711 data = null;
712 // stop listening to layer change events
713 MapView.removeLayerChangeListener(this);
714 }
715 }
716 });
717 }
718
719 public void propertyChange(PropertyChangeEvent evt) {
720 if ("center".equals(evt.getPropertyName()) || "scale".equals(evt.getPropertyName())) {
721 updateOffscreenBuffer = true;
722 }
723 }
724}
Note: See TracBrowser for help on using the repository browser.