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

This commit is contained in:
Firq 2024-10-05 14:01:50 +02:00
parent 174dd557e5
commit fbda98805f
Signed by: Firq
GPG key ID: 3ACC61C8CEC83C20
10 changed files with 235 additions and 117 deletions

1
.gitignore vendored
View file

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

27
README.md Normal file
View file

@ -0,0 +1,27 @@
# atlasimagecomposer
A small CLI toolkit that allows you to easily download and compose any FGO expression sheets
## Installations
Install it using `pip` - You need to specify my extra index for it to be found
```shell
pip install --extra-index-url https://forgejo.neshweb.net/api/packages/Firq/pypi/simple/ atlasimagecomposer
```
## Usage
Use it via the CLI command `atlasimagecomposer`
```plain
usage: atlasimagecomposer [-h] [--version] [--output OUTPUT] [--filter FILTER [FILTER ...]] [--timeout TIMEOUT] [--clear-cache]
options:
-h, --help show this help message and exit
--version show program's version number and exit
--output OUTPUT Set the output location. This can be an absolute or relative path
--filter FILTER [FILTER ...] Specify one or more spritesheet ids that will be fetched
--timeout TIMEOUT Set the timeout for all requests towards AtlasAcademy, default is 10s
--clear-cache Clear cached assets before downloading files for a servant
```

View file

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

View file

@ -1,47 +1,88 @@
from typing import List, Tuple, TypedDict
import requests import requests
from ..config import Paths, ExpressionDefaults, AtlasDefaults from ..config import AtlasDefaults, ExpressionDefaults, Paths
def fetch_image(servantid: str, imageid: str): class SpritesheetData(TypedDict):
atlasurl = f"https://static.atlasacademy.io/{AtlasDefaults.REGION}/CharaFigure/{imageid}/{imageid}_merged.png" facesize: int
position: Tuple[int, int]
savefolder = Paths.IMAGES / servantid class ReturnData(TypedDict):
id: str
faceX: int
faceY: int
extendData: dict[str, int]
def fetch_expression_sheets(servantid: int, imageid: str):
atlasurl_base = f"https://static.atlasacademy.io/{AtlasDefaults.REGION}/CharaFigure/{imageid}"
savefolder = Paths.IMAGES / str(servantid) / imageid
if not savefolder.is_dir(): if not savefolder.is_dir():
savefolder.mkdir() savefolder.mkdir(exist_ok=True, parents=True)
filelocation = savefolder / f"{imageid}.png"
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():
print(f"Found cached asset for {imageid}{postfix}.png")
idx += 1
continue
filename = f"{imageid}{postfix}.png"
atlasurl = f"{atlasurl_base}/{filename}"
with open(filelocation, 'wb') as handle: with open(filelocation, 'wb') as handle:
response = requests.get(atlasurl, stream=True, timeout=10) response = requests.get(atlasurl, stream=True, timeout=AtlasDefaults.TIMEOUT)
if not response.ok: status = response.status_code
print(response) if status != 200:
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}")
idx += 1
p = savefolder / f"{idx}.png"
p.unlink(missing_ok=True)
def fetch_info(servantid: str):
def fetch_info(servantid: int):
atlasurl = f"https://api.atlasacademy.io/basic/{AtlasDefaults.REGION}/servant/{servantid}?lang=en" atlasurl = f"https://api.atlasacademy.io/basic/{AtlasDefaults.REGION}/servant/{servantid}?lang=en"
response = requests.get(atlasurl, timeout=10) response = requests.get(atlasurl, timeout=AtlasDefaults.TIMEOUT)
if not response.ok: if not response.ok:
print(response) print(response)
data = response.json() data = response.json()
print(f"Fetching data and sprites for {data['name']} (ID: {servantid})") print(f"Fetching data and sprites for {data['name']} (ID: {servantid})")
def fetch_data(servantid: str): def fetch_data(servantid: int) -> dict[str, SpritesheetData]:
fetch_info(servantid) fetch_info(servantid)
atlasurl = f"https://api.atlasacademy.io/raw/{AtlasDefaults.REGION}/servant/{servantid}?lore=false&expand=true&lang=en" atlasurl = f"https://api.atlasacademy.io/raw/{AtlasDefaults.REGION}/servant/{servantid}?lore=false&expand=true&lang=en"
response = requests.get(atlasurl, timeout=10) response = requests.get(atlasurl, timeout=AtlasDefaults.TIMEOUT)
if not response.ok: if not response.ok:
print(response) print(response)
data = response.json() data: List[ReturnData] = response.json()["mstSvtScript"]
return {
spritesheet_data: dict[str, SpritesheetData] = {
str(spritesheet["id"]): { str(spritesheet["id"]): {
"faceSize": spritesheet["extendData"].get("faceSize", ExpressionDefaults.faceSize), "facesize": spritesheet["extendData"].get("faceSize", ExpressionDefaults.FACESIZE),
"position": (spritesheet["faceX"], spritesheet["faceY"]) "position": (spritesheet["faceX"], spritesheet["faceY"])
} for spritesheet in data["mstSvtScript"] } for spritesheet in data
} }
print(f"Found a total of {len(spritesheet_data)} spritesheets")
return spritesheet_data

