Coverage for trimesh/visual/gloss.py: 75%
178 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-24 04:40 +0000
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-24 04:40 +0000
1import numpy as np
3from ..constants import log
4from ..exceptions import ExceptionWrapper
5from ..typed import ArrayLike, Number
6from .color import linear_to_srgb, srgb_to_linear
8try:
9 from PIL.Image import Image, fromarray
10except BaseException as E:
11 Image = ExceptionWrapper(E)
12 fromarray = ExceptionWrapper(E)
15def specular_to_pbr(
16 specularFactor: ArrayLike | None = None,
17 glossinessFactor: Number | None = None,
18 specularGlossinessTexture: "Image | None" = None,
19 diffuseTexture: "Image | None" = None,
20 diffuseFactor: ArrayLike | None = None,
21 **kwargs,
22) -> dict:
23 """
24 Convert the KHR_materials_pbrSpecularGlossiness to a
25 metallicRoughness visual.
27 Parameters
28 -----------
29 specularFactor : list[float]
30 Specular color values. Ignored if specularGlossinessTexture
31 is present and defaults to [1.0, 1.0, 1.0].
32 glossinessFactor : float
33 glossiness factor in range [0, 1], scaled
34 specularGlossinessTexture if present.
35 Defaults to 1.0.
36 specularGlossinessTexture : PIL.Image
37 Texture with 4 color channels. With [0,1,2] representing
38 specular RGB and 3 glossiness.
39 diffuseTexture : PIL.Image
40 Texture with 4 color channels. With [0,1,2] representing diffuse
41 RGB and 3 opacity.
42 diffuseFactor: float
43 Diffuse RGBA color. scales diffuseTexture if present.
44 Defaults to [1.0, 1.0, 1.0, 1.0].
46 Returns
47 ----------
48 kwargs : dict
49 Constructor args for a PBRMaterial object.
50 Containing:
51 - either baseColorTexture or baseColorFactor
52 - either metallicRoughnessTexture or metallicFactor and roughnessFactor
53 """
54 # based on:
55 # https://github.com/KhronosGroup/glTF/blob/89427b26fcac884385a2e6d5803d917ab5d1b04f/extensions/2.0/Archived/KHR_materials_pbrSpecularGlossiness/examples/convert-between-workflows-bjs/js/babylon.pbrUtilities.js#L33-L64
57 if isinstance(Image, ExceptionWrapper):
58 log.debug("unable to convert specular-glossy material without pillow!")
59 result = {}
60 if isinstance(diffuseTexture, dict):
61 result["baseColorTexture"] = diffuseTexture
62 if diffuseFactor is not None:
63 result["baseColorFactor"] = diffuseFactor
64 return result
66 dielectric_specular = np.array([0.04, 0.04, 0.04], dtype=np.float32)
67 epsilon = 1e-6
69 def solve_metallic(diffuse, specular, one_minus_specular_strength):
70 if isinstance(specular, float) and specular < dielectric_specular[0]:
71 return 0.0
73 if len(diffuse.shape) == 2:
74 diffuse = diffuse[..., None]
75 if len(specular.shape) == 2:
76 specular = specular[..., None]
78 a = dielectric_specular[0]
79 b = (
80 diffuse * one_minus_specular_strength / (1.0 - dielectric_specular[0])
81 + specular
82 - 2.0 * dielectric_specular[0]
83 )
84 c = dielectric_specular[0] - specular
85 D = b * b - 4.0 * a * c
86 D = np.clip(D, epsilon, None)
87 metallic = np.clip((-b + np.sqrt(D)) / (2.0 * a), 0.0, 1.0)
88 if isinstance(metallic, np.ndarray):
89 metallic[specular < dielectric_specular[0]] = 0.0
90 return metallic
92 def get_perceived_brightness(rgb):
93 return np.sqrt(np.dot(rgb[..., :3] ** 2, [0.299, 0.587, 0.114]))
95 def toPIL(img, mode=None):
96 if isinstance(img, Image):
97 return img
98 if img.dtype == np.float32 or img.dtype == np.float64:
99 img = (np.clip(img, 0.0, 1.0) * 255.0).astype(np.uint8)
100 return fromarray(img)
102 def get_float(val):
103 if isinstance(val, float):
104 return val
105 if isinstance(val, np.ndarray) and len(val.shape) == 1:
106 return val[0]
107 return val.tolist()
109 def get_diffuse(diffuseFactor, diffuseTexture):
110 diffuseFactor = (
111 diffuseFactor if diffuseFactor is not None else [1.0, 1.0, 1.0, 1.0]
112 )
113 diffuseFactor = np.array(diffuseFactor, dtype=np.float32)
115 if diffuseTexture is not None:
116 if diffuseTexture.mode == "BGR":
117 diffuseTexture = diffuseTexture.convert("RGB")
118 elif diffuseTexture.mode == "BGRA":
119 diffuseTexture = diffuseTexture.convert("RGBA")
121 diffuse = np.array(diffuseTexture) / 255.0
122 # diffuseFactor must be applied to linear scaled colors .
123 # Sometimes, diffuse texture is only 2 channels, how do we know
124 # if they are encoded sRGB or linear?
125 diffuse = convert_texture_srgb2lin(diffuse)
127 if len(diffuse.shape) == 2:
128 diffuse = diffuse[..., None]
129 if diffuse.shape[-1] == 1:
130 diffuse = diffuse * diffuseFactor
131 elif diffuse.shape[-1] == 2:
132 alpha = diffuse[..., 1:2]
133 diffuse = diffuse[..., :1] * diffuseFactor
134 if diffuseFactor.shape[-1] == 3:
135 # this should actually not happen, but it seems like many materials are not complying with the spec
136 diffuse = np.concatenate([diffuse, alpha], axis=-1)
137 else:
138 diffuse[..., -1:] *= alpha
139 elif diffuse.shape[-1] == diffuseFactor.shape[-1]:
140 diffuse = diffuse * diffuseFactor
141 elif diffuse.shape[-1] == 3 and diffuseFactor.shape[-1] == 4:
142 diffuse = (
143 np.concatenate([diffuse, np.ones_like(diffuse[..., :1])], axis=-1)
144 * diffuseFactor
145 )
146 else:
147 log.warning(
148 "`diffuseFactor` and `diffuseTexture` have incompatible shapes: "
149 + f"{diffuseFactor.shape} and {diffuse.shape}"
150 )
151 else:
152 diffuse = diffuseFactor if diffuseFactor is not None else [1, 1, 1, 1]
153 diffuse = np.array(diffuse, dtype=np.float32)
154 return diffuse
156 def get_specular_glossiness(
157 specularFactor, glossinessFactor, specularGlossinessTexture
158 ):
159 if specularFactor is None:
160 specularFactor = [1.0, 1.0, 1.0]
161 specularFactor = np.array(specularFactor, dtype=np.float32)
162 if glossinessFactor is None:
163 glossinessFactor = 1.0
164 glossinessFactor = np.array([glossinessFactor], dtype=np.float32)
166 # specularGlossinessTexture should be a texture with 4 channels,
167 # 3 sRGB channels for specular and 1 linear channel for glossiness.
168 # in practice, it can also have just 1, 2, or 3 channels which are then to
169 # be multiplied with the provided factors
171 if specularGlossinessTexture is not None:
172 if specularGlossinessTexture.mode == "BGR":
173 specularGlossinessTexture = specularGlossinessTexture.convert("RGB")
174 elif specularGlossinessTexture.mode == "BGRA":
175 specularGlossinessTexture = specularGlossinessTexture.convert("RGBA")
177 specularGlossinessTexture = np.array(specularGlossinessTexture) / 255.0
178 specularTexture, glossinessTexture = None, None
180 if len(specularGlossinessTexture.shape) == 2:
181 # use the one channel as a multiplier for specular and glossiness
182 specularTexture = glossinessTexture = specularGlossinessTexture.reshape(
183 specularGlossinessTexture.shape[0],
184 specularGlossinessTexture.shape[1],
185 1,
186 )
187 elif specularGlossinessTexture.shape[-1] == 1:
188 # use the one channel as a multiplier for specular and glossiness
189 specularTexture = glossinessTexture = specularGlossinessTexture[
190 ..., np.newaxis
191 ]
192 elif specularGlossinessTexture.shape[-1] == 3:
193 # all channels are specular, glossiness is only a factor
194 specularTexture = specularGlossinessTexture[..., :3]
195 elif specularGlossinessTexture.shape[-1] == 2:
196 # first channel is specular, last channel is glossiness
197 specularTexture = specularGlossinessTexture[..., :1]
198 glossinessTexture = specularGlossinessTexture[..., 1:2]
199 elif specularGlossinessTexture.shape[-1] == 4:
200 # first 3 channels are specular, last channel is glossiness
201 specularTexture = specularGlossinessTexture[..., :3]
202 glossinessTexture = specularGlossinessTexture[..., 3:]
204 if specularTexture is not None:
205 # specular texture channels are sRGB
206 specularTexture = convert_texture_srgb2lin(specularTexture)
207 specular = specularTexture * specularFactor
208 else:
209 specular = specularFactor
211 if glossinessTexture is not None:
212 # glossiness texture channel is linear
213 glossiness = glossinessTexture * glossinessFactor
214 else:
215 glossiness = glossinessFactor
217 one_minus_specular_strength = 1.0 - np.max(specular, axis=-1, keepdims=True)
218 else:
219 specular = specularFactor if specularFactor is not None else [1.0, 1.0, 1.0]
220 specular = np.array(specular, dtype=np.float32)
221 glossiness = glossinessFactor if glossinessFactor is not None else 1.0
222 glossiness = np.array(glossiness, dtype=np.float32)
223 one_minus_specular_strength = 1.0 - max(specular[:3])
225 return specular, glossiness, one_minus_specular_strength
227 if diffuseTexture is not None and specularGlossinessTexture is not None:
228 # reshape to the size of the largest texture
229 max_shape = tuple(
230 max(diffuseTexture.size[i], specularGlossinessTexture.size[i])
231 for i in range(2)
232 )
233 if (
234 diffuseTexture.size[0] != max_shape[0]
235 or diffuseTexture.size[1] != max_shape[1]
236 ):
237 diffuseTexture = diffuseTexture.resize(max_shape)
238 if (
239 specularGlossinessTexture.size[0] != max_shape[0]
240 or specularGlossinessTexture.size[1] != max_shape[1]
241 ):
242 specularGlossinessTexture = specularGlossinessTexture.resize(max_shape)
244 def convert_texture_srgb2lin(texture):
245 """
246 Wrapper for srgb2lin that converts color values from sRGB to linear.
247 If texture has 2 or 4 channels, the last channel (alpha) is left unchanged.
248 """
249 result = texture.copy()
250 color_channels = result.shape[-1]
251 # only scale the color channels, not the alpha channel
252 if color_channels == 4 or color_channels == 2:
253 color_channels -= 1
254 result[..., :color_channels] = srgb_to_linear(result[..., :color_channels])
255 return result
257 def convert_texture_lin2srgb(texture):
258 """
259 Wrapper for lin2srgb that converts color values from linear to sRGB.
260 If texture has 2 or 4 channels, the last channel (alpha) is left unchanged.
261 """
263 result = texture.copy()
264 color_channels = result.shape[-1]
265 # only scale the color channels, not the alpha channel
266 if color_channels == 4 or color_channels == 2:
267 color_channels -= 1
268 result[..., :color_channels] = linear_to_srgb(result[..., :color_channels])
269 return result
271 diffuse = get_diffuse(diffuseFactor, diffuseTexture)
272 specular, glossiness, one_minus_specular_strength = get_specular_glossiness(
273 specularFactor, glossinessFactor, specularGlossinessTexture
274 )
276 metallic = solve_metallic(
277 get_perceived_brightness(diffuse),
278 get_perceived_brightness(specular),
279 one_minus_specular_strength,
280 )
281 if not isinstance(metallic, np.ndarray):
282 metallic = np.array(metallic, dtype=np.float32)
284 diffuse_rgb = diffuse[..., :3]
286 base_color_from_diffuse = diffuse_rgb * (
287 one_minus_specular_strength
288 / (1.0 - dielectric_specular[0])
289 / np.clip((1.0 - metallic), epsilon, None)
290 )
291 base_color_from_specular = (specular - dielectric_specular * (1.0 - metallic)) * (
292 1.0 / np.clip(metallic, epsilon, None)
293 )
294 mm = metallic * metallic
295 base_color = mm * base_color_from_specular + (1.0 - mm) * base_color_from_diffuse
296 base_color = np.clip(base_color, 0.0, 1.0)
298 # get opacity
299 try:
300 if diffuse.shape == (4,):
301 # opacity is a single scalar value
302 opacity = diffuse[-1]
303 if base_color.shape == (3,):
304 # simple case with one color and diffuse with opacity
305 # add on the opacity from the diffuse color
306 base_color = np.append(base_color, opacity)
307 elif len(base_color.shape) == 3:
308 # stack opacity to match the base color array
309 dim = base_color.shape
310 base_color = np.dstack(
311 (
312 base_color,
313 np.full(np.prod(dim[:2]), opacity).reshape((dim[0], dim[1], 1)),
314 )
315 )
316 elif diffuse.shape[-1] == 4:
317 opacity = diffuse[..., -1]
318 base_color = np.concatenate([base_color, opacity[..., None]], axis=-1)
319 except BaseException:
320 log.error("unable to get opacity", exc_info=True)
322 result = {}
323 if len(base_color.shape) > 1:
324 # convert back to sRGB
325 result["baseColorTexture"] = toPIL(
326 convert_texture_lin2srgb(base_color),
327 mode=("RGB" if base_color.shape[-1] == 3 else "RGBA"),
328 )
329 else:
330 result["baseColorFactor"] = base_color.tolist()
332 if len(metallic.shape) > 1 or len(glossiness.shape) > 1:
333 if len(glossiness.shape) == 1:
334 glossiness = np.tile(glossiness, (metallic.shape[0], metallic.shape[1], 1))
335 if len(metallic.shape) == 1:
336 metallic = np.tile(metallic, (glossiness.shape[0], glossiness.shape[1], 1))
338 # we need to use RGB textures, because 2 channel textures can cause problems
339 result["metallicRoughnessTexture"] = toPIL(
340 np.concatenate(
341 [np.zeros_like(metallic), 1.0 - glossiness, metallic], axis=-1
342 ),
343 mode="RGB",
344 )
345 result["metallicFactor"] = 1.0
346 result["roughnessFactor"] = 1.0
347 else:
348 result["metallicFactor"] = get_float(metallic)
349 result["roughnessFactor"] = get_float(1.0 - glossiness)
351 return result