Compare commits
No commits in common. "main" and "0.1.0-a.1" have entirely different histories.
23 changed files with 206 additions and 521 deletions
|
@ -8,13 +8,13 @@ on:
|
||||||
jobs:
|
jobs:
|
||||||
lint-and-typing:
|
lint-and-typing:
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
container: forgejo.neshweb.net/ci-docker-images/python-neshweb:3.11
|
container: nikolaik/python-nodejs:python3.11-nodejs21
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout source code
|
- name: Checkout source code
|
||||||
uses: https://code.forgejo.org/actions/checkout@v3
|
uses: https://code.forgejo.org/actions/checkout@v3
|
||||||
- name: Install packages
|
- name: Install packages
|
||||||
run: |
|
run: |
|
||||||
pip install -e .[lint,typing] -q --disable-pip-version-check -q --no-cache-dir -q
|
pip install -e .[lint,typing] -q --disable-pip-version-check -q
|
||||||
python -m pip list --format=columns --disable-pip-version-check
|
python -m pip list --format=columns --disable-pip-version-check
|
||||||
- name: Run pylint
|
- name: Run pylint
|
||||||
run: |
|
run: |
|
||||||
|
@ -28,12 +28,12 @@ jobs:
|
||||||
build-artifacts:
|
build-artifacts:
|
||||||
needs: ["lint-and-typing"]
|
needs: ["lint-and-typing"]
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
container: forgejo.neshweb.net/ci-docker-images/python-neshweb:3.11
|
container: nikolaik/python-nodejs:python3.11-nodejs21
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout source code
|
- name: Checkout source code
|
||||||
uses: https://code.forgejo.org/actions/checkout@v3
|
uses: https://code.forgejo.org/actions/checkout@v3
|
||||||
- name: Install packages
|
- name: Install packages
|
||||||
run: pip install build --no-cache-dir -q
|
run: pip install build
|
||||||
- name: Build package
|
- name: Build package
|
||||||
run: python -m build
|
run: python -m build
|
||||||
- name: Save build artifacts
|
- name: Save build artifacts
|
||||||
|
@ -45,7 +45,7 @@ jobs:
|
||||||
publish-artifacts:
|
publish-artifacts:
|
||||||
needs: ["build-artifacts"]
|
needs: ["build-artifacts"]
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
container: forgejo.neshweb.net/ci-docker-images/python-neshweb:3.11
|
container: nikolaik/python-nodejs:python3.11-nodejs21
|
||||||
steps:
|
steps:
|
||||||
- name: Downloading static site artifacts
|
- name: Downloading static site artifacts
|
||||||
uses: actions/download-artifact@v3
|
uses: actions/download-artifact@v3
|
||||||
|
@ -53,7 +53,7 @@ jobs:
|
||||||
name: packages
|
name: packages
|
||||||
path: dist
|
path: dist
|
||||||
- name: Install Dependencies
|
- name: Install Dependencies
|
||||||
run: pip install twine --no-cache-dir -q
|
run: pip install twine
|
||||||
- name: Upload package to registry
|
- 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/*
|
run: python -m twine upload --repository-url ${{ secrets.REPOSITORY_URL }} -u ${{ secrets.TWINE_DEPLOY_USER }} -p ${{ secrets.TWINE_DEPLOY_PASSWORD }} dist/*
|
||||||
|
|
||||||
|
|
|
@ -5,13 +5,13 @@ on:
|
||||||
jobs:
|
jobs:
|
||||||
pylint:
|
pylint:
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
container: forgejo.neshweb.net/ci-docker-images/python-neshweb:3.11
|
container: nikolaik/python-nodejs:python3.11-nodejs21
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout source code
|
- name: Checkout source code
|
||||||
uses: https://code.forgejo.org/actions/checkout@v3
|
uses: https://code.forgejo.org/actions/checkout@v3
|
||||||
- name: Install packages
|
- name: Install packages
|
||||||
run: |
|
run: |
|
||||||
pip install -e .[lint] --disable-pip-version-check --no-cache-dir -q
|
pip install -e .[lint] -q --disable-pip-version-check -q
|
||||||
python -m pip list --format=columns --disable-pip-version-check
|
python -m pip list --format=columns --disable-pip-version-check
|
||||||
- name: Run pylint
|
- name: Run pylint
|
||||||
run: |
|
run: |
|
||||||
|
@ -20,13 +20,13 @@ jobs:
|
||||||
|
|
||||||
mypy:
|
mypy:
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
container: forgejo.neshweb.net/ci-docker-images/python-neshweb:3.11
|
container: nikolaik/python-nodejs:python3.11-nodejs21
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout source code
|
- name: Checkout source code
|
||||||
uses: https://code.forgejo.org/actions/checkout@v3
|
uses: https://code.forgejo.org/actions/checkout@v3
|
||||||
- name: Install packages
|
- name: Install packages
|
||||||
run: |
|
run: |
|
||||||
pip install -e .[typing] --disable-pip-version-check --no-cache-dir -q
|
pip install -e .[typing] -q --disable-pip-version-check -q
|
||||||
python -m pip list --format=columns --disable-pip-version-check
|
python -m pip list --format=columns --disable-pip-version-check
|
||||||
- name: Run mypy
|
- name: Run mypy
|
||||||
run: |
|
run: |
|
||||||
|
|
3
.gitignore
vendored
3
.gitignore
vendored
|
@ -1,10 +1,7 @@
|
||||||
# Dev environment
|
# Dev environment
|
||||||
*venv/
|
*venv/
|
||||||
.vscode/
|
.vscode/
|
||||||
*out*/
|
|
||||||
assets/
|
|
||||||
|
|
||||||
# python files
|
# python files
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.egg-info/
|
*.egg-info/
|
||||||
.mypy_cache/
|
|
||||||
|
|
67
README.md
67
README.md
|
@ -1,67 +0,0 @@
|
||||||
# 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
|
|
||||||
```
|
|
2
atlasimagecomposer/__init__.py
Normal file
2
atlasimagecomposer/__init__.py
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
import importlib.metadata
|
||||||
|
__version__ = importlib.metadata.version(__package__ or "atlasimagecomposer")
|
3
atlasimagecomposer/__main__.py
Normal file
3
atlasimagecomposer/__main__.py
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
from .compose import run
|
||||||
|
|
||||||
|
run()
|
42
atlasimagecomposer/atlas.py
Normal file
42
atlasimagecomposer/atlas.py
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
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"] }
|
41
atlasimagecomposer/cli.py
Normal file
41
atlasimagecomposer/cli.py
Normal file
|
@ -0,0 +1,41 @@
|
||||||
|
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
|
88
atlasimagecomposer/compose.py
Normal file
88
atlasimagecomposer/compose.py
Normal file
|
@ -0,0 +1,88 @@
|
||||||
|
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)
|
|
@ -1,23 +1,33 @@
|
||||||
[project]
|
[project]
|
||||||
name = "skyeweave"
|
name = "atlasimagecomposer"
|
||||||
version = "1.0.0-c.3"
|
version = "0.1.0-a.1"
|
||||||
|
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 = "Tool to manage requests for supports"
|
||||||
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.compose:run"
|
||||||
"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"]
|
||||||
|
|
|
@ -1,5 +0,0 @@
|
||||||
import importlib.metadata
|
|
||||||
|
|
||||||
__version__ = importlib.metadata.version(__package__ or "skyeweave")
|
|
||||||
|
|
||||||
from .service import SkyeWeave
|
|
|
@ -1 +0,0 @@
|
||||||
from .cli import run
|
|
|
@ -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)
|
|
|
@ -1 +0,0 @@
|
||||||
from .config import AtlasAPIConfig, ExpressionDefaults, LoggingConfig, PathConfig
|
|
|
@ -1,22 +0,0 @@
|
||||||
# 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"
|
|
|
@ -1 +0,0 @@
|
||||||
from .compose import SkyeWeave
|
|
|
@ -1,126 +0,0 @@
|
||||||
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: str) -> 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: str):
|
|
||||||
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[str]:
|
|
||||||
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, str]] = responsedata["charaScripts"]
|
|
||||||
chara_ids: List[str] = [chara["id"] for chara in charascripts]
|
|
||||||
|
|
||||||
LOGGER.debug(chara_ids)
|
|
||||||
LOGGER.info(f"{svtname} ({servantid}) - {len(chara_ids)} charaIds")
|
|
||||||
return chara_ids
|
|
|
@ -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
|
|
|
@ -1,2 +0,0 @@
|
||||||
from .filesystem import rmdir
|
|
||||||
from .logger import LOGGER, disable_tqdm
|
|
|
@ -1,14 +0,0 @@
|
||||||
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()
|
|
|
@ -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()
|
|
Loading…
Reference in a new issue