"""
pypbr.io
This module provides input/output utility functions for loading and saving material maps
from and to folders, used in Physically Based Rendering (PBR) workflows. It handles image
formats and naming conventions for various material properties such as albedo, normal maps,
roughness, height, metallic, and specular maps.
Functions:
load_material_from_folder: Loads material maps from a folder based on file naming conventions.
save_material_to_folder: Saves material maps to a specified folder in the desired format.
"""
import os
import warnings
from PIL import Image
from typing import Optional, Dict, List, Type
from .material import (
MaterialBase,
BasecolorMetallicMaterial,
DiffuseSpecularMaterial,
)
[docs]
def load_material_from_folder(
folder_path: str,
map_names: Optional[Dict[str, List[str]]] = None,
preferred_workflow: Optional[str] = None,
is_srgb: bool = True,
) -> MaterialBase:
"""
Load material maps from a folder using naming conventions.
Args:
folder_path (str): Path to the folder containing material maps.
map_names (Optional[Dict[str, List[str]]]): Optional dictionary specifying map types and their possible filenames.
preferred_workflow (Optional[str]): Preferred workflow ('metallic' or 'specular'). If not specified, the workflow is determined based on available maps.
is_srgb (bool): Whether the albedo and specular maps are in sRGB color space.
Returns:
MaterialBase: An instance of a Material subclass with loaded maps.
"""
if map_names is None:
map_names = {
"basecolor": ["albedo", "basecolor"],
"diffuse": ["diffuse"],
"normal": ["normal", "normalmap"],
"height": ["height", "displacement", "bump"],
"roughness": ["roughness"],
"metallic": ["metallic", "metalness"],
"specular": ["specular"],
# Additional maps can be added here
}
supported_extensions = ["png", "jpg", "jpeg", "tiff", "bmp", "exr"]
# Dictionary to hold loaded images
loaded_maps = {}
# Scan for maps in the folder
for map_type, possible_names in map_names.items():
for name in possible_names:
for ext in supported_extensions:
filename = f"{name}.{ext}"
filepath = os.path.join(folder_path, filename)
if os.path.isfile(filepath):
image = Image.open(filepath)
if map_type in ["basecolor", "diffuse", "normal", "specular"]:
image = image.convert("RGB")
elif map_type == "height":
# For height maps, preserve the original mode if it's 16-bit or 32-bit
if image.mode in ["I", "I;16", "I;16B", "I;16L", "I;16N"]:
# Image is 16-bit unsigned integer
pass # Keep original mode
elif image.mode == "F":
# Image is 32-bit floating point
pass # Keep original mode
else:
# Ensure height map is in grayscale mode if not 16-bit or 32-bit
image = image.convert("L")
else:
image = image.convert("L") if image.mode != "L" else image
loaded_maps[map_type] = image
break # Stop searching for this map_type once found
if map_type in loaded_maps:
break # Stop searching other possible names once found
# Determine which material class to instantiate
material_class = select_material_class(loaded_maps, preferred_workflow)
# Decide which albedo map to use based on the selected material class
if issubclass(material_class, BasecolorMetallicMaterial):
# Use 'basecolor' as albedo map
albedo_map = loaded_maps.get("basecolor", None)
if albedo_map is None:
warnings.warn(
"Basecolor map not found for metallic workflow. Looking for 'albedo' or 'basecolor' maps."
)
# Remove 'diffuse' map if present
loaded_maps.pop("diffuse", None)
elif issubclass(material_class, DiffuseSpecularMaterial):
# Use 'diffuse' as albedo map
albedo_map = loaded_maps.get("diffuse", None)
if albedo_map is None:
warnings.warn(
"Diffuse map not found for specular workflow. Looking for 'diffuse' map."
)
# Remove 'basecolor' map if present
loaded_maps.pop("basecolor", None)
else:
albedo_map = None
# Prepare kwargs for material class instantiation
material_kwargs = {
k: v for k, v in loaded_maps.items() if k not in ["basecolor", "diffuse"]
}
material_kwargs["albedo"] = albedo_map
# Create the material instance
material_instance = material_class(
**material_kwargs,
albedo_is_srgb=is_srgb, # Assuming albedo is in sRGB space
specular_is_srgb=is_srgb, # Assuming specular map is in sRGB space
)
return material_instance
[docs]
def select_material_class(
loaded_maps: Dict[str, Image.Image],
preferred_workflow: Optional[str] = None,
) -> Type[MaterialBase]:
"""
Select the appropriate Material subclass based on the loaded maps.
Args:
loaded_maps (Dict[str, Image.Image]): Dictionary of loaded maps.
preferred_workflow (Optional[str]): Preferred workflow ('metallic' or 'specular').
Returns:
Type[MaterialBase]: The Material subclass to instantiate.
"""
has_metallic = "metallic" in loaded_maps
has_specular = "specular" in loaded_maps
if has_metallic and has_specular:
if preferred_workflow == "metallic":
warnings.warn(
"Both metallic and specular maps are present. Using metallic workflow as preferred."
)
# Remove 'specular' map since we're using metallic workflow
loaded_maps.pop("specular", None)
return BasecolorMetallicMaterial
elif preferred_workflow == "specular":
warnings.warn(
"Both metallic and specular maps are present. Using specular workflow as preferred."
)
# Remove 'metallic' map since we're using specular workflow
loaded_maps.pop("metallic", None)
return DiffuseSpecularMaterial
else:
warnings.warn(
"Both metallic and specular maps are present. Specify preferred_workflow to choose. Defaulting to metallic workflow."
)
# Default to metallic workflow
loaded_maps.pop("specular", None)
return BasecolorMetallicMaterial
elif has_metallic:
return BasecolorMetallicMaterial
elif has_specular:
return DiffuseSpecularMaterial
else:
# Decide based on available albedo maps
if "basecolor" in loaded_maps:
return BasecolorMetallicMaterial
elif "diffuse" in loaded_maps:
return DiffuseSpecularMaterial
else:
# Default to BasecolorMetallicMaterial and issue a warning
warnings.warn(
"Neither metallic nor specular map found, and no albedo map found. Defaulting to BasecolorMetallicMaterial."
)
return BasecolorMetallicMaterial
[docs]
def save_material_to_folder(
material: MaterialBase,
folder_path: str,
map_names: Optional[Dict[str, str]] = None,
format: str = "png",
):
"""
Save material maps to a folder using naming conventions.
Args:
material (MaterialBase): The material to save.
folder_path (str): Path to the folder where maps will be saved.
map_names (Optional[Dict[str, str]]): Optional dictionary specifying filenames for each map type.
format (str): The image format to save the maps (e.g., 'png', 'jpg').
"""
os.makedirs(folder_path, exist_ok=True)
# Default map names if not provided
if map_names is None:
map_names = {
"albedo": "albedo",
"normal": "normal",
"height": "height",
"roughness": "roughness",
"metallic": "metallic",
"specular": "specular",
# Additional maps can be added here
}
# Get maps as images
as_pil = material.to_pil()
# Save each map as an image
for map_type, image in as_pil.items():
if image is not None:
# Remove leading underscore if present
map_type_clean = map_type.lstrip("_")
filename = f"{map_names.get(map_type_clean, map_type_clean)}.{format}"
filepath = os.path.join(folder_path, filename)
image.save(filepath)
else:
continue # Skip saving if image is None