Merge pull request #1587 from pierotofy/gltf

glTF models, draco compression
pull/1589/head
Piero Toffanin 2023-01-28 11:22:43 -05:00 zatwierdzone przez GitHub
commit 28e51a4901
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: 4AEE18F83AFDEB23
7 zmienionych plików z 360 dodań i 19 usunięć

Wyświetl plik

@ -245,3 +245,14 @@ externalproject_add(lastools
SOURCE_DIR ${SB_SOURCE_DIR}/lastools
CMAKE_ARGS -DCMAKE_INSTALL_PREFIX:PATH=${SB_INSTALL_DIR}
)
externalproject_add(draco
GIT_REPOSITORY https://github.com/OpenDroneMap/draco
GIT_SHALLOW ON
GIT_TAG 304
PREFIX ${SB_BINARY_DIR}/draco
SOURCE_DIR ${SB_SOURCE_DIR}/draco
CMAKE_ARGS -DDRACO_TRANSCODER_SUPPORTED=ON
-DCMAKE_INSTALL_PREFIX:PATH=${SB_INSTALL_DIR}
${WIN32_CMAKE_ARGS}
)

Wyświetl plik

@ -461,6 +461,13 @@ def config(argv=None, parser=None):
help=('Generate OBJs that have a single material and a single texture file instead of multiple ones. '
'Default: %(default)s'))
parser.add_argument('--gltf',
action=StoreTrue,
nargs=0,
default=False,
help=('Generate single file Binary glTF (GLB) textured models. '
'Default: %(default)s'))
parser.add_argument('--gcp',
metavar='<path string>',
action=StoreValue,

Wyświetl plik

@ -41,7 +41,7 @@ settings_path = os.path.join(root_path, 'settings.yaml')
# Define supported image extensions
supported_extensions = {'.jpg','.jpeg','.png', '.tif', '.tiff', '.bmp'}
supported_video_extensions = {'.mp4', '.mov'}
supported_video_extensions = {'.mp4', '.mov', '.lrv', '.ts'}
# Define the number of cores
num_cores = multiprocessing.cpu_count()

308
opendm/gltf.py 100644
Wyświetl plik

@ -0,0 +1,308 @@
import os
import rasterio
from rasterio.io import MemoryFile
import warnings
import numpy as np
import pygltflib
from opendm import system
from opendm import io
warnings.filterwarnings("ignore", category=rasterio.errors.NotGeoreferencedWarning)
def load_obj(obj_path, _info=print):
if not os.path.isfile(obj_path):
raise IOError("Cannot open %s" % obj_path)
obj_base_path = os.path.dirname(os.path.abspath(obj_path))
obj = {
'materials': {},
}
vertices = []
uvs = []
normals = []
faces = {}
current_material = "_"
with open(obj_path) as f:
_info("Loading %s" % obj_path)
for line in f:
if line.startswith("mtllib "):
# Materials
mtl_file = "".join(line.split()[1:]).strip()
obj['materials'].update(load_mtl(mtl_file, obj_base_path, _info=_info))
elif line.startswith("v "):
# Vertices
vertices.append(list(map(float, line.split()[1:4])))
elif line.startswith("vt "):
# UVs
uvs.append(list(map(float, line.split()[1:3])))
elif line.startswith("vn "):
normals.append(list(map(float, line.split()[1:4])))
elif line.startswith("usemtl "):
mtl_name = "".join(line.split()[1:]).strip()
if not mtl_name in obj['materials']:
raise Exception("%s material is missing" % mtl_name)
current_material = mtl_name
elif line.startswith("f "):
if current_material not in faces:
faces[current_material] = []
a,b,c = line.split()[1:]
if a.count("/") == 2:
av, at, an = map(int, a.split("/")[0:3])
bv, bt, bn = map(int, b.split("/")[0:3])
cv, ct, cn = map(int, c.split("/")[0:3])
faces[current_material].append((av - 1, bv - 1, cv - 1, at - 1, bt - 1, ct - 1, an - 1, bn - 1, cn - 1))
else:
av, at = map(int, a.split("/")[0:2])
bv, bt = map(int, b.split("/")[0:2])
cv, ct = map(int, c.split("/")[0:2])
faces[current_material].append((av - 1, bv - 1, cv - 1, at - 1, bt - 1, ct - 1))
obj['vertices'] = np.array(vertices, dtype=np.float32)
obj['uvs'] = np.array(uvs, dtype=np.float32)
obj['normals'] = np.array(normals, dtype=np.float32)
obj['faces'] = faces
obj['materials'] = convert_materials_to_jpeg(obj['materials'])
return obj
def convert_materials_to_jpeg(materials):
min_value = 0
value_range = 0
skip_conversion = False
for mat in materials:
image = materials[mat]
# Stop here, assuming all other materials are also uint8
if image.dtype == np.uint8:
skip_conversion = True
break
# Find common min/range values
try:
data_range = np.iinfo(image.dtype)
min_value = min(min_value, 0)
value_range = max(value_range, float(data_range.max) - float(data_range.min))
except ValueError:
# For floats use the actual range of the image values
min_value = min(min_value, float(image.min()))
value_range = max(value_range, float(image.max()) - min_value)
if value_range == 0:
value_range = 255 # Should never happen
for mat in materials:
image = materials[mat]
if not skip_conversion:
image = image.astype(np.float32)
image -= min_value
image *= 255.0 / value_range
np.around(image, out=image)
image[image > 255] = 255
image[image < 0] = 0
image = image.astype(np.uint8)
with MemoryFile() as memfile:
bands, h, w = image.shape
bands = min(3, bands)
with memfile.open(driver='JPEG', jpeg_quality=90, count=bands, width=w, height=h, dtype=rasterio.dtypes.uint8) as dst:
for b in range(1, min(3, bands) + 1):
dst.write(image[b - 1], b)
memfile.seek(0)
materials[mat] = memfile.read()
return materials
def load_mtl(mtl_file, obj_base_path, _info=print):
mtl_file = os.path.join(obj_base_path, mtl_file)
if not os.path.isfile(mtl_file):
raise IOError("Cannot open %s" % mtl_file)
mats = {}
current_mtl = ""
with open(mtl_file) as f:
for line in f:
if line.startswith("newmtl "):
current_mtl = "".join(line.split()[1:]).strip()
elif line.startswith("map_Kd ") and current_mtl:
map_kd_filename = "".join(line.split()[1:]).strip()
map_kd = os.path.join(obj_base_path, map_kd_filename)
if not os.path.isfile(map_kd):
raise IOError("Cannot open %s" % map_kd)
_info("Loading %s" % map_kd_filename)
with rasterio.open(map_kd, 'r') as src:
mats[current_mtl] = src.read()
return mats
def paddedBuffer(buf, boundary):
r = len(buf) % boundary
if r == 0:
return buf
pad = boundary - r
return buf + b'\x00' * pad
def obj2glb(input_obj, output_glb, rtc=(None, None), draco_compression=True, _info=print):
_info("Converting %s --> %s" % (input_obj, output_glb))
obj = load_obj(input_obj, _info=_info)
vertices = obj['vertices']
uvs = obj['uvs']
# Flip Y
uvs = (([0, 1] - (uvs * [0, 1])) + uvs * [1, 0]).astype(np.float32)
normals = obj['normals']
binary = b''
accessors = []
bufferViews = []
primitives = []
materials = []
textures = []
images = []
bufOffset = 0
def addBufferView(buf, target=None):
nonlocal bufferViews, bufOffset
bufferViews += [pygltflib.BufferView(
buffer=0,
byteOffset=bufOffset,
byteLength=len(buf),
target=target,
)]
bufOffset += len(buf)
return len(bufferViews) - 1
for material in obj['faces'].keys():
faces = obj['faces'][material]
faces = np.array(faces, dtype=np.uint32)
prim_vertices = vertices[faces[:,0:3].flatten()]
prim_uvs = uvs[faces[:,3:6].flatten()]
if faces.shape[1] == 9:
prim_normals = normals[faces[:,6:9].flatten()]
normals_blob = prim_normals.tobytes()
else:
prim_normals = None
normals_blob = None
vertices_blob = prim_vertices.tobytes()
uvs_blob = prim_uvs.tobytes()
binary += vertices_blob + uvs_blob
if normals_blob is not None:
binary += normals_blob
verticesBufferView = addBufferView(vertices_blob, pygltflib.ARRAY_BUFFER)
uvsBufferView = addBufferView(uvs_blob, pygltflib.ARRAY_BUFFER)
normalsBufferView = None
if normals_blob is not None:
normalsBufferView = addBufferView(normals_blob, pygltflib.ARRAY_BUFFER)
accessors += [
pygltflib.Accessor(
bufferView=verticesBufferView,
componentType=pygltflib.FLOAT,
count=len(prim_vertices),
type=pygltflib.VEC3,
max=prim_vertices.max(axis=0).tolist(),
min=prim_vertices.min(axis=0).tolist(),
),
pygltflib.Accessor(
bufferView=uvsBufferView,
componentType=pygltflib.FLOAT,
count=len(prim_uvs),
type=pygltflib.VEC2,
max=prim_uvs.max(axis=0).tolist(),
min=prim_uvs.min(axis=0).tolist(),
),
]
if prim_normals is not None:
accessors += [
pygltflib.Accessor(
bufferView=normalsBufferView,
componentType=pygltflib.FLOAT,
count=len(prim_normals),
type=pygltflib.VEC3,
max=prim_normals.max(axis=0).tolist(),
min=prim_normals.min(axis=0).tolist(),
)
]
primitives += [pygltflib.Primitive(
attributes=pygltflib.Attributes(POSITION=verticesBufferView, TEXCOORD_0=uvsBufferView, NORMAL=normalsBufferView), material=len(primitives)
)]
for material in obj['faces'].keys():
texture_blob = paddedBuffer(obj['materials'][material], 4)
binary += texture_blob
textureBufferView = addBufferView(texture_blob)
images += [pygltflib.Image(bufferView=textureBufferView, mimeType="image/jpeg")]
textures += [pygltflib.Texture(source=len(images) - 1, sampler=0)]
mat = pygltflib.Material(pbrMetallicRoughness=pygltflib.PbrMetallicRoughness(baseColorTexture=pygltflib.TextureInfo(index=len(textures) - 1), metallicFactor=0, roughnessFactor=1),
alphaMode=pygltflib.OPAQUE)
mat.extensions = {
'KHR_materials_unlit': {}
}
materials += [mat]
gltf = pygltflib.GLTF2(
scene=0,
scenes=[pygltflib.Scene(nodes=[0])],
nodes=[pygltflib.Node(mesh=0)],
meshes=[pygltflib.Mesh(
primitives=primitives
)],
materials=materials,
textures=textures,
samplers=[pygltflib.Sampler(magFilter=pygltflib.LINEAR, minFilter=pygltflib.LINEAR)],
images=images,
accessors=accessors,
bufferViews=bufferViews,
buffers=[pygltflib.Buffer(byteLength=len(binary))],
)
gltf.extensionsRequired = ['KHR_materials_unlit']
if rtc != (None, None) and len(rtc) >= 2:
gltf.extensionsUsed = ['CESIUM_RTC', 'KHR_materials_unlit']
gltf.extensions = {
'CESIUM_RTC': {
'center': [float(rtc[0]), float(rtc[1]), 0.0]
}
}
gltf.set_binary_blob(binary)
_info("Writing...")
gltf.save(output_glb)
_info("Wrote %s" % output_glb)
if draco_compression:
_info("Compressing with draco")
try:
compressed_glb = io.related_file_path(output_glb, postfix="_compressed")
system.run('draco_transcoder -i "{}" -o "{}" -qt 16 -qp 16'.format(output_glb, compressed_glb))
if os.path.isfile(compressed_glb) and os.path.isfile(output_glb):
os.remove(output_glb)
os.rename(compressed_glb, output_glb)
except Exception as e:
log.ODM_WARNING("Cannot compress GLB with draco: %s" % str(e))

Wyświetl plik

@ -289,6 +289,7 @@ class ODM_Tree(object):
# texturing
self.odm_textured_model_obj = 'odm_textured_model_geo.obj'
self.odm_textured_model_glb = 'odm_textured_model_geo.glb'
# odm_georeferencing
self.odm_georeferencing_coords = os.path.join(

Wyświetl plik

@ -31,6 +31,7 @@ xmltodict==0.12.0
fpdf2==2.4.6
Shapely==1.7.1
onnxruntime==1.12.1
pygltflib==1.15.3
codem==0.24.0
trimesh==3.17.1
pandas==1.5.2

Wyświetl plik

@ -8,6 +8,7 @@ from opendm import types
from opendm.multispectral import get_primary_band_name
from opendm.photo import find_largest_photo_dim
from opendm.objpacker import obj_pack
from opendm.gltf import obj2glb
class ODMMvsTexStage(types.ODM_Stage):
def process(self, args, outputs):
@ -129,26 +130,38 @@ class ODMMvsTexStage(types.ODM_Stage):
'{nadirMode} '
'{labelingFile} '
'{maxTextureSize} '.format(**kwargs))
# Single material?
if args.texturing_single_material and r['primary'] and (not r['nadir'] or args.skip_3dmodel):
log.ODM_INFO("Packing to single material")
packed_dir = os.path.join(r['out_dir'], 'packed')
if io.dir_exists(packed_dir):
log.ODM_INFO("Removing old packed directory {}".format(packed_dir))
shutil.rmtree(packed_dir)
try:
obj_pack(os.path.join(r['out_dir'], tree.odm_textured_model_obj), packed_dir, _info=log.ODM_INFO)
if r['primary'] and (not r['nadir'] or args.skip_3dmodel):
# GlTF?
if args.gltf:
log.ODM_INFO("Generating glTF Binary")
odm_textured_model_glb = os.path.join(r['out_dir'], tree.odm_textured_model_glb)
try:
obj2glb(odm_textured_model_obj, odm_textured_model_glb, rtc=reconstruction.get_proj_offset(), _info=log.ODM_INFO)
except Exception as e:
log.ODM_WARNING(str(e))
# Single material?
if args.texturing_single_material:
log.ODM_INFO("Packing to single material")
packed_dir = os.path.join(r['out_dir'], 'packed')
if io.dir_exists(packed_dir):
log.ODM_INFO("Removing old packed directory {}".format(packed_dir))
shutil.rmtree(packed_dir)
# Move packed/* into texturing folder
system.delete_files(r['out_dir'], (".vec", ))
system.move_files(packed_dir, r['out_dir'])
if os.path.isdir(packed_dir):
os.rmdir(packed_dir)
except Exception as e:
log.ODM_WARNING(str(e))
try:
obj_pack(os.path.join(r['out_dir'], tree.odm_textured_model_obj), packed_dir, _info=log.ODM_INFO)
# Move packed/* into texturing folder
system.delete_files(r['out_dir'], (".vec", ))
system.move_files(packed_dir, r['out_dir'])
if os.path.isdir(packed_dir):
os.rmdir(packed_dir)
except Exception as e:
log.ODM_WARNING(str(e))
# Backward compatibility: copy odm_textured_model_geo.mtl to odm_textured_model.mtl
# for certain older WebODM clients which expect a odm_textured_model.mtl