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 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
@@ -0,0 +1,4 @@
1
+ from cardbleed import cli
2
+
3
+ if __name__ == "__main__":
4
+ cli()
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
@@ -0,0 +1,2 @@
1
+ class FileError(Exception):
2
+ """Per-file error: reported, file skipped, batch continues."""
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))