cardbleed 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.
- cardbleed/__init__.py +25 -0
- cardbleed/__main__.py +4 -0
- cardbleed/_version.py +1 -0
- cardbleed/cli.py +336 -0
- cardbleed/errors.py +2 -0
- cardbleed/filters.py +68 -0
- cardbleed/formats.py +257 -0
- cardbleed/process.py +177 -0
- cardbleed/selfcheck.py +360 -0
- cardbleed/sizing.py +132 -0
- cardbleed/synthesis.py +387 -0
- cardbleed-0.1.0.dist-info/METADATA +164 -0
- cardbleed-0.1.0.dist-info/RECORD +16 -0
- cardbleed-0.1.0.dist-info/WHEEL +4 -0
- cardbleed-0.1.0.dist-info/entry_points.txt +2 -0
- cardbleed-0.1.0.dist-info/licenses/LICENSE +21 -0
cardbleed/__init__.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Extend the borders of card scans outward for printing.
|
|
2
|
+
|
|
3
|
+
Continues the existing border pattern (holo speckle, solid colors, ...)
|
|
4
|
+
uniformly on all four edges without ever degrading the original image data:
|
|
5
|
+
|
|
6
|
+
PNG -> PNG original pixels bit-identical (lossless re-serialize)
|
|
7
|
+
WebP -> WebP written lossless; decoded original pixels preserved exactly
|
|
8
|
+
JPEG -> JPEG DCT-domain surgery: original coefficient blocks are copied
|
|
9
|
+
bit-exact into a larger grid; only new border blocks are
|
|
10
|
+
encoded (with the original's own quantization tables)
|
|
11
|
+
|
|
12
|
+
Python API (stable surface):
|
|
13
|
+
|
|
14
|
+
from cardbleed import Params, extend_image
|
|
15
|
+
|
|
16
|
+
result = extend_image(arr, (16, 16, 16, 16), Params(),
|
|
17
|
+
np.random.default_rng(0), overwrite=True, notes=[])
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from ._version import __version__
|
|
21
|
+
from .cli import cli
|
|
22
|
+
from .errors import FileError
|
|
23
|
+
from .synthesis import Params, extend_image
|
|
24
|
+
|
|
25
|
+
__all__ = ["FileError", "Params", "__version__", "cli", "extend_image"]
|
cardbleed/__main__.py
ADDED
cardbleed/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
cardbleed/cli.py
ADDED
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
"""Command-line interface (click + rich-click)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from types import SimpleNamespace
|
|
6
|
+
|
|
7
|
+
import rich_click as click
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
from rich.progress import (
|
|
10
|
+
BarColumn,
|
|
11
|
+
MofNCompleteColumn,
|
|
12
|
+
Progress,
|
|
13
|
+
SpinnerColumn,
|
|
14
|
+
TextColumn,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
from ._version import __version__
|
|
18
|
+
from .errors import FileError
|
|
19
|
+
from .process import iter_inputs, process_file
|
|
20
|
+
from .selfcheck import selfcheck
|
|
21
|
+
|
|
22
|
+
click.rich_click.USE_RICH_MARKUP = True
|
|
23
|
+
click.rich_click.SHOW_ARGUMENTS = True
|
|
24
|
+
click.rich_click.STYLE_OPTIONS_TABLE_LEADING = 0
|
|
25
|
+
click.rich_click.OPTION_GROUPS = {
|
|
26
|
+
"*": [
|
|
27
|
+
{
|
|
28
|
+
"name": "Sizing",
|
|
29
|
+
"options": [
|
|
30
|
+
"--extend",
|
|
31
|
+
"--left",
|
|
32
|
+
"--right",
|
|
33
|
+
"--top",
|
|
34
|
+
"--bottom",
|
|
35
|
+
"--target",
|
|
36
|
+
"--fix-aspect",
|
|
37
|
+
"--card-size",
|
|
38
|
+
],
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
"name": "Synthesis",
|
|
42
|
+
"options": [
|
|
43
|
+
"--mode",
|
|
44
|
+
"--sample",
|
|
45
|
+
"--trim",
|
|
46
|
+
"--jitter",
|
|
47
|
+
"--jitter-smooth",
|
|
48
|
+
"--jitter-cross",
|
|
49
|
+
"--shuffle",
|
|
50
|
+
"--noise",
|
|
51
|
+
"--smudge",
|
|
52
|
+
"--seam-feather",
|
|
53
|
+
"--corner-guard",
|
|
54
|
+
"--halo",
|
|
55
|
+
"--seed",
|
|
56
|
+
],
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
"name": "Input / Output",
|
|
60
|
+
"options": [
|
|
61
|
+
"--out-dir",
|
|
62
|
+
"--suffix",
|
|
63
|
+
"--compare",
|
|
64
|
+
"--force",
|
|
65
|
+
"--recursive",
|
|
66
|
+
"--dry-run",
|
|
67
|
+
"--version",
|
|
68
|
+
"--help",
|
|
69
|
+
],
|
|
70
|
+
},
|
|
71
|
+
]
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@click.command(context_settings={"help_option_names": ["-h", "--help"]})
|
|
76
|
+
@click.argument("inputs", nargs=-1, metavar="[INPUTS]...")
|
|
77
|
+
# sizing ---------------------------------------------------------------------
|
|
78
|
+
@click.option(
|
|
79
|
+
"-e",
|
|
80
|
+
"--extend",
|
|
81
|
+
default="16",
|
|
82
|
+
show_default=True,
|
|
83
|
+
metavar="AMT",
|
|
84
|
+
help="Border to add per edge: px ([cyan]16[/]) or mm ([cyan]2.5mm[/]).",
|
|
85
|
+
)
|
|
86
|
+
@click.option(
|
|
87
|
+
"--left",
|
|
88
|
+
default=None,
|
|
89
|
+
metavar="AMT",
|
|
90
|
+
help="Override for the left edge ([cyan]0[/] skips it).",
|
|
91
|
+
)
|
|
92
|
+
@click.option(
|
|
93
|
+
"--right", default=None, metavar="AMT", help="Override for the right edge."
|
|
94
|
+
)
|
|
95
|
+
@click.option("--top", default=None, metavar="AMT", help="Override for the top edge.")
|
|
96
|
+
@click.option(
|
|
97
|
+
"--bottom", default=None, metavar="AMT", help="Override for the bottom edge."
|
|
98
|
+
)
|
|
99
|
+
@click.option(
|
|
100
|
+
"--target",
|
|
101
|
+
default=None,
|
|
102
|
+
metavar="WxH",
|
|
103
|
+
help="Pad (centered) to an exact final size, e.g. "
|
|
104
|
+
"[cyan]69x94mm[/] or [cyan]440x600[/]; overrides -e.",
|
|
105
|
+
)
|
|
106
|
+
@click.option(
|
|
107
|
+
"--fix-aspect",
|
|
108
|
+
is_flag=True,
|
|
109
|
+
help="First pad the short axis so the image matches the card "
|
|
110
|
+
"aspect ratio exactly, then add the border.",
|
|
111
|
+
)
|
|
112
|
+
@click.option(
|
|
113
|
+
"--card-size",
|
|
114
|
+
default="63x88",
|
|
115
|
+
show_default=True,
|
|
116
|
+
metavar="WxH",
|
|
117
|
+
help="Physical card size in mm — basis for all mm math "
|
|
118
|
+
"(embedded file DPI is never trusted).",
|
|
119
|
+
)
|
|
120
|
+
# synthesis ------------------------------------------------------------------
|
|
121
|
+
@click.option(
|
|
122
|
+
"--mode",
|
|
123
|
+
type=click.Choice(["smart", "pattern", "naive"]),
|
|
124
|
+
default="smart",
|
|
125
|
+
show_default=True,
|
|
126
|
+
help="[cyan]smart[/]: stochastic band resampling · "
|
|
127
|
+
"[cyan]pattern[/]: structure-preserving randomized-mirror "
|
|
128
|
+
"continuation (auto-detects repeating patterns and keeps "
|
|
129
|
+
"them phase-aligned) · [cyan]naive[/]: replicate the "
|
|
130
|
+
"outermost clean line.",
|
|
131
|
+
)
|
|
132
|
+
@click.option(
|
|
133
|
+
"-k",
|
|
134
|
+
"--sample",
|
|
135
|
+
type=int,
|
|
136
|
+
default=12,
|
|
137
|
+
show_default=True,
|
|
138
|
+
metavar="N",
|
|
139
|
+
help="How many border pixels to sample patterns/colors from "
|
|
140
|
+
"(auto-clamped before inner border structure).",
|
|
141
|
+
)
|
|
142
|
+
@click.option(
|
|
143
|
+
"--trim",
|
|
144
|
+
default="auto",
|
|
145
|
+
show_default=True,
|
|
146
|
+
metavar="auto|N",
|
|
147
|
+
help="Outermost pixels treated as scanner bloom / cut-line junk: "
|
|
148
|
+
"excluded from sampling and (png/webp) replaced. "
|
|
149
|
+
"[cyan]auto[/] detects hard bloom lines per edge (max 3).",
|
|
150
|
+
)
|
|
151
|
+
@click.option(
|
|
152
|
+
"--jitter",
|
|
153
|
+
type=float,
|
|
154
|
+
default=0.85,
|
|
155
|
+
show_default=True,
|
|
156
|
+
help="0..1 randomness of the sampling depth "
|
|
157
|
+
"([cyan]0[/] = plain pattern continuation).",
|
|
158
|
+
)
|
|
159
|
+
@click.option(
|
|
160
|
+
"--jitter-smooth",
|
|
161
|
+
type=float,
|
|
162
|
+
default=1.2,
|
|
163
|
+
show_default=True,
|
|
164
|
+
metavar="SIGMA",
|
|
165
|
+
help="Smoothing of the jitter field; matches speckle grain size "
|
|
166
|
+
"([cyan]0[/] = per-pixel salt & pepper).",
|
|
167
|
+
)
|
|
168
|
+
@click.option(
|
|
169
|
+
"--jitter-cross",
|
|
170
|
+
type=float,
|
|
171
|
+
default=4.0,
|
|
172
|
+
show_default=True,
|
|
173
|
+
metavar="PX",
|
|
174
|
+
help="Local along-edge wobble of the sampling position; kills "
|
|
175
|
+
"repeated-fleck trails ([cyan]0[/] = perfectly straight).",
|
|
176
|
+
)
|
|
177
|
+
@click.option(
|
|
178
|
+
"--shuffle",
|
|
179
|
+
type=float,
|
|
180
|
+
default=48.0,
|
|
181
|
+
show_default=True,
|
|
182
|
+
metavar="PX",
|
|
183
|
+
help="Long-range texture borrowing along the edge (smoothed "
|
|
184
|
+
"patches): holo speckle is drawn from elsewhere on the "
|
|
185
|
+
"border so patterns never near-repeat in either direction "
|
|
186
|
+
"([cyan]0[/] = only local wobble).",
|
|
187
|
+
)
|
|
188
|
+
@click.option(
|
|
189
|
+
"--noise",
|
|
190
|
+
type=float,
|
|
191
|
+
default=0.35,
|
|
192
|
+
show_default=True,
|
|
193
|
+
metavar="F",
|
|
194
|
+
help="Added grain as a multiple of the border's own measured "
|
|
195
|
+
"grain (self-tuning; [cyan]0[/] = off).",
|
|
196
|
+
)
|
|
197
|
+
@click.option(
|
|
198
|
+
"--smudge",
|
|
199
|
+
type=float,
|
|
200
|
+
default=0.6,
|
|
201
|
+
show_default=True,
|
|
202
|
+
metavar="SIGMA",
|
|
203
|
+
help="Gaussian smudge of the new border, ramped toward the "
|
|
204
|
+
"outer edge ([cyan]0[/] = off).",
|
|
205
|
+
)
|
|
206
|
+
@click.option(
|
|
207
|
+
"--seam-feather",
|
|
208
|
+
type=int,
|
|
209
|
+
default=3,
|
|
210
|
+
show_default=True,
|
|
211
|
+
metavar="PX",
|
|
212
|
+
help="Pixels over which randomness ramps in from the seam.",
|
|
213
|
+
)
|
|
214
|
+
@click.option(
|
|
215
|
+
"--corner-guard",
|
|
216
|
+
type=int,
|
|
217
|
+
default=12,
|
|
218
|
+
show_default=True,
|
|
219
|
+
metavar="PX",
|
|
220
|
+
help="Keep sampling this far away from image corners (avoids "
|
|
221
|
+
"seeding from rounded/white scan corners).",
|
|
222
|
+
)
|
|
223
|
+
@click.option(
|
|
224
|
+
"--halo",
|
|
225
|
+
type=click.Choice(["auto", "overwrite", "blend"]),
|
|
226
|
+
default="auto",
|
|
227
|
+
show_default=True,
|
|
228
|
+
help="Trimmed halo ring handling: [cyan]overwrite[/] it "
|
|
229
|
+
"(png/webp default) or [cyan]blend[/] it out (jpeg "
|
|
230
|
+
"default; overwrite on jpeg re-encodes the outer "
|
|
231
|
+
"block ring).",
|
|
232
|
+
)
|
|
233
|
+
@click.option(
|
|
234
|
+
"--seed",
|
|
235
|
+
type=int,
|
|
236
|
+
default=0,
|
|
237
|
+
show_default=True,
|
|
238
|
+
help="RNG seed (per-file streams derived from filename).",
|
|
239
|
+
)
|
|
240
|
+
# input/output ---------------------------------------------------------------
|
|
241
|
+
@click.option(
|
|
242
|
+
"-o",
|
|
243
|
+
"--out-dir",
|
|
244
|
+
default=None,
|
|
245
|
+
metavar="DIR",
|
|
246
|
+
help="Output directory (default: alongside each input).",
|
|
247
|
+
)
|
|
248
|
+
@click.option(
|
|
249
|
+
"--suffix",
|
|
250
|
+
default="_ext",
|
|
251
|
+
show_default=True,
|
|
252
|
+
help="Appended to the output file stem.",
|
|
253
|
+
)
|
|
254
|
+
@click.option(
|
|
255
|
+
"--compare",
|
|
256
|
+
is_flag=True,
|
|
257
|
+
help="Also write a QA sheet: original | result | result with "
|
|
258
|
+
"the original boundary marked.",
|
|
259
|
+
)
|
|
260
|
+
@click.option(
|
|
261
|
+
"--force",
|
|
262
|
+
is_flag=True,
|
|
263
|
+
help="Overwrite existing output files (inputs are never overwritten).",
|
|
264
|
+
)
|
|
265
|
+
@click.option("--recursive", is_flag=True, help="Descend into subdirectories.")
|
|
266
|
+
@click.option(
|
|
267
|
+
"--dry-run", is_flag=True, help="Show what would be done without writing anything."
|
|
268
|
+
)
|
|
269
|
+
@click.option("--selfcheck", is_flag=True, hidden=True)
|
|
270
|
+
@click.version_option(__version__, "-V", "--version")
|
|
271
|
+
@click.pass_context
|
|
272
|
+
def cli(ctx: click.Context, **kw) -> None:
|
|
273
|
+
"""Extend card scan borders for printing, continuing the existing
|
|
274
|
+
border pattern (holo speckle, solid colors, ...).
|
|
275
|
+
|
|
276
|
+
Original image data is [bold]never re-encoded[/]: PNG/WebP pixels stay
|
|
277
|
+
bit-identical and JPEG goes through lossless DCT-block surgery.
|
|
278
|
+
INPUTS are image files and/or directories (png/jpg/jpeg/webp).
|
|
279
|
+
|
|
280
|
+
[dim]Examples:[/]
|
|
281
|
+
|
|
282
|
+
[dim] cardbleed card.png --compare[/]
|
|
283
|
+
|
|
284
|
+
[dim] cardbleed ./cards/ -e 2.5mm --fix-aspect[/]
|
|
285
|
+
"""
|
|
286
|
+
ctx.exit(run(SimpleNamespace(**kw)))
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def run(args) -> int:
|
|
290
|
+
console = Console(highlight=False)
|
|
291
|
+
err = Console(stderr=True, highlight=False)
|
|
292
|
+
if args.selfcheck:
|
|
293
|
+
return selfcheck(args)
|
|
294
|
+
if not args.inputs:
|
|
295
|
+
raise click.UsageError("no inputs given (image files or directories)")
|
|
296
|
+
|
|
297
|
+
files, input_errors = iter_inputs(list(args.inputs), args.recursive, args.suffix)
|
|
298
|
+
for e in input_errors:
|
|
299
|
+
err.print(f"[yellow]SKIPPED[/] {e}")
|
|
300
|
+
if not files:
|
|
301
|
+
err.print("[bold red]error:[/] no supported images found")
|
|
302
|
+
return 2
|
|
303
|
+
|
|
304
|
+
ok, failed = 0, len(input_errors)
|
|
305
|
+
claimed: dict = {}
|
|
306
|
+
with Progress(
|
|
307
|
+
SpinnerColumn(),
|
|
308
|
+
TextColumn("[progress.description]{task.description}"),
|
|
309
|
+
BarColumn(),
|
|
310
|
+
MofNCompleteColumn(),
|
|
311
|
+
console=console,
|
|
312
|
+
transient=True,
|
|
313
|
+
disable=len(files) < 3 or args.dry_run,
|
|
314
|
+
) as progress:
|
|
315
|
+
task = progress.add_task("extending", total=len(files))
|
|
316
|
+
for f in files:
|
|
317
|
+
progress.update(task, description=f.name)
|
|
318
|
+
try:
|
|
319
|
+
process_file(f, args, console=console, claimed=claimed)
|
|
320
|
+
ok += 1
|
|
321
|
+
except FileError as e:
|
|
322
|
+
err.print(f"[bold cyan]{f.name}[/]: [yellow]SKIPPED[/] — {e}")
|
|
323
|
+
failed += 1
|
|
324
|
+
except Exception as e: # unexpected: report, keep the batch alive
|
|
325
|
+
err.print(
|
|
326
|
+
f"[bold cyan]{f.name}[/]: [bold red]ERROR[/] — "
|
|
327
|
+
f"{type(e).__name__}: {e}"
|
|
328
|
+
)
|
|
329
|
+
failed += 1
|
|
330
|
+
progress.advance(task)
|
|
331
|
+
if len(files) > 1:
|
|
332
|
+
parts = [f"[green]{ok} ok[/]"]
|
|
333
|
+
if failed:
|
|
334
|
+
parts.append(f"[red]{failed} failed[/]")
|
|
335
|
+
console.print("[bold]done:[/] " + ", ".join(parts))
|
|
336
|
+
return 1 if failed else 0
|
cardbleed/errors.py
ADDED
cardbleed/filters.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Small separable-convolution helpers (pure NumPy, no SciPy)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def conv_axis(a: np.ndarray, kernel: np.ndarray, axis: int) -> np.ndarray:
|
|
11
|
+
"""Convolve one axis of `a` with a 1-D kernel (reflect padding)."""
|
|
12
|
+
r = len(kernel) // 2
|
|
13
|
+
if a.shape[axis] < 2:
|
|
14
|
+
return a.astype(np.float32, copy=True)
|
|
15
|
+
pads = [(0, 0)] * a.ndim
|
|
16
|
+
pads[axis] = (r, r)
|
|
17
|
+
# reflect needs pad < dim; fall back to edge for tiny arrays
|
|
18
|
+
mode = "reflect" if a.shape[axis] > r else "edge"
|
|
19
|
+
ap = np.pad(a, pads, mode=mode)
|
|
20
|
+
out = np.zeros(a.shape, dtype=np.float32)
|
|
21
|
+
sl = [slice(None)] * a.ndim
|
|
22
|
+
for i, kv in enumerate(kernel):
|
|
23
|
+
sl[axis] = slice(i, i + a.shape[axis])
|
|
24
|
+
out += np.float32(kv) * ap[tuple(sl)]
|
|
25
|
+
return out
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def gauss_kernel(sigma: float) -> np.ndarray:
|
|
29
|
+
r = max(1, math.ceil(3 * sigma))
|
|
30
|
+
x = np.arange(-r, r + 1, dtype=np.float32)
|
|
31
|
+
k = np.exp(-(x * x) / (2 * sigma * sigma))
|
|
32
|
+
return k / k.sum()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def gaussian_blur2d(a: np.ndarray, sigma: float) -> np.ndarray:
|
|
36
|
+
"""Separable Gaussian blur over the first two axes of (H,W) or (H,W,C)."""
|
|
37
|
+
if sigma <= 0:
|
|
38
|
+
return a.astype(np.float32, copy=True)
|
|
39
|
+
k = gauss_kernel(sigma)
|
|
40
|
+
return conv_axis(conv_axis(a, k, 0), k, 1)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def box_blur3(a: np.ndarray) -> np.ndarray:
|
|
44
|
+
k = np.full(3, 1 / 3, dtype=np.float32)
|
|
45
|
+
return conv_axis(conv_axis(a, k, 0), k, 1)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def highpass_std(region: np.ndarray) -> np.ndarray:
|
|
49
|
+
"""Per-channel std of the high-frequency residual of an (H,W,C) region."""
|
|
50
|
+
return (region.astype(np.float32) - box_blur3(region)).std(axis=(0, 1))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def smooth_field(
|
|
54
|
+
rng: np.random.Generator,
|
|
55
|
+
shape: tuple[int, int],
|
|
56
|
+
sigma: float,
|
|
57
|
+
*,
|
|
58
|
+
standardize: bool = False,
|
|
59
|
+
) -> np.ndarray:
|
|
60
|
+
"""Random field blurred to a given correlation length.
|
|
61
|
+
|
|
62
|
+
Standardized fields have mean 0 / std 1 (for signed displacements);
|
|
63
|
+
otherwise the field is renormalized to span [0, 1].
|
|
64
|
+
"""
|
|
65
|
+
f = gaussian_blur2d(rng.random(shape, dtype=np.float32), sigma)
|
|
66
|
+
if standardize:
|
|
67
|
+
return (f - f.mean()) / max(float(f.std()), 1e-6)
|
|
68
|
+
return (f - f.min()) / max(float(np.ptp(f)), 1e-6)
|
cardbleed/formats.py
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
"""Format-preserving image I/O.
|
|
2
|
+
|
|
3
|
+
The whole point of cardbleed is that original image data is never re-encoded:
|
|
4
|
+
|
|
5
|
+
PNG -> PNG lossless container; original pixels bit-identical
|
|
6
|
+
WebP -> WebP written lossless (exact); decoded original pixels preserved
|
|
7
|
+
JPEG -> JPEG DCT-domain surgery: original quantized coefficient blocks are
|
|
8
|
+
copied bit-exact into a larger grid; only new border blocks
|
|
9
|
+
are encoded, using the file's own quantization tables
|
|
10
|
+
|
|
11
|
+
Adding a new format means adding a loader + saver here; synthesis is
|
|
12
|
+
format-agnostic (it only ever sees an (H, W, C) uint8 array).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import contextlib
|
|
18
|
+
import math
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
import numpy as np
|
|
22
|
+
from PIL import Image
|
|
23
|
+
|
|
24
|
+
from .errors import FileError
|
|
25
|
+
from .synthesis import TRIM_CAP
|
|
26
|
+
|
|
27
|
+
FORMATS = {".png": "png", ".jpg": "jpeg", ".jpeg": "jpeg", ".webp": "webp"}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# --------------------------------------------------------------------------
|
|
31
|
+
# loading
|
|
32
|
+
# --------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def load_pixels(path: Path, fmt: str, notes: list[str]):
|
|
36
|
+
"""Decode to (H,W,C) uint8 + a dict of metadata for the writer."""
|
|
37
|
+
if fmt == "jpeg":
|
|
38
|
+
import jpeglib
|
|
39
|
+
|
|
40
|
+
dct = jpeglib.read_dct(str(path))
|
|
41
|
+
cs = str(getattr(dct, "jpeg_color_space", ""))
|
|
42
|
+
if dct.num_components == 3 and "YCbCr" not in cs:
|
|
43
|
+
raise FileError(
|
|
44
|
+
f"JPEG color space {cs or 'unknown'} not supported "
|
|
45
|
+
"(only YCbCr and grayscale)"
|
|
46
|
+
)
|
|
47
|
+
spat = jpeglib.read_spatial(str(path))
|
|
48
|
+
arr = np.asarray(spat.spatial)
|
|
49
|
+
if arr.ndim == 2:
|
|
50
|
+
arr = arr[:, :, None]
|
|
51
|
+
if arr.shape[2] not in (1, 3):
|
|
52
|
+
raise FileError(
|
|
53
|
+
f"unsupported JPEG color layout ({arr.shape[2]} "
|
|
54
|
+
"channels; CMYK/YCCK not supported)"
|
|
55
|
+
)
|
|
56
|
+
return arr, {"dct": dct}
|
|
57
|
+
|
|
58
|
+
im = Image.open(path)
|
|
59
|
+
if fmt == "webp" and getattr(im, "is_animated", False):
|
|
60
|
+
raise FileError("animated WebP not supported")
|
|
61
|
+
meta = {"icc_profile": im.info.get("icc_profile"), "exif": im.info.get("exif")}
|
|
62
|
+
if im.mode == "P":
|
|
63
|
+
im = im.convert("RGBA" if "transparency" in im.info else "RGB")
|
|
64
|
+
notes.append(f"palette image promoted to {im.mode} (pixel values identical)")
|
|
65
|
+
if im.mode in ("I", "I;16", "I;16B", "I;16L", "F"):
|
|
66
|
+
raise FileError(
|
|
67
|
+
f"{im.mode} (high bit depth) not supported; convert to 8-bit first"
|
|
68
|
+
)
|
|
69
|
+
if im.mode not in ("L", "LA", "RGB", "RGBA"):
|
|
70
|
+
im = im.convert("RGB")
|
|
71
|
+
notes.append("converted to RGB")
|
|
72
|
+
meta["mode"] = im.mode
|
|
73
|
+
arr = np.asarray(im)
|
|
74
|
+
if arr.ndim == 2:
|
|
75
|
+
arr = arr[:, :, None]
|
|
76
|
+
return arr, meta
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# --------------------------------------------------------------------------
|
|
80
|
+
# PNG / WebP saving (lossless)
|
|
81
|
+
# --------------------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def save_png_webp(
|
|
85
|
+
arr: np.ndarray, meta: dict, out: Path, fmt: str, dpi: tuple[float, float]
|
|
86
|
+
) -> None:
|
|
87
|
+
mode = meta.get("mode", "RGB")
|
|
88
|
+
im = Image.fromarray(arr[:, :, 0] if arr.shape[2] == 1 else arr, mode=mode)
|
|
89
|
+
kw = {}
|
|
90
|
+
if meta.get("icc_profile"):
|
|
91
|
+
kw["icc_profile"] = meta["icc_profile"]
|
|
92
|
+
if meta.get("exif"):
|
|
93
|
+
kw["exif"] = meta["exif"]
|
|
94
|
+
if fmt == "png":
|
|
95
|
+
im.save(out, format="PNG", dpi=dpi, **kw)
|
|
96
|
+
else:
|
|
97
|
+
# exact=True: keep RGB values under fully-transparent alpha
|
|
98
|
+
im.save(out, format="WEBP", lossless=True, quality=80, exact=True, **kw)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# --------------------------------------------------------------------------
|
|
102
|
+
# JPEG DCT surgery
|
|
103
|
+
# --------------------------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
_DCT_M = None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _dct_matrix() -> np.ndarray:
|
|
109
|
+
global _DCT_M
|
|
110
|
+
if _DCT_M is None:
|
|
111
|
+
i = np.arange(8, dtype=np.float64)[:, None]
|
|
112
|
+
j = np.arange(8, dtype=np.float64)[None, :]
|
|
113
|
+
m = np.cos((2 * j + 1) * i * np.pi / 16)
|
|
114
|
+
m[0] *= math.sqrt(1 / 8)
|
|
115
|
+
m[1:] *= math.sqrt(2 / 8)
|
|
116
|
+
_DCT_M = m
|
|
117
|
+
return _DCT_M
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _plane_to_qblocks(plane: np.ndarray, qt: np.ndarray) -> np.ndarray:
|
|
121
|
+
"""(h,w) float plane -> quantized DCT blocks (bv,bh,8,8) int16."""
|
|
122
|
+
h, w = plane.shape
|
|
123
|
+
bv, bh = -(-h // 8), -(-w // 8)
|
|
124
|
+
plane = np.pad(plane, ((0, bv * 8 - h), (0, bh * 8 - w)), mode="edge")
|
|
125
|
+
blocks = plane.reshape(bv, 8, bh, 8).transpose(0, 2, 1, 3) - 128.0
|
|
126
|
+
D = _dct_matrix()
|
|
127
|
+
coeff = np.einsum("ik,vhkl,jl->vhij", D, blocks, D)
|
|
128
|
+
return np.rint(coeff / qt[None, None]).astype(np.int16)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _subsample(plane: np.ndarray, fx: int, fy: int) -> np.ndarray:
|
|
132
|
+
if fx == 1 and fy == 1:
|
|
133
|
+
return plane
|
|
134
|
+
h, w = plane.shape
|
|
135
|
+
plane = np.pad(plane, ((0, (-h) % fy), (0, (-w) % fx)), mode="edge")
|
|
136
|
+
return plane.reshape(plane.shape[0] // fy, fy, plane.shape[1] // fx, fx).mean(
|
|
137
|
+
axis=(1, 3)
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def jpeg_factors(dct, ci: int) -> tuple[int, int]:
|
|
142
|
+
"""Subsampling divisors (fx, fy) of component ci.
|
|
143
|
+
|
|
144
|
+
jpeglib's samp_factor rows are ordered [vertical, horizontal].
|
|
145
|
+
"""
|
|
146
|
+
sf = np.asarray(dct.samp_factor)
|
|
147
|
+
hmax, vmax = int(sf[:, 1].max()), int(sf[:, 0].max())
|
|
148
|
+
return hmax // int(sf[ci, 1]), vmax // int(sf[ci, 0])
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def jpeg_mcu(dct) -> tuple[int, int]:
|
|
152
|
+
"""(mcu_width, mcu_height) in luma pixels."""
|
|
153
|
+
sf = np.asarray(dct.samp_factor)
|
|
154
|
+
return 8 * int(sf[:, 1].max()), 8 * int(sf[:, 0].max())
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def jpeg_paste_box(
|
|
158
|
+
dct,
|
|
159
|
+
ci: int,
|
|
160
|
+
size0: tuple[int, int],
|
|
161
|
+
extents: tuple[int, int, int, int],
|
|
162
|
+
halo_overwrite: bool,
|
|
163
|
+
trim_px: int = TRIM_CAP,
|
|
164
|
+
) -> tuple[slice, slice, int, int]:
|
|
165
|
+
"""Block region of component ci that stays bit-exact.
|
|
166
|
+
|
|
167
|
+
Returns (rows, cols, off_v, off_h): slices into the NEW block grid and the
|
|
168
|
+
placement offset of the original component blocks.
|
|
169
|
+
"""
|
|
170
|
+
W0, H0 = size0
|
|
171
|
+
L, T, R, B = extents
|
|
172
|
+
fx, fy = jpeg_factors(dct, ci)
|
|
173
|
+
comps = [dct.Y, dct.Cb, dct.Cr][ci] if dct.Cb is not None else dct.Y
|
|
174
|
+
ov, oh = comps.shape[:2]
|
|
175
|
+
off_v, off_h = (T // fy) // 8, (L // fx) // 8
|
|
176
|
+
ch0, cw0 = -(-H0 // fy), -(-W0 // fx) # component pixel dims (original)
|
|
177
|
+
lo_v, lo_h = off_v, off_h
|
|
178
|
+
hi_v, hi_h = off_v + ov, off_h + oh
|
|
179
|
+
# straddling last block row/col contains encoder padding that would become
|
|
180
|
+
# visible next to a new extension -> re-encode it (content preserved in
|
|
181
|
+
# pixel space; requantized with the original tables)
|
|
182
|
+
if B > 0 and ch0 % 8:
|
|
183
|
+
hi_v -= 1
|
|
184
|
+
if R > 0 and cw0 % 8:
|
|
185
|
+
hi_h -= 1
|
|
186
|
+
if halo_overwrite:
|
|
187
|
+
# opt-in: re-encode the outer block ring so the rewritten halo pixels
|
|
188
|
+
# actually land in the file — only on edges that were extended, and
|
|
189
|
+
# deep enough to cover the trimmed pixels
|
|
190
|
+
rx = max(1, -(-trim_px // (8 * fx)))
|
|
191
|
+
ry = max(1, -(-trim_px // (8 * fy)))
|
|
192
|
+
if L > 0:
|
|
193
|
+
lo_h += rx
|
|
194
|
+
if T > 0:
|
|
195
|
+
lo_v += ry
|
|
196
|
+
if R > 0:
|
|
197
|
+
hi_h -= rx
|
|
198
|
+
if B > 0:
|
|
199
|
+
hi_v -= ry
|
|
200
|
+
return slice(lo_v, max(lo_v, hi_v)), slice(lo_h, max(lo_h, hi_h)), off_v, off_h
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def save_jpeg(
|
|
204
|
+
arr: np.ndarray,
|
|
205
|
+
meta: dict,
|
|
206
|
+
out: Path,
|
|
207
|
+
size0: tuple[int, int],
|
|
208
|
+
extents: tuple[int, int, int, int],
|
|
209
|
+
halo_overwrite: bool,
|
|
210
|
+
trim_px: int = TRIM_CAP,
|
|
211
|
+
) -> None:
|
|
212
|
+
import jpeglib
|
|
213
|
+
|
|
214
|
+
dct = meta["dct"]
|
|
215
|
+
H1, W1, _C = arr.shape
|
|
216
|
+
ncomp = dct.num_components
|
|
217
|
+
f = arr.astype(np.float32)
|
|
218
|
+
if ncomp == 1:
|
|
219
|
+
planes = [f[:, :, 0]]
|
|
220
|
+
else:
|
|
221
|
+
R, G, B = f[:, :, 0], f[:, :, 1], f[:, :, 2]
|
|
222
|
+
planes = [
|
|
223
|
+
0.299 * R + 0.587 * G + 0.114 * B,
|
|
224
|
+
128.0 - 0.168735892 * R - 0.331264108 * G + 0.5 * B,
|
|
225
|
+
128.0 + 0.5 * R - 0.418687589 * G - 0.081312411 * B,
|
|
226
|
+
]
|
|
227
|
+
|
|
228
|
+
orig = [dct.Y, dct.Cb, dct.Cr][:ncomp]
|
|
229
|
+
comps = []
|
|
230
|
+
for ci in range(ncomp):
|
|
231
|
+
fx, fy = jpeg_factors(dct, ci)
|
|
232
|
+
qt = dct.get_component_qt(ci)
|
|
233
|
+
plane = _subsample(planes[ci], fx, fy)
|
|
234
|
+
blocks = _plane_to_qblocks(plane, np.asarray(qt, dtype=np.float64))
|
|
235
|
+
rows, cols, off_v, off_h = jpeg_paste_box(
|
|
236
|
+
dct, ci, size0, extents, halo_overwrite, trim_px
|
|
237
|
+
)
|
|
238
|
+
src = orig[ci]
|
|
239
|
+
blocks[rows, cols] = src[
|
|
240
|
+
rows.start - off_v : rows.stop - off_v,
|
|
241
|
+
cols.start - off_h : cols.stop - off_h,
|
|
242
|
+
]
|
|
243
|
+
comps.append(np.ascontiguousarray(blocks))
|
|
244
|
+
|
|
245
|
+
# jpeglib's signature declares Cb/Cr as required arrays, but None is the
|
|
246
|
+
# documented value for grayscale files
|
|
247
|
+
new = jpeglib.from_dct(
|
|
248
|
+
Y=comps[0],
|
|
249
|
+
Cb=comps[1] if ncomp > 1 else None, # pyright: ignore[reportArgumentType]
|
|
250
|
+
Cr=comps[2] if ncomp > 1 else None, # pyright: ignore[reportArgumentType]
|
|
251
|
+
qt=dct.qt,
|
|
252
|
+
quant_tbl_no=list(np.asarray(dct.quant_tbl_no)),
|
|
253
|
+
)
|
|
254
|
+
new.width, new.height = W1, H1
|
|
255
|
+
with contextlib.suppress(Exception):
|
|
256
|
+
new.markers = dct.markers
|
|
257
|
+
new.write_dct(str(out))
|