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

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

geoimage: usability - allow clicking on the image symbols only when in select mode

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