Merge pull request #1582 from pierotofy/singlemat

Add --texturing-single-material
pull/1583/head
Piero Toffanin 2023-01-11 16:03:38 -05:00 zatwierdzone przez GitHub
commit bf824d3583
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: 4AEE18F83AFDEB23
8 zmienionych plików z 576 dodań i 1 usunięć

Wyświetl plik

@ -454,6 +454,13 @@ def config(argv=None, parser=None):
help=('Keep faces in the mesh that are not seen in any camera. ' help=('Keep faces in the mesh that are not seen in any camera. '
'Default: %(default)s')) 'Default: %(default)s'))
parser.add_argument('--texturing-single-material',
action=StoreTrue,
nargs=0,
default=False,
help=('Generate OBJs that have a single material and a single texture file instead of multiple ones. '
'Default: %(default)s'))
parser.add_argument('--gcp', parser.add_argument('--gcp',
metavar='<path string>', metavar='<path string>',
action=StoreValue, action=StoreValue,

Wyświetl plik

@ -0,0 +1 @@
from .objpacker import obj_pack

Wyświetl plik

@ -0,0 +1 @@
from .imagepacker import pack

Wyświetl plik

@ -0,0 +1,239 @@
#! /usr/bin/python
# The MIT License (MIT)
# Copyright (c) 2015 Luke Gaynor
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
import rasterio
import numpy as np
import math
# Based off of the great writeup, demo and code at:
# http://codeincomplete.com/posts/2011/5/7/bin_packing/
class Block():
"""A rectangular block, to be packed"""
def __init__(self, w, h, data=None, padding=0):
self.w = w
self.h = h
self.x = None
self.y = None
self.fit = None
self.data = data
self.padding = padding # not implemented yet
def __str__(self):
return "({x},{y}) ({w}x{h}): {data}".format(
x=self.x,y=self.y, w=self.w,h=self.h, data=self.data)
class _BlockNode():
"""A BlockPacker node"""
def __init__(self, x, y, w, h, used=False, right=None, down=None):
self.x = x
self.y = y
self.w = w
self.h = h
self.used = used
self.right = right
self.down = down
def __repr__(self):
return "({x},{y}) ({w}x{h})".format(x=self.x,y=self.y,w=self.w,h=self.h)
class BlockPacker():
"""Packs blocks of varying sizes into a single, larger block"""
def __init__(self):
self.root = None
def fit(self, blocks):
nblocks = len(blocks)
w = blocks[0].w# if nblocks > 0 else 0
h = blocks[0].h# if nblocks > 0 else 0
self.root = _BlockNode(0,0, w,h)
for block in blocks:
node = self.find_node(self.root, block.w, block.h)
if node:
# print("split")
node_fit = self.split_node(node, block.w, block.h)
block.x = node_fit.x
block.y = node_fit.y
else:
# print("grow")
node_fit = self.grow_node(block.w, block.h)
block.x = node_fit.x
block.y = node_fit.y
def find_node(self, root, w, h):
if root.used:
# raise Exception("used")
node = self.find_node(root.right, w, h)
if node:
return node
return self.find_node(root.down, w, h)
elif w <= root.w and h <= root.h:
return root
else:
return None
def split_node(self, node, w, h):
node.used = True
node.down = _BlockNode(
node.x, node.y + h,
node.w, node.h - h
)
node.right = _BlockNode(
node.x + w, node.y,
node.w - w, h
)
return node
def grow_node(self, w, h):
can_grow_down = w <= self.root.w
can_grow_right = h <= self.root.h
# try to keep the packing square
should_grow_right = can_grow_right and self.root.h >= (self.root.w + w)
should_grow_down = can_grow_down and self.root.w >= (self.root.h + h)
if should_grow_right:
return self.grow_right(w, h)
elif should_grow_down:
return self.grow_down(w, h)
elif can_grow_right:
return self.grow_right(w, h)
elif can_grow_down:
return self.grow_down(w, h)
else:
raise Exception("no valid expansion avaliable!")
def grow_right(self, w, h):
old_root = self.root
self.root = _BlockNode(
0, 0,
old_root.w + w, old_root.h,
down=old_root,
right=_BlockNode(self.root.w, 0, w, self.root.h),
used=True
)
node = self.find_node(self.root, w, h)
if node:
return self.split_node(node, w, h)
else:
return None
def grow_down(self, w, h):
old_root = self.root
self.root = _BlockNode(
0, 0,
old_root.w, old_root.h + h,
down=_BlockNode(0, self.root.h, self.root.w, h),
right=old_root,
used=True
)
node = self.find_node(self.root, w, h)
if node:
return self.split_node(node, w, h)
else:
return None
def crop_by_extents(image, extent):
if min(extent.min_x,extent.min_y) < 0 or max(extent.max_x,extent.max_y) > 1:
print("\tWARNING! UV Coordinates lying outside of [0:1] space!")
_, h, w = image.shape
minx = max(math.floor(extent.min_x*w), 0)
miny = max(math.floor(extent.min_y*h), 0)
maxx = min(math.ceil(extent.max_x*w), w)
maxy = min(math.ceil(extent.max_y*h), h)
image = image[:, miny:maxy, minx:maxx]
delta_w = maxx - minx
delta_h = maxy - miny
# offset from origin x, y, horizontal scale, vertical scale
changes = (minx, miny, delta_w / w, delta_h / h)
return (image, changes)
def pack(obj, background=(0,0,0,0), format="PNG", extents=None):
blocks = []
image_name_map = {}
profile = None
for mat in obj['materials']:
filename = obj['materials'][mat]
with rasterio.open(filename, 'r') as f:
profile = f.profile
image = f.read()
image = np.flip(image, axis=1)
changes = None
if extents and extents[mat]:
image, changes = crop_by_extents(image, extents[mat])
image_name_map[filename] = image
_, h, w = image.shape
# using filename so we can pass back UV info without storing it in image
blocks.append(Block(w, h, data=(filename, mat, changes)))
# sort by width, descending (widest first)
blocks.sort(key=lambda block: -block.w)
packer = BlockPacker()
packer.fit(blocks)
# output_image = Image.new("RGBA", (packer.root.w, packer.root.h))
output_image = np.zeros((profile['count'], packer.root.h, packer.root.w), dtype=profile['dtype'])
uv_changes = {}
for block in blocks:
fname, mat, changes = block.data
image = image_name_map[fname]
_, im_h, im_w = image.shape
uv_changes[mat] = {
"offset": (
# should be in [0, 1] range
(block.x - (changes[0] if changes else 0))/output_image.shape[2],
# UV origin is bottom left, PIL assumes top left!
(block.y - (changes[1] if changes else 0))/output_image.shape[1]
),
"aspect": (
((1/changes[2]) if changes else 1) * (im_w/output_image.shape[2]),
((1/changes[3]) if changes else 1) * (im_h/output_image.shape[1])
),
}
output_image[:, block.y:block.y + im_h, block.x:block.x + im_w] = image
output_image = np.flip(output_image, axis=1)
return output_image, uv_changes, profile

Wyświetl plik

@ -0,0 +1,53 @@
#! /usr/bin/python
# The MIT License (MIT)
# Copyright (c) 2015 Luke Gaynor
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
class AABB():
def __init__(self, min_x=None, min_y=None, max_x=None, max_y=None):
self.min_x = min_x
self.min_y = min_y
self.max_x = max_x
self.max_y = max_y
def add(self, x,y):
self.min_x = min(self.min_x, x) if self.min_x is not None else x
self.min_y = min(self.min_y, y) if self.min_y is not None else y
self.max_x = max(self.max_x, x) if self.max_x is not None else x
self.max_y = max(self.max_y, y) if self.max_y is not None else y
def uv_wrap(self):
return (self.max_x - self.min_x, self.max_y - self.min_y)
def tiling(self):
if self.min_x and self.max_x and self.min_y and self.max_y:
if self.min_x < 0 or self.min_y < 0 or self.max_x > 1 or self.max_y > 1:
return (self.max_x - self.min_x, self.max_y - self.min_y)
return None
def __repr__(self):
return "({},{}) ({},{})".format(
self.min_x,
self.min_y,
self.max_x,
self.max_y
)

Wyświetl plik

@ -0,0 +1,235 @@
import os
import rasterio
import warnings
import numpy as np
try:
from .imagepacker.utils import AABB
from .imagepacker import pack
except ImportError:
from imagepacker.utils import AABB
from imagepacker import pack
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 = {
'filename': os.path.basename(obj_path),
'root_dir': os.path.dirname(os.path.abspath(obj_path)),
'mtl_filenames': [],
'materials': {},
}
uvs = []
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))
obj['mtl_filenames'].append(mtl_file)
# 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:]
at = int(a.split("/")[1])
bt = int(b.split("/")[1])
ct = int(c.split("/")[1])
faces[current_material].append((at - 1, bt - 1, ct - 1))
obj['uvs'] = np.array(uvs, dtype=np.float32)
obj['faces'] = faces
return obj
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)
mats[current_mtl] = map_kd
return mats
def write_obj_changes(obj_file, mtl_file, uv_changes, single_mat, output_dir, _info=print):
with open(obj_file) as f:
obj_lines = f.readlines()
out_lines = []
uv_lines = []
current_material = None
printed_mtllib = False
printed_usemtl = False
_info("Transforming UV coordinates")
for line_idx, line in enumerate(obj_lines):
if line.startswith("mtllib"):
if not printed_mtllib:
out_lines.append("mtllib %s\n" % mtl_file)
printed_mtllib = True
else:
out_lines.append("# \n")
elif line.startswith("usemtl"):
if not printed_usemtl:
out_lines.append("usemtl %s\n" % single_mat)
printed_usemtl = True
else:
out_lines.append("# \n")
current_material = line[7:].strip()
elif line.startswith("vt"):
uv_lines.append(line_idx)
out_lines.append(line)
elif line.startswith("f"):
for v in line[2:].split():
parts = v.split("/")
if len(parts) >= 2 and parts[1]:
uv_idx = int(parts[1]) - 1 # uv indexes start from 1
uv_line_idx = uv_lines[uv_idx]
uv_line = obj_lines[uv_line_idx][3:]
uv = [float(uv.strip()) for uv in uv_line.split()]
if current_material and current_material in uv_changes:
changes = uv_changes[current_material]
uv[0] = uv[0] * changes["aspect"][0] + changes["offset"][0]
uv[1] = uv[1] * changes["aspect"][1] + changes["offset"][1]
out_lines[uv_line_idx] = "vt %s %s\n" % (uv[0], uv[1])
out_lines.append(line)
else:
out_lines.append(line)
out_file = os.path.join(output_dir, os.path.basename(obj_file))
_info("Writing %s" % out_file)
with open(out_file, 'w') as f:
f.writelines(out_lines)
def write_output_tex(img, profile, path, _info=print):
_, w, h = img.shape
profile['width'] = w
profile['height'] = h
if 'tiled' in profile:
profile['tiled'] = False
_info("Writing %s (%sx%s pixels)" % (path, w, h))
with rasterio.open(path, 'w', **profile) as dst:
for b in range(1, img.shape[0] + 1):
dst.write(img[b - 1], b)
sidecar = path + '.aux.xml'
if os.path.isfile(sidecar):
os.unlink(sidecar)
def write_output_mtl(src_mtl, mat_file, dst_mtl):
with open(src_mtl, 'r') as src:
lines = src.readlines()
out = []
found_map = False
single_mat = None
for l in lines:
if l.startswith("newmtl"):
single_mat = "".join(l.split()[1:]).strip()
out.append(l)
elif l.startswith("map_Kd"):
out.append("map_Kd %s\n" % mat_file)
break
else:
out.append(l)
with open(dst_mtl, 'w') as dst:
dst.write("".join(out))
if single_mat is None:
raise Exception("Could not find material name in file")
return single_mat
def obj_pack(obj_file, output_dir=None, _info=print):
if not output_dir:
output_dir = os.path.join(os.path.dirname(os.path.abspath(obj_file)), "packed")
obj = load_obj(obj_file, _info=_info)
if not obj['mtl_filenames']:
raise Exception("No MTL files found, nothing to do")
if os.path.abspath(obj_file) == os.path.abspath(os.path.join(output_dir, os.path.basename(obj_file))):
raise Exception("This will overwrite %s. Choose a different output directory" % obj_file)
if len(obj['mtl_filenames']) <= 1 and len(obj['materials']) <= 1:
raise Exception("File already has a single material, nothing to do")
# Compute AABB for UVs
_info("Computing texture bounds")
extents = {}
for material in obj['materials']:
bounds = AABB()
faces = obj['faces'][material]
for f in faces:
for uv_idx in f:
uv = obj['uvs'][uv_idx]
bounds.add(uv[0], uv[1])
extents[material] = bounds
_info("Binary packing...")
output_image, uv_changes, profile = pack(obj, extents=extents)
mtl_file = obj['mtl_filenames'][0]
mat_file = os.path.basename(obj['materials'][next(iter(obj['materials']))])
if not os.path.isdir(output_dir):
os.mkdir(output_dir)
write_output_tex(output_image, profile, os.path.join(output_dir, mat_file), _info=_info)
single_mat = write_output_mtl(os.path.join(obj['root_dir'], mtl_file), mat_file, os.path.join(output_dir, mtl_file))
write_obj_changes(obj_file, mtl_file, uv_changes, single_mat, output_dir, _info=_info)
if __name__ == '__main__':
import argparse
parser = argparse.ArgumentParser(description="Packs textured .OBJ Wavefront files into a single materials")
parser.add_argument("obj", help="Path to the .OBJ file")
parser.add_argument("-o","--output-dir", help="Output directory")
args = parser.parse_args()
obj_pack(args.obj, args.output_dir)