View file

@ -1,87 +1,112 @@
import math
import pathlib import pathlib
import sys from typing import List, Optional
from typing import Tuple
import numpy as np import numpy as np
from PIL import Image from PIL import Image
from tqdm.contrib import itertools as tqdm_itertools from tqdm.contrib import itertools as tqdm_itertools
from .atlas import fetch_data, fetch_image from ..config import Paths
from ..config import ExpressionDefaults, Paths from .atlas import SpritesheetData, fetch_data, fetch_expression_sheets
def compose():
def compose(servantid: int, filters: Optional[List[str]] = None):
Paths.IMAGES.mkdir(exist_ok=True) Paths.IMAGES.mkdir(exist_ok=True)
Paths.OUTPUT.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) sprites = fetch_data(servantid)
if filters is not None:
sprites = { key: value for key, value in sprites.items() if key in filters }
for sprite in sprites: for sprite in sprites:
fetch_image(servantid, sprite) fetch_expression_sheets(servantid, sprite)
path = Paths.IMAGES / str(servantid)
savefolder = Paths.OUTPUT / str(servantid)
if not savefolder.is_dir():
savefolder.mkdir()
path = Paths.IMAGES / servantid
for f in path.iterdir(): for f in path.iterdir():
process_sprite(f, sprites[f.stem]["position"], sprites[f.stem]["faceSize"], servantid) if filters is not None and str(f.stem) not in filters:
print(f"Files have been saved at: {(Paths.OUTPUT / servantid).absolute()}") continue
process_sprite(f, servantid, sprites[f.stem])
print(f"Files have been saved at: {(Paths.OUTPUT / str(servantid)).absolute()}")
def override_expressions(expressions: Image.Image, facesize: int) -> Tuple[Image.Image, int]: def calculate_counts(width: int, height: int, facesize: int):
exp_w, exp_h = expressions.size return height // facesize, width // facesize
composites, offset, floating_offset = [], facesize // 5, 0
per_composite = (ExpressionDefaults.faceSize * 4) // facesize
# Why ..........
composite_count = math.ceil((exp_h - 1280) / (offset + facesize * per_composite)) + 1
# Remove top offset
expressions = expressions.crop((0, 256, exp_w, exp_h))
for count in range(0, composite_count):
# width_topleft, height_topleft, width_botright, height_botright
composites.append((0, 3 * count * facesize + floating_offset, exp_w, (3 * count + 3) * facesize + floating_offset - 1 ))
floating_offset += offset
# Create new expression image, then add all compositions to it
overrides = Image.new("RGBA", (exp_w, facesize * composite_count * 3), (255, 0, 0, 0))
for idx, c in enumerate(composites):
crop = expressions.crop(c)
pos = (0, idx * 3 * facesize)
overrides.paste(crop, pos, crop)
# Override expressions sheet
return overrides.copy(), per_composite
# pylint: disable=too-many-locals def process_sprite(images_folder: pathlib.Path, servantid: int, configdata: SpritesheetData):
def process_sprite(filepath: pathlib.Path, position: tuple, facesize: int, servantid: str): main = Image.open(images_folder / "0.png")
im = Image.open(filepath)
width, height = im.size
main_sprite = im.crop((0, 0, width, 768)) width, _ = main.size
save_sprite(main_sprite, servantid, filepath.stem) main_sprite = main.crop((0, 0, width, 756))
expressions = im.crop((0, 768, width, height))
expressionwidth, expressionheight = expressions.size save_sprite(main_sprite, servantid, f"{images_folder.stem}")
rows = expressionheight // facesize
expressions_p_row = expressionwidth // facesize
exp_per_row = 4 for i in images_folder.iterdir():
initial_row, index = 0, int(i.stem)
expressions = Image.open(i)
if facesize != ExpressionDefaults.faceSize: rowcount, colcount = calculate_counts(*expressions.size, configdata["facesize"])
expressions, exp_per_row = override_expressions(expressions, facesize)
for x, y in tqdm_itertools.product(range(0, rows), range(0, expressions_p_row, 1), ascii="-="): if i.name == "0.png":
img = generate_sprite(main_sprite, expressions, x, y, position, facesize) initial_row = 3
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: if img is not None:
save_sprite(img, servantid, filepath.stem, (x, y, exp_per_row)) save_sprite(img, servantid, 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,
row * facesize,
(col + 1) * facesize - 1,
(row + 1) * facesize - 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, folder_id: int, name: str, info: tuple | None = None):
savefolder = Paths.OUTPUT / str(folder_id) / 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 < 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): def is_empty(img: Image.Image):
data = np.asarray(img.crop((96, 96, 160, 160)).convert('LA')) data = np.asarray(img.crop((96, 96, 160, 160)).convert('LA'))
@ -90,29 +115,3 @@ def is_empty(img: Image.Image):
if count_unique.size < 10: if count_unique.size < 10:
return True return True
return False return False
# pylint: disable=too-many-arguments
def generate_sprite(sprite: Image.Image, expressions: Image.Image, row: int, col: int, position: tuple, facesize: int) -> Image.Image | None:
area = (
col * facesize,
row * facesize,
(col + 1) * facesize - 1,
(row + 1) * facesize - 1
)
expression = expressions.crop(area)
if is_empty(expression):
return None
composition = sprite.copy()
composition.paste(expression, position, expression)
return composition
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] * rowcol[2] + 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,10 +1,12 @@
import argparse import argparse
import pathlib import pathlib
import sys import sys
from typing import List
from .. import __version__ from .. import __version__
from ..backend import compose from ..backend import compose
from ..config import Paths from ..config import Paths, AtlasDefaults
from ..utils.filesystem import rmdir
# pylint: disable=too-few-public-methods # pylint: disable=too-few-public-methods
@ -13,6 +15,9 @@ class Arguments(argparse.Namespace):
Default Arguments when calling the CLI Default Arguments when calling the CLI
""" """
output: str output: str
cacheclear: bool
filter: List[str]
timeout: int
def parse_arguments(): def parse_arguments():
""" """
@ -21,10 +26,15 @@ def parse_arguments():
""" """
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
prog="atlasimagecomposer", prog="atlasimagecomposer",
description="CLI tool to automatically generate expression sheets for servants",) 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("--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__}") 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 = Arguments()
args, extra_args = parser.parse_known_args(sys.argv[1:], namespace=args) args, extra_args = parser.parse_known_args(sys.argv[1:], namespace=args)
@ -36,10 +46,31 @@ def welcome():
print(" developed by Firq ") print(" developed by Firq ")
print("-------------------------------------------------") print("-------------------------------------------------")
def run(): def run_cli():
args, _ = parse_arguments() args, _ = parse_arguments()
if args.output: if args.output:
Paths.OUTPUT = pathlib.Path(args.output) Paths.OUTPUT = pathlib.Path(args.output)
if args.timeout and args.timeout >= 0:
AtlasDefaults.TIMEOUT = args.timeout
welcome() welcome()
compose()
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)
if args.cacheclear:
cachepath = Paths.IMAGES / str(servantid)
if cachepath.exists():
rmdir(cachepath)
print("Successfully cleared cached assets")
else:
print("No cache to clear was found, continuing")
compose(servantid, args.filter)

