|
| 1 | +""" |
| 2 | +Sample MaterialX ImageLoader implementation using OpenImageIO package. |
| 3 | +
|
| 4 | +This module provides a MaterialX-compatible ImageLoader implementation using OpenImageIO (OIIO). |
| 5 | +The test will test loading an image, save it out, and optionally previewing it. |
| 6 | +
|
| 7 | +- Python Dependencies: |
| 8 | + - OpenImageIO (version 3.0.6.1) |
| 9 | + - API Docs can be found here: https://openimageio.readthedocs.io/en/v3.0.6.1/) |
| 10 | + - numpy : For numerical operations on image data |
| 11 | + - matplotlib : If image preview is desired. |
| 12 | +""" |
| 13 | +import ctypes |
| 14 | +import os |
| 15 | +import argparse |
| 16 | + |
| 17 | +import logging |
| 18 | +logging.basicConfig(level=logging.INFO) |
| 19 | +logger = logging.getLogger("OIIOLoad") |
| 20 | + |
| 21 | +try: |
| 22 | + import MaterialX as mx |
| 23 | + import MaterialX.PyMaterialXRender as mx_render |
| 24 | +except ImportError: |
| 25 | + logger.error("Required modules not found. Please install MaterialX.") |
| 26 | + raise |
| 27 | +try: |
| 28 | + import OpenImageIO as oiio |
| 29 | + import numpy as np |
| 30 | +except ImportError: |
| 31 | + logger.error("Required modules not found. Please install OpenImageIO and numpy.") |
| 32 | + raise |
| 33 | + |
| 34 | + |
| 35 | +have_matplot = False |
| 36 | +try: |
| 37 | + import matplotlib.pyplot as plt |
| 38 | + have_matplot = True |
| 39 | +except ImportError: |
| 40 | + logger.warning("matplotlib module not found. Image preview display is disabled.") |
| 41 | + |
| 42 | +class OiioImageLoader(mx_render.ImageLoader): |
| 43 | + """ |
| 44 | + A MaterialX ImageLoader implementation that uses OpenImageIO to read image files. |
| 45 | + |
| 46 | + Inherits from MaterialX.ImageLoader and implements the required interface methods. |
| 47 | + Supports common image formats like PNG, JPEG, TIFF, EXR, HDR, etc. |
| 48 | + """ |
| 49 | + |
| 50 | + def __init__(self): |
| 51 | + """ |
| 52 | + Initialize the OiioImageLoader and set supported extensions.""" |
| 53 | + super().__init__() |
| 54 | + |
| 55 | + # Set all extensions supported by OpenImageIO. e.g. |
| 56 | + # openexr:exr,sxr,mxr;tiff:tif,tiff,tx,env,sm,vsm;jpeg:jpg,jpe,jpeg,jif,jfif,jfi;bmp:bmp,dib;cineon:cin;dds:dds;dpx:dpx;fits:fits;hdr:hdr,rgbe;ico:ico;iff:iff,z;null:null,nul;png:png;pnm:ppm,pgm,pbm,pnm,pfm;psd:psd,pdd,psb;rla:rla;sgi:sgi,rgb,rgba,bw,int,inta;softimage:pic;targa:tga,tpic;term:term;webp:webp;zfile:zfile |
| 57 | + self._extensions = set() |
| 58 | + oiio_extensions = oiio.get_string_attribute("extension_list") |
| 59 | + # Split string by ";" |
| 60 | + for group in oiio_extensions.split(";"): |
| 61 | + # Each group is like "openexr:exr,sxr,mxr" |
| 62 | + if ":" in group: |
| 63 | + _, exts = group.split(":", 1) |
| 64 | + self._extensions.update(ext.strip() for ext in exts.split(",")) |
| 65 | + else: |
| 66 | + self._extensions.update(ext.strip() for ext in group.split(",")) |
| 67 | + logger.debug(f"Cache supported extensions: {self._extensions}") |
| 68 | + |
| 69 | + def supportedExtensions(self): |
| 70 | + """ |
| 71 | + Return a set of supported image file extensions. |
| 72 | + """ |
| 73 | + logger.info(f"OIIO supported extensions: {self._extensions}") |
| 74 | + return self._extensions |
| 75 | + |
| 76 | + def previewImage(self, title, data, width, height, nchannels): |
| 77 | + """ |
| 78 | + Utility method to preview an image using matplotlib. |
| 79 | + Handles normalization and dtype for correct display. |
| 80 | + """ |
| 81 | + if have_matplot: |
| 82 | + # If the image is float16 (half), convert to float32 |
| 83 | + if data.dtype == np.float16: |
| 84 | + data = data.astype(np.float32) |
| 85 | + |
| 86 | + flat = data.reshape(height, width, nchannels) |
| 87 | + # Always display as RGB (first 3 channels or repeat if less) |
| 88 | + if nchannels >= 3: |
| 89 | + rgb = flat[..., :3] |
| 90 | + else: |
| 91 | + rgb = np.repeat(flat[..., :1], 3, axis=-1) |
| 92 | + |
| 93 | + # Determine if normalization is needed |
| 94 | + if np.issubdtype(flat.dtype, np.floating): |
| 95 | + # If float, normalize to [0, 1] for display |
| 96 | + rgb_disp = np.clip(rgb, 0.0, 1.0) |
| 97 | + elif np.issubdtype(flat.dtype, np.integer): |
| 98 | + # If integer, assume 8 or 16 bit, scale if needed |
| 99 | + if flat.dtype == np.uint8: |
| 100 | + rgb_disp = rgb # matplotlib expects [0,255] for uint8 |
| 101 | + elif flat.dtype == np.uint16: |
| 102 | + # Scale 16-bit to 8-bit for display |
| 103 | + rgb_disp = (rgb / 257).astype(np.uint8) |
| 104 | + else: |
| 105 | + # For other integer types, try to scale to [0,255] |
| 106 | + rgb_disp = np.clip(rgb, 0, 255).astype(np.uint8) |
| 107 | + else: |
| 108 | + rgb_disp = rgb |
| 109 | + |
| 110 | + # Set title bar text for the preview window |
| 111 | + plt.title(f"{title} ({width}x{height}, {nchannels} channels, dtype={data.dtype})") |
| 112 | + plt.imshow(rgb_disp) |
| 113 | + plt.axis('off') |
| 114 | + plt.show() |
| 115 | + |
| 116 | + def loadImage(self, filePath): |
| 117 | + """ |
| 118 | + Load an image from the file system (MaterialX interface method). |
| 119 | + |
| 120 | + Args: |
| 121 | + filePath (MaterialX.FilePath): Path to the image file |
| 122 | + |
| 123 | + Returns: |
| 124 | + MaterialX.ImagePtr: MaterialX Image object or None if loading fails |
| 125 | + """ |
| 126 | + file_path_str = filePath.asString() |
| 127 | + logger.info(f"Load using OIIO loader: {file_path_str}") |
| 128 | + |
| 129 | + if not os.path.exists(file_path_str): |
| 130 | + print(f"Error: File '{file_path_str}' does not exist") |
| 131 | + return None |
| 132 | + |
| 133 | + try: |
| 134 | + # Open the image file |
| 135 | + img_input = oiio.ImageInput.open(file_path_str) |
| 136 | + if not img_input: |
| 137 | + print(f"Error: Could not open '{file_path_str}' - {oiio.geterror()}") |
| 138 | + return None |
| 139 | + |
| 140 | + # Get image specifications |
| 141 | + spec = img_input.spec() |
| 142 | + self.last_spec = spec |
| 143 | + self.last_loaded_path = file_path_str |
| 144 | + |
| 145 | + # Check channel count |
| 146 | + channels = spec.nchannels |
| 147 | + if channels > 4: |
| 148 | + channels = 4 |
| 149 | + |
| 150 | + # Determine MaterialX base type from OIIO format |
| 151 | + base_type = self._oiio_to_materialx_type(spec.format.basetype) |
| 152 | + if base_type is None: |
| 153 | + img_input.close() |
| 154 | + print(f"Error: Unsupported image format for '{file_path_str}'") |
| 155 | + return None |
| 156 | + |
| 157 | + # Create MaterialX image |
| 158 | + mx_image = mx_render.Image.create(spec.width, spec.height, channels, base_type) |
| 159 | + mx_image.createResourceBuffer() |
| 160 | + logger.debug(f"Create buffer with width: {spec.width}, height: {spec.height}, channels: {spec.nchannels} -> {channels}") |
| 161 | + |
| 162 | + # Read the image data using the correct OIIO Python API (returns a bytes object) |
| 163 | + logger.debug(f"Reading image data from '{file_path_str}' with spec: {spec}") |
| 164 | + data = img_input.read_image(0, 0, 0, channels, spec.format) |
| 165 | + if len(data) > 0: |
| 166 | + logger.debug(f"Done Reading image data from '{file_path_str}' with spec: {spec}") |
| 167 | + else: |
| 168 | + logger.error(f"Could not read image data.") |
| 169 | + return None |
| 170 | + |
| 171 | + self.previewImage("Loaded MaterialX Image", data, spec.width, spec.height, channels) |
| 172 | + |
| 173 | + # Steps: |
| 174 | + # - Copy the OIIO data into the MaterialX image resource buffer |
| 175 | + resource_buffer_ptr = mx_image.getResourceBuffer() |
| 176 | + bytes_per_channel = spec.format.size() |
| 177 | + total_bytes = spec.width * spec.height * channels * bytes_per_channel |
| 178 | + logger.info(f"Total bytes read in: {total_bytes} (width: {spec.width}, height: {spec.height}, channels: {channels}, format: {spec.format})") |
| 179 | + try: |
| 180 | + ctypes.memmove(resource_buffer_ptr, (ctypes.c_char * total_bytes).from_buffer_copy(data), total_bytes) |
| 181 | + except Exception as e: |
| 182 | + logger.error(f"Failed to update image resource buffer: {e}") |
| 183 | + |
| 184 | + img_input.close() |
| 185 | + |
| 186 | + return mx_image |
| 187 | + |
| 188 | + except Exception as e: |
| 189 | + print(f"Error loading image from '{file_path_str}': {str(e)}") |
| 190 | + return None |
| 191 | + |
| 192 | + return None |
| 193 | + |
| 194 | + def saveImage(self, filePath, image, verticalFlip=False): |
| 195 | + """ |
| 196 | + @brief Saves an image to disk using OpenImageIO (OIIO). |
| 197 | +
|
| 198 | + @param filePath The file path where the image will be saved. Expected to have an asString() method. |
| 199 | + @param image The MaterialX image object to save. |
| 200 | + @param verticalFlip Whether to vertically flip the image before saving. (Currently unused.) |
| 201 | + @return True if the image was saved successfully, False otherwise. |
| 202 | + """ |
| 203 | + filename = filePath.asString() |
| 204 | + width = image.getWidth() |
| 205 | + height = image.getHeight() |
| 206 | + |
| 207 | + # Clamp to RGBA |
| 208 | + src_channels = image.getChannelCount() |
| 209 | + channels = min(src_channels, 4) |
| 210 | + if src_channels > 4: |
| 211 | + logger.warning(f"Image has {src_channels} channels. Saving only first {channels} (RGBA).") |
| 212 | + |
| 213 | + mx_basetype = image.getBaseType() |
| 214 | + oiio_format = self._materialx_to_oiio_type(mx_basetype) |
| 215 | + logger.info(f"mx_basetype: {mx_basetype}, oiio_format: {oiio_format}, base_stride: {image.getBaseStride()}") |
| 216 | + if oiio_format is None: |
| 217 | + logger.error(f"Unsupported MaterialX base type for OIIO: {mx_basetype}") |
| 218 | + return False |
| 219 | + |
| 220 | + buffer_addr = image.getResourceBuffer() |
| 221 | + np_type = self._materialx_type_to_np_type(mx_basetype) |
| 222 | + if np_type is None: |
| 223 | + logger.error(f"No NumPy dtype mapping for base type: {mx_basetype}") |
| 224 | + return False |
| 225 | + |
| 226 | + try: |
| 227 | + # Steps: |
| 228 | + # - Maps the MaterialX base type to OIIO and NumPy types. |
| 229 | + # - Allocates a NumPy array for the pixel data. |
| 230 | + # - Copies the raw buffer from the image into the NumPy array. |
| 231 | + # - Optionally previews the image for debugging. |
| 232 | + # - Creates an OIIO ImageOutput and writes the image to disk. |
| 233 | + # |
| 234 | + base_stride = image.getBaseStride() # bytes per channel element |
| 235 | + total_bytes = width * height * src_channels * base_stride |
| 236 | + |
| 237 | + buf_type = (ctypes.c_char * total_bytes) |
| 238 | + buf = buf_type.from_address(buffer_addr) |
| 239 | + |
| 240 | + np_buffer = np.frombuffer(buf, dtype=np_type) |
| 241 | + |
| 242 | + # Validate total elements before reshape to catch mismatches early |
| 243 | + expected_elems = width * height * src_channels |
| 244 | + if np_buffer.size != expected_elems: |
| 245 | + logger.error(f"Buffer element count mismatch: got {np_buffer.size}, expected {expected_elems}.") |
| 246 | + return False |
| 247 | + |
| 248 | + np_buffer = np_buffer.reshape((height, width, src_channels)) |
| 249 | + |
| 250 | + # Keep only up to RGBA |
| 251 | + pixels = np_buffer[..., :channels].copy() |
| 252 | + |
| 253 | + if verticalFlip: |
| 254 | + logger.info("Applying vertical flip before saving image.") |
| 255 | + pixels = np.flipud(pixels) |
| 256 | + |
| 257 | + logger.info("Previewing image after load into Image and reload for save...") |
| 258 | + self.previewImage("OpenImageIO Output Image", pixels, width, height, channels) |
| 259 | + |
| 260 | + except Exception as e: |
| 261 | + logger.error(f"Error copying buffer to pixels: {e}") |
| 262 | + return False |
| 263 | + |
| 264 | + out = oiio.ImageOutput.create(filename) |
| 265 | + if not out: |
| 266 | + logger.error("Failed to create OIIO ImageOutput.") |
| 267 | + return False |
| 268 | + |
| 269 | + try: |
| 270 | + spec = oiio.ImageSpec(width, height, channels, oiio_format) |
| 271 | + out.open(filename, spec) |
| 272 | + out.write_image(pixels) |
| 273 | + logger.info(f"Image saved to {filename} (w={width}, h={height}, c={channels}, type={mx_basetype})") |
| 274 | + out.close() |
| 275 | + return True |
| 276 | + except Exception as e: |
| 277 | + logger.error(f"Failed to write image: {e}") |
| 278 | + try: |
| 279 | + out.close() |
| 280 | + finally: |
| 281 | + pass |
| 282 | + return False |
| 283 | + |
| 284 | + def _oiio_to_materialx_type(self, oiio_basetype): |
| 285 | + """Convert OIIO base type to MaterialX Image base type.""" |
| 286 | + type_mapping = { |
| 287 | + oiio.UINT8: mx_render.BaseType.UINT8, |
| 288 | + oiio.INT8: mx_render.BaseType.INT8, |
| 289 | + oiio.UINT16: mx_render.BaseType.UINT16, |
| 290 | + oiio.INT16: mx_render.BaseType.INT16, |
| 291 | + oiio.HALF: mx_render.BaseType.HALF, |
| 292 | + oiio.FLOAT: mx_render.BaseType.FLOAT |
| 293 | + } |
| 294 | + return_val = type_mapping.get(oiio_basetype, None) |
| 295 | + logger.debug(f"OIIO to MaterialX type mapping: {return_val} from {oiio_basetype}") |
| 296 | + return return_val |
| 297 | + |
| 298 | + def _materialx_to_oiio_type(self, mx_basetype): |
| 299 | + """Convert MaterialX Image base type to OIIO type.""" |
| 300 | + type_mapping = { |
| 301 | + mx_render.BaseType.UINT8: oiio.UINT8, |
| 302 | + mx_render.BaseType.UINT16: oiio.UINT16, |
| 303 | + mx_render.BaseType.INT8: oiio.INT8, |
| 304 | + mx_render.BaseType.INT16: oiio.INT16, |
| 305 | + mx_render.BaseType.HALF: oiio.HALF, |
| 306 | + mx_render.BaseType.FLOAT: oiio.FLOAT, |
| 307 | + } |
| 308 | + return_val = type_mapping.get(mx_basetype, None) |
| 309 | + logger.debug(f"MaterialX type mapping: {mx_basetype} to {return_val}") |
| 310 | + return return_val |
| 311 | + |
| 312 | + def _materialx_type_to_np_type(self, mx_basetype): |
| 313 | + """Map MaterialX base type to NumPy dtype with explicit widths.""" |
| 314 | + type_mapping = { |
| 315 | + mx_render.BaseType.UINT8: np.uint8, |
| 316 | + mx_render.BaseType.UINT16: np.uint16, |
| 317 | + mx_render.BaseType.INT8: np.int8, |
| 318 | + mx_render.BaseType.INT16: np.int16, |
| 319 | + mx_render.BaseType.HALF: np.float16, # was 'half' |
| 320 | + mx_render.BaseType.FLOAT: np.float32, # was 'float' (float64) -> WRONG |
| 321 | + } |
| 322 | + return type_mapping.get(mx_basetype, None) |
| 323 | + |
| 324 | + def _materialx_type_to_np_type_2(self, mx_basetype): |
| 325 | + """Map MaterialX base type to NumPy dtype.""" |
| 326 | + type_mapping = { |
| 327 | + mx_render.BaseType.UINT8: 'uint8', |
| 328 | + mx_render.BaseType.UINT16: 'uint16', |
| 329 | + mx_render.BaseType.INT8: 'int8', |
| 330 | + mx_render.BaseType.INT16: 'int16', |
| 331 | + mx_render.BaseType.HALF: 'half', |
| 332 | + mx_render.BaseType.FLOAT: 'float', |
| 333 | + } |
| 334 | + return type_mapping.get(mx_basetype, None) |
| 335 | + |
| 336 | + |
| 337 | +def test_load_save(): |
| 338 | + """ |
| 339 | + Example usage of the OiioImageLoader class with MaterialX ImageHandler. |
| 340 | + """ |
| 341 | + parser = argparse.ArgumentParser(description="MaterialX OIIO Image Handler") |
| 342 | + parser.add_argument("path", help="Path to the image file") |
| 343 | + parser.add_argument("--flip", action="store_true", help="Flip the image vertically") |
| 344 | + args = parser.parse_args() |
| 345 | + |
| 346 | + test_image_path = args.path |
| 347 | + if not os.path.exists(test_image_path): |
| 348 | + logger.error(f"Image file not found: {test_image_path}") |
| 349 | + return |
| 350 | + |
| 351 | + # Create MaterialX handler with custom OIIO image loader |
| 352 | + loader = OiioImageLoader() |
| 353 | + handler = mx_render.ImageHandler.create(loader) |
| 354 | + #manager = mx_render.getPluginManager() |
| 355 | + #handler = manager.getImageHandler() |
| 356 | + logger.info(f"Created {handler} with loader") |
| 357 | + handler.addLoader(loader) |
| 358 | + |
| 359 | + mx_filepath = mx.FilePath(test_image_path) |
| 360 | + |
| 361 | + # Load image using handler API |
| 362 | + logger.info(f"Loading image from path: {mx_filepath.asString()}") |
| 363 | + mx_image = handler.acquireImage(mx_filepath) |
| 364 | + if mx_image: |
| 365 | + # Q: How to check for failed image load as you |
| 366 | + # get back a 1x1 pixel image. |
| 367 | + if mx_image.getWidth() == 1 and mx_image.getHeight() == 1: |
| 368 | + logger.warning("Failed to load image. Got 1x1 pixel image returned") |
| 369 | + return |
| 370 | + logger.info(f"MaterialX Image loaded via Image Handler:") |
| 371 | + logger.info(f" Dimensions: {mx_image.getWidth()}x{mx_image.getHeight()}") |
| 372 | + logger.info(f" Channels: {mx_image.getChannelCount()}") |
| 373 | + logger.info(f" Base type: {mx_image.getBaseType()}") |
| 374 | + |
| 375 | + # Save image using handler API (to a new file) |
| 376 | + logger.info('-'*45) |
| 377 | + out_path = mx.FilePath("saved_" + os.path.basename(test_image_path)) |
| 378 | + if handler.saveImage(out_path, mx_image, verticalFlip=args.flip): |
| 379 | + logger.info(f"MaterialX Image saved to {out_path.asString()}") |
| 380 | + else: |
| 381 | + logger.error("Failed to save image.") |
| 382 | + else: |
| 383 | + logger.error("Failed to load image.") |
| 384 | + |
| 385 | +if __name__ == "__main__": |
| 386 | + test_load_save() |
0 commit comments