diff --git a/quicktex/Texture.h b/quicktex/Texture.h index c0f72e3..8975e28 100644 --- a/quicktex/Texture.h +++ b/quicktex/Texture.h @@ -88,8 +88,8 @@ class RawTexture : public Texture { size_t NBytes() const noexcept override { return static_cast(Width() * Height()) * sizeof(Color); } template ColorBlock 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 block; @@ -181,7 +181,7 @@ template 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(_blocks.data()); } - uint8_t *Data() noexcept override{ return reinterpret_cast(_blocks.data()); } + uint8_t *Data() noexcept override { return reinterpret_cast(_blocks.data()); } }; } // namespace quicktex \ No newline at end of file diff --git a/quicktex/cli/common.py b/quicktex/cli/common.py index 09c7baf..6efe26d 100644 --- a/quicktex/cli/common.py +++ b/quicktex/cli/common.py @@ -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: diff --git a/quicktex/cli/encode.py b/quicktex/cli/encode.py index 5a77d0e..fc06bff 100644 --- a/quicktex/cli/encode.py +++ b/quicktex/cli/encode.py @@ -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) diff --git a/quicktex/dds.py b/quicktex/dds.py index 447752c..a58a744 100644 --- a/quicktex/dds.py +++ b/quicktex/dds.py @@ -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 diff --git a/quicktex/s3tc/bc1/BC1Encoder.cpp b/quicktex/s3tc/bc1/BC1Encoder.cpp index 40c858a..5951c2c 100644 --- a/quicktex/s3tc/bc1/BC1Encoder.cpp +++ b/quicktex/s3tc/bc1/BC1Encoder.cpp @@ -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) diff --git a/quicktex/s3tc/bc1/BC1Encoder.h b/quicktex/s3tc/bc1/BC1Encoder.h index ae26b7e..bcf3fc2 100644 --- a/quicktex/s3tc/bc1/BC1Encoder.h +++ b/quicktex/s3tc/bc1/BC1Encoder.h @@ -143,9 +143,9 @@ class BC1Encoder final : public BlockEncoder> { struct EncodeResults { Color low; Color high; - std::array selectors; - ColorMode color_mode; - bool solid; + std::array selectors = {0}; + ColorMode color_mode = ColorMode::Incomplete; + bool solid = false; unsigned error = UINT_MAX; }; diff --git a/tests/test_bc1.py b/tests/test_bc1.py index d6c2c09..b8838ee 100644 --- a/tests/test_bc1.py +++ b/tests/test_bc1.py @@ -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"""