This commit is contained in:
REDxEYE 2025-05-23 16:18:02 +00:00 committed by GitHub
commit ed11ca071d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 555 additions and 1 deletions

BIN
Tests/images/vtf_a8.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

BIN
Tests/images/vtf_a8.vtf Normal file

Binary file not shown.

BIN
Tests/images/vtf_bgr888.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

BIN
Tests/images/vtf_bgr888.vtf Normal file

Binary file not shown.

BIN
Tests/images/vtf_dxt1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

BIN
Tests/images/vtf_dxt1.vtf Normal file

Binary file not shown.

BIN
Tests/images/vtf_dxt1A.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

BIN
Tests/images/vtf_dxt1A.vtf Normal file

Binary file not shown.

BIN
Tests/images/vtf_i8.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

BIN
Tests/images/vtf_i8.vtf Normal file

Binary file not shown.

BIN
Tests/images/vtf_ia88.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

BIN
Tests/images/vtf_ia88.vtf Normal file

Binary file not shown.

BIN
Tests/images/vtf_rgb888.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

BIN
Tests/images/vtf_rgb888.vtf Normal file

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

BIN
Tests/images/vtf_uv88.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

BIN
Tests/images/vtf_uv88.vtf Normal file

Binary file not shown.

124
Tests/test_file_vtf.py Normal file
View File

