Compare commits

..

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

23 changed files with 262 additions and 377 deletions

4
.gitignore vendored
View file

@ -1,10 +1,8 @@
# Dev environment # Dev environment
*venv/ *venv/
.vscode/ .vscode/
*out*/ *out/
assets/
# python files # python files
__pycache__/ __pycache__/
*.egg-info/ *.egg-info/
.mypy_cache/

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,7 @@
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
LOGGER = logging.getLogger(LoggingConfig.NAME)
class SpritesheetData(TypedDict): class SpritesheetData(TypedDict):
facesize: Tuple[int, int] facesize: Tuple[int, int]
@ -18,11 +14,9 @@ class ExtendData(TypedDict):
def fetch_config(chara_id: str) -> SpritesheetData: 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}") response = requests.get(url, timeout=AtlasDefaults.TIMEOUT)
response = requests.get(url, timeout=AtlasAPIConfig.TIMEOUT)
LOGGER.debug(f"{response.status_code} - {response.text}")
if not response.ok: if not response.ok:
raise ValueError() raise ValueError(f"{response.status_code} - {response.text}")
resp_data = response.json()[0] resp_data = response.json()[0]
extend_data: ExtendData = resp_data["extendData"] extend_data: ExtendData = resp_data["extendData"]
@ -39,22 +33,19 @@ def fetch_config(chara_id: str) -> SpritesheetData:
"position": position "position": position
} }
LOGGER.debug(returndata)
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") print("Found cached asset for mstsvt.json")
return return
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}")
if status != 200: if status != 200:
raise ValueError("Could not fetch mstsvnt.json from atlas - please check your network connection") raise ValueError("Could not fetch mstsvnt.json from atlas - please check your network connection")
for block in response.iter_content(1024): for block in response.iter_content(1024):
@ -62,11 +53,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
@ -80,25 +72,23 @@ def fetch_expression_sheets(tempfolder: pathlib.Path, imageid: str):
postfix = f"f{idx}" postfix = f"f{idx}"
if filelocation.exists(): if filelocation.exists():
LOGGER.info(f"Found cached asset for {imageid}{postfix}.png") print(f"Found cached asset for {imageid}{postfix}.png")
idx += 1 idx += 1
continue continue
filename = f"{imageid}{postfix}.png" filename = f"{imageid}{postfix}.png"
atlasurl = f"{atlasurl_base}/{filename}" atlasurl = f"{atlasurl_base}/{filename}"
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}")
if status != 200: if status != 200:
continue continue
for block in response.iter_content(1024): for block in response.iter_content(1024):
if not block: if not block:
break break
handle.write(block) handle.write(block)
LOGGER.info(f"Finished downloading {filename}") print(f"Finished downloading {filename}")
idx += 1 idx += 1
p = savefolder / f"{idx}.png" p = savefolder / f"{idx}.png"
p.unlink(missing_ok=True) p.unlink(missing_ok=True)
@ -107,13 +97,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}") response = requests.get(atlasurl, timeout=AtlasDefaults.TIMEOUT)
response = requests.get(atlasurl, timeout=AtlasAPIConfig.TIMEOUT)
LOGGER.debug(f"{response.status_code}")
if not response.ok: if not response.ok:
LOGGER.debug(f"{response.status_code} - {response.text}")
raise ValueError(f"{response.status_code} - {response.text}") raise ValueError(f"{response.status_code} - {response.text}")
responsedata = response.json() responsedata = response.json()
@ -121,6 +108,5 @@ def fetch_data(servantid: int) -> List[str]:
charascripts: List[dict[str, str]] = responsedata["charaScripts"] charascripts: List[dict[str, str]] = responsedata["charaScripts"]
chara_ids: List[str] = [chara["id"] for chara in charascripts] chara_ids: List[str] = [chara["id"] for chara in charascripts]
LOGGER.debug(chara_ids) print(f"{svtname} ({servantid}) - {len(chara_ids)} charaIds")
LOGGER.info(f"{svtname} ({servantid}) - {len(chara_ids)} charaIds")
return chara_ids return chara_ids

View file

