Compare commits

...

3 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
12 changed files with 183 additions and 160 deletions

View file

@ -17,7 +17,9 @@ specified depencencies are not hosted on that index.
## Usage ## Usage
`skyeweave` is a CLI application, meaning it needs to be accessed via the commandline. ### commandline
`skyeweave` is primarily a CLI application.
The following options are available: The following options are available:
@ -39,6 +41,17 @@ 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. 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 ## 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 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.

View file

@ -1,6 +1,6 @@
[project] [project]
name = "skyeweave" name = "skyeweave"
version = "1.0.0-c.1" version = "1.0.0-c.3"
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"}]
@ -47,11 +47,13 @@ disable = [
"missing-class-docstring", "missing-class-docstring",
"logging-fstring-interpolation", "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"]

View file

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

View file

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

View file

@ -1,116 +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)
def compose(input_id: int, filters: Optional[List[str]] = None):
PathConfig.IMAGES.mkdir(exist_ok=True)
PathConfig.OUTPUT.mkdir(exist_ok=True)
if input_id < 10000:
chara_ids = fetch_data(input_id)
savefolder = PathConfig.OUTPUT / str(input_id)
else:
LOGGER.info(f"Processing manually uploaded charaId {input_id}")
savefolder = PathConfig.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 ]
LOGGER.debug(chara_ids)
for char_id in chara_ids:
expfolder = fetch_expression_sheets(savefolder.stem, char_id)
config = fetch_config(char_id)
process_sprite(expfolder, config, savefolder)
LOGGER.info(f"Files have been saved at: {savefolder.absolute()}")
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
def gen_main_sprite(folder: pathlib.Path):
image = Image.open(folder / "0.png")
width, height = image.size
LOGGER.debug(f"Main sprite ({folder}): {width}:{height}")
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)
image_idx = 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 = 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 = generate_sprite(main_sprite, expressions, x, y, configdata)
if img is not None:
image_idx = save_sprite(img, outputfolder, f"{images_folder.stem}", image_idx)
LOGGER.debug(f"{x}/{y} - {'Invalid' if img is None else 'Valid'} image")
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 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
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
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

@ -5,15 +5,14 @@ import sys
from typing import List from typing import List
from .. import __version__ from .. import __version__
from ..backend import compose from ..service import SkyeWeave
from ..config import AtlasAPIConfig, LoggingConfig, PathConfig from ..config import AtlasAPIConfig, LoggingConfig, PathConfig
from ..utils.filesystem import rmdir from ..utils import rmdir, disable_tqdm
from ..utils.disables import disable_tqdm
LOGGER = logging.getLogger(LoggingConfig.NAME) LOGGER = logging.getLogger(LoggingConfig.NAME)
# pylint: disable=too-few-public-methods # pylint: disable=too-few-public-methods
class Arguments(argparse.Namespace): class CLIArguments(argparse.Namespace):
""" """
Default Arguments when calling the CLI Default Arguments when calling the CLI
""" """
@ -25,7 +24,7 @@ class Arguments(argparse.Namespace):
timeout: int timeout: int
quiet: bool quiet: bool
def parse_arguments(): def parse_arguments(arguments):
""" """
Create a parser and parse the arguments of sys.argv based on the Arguments Namespace Create a parser and parse the arguments of sys.argv based on the Arguments Namespace
Returns arguments and extra arguments separately Returns arguments and extra arguments separately
@ -44,12 +43,9 @@ def parse_arguments():
parser.add_argument("--reset", action="store_true", default=False, dest="reset", help="Delete any already downloaded assets") 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") 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)
args = Arguments() def __welcome():
args, extra_args = parser.parse_known_args(sys.argv[1:], namespace=args)
return args, extra_args
def welcome():
print("-------------------------------------------") print("-------------------------------------------")
print(" Welcome to skyeweave, an expression sheet ") print(" Welcome to skyeweave, an expression sheet ")
print(" composer developed by Firq ") print(" composer developed by Firq ")
@ -68,18 +64,16 @@ def validate_id(input_id: None | str) -> int:
return int(input_id) return int(input_id)
def run(): def run():
args, _ = parse_arguments() args, _ = parse_arguments(sys.argv[1:])
if args.output: if args.output:
PathConfig.OUTPUT = pathlib.Path(args.output) PathConfig.OUTPUT = pathlib.Path(args.output)
if args.timeout and args.timeout >= 0: if args.timeout and args.timeout >= 0:
AtlasAPIConfig.TIMEOUT = args.timeout AtlasAPIConfig.TIMEOUT = args.timeout
if args.quiet: if args.quiet:
disable_tqdm() disable_tqdm()
LOGGER.disabled = True LOGGER.disabled = True
else: else:
welcome() __welcome()
input_id = validate_id(args.id) input_id = validate_id(args.id)
@ -93,9 +87,8 @@ def run():
else: else:
LOGGER.info("No cache to clear was found, continuing") LOGGER.info("No cache to clear was found, continuing")
if args.reset: if args.reset and PathConfig.IMAGES.exists():
if PathConfig.IMAGES.exists(): rmdir(PathConfig.IMAGES)
rmdir(PathConfig.IMAGES) LOGGER.info("Successfully reset local storage")
LOGGER.info("Successfully reset local storage")
compose(input_id, args.filter) SkyeWeave().compose(input_id, args.filter)

View file

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

View file

@ -1,4 +1,5 @@
import logging import logging
import pathlib
from typing import Annotated, List, NotRequired, Tuple, TypedDict from typing import Annotated, List, NotRequired, Tuple, TypedDict
import requests import requests
@ -61,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/{AtlasAPIConfig.REGION}/CharaFigure/{imageid}" atlasurl_base = f"https://static.atlasacademy.io/{AtlasAPIConfig.REGION}/CharaFigure/{imageid}"
savefolder = PathConfig.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

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

@ -1,2 +1,2 @@
from .logger import init_logger from .filesystem import rmdir
LOGGER = init_logger() from .logger import LOGGER, disable_tqdm

View file

@ -1,5 +0,0 @@
# pylint: disable=import-outside-toplevel
def disable_tqdm():
from tqdm import tqdm
from functools import partialmethod
tqdm.__init__ = partialmethod(tqdm.__init__, disable=True)

View file

@ -1,18 +1,27 @@
# pylint: disable=import-outside-toplevel
import logging import logging
import sys import sys
from ..config import LoggingConfig
from .disables import disable_tqdm
def init_logger(): 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": if LoggingConfig.LEVEL == "DEBUG":
disable_tqdm() disable_tqdm()
logger = logging.getLogger(LoggingConfig.NAME) default_logger = logging.getLogger(LoggingConfig.NAME)
logger.setLevel(LoggingConfig.LEVEL) default_logger.setLevel(LoggingConfig.LEVEL)
handler = logging.StreamHandler(stream=sys.stdout)
formatter = logging.Formatter('[%(levelname)s] [%(name)s] %(message)s')
handler.setFormatter(formatter)
logger.addHandler(handler)
return logger 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()