Skip to content

Commit

Permalink
Merge pull request #30 from Trimatix/dev
Browse files Browse the repository at this point in the history
Beta v0.8
  • Loading branch information
Trimatix authored Mar 1, 2024
2 parents 8abea52 + a8daba3 commit a193d67
Show file tree
Hide file tree
Showing 35 changed files with 692 additions and 205 deletions.
107 changes: 23 additions & 84 deletions .github/workflows/testing.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,90 +40,29 @@ jobs:
- name: Run tests
run: |
# run tests and generate coverage
# Reject coverage below 80%
pytest --cov=. --cov-report=xml:test-coverage.xml --cov-fail-under=80
# run tests again, but this time output to a txt file for the commenting step
pytest --cov=. > pytest-coverage-comment.txt
# function to parse xml
# https://stackoverflow.com/questions/893585/how-to-parse-xml-in-bash
rdom () { local IFS=\> ; read -d \< E C ;}
# parse the coverage file to find the main 'coverage' tag
while rdom; do
if [[ $E == coverage\ * ]]; then
COV_LINE=$E;
break;
fi;
done < test-coverage.xml
# Extract the 'line-rate' (coverage) of the whole project from the coverage tag
# https://superuser.com/questions/434507/how-to-find-the-index-of-a-word-in-a-string-in-bash
COV_AR=($COV_LINE)
for el in "${COV_AR[@]}"; do
if [[ $el == line-rate=\"* ]]; then
# https://stackoverflow.com/questions/21077882/pattern-to-get-string-between-two-specific-words-characters-using-grep
TEST_COVERAGE=$(grep -oP '(?<=\").*?(?=\")' <<< $el)
echo Coverage found $TEST_COVERAGE
# multiply by 100 in a really hacky way, because no floating point arithmetic in bash
IFS="." read -ra TEST_COVERAGE_SPLIT <<< "$TEST_COVERAGE"
TEST_COVERAGE=$(sed -e 's/^[[0]]*//' <<< ${TEST_COVERAGE_SPLIT[0]})$(sed 's/./&./2' <<< ${TEST_COVERAGE_SPLIT[1]})%
echo "COVERAGE=${TEST_COVERAGE}" >> $GITHUB_ENV
break
fi;
done
# var REF = 'refs/pull/27/merge.json';
REF=${{ github.ref }}
# console.log('github.ref: ' + REF);
echo "github.ref: $REF"
# var PATHS = REF.split('/');
IFS='/' read -ra PATHS <<< "$REF"
# var BRANCH_NAME = PATHS[1] + PATHS[2];
BRANCH_NAME="${PATHS[1]}_${PATHS[2]}"
# console.log(BRANCH_NAME); // 'pull_27'
echo $BRANCH_NAME
# process.env.BRANCH = 'pull_27';
echo "BRANCH=$(echo ${BRANCH_NAME})" >> $GITHUB_ENV
# - name: Create test coverage Badge
# uses: schneegans/dynamic-badges-action@v1.0.0
# with:
# auth: ${{ secrets.GIST_SECRET }}
# gistID: 2551cac90336c1d1073d8615407cc72d
# filename: AEPi__${{ env.BRANCH }}.json
# label: test coverage
# message: ${{ env.COVERAGE }}
# color: green
# namedLogo: jest
- name: Comment on pull request with test coverage
uses: coroo/pytest-coverage-commentator@v1.0.2
pytest --cov=. --junitxml=pytest.xml --cov-report=term-missing:skip-covered | tee pytest-coverage.txt
- name: Pytest coverage comment
id: coverageComment
uses: MishaKav/pytest-coverage-comment@main
with:
pytest-coverage: pytest-coverage-comment.txt
pytest-coverage-path: ./pytest-coverage.txt
junitxml-path: ./pytest.xml
- name: Run pyright
uses: jakebailey/pyright-action@v1.3.0
# with:
# # Version of pyright to run. If not specified, the latest version will be used.
# version: # optional
# # Directory to run pyright in. If not specified, the repo root will be used.
# working-directory: # optional
# # Analyze for a specific platform (Darwin, Linux, Windows).
# python-platform: # optional
# # Analyze for a specific version (3.3, 3.4, etc.).
# python-version: # optional
# # Use typeshed type stubs at this location.
# typeshed-path: # optional
# # Directory that contains virtual environments.
# venv-path: # optional
# # Use the configuration file at this location.
# project: # optional
# # Use library code to infer types when stubs are missing.
# lib: # optional, default is false
# # Use exit code of 1 if warnings are reported.
# warnings: # optional, default is false
# # Package name to run the type verifier on; must be an *installed* library. Any score under 100% will fail the build.
# verify-types: # optional
# # Extra arguments; can be used to specify specific files to check.
# extra-args: # optional
# # Disable issue/commit comments
# no-comments: # optional, default is false
- name: Check for test failures/low coverage
run: |
# Reject if there were any failures or errors, or coverage below 80%
covperc=${{ steps.coverageComment.outputs.coverage }}
# Strip % character off the end of code coverage (no need to convert to int in bash)
if [[ ${covperc%\%*} < 80 ]] ; then
echo "::error title=Test coverage too low::Tests achieve ${{ steps.coverageComment.outputs.coverage }} coverage, but at least 80% is required"
exit 1
fi
if [[ ${{ steps.coverageComment.outputs.errors }} > 0 ]] ; then
echo "::error title=Test errors::Tests had ${{ steps.coverageComment.outputs.errors }} errors, but must have 0 to pass"
exit 1
fi
if [[ ${{ steps.coverageComment.outputs.failures }} > 0 ]] ; then
echo "::error title=Test failures::Tests had ${{ steps.coverageComment.outputs.failures }} failures, but must have 0 to pass"
exit 1
fi
19 changes: 18 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,12 @@
<li><a href="#installation">Installation</a></li>
</ul>
</li>
<li><a href="#usage">Usage</a></li>
<li>
<a href="#usage">Usage</a></li>
<ul>
<li><a href="#open-an-aei-file-on-disk">Open an .aei file on disk</a></li>
<li><a href="#create-a-new-aei">Create a new AEI</a></li>
</ul>
<li><a href="#roadmap">Roadmap</a></li>
<li><a href="#contributing">Contributing</a></li>
</ol>
Expand Down Expand Up @@ -128,6 +133,18 @@ with AEI.read("path/to/file.aei") as aei:
)
```

##### Reading textures as image segments

`AEI.textures` provides read access to all of the AEI's bounding boxes. The `AEI.getTexture` method returns the relevant segment of the AEI, as a Pillow `Image`.

Using this, you could for example, batch export all of the individual images within an AEI:

```py
for i, tex in enumerate(aei.textures):
with aei.getTexture(tex) as im:
im.save(f"batch/export/{i}.png")
```

#### Create a new AEI

```py
Expand Down
17 changes: 17 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,21 @@ build-backend = "setuptools.build_meta"
[tool.pyright]
exclude = [
"src/tests/assets"
]

[tool.pytest.ini_options]
markers = [
# Codec tests need to be run in isolation to prevent a segfault (#29)
"codecs_Unknown: Codecs that handle the Unknown format",
"codecs_Uncompressed_UI: Codecs that handle the Uncompressed_UI format",
"codecs_Uncompressed_CubeMap_PC: Codecs that handle the Uncompressed_CubeMap_PC format",
"codecs_Uncompressed_CubeMap: Codecs that handle the Uncompressed_CubeMap format",
"codecs_PVRTC12A: Codecs that handle the PVRTC12A format",
"codecs_PVRTC14A: Codecs that handle the PVRTC14A format",
"codecs_ATC: Codecs that handle the ATC format",
"codecs_DXT1: Codecs that handle the DXT1 format",
"codecs_DXT3: Codecs that handle the DXT3 format",
"codecs_DXT5: Codecs that handle the DXT5 format",
"codecs_ETC1: Codecs that handle the ETC1 format",
"codecs: All codecs"
]
5 changes: 3 additions & 2 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
etcpak
pillow
pillow
tex2img
etcpak
2 changes: 1 addition & 1 deletion setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ name = AEPi
version = attr: AEPi.__version__
author = Jasper Law
author_email = trimatix.music@gmail.com
description = Create Abyss Engine Image (AEI) files from python, for Galaxy on Fire 2
description = Read and write Abyss Engine Image (AEI) files from python, for Galaxy on Fire 2
long_description = file: README.md
long_description_content_type = text/markdown
url = https://github.com/Trimatix/AEPi
Expand Down
3 changes: 2 additions & 1 deletion src/AEPi/__init__.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
from .image import AEI, Texture
from .constants import CompressionFormat, CompressionQuality
from .codec import *
from . import codecs
from . import lib
from .lib.imageOps import switchRGBA_BGRA
from . import codec

__version__ = "0.8"
__all__ = ["AEI", "Texture", "CompressionFormat", "CompressionQuality", "codecs", "lib", "switchRGBA_BGRA", "codec"]
47 changes: 26 additions & 21 deletions src/AEPi/codec.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
from abc import ABC, abstractmethod
from io import BytesIO
from abc import ABC
from typing import Dict, Optional, Type, TypeVar, Iterable
from PIL.Image import Image

from .constants import CompressionFormat, CompressionQuality
from .exceptions import UnsupportedCompressionFormatException

class ImageCodecAdaptor(ABC):
@classmethod
@abstractmethod
def compress(cls, im: Image, format: CompressionFormat, quality: Optional[CompressionQuality]) -> bytes:
"""Compress a BGRA image into format `format`, with quality `quality`
Expand All @@ -20,22 +19,25 @@ def compress(cls, im: Image, format: CompressionFormat, quality: Optional[Compre
:return: `im`, compressed into format `format`
:rtype: bytes
"""
raise NotImplementedError()
raise NotImplementedError(f"Codec {cls.__name__} is not capable of compression")


@classmethod
@abstractmethod
def decompress(cls, fp: BytesIO, format: CompressionFormat) -> Image:
def decompress(cls, fp: bytes, format: CompressionFormat, width: int, height: int, quality: Optional[CompressionQuality]) -> Image:
"""Decompress a `format`-compressed BGRA image into a BGRA Image.
:param fp: The compressed image to decompress
:type im: BytesIO
:type im: bytes
:param format: The compression format
:type format: CompressionFormat
:param width: The width of the image
:type width: int
:param height: The height of the image
:type height: int
:return: `fp`, decompressed into a BGRA image
:rtype: Image
"""
raise NotImplementedError()
raise NotImplementedError(f"Codec {cls.__name__} is not capable of decompression")


compressors: Dict[CompressionFormat, Type[ImageCodecAdaptor]] = {}
Expand Down Expand Up @@ -74,15 +76,18 @@ def supportsFormats(
:type format: Optional[Iterable[CompressionFormat]]
"""
def inner(cls: Type[TCodec]) -> Type[TCodec]:
for f in compresses or []:
compressors[f] = cls

for f in decompresses or []:
decompressors[f] = cls

for f in both or []:
compressors[f] = cls
decompressors[f] = cls
if compresses:
for f in compresses:
compressors[f] = cls

if decompresses:
for f in decompresses:
decompressors[f] = cls

if both:
for f in both:
compressors[f] = cls
decompressors[f] = cls
return cls
return inner

Expand All @@ -94,10 +99,10 @@ def compressorFor(format: CompressionFormat) -> Type[ImageCodecAdaptor]:
:type format: CompressionFormat
:return: An ImageCodecAdaptor subclass that can compress `format`
:rtype: Type[ImageCodecAdaptor]
:raises KeyError: If no compatible codec is loaded
:raises AeiWriteException: If no compatible codec is loaded
"""
if format not in compressors:
raise KeyError(f"No {ImageCodecAdaptor.__name__} found for format '{format.name}' compression. Make sure that the module containing the class has been imported.")
raise UnsupportedCompressionFormatException(format)
return compressors[format]


Expand All @@ -108,8 +113,8 @@ def decompressorFor(format: CompressionFormat) -> Type[ImageCodecAdaptor]:
:type format: CompressionFormat
:return: An ImageCodecAdaptor subclass that can decompress `format`
:rtype: Type[ImageCodecAdaptor]
:raises KeyError: If no compatible codec is loaded
:raises AeiReadException: If no compatible codec is loaded
"""
if format not in decompressors:
raise KeyError(f"No {ImageCodecAdaptor.__name__} found for format '{format.name}' decompression. Make sure that the module containing the class has been imported.")
raise UnsupportedCompressionFormatException(format)
return decompressors[format]
20 changes: 8 additions & 12 deletions src/AEPi/codecs/EtcPakCodec.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
from io import BytesIO
from typing import Optional
from typing import Optional
from PIL.Image import Image
from ..codec import ImageCodecAdaptor, supportsFormats
from ..constants import CompressionFormat, CompressionQuality
from ..exceptions import DependancyMissingException

try:
import etcpak
except ImportError:
raise ValueError("Cannot use codec EtcPakCodec, because required library etcpak is not installed")
except ImportError as e:
raise DependancyMissingException("EtcPakCodec", "etcpak", e)


@supportsFormats(compresses=[
Expand All @@ -15,21 +17,15 @@
])
class EtcPakCodec(ImageCodecAdaptor):
@classmethod
def compress(cls, im: Image, format: CompressionFormat, quality: CompressionQuality) -> bytes:
def compress(cls, im: Image, format: CompressionFormat, quality: Optional[CompressionQuality]) -> bytes:
if format is CompressionFormat.DXT5:
if im.mode != "RGBA":
im = im.convert("RGBA")
return etcpak.compress_to_dxt5(im.tobytes(), im.width, im.height)
return etcpak.compress_to_dxt5(im.tobytes(), im.width, im.height) # type: ignore[reportUnknownVariableType]

if format is CompressionFormat.ETC1:
if im.mode != "RGBA":
im = im.convert("RGBA")
return etcpak.compress_to_etc1(im.tobytes(), im.width, im.height)
return etcpak.compress_to_etc1(im.tobytes(), im.width, im.height) # type: ignore[reportUnknownVariableType]

raise ValueError(f"Codec {EtcPakCodec.__name__} does not support format {format.name}")


@classmethod
def decompress(cls, fp: BytesIO, format: CompressionFormat, quality: CompressionQuality) -> Image:
raise NotImplementedError(f"Codec {EtcPakCodec.__name__} is not capable of decompression")

34 changes: 34 additions & 0 deletions src/AEPi/codecs/Tex2ImgCodec.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
from typing import Optional
from typing import Optional
from PIL import Image
from ..codec import ImageCodecAdaptor, supportsFormats
from ..constants import CompressionFormat, CompressionQuality
from ..exceptions import DependancyMissingException

try:
import tex2img
except ImportError as e:
raise DependancyMissingException("Tex2ImgCodec", "tex2img", e)

TEX2IMG_FORMAT_MAP = {
# CompressionFormat.PVRTC14A: 12, # Tex2ImgCodec segfaults decoding PVRTC with all tests (#29)
CompressionFormat.ATC: 14,
CompressionFormat.DXT1: 5,
CompressionFormat.DXT5: 6,
CompressionFormat.ETC1: 0
}


@supportsFormats(decompresses=TEX2IMG_FORMAT_MAP.keys())
class Tex2ImgCodec(ImageCodecAdaptor):
@classmethod
def decompress(cls, fp: bytes, format: CompressionFormat, width: int, height: int, quality: Optional[CompressionQuality]) -> Image.Image:
if format not in TEX2IMG_FORMAT_MAP:
raise ValueError(f"Codec {Tex2ImgCodec.__name__} does not support format {format.name}")

decompressed = tex2img.basisu_decompress(fp, width, height, TEX2IMG_FORMAT_MAP[format]) # type: ignore[reportUnknownMemberType]
im = Image.frombytes("RGBA", (width, height), decompressed, "raw") # type: ignore[reportUnknownMemberType]

if im.mode != format.pillowMode:
return im.convert(format.pillowMode)
return im
Loading

1 comment on commit a193d67

@github-actions
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Coverage

Coverage Report
FileStmtsMissCoverMissing
src/AEPi
   codec.py37392%22, 40, 119
   constants.py61297%33, 54
   exceptions.py401172%18–19, 28–29, 39–40, 47, 54, 61, 68–69
src/AEPi/codecs
   EtcPakCodec.py231152%10–11, 21–31
   Tex2ImgCodec.py22386%10–11, 27
   __init__.py8275%11–12
src/AEPi/image
   AEI.py2252091%120, 180, 184, 197, 224, 287, 293–296, 301, 306, 325, 332, 339–340, 371, 385–386, 463–464
   texture.py19289%29, 49
src/AEPi/lib
   binaryio.py24196%64
   imageOps.py9278%13–14
src/tests/image
   test_AEI.py199199%29
TOTAL7905893% 

Tests Skipped Failures Errors Time
43 0 💤 0 ❌ 0 🔥 0.302s ⏱️

Please sign in to comment.