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

1import numpy as np 

2 

3from ..constants import log 

4from ..exceptions import ExceptionWrapper 

5from ..typed import ArrayLike, Number 

6from .color import linear_to_srgb, srgb_to_linear 

7 

8try: 

9 from PIL.Image import Image, fromarray 

10except BaseException as E: 

11 Image = ExceptionWrapper(E) 

12 fromarray = ExceptionWrapper(E) 

13 

14 

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. 

26 

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]. 

45 

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 

56 

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 

65 

66 dielectric_specular = np.array([0.04, 0.04, 0.04], dtype=np.float32) 

67 epsilon = 1e-6 

68 

69 def solve_metallic(diffuse, specular, one_minus_specular_strength): 

70 if isinstance(specular, float) and specular < dielectric_specular[0]: 

71 return 0.0 

72 

73 if len(diffuse.shape) == 2: 

74 diffuse = diffuse[..., None] 

75 if len(specular.shape) == 2: 

76 specular = specular[..., None] 

77 

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 

91 

92 def get_perceived_brightness(rgb): 

93 return np.sqrt(np.dot(rgb[..., :3] ** 2, [0.299, 0.587, 0.114])) 

94 

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) 

101 

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() 

108 

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) 

114 

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") 

120 

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) 

126 

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 

155 

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) 

165 

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 

170 

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") 

176 

177 specularGlossinessTexture = np.array(specularGlossinessTexture) / 255.0 

178 specularTexture, glossinessTexture = None, None 

179 

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:] 

203 

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 

210 

211 if glossinessTexture is not None: 

212 # glossiness texture channel is linear 

213 glossiness = glossinessTexture * glossinessFactor 

214 else: 

215 glossiness = glossinessFactor 

216 

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]) 

224 

225 return specular, glossiness, one_minus_specular_strength 

226 

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) 

243 

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 

256 

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 """ 

262 

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 

270 

271 diffuse = get_diffuse(diffuseFactor, diffuseTexture) 

272 specular, glossiness, one_minus_specular_strength = get_specular_glossiness( 

273 specularFactor, glossinessFactor, specularGlossinessTexture 

274 ) 

275 

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) 

283 

284 diffuse_rgb = diffuse[..., :3] 

285 

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) 

297 

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) 

321 

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() 

331 

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)) 

337 

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) 

350 

351 return result