@ -0,0 +1,124 @@
from __future__ import annotations
import pytest
from PIL import Image
from PIL.VtfImagePlugin import (
VtfPF,
_closest_power,
_get_mipmap_count,
_get_texture_size,
)
from .helper import assert_image_equal, assert_image_similar
@pytest.mark.parametrize(
"size, expected_size",
[
(8, 8),
(7, 8),
(9, 8),
(192, 256),
(1, 1),
(2000, 2048),
],
)
def test_closest_power(size: int, expected_size: int) -> None:
assert _closest_power(size) == expected_size
@pytest.mark.parametrize(
"size, expected_count",
[
((1, 1), 1),
((2, 2), 2),
((4, 4), 3),
((8, 8), 4),
((128, 128), 8),
((256, 256), 9),
((512, 512), 10),
((1024, 1024), 11),
((1024, 1), 11),
],
)
def test_get_mipmap_count(size: tuple[int, int], expected_count: int) -> None:
assert _get_mipmap_count(*size) == expected_count
@pytest.mark.parametrize(
"pixel_format, size, expected_size",
[
(VtfPF.DXT1, (16, 16), (16 * 16) // 2),
(VtfPF.DXT1_ONEBITALPHA, (16, 16), (16 * 16) // 2),
(VtfPF.DXT3, (16, 16), 16 * 16),
(VtfPF.DXT5, (16, 16), 16 * 16),
(VtfPF.BGR888, (16, 16), 16 * 16 * 3),
(VtfPF.RGB888, (16, 16), 16 * 16 * 3),
(VtfPF.RGBA8888, (16, 16), 16 * 16 * 4),
(VtfPF.UV88, (16, 16), 16 * 16 * 2),
(VtfPF.A8, (16, 16), 16 * 16),
(VtfPF.I8, (16, 16), 16 * 16),
(VtfPF.IA88, (16, 16), 16 * 16 * 2),
],
)
def test_get_texture_size(
pixel_format: VtfPF, size: tuple[int, int], expected_size: int
) -> None:
assert _get_texture_size(pixel_format, *size) == expected_size
@pytest.mark.parametrize(
"etalon_path, file_path, expected_mode, epsilon",
[
("Tests/images/vtf_i8.png", "Tests/images/vtf_i8.vtf", "L", 0.0),
("Tests/images/vtf_a8.png", "Tests/images/vtf_a8.vtf", "RGBA", 0.0),
("Tests/images/vtf_ia88.png", "Tests/images/vtf_ia88.vtf", "LA", 0.0),
("Tests/images/vtf_uv88.png", "Tests/images/vtf_uv88.vtf", "RGB", 0.0),
("Tests/images/vtf_rgb888.png", "Tests/images/vtf_rgb888.vtf", "RGB", 0.0),
("Tests/images/vtf_bgr888.png", "Tests/images/vtf_bgr888.vtf", "RGB", 0.0),
("Tests/images/vtf_dxt1.png", "Tests/images/vtf_dxt1.vtf", "RGBA", 3.0),
("Tests/images/vtf_dxt1A.png", "Tests/images/vtf_dxt1A.vtf", "RGBA", 8.0),
("Tests/images/vtf_rgba8888.png", "Tests/images/vtf_rgba8888.vtf", "RGBA", 0),
],
)
def test_vtf_read(
etalon_path: str, file_path: str, expected_mode: str, epsilon: float
) -> None:
with Image.open(file_path) as f:
assert f.mode == expected_mode
with Image.open(etalon_path) as e:
converted_e = e.convert(expected_mode)
if epsilon == 0:
assert_image_equal(converted_e, f)
else:
assert_image_similar(converted_e, f, epsilon)
@pytest.mark.parametrize(
"pixel_format, file_path, expected_mode, epsilon",
[
(VtfPF.I8, "Tests/images/vtf_i8.png", "L", 0.0),
(VtfPF.A8, "Tests/images/vtf_a8.png", "RGBA", 0.0),
(VtfPF.IA88, "Tests/images/vtf_ia88.png", "LA", 0.0),
(VtfPF.UV88, "Tests/images/vtf_uv88.png", "RGB", 0.0),
(VtfPF.RGB888, "Tests/images/vtf_rgb888.png", "RGB", 0.0),
(VtfPF.BGR888, "Tests/images/vtf_bgr888.png", "RGB", 0.0),
(VtfPF.DXT1, "Tests/images/vtf_dxt1.png", "RGBA", 3.0),
(VtfPF.RGBA8888, "Tests/images/vtf_rgba8888.png", "RGBA", 0),
],
)
def test_vtf_save(
pixel_format: VtfPF, file_path: str, expected_mode: str, epsilon: float, tmp_path
) -> None:
f: Image.Image = Image.open(file_path)
out = (tmp_path / "tmp.vtf").as_posix()
f.save(out, pixel_format=pixel_format)
if pixel_format == VtfPF.DXT1:
f = f.convert("RGBA")
e = Image.open(out)
assert e.mode == expected_mode
if epsilon == 0:
assert_image_equal(e, f)
else:
assert_image_similar(e, f, epsilon)

401
src/PIL/VtfImagePlugin.py Normal file
View File

@ -0,0 +1,401 @@
"""
A Pillow loader for .vtf files (aka Valve Texture Format)
REDxEYE <med45c@gmail.com>
Documentation:
https://developer.valvesoftware.com/wiki/Valve_Texture_Format
The contents of this file are hereby released in the public domain (CC0)
Full text of the CC0 license:
https://creativecommons.org/publicdomain/zero/1.0/
"""
from __future__ import annotations
import struct
from enum import IntEnum, IntFlag
from io import BytesIO
from math import ceil, log
from typing import IO, NamedTuple
from . import Image, ImageFile
class VTFException(Exception):
pass
class CompiledVtfFlags(IntFlag):
# Flags from the *.txt config file
POINTSAMPLE = 0x00000001
TRILINEAR = 0x00000002
CLAMPS = 0x00000004
CLAMPT = 0x00000008
ANISOTROPIC = 0x00000010
HINT_DXT5 = 0x00000020
PWL_CORRECTED = 0x00000040
NORMAL = 0x00000080
NOMIP = 0x00000100
NOLOD = 0x00000200
ALL_MIPS = 0x00000400
PROCEDURAL = 0x00000800
# These are automatically generated by vtex from the texture data.
ONEBITALPHA = 0x00001000
EIGHTBITALPHA = 0x00002000
# Newer flags from the *.txt config file
ENVMAP = 0x00004000
RENDERTARGET = 0x00008000
DEPTHRENDERTARGET = 0x00010000
NODEBUGOVERRIDE = 0x00020000
SINGLECOPY = 0x00040000
PRE_SRGB = 0x00080000
UNUSED_00100000 = 0x00100000
UNUSED_00200000 = 0x00200000
UNUSED_00400000 = 0x00400000
NODEPTHBUFFER = 0x00800000
UNUSED_01000000 = 0x01000000
CLAMPU = 0x02000000
VERTEXTEXTURE = 0x04000000
SSBUMP = 0x08000000
UNUSED_10000000 = 0x10000000
BORDER = 0x20000000
UNUSED_40000000 = 0x40000000
UNUSED_80000000 = 0x80000000
class VtfPF(IntEnum):
NONE = -1
RGBA8888 = 0
ABGR8888 = 1
RGB888 = 2
BGR888 = 3
# RGB565 = 4
I8 = 5
IA88 = 6
# P8 = 7
A8 = 8
# RGB888_BLUESCREEN = 9
# BGR888_BLUESCREEN = 10
ARGB8888 = 11
BGRA8888 = 12
DXT1 = 13
DXT3 = 14
DXT5 = 15
BGRX8888 = 16
# BGR565 = 17
# BGRX5551 = 18
# BGRA4444 = 19
DXT1_ONEBITALPHA = 20
# BGRA5551 = 21
UV88 = 22
# UVWQ8888 = 23
# RGBA16161616F = 24
# RGBA16161616 = 25
# UVLX8888 = 26
class VTFHeader(NamedTuple):
header_size: int
width: int
height: int
flags: int
frames: int
first_frames: int
reflectivity_r: float
reflectivity_g: float
reflectivity_b: float
bumpmap_scale: float
pixel_format: int
mipmap_count: int
low_pixel_format: int
low_width: int
low_height: int
depth: int
resource_count: int
BLOCK_COMPRESSED = (VtfPF.DXT1, VtfPF.DXT1_ONEBITALPHA, VtfPF.DXT3, VtfPF.DXT5)
HEADER_V70 = "<I2HI2H4x3f4xfIbI2b"
HEADER_V72 = "<I2HI2H4x3f4xfIbI2bH"
HEADER_V73 = "<I2HI2H4x3f4xfIbI2bH3xI8x"
def _get_texture_size(pixel_format: VtfPF, width: int, height: int) -> int:
if pixel_format in (VtfPF.DXT1, VtfPF.DXT1_ONEBITALPHA):
return width * height // 2
elif pixel_format in (
VtfPF.DXT3,
VtfPF.DXT5,
VtfPF.A8,
VtfPF.I8,
):
return width * height
elif pixel_format in (VtfPF.UV88, VtfPF.IA88):
return width * height * 2
elif pixel_format in (VtfPF.RGB888, VtfPF.BGR888):
return width * height * 3
elif pixel_format == VtfPF.RGBA8888:
return width * height * 4
msg = f"Unsupported VTF pixel format: {pixel_format}"
raise VTFException(msg)
def _get_mipmap_count(width: int, height: int) -> int:
mip_count = 1
while True:
mip_width = width >> mip_count
mip_height = height >> mip_count
if mip_width == 0 and mip_height == 0:
return mip_count
mip_count += 1
def _write_image(fp: IO[bytes], im: Image.Image, pixel_format: VtfPF) -> None:
encoder_args: tuple[int,] | tuple[str, int, int]
if pixel_format == VtfPF.DXT1:
encoder_args = (1,)
elif pixel_format == VtfPF.DXT3:
encoder_args = (3,)
elif pixel_format == VtfPF.DXT5:
encoder_args = (5,)
elif pixel_format == VtfPF.RGB888:
encoder_args = ("RGB", 0, 0)
elif pixel_format == VtfPF.BGR888:
encoder_args = ("BGR", 0, 0)
elif pixel_format == VtfPF.RGBA8888:
encoder_args = ("RGBA", 0, 0)
elif pixel_format == VtfPF.A8:
encoder_args = ("A", 0, 0)
elif pixel_format == VtfPF.I8:
encoder_args = ("L", 0, 0)
elif pixel_format == VtfPF.IA88:
encoder_args = ("LA", 0, 0)
elif pixel_format == VtfPF.UV88:
encoder_args = ("RG", 0, 0)
else:
msg = f"Unsupported pixel format: {pixel_format!r}"
raise VTFException(msg)
codec_name = "bcn" if pixel_format in BLOCK_COMPRESSED else "raw"
tile = [ImageFile._Tile(codec_name, (0, 0) + im.size, fp.tell(), encoder_args)]
ImageFile._save(im, fp, tile, _get_texture_size(pixel_format, *im.size))
def _closest_power(x: int) -> int:
possible_results = round(log(x, 2)), ceil(log(x, 2))
return 2 ** min(possible_results, key=lambda z: abs(x - 2**z))
class VtfImageFile(ImageFile.ImageFile):
format = "VTF"
format_description = "Valve Texture Format"
def _open(self):
if not _accept(self.fp.read(12)):
msg = "not a VTF file"
raise SyntaxError(msg)
self.fp.seek(4)
version = struct.unpack("<2I", self.fp.read(8))
if version <= (7, 2):
header = VTFHeader(
*struct.unpack(HEADER_V70, self.fp.read(struct.calcsize(HEADER_V70))),
0,
0,
0,
0,
0,
)
self.fp.seek(header.header_size)
elif version < (7, 3):
header = VTFHeader(
*struct.unpack(HEADER_V72, self.fp.read(struct.calcsize(HEADER_V72))),
0,
0,
0,
0,
)
self.fp.seek(header.header_size)
elif version < (7, 5):
header = VTFHeader(
*struct.unpack(HEADER_V73, self.fp.read(struct.calcsize(HEADER_V73)))
)
self.fp.seek(header.header_size)
else:
msg = f"Unsupported VTF version: {version}"
raise VTFException(msg)
pixel_format = VtfPF(header.pixel_format)
if pixel_format in (
VtfPF.DXT1_ONEBITALPHA,
VtfPF.DXT1,
VtfPF.DXT3,
VtfPF.DXT5,
VtfPF.RGBA8888,
VtfPF.BGRA8888,
VtfPF.A8,
):
self._mode = "RGBA"
elif pixel_format in (VtfPF.RGB888, VtfPF.BGR888, VtfPF.UV88):
self._mode = "RGB"
elif pixel_format == VtfPF.I8:
self._mode = "L"
elif pixel_format == VtfPF.IA88:
self._mode = "LA"
else:
msg = f"Unsupported VTF pixel format: {pixel_format}"
raise VTFException(msg)
if pixel_format in (VtfPF.DXT1, VtfPF.DXT1_ONEBITALPHA):
args = (1, "DXT1")
elif pixel_format == VtfPF.DXT3:
args = (2, "DXT3")
elif pixel_format == VtfPF.DXT5:
args = (3, "DXT5")
elif pixel_format == VtfPF.RGBA8888:
args = ("RGBA", 0, 1)
elif pixel_format == VtfPF.RGB888:
args = ("RGB", 0, 1)
elif pixel_format == VtfPF.BGR888:
args = ("BGR", 0, 1)
elif pixel_format == VtfPF.BGRA8888:
args = ("BGRA", 0, 1)
elif pixel_format == VtfPF.UV88:
args = ("RG", 0, 1)
elif pixel_format == VtfPF.I8:
args = ("L", 0, 1)
elif pixel_format == VtfPF.A8:
args = ("A", 0, 1)
elif pixel_format == VtfPF.IA88:
args = ("LA", 0, 1)
else:
msg = f"Unsupported VTF pixel format: {pixel_format}"
raise VTFException(msg)
self._size = (header.width, header.height)
low_format = VtfPF(header.low_pixel_format)
data_start = self.fp.tell()
data_start += _get_texture_size(low_format, header.low_width, header.low_height)
min_res = 4 if pixel_format in BLOCK_COMPRESSED else 1
for mip_id in range(header.mipmap_count - 1, 0, -1):
mip_width = max(header.width >> mip_id, min_res)
mip_height = max(header.height >> mip_id, min_res)
data_start += _get_texture_size(pixel_format, mip_width, mip_height)
codec_name = "bcn" if pixel_format in BLOCK_COMPRESSED else "raw"
self.tile = [ImageFile._Tile(codec_name, (0, 0) + self.size, data_start, args)]
def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
if im.mode not in ("RGB", "RGBA", "L", "LA"):
msg = f"cannot write mode {im.mode} as VTF"
raise OSError(msg)
encoderinfo = im.encoderinfo
pixel_format = VtfPF(encoderinfo.get("pixel_format", VtfPF.RGBA8888))
version = encoderinfo.get("version", (7, 4))
generate_mips = encoderinfo.get("generate_mips", True)
flags = CompiledVtfFlags(0)
if pixel_format in (
VtfPF.DXT3,
VtfPF.DXT5,
VtfPF.RGBA8888,
VtfPF.BGRA8888,
VtfPF.A8,
VtfPF.IA88,
):
flags |= CompiledVtfFlags.EIGHTBITALPHA
im = im.resize((_closest_power(im.width), _closest_power(im.height)))
width, height = im.size
mipmap_count = 0
if generate_mips:
mipmap_count = _get_mipmap_count(width, height)
thumb_buffer = BytesIO()
thumb = im.convert("RGB")
thumb.thumbnail(((min(16, width)), (min(16, height))))
thumb = thumb.resize((_closest_power(thumb.width), _closest_power(thumb.height)))
_write_image(thumb_buffer, thumb, VtfPF.DXT1)
header = VTFHeader(
0,
width,
height,
flags,
1,
0,
1.0,
1.0,
1.0,
1.0,
pixel_format,
mipmap_count,
VtfPF.DXT1,
thumb.width,
thumb.height,
1,
2,
)
fp.write(b"VTF\x00" + struct.pack("<2I", *version))
if version < (7, 2):
size = struct.calcsize(HEADER_V70) + 12
header = header._replace(header_size=size + (16 - size % 16))
fp.write(struct.pack(HEADER_V70, *header[:15]))
elif version == (7, 2):
size = struct.calcsize(HEADER_V72) + 12
header = header._replace(header_size=size + (16 - size % 16))
fp.write(struct.pack(HEADER_V72, *header[:16]))
elif version > (7, 2):
size = struct.calcsize(HEADER_V73) + 12
header = header._replace(header_size=size + (16 - size % 16))
fp.write(struct.pack(HEADER_V73, *header))
else:
msg = f"Unsupported version {version}"
raise VTFException(msg)
if version > (7, 2):
fp.write(b"\x01\x00\x00\x00")
fp.write(struct.pack("<I", header.header_size))
fp.write(b"\x30\x00\x00\x00")
fp.write(struct.pack("<I", header.header_size + len(thumb_buffer.getbuffer())))
else:
fp.write(b"\x00" * (16 - fp.tell() % 16))
fp.write(thumb_buffer.getbuffer())
if pixel_format in BLOCK_COMPRESSED:
min_size = 4
else:
min_size = 1
for mip_id in range(mipmap_count - 1, 0, -1):
mip_width = max(min_size, width >> mip_id)
mip_height = max(min_size, height >> mip_id)
mip = im.resize((mip_width, mip_height))
_write_image(fp, mip, pixel_format)
_write_image(fp, im, pixel_format)
def _accept(prefix: bytes) -> bool:
valid_header = prefix.startswith(b"VTF\x00")
valid_version = struct.unpack_from("<2I", prefix, 4) >= (7, 0)
return valid_header and valid_version
Image.register_open(VtfImageFile.format, VtfImageFile, _accept)
Image.register_save(VtfImageFile.format, _save)
Image.register_extension(VtfImageFile.format, ".vtf")

View File

@ -72,6 +72,7 @@ _plugins = [
"XbmImagePlugin", "XbmImagePlugin",
"XpmImagePlugin", "XpmImagePlugin",
"XVThumbImagePlugin", "XVThumbImagePlugin",
"VtfImagePlugin",
] ]

View File

@ -242,6 +242,17 @@ packLA(UINT8 *out, const UINT8 *in, int pixels) {
in += 4; in += 4;
} }
} }
static void
packRG(UINT8 *out, const UINT8 *in, int pixels) {
int i;
/* RG, pixel interleaved */
for (i = 0; i < pixels; i++) {
out[0] = in[R];
out[1] = in[G];
out += 2;
in += 4;
}
}
static void static void
packLAL(UINT8 *out, const UINT8 *in, int pixels) { packLAL(UINT8 *out, const UINT8 *in, int pixels) {
@ -480,7 +491,7 @@ copy3(UINT8 *out, const UINT8 *in, int pixels) {
static void static void
copy4(UINT8 *out, const UINT8 *in, int pixels) { copy4(UINT8 *out, const UINT8 *in, int pixels) {
/* RGBA, CMYK quadruples */ /* RGBA, CMYK quadruples */
memcpy(out, in, 4 * pixels); memcpy(out, in, pixels * 4);
} }
static void static void
@ -569,6 +580,7 @@ static struct {
{"RGB", "BGRX", 32, ImagingPackBGRX}, {"RGB", "BGRX", 32, ImagingPackBGRX},
{"RGB", "XBGR", 32, ImagingPackXBGR}, {"RGB", "XBGR", 32, ImagingPackXBGR},
{"RGB", "RGB;L", 24, packRGBL}, {"RGB", "RGB;L", 24, packRGBL},
{"RGB", "RG", 16, packRG},
{"RGB", "R", 8, band0}, {"RGB", "R", 8, band0},
{"RGB", "G", 8, band1}, {"RGB", "G", 8, band1},
{"RGB", "B", 8, band2}, {"RGB", "B", 8, band2},
@ -581,6 +593,7 @@ static struct {
{"RGBA", "BGRA", 32, ImagingPackBGRA}, {"RGBA", "BGRA", 32, ImagingPackBGRA},
{"RGBA", "ABGR", 32, ImagingPackABGR}, {"RGBA", "ABGR", 32, ImagingPackABGR},
{"RGBA", "BGRa", 32, ImagingPackBGRa}, {"RGBA", "BGRa", 32, ImagingPackBGRa},
{"RGBA", "RG", 16, packRG},
{"RGBA", "R", 8, band0}, {"RGBA", "R", 8, band0},
{"RGBA", "G", 8, band1}, {"RGBA", "G", 8, band1},
{"RGBA", "B", 8, band2}, {"RGBA", "B", 8, band2},

View File

@ -426,6 +426,18 @@ unpackLA(UINT8 *_out, const UINT8 *in, int pixels) {
} }
} }
static void
unpackRG(UINT8 *_out, const UINT8 *in, int pixels) {
int i;
/* RG, pixel interleaved */
for (i = 0; i < pixels; i++) {
_out[R] = in[0];
_out[G] = in[1];
in += 2;
_out += 4;
}
}
static void static void
unpackLAL(UINT8 *_out, const UINT8 *in, int pixels) { unpackLAL(UINT8 *_out, const UINT8 *in, int pixels) {
int i; int i;
@ -1612,6 +1624,9 @@ static struct {
{"PA", "PA;L", 16, unpackLAL}, {"PA", "PA;L", 16, unpackLAL},
{"PA", "LA", 16, unpackLA}, {"PA", "LA", 16, unpackLA},
/*2 channel to RGB/RGBA*/
{"RGB", "RG", 16, unpackRG},
{"RGBA", "RG", 16, unpackRG},
/* true colour */ /* true colour */
{"RGB", "RGB", 24, ImagingUnpackRGB}, {"RGB", "RGB", 24, ImagingUnpackRGB},
{"RGB", "RGB;L", 24, unpackRGBL}, {"RGB", "RGB;L", 24, unpackRGBL},