spritegen-cli 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.
- spritegen/__init__.py +3 -0
- spritegen/atlas.py +99 -0
- spritegen/cli.py +178 -0
- spritegen/clip.py +81 -0
- spritegen/drive.py +163 -0
- spritegen/endpoints.py +113 -0
- spritegen/fal.py +228 -0
- spritegen/imaging.py +219 -0
- spritegen/ledger.py +143 -0
- spritegen/matting.py +136 -0
- spritegen/migrate.py +162 -0
- spritegen/prompts.py +96 -0
- spritegen/rrdb.py +91 -0
- spritegen/settings.py +127 -0
- spritegen/sheet.py +370 -0
- spritegen/skill/__init__.py +303 -0
- spritegen/skill/files/SKILL.md +553 -0
- spritegen/stages/__init__.py +490 -0
- spritegen/stages/anchor.py +215 -0
- spritegen/stages/board.py +68 -0
- spritegen/stages/matte.py +209 -0
- spritegen/stages/motion.py +164 -0
- spritegen/stages/pose.py +172 -0
- spritegen/stages/video.py +196 -0
- spritegen/upscale.py +444 -0
- spritegen/workspace.py +852 -0
- spritegen_cli-0.1.0.dist-info/METADATA +16 -0
- spritegen_cli-0.1.0.dist-info/RECORD +30 -0
- spritegen_cli-0.1.0.dist-info/WHEEL +4 -0
- spritegen_cli-0.1.0.dist-info/entry_points.txt +2 -0
spritegen/matting.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Cutting the background locally, on the GPU, instead of paying for it.
|
|
2
|
+
|
|
3
|
+
The paid matte is a segmentation model behind an endpoint. So is this one: `rembg` runs
|
|
4
|
+
BiRefNet — the same family the endpoint's `Matting` variant is — through onnxruntime, and
|
|
5
|
+
on a machine with CUDA the difference is seconds and no invoice. On a machine without
|
|
6
|
+
one it still runs, slower, on the CPU.
|
|
7
|
+
|
|
8
|
+
**This is not the chroma cut.** `imaging.cut_chroma` removes a colour the prompt asked
|
|
9
|
+
for; that is exact where the prompt was obeyed and useless where it was not. This asks a
|
|
10
|
+
model what the subject is, which is what hair, a blade edge and the gap under an arm
|
|
11
|
+
need — and it works on art that was never generated against a key field at all.
|
|
12
|
+
|
|
13
|
+
`rembg` is an optional extra, for the reason `torch` is: it brings onnxruntime with it,
|
|
14
|
+
and which build works depends on the machine's CUDA. Every other command keeps working
|
|
15
|
+
without it, and the refusal names the install line.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
#: The default weights. BiRefNet general is the same family as the paid endpoint's
|
|
23
|
+
#: `Matting` variant, and gives continuous alpha rather than a hard mask — which is what
|
|
24
|
+
#: hair and a blade edge need.
|
|
25
|
+
DEFAULT_MODEL = "birefnet-general"
|
|
26
|
+
|
|
27
|
+
INSTALL = (
|
|
28
|
+
"the local matte needs rembg, which is an optional extra: "
|
|
29
|
+
'pip install "spritegen-cli[matte]" (and "rembg[gpu]" for CUDA)'
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def require_rembg():
|
|
34
|
+
"""`rembg`, or an error naming the one command that fixes it."""
|
|
35
|
+
try:
|
|
36
|
+
import rembg
|
|
37
|
+
except ModuleNotFoundError as exc:
|
|
38
|
+
raise NotImplementedError(INSTALL) from exc
|
|
39
|
+
return rembg
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def available() -> bool:
|
|
43
|
+
"""Whether the local matte could run. Used to report, never to decide silently."""
|
|
44
|
+
try:
|
|
45
|
+
require_rembg()
|
|
46
|
+
except NotImplementedError:
|
|
47
|
+
return False
|
|
48
|
+
return True
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def providers() -> list[str]:
|
|
52
|
+
"""What onnxruntime says it can run on, in its own order of preference.
|
|
53
|
+
|
|
54
|
+
Reported by the command rather than assumed: a `rembg` installed without the GPU
|
|
55
|
+
build runs on the CPU and looks like a hang on a board of six frames.
|
|
56
|
+
"""
|
|
57
|
+
try:
|
|
58
|
+
import onnxruntime
|
|
59
|
+
except ModuleNotFoundError:
|
|
60
|
+
return []
|
|
61
|
+
return list(onnxruntime.get_available_providers())
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def on_gpu() -> bool:
|
|
65
|
+
return any(name.startswith(("CUDA", "ROCM")) for name in providers())
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def known_models() -> tuple[str, ...]:
|
|
69
|
+
"""The model names this rembg has, or empty if it will not say."""
|
|
70
|
+
require_rembg()
|
|
71
|
+
try:
|
|
72
|
+
from rembg.sessions import sessions_names
|
|
73
|
+
except ImportError:
|
|
74
|
+
return ()
|
|
75
|
+
return tuple(sessions_names)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def check_model(model: str) -> str:
|
|
79
|
+
"""`model` if this rembg has it, and an error naming what it does have if not.
|
|
80
|
+
|
|
81
|
+
Checked here rather than left to `new_session`. The name can arrive from a `.env`
|
|
82
|
+
two directories up as easily as from the command line, it is an unconstrained string
|
|
83
|
+
either way, and what a third-party lookup does with a name it does not recognise is
|
|
84
|
+
not this tool's decision to inherit. An installed rembg that will not enumerate its
|
|
85
|
+
models gets the benefit of the doubt rather than a refusal it cannot satisfy.
|
|
86
|
+
"""
|
|
87
|
+
known = known_models()
|
|
88
|
+
if known and model not in known:
|
|
89
|
+
raise ValueError(f"unknown matte model {model!r}; this rembg has: {', '.join(known)}")
|
|
90
|
+
return model
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def session(model: str | None = None):
|
|
94
|
+
"""One rembg session, reused across every frame of a board.
|
|
95
|
+
|
|
96
|
+
Built once and passed in, because loading the weights is most of the cost: matting
|
|
97
|
+
six frames with six sessions pays for the model six times.
|
|
98
|
+
"""
|
|
99
|
+
from . import settings
|
|
100
|
+
|
|
101
|
+
rembg = require_rembg()
|
|
102
|
+
return rembg.new_session(check_model(model or settings.load().matte_model or DEFAULT_MODEL))
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def cut(image, *, current=None, refine: bool = True, model: str | None = None):
|
|
106
|
+
"""One image with its background gone. Returns RGBA.
|
|
107
|
+
|
|
108
|
+
`refine` is the edge decontamination, and it is what kills the coloured halo — the
|
|
109
|
+
same decision the paid stage makes with `--no-refine`, named the same way so the two
|
|
110
|
+
backends do not need two vocabularies. Turn it off when the figure is losing its own
|
|
111
|
+
outline rather than gaining a fringe.
|
|
112
|
+
"""
|
|
113
|
+
rembg = require_rembg()
|
|
114
|
+
return rembg.remove(
|
|
115
|
+
image.convert("RGBA"),
|
|
116
|
+
session=current if current is not None else session(model),
|
|
117
|
+
post_process_mask=True,
|
|
118
|
+
decontaminate=refine,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def cut_file(source: Path, target: Path, *, current=None, refine: bool = True) -> dict:
|
|
123
|
+
"""Cut one file to another, and report what the alpha came out like.
|
|
124
|
+
|
|
125
|
+
The measurement is `imaging.measure_alpha`, the same one the paid stage reports, and
|
|
126
|
+
it is how a matte that worked is told from one that handed back the picture untouched:
|
|
127
|
+
an image with no transparent pixel in it did not get cut, whatever anything claimed.
|
|
128
|
+
"""
|
|
129
|
+
from PIL import Image
|
|
130
|
+
|
|
131
|
+
from .imaging import measure_alpha
|
|
132
|
+
|
|
133
|
+
result = cut(Image.open(source), current=current, refine=refine)
|
|
134
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
135
|
+
result.save(target)
|
|
136
|
+
return measure_alpha(target)
|
spritegen/migrate.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""Move a workspace from one on-disk layout to the next — R1.5.
|
|
2
|
+
|
|
3
|
+
Layout 1 was a directory per stage: `anchor/`, `board/`, `matte/`, `row/`. Layout 2 is a
|
|
4
|
+
directory per artifact kind, with art directions under `sheet/`. The names changed
|
|
5
|
+
because the two questions they were answering had come apart — see the spec.
|
|
6
|
+
|
|
7
|
+
**Nothing migrates on its own.** A command that reorganises somebody's disk in the middle
|
|
8
|
+
of another command is exactly what nobody wants to find out about afterwards, and these
|
|
9
|
+
are directories that paid calls produced. So this is its own command, it prints what it
|
|
10
|
+
would do before it does anything, and `--dry-run` is how you see that without the move.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from . import workspace
|
|
19
|
+
|
|
20
|
+
#: The old directory, and the artifact directory it becomes. `row` carries a variant:
|
|
21
|
+
#: layout 1 had one row per sprite because it had no idea an art direction existed, and
|
|
22
|
+
#: the one it has is whatever was closed — `as-is` is the honest name for that.
|
|
23
|
+
MOVES: tuple[tuple[str, str], ...] = (
|
|
24
|
+
("anchor", "anchor"),
|
|
25
|
+
("board", "frames"),
|
|
26
|
+
("matte", "cutout"),
|
|
27
|
+
("row", "sheet/as-is"),
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
#: How a layout-1 stage entry named what it produced, and the artifact key it is now.
|
|
31
|
+
PRODUCED: dict[str, str] = {
|
|
32
|
+
"anchor": "anchor",
|
|
33
|
+
"board": "frames",
|
|
34
|
+
"matte": "cutout",
|
|
35
|
+
"row": "sheet:as-is",
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def planned(asset: str) -> list[tuple[Path, Path]]:
|
|
40
|
+
"""The moves this asset needs, in order. Empty when there is nothing to do."""
|
|
41
|
+
directory = workspace.asset_dir(asset)
|
|
42
|
+
moves = []
|
|
43
|
+
for old, new in MOVES:
|
|
44
|
+
source = directory / old
|
|
45
|
+
target = directory / new
|
|
46
|
+
if source.is_dir() and source != target and not target.exists():
|
|
47
|
+
moves.append((source, target))
|
|
48
|
+
return moves
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def blocked(asset: str) -> list[tuple[Path, Path]]:
|
|
52
|
+
"""The moves this asset needs and cannot make, because something is already there.
|
|
53
|
+
|
|
54
|
+
Rare, and it has to stop the migration rather than be skipped. Rewriting the state
|
|
55
|
+
while the files stay put would point `dir` at a directory holding somebody else's
|
|
56
|
+
contents: the real output is still on disk, orphaned, invisible to `show` and to
|
|
57
|
+
every stage that reads its input through the state file — and the wrong directory
|
|
58
|
+
is what the next paid stage would read.
|
|
59
|
+
"""
|
|
60
|
+
directory = workspace.asset_dir(asset)
|
|
61
|
+
return [
|
|
62
|
+
(directory / old, directory / new)
|
|
63
|
+
for old, new in MOVES
|
|
64
|
+
if (directory / old).is_dir() and old != new and (directory / new).exists()
|
|
65
|
+
]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def rewrite_state(state: workspace.State) -> workspace.State:
|
|
69
|
+
"""Point the state at the new directories and fill in `artifacts` — R1.4.
|
|
70
|
+
|
|
71
|
+
`artifacts` is built from what is on disk rather than from the old state, because the
|
|
72
|
+
old state never listed files. That makes this the one place the two records are
|
|
73
|
+
allowed to be derived from each other, and it happens once.
|
|
74
|
+
"""
|
|
75
|
+
for name, entry in state.stages.items():
|
|
76
|
+
produced = entry.get("produced")
|
|
77
|
+
moved = PRODUCED.get(produced, produced)
|
|
78
|
+
if moved is None:
|
|
79
|
+
continue
|
|
80
|
+
entry["produced"] = moved
|
|
81
|
+
entry["dir"] = moved.replace(":", "/")
|
|
82
|
+
|
|
83
|
+
# A layout-1 `produced` that is none of the four falls through unchanged, and it
|
|
84
|
+
# came out of the same editable file every other recorded path does.
|
|
85
|
+
target = workspace.inside(state.name, entry["dir"])
|
|
86
|
+
state.artifacts[moved] = {
|
|
87
|
+
"dir": entry["dir"],
|
|
88
|
+
"files": sorted(path.name for path in target.glob("*") if path.is_file())
|
|
89
|
+
if target.is_dir()
|
|
90
|
+
else [],
|
|
91
|
+
"stage": name,
|
|
92
|
+
"prompt": entry.get("prompt"),
|
|
93
|
+
}
|
|
94
|
+
state.layout = workspace.LAYOUT
|
|
95
|
+
return state
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class Blocked(workspace.StageRefused):
|
|
99
|
+
"""A migration that cannot run without losing track of something."""
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def run(asset: str, *, dry_run: bool) -> list[tuple[Path, Path]]:
|
|
103
|
+
"""Migrate one asset. Returns the moves, whether or not they were made."""
|
|
104
|
+
state = workspace.load(asset)
|
|
105
|
+
stuck = blocked(asset)
|
|
106
|
+
if stuck:
|
|
107
|
+
where = ", ".join(target.name for _, target in stuck)
|
|
108
|
+
raise Blocked(
|
|
109
|
+
f"{asset}: {where} already exists, so the old directory cannot move there. "
|
|
110
|
+
f"Nothing was changed. Move or remove it, then migrate again."
|
|
111
|
+
)
|
|
112
|
+
moves = planned(asset)
|
|
113
|
+
if dry_run:
|
|
114
|
+
return moves
|
|
115
|
+
for source, target in moves:
|
|
116
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
117
|
+
source.rename(target)
|
|
118
|
+
workspace.save(rewrite_state(state))
|
|
119
|
+
return moves
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def cmd_migrate(args: argparse.Namespace) -> int:
|
|
123
|
+
"""Migrate one asset, or say what every outdated one would need.
|
|
124
|
+
|
|
125
|
+
ASCII only, for the reason `workspace.cmd_status` is.
|
|
126
|
+
"""
|
|
127
|
+
names = [workspace.check_name(args.name)] if args.name else workspace.known()
|
|
128
|
+
if not names:
|
|
129
|
+
print(f"no assets under {workspace.assets_root()}")
|
|
130
|
+
return 0
|
|
131
|
+
|
|
132
|
+
touched = refused = 0
|
|
133
|
+
for name in names:
|
|
134
|
+
state = workspace.load(name)
|
|
135
|
+
if not state.outdated():
|
|
136
|
+
if args.name:
|
|
137
|
+
print(f"{name} already layout {state.layout}; nothing to migrate")
|
|
138
|
+
continue
|
|
139
|
+
touched += 1
|
|
140
|
+
try:
|
|
141
|
+
moves = run(name, dry_run=args.dry_run)
|
|
142
|
+
except Blocked:
|
|
143
|
+
# Asked about one asset, a refusal is the answer and gets the exit code a
|
|
144
|
+
# refusal gets everywhere else. Asked about all of them, one that cannot move
|
|
145
|
+
# is not a reason to fail the others: it is named, and the batch goes on.
|
|
146
|
+
if args.name:
|
|
147
|
+
raise
|
|
148
|
+
refused += 1
|
|
149
|
+
print(f"{name} refused: it cannot move onto a directory that is already there")
|
|
150
|
+
continue
|
|
151
|
+
verb = "would move" if args.dry_run else "moved"
|
|
152
|
+
print(f"{name} layout {state.layout} -> {workspace.LAYOUT}")
|
|
153
|
+
for source, target in moves:
|
|
154
|
+
print(f" {verb} {source.name} -> {target.relative_to(workspace.asset_dir(name))}")
|
|
155
|
+
if not moves:
|
|
156
|
+
print(" nothing on disk to move; the state file is rewritten")
|
|
157
|
+
|
|
158
|
+
if args.dry_run and touched > refused:
|
|
159
|
+
print("nothing was moved; run again without --dry-run")
|
|
160
|
+
if refused:
|
|
161
|
+
print(f"{refused} asset(s) refused; nothing was changed for them")
|
|
162
|
+
return 0
|
spritegen/prompts.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""The prompt that produced an artifact, kept where the artifact is — R2.1 to R2.4.
|
|
2
|
+
|
|
3
|
+
An image survives its own reason. The prompt arrives through `--prompt-file`, from
|
|
4
|
+
somewhere outside the workspace, or through `--prompt`, from a shell that forgets it the
|
|
5
|
+
moment the command returns — and once the paid call is over, what is left on disk is a
|
|
6
|
+
picture nobody can regenerate or amend. Under autonomy that is worse still: the file that
|
|
7
|
+
was pointed at can be edited or deleted afterwards, so a *reference* to it is not a
|
|
8
|
+
record of anything.
|
|
9
|
+
|
|
10
|
+
So the text is copied in, and it is copied **before the call**: a prompt that only gets
|
|
11
|
+
written when the call succeeds is missing exactly when it is most wanted.
|
|
12
|
+
|
|
13
|
+
**Versioned by content, not by time.** Sending the same prompt again is not a new version
|
|
14
|
+
of it — nothing changed, and a run that produced the same words should point at the words
|
|
15
|
+
it produced. Editing one is, and the old text stays: `anchor.v1.md` is how you find out
|
|
16
|
+
what the first attempt actually asked for.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import re
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
#: `<kind>.v<N>.md`, and nothing else in the prompts directory is one.
|
|
25
|
+
PATTERN = re.compile(r"^(?P<kind>.+)\.v(?P<version>\d+)\.md$")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def directory(asset_dir: Path) -> Path:
|
|
29
|
+
"""Where this sprite's prompts live."""
|
|
30
|
+
return asset_dir / "prompts"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def name(kind: str, version: int) -> str:
|
|
34
|
+
return f"{kind}.v{version}.md"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def versions(asset_dir: Path, kind: str) -> list[int]:
|
|
38
|
+
"""Every version of `kind` on disk, oldest first."""
|
|
39
|
+
found = []
|
|
40
|
+
for path in directory(asset_dir).glob(f"{kind}.v*.md"):
|
|
41
|
+
match = PATTERN.match(path.name)
|
|
42
|
+
if match and match.group("kind") == kind:
|
|
43
|
+
found.append(int(match.group("version")))
|
|
44
|
+
return sorted(found)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def read(asset_dir: Path, kind: str, version: int) -> str:
|
|
48
|
+
"""One stored prompt, verbatim."""
|
|
49
|
+
path = directory(asset_dir) / name(kind, version)
|
|
50
|
+
if not path.is_file():
|
|
51
|
+
raise FileNotFoundError(f"no prompt {kind} v{version} in {directory(asset_dir)}")
|
|
52
|
+
return path.read_text(encoding="utf-8")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def store(asset_dir: Path, kind: str, text: str) -> str:
|
|
56
|
+
"""Keep `text` as a version of `kind`, and return the filename to record — R2.1, R2.2.
|
|
57
|
+
|
|
58
|
+
Unchanged text returns the version it already is. That is the whole of "versioned by
|
|
59
|
+
content": four candidates from one prompt cite one version, and the fifth run after an
|
|
60
|
+
edit cites a new one.
|
|
61
|
+
|
|
62
|
+
Compared against the newest version only. A prompt that was changed and then changed
|
|
63
|
+
back is a third version, not a re-use of the first — the history is a sequence of what
|
|
64
|
+
was asked, and collapsing a revert would hide that the middle one ever happened.
|
|
65
|
+
"""
|
|
66
|
+
text = text.rstrip() + "\n"
|
|
67
|
+
existing = versions(asset_dir, kind)
|
|
68
|
+
if existing and read(asset_dir, kind, existing[-1]) == text:
|
|
69
|
+
return name(kind, existing[-1])
|
|
70
|
+
|
|
71
|
+
version = (existing[-1] + 1) if existing else 1
|
|
72
|
+
target = directory(asset_dir)
|
|
73
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
74
|
+
(target / name(kind, version)).write_text(text, encoding="utf-8")
|
|
75
|
+
return name(kind, version)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def resolve(prompt: str | None, prompt_file: str | None, what: str) -> str:
|
|
79
|
+
"""The prompt text, from one source or the other and never from both.
|
|
80
|
+
|
|
81
|
+
Shared by every stage that takes a prompt, because they all had the same six lines
|
|
82
|
+
and one of them would eventually have drifted.
|
|
83
|
+
"""
|
|
84
|
+
if prompt and prompt_file:
|
|
85
|
+
raise ValueError("give --prompt or --prompt-file, not both")
|
|
86
|
+
if prompt:
|
|
87
|
+
return prompt
|
|
88
|
+
if not prompt_file:
|
|
89
|
+
raise ValueError(f"{what} needs --prompt or --prompt-file")
|
|
90
|
+
path = Path(prompt_file)
|
|
91
|
+
if not path.is_file():
|
|
92
|
+
raise FileNotFoundError(f"no prompt file at {path}")
|
|
93
|
+
text = path.read_text(encoding="utf-8").strip()
|
|
94
|
+
if not text:
|
|
95
|
+
raise ValueError(f"{path} is empty")
|
|
96
|
+
return text
|
spritegen/rrdb.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""RRDBNet, the network Real-ESRGAN's weights are for.
|
|
2
|
+
|
|
3
|
+
Ported from an `upscale_atlas.py` that reimplements it rather than depending on
|
|
4
|
+
`basicsr` — the upstream package pins an old torchvision and does not
|
|
5
|
+
install alongside a current torch. What was two classes there is one here: `RRDBNet6B`
|
|
6
|
+
differed from `RRDBNet` in the default block count and in nothing else.
|
|
7
|
+
|
|
8
|
+
**This module imports torch at the top, and so nothing may import it at package level.**
|
|
9
|
+
`upscale` resolves it inside the function that builds the network, which is what keeps
|
|
10
|
+
`status` and `cost` answering on a machine with no torch on it.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import torch
|
|
16
|
+
import torch.nn.functional as functional
|
|
17
|
+
from torch import nn
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ResidualDenseBlock(nn.Module):
|
|
21
|
+
"""Five convolutions, each seeing everything before it, added back at 0.2."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, nf: int = 64, gc: int = 32):
|
|
24
|
+
super().__init__()
|
|
25
|
+
self.conv1 = nn.Conv2d(nf, gc, 3, 1, 1)
|
|
26
|
+
self.conv2 = nn.Conv2d(nf + gc, gc, 3, 1, 1)
|
|
27
|
+
self.conv3 = nn.Conv2d(nf + 2 * gc, gc, 3, 1, 1)
|
|
28
|
+
self.conv4 = nn.Conv2d(nf + 3 * gc, gc, 3, 1, 1)
|
|
29
|
+
self.conv5 = nn.Conv2d(nf + 4 * gc, nf, 3, 1, 1)
|
|
30
|
+
self.lrelu = nn.LeakyReLU(0.2, True)
|
|
31
|
+
|
|
32
|
+
def forward(self, x):
|
|
33
|
+
x1 = self.lrelu(self.conv1(x))
|
|
34
|
+
x2 = self.lrelu(self.conv2(torch.cat((x, x1), 1)))
|
|
35
|
+
x3 = self.lrelu(self.conv3(torch.cat((x, x1, x2), 1)))
|
|
36
|
+
x4 = self.lrelu(self.conv4(torch.cat((x, x1, x2, x3), 1)))
|
|
37
|
+
x5 = self.conv5(torch.cat((x, x1, x2, x3, x4), 1))
|
|
38
|
+
return x5 * 0.2 + x
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class RRDB(nn.Module):
|
|
42
|
+
"""Three of those in a row, added back at 0.2 again."""
|
|
43
|
+
|
|
44
|
+
def __init__(self, nf: int = 64, gc: int = 32):
|
|
45
|
+
super().__init__()
|
|
46
|
+
self.rdb1 = ResidualDenseBlock(nf, gc)
|
|
47
|
+
self.rdb2 = ResidualDenseBlock(nf, gc)
|
|
48
|
+
self.rdb3 = ResidualDenseBlock(nf, gc)
|
|
49
|
+
|
|
50
|
+
def forward(self, x):
|
|
51
|
+
out = self.rdb3(self.rdb2(self.rdb1(x)))
|
|
52
|
+
return out * 0.2 + x
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class RRDBNet(nn.Module):
|
|
56
|
+
"""The whole network. `nb` is 23 for the general weights and 6 for the anime ones.
|
|
57
|
+
|
|
58
|
+
The upsampling is two nearest-neighbour doublings with a convolution after each, so
|
|
59
|
+
the network is 4x and only 4x. Any other factor is a resize of what it returned.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
def __init__(
|
|
63
|
+
self,
|
|
64
|
+
in_nc: int = 3,
|
|
65
|
+
out_nc: int = 3,
|
|
66
|
+
nf: int = 64,
|
|
67
|
+
nb: int = 23,
|
|
68
|
+
gc: int = 32,
|
|
69
|
+
scale: int = 4,
|
|
70
|
+
):
|
|
71
|
+
super().__init__()
|
|
72
|
+
self.scale = scale
|
|
73
|
+
self.conv_first = nn.Conv2d(in_nc, nf, 3, 1, 1)
|
|
74
|
+
self.body = nn.Sequential(*[RRDB(nf, gc) for _ in range(nb)])
|
|
75
|
+
self.conv_body = nn.Conv2d(nf, nf, 3, 1, 1)
|
|
76
|
+
self.conv_up1 = nn.Conv2d(nf, nf, 3, 1, 1)
|
|
77
|
+
self.conv_up2 = nn.Conv2d(nf, nf, 3, 1, 1)
|
|
78
|
+
self.conv_hr = nn.Conv2d(nf, nf, 3, 1, 1)
|
|
79
|
+
self.conv_last = nn.Conv2d(nf, out_nc, 3, 1, 1)
|
|
80
|
+
self.lrelu = nn.LeakyReLU(0.2, True)
|
|
81
|
+
|
|
82
|
+
def forward(self, x):
|
|
83
|
+
feat = self.conv_first(x)
|
|
84
|
+
feat = feat + self.conv_body(self.body(feat))
|
|
85
|
+
feat = self.lrelu(
|
|
86
|
+
self.conv_up1(functional.interpolate(feat, scale_factor=2, mode="nearest"))
|
|
87
|
+
)
|
|
88
|
+
feat = self.lrelu(
|
|
89
|
+
self.conv_up2(functional.interpolate(feat, scale_factor=2, mode="nearest"))
|
|
90
|
+
)
|
|
91
|
+
return self.conv_last(self.lrelu(self.conv_hr(feat)))
|
spritegen/settings.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Every setting this tool has, in one place — the environment, a `.env`, or a default.
|
|
2
|
+
|
|
3
|
+
Before this, configuration was four unrelated habits: `FAL_KEY` read out of `os.environ`
|
|
4
|
+
inside `fal`, `SPRITEGEN_ASSETS` read out of it inside `workspace`, a `.env` loaded by the
|
|
5
|
+
CLI into the process, and a handful of module constants nobody could override without
|
|
6
|
+
editing the source. Each was defensible alone; together they meant no answer to "what can
|
|
7
|
+
be configured", and two of them could disagree about whether a variable had been set.
|
|
8
|
+
|
|
9
|
+
**Not cached, deliberately.** A CLI runs once and exits, so the saving would be
|
|
10
|
+
microseconds; what it would cost is that a variable set after the first read stops taking
|
|
11
|
+
effect, which is precisely what a test does when it points the assets somewhere else. Read
|
|
12
|
+
it where you need it.
|
|
13
|
+
|
|
14
|
+
The import stays out of module scope everywhere it is used, for the same reason `fal` and
|
|
15
|
+
`Pillow` do: `status` and `cost` must answer without paying for anything they do not need.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Literal
|
|
22
|
+
|
|
23
|
+
from pydantic import AliasChoices, Field
|
|
24
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
25
|
+
|
|
26
|
+
#: Where a downloaded model weight goes. Outside any workspace: it is the same file for
|
|
27
|
+
#: every project, it is 64 MB or more, and it can always be fetched again.
|
|
28
|
+
DEFAULT_CACHE = Path.home() / ".cache" / "spritegen"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def search_from(start: Path | None = None) -> tuple[Path, ...]:
|
|
32
|
+
"""The directories a search may look in: the start, and its parents up to a ceiling.
|
|
33
|
+
|
|
34
|
+
**The ceiling is the point of this.** Searching upward is right — a command is typed
|
|
35
|
+
from wherever is convenient, not from the top of the thing it concerns — but upward
|
|
36
|
+
with no stopping condition means the filesystem root, and everything between here and
|
|
37
|
+
there belongs to somebody else. A `.env` in a shared parent, on a mounted drive, or in
|
|
38
|
+
a CI runner's workspace root would be read as this tool's own configuration, and it
|
|
39
|
+
can set the credential, where the assets are, and which hosts a download may come
|
|
40
|
+
from.
|
|
41
|
+
|
|
42
|
+
So the walk stops at the first repository boundary, or at the home directory,
|
|
43
|
+
whichever comes first. Neither found — the working directory is somewhere with no
|
|
44
|
+
repository above it and outside the home directory — and the search is the working
|
|
45
|
+
directory alone, which is the conservative answer rather than the far-reaching one.
|
|
46
|
+
"""
|
|
47
|
+
start = Path.cwd() if start is None else start
|
|
48
|
+
home = Path.home()
|
|
49
|
+
chain: list[Path] = []
|
|
50
|
+
for base in (start, *start.parents):
|
|
51
|
+
chain.append(base)
|
|
52
|
+
if (base / ".git").exists() or base == home:
|
|
53
|
+
return tuple(chain)
|
|
54
|
+
return (start,)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def dotenv(start: Path | None = None) -> Path | None:
|
|
58
|
+
"""The nearest `.env` at or above the working directory, within the search ceiling.
|
|
59
|
+
|
|
60
|
+
An exported variable still wins over the file — that is pydantic-settings' own order,
|
|
61
|
+
and it is what makes a one-off `FAL_KEY=... spritegen anchor ...` work while a stale
|
|
62
|
+
key sits in the file.
|
|
63
|
+
"""
|
|
64
|
+
for base in search_from(start):
|
|
65
|
+
candidate = base / ".env"
|
|
66
|
+
if candidate.is_file():
|
|
67
|
+
return candidate
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class Settings(BaseSettings):
|
|
72
|
+
"""What can be configured, and what it is when nobody says.
|
|
73
|
+
|
|
74
|
+
Every field is `SPRITEGEN_<NAME>` in the environment. `fal_key` also answers to bare
|
|
75
|
+
`FAL_KEY`, which is what fal's own documentation and every existing `.env` use — an
|
|
76
|
+
alias costs one line and breaking them would cost everyone a rewrite.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
model_config = SettingsConfigDict(
|
|
80
|
+
env_prefix="SPRITEGEN_",
|
|
81
|
+
env_file=None,
|
|
82
|
+
extra="ignore",
|
|
83
|
+
case_sensitive=False,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
#: Where the sprites live. Unset means discover it — see `workspace.assets_root`.
|
|
87
|
+
assets: Path | None = None
|
|
88
|
+
|
|
89
|
+
#: The fal credential. Absent is not an error until something tries to spend.
|
|
90
|
+
fal_key: str | None = Field(
|
|
91
|
+
default=None, validation_alias=AliasChoices("SPRITEGEN_FAL_KEY", "FAL_KEY", "fal_key")
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
#: Downloaded model weights, shared across every project on this machine.
|
|
95
|
+
cache: Path = DEFAULT_CACHE
|
|
96
|
+
|
|
97
|
+
#: The sheet cell, in pixels. A project with another one can say so once here rather
|
|
98
|
+
#: than passing `--cell` to every command.
|
|
99
|
+
cell: int = 166
|
|
100
|
+
|
|
101
|
+
#: Which Real-ESRGAN weights the local upscale uses, and where it runs. `auto` means
|
|
102
|
+
#: CUDA where there is one — reported by the command, never assumed silently.
|
|
103
|
+
upscale_model: Literal["anime", "general"] = "anime"
|
|
104
|
+
upscale_device: Literal["auto", "cuda", "cpu"] = "auto"
|
|
105
|
+
|
|
106
|
+
#: Whether the background comes off locally or through a paid endpoint. Local is the
|
|
107
|
+
#: default because it costs nothing and this machine has the GPU for it; the paid one
|
|
108
|
+
#: is there for a machine that does not.
|
|
109
|
+
matte_backend: Literal["local", "fal"] = "local"
|
|
110
|
+
matte_model: str = "birefnet-general"
|
|
111
|
+
|
|
112
|
+
#: Extra hosts a downloaded URL may come from, on top of the ones built in. A
|
|
113
|
+
#: comma-separated list, because that is what an environment variable can carry.
|
|
114
|
+
allowed_hosts: str = ""
|
|
115
|
+
|
|
116
|
+
def extra_hosts(self) -> tuple[str, ...]:
|
|
117
|
+
return tuple(part.strip() for part in self.allowed_hosts.split(",") if part.strip())
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def load(**overrides) -> Settings:
|
|
121
|
+
"""The settings as they are right now.
|
|
122
|
+
|
|
123
|
+
`overrides` is for a caller that already has the answer — a test, or a command-line
|
|
124
|
+
option that beats the environment. It is not a way to mutate anything: this builds a
|
|
125
|
+
new object every time and nothing is stored.
|
|
126
|
+
"""
|
|
127
|
+
return Settings(_env_file=dotenv(), **overrides)
|