mirror of
https://github.com/Nexoniarz/YAPF.git
synced 2026-11-03 04:54:26 +00:00
yapf.load_texture / decode_texture expose every surface and the texture description; rgba_float() for display (blocks decoded, 16-bit / float kept). Blender imports 16-bit / HDR as float images packed as EXR, decodes GPU blocks, follows the normal-map convention (DirectX flip, Z rebuilt for XY normals). Errors say why. Tested in Blender 4.4.3. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DVrVthWN8GsmgHGUhhsCYm
282 lines
11 KiB
Python
282 lines
11 KiB
Python
# YAPF image support for Blender.
|
||
#
|
||
# Copyright 2026 Nexoniarz — Apache License 2.0.
|
||
#
|
||
# File > Import > YAPF Image (.yapf) open an image, optionally as a plane
|
||
# Image Editor > Image > Open YAPF… open into the Image Editor
|
||
# Image Editor > Image > Save as YAPF… export the current image
|
||
# Drag a .yapf file into the 3D Viewport, Image Editor or a node editor.
|
||
#
|
||
# Blender cannot read .yapf files by itself, so imported images are packed
|
||
# into the .blend file; they survive saving and reopening.
|
||
|
||
bl_info = {
|
||
"name": "YAPF Image Format",
|
||
"author": "Nexoniarz",
|
||
"version": (1, 0, 0),
|
||
"blender": (4, 2, 0),
|
||
"location": "File > Import > YAPF Image, Image Editor > Image menu",
|
||
"description": "Import and export YAPF (.yapf) lossless images",
|
||
"doc_url": "https://github.com/Nexoniarz/YAPF",
|
||
"category": "Import-Export",
|
||
}
|
||
|
||
import os
|
||
|
||
import bpy
|
||
import numpy as np
|
||
from bpy.props import BoolProperty, CollectionProperty, StringProperty
|
||
from bpy_extras.io_utils import ExportHelper, ImportHelper
|
||
|
||
from . import yapf
|
||
|
||
|
||
# ── conversion helpers ────────────────────────────────────────────────
|
||
|
||
def _set_colorspace(bimg, names):
|
||
for n in names: # names differ between versions
|
||
try:
|
||
bimg.colorspace_settings.name = n
|
||
return
|
||
except TypeError:
|
||
continue
|
||
|
||
|
||
def _read(filepath):
|
||
"""(rgba float32 h×w×4, has_alpha, flags, float_data, texture or None)."""
|
||
if yapf.backend() == "c" and getattr(yapf._lib, "has_textures", False):
|
||
tex = yapf.load_texture(filepath) # every v1 / v2 file
|
||
rgba = tex.rgba_float() # frame 0, layer 0, face +X, mip 0
|
||
if tex.normal & 3 == 2: # DirectX (-Y) → Blender's OpenGL (+Y)
|
||
rgba[..., 1] = 1.0 - rgba[..., 1]
|
||
if tex.normal & 4 or (tex.normal and tex.channels == 2): # XY: rebuild Z
|
||
x, y = rgba[..., 0] * 2.0 - 1.0, rgba[..., 1] * 2.0 - 1.0
|
||
rgba[..., 2] = (np.sqrt(np.clip(1.0 - x * x - y * y, 0.0, 1.0)) + 1.0) * 0.5
|
||
alpha = tex.channels in (2, 4) and not tex.normal
|
||
wide = tex.is_hdr or (tex.payload == yapf.PAYLOAD_PIXELS and tex.sample == yapf.SAMPLE_U16)
|
||
return rgba, alpha, tex.flags, wide, tex
|
||
img = yapf.load(filepath) # pure-Python decoder: v1 only
|
||
w, h, ch = img.width, img.height, img.channels
|
||
src = np.frombuffer(img.pixels, dtype=np.uint8).reshape(h, w, ch).astype(np.float32) / 255.0
|
||
rgba = np.ones((h, w, 4), dtype=np.float32)
|
||
rgba[..., :3] = src[..., :3] if ch >= 3 else src[..., :1]
|
||
if ch in (2, 4):
|
||
rgba[..., 3] = src[..., ch - 1]
|
||
return rgba, ch in (2, 4), img.flags, False, None
|
||
|
||
|
||
def load_image(filepath):
|
||
"""Read a .yapf file into a packed bpy.types.Image. 16-bit, half,
|
||
float and BC6H textures become float images (packed as EXR so nothing
|
||
is lost); GPU blocks are decoded."""
|
||
rgba, alpha, flags, wide, tex = _read(filepath)
|
||
h, w = rgba.shape[:2]
|
||
|
||
name = os.path.basename(filepath)
|
||
bimg = bpy.data.images.new(name, w, h, alpha=alpha, float_buffer=wide)
|
||
if tex is not None and tex.is_hdr:
|
||
_set_colorspace(bimg, ("Linear Rec.709", "Linear"))
|
||
elif flags & yapf.FLAG_SRGB:
|
||
_set_colorspace(bimg, ("sRGB",))
|
||
else:
|
||
_set_colorspace(bimg, ("Non-Color",))
|
||
if flags & yapf.FLAG_PREMULT_ALPHA:
|
||
bimg.alpha_mode = "PREMUL"
|
||
bimg.pixels.foreach_set(rgba[::-1].ravel()) # Blender rows go bottom-up
|
||
bimg.file_format = "OPEN_EXR" if wide else "PNG"
|
||
bimg.pack() # keep it inside the .blend
|
||
bimg.use_fake_user = True # …even before anything uses it
|
||
bimg["yapf_source"] = filepath
|
||
bimg["yapf_alpha"] = alpha
|
||
return bimg
|
||
|
||
|
||
def save_image(bimg, filepath, mips=False):
|
||
"""Write a bpy.types.Image to a .yapf file."""
|
||
w, h = bimg.size
|
||
if w == 0 or h == 0:
|
||
raise yapf.YapfError("image '%s' has no pixels" % bimg.name)
|
||
buf = np.empty(w * h * 4, dtype=np.float32)
|
||
bimg.pixels.foreach_get(buf)
|
||
px = (np.clip(buf.reshape(h, w, 4)[::-1], 0.0, 1.0) * 255.0 + 0.5).astype(np.uint8)
|
||
has_alpha = bool((px[..., 3] != 255).any())
|
||
ch = 4 if has_alpha else 3
|
||
px = np.ascontiguousarray(px[..., :ch])
|
||
|
||
srgb = bimg.colorspace_settings.name not in ("Non-Color", "Linear", "Linear Rec.709", "Raw")
|
||
flags = (yapf.FLAG_SRGB if srgb else 0) | (yapf.FLAG_PREMULT_ALPHA if bimg.alpha_mode == "PREMUL" else 0)
|
||
levels = [px]
|
||
if mips:
|
||
cur = px.astype(np.uint32)
|
||
while cur.shape[0] > 1 or cur.shape[1] > 1:
|
||
hh, ww = max(1, cur.shape[0] // 2), max(1, cur.shape[1] // 2)
|
||
p = np.pad(cur, ((0, cur.shape[0] % 2), (0, cur.shape[1] % 2), (0, 0)), mode="edge")
|
||
cur = (p[0::2, 0::2] + p[1::2, 0::2] + p[0::2, 1::2] + p[1::2, 1::2] + 2) // 4
|
||
cur = cur[:hh, :ww]
|
||
levels.append(cur.astype(np.uint8))
|
||
if len(levels) == 16:
|
||
break
|
||
out = yapf.Image(w, h, ch, levels[0].tobytes(), flags=flags,
|
||
mips=[l.tobytes() for l in levels])
|
||
if srgb:
|
||
out.gpu_format = yapf.GPU_SRGB8_A8 if ch == 4 else yapf.GPU_SRGB8
|
||
else:
|
||
out.gpu_format = yapf.GPU_RGBA8 if ch == 4 else yapf.GPU_RGB8
|
||
yapf.save(filepath, out)
|
||
|
||
|
||
def add_image_plane(context, bimg):
|
||
"""A plane with the image's aspect ratio and a material that shows it."""
|
||
w, h = bimg.size
|
||
bpy.ops.mesh.primitive_plane_add(size=1.0, location=context.scene.cursor.location)
|
||
obj = context.active_object
|
||
obj.name = os.path.splitext(bimg.name)[0]
|
||
obj.scale = (w / max(w, h), h / max(w, h), 1.0)
|
||
|
||
mat = bpy.data.materials.new(obj.name)
|
||
mat.use_nodes = True
|
||
nodes, links = mat.node_tree.nodes, mat.node_tree.links
|
||
bsdf = next(n for n in nodes if n.type == "BSDF_PRINCIPLED")
|
||
tex = nodes.new("ShaderNodeTexImage")
|
||
tex.image = bimg
|
||
tex.location = (bsdf.location.x - 350, bsdf.location.y)
|
||
links.new(tex.outputs["Color"], bsdf.inputs["Base Color"])
|
||
if bimg.get("yapf_alpha"):
|
||
links.new(tex.outputs["Alpha"], bsdf.inputs["Alpha"])
|
||
obj.data.materials.append(mat)
|
||
return obj
|
||
|
||
|
||
def show_in_image_editor(context, bimg):
|
||
for area in context.screen.areas if context.screen else []:
|
||
if area.type == "IMAGE_EDITOR":
|
||
area.spaces.active.image = bimg
|
||
return True
|
||
return False
|
||
|
||
|
||
# ── operators ─────────────────────────────────────────────────────────
|
||
|
||
class IMPORT_IMAGE_OT_yapf(bpy.types.Operator, ImportHelper):
|
||
"""Import YAPF (.yapf) images"""
|
||
bl_idname = "import_image.yapf"
|
||
bl_label = "Import YAPF Image"
|
||
bl_options = {"REGISTER", "UNDO"}
|
||
|
||
filename_ext = ".yapf"
|
||
filter_glob: StringProperty(default="*.yapf", options={"HIDDEN"})
|
||
files: CollectionProperty(type=bpy.types.OperatorFileListElement,
|
||
options={"HIDDEN", "SKIP_SAVE"})
|
||
directory: StringProperty(subtype="DIR_PATH", options={"HIDDEN", "SKIP_SAVE"})
|
||
as_plane: BoolProperty(name="Create Plane",
|
||
description="Add a plane showing the image to the scene",
|
||
default=False)
|
||
|
||
def paths(self):
|
||
if self.files and self.directory:
|
||
return [os.path.join(self.directory, f.name) for f in self.files if f.name]
|
||
return [self.filepath]
|
||
|
||
def execute(self, context):
|
||
done = 0
|
||
for path in self.paths():
|
||
try:
|
||
bimg = load_image(path)
|
||
except (OSError, yapf.YapfError) as e:
|
||
self.report({"ERROR"}, "%s: %s" % (os.path.basename(path), e))
|
||
continue
|
||
done += 1
|
||
if self.as_plane and context.mode == "OBJECT":
|
||
add_image_plane(context, bimg)
|
||
else:
|
||
show_in_image_editor(context, bimg)
|
||
if done:
|
||
self.report({"INFO"}, "Imported %d YAPF image(s)" % done)
|
||
return {"FINISHED"} if done else {"CANCELLED"}
|
||
|
||
def invoke(self, context, event):
|
||
# Dropped files arrive with filepath/directory already set.
|
||
if self.directory and self.files:
|
||
if context.area and context.area.type == "VIEW_3D":
|
||
self.as_plane = True
|
||
return self.execute(context)
|
||
return ImportHelper.invoke(self, context, event)
|
||
|
||
|
||
class EXPORT_IMAGE_OT_yapf(bpy.types.Operator, ExportHelper):
|
||
"""Save the current image as a YAPF (.yapf) file"""
|
||
bl_idname = "export_image.yapf"
|
||
bl_label = "Save as YAPF"
|
||
|
||
filename_ext = ".yapf"
|
||
filter_glob: StringProperty(default="*.yapf", options={"HIDDEN"})
|
||
image_name: StringProperty(name="Image", description="Image to save (default: the one in the Image Editor)")
|
||
mips: BoolProperty(name="Store Mipmaps", description="Also store a full mip chain", default=False)
|
||
|
||
def target(self, context):
|
||
if self.image_name:
|
||
return bpy.data.images.get(self.image_name)
|
||
space = context.space_data
|
||
return getattr(space, "image", None)
|
||
|
||
def invoke(self, context, event):
|
||
img = self.target(context)
|
||
if img is None:
|
||
self.report({"ERROR"}, "Open an image in the Image Editor first")
|
||
return {"CANCELLED"}
|
||
self.image_name = img.name
|
||
self.filepath = os.path.splitext(img.name)[0] + ".yapf"
|
||
return ExportHelper.invoke(self, context, event)
|
||
|
||
def execute(self, context):
|
||
img = self.target(context)
|
||
if img is None:
|
||
self.report({"ERROR"}, "No image to save")
|
||
return {"CANCELLED"}
|
||
try:
|
||
save_image(img, self.filepath, self.mips)
|
||
except (OSError, yapf.YapfError) as e:
|
||
self.report({"ERROR"}, str(e))
|
||
return {"CANCELLED"}
|
||
self.report({"INFO"}, "Saved %s" % os.path.basename(self.filepath))
|
||
return {"FINISHED"}
|
||
|
||
|
||
class IMAGE_FH_yapf(bpy.types.FileHandler):
|
||
"""Drag and drop .yapf files into Blender"""
|
||
bl_idname = "IMAGE_FH_yapf"
|
||
bl_label = "YAPF Image"
|
||
bl_import_operator = "import_image.yapf"
|
||
bl_file_extensions = ".yapf"
|
||
|
||
@classmethod
|
||
def poll_drop(cls, context):
|
||
return context.area is not None and context.area.type in {
|
||
"VIEW_3D", "IMAGE_EDITOR", "NODE_EDITOR"}
|
||
|
||
|
||
def menu_import(self, context):
|
||
self.layout.operator(IMPORT_IMAGE_OT_yapf.bl_idname, text="YAPF Image (.yapf)")
|
||
|
||
|
||
def menu_image(self, context):
|
||
self.layout.separator()
|
||
self.layout.operator(IMPORT_IMAGE_OT_yapf.bl_idname, text="Open YAPF…")
|
||
self.layout.operator(EXPORT_IMAGE_OT_yapf.bl_idname, text="Save as YAPF…")
|
||
|
||
|
||
classes = (IMPORT_IMAGE_OT_yapf, EXPORT_IMAGE_OT_yapf, IMAGE_FH_yapf)
|
||
|
||
|
||
def register():
|
||
for c in classes:
|
||
bpy.utils.register_class(c)
|
||
bpy.types.TOPBAR_MT_file_import.append(menu_import)
|
||
bpy.types.IMAGE_MT_image.append(menu_image)
|
||
|
||
|
||
def unregister():
|
||
bpy.types.IMAGE_MT_image.remove(menu_image)
|
||
bpy.types.TOPBAR_MT_file_import.remove(menu_import)
|
||
for c in reversed(classes):
|
||
bpy.utils.unregister_class(c)
|