Fast image-to-DDS conversion and YTD texture dictionary toolkit
Project description
texfury
Fast image-to-DDS conversion and YTD texture dictionary toolkit for Python.
Built on bc7enc_rdo + ISPC bc7e for high-quality BC1/BC3/BC4/BC5/BC7 compression, with support for uncompressed A8R8G8B8 textures. No DirectXTex dependency — a single native DLL handles everything.
Features
- BC1, BC3, BC4, BC5, BC7 block compression with adjustable quality (0.0–1.0)
- A8R8G8B8 uncompressed 32-bit BGRA format
- DDS file read/write (legacy + DX10 extended headers)
- YTD texture dictionary creation and extraction
- Mipmap generation with configurable minimum size
- Automatic power-of-two resize (sRGB-aware via stb_image_resize2)
- Transparency detection without manual pixel iteration
- Pillow integration — accept
PIL.Imageobjects (Pillow is optional) - Batch operations with progress callbacks
- Zero Python dependencies — Pillow is optional
Installation
Copy the texfury/ package directory into your project (or add it to PYTHONPATH). The pre-compiled texfury_native.dll is included.
your_project/
texfury/
__init__.py
_native.py
formats.py
texture.py
ytd.py
utils.py
resource.py
texfury_native.dll
Pillow is optional. Install it (
pip install Pillow) only if you want to useTexture.from_pil()orhas_transparency_pil().
Quick Start
Convert a single image to DDS
from texfury import Texture, BCFormat
tex = Texture.from_image("logo.png", format=BCFormat.BC7, quality=0.8)
tex.save_dds("logo.dds")
Create a YTD from a folder of images
from texfury import create_ytd_from_folder, BCFormat
create_ytd_from_folder(
"my_textures/",
"output.ytd",
format=BCFormat.BC3,
quality=0.7,
)
Extract textures from a YTD
from texfury import extract_ytd
extract_ytd("vehicles.ytd", "extracted/")
# Creates extracted/texture_name.dds for each texture
API Reference
BCFormat — Compression Formats
from texfury import BCFormat
| Value | Name | Description |
|---|---|---|
BCFormat.BC1 |
DXT1 | RGB, 6:1 ratio. No alpha. Smallest files. |
BCFormat.BC3 |
DXT5 | RGBA, 4:1 ratio. Full alpha channel. |
BCFormat.BC4 |
ATI1 | Single channel (R), 4:1 ratio. Grayscale/height maps. |
BCFormat.BC5 |
ATI2 | Two channels (RG), 4:1 ratio. Normal maps. |
BCFormat.BC7 |
BC7 | RGBA, 4:1 ratio. Best quality, slowest to encode. |
BCFormat.A8R8G8B8 |
Uncompressed | 32-bit BGRA. No compression, largest files. |
Choosing a format:
- Opaque textures (no transparency):
BC1for speed/size,BC7for quality - Textures with alpha:
BC3orBC7 - Normal maps:
BC5 - Grayscale / height maps:
BC4 - Must be pixel-perfect:
A8R8G8B8
MipFilter — Downsampling Filters
Controls how pixels are interpolated when generating mipmaps and resizing to power-of-two.
from texfury import MipFilter
| Value | Description | Best for |
|---|---|---|
MipFilter.MITCHELL |
Balanced sharpness/smoothness (B=1/3, C=1/3). Default. | General-purpose |
MipFilter.BOX |
Simple pixel average. Fast, correct for exact 2:1 downscale. | Fast iteration |
MipFilter.TRIANGLE |
Bilinear interpolation. | Smooth gradients |
MipFilter.CATMULL_ROM |
Sharp cubic interpolation. | Preserving edges/detail |
MipFilter.CUBIC_BSPLINE |
Gaussian-like smoothing (B=1, C=0). | Maximum smoothness |
MipFilter.POINT |
Nearest-neighbor, no interpolation. | Pixel art |
Texture — Core Texture Object
Every operation in texfury produces or consumes a Texture object.
Properties
tex.width # int — pixel width
tex.height # int — pixel height
tex.format # BCFormat — compression format
tex.mip_count # int — number of mipmap levels
tex.name # str — texture name (read/write)
tex.data # bytes — raw pixel data (all mip levels concatenated)
Creating Textures
Texture.from_image(source, *, format, quality, generate_mipmaps, min_mip_size, resize_to_pot, mip_filter, name)
Load an image file and compress it.
tex = Texture.from_image(
"photo.png",
format=BCFormat.BC7, # default
quality=0.7, # 0.0 = fastest, 1.0 = best quality
generate_mipmaps=True, # default
min_mip_size=4, # smallest mip dimension (default: 4)
resize_to_pot=True, # auto-resize to power-of-two (default)
mip_filter=MipFilter.MITCHELL, # downsampling filter (default)
name="my_texture", # defaults to filename stem
)
Supported image formats: PNG, JPG/JPEG, TGA, BMP, PSD, WebP, GIF, HDR, PNM/PPM natively. With Pillow installed, any format Pillow supports (TIFF, ICO, EPS, etc.) works automatically as a fallback.
Texture.from_pil(image, *, format, quality, generate_mipmaps, min_mip_size, resize_to_pot, mip_filter, name)
Create from a Pillow Image object. Requires Pillow.
from PIL import Image
from texfury import Texture, BCFormat
img = Image.open("photo.png")
# ... manipulate with Pillow ...
tex = Texture.from_pil(img, format=BCFormat.BC3, quality=0.9)
tex.save_dds("result.dds")
Texture.from_dds(source, *, name)
Load an existing DDS file.
tex = Texture.from_dds("existing.dds")
print(tex.format, tex.width, tex.height, tex.mip_count)
Texture.from_raw(data, width, height, fmt, mip_count, mip_offsets, mip_sizes, name)
Create from raw compressed pixel data (advanced / internal use).
tex = Texture.from_raw(
data=raw_bytes,
width=256, height=256,
fmt=BCFormat.BC7,
mip_count=7,
mip_offsets=[0, 65536, ...],
mip_sizes=[65536, 16384, ...],
name="custom",
)
Saving Textures
tex.save_dds(path)
Write to a DDS file.
tex.save_dds("output.dds")
tex.to_dds_bytes()
Get the complete DDS file as bytes (useful for in-memory pipelines).
dds_data = tex.to_dds_bytes()
with open("output.dds", "wb") as f:
f.write(dds_data)
Decompression
tex.to_rgba(mip=0)
Decompress a texture back to raw RGBA pixels. Works with all formats (BC1–BC7, A8R8G8B8).
rgba_bytes, width, height = tex.to_rgba() # mip 0 (full resolution)
rgba_bytes, width, height = tex.to_rgba(mip=2) # mip level 2 (quarter resolution)
tex.to_pil(mip=0)
Decompress to a Pillow Image object. Requires Pillow.
pil_image = tex.to_pil()
pil_image.save("preview.png")
Inspection
Texture.inspect_dds(source)
Read DDS metadata without loading pixel data.
info = Texture.inspect_dds("texture.dds")
# {'name': 'texture', 'width': 512, 'height': 512, 'format': BCFormat.BC7,
# 'format_name': 'BC7', 'mip_count': 10, 'data_size': 349524}
Quality Metrics
tex.quality_metrics(original_rgba)
Compare a compressed texture against the original RGBA pixels. Returns PSNR (dB) and SSIM.
# Load original pixels
from texfury import _native as native
img = native.load_image("photo.png")
original_rgba = native.image_pixels(img, native.image_width(img), native.image_height(img))
native.free_image(img)
# Compress and measure quality loss
tex = Texture.from_image("photo.png", format=BCFormat.BC1, quality=0.5)
metrics = tex.quality_metrics(original_rgba)
print(f"PSNR: {metrics['psnr_rgb']:.1f} dB") # higher = better (40+ is good)
print(f"SSIM: {metrics['ssim']:.4f}") # 1.0 = identical
Validation
tex.validate()
Check a texture for common issues. Returns a list of warning strings (empty = all good).
warnings = tex.validate()
if warnings:
for w in warnings:
print(f"WARNING: {w}")
else:
print("Texture is valid")
Checks: dimensions, power-of-two, minimum size for BC formats, mip count, data size, max dimensions, name.
suggest_format(has_alpha, *, normal_map, single_channel, quality_over_size)
Auto-detect the best compression format based on image characteristics.
from texfury import suggest_format, has_transparency
fmt = suggest_format(
has_alpha=has_transparency("icon.png"),
quality_over_size=True, # True → BC7, False → BC1/BC3
)
# Also supports: normal_map=True → BC5, single_channel=True → BC4
YTDFile — Texture Dictionary (.ytd)
Building a YTD
from texfury import YTDFile, Texture, BCFormat
ytd = YTDFile()
ytd.add(Texture.from_image("diffuse.png", format=BCFormat.BC7))
ytd.add(Texture.from_image("normal.png", format=BCFormat.BC5))
ytd.add(Texture.from_image("specular.png", format=BCFormat.BC1))
ytd.save("my_vehicle.ytd")
print(len(ytd)) # 3
Loading and Iterating
ytd = YTDFile.load("vehicles.ytd")
for tex in ytd.textures:
print(f"{tex.name}: {tex.width}x{tex.height} {tex.format.name} ({tex.mip_count} mips)")
Lookup, Replace, Remove
ytd = YTDFile.load("vehicles.ytd")
# Check if a texture exists
if "body_d" in ytd:
tex = ytd.get("body_d")
# List all names
print(ytd.names()) # ['body_d', 'body_n', 'body_s']
# Replace a single texture without rebuilding everything
new_tex = Texture.from_image("new_body_d.png", format=BCFormat.BC7)
ytd.replace("body_d", new_tex)
ytd.save("vehicles_patched.ytd")
# Remove a texture
ytd.remove("body_s")
Inspecting Without Loading Data
info = YTDFile.inspect("vehicles.ytd")
for entry in info:
print(f"{entry['name']}: {entry['width']}x{entry['height']} "
f"{entry['format_name']} mips={entry['mip_count']} "
f"size={entry['data_size']} bytes")
Extracting to DDS
ytd = YTDFile.load("props.ytd")
for tex in ytd.textures:
tex.save_dds(f"extracted/{tex.name}.dds")
Important: Texture names must be set before adding to a YTD. Names are automatically set from filenames when using from_image() or from_dds().
Convenience Functions
create_ytd_from_folder(folder, output, *, format, quality, generate_mipmaps, min_mip_size, mip_filter, on_progress)
Convert all images in a folder into a single YTD file. Also picks up .dds files.
from texfury import create_ytd_from_folder, BCFormat
path = create_ytd_from_folder(
"textures/",
"output.ytd",
format=BCFormat.BC7,
quality=0.8,
on_progress=lambda i, total, name: print(f"[{i}/{total}] {name}"),
)
print(f"Created: {path}")
| Parameter | Default | Description |
|---|---|---|
folder |
— | Directory with image files |
output |
<folder>.ytd |
Output path |
format |
BC7 |
Compression format for all textures |
quality |
0.7 |
Compression quality 0.0–1.0 |
generate_mipmaps |
True |
Generate mipmap chain |
min_mip_size |
4 |
Minimum mip dimension |
mip_filter |
MITCHELL |
Downsampling filter for mipmaps |
on_progress |
None |
Callback (current, total, name) |
batch_convert(folder, output_dir, *, format, quality, generate_mipmaps, min_mip_size, mip_filter, on_progress)
Convert all images in a folder to individual DDS files.
from texfury import batch_convert, BCFormat
out = batch_convert(
"raw_textures/",
"dds_output/",
format=BCFormat.BC3,
quality=0.6,
on_progress=lambda i, total, name: print(f"[{i}/{total}] {name}"),
)
Parameters are the same as create_ytd_from_folder, except output_dir defaults to <folder>/dds_out/.
extract_ytd(ytd_path, output_dir)
Extract all textures from a YTD into DDS files.
from texfury import extract_ytd
output = extract_ytd("vehicles.ytd")
# Creates vehicles/texture1.dds, vehicles/texture2.dds, ...
output = extract_ytd("vehicles.ytd", "my_folder/")
# Extracts into my_folder/
Image Utilities
Standalone helper functions that work without compressing anything.
has_transparency(source)
Check if an image file has transparent pixels.
from texfury import has_transparency
if has_transparency("icon.png"):
print("Has transparency — use BC3 or BC7")
else:
print("Fully opaque — BC1 is fine")
is_power_of_two(width, height)
Check if both dimensions are powers of two.
from texfury import is_power_of_two
is_power_of_two(256, 512) # True
is_power_of_two(300, 400) # False
next_power_of_two(value)
Get the nearest power-of-two >= the given value.
from texfury import next_power_of_two
next_power_of_two(100) # 128
next_power_of_two(256) # 256
next_power_of_two(500) # 512
pot_dimensions(width, height)
Get power-of-two dimensions for a given size.
from texfury import pot_dimensions
pot_dimensions(300, 400) # (512, 512)
pot_dimensions(1920, 1080) # (2048, 2048)
image_dimensions(source)
Get width, height, and channel count of an image without full decompression.
from texfury import image_dimensions
w, h, ch = image_dimensions("photo.png")
print(f"{w}x{h}, {ch} channels") # e.g. 1920x1080, 4 channels
Examples
Auto-detect format with suggest_format
from texfury import Texture, suggest_format, has_transparency
def smart_compress(path, quality=0.8):
fmt = suggest_format(has_transparency(path))
return Texture.from_image(path, format=fmt, quality=quality)
tex = smart_compress("my_texture.png") # BC7 if alpha, BC7 if opaque (quality mode)
tex.save_dds("my_texture.dds")
Pillow pipeline: resize + overlay + compress
from PIL import Image
from texfury import Texture, BCFormat
base = Image.open("base.png").resize((512, 512))
overlay = Image.open("overlay.png").resize((512, 512))
base.paste(overlay, (0, 0), overlay)
tex = Texture.from_pil(base, format=BCFormat.BC7, quality=0.9)
tex.save_dds("composited.dds")
Build a YTD with mixed formats
from texfury import YTDFile, Texture, BCFormat
ytd = YTDFile()
# Opaque diffuse — BC1 is fine, smallest size
ytd.add(Texture.from_image("body_d.png", format=BCFormat.BC1, quality=0.7))
# Normal map — BC5 stores RG channels
ytd.add(Texture.from_image("body_n.png", format=BCFormat.BC5, quality=0.8))
# Specular with transparency — BC3
ytd.add(Texture.from_image("body_s.png", format=BCFormat.BC3, quality=0.7))
# Emissive — uncompressed for precision
ytd.add(Texture.from_image("body_e.png", format=BCFormat.A8R8G8B8))
ytd.save("body.ytd")
Batch convert with progress bar (tqdm)
from texfury import batch_convert, BCFormat
from tqdm import tqdm
pbar = None
def on_progress(i, total, name):
global pbar
if pbar is None:
pbar = tqdm(total=total, desc="Converting")
pbar.update(1)
pbar.set_postfix(texture=name)
batch_convert("raw/", "dds/", format=BCFormat.BC7, on_progress=on_progress)
if pbar:
pbar.close()
Re-pack an existing YTD with different compression
from texfury import YTDFile, extract_ytd, create_ytd_from_folder
# Extract original
extract_ytd("original.ytd", "temp_textures/")
# Re-pack with BC7 (original may have used DXT1/DXT5)
create_ytd_from_folder("temp_textures/", "repacked.ytd", format=BCFormat.BC7, quality=0.9)
Quality Guide
The quality parameter (0.0–1.0) maps to the encoder's internal quality levels:
| Range | Speed | Quality | Use case |
|---|---|---|---|
| 0.0–0.2 | Fastest | Low | Quick previews, testing |
| 0.3–0.5 | Fast | Medium | Development builds |
| 0.6–0.8 | Moderate | High | Production use (recommended) |
| 0.9–1.0 | Slow | Maximum | Final release, archival |
BC7 is the slowest format to encode but produces the best visual quality. For rapid iteration, use BC1 or BC3 at lower quality, then do a final pass with BC7 at 0.8+.
Limitations
- Windows only — the native DLL is compiled for x64 Windows with MSVC
- Power-of-two textures — YTD requires POT dimensions;
resize_to_pot=Truehandles this automatically - No BC2 / BC6H — BC2 (DXT3) is rarely used; BC6H (HDR) may be added later
- Max texture size — limited by available memory; typical textures are 256–2048px
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file texfury-1.1.0.tar.gz.
File metadata
- Download URL: texfury-1.1.0.tar.gz
- Upload date:
- Size: 955.4 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.14.3
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
12e9aa2b49581e59c277d654ee496d987db650ae3f021950d1d16720f95a59ec
|
|
| MD5 |
06d5d8b687332ea8e5cc24b6541030bf
|
|
| BLAKE2b-256 |
f2953cc5fe8e0dfac6230c8cd4bb84f560e31116cd3704c2265c6190409ac1e6
|
File details
Details for the file texfury-1.1.0-py3-none-any.whl.
File metadata
- Download URL: texfury-1.1.0-py3-none-any.whl
- Upload date:
- Size: 969.4 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.14.3
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
68a40c593b9962760c04ce962cb03a74365026d368c631813b99f92ab57dfa43
|
|
| MD5 |
583da3b56bd0ea9700753a8f19e4c7eb
|
|
| BLAKE2b-256 |
4b964fc2f68ab5f1cde72058d310ce5fbf1e673ced52b28de9a40bd73629ce23
|