Skip to content

Commit 52b3dd3

Browse files
committed
Fixes to support custom image loaders properly.
- Sample OIIO Loader test added.
1 parent 59f98fe commit 52b3dd3

6 files changed

Lines changed: 456 additions & 4 deletions

File tree

python/Scripts/OIIOLoader.py

Lines changed: 386 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,386 @@
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()

resources/Images/GammaChart.exr

22.7 KB
Binary file not shown.

0 commit comments

Comments
 (0)