diff --git a/MAVProxy/tools/mavpicviewer/mavpicviewer.bat b/MAVProxy/tools/mavpicviewer/mavpicviewer.bat new file mode 100644 index 0000000000..8e6c43896e --- /dev/null +++ b/MAVProxy/tools/mavpicviewer/mavpicviewer.bat @@ -0,0 +1,4 @@ +cd ..\..\ +python setup.py build install --user +python .\MAVProxy\tools\mavpicviewer\mavpicviewer.py +pause diff --git a/MAVProxy/tools/mavpicviewer/mavpicviewer.py b/MAVProxy/tools/mavpicviewer/mavpicviewer.py new file mode 100644 index 0000000000..f748516a0b --- /dev/null +++ b/MAVProxy/tools/mavpicviewer/mavpicviewer.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 + +''' +MAV Picture Viewer + +Quick and efficient reviewing of images taken from a drone + +AP_FLAKE8_CLEAN +''' + +import os +from argparse import ArgumentParser +from pathlib import Path +from MAVProxy.modules.lib import multiproc +import picviewer_window + +prefix_str = "mavpicviewer: " + + +class mavpicviewer(): + + # constructor + def __init__(self): + self.picviewer_window = None + + # display dialog to open a folder + def cmd_openfolder(self, folderpath): + file_list = self.file_list(folderpath, ['jpg', 'jpeg']) + if file_list is None or not file_list: + print("picviewer: no files found") + return + self.picviewer_window = picviewer_window.picviewer_window(file_list) + + # open picture viewer to display a single file + def cmd_openfile(self, filepath): + # check file exists + if not Path(filepath).exists(): + print("picviewer: %s not found" % filepath) + return + filelist = [] + filelist.append(filepath) + self.picviewer_window = picviewer_window.picviewer_window(filelist) + + # return an array of files for a given directory and extension + def file_list(self, directory, extensions): + '''return file list for a directory''' + flist = [] + for filename in os.listdir(directory): + extension = filename.split('.')[-1] + if extension.lower() in extensions: + flist.append(os.path.join(directory, filename)) + sorted_list = sorted(flist, key=str.lower) + return sorted_list + + +# main function +if __name__ == "__main__": + multiproc.freeze_support() + parser = ArgumentParser(description=__doc__) + parser.add_argument("filepath", default=".", help="filename or directory holding images") + args = parser.parse_args() + + # check destination directory exists + if not os.path.exists(args.filepath): + exit(prefix_str + "invalid destination directory") + + # check if file or directory + if os.path.isfile(args.filepath): + picviewer = mavpicviewer() + picviewer.cmd_openfile(args.filepath) + else: + picviewer = mavpicviewer() + picviewer.cmd_openfolder(args.filepath) diff --git a/MAVProxy/tools/mavpicviewer/mosaic_window.py b/MAVProxy/tools/mavpicviewer/mosaic_window.py new file mode 100644 index 0000000000..488e6c538e --- /dev/null +++ b/MAVProxy/tools/mavpicviewer/mosaic_window.py @@ -0,0 +1,195 @@ +#!/usr/bin/env python3 + +''' +Picture Viewer Window + +Displays a window for users to review a collection of images quickly + +AP_FLAKE8_CLEAN +''' + +from threading import Thread +from math import ceil +import cv2 +import time +import os +import numpy as np +from MAVProxy.modules.lib import mp_util +if mp_util.has_wxpython: + from MAVProxy.modules.lib.mp_menu import MPMenuTop + from MAVProxy.modules.lib.mp_menu import MPMenuItem + from MAVProxy.modules.lib.mp_menu import MPMenuSubMenu + from MAVProxy.modules.lib.mp_image import MPImage + from MAVProxy.modules.lib.mp_menu import MPMenuCallDirDialog + + +class mosaic_window: + """displays a mosaic of images""" + + def __init__(self, filelist): + + # determine if filelist is a string or a list of strings + self.filenumber = 0 + if type(filelist) is str: + self.filelist = [] + self.filelist.append(filelist) + else: + # use the first item in the list + self.filelist = filelist + + # hardcoded thumbnail image size and number of columns + self.thumb_size = 100 + self.thumb_columns = 5 + self.thumb_rows = ceil(len(filelist) / self.thumb_columns) + + # create image viewer + self.im = None + self.update_image() + + # create menu + self.menu = None + if mp_util.has_wxpython: + self.menu = MPMenuTop([MPMenuSubMenu('&File', items=[MPMenuItem(name='&Open\tCtrl+O', returnkey='openfolder', + handler=MPMenuCallDirDialog(title='Open Folder')), + MPMenuItem('&Save\tCtrl+S'), + MPMenuItem('Close', 'Close'), + MPMenuItem('&Quit\tCtrl+Q', 'Quit')])]) + self.im.set_menu(self.menu) + + popup = self.im.get_popup_menu() + popup.add_to_submenu(["Mode"], MPMenuItem("ClickTrack", returnkey="Mode:ClickTrack")) + popup.add_to_submenu(["Mode"], MPMenuItem("Flag", returnkey="Mode:Flag")) + + self.thread = Thread(target=self.mosaic_window_loop, name='mosaic_window_loop') + self.thread.daemon = False + self.thread.start() + + # main loop + def mosaic_window_loop(self): + """main thread""" + while True: + if self.im is None: + break + time.sleep(0.25) + self.check_events() + + # set window title + def set_title(self, title): + """set image title""" + if self.im is None: + return + self.im.set_title(title) + + # process window events + def check_events(self): + """check for image events""" + if self.im is None: + return + if not self.im.is_alive(): + self.im = None + return + for event in self.im.events(): + if isinstance(event, MPMenuItem): + if event.returnkey == "openfolder": + self.cmd_openfolder() + elif event.returnkey == "fitWindow": + print("fitting to window") + self.im.fit_to_window() + elif event.returnkey == "fullSize": + print("full size") + self.im.full_size() + elif event.returnkey == "nextimage": + self.cmd_nextimage() + elif event.returnkey == "previmage": + self.cmd_previmage() + else: + debug_str = "event: %s" % event + self.set_title(debug_str) + continue + if event.ClassName == "wxMouseEvent": + if event.X is not None and event.Y is not None: + print("mosaic pixel x:%f y:%f" % (event.X, event.Y)) + + # display dialog to open a folder + def cmd_openfolder(self): + print("I will open a folder") + + # display dialog to open a file + def cmd_openfile(self): + print("I will open a file") + + # update current image to next image + def cmd_nextimage(self): + if self.filenumber >= len(self.filelist)-1: + print("picviewer: already at last image %d" % self.filenumber) + return + self.filenumber = self.filenumber+1 + self.update_image() + + # update current image to previous image + def cmd_previmage(self): + if self.filenumber <= 0: + print("picviewer: already at first image") + return + self.filenumber = self.filenumber - 1 + self.update_image() + + # update the mosaic of images + # should be called if filenumber is changed + def update_image(self): + # update filename + self.filename = self.filelist[self.filenumber] + base_filename = os.path.basename(self.filename) + + # create image viewer if required + if self.im is None: + self.im = MPImage(title=base_filename, + mouse_events=True, + mouse_movement_events=True, + key_events=True, + can_drag=True, + can_zoom=False, + auto_size=False, + auto_fit=False) + + # check if image viewer was created + if self.im is None: + print("picviewer: failed to create image viewer") + return + + # set title to filename + self.set_title("Mosaic " + base_filename) + + # create blank image + temp_image = cv2.imread(self.filename) + h, w, c = temp_image.shape + mosaic_image = 255 * np.ones(shape=(self.thumb_rows * self.thumb_size, + self.thumb_columns * self.thumb_size, c), + dtype=np.uint8) + + # iterate through images and add thumbnails to mosaic + row = 0 + col = 0 + for i in range(len(self.filelist)): + image_filename = self.filelist[i] + image = cv2.imread(image_filename) + image_small = cv2.resize(image, (self.thumb_size, self.thumb_size), interpolation=cv2.INTER_AREA) + self.overlay_image(mosaic_image, image_small, col * self.thumb_size, row * self.thumb_size) + col = col + 1 + if col >= self.thumb_columns: + col = 0 + row = row + 1 + + # update image and colormap + self.im.set_image(mosaic_image) + self.im.set_colormap("None") + + def overlay_image(self, img, img2, x, y): + '''overlay a 2nd image on a first image, at position x,y on the first image''' + (img_width, img_height) = self.image_shape(img2) + img[y:y+img_height, x:x+img_width] = img2 + + def image_shape(self, img): + '''return (w,h) of an image, coping with different image formats''' + height, width = img.shape[:2] + return (width, height) diff --git a/MAVProxy/tools/mavpicviewer/picviewer_window.py b/MAVProxy/tools/mavpicviewer/picviewer_window.py new file mode 100644 index 0000000000..b0bef2d936 --- /dev/null +++ b/MAVProxy/tools/mavpicviewer/picviewer_window.py @@ -0,0 +1,370 @@ +#!/usr/bin/env python3 + +''' +MAV Picture Viewer + +Quick and efficient reviewing of images taken from a drone + +AP_FLAKE8_CLEAN +''' + +from threading import Thread +import cv2 +import time +import os +import piexif +import mosaic_window + +from MAVProxy.modules.lib import mp_util +from MAVProxy.modules.lib import mp_elevation + +if mp_util.has_wxpython: + from MAVProxy.modules.lib.mp_menu import MPMenuTop + from MAVProxy.modules.lib.mp_menu import MPMenuItem + from MAVProxy.modules.lib.mp_menu import MPMenuSubMenu + from MAVProxy.modules.lib.mp_image import MPImage + from MAVProxy.modules.mavproxy_map import mp_slipmap + from MAVProxy.modules.lib import camera_projection + from MAVProxy.modules.lib.mp_menu import MPMenuCallDirDialog + + +class picviewer_loc: + def __init__(self, lat, lon, alt): + self.lat = lat + self.lon = lon + self.alt = alt + + +class picviewer_pos: + def __init__(self, X, Y): + self.X = X + self.Y = Y + + +class picviewer_poi: + def __init__(self, pos1, pos2, loc1, loc2): + self.pos1 = pos1 + self.pos2 = pos2 + self.loc1 = loc1 + self.loc2 = loc2 + + +class picviewer_window: + """handle camera view image""" + + def __init__(self, filelist): + + # determine if filelist is a string or a list of strings + self.filenumber = 0 + if type(filelist) is str: + self.filelist = [] + self.filelist.append(filelist) + else: + # use the first item in the list + self.filelist = filelist + + # load elevation data + self.terrain_source = "SRTM3" + self.terrain_offline = 0 + self.elevation_model = mp_elevation.ElevationModel(self.terrain_source) + + # POIs indexed by filenumber + self.poi_dict = {} + self.poi_start_pos = None + + # create image viewer + self.im = None + self.update_image() + + # set camera parameters + self.cam1_params = camera_projection.CameraParams(xresolution=640, yresolution=512, FOV=36.9) + self.cam1_projection = camera_projection.CameraProjection(self.cam1_params, self.elevation_model, self.terrain_source) + + # hard-code mount angles + self.roll = 0 + self.pitch = -90 + self.yaw = 0 + + # display map with polygon + self.sm = None + self.update_map() + + # create mosaic of images + self.mosaic = mosaic_window.mosaic_window(self.filelist) + + # create menu + self.menu = None + if mp_util.has_wxpython: + self.menu = MPMenuTop([MPMenuSubMenu('&File', + items=[MPMenuItem(name='&Open\tCtrl+O', returnkey='openfolder', + handler=MPMenuCallDirDialog(title='Open Folder')), + MPMenuItem('&Save\tCtrl+S'), + MPMenuItem('Clea&R POI\tCtrl+R', returnkey='clearpoi'), + MPMenuItem('&Next Image\tCtrl+N', returnkey='nextimage'), + MPMenuItem('&Prev Image\tCtrl+P', returnkey='previmage'), + MPMenuItem('&Quit\tCtrl+Q', 'Quit')])]) + self.im.set_menu(self.menu) + + popup = self.im.get_popup_menu() + popup.add_to_submenu(["Mode"], MPMenuItem("ClickTrack", returnkey="Mode:ClickTrack")) + popup.add_to_submenu(["Mode"], MPMenuItem("Flag", returnkey="Mode:Flag")) + + self.thread = Thread(target=self.picviewer_window_loop, name='picviewer_window_loop') + self.thread.daemon = False + self.thread.start() + + # main loop + def picviewer_window_loop(self): + """main thread""" + while True: + if self.im is None: + break + time.sleep(0.25) + self.check_events() + + # set window title + def set_title(self, title): + """set image title""" + if self.im is None: + return + self.im.set_title(title) + + # process window events + def check_events(self): + """check for image events""" + if self.im is None: + return + if not self.im.is_alive(): + self.im = None + return + for event in self.im.events(): + if isinstance(event, MPMenuItem): + if event.returnkey == "openfolder": + self.cmd_openfolder() + elif event.returnkey == "fitWindow": + print("fitting to window") + self.im.fit_to_window() + elif event.returnkey == "fullSize": + print("full size") + self.im.full_size() + elif event.returnkey == "clearpoi": + self.cmd_clear_poi() + elif event.returnkey == "nextimage": + self.cmd_nextimage() + elif event.returnkey == "previmage": + self.cmd_previmage() + else: + debug_str = "event: %s" % event + self.set_title(debug_str) + continue + if event.ClassName == "wxMouseEvent": + if event.shiftDown: + print("shift down") + if event.X is not None and event.Y is not None: + if event.leftIsDown: + self.poi_capture_start(event.X, event.Y) + else: + self.poi_capture_done(event.X, event.Y) + else: + # if no X,Y coordinates then probably outside of window + self.poi_cancel() + + # display dialog to open a folder + def cmd_openfolder(self): + print("I will open a folder") + + # display dialog to open a file + def cmd_openfile(self): + print("I will open a file") + + # start capturing POI rectangle around part of image + def poi_capture_start(self, X, Y): + """handle user request to start capturing box around part of image""" + if self.poi_start_pos is None: + self.poi_start_pos = picviewer_pos(X, Y) + + # complete capturing box around part of image + def poi_capture_done(self, X, Y): + """handle user request to complete capturing box around part of image""" + if self.poi_start_pos is not None: + # exit if mouse has not moved a sufficient distance + if abs(X - self.poi_start_pos.X) <= 5 or abs(Y - self.poi_start_pos.Y) <= 5: + self.poi_start_pos = None + return + # calculate location of each corner + lat1, lon1, alt1 = self.get_latlonalt(self.poi_start_pos.X, self.poi_start_pos.Y) + lat2, lon2, alt2 = self.get_latlonalt(X, Y) + if lat1 is None or lat2 is None: + print("picviewer: POI capture failed") + return + # add POI object to dictionary + poi = picviewer_poi(self.poi_start_pos, picviewer_pos(X, Y), + picviewer_loc(lat1, lon1, alt1), + picviewer_loc(lat2, lon2, alt2)) + self.poi_dict[self.filenumber] = poi + lat_avg = (lat1 + lat2) / 2.0 + lon_avg = (lon1 + lon2) / 2.0 + alt_avg = (alt1 + alt2) / 2.0 + print("POI capture lat:%f lon:%f alt:%f" % (lat_avg, lon_avg, alt_avg)) + self.poi_start_pos = None + # update image + self.update_image() + # add retangle to map + self.add_rectangle_to_map(self.filename, lat1, lon1, lat2, lon2) + + # camcel capturing box around part of image. should be called if mouse leaves window, next image is loaded, etc + def poi_cancel(self): + self.poi_start_pos = None + + # clear poi from current image + def cmd_clear_poi(self): + self.poi_cancel() + if self.filenumber in self.poi_dict.keys(): + self.poi_dict.pop(self.filenumber) + self.update_image() + self.remove_rectangle_from_map(self.filename) + + # update current image to next image + def cmd_nextimage(self): + if self.filenumber >= len(self.filelist)-1: + print("picviewer: already at last image %d" % self.filenumber) + return + self.filenumber = self.filenumber + 1 + self.update_image() + self.update_map() + + # update current image to previous image + def cmd_previmage(self): + if self.filenumber <= 0: + print("picviewer: already at first image") + return + self.filenumber = self.filenumber - 1 + self.update_image() + self.update_map() + + # update the displayed image + # should be called if filenumber is changed + def update_image(self): + # update filename + self.filename = self.filelist[self.filenumber] + base_filename = os.path.basename(self.filename) + + # create image viewer if required + if self.im is None: + self.im = MPImage(title=base_filename, + mouse_events=True, + mouse_movement_events=True, + key_events=True, + can_drag=False, + can_zoom=True, + auto_size=False, + auto_fit=True) + + # check if image viewer was created + if self.im is None: + print("picviewer: failed to create image viewer") + return + + # set title to filename + title_str = base_filename + " (" + str(self.filenumber+1) + " of " + str(len(self.filelist)) + ")" + self.set_title(title_str) + + # load image from file + image = cv2.imread(self.filename) + + # add POI rectangles to image + if self.filenumber in self.poi_dict.keys(): + poi = self.poi_dict.get(self.filenumber) + cv2.rectangle(image, (poi.pos1.X, poi.pos1.Y), (poi.pos2.X, poi.pos2.Y), (255, 0, 0), 2) + + # update image and colormap + self.im.set_image(image) + self.im.set_colormap("None") + + # load exif data + self.lat, self.lon, self.alt_amsl, self.terr_alt = self.exif_location(self.filename) + + # update the displayed map with polygon + # should be called if filenumber is changed + def update_map(self): + # create and display map + if self.sm is None: + self.sm = mp_slipmap.MPSlipMap(lat=self.lat, lon=self.lon, elevation=self.terrain_source) + if self.sm is None: + print("picviewer: failed to create map") + return + + # update map center + self.sm.set_center(self.lat, self.lon) + + projection1 = self.cam1_projection.get_projection(self.lat, self.lon, self.alt_amsl, self.roll, self.pitch, self.yaw) + if projection1 is not None: + self.sm.add_object(mp_slipmap.SlipPolygon('projection1', projection1, layer=1, linewidth=2, colour=(0, 255, 0))) + else: + print("picviewer: failed to add projection to map") + + # add a rectangle specified by two locations to the map + def add_rectangle_to_map(self, name, lat1, lon1, lat2, lon2): + rect = [(lat1, lon1), (lat1, lon2), (lat2, lon2), (lat2, lon1), (lat1, lon1)] + self.sm.add_object(mp_slipmap.SlipPolygon(name, rect, layer=1, linewidth=2, colour=(255, 0, 0))) + + # remove a rectangle from the map + def remove_rectangle_from_map(self, name): + self.sm.remove_object(name) + + # get location (e.g lat, lon, alt, terr_alt) from image's exif tags + def exif_location(self, filename): + """get latitude, longitude, altitude and terrain_alt from exif tags""" + + exif_dict = piexif.load(filename) + + if piexif.GPSIFD.GPSLatitudeRef in exif_dict["GPS"]: + lat_ns = exif_dict["GPS"][piexif.GPSIFD.GPSLatitudeRef] + lat = self.dms_to_decimal(exif_dict["GPS"][piexif.GPSIFD.GPSLatitude][0], + exif_dict["GPS"][piexif.GPSIFD.GPSLatitude][1], + exif_dict["GPS"][piexif.GPSIFD.GPSLatitude][2], + lat_ns) + lon_ew = exif_dict["GPS"][piexif.GPSIFD.GPSLongitudeRef] + lon = self.dms_to_decimal(exif_dict["GPS"][piexif.GPSIFD.GPSLongitude][0], + exif_dict["GPS"][piexif.GPSIFD.GPSLongitude][1], + exif_dict["GPS"][piexif.GPSIFD.GPSLongitude][2], + lon_ew) + alt = float(exif_dict["GPS"][piexif.GPSIFD.GPSAltitude][0])/float(exif_dict["GPS"][piexif.GPSIFD.GPSAltitude][1]) + terr_alt = self.elevation_model.GetElevation(lat, lon) + if terr_alt is None: + print("WARNING: failed terrain lookup for %f %f" % (lat, lon)) + terr_alt = 0 + else: + lat = 0 + lon = 0 + alt = 0 + terr_alt = 0 + + return lat, lon, alt, terr_alt + + def dms_to_decimal(self, degrees, minutes, seconds, sign=b' '): + """Convert degrees, minutes, seconds into decimal degrees. + + >>> dms_to_decimal((10, 1), (10, 1), (10, 1)) + 10.169444444444444 + >>> dms_to_decimal((8, 1), (9, 1), (10, 1), 'S') + -8.152777777777779 + """ + return (-1 if sign in b'SWsw' else 1) * ( + float(degrees[0])/float(degrees[1]) + + float(minutes[0])/float(minutes[1]) / 60.0 + + float(seconds[0])/float(seconds[1]) / 3600.0 + ) + + def get_latlonalt(self, pixel_x, pixel_y): + ''' + get ground lat/lon given vehicle orientation, camera orientation and slant range + pixel_x and pixel_y are in image pixel coordinates with 0,0 at the top left + ''' + if self.cam1_params is None: + print("picviewer: failed to calc lat,lon because camera params not set") + return None + + return self.cam1_projection.get_latlonalt_for_pixel(int(pixel_x), int(pixel_y), + self.lat, self.lon, self.alt_amsl, + self.roll, self.pitch, self.yaw) diff --git a/shortcuts/mavpicviewer.desktop b/shortcuts/mavpicviewer.desktop new file mode 100644 index 0000000000..6668181db2 --- /dev/null +++ b/shortcuts/mavpicviewer.desktop @@ -0,0 +1,9 @@ +[Desktop Entry] +Version=1.0 +Type=Application +Terminal=true +Exec=bash -c 'export PATH="$PATH:~/.local/bin"; mavpicviewer/mavpicviewer.py %F' +Name=MAVPicViewer +Icon=mavpicviewer +Comment=Picture Review Tool + diff --git a/shortcuts/mavpicviewer.png b/shortcuts/mavpicviewer.png new file mode 100644 index 0000000000..8ca05fbc87 Binary files /dev/null and b/shortcuts/mavpicviewer.png differ diff --git a/windows/mavpicviewer.ico b/windows/mavpicviewer.ico new file mode 100644 index 0000000000..ff4dce2efe Binary files /dev/null and b/windows/mavpicviewer.ico differ