It's honestly a bit hard to tell what you're really looking for, but here's some code that:
- generates a variety of NxN random dither patterns for each grayscale shade (assuming 8-bit images)
- chooses a random pattern per NxN pixels in the original image to generate a dithered version
On my Macbook, dithering an 920x920 image takes about 17 milliseconds:
image generation 4.377
pattern generation 6.06
dither generation 16.915
import time
from contextlib import contextmanager
import numpy as np
from PIL import Image
def generate_patterns(
*,
pattern_size: int = 8,
pattern_options_per_shade: int = 8,
shades: int = 256,
):
patterns = []
for shade in range(shades):
shade_patterns = [
np.random.random((pattern_size, pattern_size)) < (shade / shades)
for i in range(pattern_options_per_shade)
]
patterns.append(shade_patterns)
return np.array(patterns)
def dither(image, patterns):
(
shades,
pattern_options_per_shade,
pattern_width,
pattern_height,
) = patterns.shape
assert shades == 256 # TODO
# image sampled at pattern_sizes
resampled = (
image[::pattern_width, ::pattern_height].round().astype(np.uint8)
)
# mask of pattern option per pattern_size block
pat_mask = np.random.randint(
0, pattern_options_per_shade, size=resampled.shape
)
dithered = np.zeros_like(image)
for (iy, ix), c in np.ndenumerate(resampled):
pattern = patterns[c, pat_mask[iy, ix]]
dithered[
iy * pattern_height : (iy + 1) * pattern_height,
ix * pattern_width : (ix + 1) * pattern_width,
] = pattern
return dithered * 255
@contextmanager
def stopwatch(title):
t0 = time.perf_counter()
yield
t1 = time.perf_counter()
print(title, round((t1 - t0) * 1000, 3))
def main():
with stopwatch("image generation"):
img_size = 920
image = (
np.linspace(0, 255, img_size)
.repeat(img_size)
.reshape((img_size, img_size))
)
image[200:280, 200:280] = 0
with stopwatch("pattern generation"):
patterns = generate_patterns()
with stopwatch("dither generation"):
dithered = dither(image, patterns)
import matplotlib.pyplot as plt
plt.figure(dpi=450)
plt.imshow(dithered, interpolation="none")
plt.show()
if __name__ == "__main__":
main()
The output image looks like (e.g.)

EDIT
A version that upscales the source image to the dithered version:
image generation 3.886
pattern generation 5.581
dither generation 1361.194
def dither_embiggen(image, patterns):
shades, pattern_options_per_shade, pattern_width, pattern_height = patterns.shape
assert shades == 256 # TODO
# mask of pattern option per source pixel
pat_mask = np.random.randint(0, pattern_options_per_shade, size=image.shape)
dithered = np.zeros((image.shape[0] * pattern_height, image.shape[1] * pattern_width))
for (iy, ix), c in np.ndenumerate(image.round().astype(np.uint8)):
pattern = patterns[c, pat_mask[iy, ix]]
dithered[iy * pattern_height:(iy + 1) * pattern_height, ix * pattern_width:(ix + 1) * pattern_width] = pattern
return (dithered * 255)
EDIT 2
This version directly writes the dithered lines to disk as a raw binary file. The reader is expected to know how many pixels there are per line. Based on a little empirical testing this seems to do the trick...
import time
from contextlib import contextmanager
import numpy as np
def generate_patterns(
*,
pattern_size: int = 8,
pattern_options_per_shade: int = 16,
shades: int = 256,
):
patterns = []
for shade in range(shades):
shade_patterns = [
np.packbits(
np.random.random((pattern_size, pattern_size))
< (shade / shades),
axis=0,
)[0]
for i in range(pattern_options_per_shade)
]
patterns.append(shade_patterns)
return np.array(patterns)
def dither_to_disk(bio, image, patterns):
assert image.dtype == np.uint8
shades, pattern_options_per_shade, pattern_height = patterns.shape
pat_mask = np.random.randint(0, pattern_options_per_shade, size=image.shape)
for y in range(image.shape[0]):
patterns[image[y, :], pat_mask[y, :]].tofile(bio)
@contextmanager
def stopwatch(title):
t0 = time.perf_counter()
yield
t1 = time.perf_counter()
print(title, round((t1 - t0) * 1000, 3))
def main():
with stopwatch("image generation"):
img_width = 25_000
img_height = 5_000
image = (
np.linspace(0, 255, img_height)
.repeat(img_width)
.reshape((img_height, img_width))
)
image[200:280, 200:280] = 0
image = image.round().astype(np.uint8)
with stopwatch("pattern generation"):
patterns = generate_patterns()
with stopwatch(f"dither_to_disk {image.shape}"):
with open("x.bin", "wb") as f:
dither_to_disk(f, image, patterns)
if __name__ == "__main__":
main()
replaceis a bit moot if yoursizeis 1, no?Values? What istiffexactly? What types of numbers do they hold? My ability to help you vectorize this hinges on tidbits like this.