Compare commits

...

25 commits

Author SHA1 Message Date
56c1d5427a
Added testing
All checks were successful
/ mypy (push) Successful in 15s
/ tests (push) Successful in 52s
/ pylint (push) Successful in 10s
2024-10-20 21:38:22 +02:00
821251b77c
Split download and compose into two functions
All checks were successful
/ pylint (push) Successful in 11s
/ mypy (push) Successful in 13s
2024-10-20 19:11:26 +02:00
27d7598f81
Switched compose to be class-based
All checks were successful
/ pylint (push) Successful in 10s
/ mypy (push) Successful in 13s
/ publish-artifacts (push) Successful in 8s
/ lint-and-typing (push) Successful in 15s
/ release (push) Successful in 6s
/ build-artifacts (push) Successful in 7s
2024-10-18 15:59:18 +02:00
fc80fc9b84
new exports and restructuring
All checks were successful
/ mypy (push) Successful in 13s
/ pylint (push) Successful in 10s
/ lint-and-typing (push) Successful in 16s
/ publish-artifacts (push) Successful in 8s
/ release (push) Successful in 6s
/ build-artifacts (push) Successful in 7s
2024-10-17 23:32:02 +02:00
43d21deeed
new exports and restructuring 2024-10-17 23:27:20 +02:00
926aa582dc
Rebranding and first RC
All checks were successful
/ pylint (push) Successful in 11s
/ mypy (push) Successful in 15s
/ lint-and-typing (push) Successful in 15s
/ build-artifacts (push) Successful in 7s
/ release (push) Successful in 6s
/ publish-artifacts (push) Successful in 8s
2024-10-16 22:28:23 +02:00
f3f4ec51d2
Rebranding
All checks were successful
/ pylint (push) Successful in 10s
/ mypy (push) Successful in 12s
2024-10-16 22:13:39 +02:00
62f36dbdc2
Updated tqdm 2024-10-16 21:11:21 +02:00
347d9fc451
Updated tqdm logging
All checks were successful
/ pylint (push) Successful in 10s
/ mypy (push) Successful in 13s
/ lint-and-typing (push) Successful in 20s
/ publish-artifacts (push) Successful in 13s
/ release (push) Successful in 8s
/ build-artifacts (push) Successful in 8s
2024-10-14 22:43:24 +02:00
3fde0f4f08
Added logging, added quiet flag
All checks were successful
/ mypy (push) Successful in 13s
/ pylint (push) Successful in 10s
/ release (push) Successful in 11s
/ lint-and-typing (push) Successful in 15s
/ build-artifacts (push) Successful in 7s
/ publish-artifacts (push) Successful in 8s
2024-10-14 20:23:09 +02:00
db406adfdc
Fixed issues with sprite overlap, fixed naming of files, added id parameter to CLI, removed numpy dependency
All checks were successful
/ pylint (push) Successful in 10s
/ mypy (push) Successful in 13s
/ lint-and-typing (push) Successful in 15s
/ build-artifacts (push) Successful in 7s
/ publish-artifacts (push) Successful in 8s
/ release (push) Successful in 6s
2024-10-13 01:31:16 +02:00
941b4c5614
gitignore
All checks were successful
/ pylint (push) Successful in 13s
/ mypy (push) Successful in 17s
2024-10-11 01:14:14 +02:00
16beb10a54
Fixed issues with mypy
Some checks failed
/ mypy (push) Successful in 18s
/ pylint (push) Successful in 13s
/ build-artifacts (push) Successful in 8s
/ lint-and-typing (push) Successful in 19s
/ release (push) Failing after 6s
/ publish-artifacts (push) Successful in 8s
2024-10-11 01:06:58 +02:00
0377c6282a
Rewrote with charaIds, fixed big sprites
Some checks failed
/ mypy (push) Failing after 19s
/ pylint (push) Successful in 15s
/ release (push) Successful in 6s
/ lint-and-typing (push) Failing after 19s
/ build-artifacts (push) Has been skipped
/ publish-artifacts (push) Has been skipped
2024-10-11 01:04:05 +02:00
ad4391caea
Added quiet flag
All checks were successful
/ pylint (push) Successful in 23s
/ mypy (push) Successful in 27s
2024-10-05 15:09:05 +02:00
fbda98805f
Release Candidate 1 for the people
All checks were successful
/ lint-and-typing (push) Successful in 4m41s
/ build-artifacts (push) Successful in 15s
/ publish-artifacts (push) Successful in 1m2s
/ release (push) Successful in 59s
/ mypy (push) Successful in 17s
/ pylint (push) Successful in 12s
2024-10-05 14:01:50 +02:00
174dd557e5
Neshweb dockers
All checks were successful
/ mypy (push) Successful in 5m50s
/ pylint (push) Successful in 3m21s
2024-10-04 21:07:26 +02:00
33dd722770
removed -q
All checks were successful
/ pylint (push) Successful in 3m52s
/ mypy (push) Successful in 5m8s
2024-10-04 20:57:21 +02:00
fc32b34095
removed -q
Some checks failed
/ pylint (push) Successful in 3m45s
/ mypy (push) Has been cancelled
2024-10-04 20:52:41 +02:00
8311d16354
--no-cache-dir
All checks were successful
/ pylint (push) Successful in 3m37s
/ mypy (push) Successful in 5m21s
2024-10-04 20:46:09 +02:00
ed8c9c446e
mypy
Some checks failed
/ build-artifacts (push) Successful in 17s
/ lint-and-typing (push) Successful in 5m2s
/ publish-artifacts (push) Successful in 1m21s
/ release (push) Successful in 1m35s
/ mypy (push) Has been cancelled
/ pylint (push) Has been cancelled
2024-10-04 20:22:38 +02:00
3d546df0e0
Fixed a lot of issues
Some checks failed
/ pylint (push) Successful in 3m13s
/ mypy (push) Has been cancelled
/ lint-and-typing (push) Failing after 4m57s
/ publish-artifacts (push) Has been skipped
/ build-artifacts (push) Has been skipped
/ release (push) Successful in 1m43s
2024-10-04 20:18:56 +02:00
fdc62c04a6
Bumped ver
Some checks failed
/ mypy (push) Failing after 5m3s
/ pylint (push) Successful in 3m21s
/ build-artifacts (push) Has been skipped
/ publish-artifacts (push) Has been skipped
/ release (push) Failing after 23m18s
/ lint-and-typing (push) Failing after 5m15s
2024-10-04 19:30:57 +02:00
4cb46ca102
non-256 faceSize fixes 2024-10-04 19:30:40 +02:00
7dfc5dcefa
Absolute path
All checks were successful
/ mypy (push) Successful in 21s
/ pylint (push) Successful in 18s
/ lint-and-typing (push) Successful in 24s
/ build-artifacts (push) Successful in 9s
/ publish-artifacts (push) Successful in 10s
/ release (push) Successful in 7s
2024-08-09 19:14:12 +02:00
26 changed files with 677 additions and 206 deletions

