Compare commits

...

10 commits

Author SHA1 Message Date
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
23 changed files with 377 additions and 262 deletions

6
.gitignore vendored
View file

@ -1,8 +1,10 @@
# 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,27 +1,67 @@
# atlasimagecomposer # skyeweave
A small CLI toolkit that allows you to easily download and compose any FGO expression sheets Easily generate any FGO expression sheets from an id
## Installations > Developed by [Firq](https://firq.dev/) and powered by the [AtlasAcademy API](https://atlasacademy.io/)
Install it using `pip` - You need to specify my extra index for it to be found ## Installation
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/ atlasimagecomposer 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 ## Usage
Use it via the CLI command `atlasimagecomposer` ### 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 ```plain
usage: atlasimagecomposer [-h] [--version] [--output OUTPUT] [--filter FILTER [FILTER ...]] [--timeout TIMEOUT] [--clear-cache] skyeweave --output out --id 70
```
options:
-h, --help show this help message and exit This would generate the expressions for Scathach (Servant Id 70) in the folder out, using subfolders to better separate the outputs of multiple runs.
--version show program's version number and exit
--output OUTPUT Set the output location. This can be an absolute or relative path ### python scripts [EXPERIMENTAL]
--filter FILTER [FILTER ...] Specify one or more spritesheet ids that will be fetched
--timeout TIMEOUT Set the timeout for all requests towards AtlasAcademy, default is 10s `skyeweave` can also be used in other Python scripts.
--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

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

View file

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

View file

@ -1,121 +0,0 @@
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

@ -1,78 +0,0 @@
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

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

View file

@ -1,33 +1,23 @@
[project] [project]
name = "atlasimagecomposer" name = "skyeweave"
version = "0.1.0-c.2" version = "1.0.0-c.3"
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 = "Package that enables people to quickly download and generate all potential spritesheet expressions with a single command" description = "Easily generate any FGO expression sheets from an id"
classifiers = [ classifiers = [
"Development Status :: 3 - Alpha", "Development Status :: 5 - Production/Stable",
"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",
] ]
[project.scripts] dependencies = [
atlasimagecomposer = "atlasimagecomposer.cli.cli:run_cli" "pillow~=10.4.0",
"requests~=2.32.3",
[tool.setuptools.packages.find] "tqdm~=4.66.5",
where = ["."] ]
include = ["atlasimagecomposer*"]
[tool.setuptools.package-data]
"*" = ["py.typed"]
[project.optional-dependencies] [project.optional-dependencies]
lint = [ lint = [
@ -39,18 +29,31 @@ 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"]

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

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

@ -0,0 +1,94 @@
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

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

View file

@ -1,17 +1,22 @@
# 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 Paths: class PathConfig:
_root = pathlib.Path(__file__).parents[1] _root = pathlib.Path(__file__).parents[1]
IMAGES = _root / ".temp" IMAGES = _root / ".temp"
OUTPUT = _root / ".out" OUTPUT = pathlib.Path.cwd() / "output"
class ExpressionDefaults: class ExpressionDefaults:
FACESIZE = 256 FACESIZE = 256
SHEETSIZE = 1024 SHEETSIZE = 1024
class AtlasDefaults: class AtlasAPIConfig:
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

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

View file

@ -1,7 +1,11 @@
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 AtlasDefaults, Paths, ExpressionDefaults from ..config import AtlasAPIConfig, PathConfig, ExpressionDefaults, LoggingConfig
LOGGER = logging.getLogger(LoggingConfig.NAME)
class SpritesheetData(TypedDict): class SpritesheetData(TypedDict):
facesize: Tuple[int, int] facesize: Tuple[int, int]
@ -14,9 +18,11 @@ 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}"
response = requests.get(url, timeout=AtlasDefaults.TIMEOUT) 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: if not response.ok:
raise ValueError(f"{response.status_code} - {response.text}") raise ValueError()
resp_data = response.json()[0] resp_data = response.json()[0]
extend_data: ExtendData = resp_data["extendData"] extend_data: ExtendData = resp_data["extendData"]
@ -33,19 +39,22 @@ 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 = AtlasDefaults.MST_SVT_JSON url = AtlasAPIConfig.MST_SVT_JSON
filelocation = Paths.IMAGES / "mstsvt.json" filelocation = PathConfig.IMAGES / "mstsvt.json"
if filelocation.exists(): if filelocation.exists():
print("Found cached asset for mstsvt.json") LOGGER.info("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=AtlasDefaults.TIMEOUT) response = requests.get(url, stream=True, timeout=AtlasAPIConfig.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):
@ -53,12 +62,11 @@ def fetch_mstsvtjson():
break break
handle.write(block) handle.write(block)
def fetch_expression_sheets(basefolder: str, imageid: str): def fetch_expression_sheets(tempfolder: pathlib.Path, imageid: str):
atlasurl_base = f"https://static.atlasacademy.io/{AtlasDefaults.REGION}/CharaFigure/{imageid}" atlasurl_base = f"https://static.atlasacademy.io/{AtlasAPIConfig.REGION}/CharaFigure/{imageid}"
savefolder = Paths.IMAGES / basefolder / str(imageid) savefolder = tempfolder / str(imageid)
if not savefolder.is_dir(): savefolder.mkdir(exist_ok=True, parents=True)
savefolder.mkdir(exist_ok=True, parents=True)
idx, status = 0, 200 idx, status = 0, 200
@ -72,23 +80,25 @@ def fetch_expression_sheets(basefolder: str, imageid: str):
postfix = f"f{idx}" postfix = f"f{idx}"
if filelocation.exists(): if filelocation.exists():
print(f"Found cached asset for {imageid}{postfix}.png") LOGGER.info(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=AtlasDefaults.TIMEOUT) response = requests.get(atlasurl, stream=True, timeout=AtlasAPIConfig.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)
print(f"Finished downloading {filename}") LOGGER.info(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)
@ -97,10 +107,13 @@ def fetch_expression_sheets(basefolder: str, imageid: str):
def fetch_data(servantid: int) -> List[str]: def fetch_data(servantid: int) -> List[str]:
atlasurl = f"https://api.atlasacademy.io/nice/{AtlasDefaults.REGION}/servant/{servantid}?lore=false&lang=en" atlasurl = f"https://api.atlasacademy.io/nice/{AtlasAPIConfig.REGION}/servant/{servantid}?lore=false&lang=en"
response = requests.get(atlasurl, timeout=AtlasDefaults.TIMEOUT) LOGGER.debug(f"Loading data for {atlasurl}")
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()
@ -108,5 +121,6 @@ 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]
print(f"{svtname} ({servantid}) - {len(chara_ids)} charaIds") LOGGER.debug(chara_ids)
LOGGER.info(f"{svtname} ({servantid}) - {len(chara_ids)} charaIds")
return chara_ids return chara_ids

View file

@ -0,0 +1,125 @@
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

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

View file

@ -1,6 +1,5 @@
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

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()