spriteworkflow 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- spriteworkflow/__init__.py +23 -0
- spriteworkflow/cli.py +202 -0
- spriteworkflow/extractor.py +64 -0
- spriteworkflow/ffmpeg_manager.py +9 -0
- spriteworkflow/matte.py +152 -0
- spriteworkflow/report.py +142 -0
- spriteworkflow/spritesheet_grid.py +132 -0
- spriteworkflow/spritesheet_lineal.py +68 -0
- spriteworkflow-0.1.0.dist-info/METADATA +236 -0
- spriteworkflow-0.1.0.dist-info/RECORD +14 -0
- spriteworkflow-0.1.0.dist-info/WHEEL +5 -0
- spriteworkflow-0.1.0.dist-info/entry_points.txt +2 -0
- spriteworkflow-0.1.0.dist-info/licenses/LICENSE +21 -0
- spriteworkflow-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""SpriteWorkflow — convert video frames into linear spritesheets.
|
|
2
|
+
|
|
3
|
+
Provides :func:`extract_frames` (extract frames from video via FFmpeg),
|
|
4
|
+
:func:`create_spritesheet_lineal` (stitch frames into a horizontal
|
|
5
|
+
spritesheet), :func:`remove_background` (chroma-key background removal
|
|
6
|
+
on frames), :func:`create_spritesheet_grid` (arrange frames in a
|
|
7
|
+
rows × columns grid), and :func:`generate_report` (inspect frames and
|
|
8
|
+
produce a JSON validation report).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from .extractor import extract_frames
|
|
12
|
+
from .spritesheet_lineal import create_spritesheet_lineal
|
|
13
|
+
from .matte import remove_background
|
|
14
|
+
from .spritesheet_grid import create_spritesheet_grid
|
|
15
|
+
from .report import generate_report
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"extract_frames",
|
|
19
|
+
"create_spritesheet_lineal",
|
|
20
|
+
"remove_background",
|
|
21
|
+
"create_spritesheet_grid",
|
|
22
|
+
"generate_report",
|
|
23
|
+
]
|
spriteworkflow/cli.py
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""SpriteWorkflow CLI — build spritesheets from video via the command line.
|
|
2
|
+
|
|
3
|
+
Usage::
|
|
4
|
+
|
|
5
|
+
spriteworkflow build <video> [options]
|
|
6
|
+
|
|
7
|
+
Subcommands
|
|
8
|
+
-----------
|
|
9
|
+
build Extract frames (optionally remove background), assemble into a
|
|
10
|
+
spritesheet, and optionally produce a validation report.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import sys
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from spriteworkflow import (
|
|
18
|
+
extract_frames,
|
|
19
|
+
remove_background,
|
|
20
|
+
create_spritesheet_lineal,
|
|
21
|
+
create_spritesheet_grid,
|
|
22
|
+
generate_report,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# ---------------------------------------------------------------------------
|
|
27
|
+
# Parser
|
|
28
|
+
# ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
31
|
+
"""Create the argument parser for the ``build`` subcommand.
|
|
32
|
+
|
|
33
|
+
Returns
|
|
34
|
+
-------
|
|
35
|
+
argparse.ArgumentParser
|
|
36
|
+
"""
|
|
37
|
+
parser = argparse.ArgumentParser(
|
|
38
|
+
prog="spriteworkflow",
|
|
39
|
+
description="Convert video frames into linear or grid spritesheets.",
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
43
|
+
build = sub.add_parser("build", help="Build a spritesheet from a video")
|
|
44
|
+
|
|
45
|
+
# Positional
|
|
46
|
+
build.add_argument(
|
|
47
|
+
"video",
|
|
48
|
+
help="Path to the input video file",
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
# Output
|
|
52
|
+
build.add_argument(
|
|
53
|
+
"--output", "-o",
|
|
54
|
+
default="spritesheet.png",
|
|
55
|
+
help="Output spritesheet path (default: %(default)s)",
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
# Layout
|
|
59
|
+
build.add_argument(
|
|
60
|
+
"--layout",
|
|
61
|
+
choices=("lineal", "grid"),
|
|
62
|
+
default="lineal",
|
|
63
|
+
help="Spritesheet layout (default: %(default)s)",
|
|
64
|
+
)
|
|
65
|
+
build.add_argument(
|
|
66
|
+
"--columns",
|
|
67
|
+
type=int,
|
|
68
|
+
default=None,
|
|
69
|
+
help="Grid columns — auto-calculated as ceil(sqrt(N)) when not set. "
|
|
70
|
+
"Only relevant with --layout=grid.",
|
|
71
|
+
)
|
|
72
|
+
build.add_argument(
|
|
73
|
+
"--padding",
|
|
74
|
+
type=int,
|
|
75
|
+
default=0,
|
|
76
|
+
help="Padding pixels between grid cells (default: %(default)s). "
|
|
77
|
+
"Only relevant with --layout=grid.",
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
# Chroma-key (matte)
|
|
81
|
+
build.add_argument(
|
|
82
|
+
"--matte",
|
|
83
|
+
action="store_true",
|
|
84
|
+
default=False,
|
|
85
|
+
help="Enable chroma-key background removal between extraction and "
|
|
86
|
+
"spritesheet assembly",
|
|
87
|
+
)
|
|
88
|
+
build.add_argument(
|
|
89
|
+
"--bg-color",
|
|
90
|
+
nargs=3,
|
|
91
|
+
type=int,
|
|
92
|
+
default=None,
|
|
93
|
+
metavar=("R", "G", "B"),
|
|
94
|
+
help="RGB triplet for chroma-key color, e.g. --bg-color 0 255 0. "
|
|
95
|
+
"Auto-detected from corner pixels when not set.",
|
|
96
|
+
)
|
|
97
|
+
build.add_argument(
|
|
98
|
+
"--tolerance",
|
|
99
|
+
type=int,
|
|
100
|
+
default=30,
|
|
101
|
+
help="Chroma-key euclidean tolerance (default: %(default)s)",
|
|
102
|
+
)
|
|
103
|
+
build.add_argument(
|
|
104
|
+
"--feather",
|
|
105
|
+
type=int,
|
|
106
|
+
default=0,
|
|
107
|
+
help="Chroma-key feather width in pixels (default: %(default)s)",
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
# Report
|
|
111
|
+
build.add_argument(
|
|
112
|
+
"--report-file",
|
|
113
|
+
default=None,
|
|
114
|
+
help="Path for JSON validation report (omitted when not set)",
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
# Internal
|
|
118
|
+
build.add_argument(
|
|
119
|
+
"--temp-dir",
|
|
120
|
+
default="temp",
|
|
121
|
+
help="Temporary directory for extracted frames (default: %(default)s)",
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
return parser
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
# ---------------------------------------------------------------------------
|
|
128
|
+
# Entry point
|
|
129
|
+
# ---------------------------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
def main(argv=None) -> None:
|
|
132
|
+
"""CLI entry point.
|
|
133
|
+
|
|
134
|
+
Parameters
|
|
135
|
+
----------
|
|
136
|
+
argv : list of str, optional
|
|
137
|
+
Command-line arguments. If ``None`` (default), ``sys.argv[1:]`` is
|
|
138
|
+
read. Passing an explicit list enables testing without I/O.
|
|
139
|
+
"""
|
|
140
|
+
parser = build_parser()
|
|
141
|
+
args = parser.parse_args(argv)
|
|
142
|
+
|
|
143
|
+
# argparse requires a subcommand — safety net for edge cases
|
|
144
|
+
if args.command != "build":
|
|
145
|
+
parser.print_help()
|
|
146
|
+
sys.exit(2)
|
|
147
|
+
|
|
148
|
+
try:
|
|
149
|
+
# ---- Step 1 : Extract frames ------------------------------------
|
|
150
|
+
print("> Extracting frames from video...")
|
|
151
|
+
frames = extract_frames(args.video, output_dir=args.temp_dir)
|
|
152
|
+
print(f" {len(frames)} frames extracted.")
|
|
153
|
+
|
|
154
|
+
# ---- Step 2 (optional) : Remove background ----------------------
|
|
155
|
+
if args.matte:
|
|
156
|
+
print("> Removing background via chroma-key...")
|
|
157
|
+
bg = tuple(args.bg_color) if args.bg_color else None
|
|
158
|
+
frames = remove_background(
|
|
159
|
+
frames=frames,
|
|
160
|
+
bg_color=bg,
|
|
161
|
+
tolerance=args.tolerance,
|
|
162
|
+
feather=args.feather,
|
|
163
|
+
output_dir=str(Path(args.temp_dir) / "matted"),
|
|
164
|
+
)
|
|
165
|
+
print(f" {len(frames)} frames processed.")
|
|
166
|
+
|
|
167
|
+
# ---- Step 3 : Build spritesheet ---------------------------------
|
|
168
|
+
print(f"> Building {args.layout} spritesheet...")
|
|
169
|
+
|
|
170
|
+
if args.layout == "grid":
|
|
171
|
+
sheet = create_spritesheet_grid(
|
|
172
|
+
frames=frames,
|
|
173
|
+
columns=args.columns,
|
|
174
|
+
padding=args.padding,
|
|
175
|
+
output_file=args.output,
|
|
176
|
+
)
|
|
177
|
+
else:
|
|
178
|
+
sheet = create_spritesheet_lineal(
|
|
179
|
+
frames=frames,
|
|
180
|
+
output_file=args.output,
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
print(f" -> Spritesheet saved: {sheet}")
|
|
184
|
+
|
|
185
|
+
# ---- Step 4 (optional) : Generate report ------------------------
|
|
186
|
+
if args.report_file:
|
|
187
|
+
print("> Generating validation report...")
|
|
188
|
+
report = generate_report(
|
|
189
|
+
frames=frames,
|
|
190
|
+
spritesheet_path=sheet,
|
|
191
|
+
output_file=args.report_file,
|
|
192
|
+
)
|
|
193
|
+
print(f" -> Report saved: {report}")
|
|
194
|
+
|
|
195
|
+
except (ValueError, FileNotFoundError, NotADirectoryError,
|
|
196
|
+
RuntimeError, PermissionError) as exc:
|
|
197
|
+
print(f"Error: {exc}", file=sys.stderr)
|
|
198
|
+
sys.exit(1)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
if __name__ == "__main__":
|
|
202
|
+
main()
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
import subprocess
|
|
3
|
+
from spriteworkflow.ffmpeg_manager import get_ffmpeg_path
|
|
4
|
+
import tempfile
|
|
5
|
+
|
|
6
|
+
def extract_frames(video_name, output_dir="temp"):
|
|
7
|
+
|
|
8
|
+
ffmpeg_path = get_ffmpeg_path()
|
|
9
|
+
|
|
10
|
+
video_path = Path(video_name)
|
|
11
|
+
|
|
12
|
+
if not video_path.exists():
|
|
13
|
+
raise FileNotFoundError(f"Video file not found: {video_name}")
|
|
14
|
+
|
|
15
|
+
if not video_path.is_file():
|
|
16
|
+
raise ValueError(f"Path is not a valid file: {video_name}")
|
|
17
|
+
|
|
18
|
+
if video_path.stat().st_size <= 0:
|
|
19
|
+
raise ValueError(f"Video file is empty: {video_name}")
|
|
20
|
+
|
|
21
|
+
try:
|
|
22
|
+
with open(video_path, "rb"):
|
|
23
|
+
pass
|
|
24
|
+
except PermissionError as e:
|
|
25
|
+
raise PermissionError(f"Video file is not readable: {video_name}") from e
|
|
26
|
+
|
|
27
|
+
temp_root = Path(output_dir)
|
|
28
|
+
temp_root.mkdir(parents=True, exist_ok=True)
|
|
29
|
+
|
|
30
|
+
temp_dir = tempfile.mkdtemp(prefix="frames_", dir=temp_root)
|
|
31
|
+
|
|
32
|
+
output_path = Path(temp_dir)
|
|
33
|
+
|
|
34
|
+
frame_pattern = output_path / "frame_%04d.png"
|
|
35
|
+
|
|
36
|
+
command = [
|
|
37
|
+
ffmpeg_path,
|
|
38
|
+
"-v",
|
|
39
|
+
"error",
|
|
40
|
+
"-i",
|
|
41
|
+
str(video_path),
|
|
42
|
+
str(frame_pattern)
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
try:
|
|
46
|
+
subprocess.run(command, check=True, stderr=subprocess.PIPE, text=True)
|
|
47
|
+
except subprocess.CalledProcessError as e:
|
|
48
|
+
stderr = e.stderr.strip()
|
|
49
|
+
raise RuntimeError(f"FFmpeg failed:\n{stderr}") from e
|
|
50
|
+
|
|
51
|
+
frames = sorted(output_path.glob("*.png"))
|
|
52
|
+
|
|
53
|
+
if not frames:
|
|
54
|
+
raise RuntimeError(
|
|
55
|
+
"No frames were extracted. "
|
|
56
|
+
"Possible causes:\n"
|
|
57
|
+
"- Corrupted video\n"
|
|
58
|
+
"- Unsupported codec\n"
|
|
59
|
+
"- Zero-duration video\n"
|
|
60
|
+
"- FFmpeg decoding issue\n"
|
|
61
|
+
"- Permission problems"
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
return frames
|
spriteworkflow/matte.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
import cv2
|
|
4
|
+
import numpy as np
|
|
5
|
+
from PIL import Image
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def remove_background(frames=None, frames_dir=None, bg_color=None, tolerance=30, feather=0, output_dir="matted"):
|
|
9
|
+
"""Remove background from frames via chroma-key (euclidean distance in RGB).
|
|
10
|
+
|
|
11
|
+
Parameters
|
|
12
|
+
----------
|
|
13
|
+
frames : list of Path or str, optional
|
|
14
|
+
List of paths to individual frame images.
|
|
15
|
+
frames_dir : Path or str, optional
|
|
16
|
+
Directory containing PNG frames. Mutually exclusive with *frames*.
|
|
17
|
+
bg_color : tuple of int, optional
|
|
18
|
+
RGB triplet (0-255 each). If ``None`` it is auto-detected by
|
|
19
|
+
averaging the four corner pixels of the first frame.
|
|
20
|
+
tolerance : int
|
|
21
|
+
Maximum euclidean distance from *bg_color* for a pixel to be
|
|
22
|
+
considered background (made transparent). Default 30.
|
|
23
|
+
feather : int
|
|
24
|
+
Width of the linear feather ramp around *tolerance*. With
|
|
25
|
+
``feather=0`` (default) the alpha mask is binary. With
|
|
26
|
+
``feather > 0`` pixels whose distance falls inside the ramp
|
|
27
|
+
``[tolerance - feather, tolerance + feather]`` get a smoothly
|
|
28
|
+
interpolated alpha value between 0 and 255.
|
|
29
|
+
output_dir : Path or str
|
|
30
|
+
Directory where the processed RGBA PNGs are saved. Created if it
|
|
31
|
+
does not exist. Default ``"matted"``.
|
|
32
|
+
|
|
33
|
+
Returns
|
|
34
|
+
-------
|
|
35
|
+
list of Path
|
|
36
|
+
Sorted list of paths to the generated RGBA PNG files.
|
|
37
|
+
|
|
38
|
+
Raises
|
|
39
|
+
------
|
|
40
|
+
ValueError
|
|
41
|
+
If neither *frames* nor *frames_dir* is provided, if both are
|
|
42
|
+
provided, if *frames_dir* points to an empty directory, if
|
|
43
|
+
*bg_color* is not a valid RGB triplet, or if *feather* is not a
|
|
44
|
+
non-negative integer.
|
|
45
|
+
FileNotFoundError
|
|
46
|
+
If *frames_dir* does not exist.
|
|
47
|
+
NotADirectoryError
|
|
48
|
+
If *frames_dir* is a file, not a directory.
|
|
49
|
+
"""
|
|
50
|
+
# ------------------------------------------------------------------
|
|
51
|
+
# Validación de entrada (mismo estilo que create_spritesheet_lineal)
|
|
52
|
+
# ------------------------------------------------------------------
|
|
53
|
+
if frames is None and frames_dir is None:
|
|
54
|
+
raise ValueError("Either 'frames' or 'frames_dir' must be provided.")
|
|
55
|
+
|
|
56
|
+
if frames is not None and frames_dir is not None:
|
|
57
|
+
raise ValueError("Only one of 'frames' or 'frames_dir' should be provided, not both.")
|
|
58
|
+
|
|
59
|
+
if frames_dir is not None:
|
|
60
|
+
frames_path = Path(frames_dir)
|
|
61
|
+
|
|
62
|
+
if not frames_path.exists():
|
|
63
|
+
raise FileNotFoundError(f"The directory '{frames_dir}' does not exist.")
|
|
64
|
+
|
|
65
|
+
if not frames_path.is_dir():
|
|
66
|
+
raise NotADirectoryError(f"The path '{frames_dir}' is not a directory.")
|
|
67
|
+
|
|
68
|
+
frames = sorted(frames_path.glob("*.png"))
|
|
69
|
+
|
|
70
|
+
if not frames:
|
|
71
|
+
raise ValueError("No frames found to process for background removal.")
|
|
72
|
+
|
|
73
|
+
# Normalise frame paths
|
|
74
|
+
frame_paths = [Path(f) for f in frames]
|
|
75
|
+
|
|
76
|
+
# ------------------------------------------------------------------
|
|
77
|
+
# Color de fondo — autodetección o validación
|
|
78
|
+
# ------------------------------------------------------------------
|
|
79
|
+
if bg_color is None:
|
|
80
|
+
bgr = cv2.imread(str(frame_paths[0]), cv2.IMREAD_COLOR)
|
|
81
|
+
if bgr is None:
|
|
82
|
+
raise ValueError(f"Cannot read frame '{frame_paths[0]}' with OpenCV.")
|
|
83
|
+
|
|
84
|
+
h, w = bgr.shape[:2]
|
|
85
|
+
corners_bgr = [
|
|
86
|
+
bgr[0, 0],
|
|
87
|
+
bgr[0, w - 1],
|
|
88
|
+
bgr[h - 1, 0],
|
|
89
|
+
bgr[h - 1, w - 1],
|
|
90
|
+
]
|
|
91
|
+
avg_bgr = np.mean(corners_bgr, axis=0).astype(int)
|
|
92
|
+
bg_color = tuple(avg_bgr[::-1]) # BGR → RGB
|
|
93
|
+
else:
|
|
94
|
+
if not isinstance(bg_color, tuple) or len(bg_color) != 3:
|
|
95
|
+
raise ValueError("bg_color must be a tuple of 3 integers (R, G, B).")
|
|
96
|
+
|
|
97
|
+
if not all(isinstance(c, int) and 0 <= c <= 255 for c in bg_color):
|
|
98
|
+
raise ValueError("bg_color values must be integers in the range 0-255.")
|
|
99
|
+
|
|
100
|
+
# ------------------------------------------------------------------
|
|
101
|
+
# Validación de feather
|
|
102
|
+
# ------------------------------------------------------------------
|
|
103
|
+
if not isinstance(feather, int) or feather < 0:
|
|
104
|
+
raise ValueError("feather must be a non-negative integer.")
|
|
105
|
+
|
|
106
|
+
# ------------------------------------------------------------------
|
|
107
|
+
# Procesar cada frame
|
|
108
|
+
# ------------------------------------------------------------------
|
|
109
|
+
output_path = Path(output_dir)
|
|
110
|
+
output_path.mkdir(parents=True, exist_ok=True)
|
|
111
|
+
|
|
112
|
+
bg_array = np.array(bg_color, dtype=np.uint8) # RGB
|
|
113
|
+
result_paths = []
|
|
114
|
+
|
|
115
|
+
for frame_path in frame_paths:
|
|
116
|
+
if not frame_path.exists():
|
|
117
|
+
raise FileNotFoundError(f"The frame '{frame_path}' does not exist.")
|
|
118
|
+
|
|
119
|
+
bgr = cv2.imread(str(frame_path), cv2.IMREAD_COLOR)
|
|
120
|
+
if bgr is None:
|
|
121
|
+
raise ValueError(f"Cannot read frame '{frame_path}' with OpenCV.")
|
|
122
|
+
|
|
123
|
+
# Convert BGR → RGB for the distance computation
|
|
124
|
+
rgb = cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB)
|
|
125
|
+
|
|
126
|
+
# Euclidean distance per pixel against bg_color
|
|
127
|
+
diff = rgb.astype(np.float32) - bg_array.astype(np.float32)
|
|
128
|
+
dist = np.sqrt(np.sum(diff ** 2, axis=2))
|
|
129
|
+
|
|
130
|
+
# Alpha mask: binary (feather=0) or linear ramp (feather>0)
|
|
131
|
+
if feather == 0:
|
|
132
|
+
alpha = np.where(dist <= tolerance, 0, 255).astype(np.uint8)
|
|
133
|
+
else:
|
|
134
|
+
lower = max(0, tolerance - feather)
|
|
135
|
+
upper = tolerance + feather
|
|
136
|
+
alpha = np.where(
|
|
137
|
+
dist <= lower,
|
|
138
|
+
np.uint8(0),
|
|
139
|
+
np.where(
|
|
140
|
+
dist >= upper,
|
|
141
|
+
np.uint8(255),
|
|
142
|
+
((dist - lower) / (upper - lower) * 255).astype(np.uint8),
|
|
143
|
+
),
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
rgba = np.dstack([rgb, alpha])
|
|
147
|
+
|
|
148
|
+
out_file = output_path / frame_path.name
|
|
149
|
+
Image.fromarray(rgba, "RGBA").save(out_file)
|
|
150
|
+
result_paths.append(out_file)
|
|
151
|
+
|
|
152
|
+
return sorted(result_paths)
|
spriteworkflow/report.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from PIL import Image
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def generate_report(frames=None, frames_dir=None, spritesheet_path=None, output_file="report.json"):
|
|
9
|
+
"""Inspect frames and produce a JSON validation report.
|
|
10
|
+
|
|
11
|
+
Use this function *after* creating a spritesheet to detect issues
|
|
12
|
+
such as mismatched frame dimensions, verify the output file, and
|
|
13
|
+
produce a portable metadata record.
|
|
14
|
+
|
|
15
|
+
Unlike :func:`create_spritesheet_lineal` and
|
|
16
|
+
:func:`create_spritesheet_grid`, this function **does not** raise
|
|
17
|
+
when frames have different sizes — mismatches are recorded in the
|
|
18
|
+
report instead, which is the whole point of validation.
|
|
19
|
+
|
|
20
|
+
Parameters
|
|
21
|
+
----------
|
|
22
|
+
frames : list of Path or str, optional
|
|
23
|
+
List of paths to individual frame images.
|
|
24
|
+
frames_dir : Path or str, optional
|
|
25
|
+
Directory containing PNG frames. Mutually exclusive with *frames*.
|
|
26
|
+
spritesheet_path : Path or str, optional
|
|
27
|
+
Path to a spritesheet file to include its dimensions in the report.
|
|
28
|
+
output_file : Path or str
|
|
29
|
+
Path for the generated JSON report. Default ``"report.json"``.
|
|
30
|
+
|
|
31
|
+
Returns
|
|
32
|
+
-------
|
|
33
|
+
Path
|
|
34
|
+
Path to the generated JSON report file.
|
|
35
|
+
|
|
36
|
+
Raises
|
|
37
|
+
------
|
|
38
|
+
ValueError
|
|
39
|
+
If neither *frames* nor *frames_dir* is provided, if both are
|
|
40
|
+
provided, or if *frames_dir* points to an empty directory.
|
|
41
|
+
FileNotFoundError
|
|
42
|
+
If *frames_dir* or *spritesheet_path* does not exist.
|
|
43
|
+
NotADirectoryError
|
|
44
|
+
If *frames_dir* is a file, not a directory.
|
|
45
|
+
"""
|
|
46
|
+
# ------------------------------------------------------------------
|
|
47
|
+
# Validación de entrada (mismo estilo que spritesheet_lineal)
|
|
48
|
+
# ------------------------------------------------------------------
|
|
49
|
+
if frames is None and frames_dir is None:
|
|
50
|
+
raise ValueError("Either 'frames' or 'frames_dir' must be provided.")
|
|
51
|
+
|
|
52
|
+
if frames is not None and frames_dir is not None:
|
|
53
|
+
raise ValueError("Only one of 'frames' or 'frames_dir' should be provided, not both.")
|
|
54
|
+
|
|
55
|
+
if frames_dir is not None:
|
|
56
|
+
frames_path = Path(frames_dir)
|
|
57
|
+
|
|
58
|
+
if not frames_path.exists():
|
|
59
|
+
raise FileNotFoundError(f"The directory '{frames_dir}' does not exist.")
|
|
60
|
+
|
|
61
|
+
if not frames_path.is_dir():
|
|
62
|
+
raise NotADirectoryError(f"The path '{frames_dir}' is not a directory.")
|
|
63
|
+
|
|
64
|
+
frames = sorted(frames_path.glob("*.png"))
|
|
65
|
+
|
|
66
|
+
if not frames:
|
|
67
|
+
raise ValueError("No frames found to report.")
|
|
68
|
+
|
|
69
|
+
# ------------------------------------------------------------------
|
|
70
|
+
# Validación de spritesheet_path
|
|
71
|
+
# ------------------------------------------------------------------
|
|
72
|
+
if spritesheet_path is not None:
|
|
73
|
+
sprite_path = Path(spritesheet_path)
|
|
74
|
+
if not sprite_path.exists():
|
|
75
|
+
raise FileNotFoundError(f"The spritesheet '{spritesheet_path}' does not exist.")
|
|
76
|
+
if not sprite_path.is_file():
|
|
77
|
+
raise ValueError(f"The path '{spritesheet_path}' is not a valid file.")
|
|
78
|
+
|
|
79
|
+
# ------------------------------------------------------------------
|
|
80
|
+
# Procesar frames
|
|
81
|
+
# ------------------------------------------------------------------
|
|
82
|
+
frame_paths = [Path(f) for f in frames]
|
|
83
|
+
|
|
84
|
+
frame_entries = []
|
|
85
|
+
mismatched = []
|
|
86
|
+
reference_size = None
|
|
87
|
+
|
|
88
|
+
for idx, frame_path in enumerate(frame_paths):
|
|
89
|
+
img = Image.open(frame_path)
|
|
90
|
+
w, h = img.size
|
|
91
|
+
|
|
92
|
+
frame_entries.append({
|
|
93
|
+
"file": frame_path.name,
|
|
94
|
+
"width": w,
|
|
95
|
+
"height": h,
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
if idx == 0:
|
|
99
|
+
reference_size = [w, h]
|
|
100
|
+
elif [w, h] != reference_size:
|
|
101
|
+
mismatched.append(frame_path.name)
|
|
102
|
+
|
|
103
|
+
# ------------------------------------------------------------------
|
|
104
|
+
# Información del spritesheet (opcional)
|
|
105
|
+
# ------------------------------------------------------------------
|
|
106
|
+
spritesheet_size = None
|
|
107
|
+
sprite_path_str = None
|
|
108
|
+
|
|
109
|
+
if spritesheet_path is not None:
|
|
110
|
+
sprite_path = Path(spritesheet_path)
|
|
111
|
+
sprite_img = Image.open(sprite_path)
|
|
112
|
+
spritesheet_size = list(sprite_img.size)
|
|
113
|
+
sprite_path_str = str(sprite_path)
|
|
114
|
+
|
|
115
|
+
# ------------------------------------------------------------------
|
|
116
|
+
# Construir y guardar el reporte
|
|
117
|
+
# ------------------------------------------------------------------
|
|
118
|
+
report = {
|
|
119
|
+
"total_frames": len(frame_paths),
|
|
120
|
+
"frames": frame_entries,
|
|
121
|
+
"consistent_dimensions": len(mismatched) == 0,
|
|
122
|
+
"reference_size": reference_size,
|
|
123
|
+
"mismatched_frames": mismatched,
|
|
124
|
+
"spritesheet_path": sprite_path_str,
|
|
125
|
+
"spritesheet_size": spritesheet_size,
|
|
126
|
+
"generated_at": datetime.now().isoformat(),
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
output_path = Path(output_file)
|
|
130
|
+
|
|
131
|
+
if output_path.suffix == "":
|
|
132
|
+
output_path = output_path.with_suffix(".json")
|
|
133
|
+
|
|
134
|
+
if output_path.exists():
|
|
135
|
+
print(f"Warning: The file '{output_file}' already exists and will be overwritten.")
|
|
136
|
+
|
|
137
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
138
|
+
|
|
139
|
+
with open(output_path, "w", encoding="utf-8") as f:
|
|
140
|
+
json.dump(report, f, indent=2)
|
|
141
|
+
|
|
142
|
+
return output_path
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import math
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from PIL import Image
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def create_spritesheet_grid(frames=None, frames_dir=None, columns=None, padding=0,
|
|
8
|
+
background=(0, 0, 0, 0), output_file="spritesheet_grid.png"):
|
|
9
|
+
"""Arrange frames into a grid spritesheet (rows × columns).
|
|
10
|
+
|
|
11
|
+
Parameters
|
|
12
|
+
----------
|
|
13
|
+
frames : list of Path or str, optional
|
|
14
|
+
List of paths to individual frame images.
|
|
15
|
+
frames_dir : Path or str, optional
|
|
16
|
+
Directory containing PNG frames. Mutually exclusive with *frames*.
|
|
17
|
+
columns : int, optional
|
|
18
|
+
Number of columns in the grid. If ``None`` it is auto-calculated as
|
|
19
|
+
``ceil(sqrt(total_frames))``.
|
|
20
|
+
padding : int
|
|
21
|
+
Pixels of empty space between adjacent cells. Default 0.
|
|
22
|
+
background : tuple of int
|
|
23
|
+
RGBA quadruplet used to fill the background canvas and any unused
|
|
24
|
+
cells. Default ``(0, 0, 0, 0)`` (fully transparent).
|
|
25
|
+
output_file : Path or str
|
|
26
|
+
Path for the generated spritesheet. Default ``"spritesheet_grid.png"``.
|
|
27
|
+
|
|
28
|
+
Returns
|
|
29
|
+
-------
|
|
30
|
+
Path
|
|
31
|
+
Path to the generated grid spritesheet.
|
|
32
|
+
|
|
33
|
+
Raises
|
|
34
|
+
------
|
|
35
|
+
ValueError
|
|
36
|
+
If neither *frames* nor *frames_dir* is provided, if both are
|
|
37
|
+
provided, if *frames_dir* points to an empty directory, if
|
|
38
|
+
*columns* is not an integer ≥ 1, or if a frame has a different
|
|
39
|
+
size than the first frame.
|
|
40
|
+
FileNotFoundError
|
|
41
|
+
If *frames_dir* does not exist.
|
|
42
|
+
NotADirectoryError
|
|
43
|
+
If *frames_dir* is a file, not a directory.
|
|
44
|
+
"""
|
|
45
|
+
# ------------------------------------------------------------------
|
|
46
|
+
# Validación de entrada (mismo estilo que create_spritesheet_lineal)
|
|
47
|
+
# ------------------------------------------------------------------
|
|
48
|
+
if frames is None and frames_dir is None:
|
|
49
|
+
raise ValueError("Either 'frames' or 'frames_dir' must be provided.")
|
|
50
|
+
|
|
51
|
+
if frames is not None and frames_dir is not None:
|
|
52
|
+
raise ValueError("Only one of 'frames' or 'frames_dir' should be provided, not both.")
|
|
53
|
+
|
|
54
|
+
if frames_dir is not None:
|
|
55
|
+
frames_path = Path(frames_dir)
|
|
56
|
+
|
|
57
|
+
if not frames_path.exists():
|
|
58
|
+
raise FileNotFoundError(f"The directory '{frames_dir}' does not exist.")
|
|
59
|
+
|
|
60
|
+
if not frames_path.is_dir():
|
|
61
|
+
raise NotADirectoryError(f"The path '{frames_dir}' is not a directory.")
|
|
62
|
+
|
|
63
|
+
frames = sorted(frames_path.glob("*.png"))
|
|
64
|
+
|
|
65
|
+
if not frames:
|
|
66
|
+
raise ValueError("No frames found to create the spritesheet.")
|
|
67
|
+
|
|
68
|
+
# ------------------------------------------------------------------
|
|
69
|
+
# Validación de columns
|
|
70
|
+
# ------------------------------------------------------------------
|
|
71
|
+
if columns is None:
|
|
72
|
+
columns = math.ceil(math.sqrt(len(frames)))
|
|
73
|
+
elif not isinstance(columns, int) or columns < 1:
|
|
74
|
+
raise ValueError("columns must be an integer >= 1.")
|
|
75
|
+
|
|
76
|
+
# ------------------------------------------------------------------
|
|
77
|
+
# Calcular dimensiones del grid
|
|
78
|
+
# ------------------------------------------------------------------
|
|
79
|
+
frame_paths = [Path(f) for f in frames]
|
|
80
|
+
|
|
81
|
+
first_frame_path = frame_paths[0]
|
|
82
|
+
if not first_frame_path.exists():
|
|
83
|
+
raise FileNotFoundError(f"The frame '{first_frame_path}' does not exist.")
|
|
84
|
+
|
|
85
|
+
first_frame = Image.open(first_frame_path)
|
|
86
|
+
frame_width, frame_height = first_frame.size
|
|
87
|
+
|
|
88
|
+
total_frames = len(frame_paths)
|
|
89
|
+
rows = math.ceil(total_frames / columns)
|
|
90
|
+
|
|
91
|
+
sheet_width = columns * frame_width + padding * (columns - 1)
|
|
92
|
+
sheet_height = rows * frame_height + padding * (rows - 1)
|
|
93
|
+
|
|
94
|
+
spritesheet = Image.new("RGBA", (sheet_width, sheet_height), background)
|
|
95
|
+
|
|
96
|
+
# ------------------------------------------------------------------
|
|
97
|
+
# Colocar frames en el grid
|
|
98
|
+
# ------------------------------------------------------------------
|
|
99
|
+
for index, frame_path in enumerate(frame_paths):
|
|
100
|
+
frame_path = Path(frame_path)
|
|
101
|
+
|
|
102
|
+
if not frame_path.exists():
|
|
103
|
+
raise FileNotFoundError(f"The frame '{frame_path}' does not exist.")
|
|
104
|
+
|
|
105
|
+
frame = Image.open(frame_path)
|
|
106
|
+
|
|
107
|
+
if frame.size != (frame_width, frame_height):
|
|
108
|
+
raise ValueError(f"Frame '{frame_path}' has a different size than the first frame.")
|
|
109
|
+
|
|
110
|
+
col = index % columns
|
|
111
|
+
row = index // columns
|
|
112
|
+
x = col * (frame_width + padding)
|
|
113
|
+
y = row * (frame_height + padding)
|
|
114
|
+
|
|
115
|
+
spritesheet.paste(frame, (x, y))
|
|
116
|
+
|
|
117
|
+
# ------------------------------------------------------------------
|
|
118
|
+
# Guardar (mismo patrón que spritesheet_lineal)
|
|
119
|
+
# ------------------------------------------------------------------
|
|
120
|
+
output_path = Path(output_file)
|
|
121
|
+
|
|
122
|
+
if output_path.suffix == "":
|
|
123
|
+
output_path = output_path.with_suffix(".png")
|
|
124
|
+
|
|
125
|
+
if output_path.exists():
|
|
126
|
+
print(f"Warning: The file '{output_file}' already exists and will be overwritten.")
|
|
127
|
+
|
|
128
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
129
|
+
|
|
130
|
+
spritesheet.save(output_path)
|
|
131
|
+
|
|
132
|
+
return output_path
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from PIL import Image
|
|
3
|
+
|
|
4
|
+
def create_spritesheet_lineal(frames=None, frames_dir=None, output_file="spritesheet.png"):
|
|
5
|
+
if frames is None and frames_dir is None:
|
|
6
|
+
raise ValueError("Either 'frames' or 'frames_dir' must be provided.")
|
|
7
|
+
|
|
8
|
+
if frames is not None and frames_dir is not None:
|
|
9
|
+
raise ValueError("Only one of 'frames' or 'frames_dir' should be provided, not both.")
|
|
10
|
+
|
|
11
|
+
if frames_dir is not None:
|
|
12
|
+
frames_path = Path(frames_dir)
|
|
13
|
+
|
|
14
|
+
if not frames_path.exists():
|
|
15
|
+
raise FileNotFoundError(f"The directory '{frames_dir}' does not exist.")
|
|
16
|
+
|
|
17
|
+
if not frames_path.is_dir():
|
|
18
|
+
raise NotADirectoryError(f"The path '{frames_dir}' is not a directory.")
|
|
19
|
+
|
|
20
|
+
frames = sorted(frames_path.glob("*.png"))
|
|
21
|
+
|
|
22
|
+
if not frames:
|
|
23
|
+
raise ValueError("No frames found to create the spritesheet.")
|
|
24
|
+
|
|
25
|
+
first_frame_path = Path(frames[0])
|
|
26
|
+
|
|
27
|
+
if not first_frame_path.exists():
|
|
28
|
+
raise FileNotFoundError(f"The frame '{frames[0]}' does not exist.")
|
|
29
|
+
|
|
30
|
+
first_frame = Image.open(first_frame_path)
|
|
31
|
+
frame_width, frame_height = first_frame.size
|
|
32
|
+
|
|
33
|
+
total_frames = len(frames)
|
|
34
|
+
|
|
35
|
+
sheet_width = frame_width * total_frames
|
|
36
|
+
sheet_height = frame_height
|
|
37
|
+
|
|
38
|
+
spritesheet = Image.new("RGBA", (sheet_width, sheet_height))
|
|
39
|
+
|
|
40
|
+
for index, frame_path in enumerate(frames):
|
|
41
|
+
frame_path = Path(frame_path)
|
|
42
|
+
|
|
43
|
+
if not frame_path.exists():
|
|
44
|
+
raise FileNotFoundError(f"The frame '{frame_path}' does not exist.")
|
|
45
|
+
|
|
46
|
+
frame = Image.open(frame_path)
|
|
47
|
+
|
|
48
|
+
if frame.size != (frame_width, frame_height):
|
|
49
|
+
raise ValueError(f"Frame '{frame_path}' has a different size than the first frame.")
|
|
50
|
+
|
|
51
|
+
x_position = index * frame_width
|
|
52
|
+
y_position = 0
|
|
53
|
+
|
|
54
|
+
spritesheet.paste(frame, (x_position, y_position))
|
|
55
|
+
|
|
56
|
+
output_path = Path(output_file)
|
|
57
|
+
|
|
58
|
+
if output_path.suffix == "":
|
|
59
|
+
output_path = output_path.with_suffix(".png")
|
|
60
|
+
|
|
61
|
+
if output_path.exists():
|
|
62
|
+
print(f"Warning: The file '{output_file}' already exists and will be overwritten.")
|
|
63
|
+
|
|
64
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
65
|
+
|
|
66
|
+
spritesheet.save(output_path)
|
|
67
|
+
|
|
68
|
+
return output_path
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: spriteworkflow
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Simple video to spritesheet workflow library.
|
|
5
|
+
Author-email: jhonvillanueva-hash <jhon.villanueva.lozano.v@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/jhonvillanueva-hash/spriteworkflow
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Operating System :: OS Independent
|
|
10
|
+
Requires-Python: >=3.10
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Requires-Dist: opencv-python>=4.8.0
|
|
14
|
+
Requires-Dist: pillow>=10.0.0
|
|
15
|
+
Requires-Dist: imageio-ffmpeg>=0.6.0
|
|
16
|
+
Provides-Extra: test
|
|
17
|
+
Requires-Dist: pytest>=7.0; extra == "test"
|
|
18
|
+
Provides-Extra: dev
|
|
19
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
20
|
+
Dynamic: license-file
|
|
21
|
+
|
|
22
|
+
# SpriteWorkflow
|
|
23
|
+
|
|
24
|
+
Biblioteca Python para convertir videos en spritesheets (hojas de sprites).
|
|
25
|
+
Extrae fotogramas de un video, opcionalmente remueve el fondo por chroma-key,
|
|
26
|
+
y los ensambla en un spritesheet lineal (horizontal) o en cuadrícula (grid).
|
|
27
|
+
Incluye un reporte de validación en formato JSON.
|
|
28
|
+
|
|
29
|
+
## Instalación
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pip install spriteworkflow
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Requiere **Python ≥ 3.10** y dependencias: `opencv-python`, `pillow`, `imageio-ffmpeg`.
|
|
36
|
+
|
|
37
|
+
### Dependencias de sistema
|
|
38
|
+
|
|
39
|
+
- **FFmpeg**: necesario para la extracción de fotogramas. La biblioteca lo
|
|
40
|
+
localiza automáticamente con `imageio-ffmpeg`, pero si no está disponible
|
|
41
|
+
puedes instalarlo manualmente:
|
|
42
|
+
- Windows: `winget install ffmpeg`
|
|
43
|
+
- macOS: `brew install ffmpeg`
|
|
44
|
+
- Linux: `sudo apt install ffmpeg`
|
|
45
|
+
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
## Funciones de la biblioteca
|
|
49
|
+
|
|
50
|
+
### `extract_frames(video_name, output_dir="temp")`
|
|
51
|
+
|
|
52
|
+
Extrae fotogramas de un video en archivos PNG numerados.
|
|
53
|
+
|
|
54
|
+
| Parámetro | Tipo | Default | Descripción |
|
|
55
|
+
|----------------|-------------------|-----------|------------------------------------------|
|
|
56
|
+
| `video_name` | `str` o `Path` | — | Ruta al archivo de video |
|
|
57
|
+
| `output_dir` | `str` o `Path` | `"temp"` | Directorio de salida (se crea si no existe) |
|
|
58
|
+
|
|
59
|
+
- **Retorna**: `list[Path]` — rutas a los PNG extraídos, ordenados.
|
|
60
|
+
- **Excepciones**: `FileNotFoundError` (video no existe),
|
|
61
|
+
`ValueError` (archivo inválido o vacío), `PermissionError` (sin permiso),
|
|
62
|
+
`RuntimeError` (FFmpeg falla o no se extrajeron fotogramas).
|
|
63
|
+
|
|
64
|
+
### `remove_background(frames=None, frames_dir=None, bg_color=None, tolerance=30, feather=0, output_dir="matted")`
|
|
65
|
+
|
|
66
|
+
Elimina el fondo de fotogramas mediante chroma-key (distancia euclidiana en RGB).
|
|
67
|
+
|
|
68
|
+
| Parámetro | Tipo | Default | Descripción |
|
|
69
|
+
|---------------|-------------------|-------------|------------------------------------------------|
|
|
70
|
+
| `frames` | `list[Path]` | `None` | Lista de rutas a los fotogramas PNG |
|
|
71
|
+
| `frames_dir` | `str` o `Path` | `None` | Directorio con PNG (alternativa a `frames`) |
|
|
72
|
+
| `bg_color` | `tuple[int,int,int]` | `None` | RGB del chroma-key; auto-detectado si es `None`|
|
|
73
|
+
| `tolerance` | `int` | `30` | Distancia euclídea máxima del color de fondo |
|
|
74
|
+
| `feather` | `int` | `0` | Ancho del difuminado lineal alrededor de `tolerance`|
|
|
75
|
+
| `output_dir` | `str` o `Path` | `"matted"` | Directorio para los PNG con canal alpha |
|
|
76
|
+
|
|
77
|
+
- **Retorna**: `list[Path]` — rutas a los PNG procesados (RGBA), ordenados.
|
|
78
|
+
- **Excepciones**: `ValueError` (parámetros inválidos),
|
|
79
|
+
`FileNotFoundError` (`frames_dir` no existe),
|
|
80
|
+
`NotADirectoryError` (`frames_dir` no es directorio).
|
|
81
|
+
|
|
82
|
+
### `create_spritesheet_lineal(frames=None, frames_dir=None, output_file="spritesheet.png")`
|
|
83
|
+
|
|
84
|
+
Ensambla fotogramas en un spritesheet horizontal (una fila).
|
|
85
|
+
|
|
86
|
+
| Parámetro | Tipo | Default | Descripción |
|
|
87
|
+
|----------------|-------------------|----------------------|------------------------------------------|
|
|
88
|
+
| `frames` | `list[Path]` | `None` | Lista de rutas a los fotogramas PNG |
|
|
89
|
+
| `frames_dir` | `str` o `Path` | `None` | Directorio con PNG (alternativa a `frames`)|
|
|
90
|
+
| `output_file` | `str` o `Path` | `"spritesheet.png"` | Ruta del spritesheet generado |
|
|
91
|
+
|
|
92
|
+
- **Retorna**: `Path` — ruta al archivo generado.
|
|
93
|
+
- **Excepciones**: `ValueError`, `FileNotFoundError`, `NotADirectoryError`.
|
|
94
|
+
|
|
95
|
+
### `create_spritesheet_grid(frames=None, frames_dir=None, columns=None, padding=0, background=(0,0,0,0), output_file="spritesheet_grid.png")`
|
|
96
|
+
|
|
97
|
+
Ensambla fotogramas en un spritesheet en cuadrícula (filas × columnas).
|
|
98
|
+
|
|
99
|
+
| Parámetro | Tipo | Default | Descripción |
|
|
100
|
+
|----------------|--------------------|----------------------------|------------------------------------------|
|
|
101
|
+
| `frames` | `list[Path]` | `None` | Lista de rutas a los fotogramas PNG |
|
|
102
|
+
| `frames_dir` | `str` o `Path` | `None` | Directorio con PNG (alternativa a `frames`)|
|
|
103
|
+
| `columns` | `int` o `None` | `None` | N° de columnas; `None` = `ceil(sqrt(N))`|
|
|
104
|
+
| `padding` | `int` | `0` | Píxeles de espacio entre celdas |
|
|
105
|
+
| `background` | `tuple[int,int,int,int]` | `(0,0,0,0)` | RGBA de relleno para celdas vacías |
|
|
106
|
+
| `output_file` | `str` o `Path` | `"spritesheet_grid.png"` | Ruta del spritesheet generado |
|
|
107
|
+
|
|
108
|
+
- **Retorna**: `Path` — ruta al archivo generado.
|
|
109
|
+
- **Excepciones**: `ValueError`, `FileNotFoundError`, `NotADirectoryError`.
|
|
110
|
+
|
|
111
|
+
### `generate_report(frames=None, frames_dir=None, spritesheet_path=None, output_file="report.json")`
|
|
112
|
+
|
|
113
|
+
Inspecciona fotogramas y produce un reporte de validación JSON.
|
|
114
|
+
|
|
115
|
+
| Parámetro | Tipo | Default | Descripción |
|
|
116
|
+
|--------------------|-------------------|----------------|------------------------------------------|
|
|
117
|
+
| `frames` | `list[Path]` | `None` | Lista de rutas a los fotogramas PNG |
|
|
118
|
+
| `frames_dir` | `str` o `Path` | `None` | Directorio con PNG (alternativa) |
|
|
119
|
+
| `spritesheet_path` | `str` o `Path` | `None` | Ruta al spritesheet (opcional) |
|
|
120
|
+
| `output_file` | `str` o `Path` | `"report.json"`| Ruta del archivo JSON generado |
|
|
121
|
+
|
|
122
|
+
- **Retorna**: `Path` — ruta al archivo JSON.
|
|
123
|
+
- **Excepciones**: `ValueError`, `FileNotFoundError`, `NotADirectoryError`.
|
|
124
|
+
|
|
125
|
+
El reporte incluye: `total_frames`, `frames[]` (archivo, ancho, alto),
|
|
126
|
+
`consistent_dimensions`, `reference_size`, `mismatched_frames`,
|
|
127
|
+
`spritesheet_path`, `spritesheet_size`, y `generated_at`.
|
|
128
|
+
|
|
129
|
+
---
|
|
130
|
+
|
|
131
|
+
## Ejemplo de uso (como biblioteca)
|
|
132
|
+
|
|
133
|
+
```python
|
|
134
|
+
from spriteworkflow import extract_frames, remove_background
|
|
135
|
+
from spriteworkflow import create_spritesheet_grid, generate_report
|
|
136
|
+
|
|
137
|
+
# 1. Extraer fotogramas de un video
|
|
138
|
+
frames = extract_frames("gameplay.mp4", output_dir="temp")
|
|
139
|
+
print(f"Extraídos {len(frames)} fotogramas")
|
|
140
|
+
|
|
141
|
+
# 2. Remover fondo verde (chroma-key)
|
|
142
|
+
frames_matted = remove_background(
|
|
143
|
+
frames=frames,
|
|
144
|
+
bg_color=(0, 255, 0),
|
|
145
|
+
tolerance=40,
|
|
146
|
+
output_dir="matted",
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
# 3. Crear spritesheet en grilla (3 columnas)
|
|
150
|
+
sheet = create_spritesheet_grid(
|
|
151
|
+
frames=frames_matted,
|
|
152
|
+
columns=3,
|
|
153
|
+
padding=2,
|
|
154
|
+
background=(0, 0, 0, 0),
|
|
155
|
+
output_file="hoja_sprites.png",
|
|
156
|
+
)
|
|
157
|
+
print(f"Spritesheet guardado: {sheet}")
|
|
158
|
+
|
|
159
|
+
# 4. Generar reporte de validación
|
|
160
|
+
report = generate_report(
|
|
161
|
+
frames=frames_matted,
|
|
162
|
+
spritesheet_path=sheet,
|
|
163
|
+
output_file="reporte.json",
|
|
164
|
+
)
|
|
165
|
+
print(f"Reporte guardado: {report}")
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
---
|
|
169
|
+
|
|
170
|
+
## Uso desde CLI
|
|
171
|
+
|
|
172
|
+
El paquete instala el comando `spriteworkflow` con un subcomando `build` que
|
|
173
|
+
encadena el pipeline completo:
|
|
174
|
+
|
|
175
|
+
```bash
|
|
176
|
+
spriteworkflow build <video> [opciones]
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
### Argumentos
|
|
180
|
+
|
|
181
|
+
| Argumento / Flag | Descripción | Default |
|
|
182
|
+
|-------------------------|-----------------------------------------------|-------------------------|
|
|
183
|
+
| `video` | Ruta al video de entrada (requerido) | — |
|
|
184
|
+
| `--output`, `-o` | Ruta del spritesheet generado | `spritesheet.png` |
|
|
185
|
+
| `--layout` | Lineal o grid (`lineal`, `grid`) | `lineal` |
|
|
186
|
+
| `--columns` | Columnas para grilla (auto si no se indica) | auto `ceil(sqrt(N))` |
|
|
187
|
+
| `--padding` | Píxeles de separación entre celdas | `0` |
|
|
188
|
+
| `--matte` | Activa remoción de fondo por chroma-key | desactivado |
|
|
189
|
+
| `--bg-color` | RGB para chroma-key, ej: `--bg-color 0 255 0`| auto-detectado |
|
|
190
|
+
| `--tolerance` | Tolerancia euclídea de chroma-key | `30` |
|
|
191
|
+
| `--feather` | Difuminado del borde del chroma-key | `0` |
|
|
192
|
+
| `--report-file` | Ruta del reporte JSON de validación | no se genera |
|
|
193
|
+
| `--temp-dir` | Directorio temporal para fotogramas extraídos | `temp` |
|
|
194
|
+
|
|
195
|
+
### Ejemplos
|
|
196
|
+
|
|
197
|
+
```bash
|
|
198
|
+
# Pipeline mínimo: extraer y ensamblar spritesheet lineal
|
|
199
|
+
spriteworkflow build partida.mp4
|
|
200
|
+
|
|
201
|
+
# Especificar ruta de salida
|
|
202
|
+
spriteworkflow build partida.mp4 -o assets/hoja.png
|
|
203
|
+
|
|
204
|
+
# Spritesheet en grilla de 4 columnas con padding
|
|
205
|
+
spriteworkflow build partida.mp4 --layout grid --columns 4 --padding 2
|
|
206
|
+
|
|
207
|
+
# Remover chroma-key verde, luego ensamblar spritesheet
|
|
208
|
+
spriteworkflow build partida.mp4 --matte --bg-color 0 255 0
|
|
209
|
+
|
|
210
|
+
# Pipeline completo con reporte
|
|
211
|
+
spriteworkflow build partida.mp4 \
|
|
212
|
+
--layout grid --columns 4 --padding 2 \
|
|
213
|
+
--matte --tolerance 40 \
|
|
214
|
+
--report-file reporte.json
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
## Ejecutar pruebas
|
|
218
|
+
|
|
219
|
+
El proyecto usa `pytest`. Para ejecutar la suite completa:
|
|
220
|
+
|
|
221
|
+
```bash
|
|
222
|
+
pip install -e ".[test]"
|
|
223
|
+
pytest -v
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
Para ejecutar un archivo de pruebas específico:
|
|
227
|
+
|
|
228
|
+
```bash
|
|
229
|
+
pytest tests/cli_test.py -v
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
---
|
|
233
|
+
|
|
234
|
+
## Licencia
|
|
235
|
+
|
|
236
|
+
MIT
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
spriteworkflow/__init__.py,sha256=nSIW9LISN71kS-YUGl-ALiRN8X2i48smgB7KjYZFrR0,828
|
|
2
|
+
spriteworkflow/cli.py,sha256=2J1NLSy4nqvdSN9NG4NDGkDxNFp7YjF8w9x8HVGPWXc,5869
|
|
3
|
+
spriteworkflow/extractor.py,sha256=kMcXwfGGYgbF2d-TYFY0sGKb-sIv3wMzRcyxF5lpvAI,1847
|
|
4
|
+
spriteworkflow/ffmpeg_manager.py,sha256=OUceUEx6VdTqB7IpA2_hg5u0mVmp_kO18OAqnLgtYD8,250
|
|
5
|
+
spriteworkflow/matte.py,sha256=euZfqo2Qd4y2UhJGJFjEMxz5S6o0P8N4y1ehbHjC5sc,5900
|
|
6
|
+
spriteworkflow/report.py,sha256=hRVxP5nB5j7aAvIYILJBuRQRs5OJRaBxLeDwVg8hLSM,5144
|
|
7
|
+
spriteworkflow/spritesheet_grid.py,sha256=f3StB0JOk-VyB9mryvnhv3R2MKFkgg5qM0AlzhQ2bnQ,4976
|
|
8
|
+
spriteworkflow/spritesheet_lineal.py,sha256=DkVRTDyPBOmHllwmdAzD6CdCIcDdhBrmnq86Ta5EMFE,2365
|
|
9
|
+
spriteworkflow-0.1.0.dist-info/licenses/LICENSE,sha256=Md8q0sNdvz6S9YWEeOpWmx-D13KRlsObRU_vO8bo0wg,1076
|
|
10
|
+
spriteworkflow-0.1.0.dist-info/METADATA,sha256=1Emo4MNKKRu5u6lj7whfUx5aq_v4GofYKbGavkuPf60,10276
|
|
11
|
+
spriteworkflow-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
12
|
+
spriteworkflow-0.1.0.dist-info/entry_points.txt,sha256=bQKlkkSyEbCjGfG9g2vnGbJ4BUZBr9EM14FlVQ83GoM,59
|
|
13
|
+
spriteworkflow-0.1.0.dist-info/top_level.txt,sha256=oc7QpLj3pf83efWq9YEbzn7KrUwMsLjRSYUV7i7HmfM,15
|
|
14
|
+
spriteworkflow-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 jhonvillanueva-hash
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
spriteworkflow
|