View file

@ -8,13 +8,13 @@ on:
jobs:
lint-and-typing:
runs-on: docker
container: nikolaik/python-nodejs:python3.11-nodejs21
container: forgejo.neshweb.net/ci-docker-images/python-neshweb:3.11
steps:
- name: Checkout source code
uses: https://code.forgejo.org/actions/checkout@v3
- name: Install packages
run: |
pip install -e .[lint,typing] -q --disable-pip-version-check -q
pip install -e .[lint,typing] -q --disable-pip-version-check -q --no-cache-dir -q
python -m pip list --format=columns --disable-pip-version-check
- name: Run pylint
run: |
@ -28,12 +28,12 @@ jobs:
build-artifacts:
needs: ["lint-and-typing"]
runs-on: docker
container: nikolaik/python-nodejs:python3.11-nodejs21
container: forgejo.neshweb.net/ci-docker-images/python-neshweb:3.11
steps:
- name: Checkout source code
uses: https://code.forgejo.org/actions/checkout@v3
- name: Install packages
run: pip install build
run: pip install build --no-cache-dir -q
- name: Build package
run: python -m build
- name: Save build artifacts
@ -45,7 +45,7 @@ jobs:
publish-artifacts:
needs: ["build-artifacts"]
runs-on: docker
container: nikolaik/python-nodejs:python3.11-nodejs21
container: forgejo.neshweb.net/ci-docker-images/python-neshweb:3.11
steps:
- name: Downloading static site artifacts
uses: actions/download-artifact@v3
@ -53,7 +53,7 @@ jobs:
name: packages
path: dist
- name: Install Dependencies
run: pip install twine
run: pip install twine --no-cache-dir -q
- name: Upload package to registry
run: python -m twine upload --repository-url ${{ secrets.REPOSITORY_URL }} -u ${{ secrets.TWINE_DEPLOY_USER }} -p ${{ secrets.TWINE_DEPLOY_PASSWORD }} dist/*

View file

@ -5,13 +5,13 @@ on:
jobs:
pylint:
runs-on: docker
container: nikolaik/python-nodejs:python3.11-nodejs21
container: forgejo.neshweb.net/ci-docker-images/python-neshweb:3.11
steps:
- name: Checkout source code
uses: https://code.forgejo.org/actions/checkout@v3
- name: Install packages
run: |
pip install -e .[lint] -q --disable-pip-version-check -q
pip install -e .[lint] --disable-pip-version-check --no-cache-dir -q
python -m pip list --format=columns --disable-pip-version-check
- name: Run pylint
run: |
@ -20,15 +20,29 @@ jobs:
mypy:
runs-on: docker
container: nikolaik/python-nodejs:python3.11-nodejs21
container: forgejo.neshweb.net/ci-docker-images/python-neshweb:3.11
steps:
- name: Checkout source code
uses: https://code.forgejo.org/actions/checkout@v3
- name: Install packages
run: |
pip install -e .[typing] -q --disable-pip-version-check -q
pip install -e .[typing] --disable-pip-version-check --no-cache-dir -q
python -m pip list --format=columns --disable-pip-version-check
- name: Run mypy
run: |
mypy --version
mypy .
tests:
runs-on: docker
container: forgejo.neshweb.net/ci-docker-images/python-neshweb:3.11
steps:
- name: Checkout source code
uses: https://code.forgejo.org/actions/checkout@v3
- name: Install packages
run: |
pip install -e .[testing] --disable-pip-version-check --no-cache-dir -q
python -m pip list --format=columns --disable-pip-version-check
- name: Run pytest
run: |
pytest

6
.gitignore vendored
View file

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

67
README.md Normal file
View file

@ -0,0 +1,67 @@
# skyeweave
Easily generate any FGO expression sheets from an id
> Developed by [Firq](https://firq.dev/) and powered by the [AtlasAcademy API](https://atlasacademy.io/)
## Installation
The CLI can be installed by using `pip`. Python >= 3.10 is required.
```shell
pip install --extra-index-url https://forgejo.neshweb.net/api/packages/Firq/pypi/simple/ skyeweave
```
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
### commandline
`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
skyeweave --output out --id 70
```
This would generate the expressions for Scathach (Servant Id 70) in the folder out, using subfolders to better separate the outputs of multiple runs.
### python scripts [EXPERIMENTAL]
`skyeweave` can also be used in other Python scripts.
```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

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

View file

@ -1,3 +0,0 @@
from .compose import run
run()

View file

@ -1,42 +0,0 @@
import requests
from .cli import Paths
def fetch_image(servantid: str, imageid: str):
atlasurl = f"https://static.atlasacademy.io/JP/CharaFigure/{imageid}/{imageid}_merged.png"
savefolder = Paths.IMAGES / servantid
if not savefolder.is_dir():
savefolder.mkdir()
filelocation = savefolder / f"{imageid}.png"
with open(filelocation, 'wb') as handle:
response = requests.get(atlasurl, stream=True, timeout=10)
if not response.ok:
print(response)
for block in response.iter_content(1024):
if not block:
break
handle.write(block)
def fetch_info(servantid: str):
atlasurl = f"https://api.atlasacademy.io/basic/JP/servant/{servantid}?lang=en"
response = requests.get(atlasurl, timeout=10)
if not response.ok:
print(response)
data = response.json()
print(f"Fetching data and sprites for {data['name']} (ID: {servantid})")
def fetch_data(servantid: str):
fetch_info(servantid)
atlasurl = f"https://api.atlasacademy.io/raw/JP/servant/{servantid}?lore=false&expand=true&lang=en"
response = requests.get(atlasurl, timeout=10)
if not response.ok:
print(response)
data = response.json()
return { str(spritesheet["id"]): (spritesheet["faceX"], spritesheet["faceY"]) for spritesheet in data["mstSvtScript"] }

View file

@ -1,41 +0,0 @@
import argparse
import pathlib
import sys
from . import __version__
# pylint: disable=too-few-public-methods
class Arguments(argparse.Namespace):
"""
Default Arguments when calling the CLI
"""
output: str
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",)
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("--version", action="version", version=f"atlasimagecomposer {__version__}")
args = Arguments()
args, extra_args = parser.parse_known_args(sys.argv[1:], namespace=args)
return args, extra_args
class Paths:
_root = pathlib.Path(__file__).parent
_args, _ = parse_arguments()
IMAGES = _root / ".temp"
OUTPUT = _root / ".out"
if _args.output:
OUTPUT = pathlib.Path(_args.output)
class Expressions:
height = 256
width = 256

View file

@ -1,88 +0,0 @@
import pathlib
import sys
import numpy as np
from PIL import Image
from tqdm.contrib import itertools as tqdm_itertools
from .atlas import fetch_data, fetch_image
from .cli import Expressions, Paths
def welcome():
print("-------------------------------------------------")
print(" Welcome to the FGO Sprite loader and composer ")
print(" developed by Firq ")
print("-------------------------------------------------")
def run():
welcome()
Paths.IMAGES.mkdir(exist_ok=True)
Paths.OUTPUT.mkdir(exist_ok=True)
servantid = input("Enter servant ID: ")
try:
t = int(servantid)
if t <= 0:
raise ValueError
except ValueError:
print("Servant ID has to be a valid integer above 0")
sys.exit(1)
sprites = fetch_data(servantid)
for sprite in sprites:
fetch_image(servantid, sprite)
path = Paths.IMAGES / servantid
for f in path.iterdir():
process_sprite(f, sprites[f.stem], servantid)
print(f"Files have been saved at: {Paths.OUTPUT / servantid}")
def process_sprite(filepath: pathlib.Path, position: tuple, servantid: str):
im = Image.open(filepath)
width, height = im.size
main_sprite = im.crop((0, 0, width, 768))
save_sprite(main_sprite, servantid, filepath.stem)
expressions = im.crop((0, 768, width, height))
_, expressionheight = expressions.size
rows = expressionheight // Expressions.height
for x, y in tqdm_itertools.product(range(0, rows), range(0, 4, 1), ascii="-="):
img = generate_sprite(main_sprite, expressions, x, y, position)
if img is not None:
save_sprite(img, servantid, filepath.stem, (x, y))
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
def generate_sprite(sprite: Image.Image, expressions: Image.Image, row: int, col: int, position: tuple) -> Image.Image | None:
area = (
col * Expressions.width,
row * Expressions.height,
(col + 1) * Expressions.width - 1,
(row + 1) * Expressions.height - 1
)
expression = expressions.crop(area)
if is_empty(expression):
return None
compose = sprite.copy()
compose.paste(expression, position, expression)
return compose
def save_sprite(image: Image.Image, folder: str, name: str, rowcol: tuple | None = None):
savefolder = Paths.OUTPUT / folder
if not savefolder.is_dir():
savefolder.mkdir()
postfix = f"_{rowcol[0] * 4 + rowcol[1] + 1}" if rowcol is not None else "_0"
outfile = savefolder / f"{name}{postfix}.png"
with open(outfile, 'wb') as file:
image.save(file)

View file

@ -1,33 +1,23 @@
[project]
name = "atlasimagecomposer"
version = "0.1.0-a.2"
dependencies = [
"numpy~=2.0.1",
"pillow~=10.4.0",
"requests~=2.32.3",
"tqdm~=4.66.5",
]
name = "skyeweave"
version = "1.0.0-c.3"
requires-python = ">= 3.10"
authors = [{name = "Firq", email = "firelp42@gmail.com"}]
maintainers = [{name = "Firq", email = "firelp42@gmail.com"}]
description = "Tool to manage requests for supports"
description = "Easily generate any FGO expression sheets from an id"
classifiers = [
"Development Status :: 3 - Alpha",
"Development Status :: 5 - Production/Stable",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
]
[project.scripts]
atlasimagecomposer = "atlasimagecomposer.compose:run"
[tool.setuptools.packages.find]
where = ["."]
include = ["atlasimagecomposer*"]
[tool.setuptools.package-data]
"*" = ["py.typed"]
dependencies = [
"pillow~=10.4.0",
"requests~=2.32.3",
"tqdm~=4.66.5",
]
[project.optional-dependencies]
lint = [
@ -38,6 +28,20 @@ typing = [
"types-tqdm~=4.66.0",
"types-requests~=2.32.0",
]
testing = [
"pytest~=8.3.3"
]
[project.scripts]
skyeweave = "skyeweave.cli:run"
[tool.setuptools.packages.find]
where = ["."]
include = ["skyeweave*"]
exclude = ["tests*"]
[tool.setuptools.package-data]
"*" = ["py.typed"]
[tool.pylint."MAIN"]
disable = [
@ -45,12 +49,22 @@ disable = [
"missing-module-docstring",
"missing-function-docstring",
"missing-class-docstring",
"logging-fstring-interpolation",
]
ignore-paths="test/*"
[tool.mypy]
python_version = "3.11"
warn_return_any = true
warn_unused_configs = true
exclude = [ "test" ]
[tool.pytest.ini_options]
minversion = "8.0"
addopts = "-rA -v"
testpaths = [
"tests",
]
[build-system]
requires = ["setuptools >= 61.0"]

5
skyeweave/__init__.py Normal file
View file

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

View file

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

96
skyeweave/cli/cli.py Normal file
View file

@ -0,0 +1,96 @@
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")
weaver = SkyeWeave(input_id, args.filter)
weaver.download()
weaver.compose()

View file

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

View file

@ -0,0 +1,22 @@
# pylint: disable=too-few-public-methods
import os
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:
_root = pathlib.Path(__file__).parents[1]
IMAGES = _root / ".temp"
OUTPUT = pathlib.Path.cwd() / "output"
class ExpressionDefaults:
FACESIZE = 256
SHEETSIZE = 1024
class AtlasAPIConfig:
REGION = "JP"
TIMEOUT = 10
MST_SVT_JSON = "https://git.atlasacademy.io/atlasacademy/fgo-game-data/raw/branch/JP/master/mstSvtScript.json"

View file

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

126
skyeweave/service/atlas.py Normal file
View file

@ -0,0 +1,126 @@
import logging
import pathlib
from typing import Annotated, List, NotRequired, Tuple, TypedDict
import requests
from ..config import AtlasAPIConfig, PathConfig, ExpressionDefaults, LoggingConfig
LOGGER = logging.getLogger(LoggingConfig.NAME)
class SpritesheetData(TypedDict):
facesize: Tuple[int, int]
position: Tuple[int, int]
class ExtendData(TypedDict):
faceSizeRect: NotRequired[Annotated[List[int], 2]]
faceSize: NotRequired[int]
def fetch_config(chara_id: int) -> SpritesheetData:
url = f"https://api.atlasacademy.io/raw/JP/svtScript?charaId={chara_id}"
LOGGER.debug(f"Loading data for {url}")
response = requests.get(url, timeout=AtlasAPIConfig.TIMEOUT)
LOGGER.debug(f"{response.status_code} - {response.text}")
if not response.ok:
raise ValueError()
resp_data = response.json()[0]
extend_data: ExtendData = resp_data["extendData"]
if "faceSizeRect" in extend_data:
facesize: Tuple[int, int] = tuple(extend_data["faceSizeRect"]) # type: ignore
else:
facesize = tuple(2 * [ extend_data.get("faceSize", ExpressionDefaults.FACESIZE) ]) # type: ignore
position: tuple[int, int] = (resp_data["faceX"], resp_data["faceY"])
returndata: SpritesheetData = {
"facesize": facesize,
"position": position
}
LOGGER.debug(returndata)
return returndata
def fetch_mstsvtjson():
url = AtlasAPIConfig.MST_SVT_JSON
filelocation = PathConfig.IMAGES / "mstsvt.json"
if filelocation.exists():
LOGGER.info("Found cached asset for mstsvt.json")
return
LOGGER.debug(f"Loading data for {url}")
with open(filelocation, 'wb') as handle:
response = requests.get(url, stream=True, timeout=AtlasAPIConfig.TIMEOUT)
status = response.status_code
LOGGER.debug(f"{response.status_code} - {response.text}")
if status != 200:
raise ValueError("Could not fetch mstsvnt.json from atlas - please check your network connection")
for block in response.iter_content(1024):
if not block:
break
handle.write(block)
def fetch_expression_sheets(tempfolder: pathlib.Path, imageid: int):
atlasurl_base = f"https://static.atlasacademy.io/{AtlasAPIConfig.REGION}/CharaFigure/{imageid}"
savefolder = tempfolder / str(imageid)
savefolder.mkdir(exist_ok=True, parents=True)
idx, status = 0, 200
while status == 200:
filelocation = savefolder / f"{idx}.png"
postfix = ""
if idx == 1:
postfix = "f"
elif idx > 1:
postfix = f"f{idx}"
if filelocation.exists():
LOGGER.info(f"Found cached asset for {imageid}{postfix}.png")
idx += 1
continue
filename = f"{imageid}{postfix}.png"
atlasurl = f"{atlasurl_base}/{filename}"
LOGGER.debug(f"Loading data for {atlasurl}")
with open(filelocation, 'wb') as handle:
response = requests.get(atlasurl, stream=True, timeout=AtlasAPIConfig.TIMEOUT)
status = response.status_code
LOGGER.debug(f"{response.status_code} - {response.text}")
if status != 200:
continue
for block in response.iter_content(1024):
if not block:
break
handle.write(block)
LOGGER.info(f"Finished downloading {filename}")
idx += 1
p = savefolder / f"{idx}.png"
p.unlink(missing_ok=True)
return savefolder
def fetch_data(servantid: int) -> List[int]:
atlasurl = f"https://api.atlasacademy.io/nice/{AtlasAPIConfig.REGION}/servant/{servantid}?lore=false&lang=en"
LOGGER.debug(f"Loading data for {atlasurl}")
response = requests.get(atlasurl, timeout=AtlasAPIConfig.TIMEOUT)
LOGGER.debug(f"{response.status_code}")
if not response.ok:
LOGGER.debug(f"{response.status_code} - {response.text}")
raise ValueError(f"{response.status_code} - {response.text}")
responsedata = response.json()
svtname = responsedata["name"]
charascripts: List[dict[str, int]] = responsedata["charaScripts"]
chara_ids: List[int] = [chara["id"] for chara in charascripts]
LOGGER.debug(chara_ids)
LOGGER.info(f"{svtname} ({servantid}) - {len(chara_ids)} charaIds")
return chara_ids

View file

@ -0,0 +1,136 @@
import logging
import pathlib
from typing import Dict, List, Optional, TypedDict
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 CharaInfos(TypedDict):
folder: pathlib.Path
config: SpritesheetData
class SkyeWeave:
output_folder: pathlib.Path
image_folder: pathlib.Path
chara_ids: List[int]
chara_infos: Dict[str, CharaInfos]
def __init__(self, input_id: int, filters: Optional[List[int]] = None, output: Optional[pathlib.Path] = None, assets: Optional[pathlib.Path] = None):
_output_folder = output or PathConfig.OUTPUT
_image_folder = assets or PathConfig.IMAGES
if input_id < 10000:
_chara_ids = fetch_data(input_id)
self.output_folder = _output_folder / str(input_id)
self.image_folder = _image_folder / str(input_id)
else:
LOGGER.info(f"Processing manually uploaded charaId {input_id}")
_chara_ids = [input_id]
self.output_folder = _output_folder / "manual"
self.image_folder = _image_folder / "manual"
self.chara_ids = [ v for v in _chara_ids if v in filters ] if filters else _chara_ids
LOGGER.debug(self.chara_ids)
self.chara_infos = {}
self.output_folder.mkdir(parents=True, exist_ok=True)
self.image_folder.mkdir(parents=True, exist_ok=True)
def download(self):
for char_id in self.chara_ids:
expfolder = fetch_expression_sheets(self.image_folder, char_id)
config = fetch_config(char_id)
self.chara_infos.update({char_id : { "folder": expfolder, "config": config}})
LOGGER.debug(self.chara_infos)
def compose(self):
for key, val in self.chara_infos.items():
LOGGER.info(f"Processing sheet for {key}")
self.process_sprite(val["folder"], val["config"], self.output_folder)
LOGGER.info(f"Files have been saved at: {self.output_folder.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

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

View file

@ -0,0 +1,14 @@
import pathlib
def rmdir(directory: pathlib.Path):
"""
Recursively deletes all files and folders in a given directory
From: https://stackoverflow.com/a/49782093 (thanks mitch)
"""
for item in directory.iterdir():
if item.is_dir():
rmdir(item)
else:
item.unlink()
directory.rmdir()

27
skyeweave/utils/logger.py Normal file
View file

@ -0,0 +1,27 @@
# 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()

15
tests/conftest.py Normal file
View file

@ -0,0 +1,15 @@
import pathlib
import shutil
import pytest
@pytest.fixture(scope="function")
def path_images():
path = pathlib.Path(__file__).parent / "test_path_images"
yield path
shutil.rmtree(path)
@pytest.fixture(scope="function")
def path_output():
path = pathlib.Path(__file__).parent / "test_path_output"
yield path
shutil.rmtree(path)

36
tests/test_01_class.py Normal file
View file

@ -0,0 +1,36 @@
from pathlib import Path
from skyeweave import SkyeWeave
def test_servantid_create(path_images: Path, path_output: Path):
test_id = 70
test_weaver = SkyeWeave(
test_id,
output=path_output,
assets=path_images
)
assert test_weaver.output_folder == path_output / str(test_id)
assert path_output.exists()
assert (path_output / str(test_id)).exists()
assert test_weaver.image_folder == path_images / str(test_id)
assert path_images.exists()
assert (path_images / str(test_id)).exists()
def test_charaid_create(path_images: Path, path_output: Path):
test_id = 3013002
test_weaver = SkyeWeave(
test_id,
output=path_output,
assets=path_images
)
assert test_weaver.output_folder == path_output / "manual"
assert path_output.exists()
assert (path_output / "manual").exists()
assert test_weaver.image_folder == path_images / "manual"
assert path_images.exists()
assert (path_images / "manual").exists()

66
tests/test_02_download.py Normal file
View file

@ -0,0 +1,66 @@
from pathlib import Path
from skyeweave import SkyeWeave
def test_servantid_download(path_images: Path, path_output: Path):
test_id = 70
test_weaver = SkyeWeave(
test_id,
output=path_output,
assets=path_images
)
test_weaver.download()
expected_path = path_images / str(test_id)
expected_dirs = [ "3013000", "3013001", "3013002", "3013300", "1098204200", "1098264100", "1098290800" ]
dirs = [f for f in expected_path.iterdir() if f.is_dir()]
assert set([d.name for d in dirs]) == set(expected_dirs)
for d in dirs:
expected_files = ["0.png", "1.png"]
files = [f.name for f in d.iterdir() if f.is_file()]
assert set(files) == set(expected_files)
def test_servantid_download_filter(path_images: Path, path_output: Path):
test_id = 70
test_weaver = SkyeWeave(
test_id,
filters=[ 3013000, 3013001, 1098290800],
output=path_output,
assets=path_images
)
test_weaver.download()
expected_path = path_images / str(test_id)
expected_dirs = [ "3013000", "3013001", "1098290800" ]
dirs =[f for f in expected_path.iterdir() if f.is_dir()]
assert set([d.name for d in dirs]) == set(expected_dirs)
for d in dirs:
expected_files = ["0.png", "1.png"]
files = [f.name for f in d.iterdir() if f.is_file()]
assert set(files) == set(expected_files)
def test_charaid_download(path_images: Path, path_output: Path):
test_id = 3013000
test_weaver = SkyeWeave(
test_id,
output=path_output,
assets=path_images
)
test_weaver.download()
expected_path = path_images / "manual"
expected_dirs = [ "3013000" ]
dirs = [f for f in expected_path.iterdir() if f.is_dir()]
assert set([d.name for d in dirs]) == set(expected_dirs)
for d in dirs:
expected_files = ["0.png", "1.png"]
files = [f.name for f in d.iterdir() if f.is_file()]
assert set(files) == set(expected_files)