Compare commits

..

No commits in common. "main" and "1.0.0-c.1" have entirely different histories.

12 changed files with 159 additions and 182 deletions

View file

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

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

View file

@ -1,5 +1,4 @@
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
@ -62,11 +61,12 @@ def fetch_mstsvtjson():
break break
handle.write(block) handle.write(block)
def fetch_expression_sheets(tempfolder: pathlib.Path, imageid: str): def fetch_expression_sheets(basefolder: str, 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 = tempfolder / str(imageid) savefolder = PathConfig.IMAGES / basefolder / str(imageid)
savefolder.mkdir(exist_ok=True, parents=True) if not savefolder.is_dir():
savefolder.mkdir(exist_ok=True, parents=True)
idx, status = 0, 200 idx, status = 0, 200

View file

@ -0,0 +1,116 @@
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,14 +5,15 @@ import sys
from typing import List from typing import List
from .. import __version__ from .. import __version__
from ..service import SkyeWeave from ..backend import compose
from ..config import AtlasAPIConfig, LoggingConfig, PathConfig from ..config import AtlasAPIConfig, LoggingConfig, PathConfig
from ..utils import rmdir, disable_tqdm from ..utils.filesystem import rmdir
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 CLIArguments(argparse.Namespace): class Arguments(argparse.Namespace):
""" """
Default Arguments when calling the CLI Default Arguments when calling the CLI
""" """
@ -24,7 +25,7 @@ class CLIArguments(argparse.Namespace):
timeout: int timeout: int
quiet: bool quiet: bool
def parse_arguments(arguments): def parse_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
@ -43,9 +44,12 @@ def parse_arguments(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)
def __welcome(): args = Arguments()
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 ")
@ -64,16 +68,18 @@ def validate_id(input_id: None | str) -> int:
return int(input_id) return int(input_id)
def run(): def run():
args, _ = parse_arguments(sys.argv[1:]) args, _ = parse_arguments()
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)
@ -87,8 +93,9 @@ 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 and PathConfig.IMAGES.exists(): if args.reset:
rmdir(PathConfig.IMAGES) if PathConfig.IMAGES.exists():
LOGGER.info("Successfully reset local storage") rmdir(PathConfig.IMAGES)
LOGGER.info("Successfully reset local storage")
SkyeWeave().compose(input_id, args.filter) compose(input_id, args.filter)

View file

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

View file

@ -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

View file

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

View file

@ -0,0 +1,5 @@
# 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,27 +1,18 @@
# pylint: disable=import-outside-toplevel
import logging import logging
import sys import sys
from ..config import LoggingConfig from ..config import LoggingConfig
from .disables import disable_tqdm
def disable_tqdm(): def init_logger():
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()
default_logger = logging.getLogger(LoggingConfig.NAME) logger = logging.getLogger(LoggingConfig.NAME)
default_logger.setLevel(LoggingConfig.LEVEL) 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)
default_handler = logging.StreamHandler(stream=sys.stdout) return logger
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()