imagecalculator 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.
- imagecalculator/__init__.py +27 -0
- imagecalculator/_version.py +1 -0
- imagecalculator/bands.py +94 -0
- imagecalculator/cli.py +128 -0
- imagecalculator/codec.py +331 -0
- imagecalculator/config.py +163 -0
- imagecalculator/image.py +69 -0
- imagecalculator/policy.py +91 -0
- imagecalculator/py.typed +0 -0
- imagecalculator/tokens.py +175 -0
- imagecalculator/transform.py +212 -0
- imagecalculator-0.1.0.dist-info/METADATA +160 -0
- imagecalculator-0.1.0.dist-info/RECORD +16 -0
- imagecalculator-0.1.0.dist-info/WHEEL +4 -0
- imagecalculator-0.1.0.dist-info/entry_points.txt +2 -0
- imagecalculator-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""imagecalculator: progressive block-DCT image codec.
|
|
2
|
+
|
|
3
|
+
Tier 1 -- truncation reduces your token count::
|
|
4
|
+
|
|
5
|
+
enc = ic.compress("photo.jpg", keep=8) # 8 of 63 diagonals -> 112 px for the model
|
|
6
|
+
img = ic.decompress(enc) # PIL image at the reduced native size
|
|
7
|
+
|
|
8
|
+
Tier 2 -- truncation + per-diagonal quantization, YCrCb, chroma subsampling and
|
|
9
|
+
entropy coding reduce the bytes over the air (``enc.nbytes``).
|
|
10
|
+
"""
|
|
11
|
+
from ._version import __version__
|
|
12
|
+
from .bands import decode_payload, diag_support_index, encode_payload
|
|
13
|
+
from .codec import EncodedImage, Encoder, compress, decode, decompress, load
|
|
14
|
+
from .config import IC_BLOCK_SIZES, IC_QUANTIZATION_FACTORS, SUBSAMPLING_STRATEGIES, CodecConfig, quantization_bands
|
|
15
|
+
from .image import load_image, load_pil, png_b64, png_bytes, to_pil
|
|
16
|
+
from .policy import DiagonalPolicy
|
|
17
|
+
from .tokens import TokenEstimate, estimate as token_estimate
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"__version__",
|
|
21
|
+
"CodecConfig", "quantization_bands", "IC_BLOCK_SIZES", "IC_QUANTIZATION_FACTORS", "SUBSAMPLING_STRATEGIES",
|
|
22
|
+
"DiagonalPolicy",
|
|
23
|
+
"Encoder", "EncodedImage", "compress", "decompress", "decode", "load",
|
|
24
|
+
"load_image", "load_pil", "to_pil", "png_bytes", "png_b64",
|
|
25
|
+
"token_estimate", "TokenEstimate",
|
|
26
|
+
"encode_payload", "decode_payload", "diag_support_index",
|
|
27
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
imagecalculator/bands.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Per-diagonal band payloads: support indices and the blosc record format.
|
|
2
|
+
|
|
3
|
+
A payload is ``[1-byte dtype code][blosc(zstd, BITSHUFFLE) of the compact
|
|
4
|
+
(3, n_d) integer array]`` -- byte-identical to one ``img_<i>/diag_<d>.col``
|
|
5
|
+
file of the research dir store.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from functools import lru_cache
|
|
10
|
+
|
|
11
|
+
import blosc
|
|
12
|
+
import numpy as np
|
|
13
|
+
|
|
14
|
+
DTYPE_CODES = {
|
|
15
|
+
"int8": 0, "int16": 1, "int32": 2, "int64": 3,
|
|
16
|
+
"uint8": 4, "uint16": 5, "uint32": 6, "uint64": 7,
|
|
17
|
+
}
|
|
18
|
+
CODE_TO_DTYPE = {v: k for k, v in DTYPE_CODES.items()}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@lru_cache(maxsize=64)
|
|
22
|
+
def diag_support_index(image_size: int, block_size: int) -> tuple[np.ndarray, ...]:
|
|
23
|
+
"""Flat (C-raster) positions of each anti-diagonal's support in an ``S x S`` plane.
|
|
24
|
+
|
|
25
|
+
Position ``(i, j)`` belongs to anti-diagonal ``(i % N) + (j % N)``. Entry
|
|
26
|
+
``d`` of the result gathers and scatters diagonal ``d`` with the same index,
|
|
27
|
+
so a compact band round-trips to its dense positions exactly.
|
|
28
|
+
"""
|
|
29
|
+
S, N = image_size, block_size
|
|
30
|
+
within = np.arange(S) % N
|
|
31
|
+
antidiag = (within[:, None] + within[None, :]).ravel()
|
|
32
|
+
return tuple(np.flatnonzero(antidiag == d) for d in range(2 * N - 1))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def diag_support_sizes(image_size: int, block_size: int) -> tuple[int, ...]:
|
|
36
|
+
return tuple(int(ix.size) for ix in diag_support_index(image_size, block_size))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def choose_int_dtype(min_val: int, max_val: int) -> np.dtype:
|
|
40
|
+
if min_val >= 0:
|
|
41
|
+
for t in (np.uint8, np.uint16, np.uint32):
|
|
42
|
+
if max_val <= np.iinfo(t).max:
|
|
43
|
+
return np.dtype(t)
|
|
44
|
+
return np.dtype(np.uint64)
|
|
45
|
+
for t in (np.int8, np.int16, np.int32):
|
|
46
|
+
if min_val >= np.iinfo(t).min and max_val <= np.iinfo(t).max:
|
|
47
|
+
return np.dtype(t)
|
|
48
|
+
return np.dtype(np.int64)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def encode_blob(arr: np.ndarray, clevel: int) -> tuple[bytes, np.dtype]:
|
|
52
|
+
"""Narrow an int64 array to the smallest dtype holding its range and blosc-compress it."""
|
|
53
|
+
arr = arr if arr.dtype == np.int64 else arr.astype(np.int64)
|
|
54
|
+
dtype = choose_int_dtype(int(arr.min()), int(arr.max()))
|
|
55
|
+
arr = np.ascontiguousarray(arr.astype(dtype, copy=False))
|
|
56
|
+
blob = blosc.compress(
|
|
57
|
+
arr.tobytes(),
|
|
58
|
+
typesize=arr.itemsize,
|
|
59
|
+
cname="zstd",
|
|
60
|
+
clevel=int(clevel),
|
|
61
|
+
shuffle=blosc.BITSHUFFLE,
|
|
62
|
+
)
|
|
63
|
+
return blob, dtype
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def decode_blob(blob: bytes, dtype: str | np.dtype, shape: tuple[int, ...]) -> np.ndarray:
|
|
67
|
+
raw = blosc.decompress(blob)
|
|
68
|
+
return np.frombuffer(raw, dtype=np.dtype(dtype)).reshape(shape).astype(np.int64)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def encode_payload(compact: np.ndarray, clevel: int) -> bytes:
|
|
72
|
+
"""``(3, n_d)`` int64 -> ``[dtype code][blob]`` (the dir-store file content)."""
|
|
73
|
+
blob, dtype = encode_blob(compact, clevel)
|
|
74
|
+
return bytes((DTYPE_CODES[dtype.name],)) + blob
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def decode_payload(payload: bytes, n_d: int) -> np.ndarray:
|
|
78
|
+
"""``[dtype code][blob]`` -> ``(3, n_d)`` int64."""
|
|
79
|
+
if len(payload) < 2:
|
|
80
|
+
raise ValueError("payload too short")
|
|
81
|
+
code = payload[0]
|
|
82
|
+
if code not in CODE_TO_DTYPE:
|
|
83
|
+
raise ValueError(f"unknown dtype code {code}")
|
|
84
|
+
return decode_blob(payload[1:], CODE_TO_DTYPE[code], (3, n_d))
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def compact_to_dense(compact: np.ndarray, index: np.ndarray, image_size: int) -> np.ndarray:
|
|
88
|
+
dense = np.zeros((3, image_size * image_size), dtype=compact.dtype)
|
|
89
|
+
dense[:, index] = compact
|
|
90
|
+
return dense.reshape(3, image_size, image_size)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def dense_to_compact(dense: np.ndarray, index: np.ndarray) -> np.ndarray:
|
|
94
|
+
return np.ascontiguousarray(dense.reshape(3, -1)[:, index])
|
imagecalculator/cli.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""``imagecalculator compress | decompress | info``."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from . import __version__
|
|
10
|
+
from .codec import EncodedImage, compress, decompress
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _parse_quantization(text: str | None):
|
|
14
|
+
if text is None:
|
|
15
|
+
return None
|
|
16
|
+
text = text.strip()
|
|
17
|
+
if text.startswith("["):
|
|
18
|
+
return json.loads(text)
|
|
19
|
+
return int(text)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _parse_keep(text: str):
|
|
23
|
+
if text == "all":
|
|
24
|
+
return "all"
|
|
25
|
+
if "." in text or text.endswith("%"):
|
|
26
|
+
v = float(text.rstrip("%"))
|
|
27
|
+
return v / 100.0 if text.endswith("%") else v
|
|
28
|
+
return int(text)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _fmt_bytes(n: int) -> str:
|
|
32
|
+
return f"{n / 1024:.1f} KiB" if n >= 1024 else f"{n} B"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def cmd_compress(a: argparse.Namespace) -> int:
|
|
36
|
+
enc = compress(
|
|
37
|
+
a.image, keep=_parse_keep(a.keep), quantization=_parse_quantization(a.quantization),
|
|
38
|
+
block_size=a.block_size, image_size=a.image_size, use_ycrcb=not a.no_ycrcb,
|
|
39
|
+
subsampling=a.subsampling, clevel=a.clevel, device=a.device,
|
|
40
|
+
)
|
|
41
|
+
out = Path(a.output) if a.output else Path(a.image).with_suffix(".icf")
|
|
42
|
+
enc.save(out)
|
|
43
|
+
_print_info(enc, original=a.image, provider=a.provider, detail=a.detail)
|
|
44
|
+
print(f"wrote {out}")
|
|
45
|
+
return 0
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def cmd_decompress(a: argparse.Namespace) -> int:
|
|
49
|
+
size: str | int = a.size if a.size in ("native", "full") else int(a.size)
|
|
50
|
+
img = decompress(a.icf, size=size, device=a.device)
|
|
51
|
+
out = Path(a.output) if a.output else Path(a.icf).with_suffix(".png")
|
|
52
|
+
img.save(out)
|
|
53
|
+
print(f"wrote {out} ({img.size[0]}x{img.size[1]})")
|
|
54
|
+
return 0
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _print_info(enc: EncodedImage, original: str | None = None, provider: str = "anthropic", detail: str | None = None) -> None:
|
|
58
|
+
c = enc.config
|
|
59
|
+
sv = enc.savings(original, provider, detail)
|
|
60
|
+
q = c.quantization if c.is_uniform else f"schedule(min={c.quantization_factor})"
|
|
61
|
+
print(f"config: block {c.block_size}, image {c.image_size}, ycrcb={c.use_ycrcb}, subsampling={c.subsampling}, q={q}, clevel={c.clevel}")
|
|
62
|
+
print(f"keep: {enc.keep_k}/{c.max_diag} diagonals -> native decode {enc.pixels[0]}x{enc.pixels[1]} px")
|
|
63
|
+
t = sv["tokens"]
|
|
64
|
+
print(f"tokens: {t['reduced']} (full image {t['full_image']}, {t['provider']} estimate, {t['ratio']:.1f}x fewer)")
|
|
65
|
+
b = sv["bytes"]
|
|
66
|
+
if b["original_file"]:
|
|
67
|
+
print(f"bytes over the air: {_fmt_bytes(b['over_the_air'])} (original file {_fmt_bytes(b['original_file'])}, {b['ratio']:.1f}x smaller)")
|
|
68
|
+
else:
|
|
69
|
+
print(f"bytes over the air: {_fmt_bytes(b['over_the_air'])}")
|
|
70
|
+
if enc.transform_label:
|
|
71
|
+
print(f"transform: {enc.transform_label} (not research-store compatible)")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def cmd_info(a: argparse.Namespace) -> int:
|
|
75
|
+
enc = EncodedImage.load(a.icf)
|
|
76
|
+
_print_info(enc, original=a.original, provider=a.provider, detail=a.detail)
|
|
77
|
+
if a.json:
|
|
78
|
+
print(json.dumps(enc.savings(a.original, a.provider, a.detail), indent=2))
|
|
79
|
+
return 0
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _add_token_args(p: argparse.ArgumentParser) -> None:
|
|
83
|
+
p.add_argument("--provider", default="anthropic", help="token estimate: anthropic | openai[:model] | google[:model]")
|
|
84
|
+
p.add_argument("--detail", default=None, help="OpenAI detail or Gemini media_resolution (default: the API default)")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
88
|
+
p = argparse.ArgumentParser(prog="imagecalculator", description="Progressive block-DCT image codec")
|
|
89
|
+
p.add_argument("--version", action="version", version=f"imagecalculator {__version__}")
|
|
90
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
91
|
+
|
|
92
|
+
c = sub.add_parser("compress", help="truncate + quantize an image into an .icf blob")
|
|
93
|
+
c.add_argument("image")
|
|
94
|
+
c.add_argument("--keep", default="all", help="diagonals to keep: int, fraction (0.25) or percent (25%%), or 'all'")
|
|
95
|
+
c.add_argument("--quantization", "-q", default=None, help="scalar step, or JSON bands [[from,to,q],...]")
|
|
96
|
+
c.add_argument("--block-size", type=int, default=32)
|
|
97
|
+
c.add_argument("--image-size", type=int, default=448)
|
|
98
|
+
c.add_argument("--no-ycrcb", action="store_true")
|
|
99
|
+
c.add_argument("--subsampling", default="1x1", choices=["1x1", "2x2-r", "2x2-c", "4x4"])
|
|
100
|
+
c.add_argument("--clevel", type=int, default=9)
|
|
101
|
+
c.add_argument("--device", default="cpu")
|
|
102
|
+
c.add_argument("-o", "--output")
|
|
103
|
+
_add_token_args(c)
|
|
104
|
+
c.set_defaults(fn=cmd_compress)
|
|
105
|
+
|
|
106
|
+
d = sub.add_parser("decompress", help="decode an .icf blob to PNG")
|
|
107
|
+
d.add_argument("icf")
|
|
108
|
+
d.add_argument("--size", default="native", help="native | full | <side px>")
|
|
109
|
+
d.add_argument("--device", default="cpu")
|
|
110
|
+
d.add_argument("-o", "--output")
|
|
111
|
+
d.set_defaults(fn=cmd_decompress)
|
|
112
|
+
|
|
113
|
+
i = sub.add_parser("info", help="print config, keep, tokens and bytes")
|
|
114
|
+
i.add_argument("icf")
|
|
115
|
+
i.add_argument("--original", help="original image file for the byte baseline")
|
|
116
|
+
i.add_argument("--json", action="store_true")
|
|
117
|
+
_add_token_args(i)
|
|
118
|
+
i.set_defaults(fn=cmd_info)
|
|
119
|
+
return p
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def main(argv: list[str] | None = None) -> int:
|
|
123
|
+
a = build_parser().parse_args(argv)
|
|
124
|
+
return a.fn(a)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
if __name__ == "__main__":
|
|
128
|
+
sys.exit(main())
|
imagecalculator/codec.py
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
"""Encoder / EncodedImage / decode: the compress-for-the-model path.
|
|
2
|
+
|
|
3
|
+
Pipeline (bit-exact with the research dir-store builder when no ``transform``
|
|
4
|
+
is used): load -> resize -> [YCrCb -> chroma subsample] -> block DCT ->
|
|
5
|
+
``round(coeff / q[d])`` -> keep the first ``k`` anti-diagonals -> one compact
|
|
6
|
+
blosc payload per diagonal. Decoding runs an ``m``-point inverse DCT on each
|
|
7
|
+
block's low corner, so ``k`` diagonals come back as an ``S*k/N`` px image.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import struct
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Sequence
|
|
17
|
+
|
|
18
|
+
import numpy as np
|
|
19
|
+
import torch
|
|
20
|
+
from PIL import Image
|
|
21
|
+
from torch import Tensor
|
|
22
|
+
|
|
23
|
+
from . import tokens as _tokens
|
|
24
|
+
from .bands import decode_payload, diag_support_index, encode_payload
|
|
25
|
+
from .config import CodecConfig, quantization_bands
|
|
26
|
+
from .image import ImageLike, load_image, to_pil
|
|
27
|
+
from .policy import DiagonalPolicy, ResolvedPolicy
|
|
28
|
+
from .transform import deblockify, forward_plane, inverse_plane
|
|
29
|
+
|
|
30
|
+
MAGIC = b"ICF1"
|
|
31
|
+
FORMAT_VERSION = 1
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# ---------------------------------------------------------------------------
|
|
35
|
+
# EncodedImage
|
|
36
|
+
# ---------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class EncodedImage:
|
|
40
|
+
"""One image's kept diagonals as compressed payloads.
|
|
41
|
+
|
|
42
|
+
``payloads[d]`` is byte-identical to ``img_<i>/diag_<d>.col`` of a research
|
|
43
|
+
dir store built with the same config (``transform=None``).
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
config: CodecConfig
|
|
47
|
+
payloads: list[bytes]
|
|
48
|
+
transform_label: str | None = None
|
|
49
|
+
_support: tuple[int, ...] = field(default_factory=tuple, repr=False, compare=False)
|
|
50
|
+
|
|
51
|
+
def __post_init__(self) -> None:
|
|
52
|
+
if not self.payloads:
|
|
53
|
+
raise ValueError("an EncodedImage needs at least one diagonal")
|
|
54
|
+
if len(self.payloads) > self.config.max_diag:
|
|
55
|
+
raise ValueError(f"{len(self.payloads)} payloads but max_diag is {self.config.max_diag}")
|
|
56
|
+
if not self._support:
|
|
57
|
+
self._support = tuple(int(ix.size) for ix in diag_support_index(self.config.image_size, self.config.block_size))
|
|
58
|
+
|
|
59
|
+
# -- geometry ------------------------------------------------------------
|
|
60
|
+
@property
|
|
61
|
+
def keep_k(self) -> int:
|
|
62
|
+
return len(self.payloads)
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def is_truncated(self) -> bool:
|
|
66
|
+
return self.keep_k < self.config.max_diag
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def native_m(self) -> int:
|
|
70
|
+
"""Output block size of the native decode: the smallest corner holding the kept triangle."""
|
|
71
|
+
return min(self.keep_k, self.config.block_size)
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def native_side(self) -> int:
|
|
75
|
+
return self.config.image_size * self.native_m // self.config.block_size
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def pixels(self) -> tuple[int, int]:
|
|
79
|
+
"""``(width, height)`` of the native-size decode (tier 1: what the model sees)."""
|
|
80
|
+
return (self.native_side, self.native_side)
|
|
81
|
+
|
|
82
|
+
def valid_sizes(self) -> list[int]:
|
|
83
|
+
S, N = self.config.image_size, self.config.block_size
|
|
84
|
+
return [S * m // N for m in range(self.native_m, N + 1)]
|
|
85
|
+
|
|
86
|
+
# -- bytes ---------------------------------------------------------------
|
|
87
|
+
@property
|
|
88
|
+
def payload_bytes(self) -> int:
|
|
89
|
+
return sum(len(p) for p in self.payloads)
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def nbytes(self) -> int:
|
|
93
|
+
"""Bytes over the air: the ``.icf`` container (header + kept payloads) (tier 2)."""
|
|
94
|
+
return len(self.to_bytes())
|
|
95
|
+
|
|
96
|
+
def cumulative_bytes(self) -> np.ndarray:
|
|
97
|
+
"""int64 ``(keep_k,)``: payload bytes through each kept diagonal."""
|
|
98
|
+
return np.cumsum([len(p) for p in self.payloads], dtype=np.int64)
|
|
99
|
+
|
|
100
|
+
# -- tokens --------------------------------------------------------------
|
|
101
|
+
def token_estimate(self, provider: str = "anthropic", detail: str | None = None) -> _tokens.TokenEstimate:
|
|
102
|
+
"""``provider`` is ``"anthropic"``, ``"openai[:model]"`` or ``"google[:model]"``."""
|
|
103
|
+
w, h = self.pixels
|
|
104
|
+
return _tokens.estimate(w, h, provider, detail)
|
|
105
|
+
|
|
106
|
+
def savings(self, original: str | Path | int | None = None, provider: str = "anthropic",
|
|
107
|
+
detail: str | None = None) -> dict:
|
|
108
|
+
"""Tier 1 and tier 2 numbers next to their full-image baselines."""
|
|
109
|
+
S = self.config.image_size
|
|
110
|
+
full = _tokens.estimate(S, S, provider, detail)
|
|
111
|
+
red = self.token_estimate(provider, detail)
|
|
112
|
+
if isinstance(original, (str, Path)):
|
|
113
|
+
orig_bytes: int | None = os.path.getsize(original)
|
|
114
|
+
elif isinstance(original, int):
|
|
115
|
+
orig_bytes = original
|
|
116
|
+
else:
|
|
117
|
+
orig_bytes = None
|
|
118
|
+
out = {
|
|
119
|
+
"tokens": {"reduced": red.tokens, "full_image": full.tokens, "provider": provider},
|
|
120
|
+
"pixels": {"reduced": self.pixels, "full_image": (S, S)},
|
|
121
|
+
"bytes": {"over_the_air": self.nbytes, "original_file": orig_bytes},
|
|
122
|
+
"keep": self.keep_k,
|
|
123
|
+
"max_diag": self.config.max_diag,
|
|
124
|
+
}
|
|
125
|
+
if orig_bytes:
|
|
126
|
+
out["bytes"]["ratio"] = orig_bytes / self.nbytes
|
|
127
|
+
out["tokens"]["ratio"] = full.tokens / red.tokens
|
|
128
|
+
return out
|
|
129
|
+
|
|
130
|
+
# -- coefficients --------------------------------------------------------
|
|
131
|
+
def coefficients(self, d: int) -> np.ndarray:
|
|
132
|
+
"""Quantized integer coefficients of diagonal ``d`` as ``(3, n_d)`` int64."""
|
|
133
|
+
if not (0 <= d < self.keep_k):
|
|
134
|
+
raise IndexError(f"diagonal {d} not kept (keep_k={self.keep_k})")
|
|
135
|
+
return decode_payload(self.payloads[d], self._support[d])
|
|
136
|
+
|
|
137
|
+
def dense_plane(self) -> np.ndarray:
|
|
138
|
+
"""Dequantized coefficients scattered into a dense ``(3, S, S)`` float32 plane (research reader arithmetic)."""
|
|
139
|
+
S = self.config.image_size
|
|
140
|
+
idx = diag_support_index(S, self.config.block_size)
|
|
141
|
+
q = self.config.q_vector
|
|
142
|
+
acc = np.zeros((3, S * S), dtype=np.float32)
|
|
143
|
+
for d in range(self.keep_k):
|
|
144
|
+
vals = self.coefficients(d)
|
|
145
|
+
acc[:, idx[d]] += vals.astype(np.float32) * float(q[d])
|
|
146
|
+
return acc.reshape(3, S, S)
|
|
147
|
+
|
|
148
|
+
def truncate(self, k: int) -> "EncodedImage":
|
|
149
|
+
if not (1 <= k <= self.keep_k):
|
|
150
|
+
raise ValueError(f"k must be in 1..{self.keep_k}; got {k}")
|
|
151
|
+
return EncodedImage(self.config, list(self.payloads[:k]), self.transform_label, self._support)
|
|
152
|
+
|
|
153
|
+
# -- container -----------------------------------------------------------
|
|
154
|
+
def header(self) -> dict:
|
|
155
|
+
h = self.config.to_dict()
|
|
156
|
+
h.update({
|
|
157
|
+
"format_version": FORMAT_VERSION,
|
|
158
|
+
"keep": self.keep_k,
|
|
159
|
+
"lengths": [len(p) for p in self.payloads],
|
|
160
|
+
})
|
|
161
|
+
if self.transform_label is not None:
|
|
162
|
+
h["transform"] = self.transform_label
|
|
163
|
+
return h
|
|
164
|
+
|
|
165
|
+
def to_bytes(self) -> bytes:
|
|
166
|
+
hdr = json.dumps(self.header(), separators=(",", ":")).encode("utf-8")
|
|
167
|
+
return b"".join([MAGIC, struct.pack("<I", len(hdr)), hdr, *self.payloads])
|
|
168
|
+
|
|
169
|
+
@classmethod
|
|
170
|
+
def from_bytes(cls, data: bytes) -> "EncodedImage":
|
|
171
|
+
if data[:4] != MAGIC:
|
|
172
|
+
raise ValueError("not an .icf blob (bad magic)")
|
|
173
|
+
(hlen,) = struct.unpack("<I", data[4:8])
|
|
174
|
+
hdr = json.loads(data[8:8 + hlen].decode("utf-8"))
|
|
175
|
+
config = CodecConfig.from_dict(hdr)
|
|
176
|
+
lengths = [int(v) for v in hdr["lengths"]]
|
|
177
|
+
if len(lengths) != int(hdr["keep"]):
|
|
178
|
+
raise ValueError("corrupt header: keep != len(lengths)")
|
|
179
|
+
pos = 8 + hlen
|
|
180
|
+
payloads = []
|
|
181
|
+
for n in lengths:
|
|
182
|
+
payloads.append(data[pos:pos + n])
|
|
183
|
+
pos += n
|
|
184
|
+
if pos != len(data):
|
|
185
|
+
raise ValueError(f"container length mismatch: expected {pos} bytes, got {len(data)}")
|
|
186
|
+
return cls(config, payloads, hdr.get("transform"))
|
|
187
|
+
|
|
188
|
+
def save(self, path: str | Path) -> None:
|
|
189
|
+
Path(path).write_bytes(self.to_bytes())
|
|
190
|
+
|
|
191
|
+
@classmethod
|
|
192
|
+
def load(cls, path: str | Path) -> "EncodedImage":
|
|
193
|
+
return cls.from_bytes(Path(path).read_bytes())
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
# ---------------------------------------------------------------------------
|
|
197
|
+
# Encoder
|
|
198
|
+
# ---------------------------------------------------------------------------
|
|
199
|
+
|
|
200
|
+
class Encoder:
|
|
201
|
+
"""Encode images under one :class:`CodecConfig` on one device."""
|
|
202
|
+
|
|
203
|
+
def __init__(self, config: CodecConfig, device: torch.device | str = "cpu"):
|
|
204
|
+
self.config = config
|
|
205
|
+
self.device = torch.device(device)
|
|
206
|
+
S, N = config.image_size, config.block_size
|
|
207
|
+
self._support = diag_support_index(S, N)
|
|
208
|
+
# per-position diagonal index of the dense S x S plane
|
|
209
|
+
within = np.arange(S) % N
|
|
210
|
+
self._diag_of_pos = torch.from_numpy((within[:, None] + within[None, :]).ravel()).to(self.device)
|
|
211
|
+
|
|
212
|
+
def _plane(self, image: ImageLike) -> Tensor:
|
|
213
|
+
"""Un-quantized dense coefficient plane ``(3, S*S)`` float32 on the device."""
|
|
214
|
+
cfg = self.config
|
|
215
|
+
img = load_image(image, cfg.image_size, self.device)
|
|
216
|
+
blocks = forward_plane(img, cfg.block_size, cfg.use_ycrcb, cfg.subsampling)
|
|
217
|
+
return deblockify(blocks, (cfg.image_size, cfg.image_size)).reshape(3, -1)
|
|
218
|
+
|
|
219
|
+
def _quantize(self, plane: Tensor, policy: ResolvedPolicy) -> np.ndarray:
|
|
220
|
+
q_pos = torch.from_numpy(policy.q_vector).to(self.device)[self._diag_of_pos]
|
|
221
|
+
if policy.transform is not None:
|
|
222
|
+
plane = plane.clone()
|
|
223
|
+
for d in range(policy.keep_k):
|
|
224
|
+
ix = torch.from_numpy(self._support[d]).to(self.device)
|
|
225
|
+
plane[:, ix] = policy.transform(d, plane[:, ix])
|
|
226
|
+
return torch.round(plane / q_pos).to(torch.int64).cpu().numpy()
|
|
227
|
+
|
|
228
|
+
def encode(self, image: ImageLike, policy: DiagonalPolicy | None = None, transform_label: str | None = None) -> EncodedImage:
|
|
229
|
+
cfg = self.config
|
|
230
|
+
pol = (policy or DiagonalPolicy()).resolve(cfg.max_diag, cfg.q_vector, cfg.clevel)
|
|
231
|
+
plane_q = self._quantize(self._plane(image), pol)
|
|
232
|
+
payloads = [
|
|
233
|
+
encode_payload(np.ascontiguousarray(plane_q[:, self._support[d]]), pol.clevels[d])
|
|
234
|
+
for d in range(pol.keep_k)
|
|
235
|
+
]
|
|
236
|
+
eff_cfg = cfg
|
|
237
|
+
if not np.array_equal(pol.q_vector, cfg.q_vector):
|
|
238
|
+
qv = [int(v) for v in pol.q_vector]
|
|
239
|
+
quant = qv[0] if len(set(qv)) == 1 else tuple(qv)
|
|
240
|
+
eff_cfg = CodecConfig(cfg.block_size, cfg.image_size, cfg.use_ycrcb, cfg.subsampling, quant, cfg.clevel)
|
|
241
|
+
label = transform_label if transform_label is not None else ("custom" if pol.transform is not None else None)
|
|
242
|
+
return EncodedImage(eff_cfg, payloads, label, tuple(int(ix.size) for ix in self._support))
|
|
243
|
+
|
|
244
|
+
def coefficient_plane(self, image: ImageLike) -> Tensor:
|
|
245
|
+
"""Quantized integer coefficient plane ``(1, 3, S, S)`` (config quantization, nothing truncated)."""
|
|
246
|
+
cfg = self.config
|
|
247
|
+
pol = DiagonalPolicy().resolve(cfg.max_diag, cfg.q_vector, cfg.clevel)
|
|
248
|
+
plane_q = self._quantize(self._plane(image), pol)
|
|
249
|
+
return torch.from_numpy(plane_q.reshape(3, cfg.image_size, cfg.image_size)).unsqueeze(0)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
# ---------------------------------------------------------------------------
|
|
253
|
+
# Decoding
|
|
254
|
+
# ---------------------------------------------------------------------------
|
|
255
|
+
|
|
256
|
+
def _resolve_m(enc: EncodedImage, size: str | int) -> int:
|
|
257
|
+
S, N = enc.config.image_size, enc.config.block_size
|
|
258
|
+
if size == "native":
|
|
259
|
+
return enc.native_m
|
|
260
|
+
if size == "full":
|
|
261
|
+
return N
|
|
262
|
+
side = int(size)
|
|
263
|
+
if side * N % S != 0:
|
|
264
|
+
raise ValueError(f"size {side} is not S*m/N for an integer m; valid sizes: {enc.valid_sizes()}")
|
|
265
|
+
m = side * N // S
|
|
266
|
+
if not (enc.native_m <= m <= N):
|
|
267
|
+
raise ValueError(f"size {side} is outside the valid range; valid sizes: {enc.valid_sizes()}")
|
|
268
|
+
return m
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def decode(encoded: EncodedImage, size: str | int = "native", device: torch.device | str = "cpu") -> Tensor:
|
|
272
|
+
"""RGB float32 ``(1, 3, h, w)`` in [0, 255] at ``size`` = ``"native"`` (``S*k/N``), ``"full"`` or an int side."""
|
|
273
|
+
m = _resolve_m(encoded, size)
|
|
274
|
+
plane = torch.from_numpy(encoded.dense_plane()).unsqueeze(0).to(device)
|
|
275
|
+
return inverse_plane(plane, encoded.config.block_size, encoded.config.use_ycrcb, m)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def decompress(encoded: EncodedImage | bytes | str | Path, size: str | int = "native", device: torch.device | str = "cpu") -> Image.Image:
|
|
279
|
+
"""Decode to a PIL image (uint8 truncation, as the research reader)."""
|
|
280
|
+
if isinstance(encoded, (bytes, bytearray)):
|
|
281
|
+
encoded = EncodedImage.from_bytes(bytes(encoded))
|
|
282
|
+
elif isinstance(encoded, (str, Path)):
|
|
283
|
+
encoded = EncodedImage.load(encoded)
|
|
284
|
+
return to_pil(decode(encoded, size, device))
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
# ---------------------------------------------------------------------------
|
|
288
|
+
# One-call API
|
|
289
|
+
# ---------------------------------------------------------------------------
|
|
290
|
+
|
|
291
|
+
def _as_quantization(quantization, block_size: int):
|
|
292
|
+
if quantization is None:
|
|
293
|
+
return 1
|
|
294
|
+
if isinstance(quantization, (int, np.integer)):
|
|
295
|
+
return int(quantization)
|
|
296
|
+
seq = list(quantization)
|
|
297
|
+
if seq and isinstance(seq[0], (list, tuple)):
|
|
298
|
+
return quantization_bands(seq, block_size)
|
|
299
|
+
return tuple(int(v) for v in seq)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def compress(
|
|
303
|
+
image: ImageLike,
|
|
304
|
+
*,
|
|
305
|
+
keep: int | float | str = "all",
|
|
306
|
+
quantization: int | Sequence[int] | Sequence[Sequence[int]] | None = None,
|
|
307
|
+
block_size: int = 32,
|
|
308
|
+
image_size: int = 448,
|
|
309
|
+
use_ycrcb: bool = True,
|
|
310
|
+
subsampling: str = "1x1",
|
|
311
|
+
clevel: int = 9,
|
|
312
|
+
policy: DiagonalPolicy | None = None,
|
|
313
|
+
device: torch.device | str = "cpu",
|
|
314
|
+
) -> EncodedImage:
|
|
315
|
+
"""Truncate to ``keep`` diagonals, quantize per diagonal, and pack the compact payloads.
|
|
316
|
+
|
|
317
|
+
``keep`` sets the pixel/token budget (tier 1). ``quantization``, ``use_ycrcb``,
|
|
318
|
+
``subsampling`` and ``clevel`` set bytes per pixel (tier 2). ``policy`` is
|
|
319
|
+
the general form; when given, its ``keep``/``quantization``/``clevel`` win
|
|
320
|
+
over the keyword arguments they override (``None`` fields fall back).
|
|
321
|
+
"""
|
|
322
|
+
cfg = CodecConfig(block_size, image_size, use_ycrcb, subsampling, _as_quantization(quantization, block_size), clevel)
|
|
323
|
+
if policy is None:
|
|
324
|
+
policy = DiagonalPolicy(keep=keep)
|
|
325
|
+
elif policy.keep == "all" and keep != "all":
|
|
326
|
+
policy = DiagonalPolicy(keep, policy.quantization, policy.transform, policy.clevel)
|
|
327
|
+
return Encoder(cfg, device).encode(image, policy)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def load(path: str | Path) -> EncodedImage:
|
|
331
|
+
return EncodedImage.load(path)
|