View file

@ -1,13 +1,16 @@
# pylint: disable=too-few-public-methods # pylint: disable=too-few-public-methods
import pathlib import pathlib
class Paths: class Paths:
_root = pathlib.Path(__file__).parents[1] _root = pathlib.Path(__file__).parents[1]
IMAGES = _root / ".temp" IMAGES = _root / ".temp"
OUTPUT = _root / ".out" OUTPUT = _root / ".out"
class ExpressionDefaults: class ExpressionDefaults:
faceSize = 256 FACESIZE = 256
SHEETSIZE = 1024
class AtlasDefaults: class AtlasDefaults:
REGION = "JP" REGION = "JP"
TIMEOUT = 10

View file

View file

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

View file

@ -1,6 +1,6 @@
[project] [project]
name = "atlasimagecomposer" name = "atlasimagecomposer"
version = "0.1.0-a.6" version = "0.1.0-c.1"
dependencies = [ dependencies = [
"numpy~=2.0.1", "numpy~=2.0.1",
"pillow~=10.4.0", "pillow~=10.4.0",
@ -10,7 +10,7 @@ dependencies = [
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 = "Tool to manage requests for supports" description = "Package that enables peopßle to quickly download and generate all potential spritesheet expressions with a single command"
classifiers = [ classifiers = [
"Development Status :: 3 - Alpha", "Development Status :: 3 - Alpha",
"Programming Language :: Python :: 3", "Programming Language :: Python :: 3",
@ -20,7 +20,7 @@ classifiers = [
] ]
[project.scripts] [project.scripts]
atlasimagecomposer = "atlasimagecomposer.cli.cli:run" atlasimagecomposer = "atlasimagecomposer.cli.cli:run_cli"
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
where = ["."] where = ["."]