io_convert_image_to_mesh_img: complete rewrite: T51754
This commit is contained in:
parent
c667612755
commit
722e9d5428
|
@ -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()
|
||||
|
|
|
@ -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'}
|
|
@ -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', ]
|
|
@ -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)
|
|
@ -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
|
||||
}
|
|
@ -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)
|
|
@ -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)
|
|
@ -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)
|
|
@ -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
|
|
@ -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
|
||||
)
|
|
@ -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', ]
|
|
@ -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'}
|
|
@ -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"}
|
Loading…
Reference in New Issue