Compare commits
3 commits
Author | SHA1 | Date | |
---|---|---|---|
27d7598f81 | |||
fc80fc9b84 | |||
43d21deeed |
12 changed files with 183 additions and 160 deletions
15
README.md
15
README.md
|
@ -17,7 +17,9 @@ specified depencencies are not hosted on that index.
|
|||
|
||||
## 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:
|
||||
|
||||
|
@ -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.
|
||||
|
||||
### 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
|
||||
|
||||
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.
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
[project]
|
||||
name = "skyeweave"
|
||||
version = "1.0.0-c.1"
|
||||
version = "1.0.0-c.3"
|
||||
requires-python = ">= 3.10"
|
||||
authors = [{name = "Firq", email = "firelp42@gmail.com"}]
|
||||
maintainers = [{name = "Firq", email = "firelp42@gmail.com"}]
|
||||
|
@ -47,11 +47,13 @@ disable = [
|
|||
"missing-class-docstring",
|
||||
"logging-fstring-interpolation",
|
||||
]
|
||||
ignore-paths="test/*"
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.11"
|
||||
warn_return_any = true
|
||||
warn_unused_configs = true
|
||||
exclude = [ "test" ]
|
||||
|
||||
[build-system]
|
||||
requires = ["setuptools >= 61.0"]
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
import importlib.metadata
|
||||
|
||||
__version__ = importlib.metadata.version(__package__ or "skyeweave")
|
||||
|
||||
from .service import SkyeWeave
|
||||
|
|
|
@ -1 +0,0 @@
|
|||
from .compose import compose
|
|
@ -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
|
|
@ -5,15 +5,14 @@ import sys
|
|||
from typing import List
|
||||
|
||||
from .. import __version__
|
||||
from ..backend import compose
|
||||
from ..service import SkyeWeave
|
||||
from ..config import AtlasAPIConfig, LoggingConfig, PathConfig
|
||||
from ..utils.filesystem import rmdir
|
||||
from ..utils.disables import disable_tqdm
|
||||
from ..utils import rmdir, disable_tqdm
|
||||
|
||||
LOGGER = logging.getLogger(LoggingConfig.NAME)
|
||||
|
||||
# pylint: disable=too-few-public-methods
|
||||
class Arguments(argparse.Namespace):
|
||||
class CLIArguments(argparse.Namespace):
|
||||
"""
|
||||
Default Arguments when calling the CLI
|
||||
"""
|
||||
|
@ -25,7 +24,7 @@ class Arguments(argparse.Namespace):
|
|||
timeout: int
|
||||
quiet: bool
|
||||
|
||||
def parse_arguments():
|
||||
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
|
||||
|
@ -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("--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()
|
||||
args, extra_args = parser.parse_known_args(sys.argv[1:], namespace=args)
|
||||
return args, extra_args
|
||||
|
||||
def welcome():
|
||||
def __welcome():
|
||||
print("-------------------------------------------")
|
||||
print(" Welcome to skyeweave, an expression sheet ")
|
||||
print(" composer developed by Firq ")
|
||||
|
@ -68,18 +64,16 @@ def validate_id(input_id: None | str) -> int:
|
|||
return int(input_id)
|
||||
|
||||
def run():
|
||||
args, _ = parse_arguments()
|
||||
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()
|
||||
__welcome()
|
||||
|
||||
input_id = validate_id(args.id)
|
||||
|
||||
|
@ -93,9 +87,8 @@ def run():
|
|||
else:
|
||||
LOGGER.info("No cache to clear was found, continuing")
|
||||
|
||||
if args.reset:
|
||||
if PathConfig.IMAGES.exists():
|
||||
if args.reset and PathConfig.IMAGES.exists():
|
||||
rmdir(PathConfig.IMAGES)
|
||||
LOGGER.info("Successfully reset local storage")
|
||||
|
||||
compose(input_id, args.filter)
|
||||
SkyeWeave().compose(input_id, args.filter)
|
||||
|
|
1
skyeweave/service/__init__.py
Normal file
1
skyeweave/service/__init__.py
Normal file
|
@ -0,0 +1 @@
|
|||
from .compose import SkyeWeave
|
|
@ -1,4 +1,5 @@
|
|||
import logging
|
||||
import pathlib
|
||||
from typing import Annotated, List, NotRequired, Tuple, TypedDict
|
||||
import requests
|
||||
|
||||
|
@ -61,11 +62,10 @@ def fetch_mstsvtjson():
|
|||
break
|
||||
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}"
|
||||
|
||||
savefolder = PathConfig.IMAGES / basefolder / str(imageid)
|
||||
if not savefolder.is_dir():
|
||||
savefolder = tempfolder / str(imageid)
|
||||
savefolder.mkdir(exist_ok=True, parents=True)
|
||||
|
||||
idx, status = 0, 200
|
125
skyeweave/service/compose.py
Normal file
125
skyeweave/service/compose.py
Normal 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
|
|
@ -1,2 +1,2 @@
|
|||
from .logger import init_logger
|
||||
LOGGER = init_logger()
|
||||
from .filesystem import rmdir
|
||||
from .logger import LOGGER, disable_tqdm
|
||||
|
|
|
@ -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)
|
|
@ -1,18 +1,27 @@
|
|||
|
||||
# pylint: disable=import-outside-toplevel
|
||||
import logging
|
||||
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":
|
||||
disable_tqdm()
|
||||
|
||||
logger = logging.getLogger(LoggingConfig.NAME)
|
||||
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_logger = logging.getLogger(LoggingConfig.NAME)
|
||||
default_logger.setLevel(LoggingConfig.LEVEL)
|
||||
|
||||
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()
|
||||
|
|
Loading…
Reference in a new issue