Add encode command

This commit is contained in:
Andrew Cassidy 2021-04-10 21:34:49 -07:00
parent 560acb20ea
commit 0152e99c44
7 changed files with 195 additions and 18 deletions

View File

@ -88,8 +88,8 @@ class RawTexture : public Texture {
size_t NBytes() const noexcept override { return static_cast<unsigned long>(Width() * Height()) * sizeof(Color); }
template <int N, int M> ColorBlock<N, M> GetBlock(int block_x, int block_y) const {
if (block_x < 0 || (block_x + 1) * N > _width) throw std::out_of_range("x value out of range.");
if (block_y < 0 || (block_y + 1) * M > _height) throw std::out_of_range("y value out of range.");
if (block_x < 0) throw std::out_of_range("x value out of range.");
if (block_y < 0) throw std::out_of_range("y value out of range.");
// coordinates in the image of the top-left pixel of the selected block
ColorBlock<N, M> block;
@ -181,7 +181,7 @@ template <typename B> class BlockTexture final : public Texture {
size_t NBytes() const noexcept override { return _blocks.size() * sizeof(B); }
const uint8_t *Data() const noexcept override { return reinterpret_cast<const uint8_t *>(_blocks.data()); }
uint8_t *Data() noexcept override{ return reinterpret_cast<uint8_t *>(_blocks.data()); }
uint8_t *Data() noexcept override { return reinterpret_cast<uint8_t *>(_blocks.data()); }
};
} // namespace quicktex

View File

@ -53,8 +53,8 @@ def path_pairs(inputs, output, suffix, extension):
# decode to a file
if len(inputs) > 1:
raise click.BadOptionUsage('output', 'Output is a single file, but multiple input files were provided.')
if outpath.suffix not in decoded_extensions:
raise click.BadOptionUsage('output', f'File has incorrect extension for decoded file. Valid extensions are:\n{decoded_extensions}')
# if outpath.suffix not in decoded_extensions:
# raise click.BadOptionUsage('output', f'File has incorrect extension for decoded file. Valid extensions are:\n{decoded_extensions}')
return [(inpath, outpath) for inpath in inpaths]
else:

View File

@ -1,6 +1,139 @@
import click
import os
import pathlib
import quicktex.s3tc.bc1
import quicktex.s3tc.bc3
import quicktex.s3tc.bc4
import quicktex.s3tc.bc5
import quicktex.dds as dds
import quicktex.cli.common as common
from PIL import Image
@click.group()
def encode():
"""Encode an input image to a texture file of the given format"""
"""Decode DDS files to images of the given format."""
@click.command()
@click.option('-f/-F', '--flip/--no-flip', default=True, show_default=True, help="Vertically flip image before converting.")
@click.option('-r', '--remove', is_flag=True, help="Remove input images after converting.")
@click.option('-s', '--suffix', type=str, default='', help="Suffix to append to output file(s). Ignored if output is a single file.")
@click.option('-o', '--output',
type=click.Path(writable=True), default=None,
help="Output file or directory. If outputting to a file, input filenames must be only a single item. By default, files are decoded in place.")
@click.argument('filenames', nargs=-1, type=click.Path(exists=True, readable=True, dir_okay=False))
def encode_format(encoder, four_cc, flip, remove, suffix, output, filenames):
path_pairs = common.path_pairs(filenames, output, suffix, '.dds')
with click.progressbar(path_pairs, show_eta=False, show_pos=True, item_show_func=lambda x: str(x[0]) if x else '') as bar:
for inpath, outpath in bar:
image = Image.open(inpath)
if flip:
image = image.transpose(Image.FLIP_TOP_BOTTOM)
dds.encode(image, encoder, four_cc).save(outpath)
if remove:
os.remove(inpath)
@click.command('auto')
@click.option('-l', '--level', type=click.IntRange(0, 18), default=18, help='Quality level to use. Higher values = higher quality, but slower.')
@click.option('-b/-B', '--black/--no-black',
help='[BC1 only] Enable 3-color mode for blocks containing black or very dark pixels. --3color must also be enabled for this to work.'
' (Important: engine/shader MUST ignore decoded texture alpha if this flag is enabled!)')
@click.option('-3/-4', '--3color/--4color', 'threecolor', default=True, help='[BC1 only] Enable 3-color mode for non-black pixels. Higher quality, but slightly slower.')
@click.option('-f/-F', '--flip/--no-flip', default=True, show_default=True, help="Vertically flip image before converting.")
@click.option('-r', '--remove', is_flag=True, help="Remove input images after converting.")
@click.option('-s', '--suffix', type=str, default='', help="Suffix to append to output file(s). Ignored if output is a single file.")
@click.option('-o', '--output',
type=click.Path(writable=True), default=None,
help="Output file or directory. If outputting to a file, input filenames must be only a single item. By default, files are decoded in place.")
@click.argument('filenames', nargs=-1, type=click.Path(exists=True, readable=True, dir_okay=False))
def encode_auto(level, black, threecolor, flip, remove, suffix, output, filenames):
"""Encode images to BC1 or BC3, with the format chosen based on each image's alpha channel."""
color_mode = quicktex.s3tc.bc1.BC1Encoder.ColorMode
if not threecolor:
mode = color_mode.FourColor
elif not black:
mode = color_mode.ThreeColor
else:
mode = color_mode.ThreeColorBlack
bc1_encoder = quicktex.s3tc.bc1.BC1Encoder(level, mode)
bc3_encoder = quicktex.s3tc.bc3.BC3Encoder(level)
path_pairs = common.path_pairs(filenames, output, suffix, '.dds')
with click.progressbar(path_pairs, show_eta=False, show_pos=True, item_show_func=lambda x: str(x[0]) if x else '') as bar:
for inpath, outpath in bar:
image = Image.open(inpath)
if flip:
image = image.transpose(Image.FLIP_TOP_BOTTOM)
if 'A' not in image.mode:
has_alpha = False
else:
alpha_hist = image.getchannel('A').histogram()
has_alpha = any([a > 0 for a in alpha_hist[:-1]])
if has_alpha:
dds.encode(image, bc3_encoder, 'DXT5').save(outpath)
else:
dds.encode(image, bc1_encoder, 'DXT1').save(outpath)
if remove:
os.remove(inpath)
@click.command('bc1')
@click.option('-l', '--level', type=click.IntRange(0, 18), default=18, help='Quality level to use. Higher values = higher quality, but slower.')
@click.option('-b/-B', '--black/--no-black',
help='Enable 3-color mode for blocks containing black or very dark pixels. --3color must also be enabled for this to work.'
' (Important: engine/shader MUST ignore decoded texture alpha if this flag is enabled!)')
@click.option('-3/-4', '--3color/--4color', 'threecolor', default=True, help='Enable 3-color mode for non-black pixels. Higher quality, but slightly slower.')
def encode_bc1(level, black, threecolor, **kwargs):
"""Encode images to BC1 (RGB, no alpha)."""
color_mode = quicktex.s3tc.bc1.BC1Encoder.ColorMode
if not threecolor:
mode = color_mode.FourColor
elif not black:
mode = color_mode.ThreeColor
else:
mode = color_mode.ThreeColorBlack
encode_format.callback(encoder=quicktex.s3tc.bc1.BC1Encoder(level, mode), four_cc='DXT1', **kwargs)
@click.command('bc3')
@click.option('-l', '--level', type=click.IntRange(0, 18), default=18, help='Quality level to use. Higher values = higher quality, but slower.')
def encode_bc3(level, **kwargs):
"""Encode images to BC4 (RGBA, 8-bit interpolated alpha)."""
encode_format.callback(quicktex.s3tc.bc3.BC3Encoder(level), 'DXT5', **kwargs)
@click.command('bc4')
def encode_bc4(**kwargs):
"""Encode images to BC4 (Single channel, 8-bit interpolated red channel)."""
encode_format.callback(quicktex.s3tc.bc4.BC4Encoder(), 'ATI1', **kwargs)
@click.command('bc5')
def encode_bc5(**kwargs):
"""Encode images to BC5 (2-channel, 8-bit interpolated red and green channels)."""
encode_format.callback(quicktex.s3tc.bc5.BC5Encoder(), 'ATI2', **kwargs)
encode_bc1.params += encode_format.params
encode_bc3.params += encode_format.params
encode_bc4.params += encode_format.params
encode_bc5.params += encode_format.params
encode.add_command(encode_bc1)
encode.add_command(encode_bc3)
encode.add_command(encode_bc4)
encode.add_command(encode_bc5)
encode.add_command(encode_auto)

View File

@ -87,6 +87,19 @@ class DDSFlags(enum.IntFlag):
TEXTURE = CAPS | HEIGHT | WIDTH | PIXEL_FORMAT
class Caps0(enum.IntFlag):
"""Flags to indicate surface complexity"""
COMPLEX = 0x8
"""Optional; must be used on any file that contains more than one surface (a mipmap, a cubic environment map, or mipmapped volume texture)."""
MIPMAP = 0x400000
"""Optional; should be used for a mipmap."""
TEXTURE = 0x1000
"""Required"""
@typing.final
class DDSFile:
"""
@ -134,7 +147,7 @@ class DDSFile:
self.pixel_bitmasks: typing.Tuple[int, int, int, int] = (0, 0, 0, 0)
"""Tuple of bitmasks for each channel"""
self.caps: typing.Tuple[int, int, int, int] = (0, 0, 0, 0)
self.caps: typing.Tuple[Caps0, int, int, int] = (Caps0.TEXTURE, 0, 0, 0)
"""Specifies the complexity of the surfaces stored."""
self.textures: typing.List = []
@ -149,12 +162,6 @@ class DDSFile:
:param path: string or path-like object to write to
"""
with open(path, 'wb') as file:
self.size = self.textures[0].size
self.pitch = self.textures[0].nbytes
self.mipmap_count = len(self.textures)
assert quicktex.image_utils.mip_sizes(self.size, self.mipmap_count) == [tex.size for tex in self.textures], 'incorrect mipmap sizes'
file.write(DDSFile.magic)
# WRITE HEADER
@ -241,3 +248,33 @@ def read(path: os.PathLike) -> DDSFile:
dds.textures.append(texture)
return dds
def encode(image: Image.Image, encoder, four_cc: str, mip_count: typing.Optional[int] = None) -> DDSFile:
if image.mode != 'RGBA' or image.mode != 'RGBX':
mode = 'RGBA' if 'A' in image.mode else 'RGBX'
image = image.convert(mode)
sizes = quicktex.image_utils.mip_sizes(image.size, mip_count)
images = [image] + [image.resize(size, Image.BILINEAR) for size in sizes[1:]]
dds = DDSFile()
for i in images:
rawtex = quicktex.RawTexture.frombytes(i.tobytes('raw', mode), *i.size)
dds.textures.append(encoder.encode(rawtex))
dds.flags = DDSFlags.TEXTURE | DDSFlags.LINEAR_SIZE
caps0 = Caps0.TEXTURE
if len(images) > 1:
dds.flags |= DDSFlags.MIPMAPCOUNT
caps0 |= Caps0.MIPMAP | Caps0.COMPLEX
dds.caps = (caps0, 0, 0, 0)
dds.mipmap_count = len(images)
dds.pitch = dds.textures[0].nbytes
dds.size = dds.textures[0].size
dds.pf_flags = PFFlags.FOURCC
dds.four_cc = four_cc
return dds

View File

@ -367,7 +367,7 @@ BC1Block BC1Encoder::WriteBlockSolid(Color color) const {
min16 = result.low.Pack565Unscaled();
max16 = result.high.Pack565Unscaled();
if (result.solid) {
if (result.color_mode == ColorMode::FourColor) {
if (min16 == max16) {
// make sure this isnt accidentally a 3-color block
// so make max16 > min16 (l > h)

View File

@ -143,9 +143,9 @@ class BC1Encoder final : public BlockEncoder<BlockTexture<BC1Block>> {
struct EncodeResults {
Color low;
Color high;
std::array<uint8_t, 16> selectors;
ColorMode color_mode;
bool solid;
std::array<uint8_t, 16> selectors = {0};
ColorMode color_mode = ColorMode::Incomplete;
bool solid = false;
unsigned error = UINT_MAX;
};

View File

@ -1,8 +1,10 @@
import unittest
import nose
import os.path
from parameterized import parameterized, parameterized_class
import quicktex
from quicktex.s3tc.bc1 import BC1Block, BC1Texture, BC1Encoder, BC1Decoder
from tests.images import BC1Blocks
from tests.images import BC1Blocks, image_path
from PIL import Image, ImageChops
in_endpoints = ((253, 254, 255), (65, 70, 67)) # has some small changes that should encode the same
@ -180,6 +182,11 @@ class TestBC1Encoder(unittest.TestCase):
else:
self.assertFalse(out_block.is_3color, 'returned 3-color block in 4-color mode')
def test_image(self):
image = Image.open(os.path.join(image_path, 'Bun.png'))
rawtex = quicktex.RawTexture.frombytes(image.tobytes('raw', 'RGBA'), *image.size)
out_tex = self.bc1_encoder.encode(rawtex)
class TestBC1Decoder(unittest.TestCase):
"""Test BC1Decoder"""