Compare commits

..

No commits in common. "main" and "0.1.0-c.4" have entirely different histories.

25 changed files with 280 additions and 341 deletions

3
.gitignore vendored
View file

@ -1,8 +1,7 @@
# Dev environment # Dev environment
*venv/ *venv/
.vscode/ .vscode/
*out*/ *out/
assets/
# python files # python files
__pycache__/ __pycache__/

View file

@ -1,67 +1,27 @@
# skyeweave # atlasimagecomposer
Easily generate any FGO expression sheets from an id A small CLI toolkit that allows you to easily download and compose any FGO expression sheets
> Developed by [Firq](https://firq.dev/) and powered by the [AtlasAcademy API](https://atlasacademy.io/) ## Installations
## Installation Install it using `pip` - You need to specify my extra index for it to be found
The CLI can be installed by using `pip`. Python >= 3.10 is required.
```shell ```shell
pip install --extra-index-url https://forgejo.neshweb.net/api/packages/Firq/pypi/simple/ skyeweave pip install --extra-index-url https://forgejo.neshweb.net/api/packages/Firq/pypi/simple/ atlasimagecomposer
``` ```
Note: Specifying the additional index of `forgejo.neshweb.net` is necessary, as I don't host my packages on PyPI. Using `--extra-index-url` is necessary as the
specified depencencies are not hosted on that index.
## Usage ## Usage
### commandline Use it via the CLI command `atlasimagecomposer`
`skyeweave` is primarily a CLI application.
The following options are available:
- `--help` / `-h`: Shows helpful information about the other arguments
- `--version`: Shows the version number
- `--output`: Sets the output file path. This can be a relative path (`./out` or an absolute path `C:/files/out`)
- `--id`: Specify a servantId or charaId. This will skip the user prompt afterwards (useful in CI applications)
- `--filter`: Specify which spritesheets will actually be used. Useful if you want multiple spritesheets, but not all
- `--timeout`: Sets the timeout for any requests towards the Atlas Academy API. The default is 10 seconds
- `--no-cache`: Clear cache for this id, keeping all other files
- `--reset`: Delete any already downloaded assets
- `--quiet` / `-q`: Mute the output and hide progress bars
Typical usage:
```plain ```plain
skyeweave --output out --id 70 usage: atlasimagecomposer [-h] [--version] [--output OUTPUT] [--filter FILTER [FILTER ...]] [--timeout TIMEOUT] [--clear-cache]
```
options:
This would generate the expressions for Scathach (Servant Id 70) in the folder out, using subfolders to better separate the outputs of multiple runs. -h, --help show this help message and exit
--version show program's version number and exit
### python scripts [EXPERIMENTAL] --output OUTPUT Set the output location. This can be an absolute or relative path
--filter FILTER [FILTER ...] Specify one or more spritesheet ids that will be fetched
`skyeweave` can also be used in other Python scripts. --timeout TIMEOUT Set the timeout for all requests towards AtlasAcademy, default is 10s
--clear-cache Clear cached assets before downloading files for a servant
```python
from skyeweave import SkyeWeave
SkyeWeave().compose(70)
```
This feature will be expanded upon in future releases.
## Issues / Support
If there are any issues with `skyeweave`, feel free to reach out to me using the AtlasAcademy discord (`#dev-corner`) or create an issue in this repo.
If you want to help me debug when an issue occurs, set the environment variable `SKYEWEAVE_STDOUT_LEVEL` to `debug` and send me a copy of the log
Example on Windows:
```shell
$env:SKYEWEAVE_STDOUT_LEVEL="debug"
skyeweave --output out --id 70 > log.log
``` ```

View file

@ -0,0 +1,3 @@
import importlib.metadata
__version__ = importlib.metadata.version(__package__ or "atlasimagecomposer")

View file

@ -0,0 +1 @@
from .compose import compose

View file

@ -1,11 +1,10 @@
import logging import logging
import pathlib
from typing import Annotated, List, NotRequired, Tuple, TypedDict from typing import Annotated, List, NotRequired, Tuple, TypedDict
import requests import requests
from ..config import AtlasAPIConfig, PathConfig, ExpressionDefaults, LoggingConfig from ..config import AtlasDefaults, Paths, ExpressionDefaults, Logging
LOGGER = logging.getLogger(LoggingConfig.NAME) LOGGER = logging.getLogger(Logging.NAME)
class SpritesheetData(TypedDict): class SpritesheetData(TypedDict):
facesize: Tuple[int, int] facesize: Tuple[int, int]
@ -19,7 +18,7 @@ def fetch_config(chara_id: str) -> SpritesheetData:
url = f"https://api.atlasacademy.io/raw/JP/svtScript?charaId={chara_id}" url = f"https://api.atlasacademy.io/raw/JP/svtScript?charaId={chara_id}"
LOGGER.debug(f"Loading data for {url}") LOGGER.debug(f"Loading data for {url}")
response = requests.get(url, timeout=AtlasAPIConfig.TIMEOUT) response = requests.get(url, timeout=AtlasDefaults.TIMEOUT)
LOGGER.debug(f"{response.status_code} - {response.text}") LOGGER.debug(f"{response.status_code} - {response.text}")
if not response.ok: if not response.ok:
raise ValueError() raise ValueError()
@ -43,8 +42,8 @@ def fetch_config(chara_id: str) -> SpritesheetData:
return returndata return returndata
def fetch_mstsvtjson(): def fetch_mstsvtjson():
url = AtlasAPIConfig.MST_SVT_JSON url = AtlasDefaults.MST_SVT_JSON
filelocation = PathConfig.IMAGES / "mstsvt.json" filelocation = Paths.IMAGES / "mstsvt.json"
if filelocation.exists(): if filelocation.exists():
LOGGER.info("Found cached asset for mstsvt.json") LOGGER.info("Found cached asset for mstsvt.json")
@ -52,7 +51,7 @@ def fetch_mstsvtjson():
LOGGER.debug(f"Loading data for {url}") LOGGER.debug(f"Loading data for {url}")
with open(filelocation, 'wb') as handle: with open(filelocation, 'wb') as handle:
response = requests.get(url, stream=True, timeout=AtlasAPIConfig.TIMEOUT) response = requests.get(url, stream=True, timeout=AtlasDefaults.TIMEOUT)
status = response.status_code status = response.status_code
LOGGER.debug(f"{response.status_code} - {response.text}") LOGGER.debug(f"{response.status_code} - {response.text}")
if status != 200: if status != 200:
@ -62,11 +61,12 @@ def fetch_mstsvtjson():
break break
handle.write(block) handle.write(block)
def fetch_expression_sheets(tempfolder: pathlib.Path, imageid: str): def fetch_expression_sheets(basefolder: str, imageid: str):
atlasurl_base = f"https://static.atlasacademy.io/{AtlasAPIConfig.REGION}/CharaFigure/{imageid}" atlasurl_base = f"https://static.atlasacademy.io/{AtlasDefaults.REGION}/CharaFigure/{imageid}"
savefolder = tempfolder / str(imageid) savefolder = Paths.IMAGES / basefolder / str(imageid)
savefolder.mkdir(exist_ok=True, parents=True) if not savefolder.is_dir():
savefolder.mkdir(exist_ok=True, parents=True)
idx, status = 0, 200 idx, status = 0, 200
@ -89,7 +89,7 @@ def fetch_expression_sheets(tempfolder: pathlib.Path, imageid: str):
LOGGER.debug(f"Loading data for {atlasurl}") LOGGER.debug(f"Loading data for {atlasurl}")
with open(filelocation, 'wb') as handle: with open(filelocation, 'wb') as handle:
response = requests.get(atlasurl, stream=True, timeout=AtlasAPIConfig.TIMEOUT) response = requests.get(atlasurl, stream=True, timeout=AtlasDefaults.TIMEOUT)
status = response.status_code status = response.status_code
LOGGER.debug(f"{response.status_code} - {response.text}") LOGGER.debug(f"{response.status_code} - {response.text}")
if status != 200: if status != 200:
@ -107,10 +107,10 @@ def fetch_expression_sheets(tempfolder: pathlib.Path, imageid: str):
def fetch_data(servantid: int) -> List[str]: def fetch_data(servantid: int) -> List[str]:
atlasurl = f"https://api.atlasacademy.io/nice/{AtlasAPIConfig.REGION}/servant/{servantid}?lore=false&lang=en" atlasurl = f"https://api.atlasacademy.io/nice/{AtlasDefaults.REGION}/servant/{servantid}?lore=false&lang=en"
LOGGER.debug(f"Loading data for {atlasurl}") LOGGER.debug(f"Loading data for {atlasurl}")
response = requests.get(atlasurl, timeout=AtlasAPIConfig.TIMEOUT) response = requests.get(atlasurl, timeout=AtlasDefaults.TIMEOUT)
LOGGER.debug(f"{response.status_code}") LOGGER.debug(f"{response.status_code}")
if not response.ok: if not response.ok:
LOGGER.debug(f"{response.status_code} - {response.text}") LOGGER.debug(f"{response.status_code} - {response.text}")

View file

@ -0,0 +1,114 @@
import logging
import pathlib
from typing import List, Optional
from collections import Counter
from PIL import Image
from tqdm.contrib import itertools as tqdm_itertools
from ..config import Paths, Logging
from .atlas import SpritesheetData, fetch_data, fetch_expression_sheets, fetch_config
LOGGER = logging.getLogger(Logging.NAME)
def compose(input_id: int, filters: Optional[List[str]] = None):
Paths.IMAGES.mkdir(exist_ok=True)
Paths.OUTPUT.mkdir(exist_ok=True)
if input_id < 10000:
chara_ids = fetch_data(input_id)
savefolder = Paths.OUTPUT / str(input_id)
else:
LOGGER.info(f"Processing manually uploaded charaId {input_id}")
savefolder = Paths.OUTPUT / "manual"
chara_ids = [str(input_id)]
if not savefolder.is_dir():
savefolder.mkdir(parents=True, exist_ok=True)
if filters is not None:
chara_ids = [ v for v in chara_ids if v in filters ]
LOGGER.debug(chara_ids)
for char_id in chara_ids:
expfolder = fetch_expression_sheets(savefolder.stem, char_id)
config = fetch_config(char_id)
process_sprite(expfolder, config, savefolder)
LOGGER.info(f"Files have been saved at: {savefolder.absolute()}")
def calculate_counts(width: int, height: int, facesize: tuple[int, int]):
rowcount, colcount = height // facesize[1], width // facesize[0]
LOGGER.debug(f"{height} | {facesize[1]} --> {rowcount}")
LOGGER.debug(f"{width} | {facesize[0]} --> {colcount}")
return rowcount, colcount
def gen_main_sprite(folder: pathlib.Path):
image = Image.open(folder / "0.png")
width, height = image.size
LOGGER.debug(f"Main sprite ({folder}): {width}:{height}")
return image.crop((0, 0, width, height - 256))
def process_sprite(images_folder: pathlib.Path, configdata: SpritesheetData, outputfolder: pathlib.Path):
main_sprite = gen_main_sprite(images_folder)
image_idx = save_sprite(main_sprite, outputfolder, f"{images_folder.stem}")
for i in images_folder.iterdir():
LOGGER.debug(f"Idx: {image_idx}")
initial_row = 0
expressions = Image.open(i)
rowcount, colcount = calculate_counts(*expressions.size, configdata["facesize"])
if i.name == "0.png" and 256 < configdata["facesize"][1]:
continue
if i.name == "0.png":
initial_row = rowcount - 1
for x, y in tqdm_itertools.product(range(initial_row, rowcount), range(0, colcount), ascii="-="):
img = generate_sprite(main_sprite, expressions, x, y, configdata)
if img is not None:
image_idx = save_sprite(img, outputfolder, f"{images_folder.stem}", image_idx)
LOGGER.debug(f"{x}/{y} - {'Invalid' if img is None else 'Valid'} image")
def generate_sprite(main_sprite: Image.Image, expressions: Image.Image, row: int, col: int, configdata: SpritesheetData) -> Image.Image | None:
position, facesize = configdata["position"], configdata["facesize"]
roi = (
col * facesize[0],
row * facesize[1],
(col + 1) * facesize[0],
(row + 1) * facesize[1]
)
LOGGER.debug(roi)
expression = expressions.crop(roi)
if is_empty(expression):
LOGGER.debug("Image empty")
return None
mask = Image.new("RGBA", (facesize[0], facesize[1]), (255,255,255,255))
composition = main_sprite.copy()
composition.paste(expression, position, mask)
return composition
def save_sprite(image: Image.Image, outputfolder: pathlib.Path, name: str, idx: int = 0) -> int:
savefolder = outputfolder / name
if not savefolder.is_dir():
savefolder.mkdir()
outfile = savefolder / f"{idx}.png"
with open(outfile, 'wb') as file:
image.save(file)
return idx + 1
def is_empty(img: Image.Image):
data = Counter(img.crop((96, 96, 160, 160)).convert('LA').getdata())
LOGGER.debug(f"Counts: {len(data)}")
if len(data) < 10:
return True
return False

View file

@ -0,0 +1,94 @@
import argparse
import logging
import pathlib
import sys
from typing import List
from .. import __version__
from ..backend import compose
from ..config import Paths, AtlasDefaults, Logging
from ..utils.filesystem import rmdir
from ..utils.disables import disable_tqdm
LOGGER = logging.getLogger(Logging.NAME)
# pylint: disable=too-few-public-methods
class Arguments(argparse.Namespace):
"""
Default Arguments when calling the CLI
"""
output: str
id: str
cacheclear: bool
filter: List[str]
timeout: int
quiet: bool
def parse_arguments():
"""
Create a parser and parse the arguments of sys.argv based on the Arguments Namespace
Returns arguments and extra arguments separately
"""
parser = argparse.ArgumentParser(
prog="atlasimagecomposer",
description="CLI tool to automatically generate expression sheets for servants",
epilog="If there are any issues during execution, it helps to clear the cache from time to time")
parser.add_argument("--version", action="version", version=f"atlasimagecomposer {__version__}")
parser.add_argument("--output", action="store", type=str, default=None, dest="output", help="Set the output location. This can be an absolute or relative path")
parser.add_argument("--id", action="store", type=str, default=None, dest="id", help="Set the servantId/charaId - Skips user prompt when provided")
parser.add_argument("--filter", action="extend", nargs="+", dest="filter", help='Specify one or more spritesheet ids that will be fetched')
parser.add_argument("--timeout", action="store", type=int, default=None, dest="timeout", help="Set the timeout for all requests towards AtlasAcademy, default is 10s")
parser.add_argument("--clear-cache", action="store_true", default=False, dest="cacheclear", help="Clear cached assets before downloading files")
parser.add_argument("--quiet", "-q", action="store_true", default=False, dest="quiet", help="Disable logging output")
args = Arguments()
args, extra_args = parser.parse_known_args(sys.argv[1:], namespace=args)
return args, extra_args
def welcome():
print("-------------------------------------------------")
print(" Welcome to the FGO Sprite loader and composer ")
print(" developed by Firq ")
print("-------------------------------------------------")
def run_cli():
args, _ = parse_arguments()
if args.output:
Paths.OUTPUT = pathlib.Path(args.output)
if args.timeout and args.timeout >= 0:
AtlasDefaults.TIMEOUT = args.timeout
if args.quiet:
disable_tqdm()
LOGGER.disabled = True
else:
welcome()
input_id = args.id
if not input_id:
input_id = input("Enter servantId/charaId: ")
try:
t = int(input_id)
if t <= 0:
raise ValueError
except ValueError:
LOGGER.error("Servant ID has to be a valid integer above 0")
sys.exit(1)
input_id = int(input_id)
if args.cacheclear:
cachepath = Paths.IMAGES / str(input_id)
if input_id > 10000:
cachepath = Paths.IMAGES / "manual" / str(input_id)
if cachepath.exists():
rmdir(cachepath)
LOGGER.info("Successfully cleared cached assets")
else:
LOGGER.info("No cache to clear was found, continuing")
compose(input_id, args.filter)

View file

@ -0,0 +1 @@
from .config import AtlasDefaults, ExpressionDefaults, Logging, Paths

View file

@ -2,21 +2,21 @@
import os import os
import pathlib import pathlib
class LoggingConfig: class Logging:
_level = os.environ.get("SKYEWEAVE_STDOUT_LEVEL", "info") _level = os.environ.get("AIC_STDOUT_LEVEL", "info")
LEVEL = int(_level) if _level.isdigit() else _level.upper() LEVEL = int(_level) if _level.isdigit() else _level.upper()
NAME = "skyeweave" NAME = "atlasimagecomposer"
class PathConfig: class Paths:
_root = pathlib.Path(__file__).parents[1] _root = pathlib.Path(__file__).parents[1]
IMAGES = _root / ".temp" IMAGES = _root / ".temp"
OUTPUT = pathlib.Path.cwd() / "output" OUTPUT = _root / ".out"
class ExpressionDefaults: class ExpressionDefaults:
FACESIZE = 256 FACESIZE = 256
SHEETSIZE = 1024 SHEETSIZE = 1024
class AtlasAPIConfig: class AtlasDefaults:
REGION = "JP" REGION = "JP"
TIMEOUT = 10 TIMEOUT = 10
MST_SVT_JSON = "https://git.atlasacademy.io/atlasacademy/fgo-game-data/raw/branch/JP/master/mstSvtScript.json" MST_SVT_JSON = "https://git.atlasacademy.io/atlasacademy/fgo-game-data/raw/branch/JP/master/mstSvtScript.json"

View file

View file

@ -0,0 +1,2 @@
from .logger import init_logger
LOGGER = init_logger()

View file

@ -0,0 +1,4 @@
def disable_tqdm():
from tqdm import tqdm
from functools import partialmethod
tqdm.__init__ = partialmethod(tqdm.__init__, disable=True)

View file

@ -1,5 +1,6 @@
import pathlib import pathlib
def rmdir(directory: pathlib.Path): def rmdir(directory: pathlib.Path):
""" """
Recursively deletes all files and folders in a given directory Recursively deletes all files and folders in a given directory

View file

@ -0,0 +1,18 @@
import logging
import sys
from ..config import Logging
from .disables import disable_tqdm
def init_logger():
if Logging.LEVEL == "DEBUG":
disable_tqdm()
logger = logging.getLogger(Logging.NAME)
logger.setLevel(Logging.LEVEL)
handler = logging.StreamHandler(stream=sys.stdout)
formatter = logging.Formatter('[%(levelname)s] [%(name)s] %(asctime)s - %(message)s')
handler.setFormatter(formatter)
logger.addHandler(handler)
return logger

View file

@ -1,12 +1,12 @@
[project] [project]
name = "skyeweave" name = "atlasimagecomposer"
version = "1.0.0-c.3" version = "0.1.0-c.4"
requires-python = ">= 3.10" requires-python = ">= 3.10"
authors = [{name = "Firq", email = "firelp42@gmail.com"}] authors = [{name = "Firq", email = "firelp42@gmail.com"}]
maintainers = [{name = "Firq", email = "firelp42@gmail.com"}] maintainers = [{name = "Firq", email = "firelp42@gmail.com"}]
description = "Easily generate any FGO expression sheets from an id" description = "Package that enables people to quickly download and generate all potential spritesheet expressions with a single command"
classifiers = [ classifiers = [
"Development Status :: 5 - Production/Stable", "Development Status :: 3 - Alpha",
"Programming Language :: Python :: 3", "Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.11",
@ -30,11 +30,11 @@ typing = [
] ]
[project.scripts] [project.scripts]
skyeweave = "skyeweave.cli:run" atlasimagecomposer = "atlasimagecomposer.cli.cli:run_cli"
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
where = ["."] where = ["."]
include = ["skyeweave*"] include = ["atlasimagecomposer*"]
[tool.setuptools.package-data] [tool.setuptools.package-data]
"*" = ["py.typed"] "*" = ["py.typed"]
@ -47,13 +47,11 @@ disable = [
"missing-class-docstring", "missing-class-docstring",
"logging-fstring-interpolation", "logging-fstring-interpolation",
] ]
ignore-paths="test/*"
[tool.mypy] [tool.mypy]
python_version = "3.11" python_version = "3.11"
warn_return_any = true warn_return_any = true
warn_unused_configs = true warn_unused_configs = true
exclude = [ "test" ]
[build-system] [build-system]
requires = ["setuptools >= 61.0"] requires = ["setuptools >= 61.0"]

View file

@ -1,5 +0,0 @@
import importlib.metadata
__version__ = importlib.metadata.version(__package__ or "skyeweave")
from .service import SkyeWeave

View file

@ -1 +0,0 @@
from .cli import run

View file

@ -1,94 +0,0 @@
import argparse
import logging
import pathlib
import sys
from typing import List
from .. import __version__
from ..service import SkyeWeave
from ..config import AtlasAPIConfig, LoggingConfig, PathConfig
from ..utils import rmdir, disable_tqdm
LOGGER = logging.getLogger(LoggingConfig.NAME)
# pylint: disable=too-few-public-methods
class CLIArguments(argparse.Namespace):
"""
Default Arguments when calling the CLI
"""
output: str
id: int
reset: bool
nocache: bool
filter: List[str]
timeout: int
quiet: bool
def parse_arguments(arguments):
"""
Create a parser and parse the arguments of sys.argv based on the Arguments Namespace
Returns arguments and extra arguments separately
"""
parser = argparse.ArgumentParser(
prog="skyeweave",
description="CLI tool to automatically generate expression sheets for servants",
epilog="If there are any issues during execution, it helps to clear the cache from time to time")
parser.add_argument("--version", action="version", version=f"skyeweave {__version__}")
parser.add_argument("--output", action="store", type=str, default=None, dest="output", help="Set the output location. This can be an absolute or relative path")
parser.add_argument("--id", action="store", type=int, default=None, dest="id", help="Set the servantId/charaId - Skips user prompt when provided")
parser.add_argument("--filter", action="extend", nargs="+", dest="filter", help='SSpecify which spritesheets will actually be used (one or more charaIds)')
parser.add_argument("--timeout", action="store", type=int, default=None, dest="timeout", help="Set the timeout for all requests towards AtlasAcademy (default: 10s)")
parser.add_argument("--no-cache", action="store_true", default=False, dest="nocache", help="Clear cache for this id, keeping all other files")
parser.add_argument("--reset", action="store_true", default=False, dest="reset", help="Delete any already downloaded assets")
parser.add_argument("--quiet", "-q", action="store_true", default=False, dest="quiet", help="Mute output and hide progress bars")
return parser.parse_known_args(arguments, namespace=CLIArguments)
def __welcome():
print("-------------------------------------------")
print(" Welcome to skyeweave, an expression sheet ")
print(" composer developed by Firq ")
print("-------------------------------------------")
def validate_id(input_id: None | str) -> int:
input_id = input_id or input("Enter servantId or charaId: ")
try:
if int(input_id) <= 0:
raise ValueError
except ValueError:
LOGGER.error("Servant ID has to be a valid integer above 0")
sys.exit(1)
return int(input_id)
def run():
args, _ = parse_arguments(sys.argv[1:])
if args.output:
PathConfig.OUTPUT = pathlib.Path(args.output)
if args.timeout and args.timeout >= 0:
AtlasAPIConfig.TIMEOUT = args.timeout
if args.quiet:
disable_tqdm()
LOGGER.disabled = True
else:
__welcome()
input_id = validate_id(args.id)
if args.nocache:
cachepath = PathConfig.IMAGES / str(input_id)
if input_id > 10000:
cachepath = PathConfig.IMAGES / "manual" / str(input_id)
if cachepath.exists():
rmdir(cachepath)
LOGGER.info("Successfully cleared cached assets")
else:
LOGGER.info("No cache to clear was found, continuing")
if args.reset and PathConfig.IMAGES.exists():
rmdir(PathConfig.IMAGES)
LOGGER.info("Successfully reset local storage")
SkyeWeave().compose(input_id, args.filter)

View file

@ -1 +0,0 @@
from .config import AtlasAPIConfig, ExpressionDefaults, LoggingConfig, PathConfig

View file

@ -1 +0,0 @@
from .compose import SkyeWeave

View file

@ -1,125 +0,0 @@
import logging
import pathlib
from typing import List, Optional
from collections import Counter
from PIL import Image
from tqdm.contrib import itertools as tqdm_itertools
from ..config import PathConfig, LoggingConfig
from .atlas import SpritesheetData, fetch_data, fetch_expression_sheets, fetch_config
LOGGER = logging.getLogger(LoggingConfig.NAME)
class SkyeWeave:
output_folder: pathlib.Path
image_folder: pathlib.Path
def __init__(self, output: Optional[pathlib.Path] = None, assets: Optional[pathlib.Path] = None):
self.output_folder = output or PathConfig.OUTPUT
self.image_folder = assets or PathConfig.IMAGES
self.output_folder.mkdir(exist_ok=True)
self.image_folder.mkdir(exist_ok=True)
def compose(self, input_id: int, filters: Optional[List[str]] = None):
if input_id < 10000:
chara_ids = fetch_data(input_id)
savefolder, tempfolder = self.output_folder / str(input_id), self.image_folder / str(input_id)
else:
LOGGER.info(f"Processing manually uploaded charaId {input_id}")
savefolder, tempfolder = self.output_folder / "manual", self.image_folder / "manual"
chara_ids = [str(input_id)]
savefolder.mkdir(parents=True, exist_ok=True)
tempfolder.mkdir(parents=True, exist_ok=True)
chara_ids = [ v for v in chara_ids if v in filters ] if filters else chara_ids
LOGGER.debug(chara_ids)
for char_id in chara_ids:
expfolder = fetch_expression_sheets(tempfolder, char_id)
config = fetch_config(char_id)
self.process_sprite(expfolder, config, savefolder)
LOGGER.info(f"Files have been saved at: {savefolder.absolute()}")
def process_sprite(self, images_folder: pathlib.Path, configdata: SpritesheetData, outputfolder: pathlib.Path):
main_sprite = self._gen_main_sprite(images_folder / "0.png")
image_idx = self._save_sprite(main_sprite, outputfolder, f"{images_folder.stem}")
for i in images_folder.iterdir():
LOGGER.debug(f"Idx: {image_idx}")
initial_row = 0
expressions = Image.open(i)
rowcount, colcount = self._calculate_counts(*expressions.size, configdata["facesize"])
if i.name == "0.png" and 256 < configdata["facesize"][1]:
continue
if i.name == "0.png":
initial_row = rowcount - 1
for x, y in tqdm_itertools.product(range(initial_row, rowcount), range(0, colcount), ascii="-=", desc=f"[PROG] [{LoggingConfig.NAME}]", bar_format="{desc} {percentage:3.0f}% |{bar}|"):
img = self._generate_sprite(main_sprite, expressions, x, y, configdata)
if img is not None:
image_idx = self._save_sprite(img, outputfolder, f"{images_folder.stem}", image_idx)
LOGGER.debug(f"{x}/{y} - {'Invalid' if img is None else 'Valid'} image")
@staticmethod
def _calculate_counts(width: int, height: int, facesize: tuple[int, int]):
rowcount, colcount = height // facesize[1], width // facesize[0]
LOGGER.debug(f"{height} | {facesize[1]} --> {rowcount}")
LOGGER.debug(f"{width} | {facesize[0]} --> {colcount}")
return rowcount, colcount
@staticmethod
def _gen_main_sprite(imagepath: pathlib.Path):
image = Image.open(imagepath)
width, height = image.size
LOGGER.debug(f"Main sprite ({imagepath}): {width}:{height}")
return image.crop((0, 0, width, height - 256))
@staticmethod
def _generate_sprite(main_sprite: Image.Image, expressions: Image.Image, row: int, col: int, configdata: SpritesheetData) -> Image.Image | None:
position, facesize = configdata["position"], configdata["facesize"]
roi = (
col * facesize[0],
row * facesize[1],
(col + 1) * facesize[0],
(row + 1) * facesize[1]
)
LOGGER.debug(roi)
expression = expressions.crop(roi)
if SkyeWeave._is_empty(expression):
LOGGER.debug("Image empty")
return None
mask = Image.new("RGBA", (facesize[0], facesize[1]), (255,255,255,255))
composition = main_sprite.copy()
composition.paste(expression, position, mask)
return composition
@staticmethod
def _save_sprite(image: Image.Image, outputfolder: pathlib.Path, name: str, idx: int = 0) -> int:
savefolder = outputfolder / name
if not savefolder.is_dir():
savefolder.mkdir()
outfile = savefolder / f"{idx}.png"
with open(outfile, 'wb') as file:
image.save(file)
return idx + 1
@staticmethod
def _is_empty(img: Image.Image):
w, h = img.size
croparea = (w * 0.375, h * 0.375, w * 0.625, h * 0.625 )
data = Counter(img.crop(croparea).convert('LA').getdata())
LOGGER.debug(f"Counts: {len(data)}")
if len(data) < 6:
return True
return False

View file

@ -1,2 +0,0 @@
from .filesystem import rmdir
from .logger import LOGGER, disable_tqdm

View file

@ -1,27 +0,0 @@
# pylint: disable=import-outside-toplevel
import logging
import sys
from ..config import LoggingConfig
def disable_tqdm():
from tqdm import tqdm
from functools import partialmethod
tqdm.__init__ = partialmethod(tqdm.__init__, disable=True)
def __init_logger():
if LoggingConfig.LEVEL == "DEBUG":
disable_tqdm()
default_logger = logging.getLogger(LoggingConfig.NAME)
default_logger.setLevel(LoggingConfig.LEVEL)
default_handler = logging.StreamHandler(stream=sys.stdout)
default_formatter = logging.Formatter('[%(levelname)s] [%(name)s] %(message)s')
default_handler.setFormatter(default_formatter)
default_logger.addHandler(default_handler)
return default_logger
LOGGER = __init_logger()