walsh 0.1.1__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.
- walsh/__init__.py +43 -0
- walsh/cli.py +104 -0
- walsh/colors.py +63 -0
- walsh/decorators.py +57 -0
- walsh/image.py +312 -0
- walsh/py.typed +0 -0
- walsh/task.py +226 -0
- walsh/transforms.py +96 -0
- walsh-0.1.1.dist-info/METADATA +176 -0
- walsh-0.1.1.dist-info/RECORD +14 -0
- walsh-0.1.1.dist-info/WHEEL +5 -0
- walsh-0.1.1.dist-info/entry_points.txt +2 -0
- walsh-0.1.1.dist-info/licenses/LICENSE +21 -0
- walsh-0.1.1.dist-info/top_level.txt +1 -0
walsh/__init__.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Image compression with the Walsh-Hadamard transform.
|
|
2
|
+
|
|
3
|
+
Typical use::
|
|
4
|
+
|
|
5
|
+
from walsh import Task
|
|
6
|
+
|
|
7
|
+
Task().with_action("compress").with_input("image.bmp").with_output("out.cim").run()
|
|
8
|
+
Task().with_action("extract").with_input("out.cim").with_output("back.bmp").run()
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
14
|
+
|
|
15
|
+
from walsh.colors import ColorModel, RgbColorModel, YCbCrColorModel
|
|
16
|
+
from walsh.image import (
|
|
17
|
+
BlockDescription,
|
|
18
|
+
BMPImage,
|
|
19
|
+
CustomizableImage,
|
|
20
|
+
UnsupportedFileFormatError,
|
|
21
|
+
)
|
|
22
|
+
from walsh.task import Action, Task
|
|
23
|
+
from walsh.transforms import Transform, WalshHadamardTransform
|
|
24
|
+
|
|
25
|
+
try:
|
|
26
|
+
__version__ = version("walsh")
|
|
27
|
+
except PackageNotFoundError: # pragma: no cover - source checkout without install
|
|
28
|
+
__version__ = "0.0.0.dev0"
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"Action",
|
|
32
|
+
"BMPImage",
|
|
33
|
+
"BlockDescription",
|
|
34
|
+
"ColorModel",
|
|
35
|
+
"CustomizableImage",
|
|
36
|
+
"RgbColorModel",
|
|
37
|
+
"Task",
|
|
38
|
+
"Transform",
|
|
39
|
+
"UnsupportedFileFormatError",
|
|
40
|
+
"WalshHadamardTransform",
|
|
41
|
+
"YCbCrColorModel",
|
|
42
|
+
"__version__",
|
|
43
|
+
]
|
walsh/cli.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Command line interface: ``walsh compress`` / ``walsh extract``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import logging
|
|
7
|
+
import sys
|
|
8
|
+
from collections.abc import Sequence
|
|
9
|
+
|
|
10
|
+
from walsh import __version__
|
|
11
|
+
from walsh.image import UnsupportedFileFormatError
|
|
12
|
+
from walsh.task import (
|
|
13
|
+
DEFAULT_CHROMA_BLOCK_SIZE,
|
|
14
|
+
DEFAULT_PACKED_BLOCK_SIZE,
|
|
15
|
+
DEFAULT_Y_BLOCK_SIZE,
|
|
16
|
+
Action,
|
|
17
|
+
Task,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
__all__ = ["build_parser", "main"]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
24
|
+
parser = argparse.ArgumentParser(
|
|
25
|
+
prog="walsh",
|
|
26
|
+
description="Compress and restore images with the Walsh-Hadamard transform.",
|
|
27
|
+
)
|
|
28
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
29
|
+
parser.add_argument(
|
|
30
|
+
"-v",
|
|
31
|
+
"--verbose",
|
|
32
|
+
action="count",
|
|
33
|
+
default=0,
|
|
34
|
+
help="increase log verbosity (repeat for debug output)",
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
subparsers = parser.add_subparsers(dest="action", required=True)
|
|
38
|
+
|
|
39
|
+
compress = subparsers.add_parser(
|
|
40
|
+
Action.COMPRESS.value, help="transform a 24-bit BMP into a .cim file"
|
|
41
|
+
)
|
|
42
|
+
compress.add_argument("input", help="path to a 24-bit uncompressed BMP")
|
|
43
|
+
compress.add_argument("output", help="path of the .cim file to write")
|
|
44
|
+
compress.add_argument(
|
|
45
|
+
"--y-block-size",
|
|
46
|
+
type=int,
|
|
47
|
+
default=DEFAULT_Y_BLOCK_SIZE,
|
|
48
|
+
help="luma block edge (default: %(default)s)",
|
|
49
|
+
)
|
|
50
|
+
compress.add_argument(
|
|
51
|
+
"--chroma-block-size",
|
|
52
|
+
type=int,
|
|
53
|
+
default=DEFAULT_CHROMA_BLOCK_SIZE,
|
|
54
|
+
help="Cb/Cr block edge (default: %(default)s)",
|
|
55
|
+
)
|
|
56
|
+
compress.add_argument(
|
|
57
|
+
"--packed-block-size",
|
|
58
|
+
type=int,
|
|
59
|
+
default=DEFAULT_PACKED_BLOCK_SIZE,
|
|
60
|
+
help="coefficients kept per axis; lower means smaller and lossier (default: %(default)s)",
|
|
61
|
+
)
|
|
62
|
+
compress.add_argument(
|
|
63
|
+
"--coeff-removal",
|
|
64
|
+
type=float,
|
|
65
|
+
default=None,
|
|
66
|
+
help="zero Hadamard matrix entries at or below this threshold",
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
extract = subparsers.add_parser(Action.EXTRACT.value, help="restore a BMP from a .cim file")
|
|
70
|
+
extract.add_argument("input", help="path to a .cim file")
|
|
71
|
+
extract.add_argument("output", help="path of the BMP to write")
|
|
72
|
+
|
|
73
|
+
return parser
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
77
|
+
args = build_parser().parse_args(argv)
|
|
78
|
+
|
|
79
|
+
level = logging.WARNING - min(args.verbose, 2) * 10
|
|
80
|
+
logging.basicConfig(level=level, format="%(levelname)s %(name)s: %(message)s")
|
|
81
|
+
|
|
82
|
+
action = Action(args.action)
|
|
83
|
+
if action is Action.COMPRESS:
|
|
84
|
+
task = Task(
|
|
85
|
+
y_block_size=args.y_block_size,
|
|
86
|
+
cb_block_size=args.chroma_block_size,
|
|
87
|
+
cr_block_size=args.chroma_block_size,
|
|
88
|
+
packed_block_size=args.packed_block_size,
|
|
89
|
+
).with_coeff_removal(args.coeff_removal)
|
|
90
|
+
else:
|
|
91
|
+
task = Task()
|
|
92
|
+
|
|
93
|
+
task.with_action(action).with_input(args.input).with_output(args.output)
|
|
94
|
+
|
|
95
|
+
try:
|
|
96
|
+
task.run()
|
|
97
|
+
except (OSError, UnsupportedFileFormatError, ValueError) as error:
|
|
98
|
+
print(f"walsh: {error}", file=sys.stderr)
|
|
99
|
+
return 1
|
|
100
|
+
return 0
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
if __name__ == "__main__": # pragma: no cover
|
|
104
|
+
raise SystemExit(main())
|
walsh/colors.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Colour model conversions between RGB and YCbCr."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
|
|
7
|
+
__all__ = ["ColorModel", "Rgb", "RgbColorModel", "Triple", "YCbCrColorModel"]
|
|
8
|
+
|
|
9
|
+
#: A pixel as three numbers, in whatever model the class in question uses.
|
|
10
|
+
Triple = tuple[float, float, float]
|
|
11
|
+
|
|
12
|
+
#: A pixel as three 0-255 integers.
|
|
13
|
+
Rgb = tuple[int, int, int]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ColorModel(ABC):
|
|
17
|
+
"""Converts a single pixel between its native model and RGB/YCbCr."""
|
|
18
|
+
|
|
19
|
+
@abstractmethod
|
|
20
|
+
def get_rgb(self, color: Triple) -> Rgb:
|
|
21
|
+
"""Return ``color`` as an ``(r, g, b)`` triple of 0-255 ints."""
|
|
22
|
+
|
|
23
|
+
@abstractmethod
|
|
24
|
+
def get_y_cb_cr(self, color: Triple) -> Triple:
|
|
25
|
+
"""Return ``color`` as a ``(y, cb, cr)`` triple."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class RgbColorModel(ColorModel):
|
|
29
|
+
"""Pixels stored as RGB."""
|
|
30
|
+
|
|
31
|
+
def get_rgb(self, color: Triple) -> Rgb:
|
|
32
|
+
return _as_rgb(*color)
|
|
33
|
+
|
|
34
|
+
def get_y_cb_cr(self, color: Triple) -> Triple:
|
|
35
|
+
r, g, b = color
|
|
36
|
+
y = 0.299 * r + 0.587 * g + 0.114 * b
|
|
37
|
+
cb = 128 - 0.168736 * r - 0.331264 * g + 0.5 * b
|
|
38
|
+
cr = 128 + 0.5 * r - 0.418688 * g - 0.081312 * b
|
|
39
|
+
return y, cb, cr
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class YCbCrColorModel(ColorModel):
|
|
43
|
+
"""Pixels stored as YCbCr."""
|
|
44
|
+
|
|
45
|
+
def get_rgb(self, color: Triple) -> Rgb:
|
|
46
|
+
y, cb, cr = color
|
|
47
|
+
return _as_rgb(
|
|
48
|
+
y + 1.402 * (cr - 128),
|
|
49
|
+
y - 0.34414 * (cb - 128) - 0.71414 * (cr - 128),
|
|
50
|
+
y + 1.772 * (cb - 128),
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
def get_y_cb_cr(self, color: Triple) -> Triple:
|
|
54
|
+
return color
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _as_rgb(r: float, g: float, b: float) -> Rgb:
|
|
58
|
+
"""Truncate towards zero and clamp each channel into the 0-255 range."""
|
|
59
|
+
return _clamp(int(r)), _clamp(int(g)), _clamp(int(b))
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _clamp(value: int, low: int = 0, high: int = 255) -> int:
|
|
63
|
+
return max(low, min(high, value))
|
walsh/decorators.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Small caching helper used to memoise expensive matrix construction."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import functools
|
|
6
|
+
from collections.abc import Callable, Hashable
|
|
7
|
+
from typing import Any, ParamSpec, TypeVar
|
|
8
|
+
|
|
9
|
+
__all__ = ["cached"]
|
|
10
|
+
|
|
11
|
+
P = ParamSpec("P")
|
|
12
|
+
R = TypeVar("R")
|
|
13
|
+
|
|
14
|
+
#: Separates positional from keyword arguments in a cache key. Must be a module
|
|
15
|
+
#: level constant: a fresh marker per call would make every lookup miss.
|
|
16
|
+
_KWARGS_MARKER = object()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _hashable(value: Any) -> Hashable:
|
|
20
|
+
"""Return ``value`` if it can be used as a dict key, else its ``repr``."""
|
|
21
|
+
if isinstance(value, Hashable):
|
|
22
|
+
return value
|
|
23
|
+
return repr(value)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _make_key(args: tuple[Any, ...], kwargs: dict[str, Any]) -> Hashable:
|
|
27
|
+
key: tuple[Hashable, ...] = tuple(_hashable(a) for a in args)
|
|
28
|
+
if kwargs:
|
|
29
|
+
key = (
|
|
30
|
+
*key,
|
|
31
|
+
_KWARGS_MARKER,
|
|
32
|
+
*((name, _hashable(value)) for name, value in sorted(kwargs.items())),
|
|
33
|
+
)
|
|
34
|
+
return key
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def cached(function: Callable[P, R]) -> Callable[P, R]:
|
|
38
|
+
"""Memoise ``function`` on its arguments, tolerating unhashable ones.
|
|
39
|
+
|
|
40
|
+
Unlike :func:`functools.lru_cache` this accepts unhashable arguments by
|
|
41
|
+
falling back to their ``repr``, which is what the numpy-heavy call sites
|
|
42
|
+
here need. The cache is unbounded and lives for the life of the process.
|
|
43
|
+
"""
|
|
44
|
+
cache: dict[Hashable, R] = {}
|
|
45
|
+
|
|
46
|
+
@functools.wraps(function)
|
|
47
|
+
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
|
|
48
|
+
key = _make_key(args, kwargs)
|
|
49
|
+
try:
|
|
50
|
+
return cache[key]
|
|
51
|
+
except KeyError:
|
|
52
|
+
result = cache[key] = function(*args, **kwargs)
|
|
53
|
+
return result
|
|
54
|
+
|
|
55
|
+
wrapper.cache_clear = cache.clear # type: ignore[attr-defined]
|
|
56
|
+
wrapper.cache_size = lambda: len(cache) # type: ignore[attr-defined]
|
|
57
|
+
return wrapper
|
walsh/image.py
ADDED
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
"""Container formats: 24-bit BMP in, custom ``.cim`` spectral format out."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import logging
|
|
7
|
+
import struct
|
|
8
|
+
import sys
|
|
9
|
+
from collections.abc import Generator, Sequence
|
|
10
|
+
from os import PathLike
|
|
11
|
+
from typing import BinaryIO, NamedTuple
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
import numpy.typing as npt
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"BMPImage",
|
|
18
|
+
"BlockDescription",
|
|
19
|
+
"CustomizableImage",
|
|
20
|
+
"UnsupportedFileFormatError",
|
|
21
|
+
"align",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
log = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
FileSource = str | PathLike[str] | None
|
|
27
|
+
|
|
28
|
+
Block = npt.NDArray[np.float64]
|
|
29
|
+
Pixel = tuple[int, int, int]
|
|
30
|
+
|
|
31
|
+
BMP_HEADER_FORMAT = "<2sIHHIIIIHHIIIIII"
|
|
32
|
+
BMP_SIGNATURE = b"BM"
|
|
33
|
+
BMP_HEADER_SIZE = 40
|
|
34
|
+
BMP_PIXEL_OFFSET = 54
|
|
35
|
+
|
|
36
|
+
#: Spectral coefficients are stored as little-endian signed 16-bit integers.
|
|
37
|
+
COEFF_DTYPE = np.dtype("<i2")
|
|
38
|
+
COEFF_MIN = np.iinfo(COEFF_DTYPE).min
|
|
39
|
+
COEFF_MAX = np.iinfo(COEFF_DTYPE).max
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class UnsupportedFileFormatError(Exception):
|
|
43
|
+
"""Raised for BMP files that are not 24-bit, single-plane, uncompressed."""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def align(x: int, a: int) -> int:
|
|
47
|
+
"""Round ``x`` up to the next multiple of ``a``."""
|
|
48
|
+
return (((x - 1) // a) + 1) * a
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@contextlib.contextmanager
|
|
52
|
+
def _open_binary(source: FileSource, mode: str) -> Generator[BinaryIO, None, None]:
|
|
53
|
+
"""Open ``source``, or fall back to std streams when it is ``None``.
|
|
54
|
+
|
|
55
|
+
The std streams are deliberately not closed on exit.
|
|
56
|
+
"""
|
|
57
|
+
if source is None:
|
|
58
|
+
yield sys.stdin.buffer if "r" in mode else sys.stdout.buffer
|
|
59
|
+
return
|
|
60
|
+
with open(source, mode) as handle:
|
|
61
|
+
yield handle # type: ignore[misc]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class BMPImage:
|
|
65
|
+
"""A 24-bit uncompressed Windows bitmap.
|
|
66
|
+
|
|
67
|
+
.. note::
|
|
68
|
+
Pixel triples are kept in the order they appear in the file, which for
|
|
69
|
+
BMP is blue-green-red. Reading and writing use the same order, so a
|
|
70
|
+
round trip is lossless, but callers that interpret a triple as
|
|
71
|
+
``(r, g, b)`` are working with the channels swapped.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
def __init__(self) -> None:
|
|
75
|
+
self._signature = BMP_SIGNATURE
|
|
76
|
+
self._size = 0
|
|
77
|
+
self._offset = BMP_PIXEL_OFFSET
|
|
78
|
+
self._header_size = BMP_HEADER_SIZE
|
|
79
|
+
self._width = 0
|
|
80
|
+
self._height = 0
|
|
81
|
+
self._planes = 1
|
|
82
|
+
self._bpp = 24
|
|
83
|
+
self._compression = 0
|
|
84
|
+
self._size_of_data = 0
|
|
85
|
+
self._horizontal_res = 2835
|
|
86
|
+
self._vertical_res = 2835
|
|
87
|
+
self._raw_data: list[Pixel] = []
|
|
88
|
+
|
|
89
|
+
def _read_header(self, file: BinaryIO) -> None:
|
|
90
|
+
header = file.read(struct.calcsize(BMP_HEADER_FORMAT))
|
|
91
|
+
(
|
|
92
|
+
self._signature,
|
|
93
|
+
self._size,
|
|
94
|
+
_,
|
|
95
|
+
_,
|
|
96
|
+
self._offset,
|
|
97
|
+
self._header_size,
|
|
98
|
+
self._width,
|
|
99
|
+
self._height,
|
|
100
|
+
self._planes,
|
|
101
|
+
self._bpp,
|
|
102
|
+
self._compression,
|
|
103
|
+
self._size_of_data,
|
|
104
|
+
self._horizontal_res,
|
|
105
|
+
self._vertical_res,
|
|
106
|
+
_,
|
|
107
|
+
_,
|
|
108
|
+
) = struct.unpack(BMP_HEADER_FORMAT, header)
|
|
109
|
+
|
|
110
|
+
actual = (self._signature, self._planes, self._bpp, self._compression)
|
|
111
|
+
if actual != (BMP_SIGNATURE, 1, 24, 0):
|
|
112
|
+
raise UnsupportedFileFormatError(
|
|
113
|
+
"expected a 24-bit single-plane uncompressed BMP, got "
|
|
114
|
+
f"signature={self._signature!r} planes={self._planes} "
|
|
115
|
+
f"bpp={self._bpp} compression={self._compression}"
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
def _read_data(self, file: BinaryIO) -> None:
|
|
119
|
+
file.seek(self._offset)
|
|
120
|
+
stride = align(self._width * 3, 4)
|
|
121
|
+
self._raw_data = []
|
|
122
|
+
for _ in range(self._height):
|
|
123
|
+
line = file.read(stride)
|
|
124
|
+
for i in range(0, self._width * 3, 3):
|
|
125
|
+
self._raw_data.append(struct.unpack_from("<BBB", line, i))
|
|
126
|
+
|
|
127
|
+
def load(self, filename: FileSource) -> None:
|
|
128
|
+
"""Read a BMP from ``filename``, or from stdin when it is ``None``."""
|
|
129
|
+
with _open_binary(filename, "rb") as file:
|
|
130
|
+
self._read_header(file)
|
|
131
|
+
self._read_data(file)
|
|
132
|
+
log.debug("loaded BMP %dx%d from %s", self._width, self._height, filename)
|
|
133
|
+
|
|
134
|
+
def _write_header(self, file: BinaryIO) -> None:
|
|
135
|
+
ignored = 0
|
|
136
|
+
file.write(
|
|
137
|
+
struct.pack(
|
|
138
|
+
BMP_HEADER_FORMAT,
|
|
139
|
+
self._signature,
|
|
140
|
+
self._size,
|
|
141
|
+
ignored,
|
|
142
|
+
ignored,
|
|
143
|
+
self._offset,
|
|
144
|
+
self._header_size,
|
|
145
|
+
self._width,
|
|
146
|
+
self._height,
|
|
147
|
+
self._planes,
|
|
148
|
+
self._bpp,
|
|
149
|
+
self._compression,
|
|
150
|
+
self._size_of_data,
|
|
151
|
+
self._horizontal_res,
|
|
152
|
+
self._vertical_res,
|
|
153
|
+
ignored,
|
|
154
|
+
ignored,
|
|
155
|
+
)
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
def _write_data(self, file: BinaryIO) -> None:
|
|
159
|
+
padding = align(self._width * 3, 4) - self._width * 3
|
|
160
|
+
log.debug("row padding: %d byte(s)", padding)
|
|
161
|
+
pad = b"\x00" * padding
|
|
162
|
+
for start in range(0, len(self._raw_data), self._width):
|
|
163
|
+
for pixel in self._raw_data[start : start + self._width]:
|
|
164
|
+
file.write(struct.pack("<BBB", *pixel))
|
|
165
|
+
file.write(pad)
|
|
166
|
+
|
|
167
|
+
def save(self, filename: FileSource) -> None:
|
|
168
|
+
"""Write this bitmap to ``filename``, or to stdout when it is ``None``."""
|
|
169
|
+
with _open_binary(filename, "wb") as file:
|
|
170
|
+
self._write_header(file)
|
|
171
|
+
self._write_data(file)
|
|
172
|
+
|
|
173
|
+
def get_dimensions(self) -> tuple[int, int]:
|
|
174
|
+
return self._width, self._height
|
|
175
|
+
|
|
176
|
+
def set_dimensions(self, width: int, height: int) -> None:
|
|
177
|
+
self._width = width
|
|
178
|
+
self._height = height
|
|
179
|
+
self._size_of_data = width * height * 3
|
|
180
|
+
self._size = BMP_PIXEL_OFFSET + align(width * 3, 4) * height
|
|
181
|
+
|
|
182
|
+
def get_raw_data(self) -> list[Pixel]:
|
|
183
|
+
return self._raw_data
|
|
184
|
+
|
|
185
|
+
def set_raw_data(self, new_data: Sequence[Pixel]) -> None:
|
|
186
|
+
self._raw_data = list(new_data)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
class BlockDescription(NamedTuple):
|
|
190
|
+
"""How one channel's blocks are laid out in a ``.cim`` file."""
|
|
191
|
+
|
|
192
|
+
original_block_size: int
|
|
193
|
+
packed_block_size: int
|
|
194
|
+
number_of_blocks: int
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
class CustomizableImage:
|
|
198
|
+
"""The custom ``.cim`` container holding truncated spectral blocks.
|
|
199
|
+
|
|
200
|
+
Layout: ``<II`` width/height, then three ``<HHH`` :class:`BlockDescription`
|
|
201
|
+
records (Y, Cb, Cr), then each channel's blocks as row-major ``int16``.
|
|
202
|
+
"""
|
|
203
|
+
|
|
204
|
+
HEADER_FORMAT = "<II"
|
|
205
|
+
DESCRIPTION_FORMAT = "<HHH"
|
|
206
|
+
|
|
207
|
+
def __init__(self) -> None:
|
|
208
|
+
self._width = 0
|
|
209
|
+
self._height = 0
|
|
210
|
+
self._descriptions: dict[str, BlockDescription | None] = {
|
|
211
|
+
"y": None,
|
|
212
|
+
"cb": None,
|
|
213
|
+
"cr": None,
|
|
214
|
+
}
|
|
215
|
+
self._data: dict[str, list[Block]] = {"y": [], "cb": [], "cr": []}
|
|
216
|
+
|
|
217
|
+
def _read_header(self, file: BinaryIO) -> None:
|
|
218
|
+
size = struct.calcsize(self.HEADER_FORMAT)
|
|
219
|
+
self._width, self._height = struct.unpack(self.HEADER_FORMAT, file.read(size))
|
|
220
|
+
size = struct.calcsize(self.DESCRIPTION_FORMAT)
|
|
221
|
+
for channel in self._descriptions:
|
|
222
|
+
fields = struct.unpack(self.DESCRIPTION_FORMAT, file.read(size))
|
|
223
|
+
self._descriptions[channel] = BlockDescription(*fields)
|
|
224
|
+
|
|
225
|
+
@staticmethod
|
|
226
|
+
def _read_blocks(file: BinaryIO, description: BlockDescription) -> list[Block]:
|
|
227
|
+
original, packed, count = description
|
|
228
|
+
pattern = "<" + "h" * (packed * packed)
|
|
229
|
+
size = struct.calcsize(pattern)
|
|
230
|
+
|
|
231
|
+
blocks = []
|
|
232
|
+
for _ in range(count):
|
|
233
|
+
data = struct.unpack(pattern, file.read(size))
|
|
234
|
+
block = np.zeros((original, original), dtype=np.float64)
|
|
235
|
+
block[:packed, :packed] = np.asarray(data, dtype=np.float64).reshape(packed, packed)
|
|
236
|
+
blocks.append(block)
|
|
237
|
+
return blocks
|
|
238
|
+
|
|
239
|
+
@classmethod
|
|
240
|
+
def load(cls, filename: FileSource) -> CustomizableImage:
|
|
241
|
+
"""Read a ``.cim`` from ``filename``, or from stdin when it is ``None``."""
|
|
242
|
+
image = cls()
|
|
243
|
+
with _open_binary(filename, "rb") as file:
|
|
244
|
+
image._read_header(file)
|
|
245
|
+
for channel, description in image._descriptions.items():
|
|
246
|
+
if description is not None and description.number_of_blocks > 0:
|
|
247
|
+
image._data[channel] = cls._read_blocks(file, description)
|
|
248
|
+
return image
|
|
249
|
+
|
|
250
|
+
def get_y_data(self) -> list[Block]:
|
|
251
|
+
return self._data["y"]
|
|
252
|
+
|
|
253
|
+
def get_cb_data(self) -> list[Block]:
|
|
254
|
+
return self._data["cb"]
|
|
255
|
+
|
|
256
|
+
def get_cr_data(self) -> list[Block]:
|
|
257
|
+
return self._data["cr"]
|
|
258
|
+
|
|
259
|
+
def get_dimensions(self) -> tuple[int, int]:
|
|
260
|
+
return self._width, self._height
|
|
261
|
+
|
|
262
|
+
def set_dimensions(self, width: int, height: int) -> None:
|
|
263
|
+
self._width = width
|
|
264
|
+
self._height = height
|
|
265
|
+
|
|
266
|
+
def set_descriptions(
|
|
267
|
+
self,
|
|
268
|
+
y_description: BlockDescription,
|
|
269
|
+
cb_description: BlockDescription,
|
|
270
|
+
cr_description: BlockDescription,
|
|
271
|
+
) -> None:
|
|
272
|
+
self._descriptions["y"] = BlockDescription(*y_description)
|
|
273
|
+
self._descriptions["cb"] = BlockDescription(*cb_description)
|
|
274
|
+
self._descriptions["cr"] = BlockDescription(*cr_description)
|
|
275
|
+
|
|
276
|
+
def set_data(
|
|
277
|
+
self,
|
|
278
|
+
y_data: Sequence[Block],
|
|
279
|
+
cb_data: Sequence[Block],
|
|
280
|
+
cr_data: Sequence[Block],
|
|
281
|
+
) -> None:
|
|
282
|
+
"""Store blocks, keeping only the low-frequency corner of each.
|
|
283
|
+
|
|
284
|
+
This is where the codec actually loses information: each block is
|
|
285
|
+
cropped to ``packed_block_size`` x ``packed_block_size``.
|
|
286
|
+
"""
|
|
287
|
+
for channel, blocks in (("y", y_data), ("cb", cb_data), ("cr", cr_data)):
|
|
288
|
+
description = self._descriptions[channel]
|
|
289
|
+
if description is None:
|
|
290
|
+
raise ValueError("set_descriptions() must be called before set_data()")
|
|
291
|
+
packed = description.packed_block_size
|
|
292
|
+
self._data[channel] = [block[:packed, :packed] for block in blocks]
|
|
293
|
+
|
|
294
|
+
def _write_header(self, file: BinaryIO) -> None:
|
|
295
|
+
file.write(struct.pack(self.HEADER_FORMAT, self._width, self._height))
|
|
296
|
+
for channel, description in self._descriptions.items():
|
|
297
|
+
if description is None:
|
|
298
|
+
raise ValueError(f"no block description set for channel {channel!r}")
|
|
299
|
+
file.write(struct.pack(self.DESCRIPTION_FORMAT, *description))
|
|
300
|
+
|
|
301
|
+
@staticmethod
|
|
302
|
+
def _write_blocks(file: BinaryIO, blocks: Sequence[Block]) -> None:
|
|
303
|
+
for block in blocks:
|
|
304
|
+
data = np.clip(np.rint(block), COEFF_MIN, COEFF_MAX).astype(COEFF_DTYPE)
|
|
305
|
+
file.write(data.reshape(-1).tobytes())
|
|
306
|
+
|
|
307
|
+
def save(self, filename: FileSource) -> None:
|
|
308
|
+
"""Write this image to ``filename``, or to stdout when it is ``None``."""
|
|
309
|
+
with _open_binary(filename, "wb") as file:
|
|
310
|
+
self._write_header(file)
|
|
311
|
+
for blocks in self._data.values():
|
|
312
|
+
self._write_blocks(file, blocks)
|
walsh/py.typed
ADDED
|
File without changes
|
walsh/task.py
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""Orchestration: the compress and extract pipelines."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from collections.abc import Callable, Sequence
|
|
7
|
+
from enum import Enum
|
|
8
|
+
from typing import ClassVar
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
import numpy.typing as npt
|
|
12
|
+
|
|
13
|
+
from walsh.colors import RgbColorModel, YCbCrColorModel
|
|
14
|
+
from walsh.image import BlockDescription, BMPImage, CustomizableImage, FileSource
|
|
15
|
+
from walsh.transforms import WalshHadamardTransform
|
|
16
|
+
|
|
17
|
+
__all__ = ["Action", "Task"]
|
|
18
|
+
|
|
19
|
+
log = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
Block = npt.NDArray[np.float64]
|
|
22
|
+
|
|
23
|
+
#: Defaults carried over from the original implementation.
|
|
24
|
+
DEFAULT_Y_BLOCK_SIZE = 8
|
|
25
|
+
DEFAULT_CHROMA_BLOCK_SIZE = 16
|
|
26
|
+
DEFAULT_PACKED_BLOCK_SIZE = 4
|
|
27
|
+
|
|
28
|
+
#: Neutral fill values used when a channel carries no blocks.
|
|
29
|
+
NEUTRAL_LUMA = 0
|
|
30
|
+
NEUTRAL_CHROMA = 128
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Action(str, Enum):
|
|
34
|
+
"""What a :class:`Task` should do when run."""
|
|
35
|
+
|
|
36
|
+
COMPRESS = "compress"
|
|
37
|
+
EXTRACT = "extract"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class Task:
|
|
41
|
+
"""A single compress or extract run, configured fluently.
|
|
42
|
+
|
|
43
|
+
>>> Task().with_action("compress").with_input("in.bmp").with_output("out.cim").run()
|
|
44
|
+
... # doctest: +SKIP
|
|
45
|
+
|
|
46
|
+
:param y_block_size: block edge used for the luma channel.
|
|
47
|
+
:param cb_block_size: block edge used for the Cb channel.
|
|
48
|
+
:param cr_block_size: block edge used for the Cr channel.
|
|
49
|
+
:param packed_block_size: how many low-frequency coefficients per axis are
|
|
50
|
+
kept when writing. This is the codec's lossy knob.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
def __init__(
|
|
54
|
+
self,
|
|
55
|
+
*,
|
|
56
|
+
y_block_size: int = DEFAULT_Y_BLOCK_SIZE,
|
|
57
|
+
cb_block_size: int = DEFAULT_CHROMA_BLOCK_SIZE,
|
|
58
|
+
cr_block_size: int = DEFAULT_CHROMA_BLOCK_SIZE,
|
|
59
|
+
packed_block_size: int = DEFAULT_PACKED_BLOCK_SIZE,
|
|
60
|
+
) -> None:
|
|
61
|
+
self._input: FileSource = None
|
|
62
|
+
self._output: FileSource = None
|
|
63
|
+
self._action: Action | None = None
|
|
64
|
+
self._coeff_removal: float | None = None
|
|
65
|
+
self._y_block_size = y_block_size
|
|
66
|
+
self._cb_block_size = cb_block_size
|
|
67
|
+
self._cr_block_size = cr_block_size
|
|
68
|
+
self._packed_block_size = packed_block_size
|
|
69
|
+
|
|
70
|
+
# -- configuration ---------------------------------------------------
|
|
71
|
+
|
|
72
|
+
def with_input(self, source: FileSource) -> Task:
|
|
73
|
+
self._input = source
|
|
74
|
+
return self
|
|
75
|
+
|
|
76
|
+
def with_output(self, destination: FileSource) -> Task:
|
|
77
|
+
self._output = destination
|
|
78
|
+
return self
|
|
79
|
+
|
|
80
|
+
def with_action(self, action: Action | str) -> Task:
|
|
81
|
+
"""Select the pipeline to run. Raises :class:`ValueError` if unknown."""
|
|
82
|
+
try:
|
|
83
|
+
self._action = Action(action)
|
|
84
|
+
except ValueError:
|
|
85
|
+
valid = ", ".join(repr(a.value) for a in Action)
|
|
86
|
+
raise ValueError(f"unknown action {action!r}; expected one of {valid}") from None
|
|
87
|
+
return self
|
|
88
|
+
|
|
89
|
+
def with_coeff_removal(self, coeff: float | None) -> Task:
|
|
90
|
+
"""Zero Hadamard matrix entries at or below ``coeff`` (see transforms)."""
|
|
91
|
+
self._coeff_removal = coeff
|
|
92
|
+
return self
|
|
93
|
+
|
|
94
|
+
# -- helpers ---------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
@staticmethod
|
|
97
|
+
def _get_padding_size(x: int, a: int) -> int:
|
|
98
|
+
"""How much to add to ``x`` to reach the next multiple of ``a``."""
|
|
99
|
+
return ((x - 1) // a + 1) * a - x
|
|
100
|
+
|
|
101
|
+
def _slice(
|
|
102
|
+
self, values: Sequence[float], width: int, height: int, block_size: int
|
|
103
|
+
) -> list[Block]:
|
|
104
|
+
"""Reshape a flat channel into zero-padded ``block_size`` square blocks."""
|
|
105
|
+
plane = np.asarray(values, dtype=np.float64).reshape(height, width)
|
|
106
|
+
|
|
107
|
+
height_padding = self._get_padding_size(height, block_size)
|
|
108
|
+
width_padding = self._get_padding_size(width, block_size)
|
|
109
|
+
log.debug(
|
|
110
|
+
"slicing %dx%d into %d-blocks (pad h=%d w=%d)",
|
|
111
|
+
width,
|
|
112
|
+
height,
|
|
113
|
+
block_size,
|
|
114
|
+
height_padding,
|
|
115
|
+
width_padding,
|
|
116
|
+
)
|
|
117
|
+
plane = np.pad(plane, ((0, height_padding), (0, width_padding)))
|
|
118
|
+
|
|
119
|
+
blocks: list[Block] = []
|
|
120
|
+
for row in np.vsplit(plane, plane.shape[0] // block_size):
|
|
121
|
+
blocks.extend(np.hsplit(row, row.shape[1] // block_size))
|
|
122
|
+
|
|
123
|
+
log.debug("produced %d block(s) of %s", len(blocks), blocks[0].shape)
|
|
124
|
+
return blocks
|
|
125
|
+
|
|
126
|
+
@staticmethod
|
|
127
|
+
def _merge(blocks: Sequence[Block], width: int, height: int) -> Block:
|
|
128
|
+
"""Reassemble blocks into a plane and crop the padding back off."""
|
|
129
|
+
_, block_width = blocks[0].shape
|
|
130
|
+
blocks_per_row = (width - 1) // block_width + 1
|
|
131
|
+
blocks_per_column = len(blocks) // blocks_per_row
|
|
132
|
+
rows = [
|
|
133
|
+
np.hstack(blocks[i * blocks_per_row : (i + 1) * blocks_per_row])
|
|
134
|
+
for i in range(blocks_per_column)
|
|
135
|
+
]
|
|
136
|
+
return np.vstack(rows)[0:height, 0:width]
|
|
137
|
+
|
|
138
|
+
# -- pipelines -------------------------------------------------------
|
|
139
|
+
|
|
140
|
+
def compress(self) -> None:
|
|
141
|
+
"""BMP in, spectral ``.cim`` out."""
|
|
142
|
+
log.info("compressing %s -> %s", self._input, self._output)
|
|
143
|
+
|
|
144
|
+
bmp_image = BMPImage()
|
|
145
|
+
bmp_image.load(self._input)
|
|
146
|
+
|
|
147
|
+
width, height = bmp_image.get_dimensions()
|
|
148
|
+
data = bmp_image.get_raw_data()
|
|
149
|
+
|
|
150
|
+
color = RgbColorModel()
|
|
151
|
+
y, cb, cr = zip(*(color.get_y_cb_cr(pixel) for pixel in data), strict=True)
|
|
152
|
+
|
|
153
|
+
blocks = {
|
|
154
|
+
"y": self._slice(y, width, height, self._y_block_size),
|
|
155
|
+
"cb": self._slice(cb, width, height, self._cb_block_size),
|
|
156
|
+
"cr": self._slice(cr, width, height, self._cr_block_size),
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
transform = WalshHadamardTransform(self._coeff_removal)
|
|
160
|
+
spectral = {
|
|
161
|
+
channel: transform.transform_sequence(channel_blocks)
|
|
162
|
+
for channel, channel_blocks in blocks.items()
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
packed = self._packed_block_size
|
|
166
|
+
customizable_image = CustomizableImage()
|
|
167
|
+
customizable_image.set_dimensions(width, height)
|
|
168
|
+
customizable_image.set_descriptions(
|
|
169
|
+
BlockDescription(self._y_block_size, packed, len(spectral["y"])),
|
|
170
|
+
BlockDescription(self._cb_block_size, packed, len(spectral["cb"])),
|
|
171
|
+
BlockDescription(self._cr_block_size, packed, len(spectral["cr"])),
|
|
172
|
+
)
|
|
173
|
+
customizable_image.set_data(spectral["y"], spectral["cb"], spectral["cr"])
|
|
174
|
+
customizable_image.save(self._output)
|
|
175
|
+
|
|
176
|
+
def extract(self) -> None:
|
|
177
|
+
"""Spectral ``.cim`` in, BMP out."""
|
|
178
|
+
log.info("extracting %s -> %s", self._input, self._output)
|
|
179
|
+
|
|
180
|
+
customizable_image = CustomizableImage.load(self._input)
|
|
181
|
+
width, height = customizable_image.get_dimensions()
|
|
182
|
+
transform = WalshHadamardTransform()
|
|
183
|
+
|
|
184
|
+
channels = {
|
|
185
|
+
"y": (customizable_image.get_y_data(), NEUTRAL_LUMA),
|
|
186
|
+
"cb": (customizable_image.get_cb_data(), NEUTRAL_CHROMA),
|
|
187
|
+
"cr": (customizable_image.get_cr_data(), NEUTRAL_CHROMA),
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
planes: dict[str, list[float]] = {}
|
|
191
|
+
for channel, (spectral, neutral) in channels.items():
|
|
192
|
+
if not spectral:
|
|
193
|
+
log.debug("channel %s is empty, filling with %d", channel, neutral)
|
|
194
|
+
planes[channel] = [float(neutral)] * (width * height)
|
|
195
|
+
continue
|
|
196
|
+
merged = self._merge(transform.inverse_transform_sequence(spectral), width, height)
|
|
197
|
+
planes[channel] = np.asarray(merged).reshape(-1).tolist()
|
|
198
|
+
|
|
199
|
+
color = YCbCrColorModel()
|
|
200
|
+
pixels = [
|
|
201
|
+
color.get_rgb(triple)
|
|
202
|
+
for triple in zip(planes["y"], planes["cb"], planes["cr"], strict=True)
|
|
203
|
+
]
|
|
204
|
+
|
|
205
|
+
bmp_image = BMPImage()
|
|
206
|
+
bmp_image.set_dimensions(width, height)
|
|
207
|
+
bmp_image.set_raw_data(pixels)
|
|
208
|
+
bmp_image.save(self._output)
|
|
209
|
+
|
|
210
|
+
_ACTIONS: ClassVar[dict[Action, Callable[[Task], None]]] = {
|
|
211
|
+
Action.COMPRESS: compress,
|
|
212
|
+
Action.EXTRACT: extract,
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
def run(self) -> None:
|
|
216
|
+
"""Execute the configured action. Raises if none was selected."""
|
|
217
|
+
if self._action is None:
|
|
218
|
+
raise ValueError("no action selected; call with_action() first")
|
|
219
|
+
log.debug(
|
|
220
|
+
"run action=%s input=%s output=%s coeff_removal=%s",
|
|
221
|
+
self._action.value,
|
|
222
|
+
self._input,
|
|
223
|
+
self._output,
|
|
224
|
+
self._coeff_removal,
|
|
225
|
+
)
|
|
226
|
+
Task._ACTIONS[self._action](self)
|
walsh/transforms.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""The Walsh-Hadamard transform itself."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from abc import ABC, abstractmethod
|
|
7
|
+
from collections.abc import Iterable
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
import numpy.typing as npt
|
|
11
|
+
|
|
12
|
+
from walsh.decorators import cached
|
|
13
|
+
|
|
14
|
+
__all__ = ["Transform", "WalshHadamardTransform"]
|
|
15
|
+
|
|
16
|
+
Block = npt.NDArray[np.float64]
|
|
17
|
+
|
|
18
|
+
#: Tolerance used when comparing a matrix entry against the removal coefficient.
|
|
19
|
+
COEFF_TOLERANCE = 0.000001
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Transform(ABC):
|
|
23
|
+
"""A block transform and its inverse."""
|
|
24
|
+
|
|
25
|
+
@abstractmethod
|
|
26
|
+
def transform(self, src: Block) -> Block:
|
|
27
|
+
"""Transform a single square block."""
|
|
28
|
+
|
|
29
|
+
@abstractmethod
|
|
30
|
+
def inverse_transform(self, src: Block) -> Block:
|
|
31
|
+
"""Invert :meth:`transform` for a single square block."""
|
|
32
|
+
|
|
33
|
+
def transform_sequence(self, src_seq: Iterable[Block]) -> list[Block]:
|
|
34
|
+
return [self.transform(block) for block in src_seq]
|
|
35
|
+
|
|
36
|
+
def inverse_transform_sequence(self, src_seq: Iterable[Block]) -> list[Block]:
|
|
37
|
+
return [self.inverse_transform(block) for block in src_seq]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class WalshHadamardTransform(Transform):
|
|
41
|
+
"""Orthonormal Walsh-Hadamard transform in sequency (Walsh) order.
|
|
42
|
+
|
|
43
|
+
The transform is symmetric and involutive, so :meth:`inverse_transform`
|
|
44
|
+
is simply :meth:`transform` applied again.
|
|
45
|
+
|
|
46
|
+
:param coeff: optional coefficient-removal threshold. When set, matrix
|
|
47
|
+
entries that fall at or below it during construction are zeroed,
|
|
48
|
+
which discards part of the spectrum. See :meth:`_build_matrix`.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
def __init__(self, coeff: float | None = None) -> None:
|
|
52
|
+
self._coeff = coeff
|
|
53
|
+
|
|
54
|
+
@cached
|
|
55
|
+
def _build_matrix(self, size: int) -> Block:
|
|
56
|
+
"""Build the ``size`` x ``size`` Walsh-Hadamard matrix.
|
|
57
|
+
|
|
58
|
+
Starts from a uniform matrix of ``1 / sqrt(2) ** n`` and negates the
|
|
59
|
+
entries whose row and column indices share a set bit, which yields the
|
|
60
|
+
natural (Hadamard) ordering. Rows are then sorted by the number of
|
|
61
|
+
sign changes to reach sequency (Walsh) ordering.
|
|
62
|
+
"""
|
|
63
|
+
n = int(math.log(size, 2))
|
|
64
|
+
matrix = np.full((size, size), 1 / (np.sqrt(2) ** n), dtype=np.float64)
|
|
65
|
+
|
|
66
|
+
for i in range(n):
|
|
67
|
+
for j in range(size):
|
|
68
|
+
for k in range(size):
|
|
69
|
+
if (j // 2**i) % 2 == 1 and (k // 2**i) % 2 == 1:
|
|
70
|
+
matrix[j, k] = -matrix[j, k]
|
|
71
|
+
# NOTE: preserved verbatim from the original Python 2
|
|
72
|
+
# implementation. The comparison is one-sided rather
|
|
73
|
+
# than on the magnitude, so in practice any non-None
|
|
74
|
+
# coeff above -1/sqrt(2)**n zeroes every entry that
|
|
75
|
+
# ever gets negated. Changing it changes output.
|
|
76
|
+
if self._coeff is not None and matrix[j, k] - self._coeff < COEFF_TOLERANCE:
|
|
77
|
+
matrix[j, k] = 0
|
|
78
|
+
|
|
79
|
+
return matrix[np.argsort(_sign_changes(matrix), kind="stable")]
|
|
80
|
+
|
|
81
|
+
def transform(self, src: Block) -> Block:
|
|
82
|
+
src = np.asarray(src, dtype=np.float64)
|
|
83
|
+
if src.ndim != 2 or src.shape[0] != src.shape[1]:
|
|
84
|
+
raise ValueError(f"expected a square block, got shape {src.shape}")
|
|
85
|
+
|
|
86
|
+
h = self._build_matrix(src.shape[0])
|
|
87
|
+
return h @ src @ h
|
|
88
|
+
|
|
89
|
+
def inverse_transform(self, src: Block) -> Block:
|
|
90
|
+
return self.transform(src)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _sign_changes(matrix: Block) -> npt.NDArray[np.int64]:
|
|
94
|
+
"""Count sign changes along each row -- the sequency of that Walsh function."""
|
|
95
|
+
counts: npt.NDArray[np.int64] = np.count_nonzero(matrix[:, 1:] * matrix[:, :-1] < 0, axis=1)
|
|
96
|
+
return counts
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: walsh
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Image compression with the Walsh-Hadamard transform
|
|
5
|
+
Author-email: Oskar Jarczyk <oskar.jarczyk@gmail.com>
|
|
6
|
+
Maintainer-email: Oskar Jarczyk <oskar.jarczyk@gmail.com>
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
Project-URL: Homepage, https://github.com/oskar-j/walsh-hadamard-transform
|
|
9
|
+
Project-URL: Repository, https://github.com/oskar-j/walsh-hadamard-transform
|
|
10
|
+
Project-URL: Issues, https://github.com/oskar-j/walsh-hadamard-transform/issues
|
|
11
|
+
Project-URL: Changelog, https://github.com/oskar-j/walsh-hadamard-transform/blob/master/CHANGELOG.md
|
|
12
|
+
Keywords: walsh,hadamard,transform,image,compression,dsp
|
|
13
|
+
Classifier: Development Status :: 3 - Alpha
|
|
14
|
+
Classifier: Intended Audience :: Science/Research
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
22
|
+
Classifier: Topic :: Multimedia :: Graphics
|
|
23
|
+
Classifier: Topic :: Scientific/Engineering :: Image Processing
|
|
24
|
+
Requires-Python: >=3.10
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
License-File: LICENSE
|
|
27
|
+
Requires-Dist: numpy<3,>=1.24
|
|
28
|
+
Provides-Extra: demo
|
|
29
|
+
Requires-Dist: matplotlib<4,>=3.7; extra == "demo"
|
|
30
|
+
Requires-Dist: Pillow<13,>=10.0; extra == "demo"
|
|
31
|
+
Dynamic: license-file
|
|
32
|
+
|
|
33
|
+
# Walsh-hadamard transform
|
|
34
|
+
|
|
35
|
+
[](https://pypi.org/project/walsh/)
|
|
36
|
+
[](https://pypi.org/project/walsh/)
|
|
37
|
+
[](https://github.com/oskar-j/walsh-hadamard-transform/actions/workflows/ci.yml)
|
|
38
|
+
[](LICENSE)
|
|
39
|
+
|
|
40
|
+
Compressing images with a Hadamard transform
|
|
41
|
+
|
|
42
|
+
## Description
|
|
43
|
+
|
|
44
|
+
**From Wikipedia:** The Hadamard transform (also known as the *Walsh–Hadamard transform*,
|
|
45
|
+
*Hadamard–Rademacher–Walsh transform*, *Walsh transform*, or *Walsh–Fourier transform*) is an example
|
|
46
|
+
of a generalized class of Fourier transforms. It performs an orthogonal, symmetric,
|
|
47
|
+
involutive, linear operation on 2m real numbers (or complex numbers, although the
|
|
48
|
+
Hadamard matrices themselves are purely real).
|
|
49
|
+
|
|
50
|
+
The Hadamard transform can be regarded as being built out of *size-2
|
|
51
|
+
discrete Fourier transforms* (DFTs), and is in fact equivalent to a
|
|
52
|
+
multidimensional DFT of size `2 × 2 × ⋯ × 2 × 2`. It decomposes an
|
|
53
|
+
arbitrary input vector into a superposition of *Walsh functions*.
|
|
54
|
+
|
|
55
|
+
The transform is named for the French mathematician Jacques Hadamard,
|
|
56
|
+
the German-American mathematician Hans Rademacher, and the
|
|
57
|
+
American mathematician Joseph L. Walsh.
|
|
58
|
+
|
|
59
|
+
The Hadamard transform is also used in data encryption, as well as many signal processing
|
|
60
|
+
and data compression algorithms, such as `JPEG XR` and `MPEG-4 AVC`. In video compression
|
|
61
|
+
applications, it is usually used in the form of the sum of absolute transformed differences.
|
|
62
|
+
It is also a crucial part of *Grover's algorithm* and *Shor's algorithm* in quantum computing.
|
|
63
|
+
|
|
64
|
+
## Acknowledgement
|
|
65
|
+
|
|
66
|
+
This code is partially based on the solution from [ktisha/python2012](https://github.com/ktisha/python2012/tree/dee4beda8e22f3a66a3e31384d4b72ab66102e88/avereshchagin)
|
|
67
|
+
|
|
68
|
+
## Installation
|
|
69
|
+
|
|
70
|
+
Requires Python 3.10 or newer.
|
|
71
|
+
|
|
72
|
+
```
|
|
73
|
+
pip install walsh
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
or, with [uv](https://docs.astral.sh/uv/):
|
|
77
|
+
|
|
78
|
+
```
|
|
79
|
+
uv add walsh # into a project
|
|
80
|
+
uv tool install walsh # just the command line tool
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
The example script additionally needs matplotlib and Pillow, which are the
|
|
84
|
+
`demo` extra: `pip install "walsh[demo]"`.
|
|
85
|
+
|
|
86
|
+
### Development
|
|
87
|
+
|
|
88
|
+
`uv.lock` is committed, so a checkout reproduces exactly the environment CI
|
|
89
|
+
uses:
|
|
90
|
+
|
|
91
|
+
```
|
|
92
|
+
uv sync --group dev --all-extras
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
`--group dev` brings in pytest, ruff and mypy; `--all-extras` adds the `demo`
|
|
96
|
+
extra so `examples/roundtrip.py` runs too. Without uv:
|
|
97
|
+
|
|
98
|
+
```
|
|
99
|
+
pip install -e ".[demo]" -r requirements-dev.txt
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## How to run
|
|
103
|
+
|
|
104
|
+
### Command line
|
|
105
|
+
|
|
106
|
+
```
|
|
107
|
+
walsh compress data/image.bmp data/transformed.cim
|
|
108
|
+
walsh extract data/transformed.cim data/recreated.bmp
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
`compress` accepts `--packed-block-size` (how many low-frequency coefficients
|
|
112
|
+
per axis to keep -- lower is smaller and lossier), `--y-block-size`,
|
|
113
|
+
`--chroma-block-size` and `--coeff-removal`. Add `-v`/`-vv` for progress
|
|
114
|
+
logging, and see `walsh --help` for the full list.
|
|
115
|
+
|
|
116
|
+
### As a library
|
|
117
|
+
|
|
118
|
+
```python
|
|
119
|
+
from walsh import Task
|
|
120
|
+
|
|
121
|
+
Task().with_action("compress").with_input("data/image.bmp").with_output("out.cim").run()
|
|
122
|
+
Task().with_action("extract").with_input("out.cim").with_output("back.bmp").run()
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
### Example
|
|
126
|
+
|
|
127
|
+
`examples/roundtrip.py` compresses the sample image, restores it, and plots
|
|
128
|
+
both images with their histograms side by side (needs the `demo` extra):
|
|
129
|
+
|
|
130
|
+
```
|
|
131
|
+
python examples/roundtrip.py
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
## Requirements
|
|
135
|
+
|
|
136
|
+
The package itself needs only `numpy` -- BMP parsing is done by hand with
|
|
137
|
+
`struct`. `matplotlib` and `Pillow` are needed only by the example script, and
|
|
138
|
+
are declared as the `demo` extra. Versions are pinned in `pyproject.toml`;
|
|
139
|
+
`requirements.txt`, `requirements-demo.txt` and `requirements-dev.txt` mirror
|
|
140
|
+
them for plain `pip install -r` workflows.
|
|
141
|
+
|
|
142
|
+
## Development commands
|
|
143
|
+
|
|
144
|
+
```
|
|
145
|
+
uv run pytest # test suite
|
|
146
|
+
uv run ruff check . # lint
|
|
147
|
+
uv run ruff format . # format
|
|
148
|
+
uv run mypy # strict type check
|
|
149
|
+
uv build # sdist + wheel into dist/
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
CI runs exactly these on every pull request, plus the test suite against
|
|
153
|
+
Python 3.10 through 3.14.
|
|
154
|
+
|
|
155
|
+
## Releasing
|
|
156
|
+
|
|
157
|
+
The version in `pyproject.toml` is the single source of truth. To cut a
|
|
158
|
+
release, bump it, add the matching `## [x.y.z]` section to `CHANGELOG.md`, and
|
|
159
|
+
merge to `master`. The release workflow then tags `v<version>`, creates a
|
|
160
|
+
GitHub Release with those notes, and publishes the sdist and wheel to
|
|
161
|
+
[PyPI](https://pypi.org/project/walsh/) using Trusted Publishing — no API token
|
|
162
|
+
is stored in this repository.
|
|
163
|
+
|
|
164
|
+
Merges that do not change the version are a no-op, since PyPI permanently
|
|
165
|
+
refuses to accept the same version twice.
|
|
166
|
+
|
|
167
|
+
## File format
|
|
168
|
+
|
|
169
|
+
`compress` writes a `.cim` file: an atypical, project-specific container, so
|
|
170
|
+
most commercial tools will not be able to read it. It stores the image
|
|
171
|
+
dimensions, three block-layout descriptions (Y, Cb, Cr), and the retained
|
|
172
|
+
Walsh-Hadamard coefficients as little-endian `int16`.
|
|
173
|
+
|
|
174
|
+
## Effects
|
|
175
|
+
|
|
176
|
+

|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
walsh/__init__.py,sha256=z4qmhYWDtt8jTDpLKZjeDvPFAexrYMcmfIhnZMOLOBQ,1086
|
|
2
|
+
walsh/cli.py,sha256=yW8D1JRuRBi8fTvytjxkknc63qkcZXdTPR2wcsYjSzw,3188
|
|
3
|
+
walsh/colors.py,sha256=MCndT-6a2gsQ1ogVJfc-cyw6tNkxBM_JmN6seb-n2CQ,1832
|
|
4
|
+
walsh/decorators.py,sha256=yROYvcJnA7KxIEIiH94G8CeopeglKJ6uD9ul1pd2jhc,1832
|
|
5
|
+
walsh/image.py,sha256=05ts2ik5rCY328QfSTrwc5JJJP65lxqCkW2tgnAeoM8,10815
|
|
6
|
+
walsh/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
walsh/task.py,sha256=eYa60rD-cP9OmoFZMUWRA3xWJvEs3mJ8FFyfPOUwjMs,8286
|
|
8
|
+
walsh/transforms.py,sha256=ztW7a414lDJvWDsrKNxAd0aIE5Ki4AXjKIsiBHHhJLw,3631
|
|
9
|
+
walsh-0.1.1.dist-info/licenses/LICENSE,sha256=RBQ-xJOWhbkaAnMhPY22613A552wbnzo9YDyyzkgi3s,1070
|
|
10
|
+
walsh-0.1.1.dist-info/METADATA,sha256=91YJQcfNz1QO1waWotalK3BLar62fxwikkEXqwqD4Oo,6685
|
|
11
|
+
walsh-0.1.1.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
12
|
+
walsh-0.1.1.dist-info/entry_points.txt,sha256=79C5XI5316tKYSbcQK0PExCg1i36aIl5nl_yLd7zLwQ,41
|
|
13
|
+
walsh-0.1.1.dist-info/top_level.txt,sha256=LHrQEOKEm3Lp4RmLHTFxM25Up6nPIke45PfQCedudLw,6
|
|
14
|
+
walsh-0.1.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2019 Oskar Jarczyk
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
walsh
|