@ -0,0 +1,121 @@
import pathlib
from typing import List, Optional
import numpy as np
from PIL import Image
from tqdm.contrib import itertools as tqdm_itertools
from ..config import Paths
from .atlas import SpritesheetData, fetch_data, fetch_expression_sheets, fetch_config
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:
print(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 ]
for char_id in chara_ids:
expfolder = fetch_expression_sheets(savefolder.stem, char_id)
config = fetch_config(char_id)
process_sprite(expfolder, config, savefolder)
print(f"Files have been saved at: {savefolder.absolute()}")
def calculate_counts(width: int, height: int, facesize: tuple[int, int]):
return height // facesize[1], width // facesize[0]
def gen_main_sprite(folder: pathlib.Path):
image = Image.open(folder / "0.png")
width, height = image.size
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)
save_sprite(main_sprite, outputfolder, f"{images_folder.stem}")
for i in images_folder.iterdir():
initial_row, index = 0, int(i.stem)
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:
save_sprite(img, outputfolder, f"{images_folder.stem}", (x, y, colcount, index))
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] - 1,
(row + 1) * facesize[1] - 1
)
expression = expressions.crop(roi)
if is_empty(expression):
return None
composition = main_sprite.copy()
composition.paste(expression, position, expression)
return composition
def save_sprite(image: Image.Image, outputfolder: pathlib.Path, name: str, info: tuple | None = None):
savefolder = outputfolder / name
if not savefolder.is_dir():
savefolder.mkdir()
postfix = "0"
if info is not None:
(row, col, column_count, file_idx) = info
if file_idx == 0 and column_count == 4:
postfix = str(col + 1)
elif file_idx == 0:
raise ValueError("Should not have any faces")
elif column_count == 4:
postfix = str((column_count * row + col + 1) + pow(column_count, 2) * (file_idx - 1) + column_count)
elif column_count == 1:
postfix = str((file_idx - 1) * 16 + 1 if file_idx >= 2 else file_idx * 4 + 1)
elif column_count < 4:
postfix = str((column_count * row + col + 1) + pow(column_count, 2) * (file_idx - 1))
else:
raise ValueError("Unaccounted case")
outfile = savefolder / f"{postfix}.png"
with open(outfile, 'wb') as file:
image.save(file)
def is_empty(img: Image.Image):
data = np.asarray(img.crop((96, 96, 160, 160)).convert('LA'))
np.reshape(data, (-1, 1))
_, count_unique = np.unique(data, return_counts=True)
if count_unique.size < 10:
return True
return False

View file

@ -0,0 +1,78 @@
import argparse
import pathlib
import sys
from typing import List
from .. import __version__
from ..backend import compose
from ..config import Paths, AtlasDefaults
from ..utils.filesystem import rmdir
# pylint: disable=too-few-public-methods
class Arguments(argparse.Namespace):
"""
Default Arguments when calling the CLI
"""
output: str
cacheclear: bool
filter: List[str]
timeout: int
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("--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 for a servant")
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
welcome()
input_id = input("Enter servantId/charaId: ")
try:
t = int(input_id)
if t <= 0:
raise ValueError
except ValueError:
print("Servant ID has to be a valid integer above 0")
sys.exit(1)
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)
print("Successfully cleared cached assets")
else:
print("No cache to clear was found, continuing")
compose(int(input_id), args.filter)

View file

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

View file

@ -1,22 +1,17 @@
# pylint: disable=too-few-public-methods # pylint: disable=too-few-public-methods
import os
import pathlib import pathlib
class LoggingConfig:
_level = os.environ.get("SKYEWEAVE_STDOUT_LEVEL", "info")
LEVEL = int(_level) if _level.isdigit() else _level.upper()
NAME = "skyeweave"
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

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

@ -1,23 +1,33 @@
[project] [project]
name = "skyeweave" name = "atlasimagecomposer"
version = "1.0.0-c.3" version = "0.1.0-c.2"
dependencies = [
"numpy~=2.0.1",
"pillow~=10.4.0",
"requests~=2.32.3",
"tqdm~=4.66.5",
]
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",
"Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.12",
] ]
dependencies = [ [project.scripts]
"pillow~=10.4.0", atlasimagecomposer = "atlasimagecomposer.cli.cli:run_cli"
"requests~=2.32.3",
"tqdm~=4.66.5", [tool.setuptools.packages.find]
] where = ["."]
include = ["atlasimagecomposer*"]
[tool.setuptools.package-data]
"*" = ["py.typed"]
[project.optional-dependencies] [project.optional-dependencies]
lint = [ lint = [
@ -29,31 +39,18 @@ typing = [
"types-requests~=2.32.0", "types-requests~=2.32.0",
] ]
[project.scripts]
skyeweave = "skyeweave.cli:run"
[tool.setuptools.packages.find]
where = ["."]
include = ["skyeweave*"]
[tool.setuptools.package-data]
"*" = ["py.typed"]
[tool.pylint."MAIN"] [tool.pylint."MAIN"]
disable = [ disable = [
"line-too-long", "line-too-long",
"missing-module-docstring", "missing-module-docstring",
"missing-function-docstring", "missing-function-docstring",
"missing-class-docstring", "missing-class-docstring",
"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()