Skip to main content

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 (GTA V Legacy, GTA V Enhanced, and RDR2)
  • 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.Image objects (Pillow is optional)
  • Batch operations with progress callbacks
  • Zero Python dependencies — Pillow is optional

Installation

pip install texfury

Pillow is optional. Install it (pip install Pillow) only if you want to use Texture.from_pil(), Texture.to_pil(), or has_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,
)

Create a GTA V Enhanced or RDR2 YTD

from texfury import create_ytd_from_folder, BCFormat, Game

# GTA V Enhanced
create_ytd_from_folder("my_textures/", "output.ytd", game=Game.GTAV_ENHANCED)

# RDR2
create_ytd_from_folder("my_textures/", "output.ytd", game=Game.RDR2)

Extract textures from a YTD

from texfury import extract_ytd

extract_ytd("vehicles.ytd", "extracted/")
# Auto-detects GTA V (Legacy/Enhanced) or RDR2 format
# 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): BC1 for speed/size, BC7 for quality
  • Textures with alpha: BC3 or BC7
  • 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

Game — Target Game

from texfury import Game
Value Description
Game.GTAV_LEGACY GTA V (original / Legacy edition). RSC7 version 13. Default.
Game.GTAV_ENHANCED GTA V Enhanced edition. RSC7 version 5.
Game.RDR2 Red Dead Redemption 2. RSC8.

The Game enum controls which binary format is used when building YTD files. When loading or inspecting a YTD, the game is auto-detected from the file header.


ITD — Internal Texture Dictionary (.ytd)

ITD is the unified class for reading and writing texture dictionaries across supported RAGE games.

Building a YTD

from texfury import ITD, Texture, BCFormat, Game

# GTA V (default)
ytd = ITD()
ytd.add(Texture.from_image("diffuse.png", format=BCFormat.BC7))
ytd.add(Texture.from_image("normal.png", format=BCFormat.BC5))
ytd.save("my_vehicle.ytd")

# RDR2
ytd = ITD(game=Game.RDR2)
ytd.add(Texture.from_image("diffuse.png", format=BCFormat.BC7))
ytd.save("my_rdr2_vehicle.ytd")

print(len(ytd))       # number of textures
print(ytd.game)        # Game.GTAV_LEGACY, Game.GTAV_ENHANCED, or Game.RDR2

Loading and Iterating

ytd = ITD.load("vehicles.ytd")  # auto-detects game/edition
print(ytd.game)  # Game.GTAV_LEGACY, Game.GTAV_ENHANCED, or Game.RDR2

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 = ITD.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 = ITD.inspect("vehicles.ytd")  # auto-detects game
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 = ITD.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, *, game, 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, Game

path = create_ytd_from_folder(
    "textures/",
    "output.ytd",
    game=Game.RDR2,        # default: Game.GTAV_LEGACY
    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
game GTAV_LEGACY Target game (Game.GTAV_LEGACY, Game.GTAV_ENHANCED, or Game.RDR2)
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. Auto-detects game format.

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 ITD, Texture, BCFormat

ytd = ITD()

# 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 ITD, 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=True handles 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


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

texfury-1.3.0.tar.gz (959.5 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

texfury-1.3.0-py3-none-any.whl (973.4 kB view details)

Uploaded Python 3

File details

Details for the file texfury-1.3.0.tar.gz.

File metadata

  • Download URL: texfury-1.3.0.tar.gz
  • Upload date:
  • Size: 959.5 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.14.3

File hashes

Hashes for texfury-1.3.0.tar.gz
Algorithm Hash digest
SHA256 6b515244d50d87e43b98c4610df0897099a07b974e120e35009841007977f4e6
MD5 f8703eeac59eae42b8ee792bd9e94201
BLAKE2b-256 48c46113ca5d5906872dd0463ac1982a3e40257d427317f97512fe475b6d2c75

See more details on using hashes here.

File details

Details for the file texfury-1.3.0-py3-none-any.whl.

File metadata

  • Download URL: texfury-1.3.0-py3-none-any.whl
  • Upload date:
  • Size: 973.4 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.14.3

File hashes

Hashes for texfury-1.3.0-py3-none-any.whl
Algorithm Hash digest
SHA256 17373b0fffcbf43aa6d23e924f22bc9730599a05acb727b101b113dd5727c442
MD5 b3f41deb30dde712f633fb00fbebc169
BLAKE2b-256 6552738c2ba359c185d31bc5a1afc1e5138515ec07a9169242e26fd53bcbe477

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page