From fbda98805f227d1f47ba81c6b2466f7ed2294a6a Mon Sep 17 00:00:00 2001 From: Firq Date: Sat, 5 Oct 2024 14:01:50 +0200 Subject: [PATCH] Release Candidate 1 for the people --- .gitignore | 1 + README.md | 27 ++++ atlasimagecomposer/__init__.py | 1 + atlasimagecomposer/backend/atlas.py | 85 ++++++++---- atlasimagecomposer/backend/compose.py | 171 ++++++++++++------------- atlasimagecomposer/cli/cli.py | 41 +++++- atlasimagecomposer/config/config.py | 5 +- atlasimagecomposer/utils/__init__.py | 0 atlasimagecomposer/utils/filesystem.py | 15 +++ pyproject.toml | 6 +- 10 files changed, 235 insertions(+), 117 deletions(-) create mode 100644 README.md create mode 100644 atlasimagecomposer/utils/__init__.py create mode 100644 atlasimagecomposer/utils/filesystem.py diff --git a/.gitignore b/.gitignore index ea936fa..6e7a7c1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # Dev environment *venv/ .vscode/ +.out/ # python files __pycache__/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..731d70c --- /dev/null +++ b/README.md @@ -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 +``` diff --git a/atlasimagecomposer/__init__.py b/atlasimagecomposer/__init__.py index 89bcc1f..cb9f729 100644 --- a/atlasimagecomposer/__init__.py +++ b/atlasimagecomposer/__init__.py @@ -1,2 +1,3 @@ import importlib.metadata + __version__ = importlib.metadata.version(__package__ or "atlasimagecomposer") diff --git a/atlasimagecomposer/backend/atlas.py b/atlasimagecomposer/backend/atlas.py index 3c7f89b..fcdc3f9 100644 --- a/atlasimagecomposer/backend/atlas.py +++ b/atlasimagecomposer/backend/atlas.py @@ -1,47 +1,88 @@ +from typing import List, Tuple, TypedDict + import requests -from ..config import Paths, ExpressionDefaults, AtlasDefaults +from ..config import AtlasDefaults, ExpressionDefaults, Paths -def fetch_image(servantid: str, imageid: str): - atlasurl = f"https://static.atlasacademy.io/{AtlasDefaults.REGION}/CharaFigure/{imageid}/{imageid}_merged.png" +class SpritesheetData(TypedDict): + 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(): - savefolder.mkdir() - filelocation = savefolder / f"{imageid}.png" + savefolder.mkdir(exist_ok=True, parents=True) - 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) + idx, status = 0, 200 -def fetch_info(servantid: str): + 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: + response = requests.get(atlasurl, stream=True, timeout=AtlasDefaults.TIMEOUT) + status = response.status_code + if status != 200: + continue + for block in response.iter_content(1024): + if not block: + break + handle.write(block) + print(f"Finished downloading {filename}") + idx += 1 + p = savefolder / f"{idx}.png" + p.unlink(missing_ok=True) + + +def fetch_info(servantid: int): 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: print(response) data = response.json() 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) 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: print(response) - data = response.json() - return { + data: List[ReturnData] = response.json()["mstSvtScript"] + + spritesheet_data: dict[str, SpritesheetData] = { str(spritesheet["id"]): { - "faceSize": spritesheet["extendData"].get("faceSize", ExpressionDefaults.faceSize), + "facesize": spritesheet["extendData"].get("faceSize", ExpressionDefaults.FACESIZE), "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 diff --git a/atlasimagecomposer/backend/compose.py b/atlasimagecomposer/backend/compose.py index 6bf1d47..7dfa989 100644 --- a/atlasimagecomposer/backend/compose.py +++ b/atlasimagecomposer/backend/compose.py @@ -1,87 +1,112 @@ -import math import pathlib -import sys -from typing import Tuple +from typing import List, Optional import numpy as np from PIL import Image from tqdm.contrib import itertools as tqdm_itertools -from .atlas import fetch_data, fetch_image -from ..config import ExpressionDefaults, Paths +from ..config import 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.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) + if filters is not None: + sprites = { key: value for key, value in sprites.items() if key in filters } + 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(): - process_sprite(f, sprites[f.stem]["position"], sprites[f.stem]["faceSize"], servantid) - print(f"Files have been saved at: {(Paths.OUTPUT / servantid).absolute()}") + if filters is not None and str(f.stem) not in filters: + 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]: - exp_w, exp_h = expressions.size - 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 +def calculate_counts(width: int, height: int, facesize: int): + return height // facesize, width // facesize -# pylint: disable=too-many-locals -def process_sprite(filepath: pathlib.Path, position: tuple, facesize: int, servantid: str): - im = Image.open(filepath) - width, height = im.size +def process_sprite(images_folder: pathlib.Path, servantid: int, configdata: SpritesheetData): + main = Image.open(images_folder / "0.png") - main_sprite = im.crop((0, 0, width, 768)) - save_sprite(main_sprite, servantid, filepath.stem) - expressions = im.crop((0, 768, width, height)) + width, _ = main.size + main_sprite = main.crop((0, 0, width, 756)) - expressionwidth, expressionheight = expressions.size - rows = expressionheight // facesize - expressions_p_row = expressionwidth // facesize + save_sprite(main_sprite, servantid, f"{images_folder.stem}") - 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: - expressions, exp_per_row = override_expressions(expressions, facesize) + rowcount, colcount = calculate_counts(*expressions.size, configdata["facesize"]) + + if i.name == "0.png": + 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: + 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) - for x, y in tqdm_itertools.product(range(0, rows), range(0, expressions_p_row, 1), ascii="-="): - img = generate_sprite(main_sprite, expressions, x, y, position, facesize) - if img is not None: - save_sprite(img, servantid, filepath.stem, (x, y, exp_per_row)) def is_empty(img: Image.Image): 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: return True 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) diff --git a/atlasimagecomposer/cli/cli.py b/atlasimagecomposer/cli/cli.py index a0d9657..20c4059 100644 --- a/atlasimagecomposer/cli/cli.py +++ b/atlasimagecomposer/cli/cli.py @@ -1,10 +1,12 @@ import argparse import pathlib import sys +from typing import List from .. import __version__ from ..backend import compose -from ..config import Paths +from ..config import Paths, AtlasDefaults +from ..utils.filesystem import rmdir # pylint: disable=too-few-public-methods @@ -13,6 +15,9 @@ class Arguments(argparse.Namespace): Default Arguments when calling the CLI """ output: str + cacheclear: bool + filter: List[str] + timeout: int def parse_arguments(): """ @@ -21,10 +26,15 @@ def parse_arguments(): """ parser = argparse.ArgumentParser( 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("--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) @@ -36,10 +46,31 @@ def welcome(): print(" developed by Firq ") print("-------------------------------------------------") -def run(): +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() - 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) diff --git a/atlasimagecomposer/config/config.py b/atlasimagecomposer/config/config.py index 091f8b7..8d10a0d 100644 --- a/atlasimagecomposer/config/config.py +++ b/atlasimagecomposer/config/config.py @@ -1,13 +1,16 @@ # pylint: disable=too-few-public-methods import pathlib + class Paths: _root = pathlib.Path(__file__).parents[1] IMAGES = _root / ".temp" OUTPUT = _root / ".out" class ExpressionDefaults: - faceSize = 256 + FACESIZE = 256 + SHEETSIZE = 1024 class AtlasDefaults: REGION = "JP" + TIMEOUT = 10 diff --git a/atlasimagecomposer/utils/__init__.py b/atlasimagecomposer/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/atlasimagecomposer/utils/filesystem.py b/atlasimagecomposer/utils/filesystem.py new file mode 100644 index 0000000..d01b49c --- /dev/null +++ b/atlasimagecomposer/utils/filesystem.py @@ -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() diff --git a/pyproject.toml b/pyproject.toml index d8c214b..4b00d5c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "atlasimagecomposer" -version = "0.1.0-a.6" +version = "0.1.0-c.1" dependencies = [ "numpy~=2.0.1", "pillow~=10.4.0", @@ -10,7 +10,7 @@ dependencies = [ 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 = "Package that enables peopßle to quickly download and generate all potential spritesheet expressions with a single command" classifiers = [ "Development Status :: 3 - Alpha", "Programming Language :: Python :: 3", @@ -20,7 +20,7 @@ classifiers = [ ] [project.scripts] -atlasimagecomposer = "atlasimagecomposer.cli.cli:run" +atlasimagecomposer = "atlasimagecomposer.cli.cli:run_cli" [tool.setuptools.packages.find] where = ["."]