io_convert_image_to_mesh_img: complete rewrite: T51754

This commit is contained in:
Brendon Murphy 2017-06-15 22:13:42 +10:00
parent c667612755
commit 722e9d5428
13 changed files with 1309 additions and 820 deletions

View File

@ -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 #####
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",
"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",
}
# 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
# 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 bpy.props import *
from bpy_extras.io_utils import ImportHelper
from .ui import importer
from .ui import terrainpanel
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'}
bl_info = {
"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",
"category": "Import-Export",
"wiki_url": "", # TBD
"tracker_url": "", # TBD
"link": "", # TBD
"support": "TESTING",
}
filename_ext = ".IMG"
filter_glob = StringProperty(default="*.IMG", options={'HIDDEN'})
if "bpy" in locals():
import imp
imp.reload(importer)
imp.reload(terrainpanel)
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()

View File

@ -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'}

View File

@ -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', ]

View File

@ -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)

View File

@ -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
}

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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

View File

@ -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
)

View File

@ -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', ]

View File

@ -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'}

View File

@ -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"}