408 lines
11 KiB
Python
408 lines
11 KiB
Python
|
"""
|
||
|
material.py
|
||
|
-------------
|
||
|
|
||
|
Store visual materials as objects.
|
||
|
"""
|
||
|
import numpy as np
|
||
|
|
||
|
from . import color
|
||
|
from .. import util
|
||
|
from .. import grouping
|
||
|
|
||
|
|
||
|
class Material(object):
|
||
|
def __init__(self, *args, **kwargs):
|
||
|
raise NotImplementedError('material must be subclassed!')
|
||
|
|
||
|
def __hash__(self):
|
||
|
return id(self)
|
||
|
|
||
|
@property
|
||
|
def main_color(self):
|
||
|
raise NotImplementedError('material must be subclassed!')
|
||
|
|
||
|
|
||
|
class SimpleMaterial(Material):
|
||
|
"""
|
||
|
Hold a single image texture.
|
||
|
"""
|
||
|
|
||
|
def __init__(self,
|
||
|
image=None,
|
||
|
diffuse=None,
|
||
|
ambient=None,
|
||
|
specular=None,
|
||
|
glossiness=None,
|
||
|
**kwargs):
|
||
|
|
||
|
# save image
|
||
|
self.image = image
|
||
|
|
||
|
# save material colors as RGBA
|
||
|
self.ambient = color.to_rgba(ambient)
|
||
|
self.diffuse = color.to_rgba(diffuse)
|
||
|
self.specular = color.to_rgba(specular)
|
||
|
|
||
|
# save Ns
|
||
|
self.glossiness = glossiness
|
||
|
|
||
|
# save other keyword arguments
|
||
|
self.kwargs = kwargs
|
||
|
|
||
|
def to_color(self, uv):
|
||
|
return color.uv_to_color(uv, self.image)
|
||
|
|
||
|
def to_obj(self, tex_name=None, mtl_name=None):
|
||
|
"""
|
||
|
Convert the current material to an OBJ format material.
|
||
|
|
||
|
Parameters
|
||
|
-----------
|
||
|
name : str or None
|
||
|
Name to apply to the material
|
||
|
|
||
|
Returns
|
||
|
-----------
|
||
|
tex_name : str
|
||
|
Name of material
|
||
|
mtl_name : str
|
||
|
Name of mtl file in files
|
||
|
files : dict
|
||
|
Data as {file name : bytes}
|
||
|
"""
|
||
|
# material parameters as 0.0-1.0 RGB
|
||
|
Ka = color.to_float(self.ambient)[:3]
|
||
|
Kd = color.to_float(self.diffuse)[:3]
|
||
|
Ks = color.to_float(self.specular)[:3]
|
||
|
|
||
|
if tex_name is None:
|
||
|
tex_name = 'material0'
|
||
|
if mtl_name is None:
|
||
|
mtl_name = '{}.mtl'.format(tex_name)
|
||
|
|
||
|
# what is the name of the export image to save
|
||
|
|
||
|
image_type = self.image.format
|
||
|
if image_type is None:
|
||
|
image_type = 'png'
|
||
|
|
||
|
image_name = '{}.{}'.format(
|
||
|
tex_name, image_type).lower()
|
||
|
|
||
|
# create an MTL file
|
||
|
mtl = '\n'.join(
|
||
|
['# https://github.com/mikedh/trimesh',
|
||
|
'newmtl {}'.format(tex_name),
|
||
|
'Ka {:0.8f} {:0.8f} {:0.8f}'.format(*Ka),
|
||
|
'Kd {:0.8f} {:0.8f} {:0.8f}'.format(*Kd),
|
||
|
'Ks {:0.8f} {:0.8f} {:0.8f}'.format(*Ks),
|
||
|
'Ns {:0.8f}'.format(self.glossiness),
|
||
|
'map_Kd {}'.format(image_name)])
|
||
|
|
||
|
# save the image texture as bytes in the original format
|
||
|
f_obj = util.BytesIO()
|
||
|
self.image.save(fp=f_obj, format=image_type)
|
||
|
f_obj.seek(0)
|
||
|
|
||
|
# collect the OBJ data into files
|
||
|
data = {mtl_name: mtl.encode('utf-8'),
|
||
|
image_name: f_obj.read()}
|
||
|
|
||
|
return data, tex_name, mtl_name
|
||
|
|
||
|
def __hash__(self):
|
||
|
"""
|
||
|
Provide a hash of the material so we can detect
|
||
|
duplicates.
|
||
|
|
||
|
Returns
|
||
|
------------
|
||
|
hash : int
|
||
|
Hash of image and parameters
|
||
|
"""
|
||
|
if hasattr(self.image, 'tobytes'):
|
||
|
# start with hash of raw image bytes
|
||
|
hashed = hash(self.image.tobytes())
|
||
|
else:
|
||
|
# otherwise start with zero
|
||
|
hashed = 0
|
||
|
# we will add additional parameters with
|
||
|
# an in-place xor of the additional value
|
||
|
# if stored as numpy arrays add parameters
|
||
|
if hasattr(self.ambient, 'tobytes'):
|
||
|
hashed ^= hash(self.ambient.tobytes())
|
||
|
if hasattr(self.diffuse, 'tobytes'):
|
||
|
hashed ^= hash(self.diffuse.tobytes())
|
||
|
if hasattr(self.specular, 'tobytes'):
|
||
|
hashed ^= hash(self.specular.tobytes())
|
||
|
if isinstance(self.glossiness, float):
|
||
|
hashed ^= hash(int(self.glossiness * 1000))
|
||
|
return hashed
|
||
|
|
||
|
@property
|
||
|
def main_color(self):
|
||
|
"""
|
||
|
Return the most prominent color.
|
||
|
"""
|
||
|
return self.diffuse
|
||
|
|
||
|
@property
|
||
|
def glossiness(self):
|
||
|
if hasattr(self, '_glossiness'):
|
||
|
return self._glossiness
|
||
|
return 1.0
|
||
|
|
||
|
@glossiness.setter
|
||
|
def glossiness(self, value):
|
||
|
if value is None:
|
||
|
return
|
||
|
self._glossiness = float(value)
|
||
|
|
||
|
def to_pbr(self):
|
||
|
"""
|
||
|
Convert the current simple material to a PBR material.
|
||
|
|
||
|
Returns
|
||
|
------------
|
||
|
pbr : PBRMaterial
|
||
|
Contains material information in PBR format.
|
||
|
"""
|
||
|
# convert specular exponent to roughness
|
||
|
roughness = (2 / (self.glossiness + 2)) ** (1.0 / 4.0)
|
||
|
|
||
|
return PBRMaterial(roughnessFactor=roughness,
|
||
|
baseColorTexture=self.image,
|
||
|
baseColorFactor=self.diffuse)
|
||
|
|
||
|
|
||
|
class PBRMaterial(Material):
|
||
|
"""
|
||
|
Create a material for physically based rendering as
|
||
|
specified by GLTF 2.0:
|
||
|
https://git.io/fhkPZ
|
||
|
|
||
|
Parameters with `Texture` in them must be PIL.Image objects
|
||
|
"""
|
||
|
|
||
|
def __init__(self,
|
||
|
name=None,
|
||
|
emissiveFactor=None,
|
||
|
emissiveTexture=None,
|
||
|
normalTexture=None,
|
||
|
occlusionTexture=None,
|
||
|
baseColorTexture=None,
|
||
|
baseColorFactor=None,
|
||
|
metallicFactor=None,
|
||
|
roughnessFactor=None,
|
||
|
metallicRoughnessTexture=None,
|
||
|
doubleSided=False,
|
||
|
alphaMode='OPAQUE',
|
||
|
alphaCutoff=0.5):
|
||
|
|
||
|
# (4,) float
|
||
|
if baseColorFactor is not None:
|
||
|
baseColorFactor = color.to_rgba(baseColorFactor)
|
||
|
self.baseColorFactor = baseColorFactor
|
||
|
|
||
|
if emissiveFactor is not None:
|
||
|
emissiveFactor = np.array(emissiveFactor, dtype=np.float64)
|
||
|
|
||
|
# (3,) float
|
||
|
self.emissiveFactor = emissiveFactor
|
||
|
|
||
|
# float
|
||
|
self.metallicFactor = metallicFactor
|
||
|
self.roughnessFactor = roughnessFactor
|
||
|
self.alphaCutoff = alphaCutoff
|
||
|
|
||
|
# PIL image
|
||
|
self.normalTexture = normalTexture
|
||
|
self.emissiveTexture = emissiveTexture
|
||
|
self.occlusionTexture = occlusionTexture
|
||
|
self.baseColorTexture = baseColorTexture
|
||
|
self.metallicRoughnessTexture = metallicRoughnessTexture
|
||
|
|
||
|
# bool
|
||
|
self.doubleSided = doubleSided
|
||
|
|
||
|
# str
|
||
|
self.name = name
|
||
|
self.alphaMode = alphaMode
|
||
|
|
||
|
def to_color(self, uv):
|
||
|
"""
|
||
|
Get the rough color at a list of specified UV coordinates.
|
||
|
|
||
|
Parameters
|
||
|
-------------
|
||
|
uv : (n, 2) float
|
||
|
UV coordinates on the material
|
||
|
|
||
|
Returns
|
||
|
-------------
|
||
|
colors :
|
||
|
"""
|
||
|
colors = color.uv_to_color(uv=uv, image=self.baseColorTexture)
|
||
|
if colors is None and self.baseColorFactor is not None:
|
||
|
colors = self.baseColorFactor.copy()
|
||
|
return colors
|
||
|
|
||
|
@property
|
||
|
def main_color(self):
|
||
|
# will return default color if None
|
||
|
result = color.to_rgba(self.baseColorFactor)
|
||
|
return result
|
||
|
|
||
|
def __hash__(self):
|
||
|
"""
|
||
|
Provide a hash of the material so we can detect
|
||
|
duplicate materials.
|
||
|
|
||
|
Returns
|
||
|
------------
|
||
|
hash : int
|
||
|
Hash of image and parameters
|
||
|
"""
|
||
|
if hasattr(self.baseColorTexture, 'tobytes'):
|
||
|
# start with hash of raw image bytes
|
||
|
hashed = hash(self.baseColorTexture.tobytes())
|
||
|
else:
|
||
|
# otherwise start with zero
|
||
|
hashed = 0
|
||
|
# we will add additional parameters with
|
||
|
# an in-place xor of the additional value
|
||
|
# if stored as numpy arrays add parameters
|
||
|
if hasattr(self.baseColorFactor, 'tobytes'):
|
||
|
hashed ^= hash(self.baseColorFactor.tobytes())
|
||
|
|
||
|
return hashed
|
||
|
|
||
|
|
||
|
def empty_material(color=None):
|
||
|
"""
|
||
|
Return an empty material set to a single color
|
||
|
|
||
|
Parameters
|
||
|
-----------
|
||
|
color : None or (3,) uint8
|
||
|
RGB color
|
||
|
|
||
|
Returns
|
||
|
-------------
|
||
|
material : SimpleMaterial
|
||
|
Image is a a one pixel RGB
|
||
|
"""
|
||
|
from PIL import Image
|
||
|
if color is None or np.shape(color) not in ((3,), (4,)):
|
||
|
color = np.array([255, 255, 255], dtype=np.uint8)
|
||
|
else:
|
||
|
color = np.array(color, dtype=np.uint8)[:3]
|
||
|
# create a one pixel RGB image
|
||
|
image = Image.fromarray(
|
||
|
np.tile(color, (4, 1)).reshape((2, 2, 3)))
|
||
|
return SimpleMaterial(image=image)
|
||
|
|
||
|
|
||
|
def from_color(vertex_colors):
|
||
|
"""
|
||
|
Convert vertex colors into UV coordinates and materials.
|
||
|
|
||
|
TODO : pack colors
|
||
|
|
||
|
Parameters
|
||
|
------------
|
||
|
vertex_colors : (n, 3) float
|
||
|
Array of vertex colors
|
||
|
|
||
|
Returns
|
||
|
------------
|
||
|
material : SimpleMaterial
|
||
|
Material containing color information
|
||
|
uvs : (n, 2) float
|
||
|
UV coordinates
|
||
|
"""
|
||
|
unique, inverse = grouping.unique_rows(vertex_colors)
|
||
|
# TODO : tile colors nicely
|
||
|
material = empty_material(color=vertex_colors[unique[0]])
|
||
|
uvs = np.zeros((len(vertex_colors), 2)) + 0.5
|
||
|
|
||
|
return material, uvs
|
||
|
|
||
|
|
||
|
def pack(materials, uvs, deduplicate=True):
|
||
|
"""
|
||
|
Pack multiple materials with texture into a single material.
|
||
|
|
||
|
Parameters
|
||
|
-----------
|
||
|
materials : (n,) Material
|
||
|
List of multiple materials
|
||
|
uvs : (n, m, 2) float
|
||
|
Original UV coordinates
|
||
|
|
||
|
Returns
|
||
|
------------
|
||
|
material : Material
|
||
|
Combined material
|
||
|
uv : (p, 2) float
|
||
|
Combined UV coordinates
|
||
|
"""
|
||
|
|
||
|
from PIL import Image
|
||
|
from ..path import packing
|
||
|
import collections
|
||
|
|
||
|
if deduplicate:
|
||
|
# start by collecting a list of indexes for each material hash
|
||
|
unique_idx = collections.defaultdict(list)
|
||
|
[unique_idx[hash(m)].append(i) for i, m in enumerate(materials)]
|
||
|
# now we only need the indexes and don't care about the hashes
|
||
|
mat_idx = list(unique_idx.values())
|
||
|
else:
|
||
|
# otherwise just use all the indexes
|
||
|
mat_idx = np.arange(len(materials)).reshape((-1, 1))
|
||
|
|
||
|
# store the images to combine later
|
||
|
images = []
|
||
|
# first collect the images from the materials
|
||
|
for idx in mat_idx:
|
||
|
# get the first material from the group
|
||
|
m = materials[idx[0]]
|
||
|
# extract an image for each material
|
||
|
if isinstance(m, PBRMaterial):
|
||
|
if m.baseColorTexture is not None:
|
||
|
img = m.baseColorTexture
|
||
|
elif m.baseColorFactor is not None:
|
||
|
img = Image.fromarray(m.baseColorFactor[:3].reshape((1, 1, 3)))
|
||
|
else:
|
||
|
img = Image.new(mode='RGB', size=(1, 1))
|
||
|
elif hasattr(m, 'image'):
|
||
|
img = m.image
|
||
|
else:
|
||
|
raise ValueError('no image to pack!')
|
||
|
images.append(img)
|
||
|
|
||
|
# pack the multiple images into a single large image
|
||
|
final, offsets = packing.images(images, power_resize=True)
|
||
|
|
||
|
# the size of the final texture image
|
||
|
final_size = np.array(final.size, dtype=np.float64)
|
||
|
# collect scaled new UV coordinates
|
||
|
new_uv = []
|
||
|
|
||
|
for idxs, img, off in zip(mat_idx, images, offsets):
|
||
|
# how big was the original image
|
||
|
scale = img.size / final_size
|
||
|
# what is the offset in fractions of final image
|
||
|
uv_off = off / final_size
|
||
|
# scale and translate each of the new UV coordinates
|
||
|
# [new_uv.append((uvs[i] * scale) + uv_off) for i in idxs]
|
||
|
# TODO : figure out why this is broken sometimes...
|
||
|
[new_uv.append((uvs[i] * scale) + uv_off) for i in idxs]
|
||
|
|
||
|
# stack UV coordinates into single (n, 2) array
|
||
|
stacked = np.vstack(new_uv)
|
||
|
|
||
|
return SimpleMaterial(image=final), stacked
|