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 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
+ [![PyPI](https://img.shields.io/pypi/v/walsh.svg)](https://pypi.org/project/walsh/)
36
+ [![Python versions](https://img.shields.io/pypi/pyversions/walsh.svg)](https://pypi.org/project/walsh/)
37
+ [![CI](https://github.com/oskar-j/walsh-hadamard-transform/actions/workflows/ci.yml/badge.svg)](https://github.com/oskar-j/walsh-hadamard-transform/actions/workflows/ci.yml)
38
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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
+ ![https://raw.githubusercontent.com/oskar-j/walsh-hadamard-transform/master/doc/sample_usage.jpg](https://raw.githubusercontent.com/oskar-j/walsh-hadamard-transform/master/doc/sample_usage.jpg)
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ walsh = walsh.cli:main
@@ -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