Wyświetl plik

@ -7,6 +7,7 @@ import subprocess
import string import string
import signal import signal
import io import io
import shutil
from collections import deque from collections import deque
from opendm import context from opendm import context
@ -154,3 +155,20 @@ def link_file(src, dst):
os.link(src, dst) os.link(src, dst)
else: else:
os.symlink(os.path.relpath(os.path.abspath(src), os.path.dirname(os.path.abspath(dst))), dst) os.symlink(os.path.relpath(os.path.abspath(src), os.path.dirname(os.path.abspath(dst))), dst)
def move_files(src, dst):
if not os.path.isdir(dst):
raise IOError("Not a directory: %s" % dst)
for f in os.listdir(src):
if os.path.isfile(os.path.join(src, f)):
shutil.move(os.path.join(src, f), dst)
def delete_files(folder, exclude=()):
if not os.path.isdir(folder):
return
for f in os.listdir(folder):
if os.path.isfile(os.path.join(folder, f)):
if not exclude or not f.endswith(exclude):
os.unlink(os.path.join(folder, f))

Wyświetl plik

@ -7,6 +7,7 @@ from opendm import context
from opendm import types from opendm import types
from opendm.multispectral import get_primary_band_name from opendm.multispectral import get_primary_band_name
from opendm.photo import find_largest_photo_dim from opendm.photo import find_largest_photo_dim
from opendm.objpacker import obj_pack
class ODMMvsTexStage(types.ODM_Stage): class ODMMvsTexStage(types.ODM_Stage):
def process(self, args, outputs): def process(self, args, outputs):
@ -129,6 +130,26 @@ class ODMMvsTexStage(types.ODM_Stage):
'{labelingFile} ' '{labelingFile} '
'{maxTextureSize} '.format(**kwargs)) '{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)
# 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 # 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 # for certain older WebODM clients which expect a odm_textured_model.mtl
# to be present for visualization # to be present for visualization
@ -137,7 +158,7 @@ class ODMMvsTexStage(types.ODM_Stage):
if io.file_exists(geo_mtl): if io.file_exists(geo_mtl):
nongeo_mtl = os.path.join(r['out_dir'], 'odm_textured_model.mtl') nongeo_mtl = os.path.join(r['out_dir'], 'odm_textured_model.mtl')
shutil.copy(geo_mtl, nongeo_mtl) shutil.copy(geo_mtl, nongeo_mtl)
progress += progress_per_run progress += progress_per_run
self.update_progress(progress) self.update_progress(progress)
else: else: