diff options
author | meta-androcto <meta.androcto1@gmail.com> | 2017-06-15 15:13:42 +0300 |
---|---|---|
committer | meta-androcto <meta.androcto1@gmail.com> | 2017-06-15 15:13:42 +0300 |
commit | 722e9d54282befe9eac75b0cd6879a336fe0e394 (patch) | |
tree | ca89d1e990a3b3c930a37dc816b922cc8d75de8e | |
parent | c6676127556e5756e4c94f39784d27149f2eb86d (diff) |
io_convert_image_to_mesh_img: complete rewrite: T51754
-rw-r--r-- | io_convert_image_to_mesh_img/__init__.py | 147 | ||||
-rw-r--r-- | io_convert_image_to_mesh_img/import_img.py | 713 | ||||
-rw-r--r-- | io_convert_image_to_mesh_img/mesh/__init__.py | 25 | ||||
-rw-r--r-- | io_convert_image_to_mesh_img/mesh/dtm.py | 219 | ||||
-rw-r--r-- | io_convert_image_to_mesh_img/mesh/terrain.py | 245 | ||||
-rw-r--r-- | io_convert_image_to_mesh_img/mesh/triangulate.py | 186 | ||||
-rw-r--r-- | io_convert_image_to_mesh_img/pvl/__init__.py | 27 | ||||
-rw-r--r-- | io_convert_image_to_mesh_img/pvl/label.py | 24 | ||||
-rw-r--r-- | io_convert_image_to_mesh_img/pvl/parse.py | 147 | ||||
-rw-r--r-- | io_convert_image_to_mesh_img/pvl/patterns.py | 59 | ||||
-rw-r--r-- | io_convert_image_to_mesh_img/ui/__init__.py | 25 | ||||
-rw-r--r-- | io_convert_image_to_mesh_img/ui/importer.py | 177 | ||||
-rw-r--r-- | io_convert_image_to_mesh_img/ui/terrainpanel.py | 133 |
13 files changed, 1308 insertions, 819 deletions
diff --git a/io_convert_image_to_mesh_img/__init__.py b/io_convert_image_to_mesh_img/__init__.py index 35b60997..54213465 100644 --- a/io_convert_image_to_mesh_img/__init__.py +++ b/io_convert_image_to_mesh_img/__init__.py @@ -1,130 +1,65 @@ -# ##### BEGIN GPL LICENSE BLOCK ##### +# This file is a part of the HiRISE DTM Importer for Blender # -# This program is free software; you can redistribute it and/or -# modify it under the terms of the GNU General Public License -# as published by the Free Software Foundation; either version 2 -# of the License, or (at your option) any later version. +# Copyright (C) 2017 Arizona Board of Regents on behalf of the Planetary Image +# Research Laboratory, Lunar and Planetary Laboratory at the University of +# Arizona. # -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. +# This program is free software: you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. # -# You should have received a copy of the GNU General Public License -# along with this program; if not, write to the Free Software Foundation, -# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY +# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +# for more details. # -# ##### END GPL LICENSE BLOCK ##### +# You should have received a copy of the GNU General Public License along +# with this program. If not, see <http://www.gnu.org/licenses/>. + +"""A HiRISE DTM Importer for Blender""" + +import bpy + +from .ui import importer +from .ui import terrainpanel bl_info = { - "name": "HiRISE DTM from PDS IMG", - "author": "Tim Spriggs (tims@uahirise.org)", - "version": (0, 1, 4), - "blender": (2, 63, 0), - "location": "File > Import > HiRISE DTM from PDS IMG (.IMG)", - "description": "Import a HiRISE DTM formatted as a PDS IMG file", + "name": "HiRISE DTM Importer", + "author": "Nicholas Wolf (nicwolf@pirl.lpl.arizona.edu)", + "version": (0, 2, 1), + "blender": (2, 78, 0), + "license": "GPL", + "location": "File > Import > HiRISE DTM (.img)", + "description": "Import a HiRISE DTM as a mesh.", "warning": "May consume a lot of memory", - "wiki_url": "http://wiki.blender.org/index.php/Extensions:2.6/Py/" - "Scripts/Import-Export/HiRISE_DTM_from_PDS_IMG", "category": "Import-Export", + "wiki_url": "", # TBD + "tracker_url": "", # TBD + "link": "", # TBD + "support": "TESTING", } - -# Revision History: -# 0.1.1 - make default import 12x12 bin (fast) to not consume too much memory -# by default (TJS - 2010-12-07) -# 0.1.2 - included into svn under the tree: -# trunk/py/scripts/addons/io_convert_image_to_mesh_img -# may be moved out to contrib once the blender downloader works well -# (TJS - 2010-12-14) -# 0.1.3 - upstream blender updates -# performance enhancements by Chris Van Horne -# (TJS - 2012-03-14) -# 0.1.4 - use bmesh from_pydata in blender 2.6.3 -# fixed/optimized bin2 method -# (TJS - 2012-04-30) - - if "bpy" in locals(): - import importlib - importlib.reload(import_img) -else: - from . import import_img - - -import bpy -from bpy.props import * -from bpy_extras.io_utils import ImportHelper - - -class ImportHiRISEIMGDTM(bpy.types.Operator, ImportHelper): - """Import a HiRISE DTM formatted as a PDS IMG file""" - bl_idname = "import_shape.img" - bl_label = "Import HiRISE DTM from PDS IMG" - bl_options = {'UNDO'} + import imp + imp.reload(importer) + imp.reload(terrainpanel) - filename_ext = ".IMG" - filter_glob = StringProperty(default="*.IMG", options={'HIDDEN'}) - - scale = FloatProperty(name="Scale", - description="Scale the IMG by this value", - min=0.0001, - max=10.0, - soft_min=0.001, - soft_max=100.0, - default=0.01) - - bin_mode = EnumProperty(items=( - ('NONE', "None", "Don't bin the image"), - ('BIN2', "2x2", "use 2x2 binning to import the mesh"), - ('BIN6', "6x6", "use 6x6 binning to import the mesh"), - ('BIN6-FAST', "6x6 Fast", "use one sample per 6x6 region"), - ('BIN12', "12x12", "use 12x12 binning to import the mesh"), - ('BIN12-FAST', "12x12 Fast", "use one sample per 12x12 region"), - ), - name="Binning", - description="Import Binning", - default='BIN12-FAST' - ) - - ## TODO: add support for cropping on import when the checkbox is checked - # do_crop = BoolProperty(name="Crop Image", description="Crop the image during import", ... ) - ## we only want these visible when the above is "true" - # crop_x = IntProperty(name="X", description="Offset from left side of image") - # crop_y = IntProperty(name="Y", description="Offset from top of image") - # crop_w = IntProperty(name="Width", description="width of cropped operation") - # crop_h = IntProperty(name="Height", description="height of cropped region") - ## This is also a bit ugly and maybe an anti-pattern. The problem is that - ## importing a HiRISE DTM at full resolution will likely kill any mortal user with - ## less than 16 GB RAM and getting at specific features in a DTM at full res - ## may prove beneficial. Someday most mortals will have 16GB RAM. - ## -TJS 2010-11-23 - - def execute(self, context): - filepath = self.filepath - filepath = bpy.path.ensure_ext(filepath, self.filename_ext) - - return import_img.load(self, context, - filepath=self.filepath, - scale=self.scale, - bin_mode=self.bin_mode, - cropVars=False, - ) - -## How to register the script inside of Blender def menu_import(self, context): - self.layout.operator(ImportHiRISEIMGDTM.bl_idname, text="HiRISE DTM from PDS IMG (*.IMG)") + i = importer.ImportHiRISETerrain + self.layout.operator(i.bl_idname, text=i.bl_label) + def register(): bpy.utils.register_module(__name__) - bpy.types.INFO_MT_file_import.append(menu_import) + def unregister(): bpy.utils.unregister_module(__name__) - bpy.types.INFO_MT_file_import.remove(menu_import) -if __name__ == "__main__": + +if __name__ == '__main__': register() diff --git a/io_convert_image_to_mesh_img/import_img.py b/io_convert_image_to_mesh_img/import_img.py deleted file mode 100644 index 7c9e76d1..00000000 --- a/io_convert_image_to_mesh_img/import_img.py +++ /dev/null @@ -1,713 +0,0 @@ -# ##### BEGIN GPL LICENSE BLOCK ##### -# -# This program is free software; you can redistribute it and/or -# modify it under the terms of the GNU General Public License -# as published by the Free Software Foundation; either version 2 -# of the License, or (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program; if not, write to the Free Software Foundation, -# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. -# -# ##### END GPL LICENSE BLOCK ##### - -""" -This script can import a HiRISE DTM .IMG file. -""" - -import bpy -from bpy.props import * - -from struct import pack, unpack -import os -import queue, threading - -class image_properties: - """ keeps track of image attributes throughout the hirise_dtm_importer class """ - def __init__(self, name, dimensions, pixel_scale): - self.name( name ) - self.dims( dimensions ) - self.processed_dims( dimensions ) - self.pixel_scale( pixel_scale ) - - def dims(self, dims=None): - if dims is not None: - self.__dims = dims - return self.__dims - - def processed_dims(self, processed_dims=None): - if processed_dims is not None: - self.__processed_dims = processed_dims - return self.__processed_dims - - def name(self, name=None): - if name is not None: - self.__name = name - return self.__name - - def pixel_scale(self, pixel_scale=None): - if pixel_scale is not None: - self.__pixel_scale = pixel_scale - return self.__pixel_scale - -class hirise_dtm_importer(object): - """ methods to understand/import a HiRISE DTM formatted as a PDS .IMG """ - - def __init__(self, context, filepath): - self.__context = context - self.__filepath = filepath - self.__ignore_value = 0x00000000 - self.__bin_mode = 'BIN6' - self.scale( 1.0 ) - self.__cropXY = False - - def bin_mode(self, bin_mode=None): - if bin_mode is not None: - self.__bin_mode = bin_mode - return self.__bin_mode - - def scale(self, scale=None): - if scale is not None: - self.__scale = scale - return self.__scale - - def crop(self, widthX, widthY, offX, offY): - self.__cropXY = [ widthX, widthY, offX, offY ] - return self.__cropXY - - ############################################################################ - ## PDS Label Operations - ############################################################################ - - def parsePDSLabel(self, labelIter, currentObjectName=None, level = ""): - # Let's parse this thing... semi-recursively - ## I started writing this caring about everything in the PDS standard but ... - ## it's a mess and I only need a few things -- thar be hacks below - ## Mostly I just don't care about continued data from previous lines - label_structure = [] - - # When are we done with this level? - endStr = "END" - if not currentObjectName is None: - endStr = "END_OBJECT = %s" % currentObjectName - line = "" - - while not line.rstrip() == endStr: - line = next(labelIter) - - # Get rid of comments - comment = line.find("/*") - if comment > -1: - line = line[:comment] - - # Take notice of objects - if line[:8] == "OBJECT =": - objName = line[8:].rstrip() - label_structure.append( - ( - objName.lstrip().rstrip(), - self.parsePDSLabel(labelIter, objName.lstrip().rstrip(), level + " ") - ) - ) - elif line.find("END_OBJECT =") > -1: - pass - elif len(line.rstrip().lstrip()) > 0: - key_val = line.split(" = ", 2) - if len(key_val) == 2: - label_structure.append( (key_val[0].rstrip().lstrip(), key_val[1].rstrip().lstrip()) ) - - return label_structure - - # There has got to be a better way in python? - def iterArr(self, label): - for line in label: - yield line - - def getPDSLabel(self, img): - # Just takes file and stores it into an array for later use - label = [] - done = False; - # Grab label into array of lines - while not done: - line = str(img.readline(), 'utf-8') - if line.rstrip() == "END": - done = True - label.append(line) - return (label, self.parsePDSLabel(self.iterArr(label))) - - def getLinesAndSamples(self, label): - """ uses the parsed PDS Label to get the LINES and LINE_SAMPLES parameters - from the first object named "IMAGE" -- is hackish - """ - for obj in label: - if obj[0] == "IMAGE": - return self.getLinesAndSamples(obj[1]) - if obj[0] == "LINES": - lines = int(obj[1]) - if obj[0] == "LINE_SAMPLES": - line_samples = int(obj[1]) - - return ( line_samples, lines ) - - def getValidMinMax(self, label): - """ uses the parsed PDS Label to get the VALID_MINIMUM and VALID_MAXIMUM parameters - from the first object named "IMAGE" -- is hackish - """ - for obj in label: - if obj[0] == "IMAGE": - return self.getValidMinMax(obj[1]) - if obj[0] == "VALID_MINIMUM": - vmin = float(obj[1]) - if obj[0] == "VALID_MAXIMUM": - vmax = float(obj[1]) - - return vmin, vmax - - def getMissingConstant(self, label): - """ uses the parsed PDS Label to get the MISSING_CONSTANT parameter - from the first object named "IMAGE" -- is hackish - """ - for obj in label: - if obj[0] == "IMAGE": - return self.getMissingConstant(obj[1]) - if obj[0] == "MISSING_CONSTANT": - bit_string_repr = obj[1] - - # This is always the same for a HiRISE image, so we are just checking it - # to be a little less insane here. If someone wants to support another - # constant then go for it. Just make sure this one continues to work too - pieces = bit_string_repr.split("#") - if pieces[0] == "16" and pieces[1] == "FF7FFFFB": - ignore_value = unpack("f", pack("I", 0xFF7FFFFB))[0] - - return ( ignore_value ) - - ############################################################################ - ## Image operations - ############################################################################ - - def bin2(self, image_iter, bin2_method_type="SLOW"): - """ this is an iterator that: Given an image iterator will yield binned lines """ - - ignore_value = self.__ignore_value - - img_props = next(image_iter) - # dimensions shrink as we remove pixels - processed_dims = img_props.processed_dims() - processed_dims = ( processed_dims[0]//2, processed_dims[1]//2 ) - img_props.processed_dims( processed_dims ) - # each pixel is larger as binning gets larger - pixel_scale = img_props.pixel_scale() - pixel_scale = ( pixel_scale[0]*2, pixel_scale[1]*2 ) - img_props.pixel_scale( pixel_scale ) - yield img_props - - # Take two lists [a1, a2, a3], [b1, b2, b3] and combine them into one - # list of [a1 + b1, a2+b2, ... ] as long as both values are not ignorable - combine_fun = lambda a, b: a != ignore_value and b != ignore_value and (a + b)/2 or ignore_value - - line_count = 0 - ret_list = [] - for line in image_iter: - if line_count == 1: - line_count = 0 - tmp_list = list(map(combine_fun, line, last_line)) - while len(tmp_list) > 1: - ret_list.append( combine_fun( tmp_list[0], tmp_list[1] ) ) - del tmp_list[0:2] - yield ret_list - ret_list = [] - else: - last_line = line - line_count += 1 - - def bin6(self, image_iter, bin6_method_type="SLOW"): - """ this is an iterator that: Given an image iterator will yield binned lines """ - - img_props = next(image_iter) - # dimensions shrink as we remove pixels - processed_dims = img_props.processed_dims() - processed_dims = ( processed_dims[0]//6, processed_dims[1]//6 ) - img_props.processed_dims( processed_dims ) - # each pixel is larger as binning gets larger - pixel_scale = img_props.pixel_scale() - pixel_scale = ( pixel_scale[0]*6, pixel_scale[1]*6 ) - img_props.pixel_scale( pixel_scale ) - yield img_props - - if bin6_method_type == "FAST": - bin6_method = self.bin6_real_fast - else: - bin6_method = self.bin6_real - - raw_data = [] - line_count = 0 - for line in image_iter: - raw_data.append( line ) - line_count += 1 - if line_count == 6: - yield bin6_method( raw_data ) - line_count = 0 - raw_data = [] - - def bin6_real(self, raw_data): - """ does a 6x6 sample of raw_data and returns a single line of data """ - # TODO: make this more efficient - - binned_data = [] - - # Filter out those unwanted hugely negative values... - IGNORE_VALUE = self.__ignore_value - - base = 0 - for i in range(0, len(raw_data[0])//6): - - ints = (raw_data[0][base:base+6] + - raw_data[1][base:base+6] + - raw_data[2][base:base+6] + - raw_data[3][base:base+6] + - raw_data[4][base:base+6] + - raw_data[5][base:base+6] ) - - ints = [num for num in ints if num != IGNORE_VALUE] - - # If we have all pesky values, return a pesky value - if not ints: - binned_data.append( IGNORE_VALUE ) - else: - binned_data.append( sum(ints, 0.0) / len(ints) ) - - base += 6 - - return binned_data - - def bin6_real_fast(self, raw_data): - """ takes a single value from each 6x6 sample of raw_data and returns a single line of data """ - # TODO: make this more efficient - - binned_data = [] - - base = 0 - for i in range(0, len(raw_data[0])//6): - binned_data.append( raw_data[0][base] ) - base += 6 - - return binned_data - - def bin12(self, image_iter, bin12_method_type="SLOW"): - """ this is an iterator that: Given an image iterator will yield binned lines """ - - img_props = next(image_iter) - # dimensions shrink as we remove pixels - processed_dims = img_props.processed_dims() - processed_dims = ( processed_dims[0]//12, processed_dims[1]//12 ) - img_props.processed_dims( processed_dims ) - # each pixel is larger as binning gets larger - pixel_scale = img_props.pixel_scale() - pixel_scale = ( pixel_scale[0]*12, pixel_scale[1]*12 ) - img_props.pixel_scale( pixel_scale ) - yield img_props - - if bin12_method_type == "FAST": - bin12_method = self.bin12_real_fast - else: - bin12_method = self.bin12_real - - raw_data = [] - line_count = 0 - for line in image_iter: - raw_data.append( line ) - line_count += 1 - if line_count == 12: - yield bin12_method( raw_data ) - line_count = 0 - raw_data = [] - - def bin12_real(self, raw_data): - """ does a 12x12 sample of raw_data and returns a single line of data """ - - binned_data = [] - - # Filter out those unwanted hugely negative values... - filter_fun = lambda a: self.__ignore_value.__ne__(a) - - base = 0 - for i in range(0, len(raw_data[0])//12): - - ints = list(filter( filter_fun, raw_data[0][base:base+12] + - raw_data[1][base:base+12] + - raw_data[2][base:base+12] + - raw_data[3][base:base+12] + - raw_data[4][base:base+12] + - raw_data[5][base:base+12] + - raw_data[6][base:base+12] + - raw_data[7][base:base+12] + - raw_data[8][base:base+12] + - raw_data[9][base:base+12] + - raw_data[10][base:base+12] + - raw_data[11][base:base+12] )) - len_ints = len( ints ) - - # If we have all pesky values, return a pesky value - if len_ints == 0: - binned_data.append( self.__ignore_value ) - else: - binned_data.append( sum(ints) / len(ints) ) - - base += 12 - return binned_data - - def bin12_real_fast(self, raw_data): - """ takes a single value from each 12x12 sample of raw_data and returns a single line of data """ - return raw_data[0][11::12] - - def cropXY(self, image_iter, XSize=None, YSize=None, XOffset=0, YOffset=0): - """ return a cropped portion of the image """ - - img_props = next(image_iter) - # dimensions shrink as we remove pixels - processed_dims = img_props.processed_dims() - - if XSize is None: - XSize = processed_dims[0] - if YSize is None: - YSize = processed_dims[1] - - if XSize + XOffset > processed_dims[0]: - XSize = processed_dims[0] - XOffset = 0 - if YSize + YOffset > processed_dims[1]: - YSize = processed_dims[1] - YOffset = 0 - - img_props.processed_dims( (XSize, YSize) ) - yield img_props - - currentY = 0 - for line in image_iter: - if currentY >= YOffset and currentY <= YOffset + YSize: - yield line[XOffset:XOffset+XSize] - # Not much point in reading the rest of the data... - if currentY == YOffset + YSize: - return - currentY += 1 - - def getImage(self, img, img_props): - """ Assumes 32-bit pixels -- bins image """ - dims = img_props.dims() - - # setup to unpack more efficiently. - x_len = dims[0] - # little endian (PC_REAL) - unpack_str = "<" - # unpack_str = ">" - unpack_bytes_str = "<" - pack_bytes_str = "=" - # 32 bits/sample * samples/line = y_bytes (per line) - x_bytes = 4*x_len - for x in range(0, x_len): - # 32-bit float is "d" - unpack_str += "f" - unpack_bytes_str += "I" - pack_bytes_str += "I" - - # Each iterator yields this first ... it is for reference of the next iterator: - yield img_props - - for y in range(0, dims[1]): - # pixels is a byte array - pixels = b'' - while len(pixels) < x_bytes: - new_pixels = img.read( x_bytes - len(pixels) ) - pixels += new_pixels - if len(new_pixels) == 0: - x_bytes = -1 - pixels = [] - if len(pixels) == x_bytes: - if 0 == 1: - repacked_pixels = b'' - for integer in unpack(unpack_bytes_str, pixels): - repacked_pixels += pack("=I", integer) - yield unpack( unpack_str, repacked_pixels ) - else: - yield unpack( unpack_str, pixels ) - - def shiftToOrigin(self, image_iter, image_min_max): - """ takes a generator and shifts the points by the valid minimum - also removes points with value self.__ignore_value and replaces them with None - """ - - # use the passed in values ... - valid_min = image_min_max[0] - - # pass on dimensions/pixel_scale since we don't modify them here - yield next(image_iter) - - - # closures rock! - def normalize_fun(point): - if point == self.__ignore_value: - return None - return point - valid_min - - for line in image_iter: - yield list(map(normalize_fun, line)) - - def scaleZ(self, image_iter, scale_factor): - """ scales the mesh values by a factor """ - # pass on dimensions since we don't modify them here - yield next(image_iter) - - scale_factor = self.scale() - - def scale_fun(point): - try: - return point * scale_factor - except: - return None - - for line in image_iter: - yield list(map(scale_fun, line)) - - def genMesh(self, image_iter): - """Returns a mesh object from an image iterator this has the - value-added feature that a value of "None" is ignored - """ - - # Get the output image size given the above transforms - img_props = next(image_iter) - - # Let's interpolate the binned DTM with blender -- yay meshes! - coords = [] - faces = [] - face_count = 0 - coord = -1 - max_x = img_props.processed_dims()[0] - max_y = img_props.processed_dims()[1] - - scale_x = self.scale() * img_props.pixel_scale()[0] - scale_y = self.scale() * img_props.pixel_scale()[1] - - line_count = 0 - # seed the last line (or previous line) with a line - last_line = next(image_iter) - point_offset = 0 - previous_point_offset = 0 - - # Let's add any initial points that are appropriate - x = 0 - point_offset += len( last_line ) - last_line.count(None) - for z in last_line: - if z is not None: - coords.append( (x*scale_x, 0.0, z) ) - coord += 1 - x += 1 - - # We want to ignore points with a value of "None" but we also need to create vertices - # with an index that we can re-create on the next line. The solution is to remember - # two offsets: the point offset and the previous point offset. - # these offsets represent the point index that blender gets -- not the number of - # points we have read from the image - - # if "x" represents points that are "None" valued then conceptually this is how we - # think of point indices: - # - # previous line: offset0 x x +1 +2 +3 - # current line: offset1 x +1 +2 +3 x - - # once we can map points we can worry about making triangular or square faces to fill - # the space between vertices so that blender is more efficient at managing the final - # structure. - - # read each new line and generate coordinates+faces - for dtm_line in image_iter: - - # Keep track of where we are in the image - line_count += 1 - y_val = line_count*-scale_y - - # Just add all points blindly - # TODO: turn this into a map - x = 0 - for z in dtm_line: - if z is not None: - coords.append( (x*scale_x, y_val, z) ) - coord += 1 - x += 1 - - # Calculate faces - for x in range(0, max_x - 1): - vals = [ - last_line[ x + 1 ], - last_line[ x ], - dtm_line[ x ], - dtm_line[ x + 1 ], - ] - - # Two or more values of "None" means we can ignore this block - none_val = vals.count(None) - - # Common case: we can create a square face - if none_val == 0: - faces.append( ( - previous_point_offset, - previous_point_offset+1, - point_offset+1, - point_offset, - ) ) - face_count += 1 - elif none_val == 1: - # special case: we can implement a triangular face - ## NB: blender 2.5 makes a triangular face when the last coord is 0 - # TODO: implement a triangular face - pass - - if vals[1] is not None: - previous_point_offset += 1 - if vals[2] is not None: - point_offset += 1 - - # Squeeze the last point offset increment out of the previous line - if last_line[-1] is not None: - previous_point_offset += 1 - - # Squeeze the last point out of the current line - if dtm_line[-1] is not None: - point_offset += 1 - - # remember what we just saw (and forget anything before that) - last_line = dtm_line - - me = bpy.data.meshes.new(img_props.name()) # create a new mesh - #from_pydata(self, vertices, edges, faces) - #Make a mesh from a list of vertices/edges/faces - #Until we have a nicer way to make geometry, use this. - #:arg vertices: - # float triplets each representing (X, Y, Z) - # eg: [(0.0, 1.0, 0.5), ...]. - #:type vertices: iterable object - #:arg edges: - # int pairs, each pair contains two indices to the - # *vertices* argument. eg: [(1, 2), ...] - #:type edges: iterable object - #:arg faces: - # iterator of faces, each faces contains three or more indices to - # the *vertices* argument. eg: [(5, 6, 8, 9), (1, 2, 3), ...] - #:type faces: iterable object - me.from_pydata(coords, [], faces) - - # me.vertices.add(len(coords)/3) - # me.vertices.foreach_set("co", coords) - # me.faces.add(len(faces)/4) - # me.faces.foreach_set("vertices_raw", faces) - - me.update() - - bin_desc = self.bin_mode() - if bin_desc == 'NONE': - bin_desc = 'No Bin' - - ob=bpy.data.objects.new("DTM - %s" % bin_desc, me) - - return ob - - ################################################################################ - # Yay, done with importer functions ... let's see the abstraction in action! # - ################################################################################ - def execute(self): - - img = open(self.__filepath, 'rb') - - (label, parsedLabel) = self.getPDSLabel(img) - - image_dims = self.getLinesAndSamples(parsedLabel) - img_min_max_vals = self.getValidMinMax(parsedLabel) - self.__ignore_value = self.getMissingConstant(parsedLabel) - - - # MAGIC VALUE? -- need to formalize this to rid ourselves of bad points - img.seek(28) - # Crop off 4 lines - img.seek(4*image_dims[0]) - - # HiRISE images (and most others?) have 1m x 1m pixels - pixel_scale=(1, 1) - - # The image we are importing - image_name = os.path.basename( self.__filepath ) - - # Set the properties of the image in a manageable object - img_props = image_properties( image_name, image_dims, pixel_scale ) - - # Get an iterator to iterate over lines - image_iter = self.getImage(img, img_props) - - ## Wrap the image_iter generator with other generators to modify the dtm on a - ## line-by-line basis. This creates a stream of modifications instead of reading - ## all of the data at once, processing all of the data (potentially several times) - ## and then handing it off to blender - ## TODO: find a way to alter projection based on transformations below - - if self.__cropXY: - image_iter = self.cropXY(image_iter, - XSize=self.__cropXY[0], - YSize=self.__cropXY[1], - XOffset=self.__cropXY[2], - YOffset=self.__cropXY[3] - ) - - # Select an appropriate binning mode - ## TODO: generalize the binning fn's - bin_mode = self.bin_mode() - bin_mode_funcs = { - 'BIN2': self.bin2(image_iter), - 'BIN6': self.bin6(image_iter), - 'BIN6-FAST': self.bin6(image_iter, 'FAST'), - 'BIN12': self.bin12(image_iter), - 'BIN12-FAST': self.bin12(image_iter, 'FAST') - } - if bin_mode in bin_mode_funcs.keys(): - image_iter = bin_mode_funcs[ bin_mode ] - - image_iter = self.shiftToOrigin(image_iter, img_min_max_vals) - - if self.scale != 1.0: - image_iter = self.scaleZ(image_iter, img_min_max_vals) - - # Create a new mesh object and set data from the image iterator - ob_new = self.genMesh(image_iter) - - if img: - img.close() - - # Add mesh object to the current scene - scene = self.__context.scene - scene.objects.link(ob_new) - scene.update() - - # deselect other objects - bpy.ops.object.select_all(action='DESELECT') - - # scene.objects.active = ob_new - # Select the new mesh - ob_new.select = True - - return ('FINISHED',) - -def load(operator, context, filepath, scale, bin_mode, cropVars): - print("Bin Mode: %s" % bin_mode) - print("Scale: %f" % scale) - importer = hirise_dtm_importer(context,filepath) - importer.bin_mode( bin_mode ) - importer.scale( scale ) - if cropVars: - importer.crop( cropVars[0], cropVars[1], cropVars[2], cropVars[3] ) - importer.execute() - - print("Loading %s" % filepath) - return {'FINISHED'} diff --git a/io_convert_image_to_mesh_img/mesh/__init__.py b/io_convert_image_to_mesh_img/mesh/__init__.py new file mode 100644 index 00000000..edbc8c88 --- /dev/null +++ b/io_convert_image_to_mesh_img/mesh/__init__.py @@ -0,0 +1,25 @@ +# This file is a part of the HiRISE DTM Importer for Blender +# +# Copyright (C) 2017 Arizona Board of Regents on behalf of the Planetary Image +# Research Laboratory, Lunar and Planetary Laboratory at the University of +# Arizona. +# +# This program is free software: you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY +# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +# for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program. If not, see <http://www.gnu.org/licenses/>. + +"""A sub-package for loading DTMs as 3D models""" + +from . import dtm +from . import terrain + +__all__ = ['dtm', 'terrain', ] diff --git a/io_convert_image_to_mesh_img/mesh/dtm.py b/io_convert_image_to_mesh_img/mesh/dtm.py new file mode 100644 index 00000000..a6ab6e30 --- /dev/null +++ b/io_convert_image_to_mesh_img/mesh/dtm.py @@ -0,0 +1,219 @@ +# This file is a part of the HiRISE DTM Importer for Blender +# +# Copyright (C) 2017 Arizona Board of Regents on behalf of the Planetary Image +# Research Laboratory, Lunar and Planetary Laboratory at the University of +# Arizona. +# +# This program is free software: you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY +# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +# for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program. If not, see <http://www.gnu.org/licenses/>. + +"""Objects for importing HiRISE DTMs.""" + +import numpy as np + +from .. import pvl + + +class DTM: + """ + HiRISE Digital Terrain Model + + This class imports a HiRISE DTM from a Planetary Data Systems (PDS) + compliant .IMG file. + + Parameters + ---------- + path : str + terrain_resolution : float, optional + Controls the resolution the DTM is read at. This should be a float + in the range [0.01, 1.0] (and will be constrained to this range). A + value of 1.0 will result in the DTM being read at full resolution. A + value of 0.01 will result in the DTM being read at 1/100th resolution. + Default is 1.0 (no downsampling). + + Todo + ---- + * Use GDAL for importing the DTM if it is installed for this Python + environment. If/when I have the time to do this, it probably + warrants breaking out separate importer classes. The benefits of + doing this are pretty substantial, though: + + + More reliable (doesn't rely on my PVL parser for finding the + valid values in the DTM, for locating the starting position of + the elevation data in the .IMG file) + + + Other, better, downsampling algorithms are already built in. + + + Would make this much better at general PDS DTM importing, + currently some of the import code is specific to HiRISE DTMs. + + """ + + # Special constants in our data: + # NULL : No data at this point. + # LRS : Low Representation Saturation + # LIS : Low Instrument Saturation + # HRS : High Representation Saturation + # HIS : High Insturment Saturation + SPECIAL_VALUES = { + "NULL": np.fromstring(b'\xFF\x7F\xFF\xFB', dtype='>f4')[0], + "LRS": np.fromstring(b'\xFF\x7F\xFF\xFC', dtype='>f4')[0], + "LIS": np.fromstring(b'\xFF\x7F\xFF\xFD', dtype='>f4')[0], + "HRS": np.fromstring(b'\xFF\x7F\xFF\xFE', dtype='>f4')[0], + "HIS": np.fromstring(b'\xFF\x7F\xFF\xFF', dtype='>f4')[0] + } + + def __init__(self, path, terrain_resolution=1.0): + self.path = path + self.terrain_resolution = terrain_resolution + self.label = self._read_label() + self.data = self._read_data() + + def _read_label(self): + """Returns a dict-like representation of a PVL label""" + return pvl.load(self.path) + + def _read_data(self): + """ + Reads elevation data from a PDS .IMG file. + + Notes + ----- + * Uses nearest-neighbor to downsample data. + + Todo + ---- + * Add other downsampling algorithms. + + """ + h, w = self.image_resolution + max_samples = int(w - w % self.bin_size) + + data = np.zeros(self.shape) + with open(self.path, 'rb') as f: + # Seek to the first byte of data + start_byte = self._get_data_start() + f.seek(start_byte) + # Iterate over each row of the data + for r in range(data.shape[0]): + # Each iteration, seek to the right location before + # reading a row. We determine this location as the + # first byte of data PLUS a offset which we calculate as the + # product of: + # + # 4, the number of bytes in a single record + # r, the current row index + # w, the number of records in a row of the DTM + # bin_size, the number of records in a bin + # + # This is where we account for skipping over rows. + offset = int(4 * r * w * self.bin_size) + f.seek(start_byte + offset) + # Read a row + row = np.fromfile(f, dtype=np.float32, count=max_samples) + # This is where we account for skipping over columns. + data[r] = row[::self.bin_size] + + data = self._process_invalid_data(data) + return data + + def _get_data_start(self): + """Gets the start position of the DTM data block""" + label_length = self.label['RECORD_BYTES'] + num_labels = self.label.get('LABEL_RECORDS', 1) + return int(label_length * num_labels) + + def _process_invalid_data(self, data): + """Sets any 'NULL' elevation values to np.NaN""" + invalid_data_mask = (data <= self.SPECIAL_VALUES['NULL']) + data[invalid_data_mask] = np.NaN + return data + + @property + def map_size(self): + """Geographic size of the bounding box around the DTM""" + scale = self.map_scale * self.unit_scale + w = self.image_resolution[0] * scale + h = self.image_resolution[1] * scale + return (w, h) + + @property + def mesh_scale(self): + """Geographic spacing between mesh vertices""" + return self.bin_size * self.map_scale * self.unit_scale + + @property + def map_info(self): + """Map Projection metadata""" + return self.label['IMAGE_MAP_PROJECTION'] + + @property + def map_scale(self): + """Geographic spacing between DTM posts""" + map_scale = self.map_info.get('MAP_SCALE', None) + return getattr(map_scale, 'value', 1.0) + + @property + def map_units(self): + """Geographic unit for spacing between DTM posts""" + map_scale = self.map_info.get('MAP_SCALE', None) + return getattr(map_scale, 'units', None) + + @property + def unit_scale(self): + """ + The function that creates a Blender mesh from this object will assume + that the height values passed into it are in meters --- this + property is a multiplier to convert DTM-units to meters. + """ + scaling_factors = { + 'KM/PIXEL': 1000, + 'METERS/PIXEL': 1 + } + return scaling_factors.get(self.map_units, 1.0) + + @property + def terrain_resolution(self): + """Vertex spacing, meters""" + return self._terrain_resolution + + @terrain_resolution.setter + def terrain_resolution(self, t): + self._terrain_resolution = np.clip(t, 0.01, 1.0) + + @property + def bin_size(self): + """The width of the (square) downsampling bin""" + return int(np.ceil(1 / self.terrain_resolution)) + + @property + def image_stats(self): + """Image statistics from the original DTM label""" + return self.label['IMAGE'] + + @property + def image_resolution(self): + """(Line, Sample) resolution of the original DTM""" + return (self.image_stats['LINES'], self.image_stats['LINE_SAMPLES']) + + @property + def size(self): + """Number of posts in our reduced DTM""" + return self.shape[0] * self.shape[1] + + @property + def shape(self): + """Shape of our reduced DTM""" + num_rows = self.image_resolution[0] // self.bin_size + num_cols = self.image_resolution[1] // self.bin_size + return (num_rows, num_cols) diff --git a/io_convert_image_to_mesh_img/mesh/terrain.py b/io_convert_image_to_mesh_img/mesh/terrain.py new file mode 100644 index 00000000..db866289 --- /dev/null +++ b/io_convert_image_to_mesh_img/mesh/terrain.py @@ -0,0 +1,245 @@ +# This file is a part of the HiRISE DTM Importer for Blender +# +# Copyright (C) 2017 Arizona Board of Regents on behalf of the Planetary Image +# Research Laboratory, Lunar and Planetary Laboratory at the University of +# Arizona. +# +# This program is free software: you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY +# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +# for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program. If not, see <http://www.gnu.org/licenses/>. + +"""Objects for creating 3D models in Blender""" + +import bpy +import bmesh + +import numpy as np + +from .triangulate import Triangulate + + +class BTerrain: + """ + Functions for creating Blender meshes from DTM objects + + This class contains functions that convert DTM objects to Blender meshes. + Its main responsiblity is to triangulate a mesh from the elevation data in + the DTM. Additionally, it attaches some metadata to the object and creates + a UV map for it so that companion ortho-images drape properly. + + This class provides two public methods: `new()` and `reload()`. + + `new()` creates a new object[1] and attaches a new mesh to it. + + `reload()` replaces the mesh that is attached to an already existing + object. This allows us to retain the location and orientation of the parent + object's coordinate system but to reload the terrain at a different + resolution. + + Notes + ---------- + [1] If you're unfamiliar with Blender, one thing that will help you in + reading this code is knowing the difference between 'meshes' and + 'objects'. A mesh is just a collection of vertices, edges and + faces. An object may have a mesh as a child data object and + contains additional information, e.g. the location and orientation + of the coordinate system its child-meshes are reckoned in terms of. + + """ + + @staticmethod + def new(dtm, name='Terrain'): + """ + Loads a new terrain + + Parameters + ---------- + dtm : DTM + name : str, optional + The name that will be assigned to the new object, defaults + to 'Terrain' (and, if an object named 'Terrain' already + exists, Blender will automatically extend the name of the + new object to something like 'Terrain.001') + + Returns + ---------- + obj : bpy_types.Object + + """ + bpy.ops.object.add(type="MESH") + obj = bpy.context.object + obj.name = name + + # Fill the object data with a Terrain mesh + obj.data = BTerrain._mesh_from_dtm(dtm) + + # Add some meta-information to the object + metadata = BTerrain._create_metadata(dtm) + BTerrain._setobjattrs(obj, **metadata) + + # Center the mesh to its origin and create a UV map for draping + # ortho images. + BTerrain._center(obj) + + return obj + + @staticmethod + def reload(obj, dtm): + """ + Replaces an exisiting object's terrain mesh + + This replaces an object's mesh with a new mesh, transferring old + materials over to the new mesh. This is useful for reloading DTMs + at different resolutions but maintaining textures/location/rotation. + + Parameters + ----------- + obj : bpy_types.Object + An already existing Blender object + dtm : DTM + + Returns + ---------- + obj : bpy_types.Object + + """ + old_mesh = obj.data + new_mesh = BTerrain._mesh_from_dtm(dtm) + + # Copy any old materials to the new mesh + for mat in old_mesh.materials: + new_mesh.materials.append(mat.copy()) + + # Swap out the old mesh for the new one + obj.data = new_mesh + + # Update out-dated meta-information + metadata = BTerrain._create_metadata(dtm) + BTerrain._setobjattrs(obj, **metadata) + + # Center the mesh to its origin and create a UV map for draping + # ortho images. + BTerrain._center(obj) + + return obj + + @staticmethod + def _mesh_from_dtm(dtm, name='Terrain'): + """ + Creates a Blender *mesh* from a DTM + + Parameters + ---------- + dtm : DTM + name : str, optional + The name that will be assigned to the new mesh, defaults + to 'Terrain' (and, if an object named 'Terrain' already + exists, Blender will automatically extend the name of the + new object to something like 'Terrain.001') + + Returns + ---------- + mesh : bpy_types.Mesh + + Notes + ---------- + * We are switching coordinate systems from the NumPy to Blender. + + Numpy: Blender: + + ----> (0, j) ^ (0, y) + | | + | | + v (i, 0) + ----> (x, 0) + + """ + # Create an empty mesh + mesh = bpy.data.meshes.new(name) + + # Get the xy-coordinates from the DTM, see docstring notes + y, x = np.indices(dtm.data.shape).astype('float64') + x *= dtm.mesh_scale + y *= -1 * dtm.mesh_scale + + # Create an array of 3D vertices + vertices = np.dstack([x, y, dtm.data]).reshape((-1, 3)) + + # Drop vertices with NaN values (used in the DTM to represent + # areas with no data) + vertices = vertices[~np.isnan(vertices).any(axis=1)] + + # Calculate the faces of the mesh + triangulation = Triangulate(dtm.data) + faces = triangulation.face_list() + + # Fill the mesh + mesh.from_pydata(vertices, [], faces) + mesh.update() + + # Create a new UV layer + mesh.uv_textures.new("HiRISE Generated UV Map") + # We'll use a bmesh to populate the UV map with values + bm = bmesh.new() + bm.from_mesh(mesh) + bm.faces.ensure_lookup_table() + uv_layer = bm.loops.layers.uv[0] + + # Iterate over each face in the bmesh + num_faces = len(bm.faces) + w = dtm.data.shape[1] + h = dtm.data.shape[0] + for face_index in range(num_faces): + # Iterate over each loop in the face + for loop in bm.faces[face_index].loops: + # Get this loop's vertex coordinates + vert_coords = loop.vert.co.xy + # And calculate it's uv coordinate. We do this by dividing the + # vertice's x and y coordinates by: + # + # d + 1, dimensions of DTM (in "posts") + # mesh_scale, meters/DTM "post" + # + # This has the effect of mapping the vertex to its + # corresponding "post" index in the DTM, and then mapping + # that value to the range [0, 1). + u = vert_coords.x / ((w + 1) * dtm.mesh_scale) + v = 1 + vert_coords.y / ((h + 1) * dtm.mesh_scale) + loop[uv_layer].uv = (u, v) + + bm.to_mesh(mesh) + + return mesh + + @staticmethod + def _center(obj): + """Move object geometry to object origin""" + bpy.context.scene.objects.active = obj + bpy.ops.object.origin_set(center='BOUNDS') + + @staticmethod + def _setobjattrs(obj, **attrs): + for key, value in attrs.items(): + obj[key] = value + + @staticmethod + def _create_metadata(dtm): + """Returns a dict containing meta-information about a DTM""" + return { + 'PATH': dtm.path, + 'MESH_SCALE': dtm.mesh_scale, + 'DTM_RESOLUTION': dtm.terrain_resolution, + 'BIN_SIZE': dtm.bin_size, + 'MAP_SIZE': dtm.map_size, + 'MAP_SCALE': dtm.map_scale * dtm.unit_scale, + 'UNIT_SCALE': dtm.unit_scale, + 'IS_TERRAIN': True, + 'HAS_UV_MAP': True + } diff --git a/io_convert_image_to_mesh_img/mesh/triangulate.py b/io_convert_image_to_mesh_img/mesh/triangulate.py new file mode 100644 index 00000000..bb0a50ac --- /dev/null +++ b/io_convert_image_to_mesh_img/mesh/triangulate.py @@ -0,0 +1,186 @@ +# This file is a part of the HiRISE DTM Importer for Blender +# +# Copyright (C) 2017 Arizona Board of Regents on behalf of the Planetary Image +# Research Laboratory, Lunar and Planetary Laboratory at the University of +# Arizona. +# +# This program is free software: you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY +# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +# for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program. If not, see <http://www.gnu.org/licenses/>. + +"""Triangulation algorithms""" + +import numpy as np + + +class Triangulate: + """ + A triangulation algorithm for creating a mesh from a DTM raster. + + I have been re-writing parts of the Blender HiRISE DTM importer in an + effort to cull its dependencies on external packages. Originally, the + add-on relied on SciPy's Delaunay triangulation (really a wrapper for + Qhull's Delaunay triangulation) to triangulate a mesh from a HiRISE DTM. + + This re-write is much better suited to the problem domain. The SciPy + Delaunay triangulation creates a mesh from any arbitrary point cloud and, + while robust, doesn't care about the fact that our HiRISE DTMs are + regularly gridded rasters. This triangulation algorithm is less robust + but much faster. Credit is due to Tim Spriggs for his work on the previous + Blender HiRISE DTM importer --- this triangulation algorithm largely + models the one in his add-on with a few changes (namely interfacing + with NumPy's API). + + Overview + ---------- + Suppose we have a DTM: + + .. code:: + + - - - - - - - - X X - - - - - + - - - - - - X X X X X - - - - + - - - - X X X X X X X X - - - + - - X X X X X X X X X X X - - + X X X X X X X X X X X X X X - + - X X X X X X X X X X X X X X + - - X X X X X X X X X X X - - + - - - X X X X X X X X - - - - + - - - - X X X X X - - - - - - + - - - - - X X - - - - - - - - + + where 'X' represents valid values and '-' represents invalid values. + Valid values should become vertices in the resulting mesh, invalid + values should be ignored. + + Our end goal is to supply Blender with: + + 1. an (n x 3) list of vertices + + 2. an (m x 3) list of faces. + + A vertex is a 3-tuple that we get from the DTM raster array. The + z-coordinate is whatever elevation value is in the DTM and the xy- + coordinates are the image indices multiplied by the resolution of the + DTM (e.g. if the DTM is at 5m/px, the first vertex is at (0m, 0m, + z_00) and the vertex to the right of it is at (5m, 0m, z_01)). + + A face is a 3-tuple (because we're using triangles) where each element + is an index of a vertex in the vertices list. Computing the faces is + tricky because we want to leverage the orthogonal structure of the DTM + raster for computational efficiency but we also need to reference + vertex indices in our faces, which don't observe any regular + structure. + + We take two rows at a time from the DTM raster and track the *raster + row* indices as well as well as the *vertex* indices. Raster row + indices are the distance of a pixel in the raster from the left-most + (valid *or* invalid) pixel of the row. The first vertex is index 0 and + corresponds to the upperleft-most valid pixel in the DTM raster. + Vertex indices increase to the right and then down. + + For example, the first two rows: + + .. code:: + + - - - - - - - - X X - - - - - + - - - - - - X X X X X - - - - + + in vertex indices: + + .. code:: + + - - - - - - - - 0 1 - - - - - + - - - - - - 2 3 4 5 6 - - - - + + and in raster row indices: + + .. code:: + + - - - - - - - - 9 10 - - - - - + - - - - - - 7 8 9 10 11 - - - - + + To simplify, we will only add valid square regions to our mesh. So, + for these first two rows the only region that will be added to our + mesh is the quadrilateral formed by vertices 0, 1, 4 and 5. We + further divide this area into 2 triangles and add the vertices to the + face list in CCW order (i.e. t1: (4, 1, 0), t2: (4, 5, 1)). + + After the triangulation between two rows is completed, the bottom + row is cached as the top row and the next row in the DTM raster is + read as the new bottom row. This process continues until the entire + raster has been triangulated. + + Todo + --------- + * It should be pretty trivial to add support for triangular + regions (i.e. in the example above, also adding the triangles + formed by (3, 4, 0) and (5, 6, 1)). + + """ + def __init__(self, array): + self.array = array + self.faces = self._triangulate() + + def _triangulate(self): + """Triangulate a mesh from a topography array.""" + # Allocate memory for the triangles array + max_tris = (self.array.shape[0] - 1) * (self.array.shape[1] - 1) * 2 + tris = np.zeros((max_tris, 3), dtype=int) + ntri = 0 + + # We initialize a vertex counter at 0 + prev_vtx_start = 0 + # We don't care about the values in the array, just whether or not + # they are valid. + prev = ~np.isnan(self.array[0]) + # We can sum this boolean array to count the number of valid entries + prev_num_valid = prev.sum() + # TODO: Probably a more clear (and faster) function than argmax for + # getting the first Truth-y value in a 1d array. + prev_img_start = np.argmax(prev) + + # Start quadrangulation + for i in range(1, self.array.shape[0]): + # Fetch this row, get our bearings in image *and* vertex space + curr = ~np.isnan(self.array[i]) + curr_vtx_start = prev_vtx_start + prev_num_valid + curr_img_start = np.argmax(curr) + curr_num_valid = curr.sum() + # Find the overlap between this row and the previous one + overlap = np.logical_and(prev, curr) + num_tris = overlap.sum() - 1 + overlap_start = np.argmax(overlap) + # Store triangles + for j in range(num_tris): + curr_pad = overlap_start - curr_img_start + j + prev_pad = overlap_start - prev_img_start + j + tris[ntri + 0] = [ + curr_vtx_start + curr_pad, + prev_vtx_start + prev_pad + 1, + prev_vtx_start + prev_pad + ] + tris[ntri + 1] = [ + curr_vtx_start + curr_pad, + curr_vtx_start + curr_pad + 1, + prev_vtx_start + prev_pad + 1 + ] + ntri += 2 + # Cache current row as previous row + prev = curr + prev_vtx_start = curr_vtx_start + prev_img_start = curr_img_start + prev_num_valid = curr_num_valid + + return tris[:ntri] + + def face_list(self): + return list(self.faces) diff --git a/io_convert_image_to_mesh_img/pvl/__init__.py b/io_convert_image_to_mesh_img/pvl/__init__.py new file mode 100644 index 00000000..4fcf394e --- /dev/null +++ b/io_convert_image_to_mesh_img/pvl/__init__.py @@ -0,0 +1,27 @@ +# This file is a part of the HiRISE DTM Importer for Blender +# +# Copyright (C) 2017 Arizona Board of Regents on behalf of the Planetary Image +# Research Laboratory, Lunar and Planetary Laboratory at the University of +# Arizona. +# +# This program is free software: you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY +# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +# for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program. If not, see <http://www.gnu.org/licenses/>. + +"""A sub-package for parsing PVL labels""" + +from .parse import LabelParser + + +def load(path): + """Returns a dict-like representation of a PVL label""" + return LabelParser.load(path) diff --git a/io_convert_image_to_mesh_img/pvl/label.py b/io_convert_image_to_mesh_img/pvl/label.py new file mode 100644 index 00000000..68d4f914 --- /dev/null +++ b/io_convert_image_to_mesh_img/pvl/label.py @@ -0,0 +1,24 @@ +# This file is a part of the HiRISE DTM Importer for Blender +# +# Copyright (C) 2017 Arizona Board of Regents on behalf of the Planetary Image +# Research Laboratory, Lunar and Planetary Laboratory at the University of +# Arizona. +# +# This program is free software: you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY +# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +# for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program. If not, see <http://www.gnu.org/licenses/>. + + +class Label(dict): + """A dict-like representation of a PVL label""" + def __init__(self, *args, **kwargs): + super(Label, self).__init__(*args, **kwargs) diff --git a/io_convert_image_to_mesh_img/pvl/parse.py b/io_convert_image_to_mesh_img/pvl/parse.py new file mode 100644 index 00000000..3a87d2f3 --- /dev/null +++ b/io_convert_image_to_mesh_img/pvl/parse.py @@ -0,0 +1,147 @@ +# This file is a part of the HiRISE DTM Importer for Blender +# +# Copyright (C) 2017 Arizona Board of Regents on behalf of the Planetary Image +# Research Laboratory, Lunar and Planetary Laboratory at the University of +# Arizona. +# +# This program is free software: you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY +# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +# for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program. If not, see <http://www.gnu.org/licenses/>. + +"""PVL Label Parsing""" + +import collections +import re + +from . import patterns +from .label import Label + +Quantity = collections.namedtuple('Quantity', ['value', 'units']) + + +class PVLParseError(Exception): + """Error parsing PVL file""" + def __init__(self, message): + super(PVLParseError, self).__init__(message) + + +class LabelParser: + """A PVL Parser""" + @staticmethod + def load(path): + """ + Load a dict-like representation of a PVL label header + + Parameters + ---------- + path : str + Path to a file containing a PVL header + + Returns + ---------- + label : pvl.Label + + """ + raw = LabelParser._read(path) + return Label(**LabelParser._parse(raw)) + + @staticmethod + def _read(path): + """ + Get the PVL header from a file as a string + + Parameters + ---------- + path : str + Path to a file containing a PVL header + + Returns + ---------- + raw : str + + Notes + --------- + * This function assumes that the file begins with a PVL header + and it will read lines from the file until it encounters + a PVL end statement. + + To-Do + --------- + * This could be more robust. What happens if there is no label + in the file? + + """ + with open(path, 'rb') as f: + raw = '' + while True: + try: + line = f.readline().decode() + raw += line + if re.match(patterns.END, line): + break + except UnicodeDecodeError: + raise PVLParseError("Error parsing PVL label from " + "file: {}".format(path)) + return raw + + @staticmethod + def _remove_comments(raw): + return re.sub(patterns.COMMENT, '', raw) + + @staticmethod + def _parse(raw): + raw = LabelParser._remove_comments(raw) + label_iter = re.finditer(patterns.STATEMENT, raw) + return LabelParser._parse_iter(label_iter) + + @staticmethod + def _parse_iter(label_iter): + """Recursively parse a PVL label iterator""" + obj = {} + while True: + try: + # Try to fetch the next match from the iter + match = next(label_iter) + val = match.group('val') + key = match.group('key') + # Handle nested object groups + if key == 'OBJECT': + obj.update({ + val: LabelParser._parse_iter(label_iter) + }) + elif key == 'END_OBJECT': + return obj + # Add key/value pair to dict + else: + # Should this value be a numeric type? + try: + val = LabelParser._convert_to_numeric(val) + except ValueError: + pass + # Should this value have units? + if match.group('units'): + val = Quantity(val, match.group('units')) + # Add it to the dict + obj.update({key: val}) + except StopIteration: + break + return obj + + @staticmethod + def _convert_to_numeric(s): + """Convert a string to its appropriate numeric type""" + if re.match(patterns.INTEGER, s): + return int(s) + elif re.match(patterns.FLOATING, s): + return float(s) + else: + raise ValueError diff --git a/io_convert_image_to_mesh_img/pvl/patterns.py b/io_convert_image_to_mesh_img/pvl/patterns.py new file mode 100644 index 00000000..af921803 --- /dev/null +++ b/io_convert_image_to_mesh_img/pvl/patterns.py @@ -0,0 +1,59 @@ +# This file is a part of the HiRISE DTM Importer for Blender +# +# Copyright (C) 2017 Arizona Board of Regents on behalf of the Planetary Image +# Research Laboratory, Lunar and Planetary Laboratory at the University of +# Arizona. +# +# This program is free software: you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY +# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +# for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program. If not, see <http://www.gnu.org/licenses/>. + +"""PVL Regular Expression Patterns""" + +import re + +# End of PVL File +END = re.compile( + r'\s* \bEND\b \s*', (re.VERBOSE + re.IGNORECASE) +) + +# PVL Comment +COMMENT = re.compile( + r'/\* .*? \*/', (re.DOTALL + re.VERBOSE) +) + +# PVL Statement +STATEMENT = re.compile( + r""" + \s* (?P<key>\w+) # Match a PVL key + \s* = \s* # Who knows how many spaces we encounter + (?P<val> # Match a PVL value + ([+-]?\d+\.?\d*) # We could match a number + | (['"]?((\w+ \s*?)+)['"]?) # Or a string + ) + (\s* <(?P<units>.*?) >)? # The value may have an associated unit + """, re.VERBOSE +) + +# Integer Number +INTEGER = re.compile( + r""" + [+-]?(?<!\.)\b[0-9]+\b(?!\.[0-9]) + """, re.VERBOSE +) + +# Floating Point Number +FLOATING = re.compile( + r""" + [+-]?\b[0-9]*\.?[0-9]+ + """, re.VERBOSE +) diff --git a/io_convert_image_to_mesh_img/ui/__init__.py b/io_convert_image_to_mesh_img/ui/__init__.py new file mode 100644 index 00000000..57bd9fde --- /dev/null +++ b/io_convert_image_to_mesh_img/ui/__init__.py @@ -0,0 +1,25 @@ +# This file is a part of the HiRISE DTM Importer for Blender +# +# Copyright (C) 2017 Arizona Board of Regents on behalf of the Planetary Image +# Research Laboratory, Lunar and Planetary Laboratory at the University of +# Arizona. +# +# This program is free software: you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY +# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +# for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program. If not, see <http://www.gnu.org/licenses/>. + +"""A sub-package for Blender UI elements""" + +from . import importer +from . import terrainpanel + +__all__ = ['importer', 'terrainpanel', ] diff --git a/io_convert_image_to_mesh_img/ui/importer.py b/io_convert_image_to_mesh_img/ui/importer.py new file mode 100644 index 00000000..981e49c2 --- /dev/null +++ b/io_convert_image_to_mesh_img/ui/importer.py @@ -0,0 +1,177 @@ +# This file is a part of the HiRISE DTM Importer for Blender +# +# Copyright (C) 2017 Arizona Board of Regents on behalf of the Planetary Image +# Research Laboratory, Lunar and Planetary Laboratory at the University of +# Arizona. +# +# This program is free software: you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY +# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +# for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program. If not, see <http://www.gnu.org/licenses/>. + +"""Blender menu importer for loading a DTM""" + +import bpy +import bpy.props +from bpy_extras.io_utils import ImportHelper + +from ..mesh.terrain import BTerrain +from ..mesh.dtm import DTM + + +class ImportHiRISETerrain(bpy.types.Operator, ImportHelper): + """DTM Import Helper""" + bl_idname = "import.pds_dtm" + bl_label = "Import HiRISE Terrain Model" + bl_options = {'UNDO'} + filename_ext = ".img" + filter_glob = bpy.props.StringProperty( + options={'HIDDEN'}, + default="*.img" + ) + + # Allow the user to specify a resolution factor for loading the + # terrain data at. This is useful because it allows the user to stage + # a scene with a low resolution terrain map, apply textures, modifiers, + # etc. and then increase the resolution to prepare for final rendering. + # + # Displaying this value as a percentage (0, 100] is an intuitive way + # for users to grasp what this value does. The DTM importer, however, + # wants to recieve a value between (0, 1]. This is obviously a + # straightforward conversion: + # + # f(x) = x / 100 + # + # But this conversion should happen here, in the terrain panel, rather + # than in the DTM importing utility itself. We can't pass get/set + # functions to the property itself because they result in a recursion + # error. Instead, we use another, hidden, property to store the scaled + # resolution. + dtm_resolution = bpy.props.FloatProperty( + subtype="PERCENTAGE", + description=( + "Percentage scale for terrain model resolution. 100\% loads the " + "model at full resolution (i.e. one vertex for each post in the " + "original terrain model) and is *MEMORY INTENSIVE*. Downsampling " + "uses Nearest Neighbors. You will be able to increase the " + "resolution of your mesh later, and still maintain all textures, " + "transformations, modifiers, etc., so best practice is to start " + "small. The downsampling algorithm may need to alter the " + "resolution you specify here to ensure it results in a whole " + "number of vertices. If it needs to alter the value you specify, " + "you are guaranteed that it will shrink it (i.e. decrease the " + "DTM resolution." + ), + name="Terrain Model Resolution", + min=1.0, max=100.0, default=10.0 + ) + scaled_dtm_resolution = bpy.props.FloatProperty( + options={'HIDDEN'}, + name="Scaled Terrain Model Resolution", + get=(lambda self: self.dtm_resolution / 100) + ) + + # HiRISE DTMs are huge, but it can be nice to load them in at scale. Here, + # we present the user with the option of setting up the Blender viewport + # to avoid a couple of common pitfalls encountered when working with such + # a large mesh. + # + # 1. The Blender viewport has a default clipping distance of 1km. HiRISE + # DTMs are often many kilometers in each direction. If this setting is + # not changed, an unsuspecting user may only see part (or even nothing + # at all) of the terrain. This option (true, by default) instructs + # Blender to change the clipping distance to something appropriate for + # the DTM, and scales the grid floor to have gridlines 1km apart, + # instead of 1m apart. + should_setup_viewport = bpy.props.BoolProperty( + description=( + "Set up the Blender screen to try and avoid clipping the DTM " + "and to make the grid floor larger. *WARNING* This will change " + "clipping distances and the Blender grid floor, and will fit the " + "DTM in the viewport." + ), + name="Setup Blender Scene", default=True + ) + # 2. Blender's default units are dimensionless. This option instructs + # Blender to change its unit's dimension to meters. + should_setup_units = bpy.props.BoolProperty( + description=( + "Set the Blender scene to use meters as its unit." + ), + name="Set Blender Units to Meters", default=True + ) + + def execute(self, context): + """Runs when the "Import HiRISE Terrain Model" button is pressed""" + filepath = bpy.path.ensure_ext(self.filepath, self.filename_ext) + # Create a BTerrain from the DTM + dtm = DTM(filepath, self.scaled_dtm_resolution) + BTerrain.new(dtm) + + # Set up the Blender UI + if self.should_setup_units: + self._setup_units(context) + if self.should_setup_viewport: + self._setup_viewport(context) + + return {"FINISHED"} + + def _setup_units(self, context): + """Sets up the Blender scene for viewing the DTM""" + scene = bpy.context.scene + + # Set correct units + scene.unit_settings.system = 'METRIC' + scene.unit_settings.scale_length = 1.0 + + return {'FINISHED'} + + def _setup_viewport(self, context): + """Sets up the Blender screen to make viewing the DTM easier""" + screen = bpy.context.screen + + # Fetch the 3D_VIEW Area + for area in screen.areas: + if area.type == 'VIEW_3D': + space = area.spaces[0] + # Adjust 3D View Properties + # TODO: Can these be populated more intelligently? + space.clip_end = 100000 + space.grid_scale = 1000 + space.grid_lines = 50 + + # Fly to a nice view of the DTM + self._view_dtm(context) + + return {'FINISHED'} + + def _view_dtm(self, context): + """Sets up the Blender screen to make viewing the DTM easier""" + screen = bpy.context.screen + + # Fetch the 3D_VIEW Area + for area in screen.areas: + if area.type == 'VIEW_3D': + # Move the camera around in the viewport. This requires + # a context override. + for region in area.regions: + if region.type == 'WINDOW': + override = { + 'area': area, + 'region': region, + 'edit_object': bpy.context.edit_object + } + # Center View on DTM (SHORTCUT: '.') + bpy.ops.view3d.view_selected(override) + # Move to 'TOP' viewport (SHORTCUT: NUMPAD7) + bpy.ops.view3d.viewnumpad(override, type='TOP') + + return {'FINISHED'} diff --git a/io_convert_image_to_mesh_img/ui/terrainpanel.py b/io_convert_image_to_mesh_img/ui/terrainpanel.py new file mode 100644 index 00000000..80dfe8e4 --- /dev/null +++ b/io_convert_image_to_mesh_img/ui/terrainpanel.py @@ -0,0 +1,133 @@ +# This file is a part of the HiRISE DTM Importer for Blender +# +# Copyright (C) 2017 Arizona Board of Regents on behalf of the Planetary Image +# Research Laboratory, Lunar and Planetary Laboratory at the University of +# Arizona. +# +# This program is free software: you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY +# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +# for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program. If not, see <http://www.gnu.org/licenses/>. + +"""Blender panel for managing a DTM *after* it's been imported""" + +import bpy + +from ..mesh.terrain import BTerrain +from ..mesh.dtm import DTM + + +class TerrainPanel(bpy.types.Panel): + """Creates a Panel in the Object properites window for terrain objects""" + bl_label = "Terrain Model" + bl_idname = "OBJECT_PT_terrain" + bl_space_type = "PROPERTIES" + bl_region_type = "WINDOW" + bl_context = "object" + + # Allow the user to specify a new resolution factor for reloading the + # terrain data at. This is useful because it allows the user to stage + # a scene with a low resolution terrain map, apply textures, modifiers, + # etc. and then increase the resolution to prepare for final rendering. + # + # Displaying this value as a percentage (0, 100] is an intuitive way + # for users to grasp what this value does. The DTM importer, however, + # wants to recieve a value between (0, 1]. This is obviously a + # straightforward conversion: + # + # f(x) = x / 100 + # + # But this conversion should happen here, in the terrain panel, rather + # than in the DTM importing utility itself. We can't pass get/set + # functions to the property itself because they result in a recursion + # error. Instead, we use another, hidden, property to store the scaled + # resolution. + bpy.types.Object.dtm_resolution = bpy.props.FloatProperty( + subtype="PERCENTAGE", + name="New Resolution", + description=( + "Percentage scale for terrain model resolution. 100\% loads the " + "model at full resolution (i.e. one vertex for each post in the " + "original terrain model) and is *MEMORY INTENSIVE*. Downsampling " + "uses Nearest Neighbors. The downsampling algorithm may need to " + "alter the resolution you specify here to ensure it results in a " + "whole number of vertices. If it needs to alter the value you " + "specify, you are guaranteed that it will shrink it (i.e. " + "decrease the DTM resolution." + ), + min=1.0, max=100.0, default=10.0 + ) + bpy.types.Object.scaled_dtm_resolution = bpy.props.FloatProperty( + options={'HIDDEN'}, + name="Scaled Terrain Model Resolution", + get=(lambda self: self.dtm_resolution / 100.0) + ) + + @classmethod + def poll(cls, context): + return context.object.get("IS_TERRAIN", False) + + def draw(self, context): + obj = context.active_object + layout = self.layout + + # User Controls + layout.prop(obj, 'dtm_resolution') + layout.operator("terrain.reload") + + # Metadata + self.draw_metadata_panel(context) + + def draw_metadata_panel(self, context): + """Display some metadata about the DTM""" + obj = context.active_object + layout = self.layout + + metadata_panel = layout.box() + + dtm_resolution = metadata_panel.row() + dtm_resolution.label('Current Resolution: ') + dtm_resolution.label('{:9,.2%}'.format( + obj['DTM_RESOLUTION'] + )) + + mesh_scale = metadata_panel.row() + mesh_scale.label('Current Scale: ') + mesh_scale.label('{:9,.2f} m/post'.format( + obj['MESH_SCALE'] + )) + + dtm_scale = metadata_panel.row() + dtm_scale.label('Original Scale: ') + dtm_scale.label('{:9,.2f} m/post'.format( + obj['MAP_SCALE'] + )) + + return {'FINISHED'} + + +class ReloadTerrain(bpy.types.Operator): + """Button for reloading the terrain mesh at a new resolution.""" + bl_idname = "terrain.reload" + bl_label = "Reload Terrain" + + def execute(self, context): + # Reload the terrain + obj = context.object + path = obj['PATH'] + + scaled_dtm_resolution = obj.scaled_dtm_resolution + + # Reload BTerrain with new DTM + dtm = DTM(path, scaled_dtm_resolution) + BTerrain.reload(obj, dtm) + + return {"FINISHED"} |