Checkpoint WIP

This commit is contained in:
kris 2021-01-21 23:17:55 +00:00
parent 9f0cd870e7
commit a4ecf69610
7 changed files with 344 additions and 37 deletions

141
README.md Normal file
View File

@ -0,0 +1,141 @@
# ][-pix
][-pix is an image conversion utility targeting Apple II graphics modes, currently Double Hi-Res.
## Installation
Requires:
* python 3.x
* numpy
* cython
* colour-science [XXX]
XXX cython compilation
XXX precompute distance matrix
## Examples
Original:
![Two colourful parrots sitting on a branch](examples/Vibrant_Wings.jpg)
Preview:
![Two colourful parrots sitting on a branch](examples/wings-preview.png)
OpenEmulator screenshot:
![Two colourful parrots sitting on a branch](examples/wings-openemulator.png)
(Source: [Wikimedia](https://commons.wikimedia.org/wiki/File:Vibrant_Wings.jpg))
## Some background on Apple II Double Hi-Res graphics
Like other (pre-//gs) Apple II graphics modes, Double Hi-Res relies on NTSC Artifact Colour, which means that the colour of a pixel is entirely determined by its horizontal position on the screen, and the on/off status of preceding horizontal pixels.
In Double Hi-Res mode, there are 560 horizontal pixels per line, which are individually addressable. This is an improvement over the (single) Hi-Res mode, which also has 560 horizontal pixels, but which can only be addressed in groups of two (with an option to shift a block of 3.5 pixels by one dot). See XXX for an introduction to this.
Double Hi-Res is capable of producing 16 display colours, but with heavy restrictions on how these colours can be arranged horizontally. One simple model is to only treat the display in groups of 4 horizontal pixels, which gives an effective resolution of 140x192 in 16 colours. These 140 pixel colours can be chosen independently, but they exhibit interference/fringing effects when two colours meet.
A more useful model for thinking about DHGR is to consider a _sliding_ window of 4 horizontal pixels moving across the screen. These 4 pixel values produce one of 16 colours at each of 560 horizontal positions. The precise mapping depends also on the value x%4, which approximates the NTSC colour phase.
This allows us to understand and predict the interference behaviour in terms of the "effective 140px" model described above.
XXX slides
When we consider the next horizontal position, 3 of the values in our sliding window are already fixed, and we only have 2 choices available, namely a 0 or 1 in the new position. This means that there are only *two* possible colours for each successive pixel. One of these corresponds to the same colour as the current pixel; the other is some other colour from our palette of 16.
So, if we want to transition from one colour to a particular new colour, it may take up to 4 horizontal pixels before we are able to achieve it (e.g. 0000 --> 1111). In the meantime we have to transition through up to 3 other colours, which may or may not be desirable visually.
These constraints are difficult to work with when constructing DHGR graphics "by hand" (though you can easily construct a transition graph/table showing the available choices for a given x colour and x%4 value), but we can account for them programmatically in our image conversion to take full advantage of the "true" 560px resolution while accounting for colour interference effects.
### Limitations of this colour model
In practise the above description of the Apple II colour model is only a discrete approximation. On real hardware, the video signal is a continuous analogue signal, and colour is continuously modulated rather than producing discrete coloured pixels with fixed colour values.
Furthermore, in an NTSC video signal the colour (chroma) signal has a lower bandwidth than the luma (brightness) signal,
which means that colours will tend to bleed across multiple pixels. i.e. the influence on pixel x+1 from previous pixel on/off states is a more complex function then the mapping described above.
This means that images produced by ][-pix do not always quite match colours produced on real hardware (or high-fidelity emulators, like OpenEmulator) due to this colour bleeding effect. In principle, it would be possible to simulate the NTSC video signal more directly to account for this during image processing.
For example XXX
## Dithering and Double Hi-Res
Dithering an image to produce an approximation with fewer image colours is a well-known technique. The basic idea is to pick a "best colour match" for a pixel from our limited palette, then to compute the difference between the true and selected colour values and diffuse this error to nearby pixels (using some pattern).
In the particular case of DHGR this algorithm runs into difficulties, because each pixel only has two possible colour choices (from a total of 16). If we only consider the two possibilities for the immediate next pixel then neither may be a particularly good match. However it may be more beneficial to make a suboptimal choice now (deliberately introduce more error), if it allows us access to a better colour for a subsequent pixel. "Classical" dithering algorithms do not account for these palette constraints, and produce suboptimal image quality for DHGR conversions.
We can deal with this by looking ahead N pixels (6 by default) for each image position (x,y), and computing the effect of choosing all 2^N combinations of these N-pixel states on the dithered source image.
Specifically, for a fixed choice of one of these N pixel sequences, we tentatively perform the error diffusion as normal on a copy of the image, and compute the total mean squared distance from the (fixed) N-pixel sequence to the error-diffused source image. For the perceptual colour distance metric we use CIE2000 delta-E, see XXX
Finally, we pick the N-pixel sequence with the lowest total error, and select the first pixel of this N-pixel sequence for position (x,y). We then performing error diffusion as usual for this single pixel, and proceed to x+1.
This allows us to "look beyond" local minima to find cases where it is better to make a suboptimal choice now to allow better overall image quality in subsequent pixels. Since we will sometimes find that our choice of 2 next-pixel colours actually includes (or comes close to) the "ideal" choice, this means we can take maximal advantage of the 560-pixel horizontal resolution.
## Palettes
Since the Apple II graphics are not based on RGB colour, we have to approximate an RGB colour palette when dithering an RGB image.
Different emulators have made (often quite different) choices for their RGB colour palettes, so an image that looks good on one emulator may not look good on another (or on real hardware). For example Virtual II uses two different RGB shades of grey for the DHGR colour values that are rendered as identical shade of grey in NTSC.
Secondly, the actual display colours rendered by an Apple II are not fixed, but bleed into each other due to the behaviour of the (analogue) NTSC video signal. i.e. the entire notion of a "16-colour RGB palette" is a flawedone. The model described above where we can assign from 16 fixed colours to each of 560 discrete pixels is only an approximation (though a useful one in practise).
Some emulators emulate the NTSC video signal more faithfully (e.g. OpenEmulator), in which case they do not have a true "RGB palette". Others (e.g. Virtual II) seem to use a discrete approximation similar to the one described above, so a fixed palette can be reconstructed.
To compute the emulator palettes used by ][-pix I measured the sRGB colour values produced by a full-screen Apple II colour image (using the colour picker tool of Mac OS X). I have not yet attempted to measure/estimate palettes of other emulators, or "real hardware" (since I don't actually have a composite colour monitor!)
Existing conversion tools (see below) tend to support a variety of RGB palette values sourced from various places (older tools, emulators, theoretical estimations etc). I suppose the intention is to try various of these on your target platform (emulator or hardware) to see which give good results. In practise I think it would be more useful to only support additional targets that are in modern use.
## Precomputing distance matrix
Computing the CIE2000 distance between two colour values is fairly expensive, since the formula is complex. We deal with this by precomputing a matrix from all 256^3 integer RGB values to the 16 RGB values in a palette. This 256MB matrix is generated on disk by the precompute_distance.py utility, and is mmapped at runtime for efficient access.
# Comparison to other DHGR image converters
## bmp2dhr
* bmp2dhr (XXX) supports additional graphics modes not yet supported by ii-pix, namely (double) lo-res, and hi-res. Support for the lores modes would be easy to add to ii-pix, although hi-res requires more work to accommodate the colour model. A similar lookahead strategy will likely work well though.
* supports additional dither modes
* It does not perform RGB colour space conversions before dithering, i.e. if the input image is in sRGB colour space (as most digital images will be) then the dithering is also performed in sRGB. Since sRGB is not a linear colour space, the effect of dithering is to distribute errors non-linearly, which reduces image quality.
* DHGR conversions are treated as simple 140x192x16 colour images without colour constraints, and ignores the colour fringing behaviour described above. The generated .bmp preview images also do not show fringing, but it is (of course) present when viewing the image on an Apple II or emulator that accounts for it. i.e. the preview images are not especially representative of the actual results.
* Apart from ignoring DHGR colour interactions, the 140px converted images are also lower than ideal resolution since they do not make use of the ability to address all 560px independently.
* The perceptual colour distance metric used to match the best colour to an input pixel seems to be an ad-hoc one based on a weighted sum of Euclidean sRGB distance and Rec.601 luma value. In practise this seems to give lower quality results than CIE2000 (though the latter is slower to compute - which is why we precompute the distance matrix ahead of time)
## a2bestpix ([Link](http://lukazi.blogspot.com/2017/03/double-high-resolution-graphics-dhgr.html))
* Like ii-pix, it only supports DHGR conversion. Overall quality is fairly good although colours are slightly distorted (for reasons described below), and the generated preview images do not quite give a faithful representation of the native image quality.
* Like ii-pix, and unlike bmp2dhr, a2bestpix does apply a model of the DHGR colour interactions, albeit an ad-hoc one based on rules and tables of 4-pixel "colour blocks" reconstructed from (AppleWin) emulator behaviour. This does allow it to make use of (closer to) full 560px resolution, although it still treats the screen as a sequence of 140 4-pixel colour blocks (with some constraints on the allowed arrangement of these blocks).
* supports additional dither modes (partly out of necessity due to the custom "colour block" model)
* Supports a variety of perceptual colour distance metrics including CIE2000 and the one bmp2dhr uses. In practise I'm not sure the others are useful since CIE2000 is the most recent refinement of much research on this topic, and is the most accurate.
* Does not transform from sRGB to linear RGB before dithering (though sRGB conversion is done when computing CIE2000 distance), so error is diffused non-linearly. This impacts colour balance when dithering.
* image conversion performs an optimization over groups of multiple pixels (via choice of "colour blocks"). From what I can tell this minimizes the total colour distance from a fixed list of colour blocks to a group of 4 target pixels, similar to --lookahead=4 for ii-pix (though I'm not sure it's evaluating all 2^4 pixel combinations). But since the image is (AFAICT) treated as a sequence of (non-overlapping) 4-pixel blocks this does not result in optimizing each output pixel independently.
* The list of "colour blocks" seem to contain colour sequences that cannot actually be rendered on the Apple II. For example compare the spacing of yellow and orange pixels on the parrot between the preview image (LHS) and openemulator (RHS):
![Detail of a2bestpix preview image](docs/a2bestbix-preview-crop.png)
![Detail of openemulator render](docs/a2bestpix-openemulator-crop.png)
* Other discrepancies are also visible when comparing these two images. This means that (like bmp2dhr) the generated "preview" image does not closely match the native image, and the dithering algorithm is also optimizing over a slightly incorrect set of colour sequences, which impacts image quality. Possibly these are transcription errors, or artifacts of the particular emulator (AppleWin) from which they were reconstructed.
# Future work
* Supporting lo-res and double lo-res graphics modes would be straightforward.
* Hi-res will require more care, since the 560 pixel display is not individually dot addressible. In particular the behaviour of the "palette bit" (which shifts a group of 7 dots to the right by 1) is another optimization constraint. In practise a similar lookahead algorithm should work well though.
* With more work to model the NTSC video signal it should be possible to produce images that better account for the NTSC signal behaviour. For example I think it is still true that at each horizontal dot position there is a choice of two possible "output colours", but these are influenced by the previous pixel values in a more complex way and do not come from a fixed palette of 16 choices.
* I would like to be able to find an ordered dithering algorithm that works well for Apple II graphics. Ordered dithering specifically avoids diffusing errors arbitrarily across the image, which produces visual noise (and unnecessary deltas) when combined with animation. For example such a thing may work well with my II-Vision video streamer. However the properties of NTSC artifact colour seem to be in conflict with these requirements, i.e. pixel changes *always* propagate colour to some extent.

View File

@ -66,6 +66,7 @@ def main():
Image.fromarray(resized.astype(np.uint8)).show()
dither = dither_pattern.PATTERNS[args.dither]()
print(dither.PATTERN)
start = time.time()
output_4bit, output_rgb = dither_pyx.dither_image(
@ -86,9 +87,10 @@ def main():
out_image = Image.fromarray(image_py.linear_to_srgb(output_rgb).astype(
np.uint8))
if args.show_output:
# out_image.show()
image_py.resize(out_image, 560, 384, srgb_output=True).show()
outfile = os.path.join(os.path.splitext(args.output)[0] + ".png")
outfile = os.path.join(os.path.splitext(args.output)[0] + "_preview.png")
out_image.save(outfile, "PNG")
with open(args.output, "wb") as f:

View File

@ -14,19 +14,20 @@ cdef float clip(float a, float min_value, float max_value) nogil:
return min(max(a, min_value), max_value)
@cython.boundscheck(False)
#@cython.boundscheck(False)
@cython.wraparound(False)
cdef void apply_one_line(float[:, :, ::1] pattern, int xl, int xr, float[] image, int image_shape0, float[] quant_error) nogil:
cdef void apply_one_line(float[:, :, ::1] pattern, int xl, int xr, int x, int x_origin, float[] image, int image_shape1, float[] quant_error):
cdef int i, j
cdef float error
for i in range(xr - xl):
for i in range(xl, xr):
for j in range(3):
error = pattern[0, i, 0] * quant_error[j]
image[(xl + i) * image_shape0 + j] = clip(image[(xl + i) * image_shape0 + j] + error, 0, 255)
# print("aol: x=%d, applying pattern pos %d to pos %d" % (x, i-x+1, i))
error = pattern[0, i - x + x_origin, 0] * quant_error[j]
image[i * image_shape1 + j] = clip(image[i * image_shape1 + j] + error, 0, 255)
@cython.boundscheck(False)
#@cython.boundscheck(False)
@cython.wraparound(False)
cdef apply(dither, screen, int x, int y, float [:, :, ::1]image, float[] quant_error):
cdef int i, j, k
@ -38,16 +39,24 @@ cdef apply(dither, screen, int x, int y, float [:, :, ::1]image, float[] quant_e
cdef int yb = dither_bounds_yb(pattern, dither.ORIGIN[0], screen.Y_RES, y)
cdef int xl = dither_bounds_xl(dither.ORIGIN[1], x)
cdef int xr = dither_bounds_xr(pattern, dither.ORIGIN[1], screen.X_RES, x)
# print("X %d %d %d" % (xl, x, xr))
# print("Y %d %d %d" % (yt, y, yb))
cdef float error
# We could avoid clipping here, i.e. allow RGB values to extend beyond
# 0..255 to capture a larger range of residual error. This is faster
# but seems to reduce image quality.
for i in range(yb - yt):
for j in range(xr - xl):
for i in range(yt, yb):
for j in range(xl, xr):
# XXX partially compute error here
for k in range(3):
error = pattern[i, j, 0] * quant_error[k]
image[yt+i, xl+j, k] = clip(image[yt+i, xl+j, k] + error, 0, 255)
# XXX unroll/malloc pattern
error = pattern[i - y, j - x + dither.ORIGIN[1], 0] * quant_error[k]
#print("Pattern %f " % pattern[i - y, j - x + dither.ORIGIN[1], 0])
#print("Apply error %f" % quant_error[k])
#print(error)
#print("(%d, %d) -> (%d, %d, %d): %f --> %f (%f, %f)" % (y, x, i, j, k, image[i,j,k], error, pattern[i - y, j - x + dither.ORIGIN[1], 0], quant_error[k]))
image[i, j, k] = clip(image[i, j, k] + error, 0, 255)
# print("%d %d %d" % (i,j,k))
@cython.boundscheck(False)
@ -55,13 +64,15 @@ cdef apply(dither, screen, int x, int y, float [:, :, ::1]image, float[] quant_e
cdef int dither_bounds_xl(int x_origin, int x):
cdef int el = max(x_origin - x, 0)
cdef int xl = x - x_origin + el
# print("xl: origin=%d x=%d el=%d, xl=%d" % (x_origin, x, el, xl))
return xl
@cython.boundscheck(False)
@cython.wraparound(False)
cdef int dither_bounds_xr(float [:, :, ::1] pattern, int x_origin, int x_res, int x):
cdef int er = min(pattern.shape[1], x_res - 1 - x)
cdef int er = min(pattern.shape[1], x_res - x)
cdef int xr = x - x_origin + er
# print("xr: shape=%d origin=%d res=%d x=%d er=%d, xr=%d" % (pattern.shape[1], x_origin, x_res, x, er, xr))
return xr
@cython.boundscheck(False)
@ -75,11 +86,11 @@ cdef int dither_bounds_yt(int y_origin, int y):
@cython.boundscheck(False)
@cython.wraparound(False)
cdef int dither_bounds_yb(float [:, :, ::1] pattern, int y_origin, int y_res, int y):
cdef int eb = min(pattern.shape[0], y_res - 1 - y)
cdef int eb = min(pattern.shape[0], y_res - y)
cdef int yb = y - y_origin + eb
return yb
@cython.boundscheck(False)
# @cython.boundscheck(False)
@cython.wraparound(False)
def dither_lookahead(
screen, float[:,:,::1] image_rgb, dither, int x, int y, unsigned char[:, ::1] options_4bit,
@ -92,8 +103,8 @@ def dither_lookahead(
cdef int xr = dither_bounds_xr(pattern, dither_x_origin, x_res, x)
# X coord value of larger of dither bounding box or lookahead horizon
cdef int xxr = min(max(x + lookahead, xr), x_res)
cdef int xxr = min(max(x + lookahead, xr), x_res) # XXX
# print("xxr=%d, x=%d, xr=%d, x_res=%d" % (xxr, x, xr, x_res))
cdef int i, j, k, l
cdef int lah_shape0 = 2 ** lookahead
@ -106,7 +117,7 @@ def dither_lookahead(
for k in range(3):
lah_image_rgb[i * lah_shape1 * lah_shape2 + j * lah_shape2 + k] = image_rgb[y, x+j, k]
# Leave enough space at right of image so we can dither the last of our lookahead pixels.
for j in range(xxr - x, lookahead + xr - xl):
for j in range(xxr - x, lookahead + xr - xl): # XXX
for k in range(3):
lah_image_rgb[i * lah_shape1 * lah_shape2 + j * lah_shape2 + k] = 0
@ -114,7 +125,8 @@ def dither_lookahead(
# Iterating by row then column is faster for some reason?
for i in range(xxr - x):
xl = dither_bounds_xl(dither_x_origin, i)
xr = dither_bounds_xr(pattern, dither_x_origin, x_res, i)
xr = dither_bounds_xr(pattern, dither_x_origin, x_res - x, i)# XXX right-hand bounds?
# print("aol: %d %d (%d) %d" % (xl, i, i+x, xr))
for j in range(2 ** lookahead):
# Don't update the input at position x (since we've already chosen
# fixed outputs), but do propagate quantization errors to positions >x
@ -124,8 +136,10 @@ def dither_lookahead(
# quantization error from having made these choices, in order to compute
# the total error
for k in range(3):
#print("j=%d, i=%d, k=%d, lah=%f, option=%f" % (j, i, k, lah_image_rgb[j * lah_shape1 * lah_shape2 + i * lah_shape2 + k] , options_rgb[j,i,k]))
quant_error[k] = lah_image_rgb[j * lah_shape1 * lah_shape2 + i * lah_shape2 + k] - options_rgb[j, i, k]
apply_one_line(pattern, xl, xr, &lah_image_rgb[j * lah_shape1 * lah_shape2], lah_shape2, quant_error)
#print("qe=%f" % (quant_error[k]))
apply_one_line(pattern, xl, xr, i, dither_x_origin, &lah_image_rgb[j * lah_shape1 * lah_shape2], lah_shape2, quant_error)
cdef unsigned char bit4
cdef int best
@ -144,14 +158,17 @@ def dither_lookahead(
b = <long>clip(lah_image_rgb[i * lah_shape1 * lah_shape2 + j * lah_shape2 + 2], 0, 255)
flat = (r << 16) + (g << 8) + b
# print("%f, r=%d, g=%d, b=%d, flat=%d" % (lah_image_rgb[i * lah_shape1 * lah_shape2 + j * lah_shape2 + 2], r,g,b,flat))
bit4 = options_4bit[i, j]
dist = distances[flat, bit4]
total_error += dist ** 2
if total_error >= best_error:
break
total_error += <long>dist ** 2 # * (j+1)
#if total_error >= best_error:
# break
#print("total_error %d %d" % (i, total_error))
if total_error < best_error:
best_error = total_error
best = i
#print("best=%d" % best)
free(lah_image_rgb)
return options_4bit[best, 0], options_rgb[best, 0, :]
@ -214,10 +231,12 @@ def dither_image(
cdef float[::1] input_pixel_rgb
for y in range(yres):
# print(y)
#print(y)
output_pixel_4bit = 0
for x in range(xres):
input_pixel_rgb = image_rgb[y, x, :]
#for i in range(3):
#print("Input %f" % input_pixel_rgb[i])
if lookahead:
palette_choices_4bit, palette_choices_rgb = lookahead_options(
screen, lookahead, output_pixel_4bit, x % 4)
@ -231,8 +250,14 @@ def dither_image(
find_nearest_colour(screen, input_pixel_rgb, palette_choices_4bit, palette_choices_rgb)
for i in range(3):
quant_error[i] = input_pixel_rgb[i] - output_pixel_rgb[i]
image_rgb[y, x, i] = output_pixel_rgb[i]
#print("Input2 %f" % input_pixel_rgb[i])
#print("Output %f" % output_pixel_rgb[i])
#print("QE %f" % quant_error[i])
# XXX dither channels independently
image_4bit[y, x] = output_pixel_4bit
apply(dither, screen, x, y, image_rgb, quant_error)
for i in range(3):
# print(output_pixel_rgb[i])
image_rgb[y, x, i] = output_pixel_rgb[i]
return image_4bit, np.array(image_rgb)

View File

@ -16,6 +16,18 @@ class FloydSteinbergDither(DitherPattern):
ORIGIN = (0, 1)
class FloydSteinbergDither2(DitherPattern):
"""Floyd-Steinberg dither."""
# 0 * 7
# 3 5 1
PATTERN = np.array(
((0, 0, 0, 0, 0, 7),
(3, 5, 1, 0, 0, 0)),
dtype=np.float32).reshape(2, 6, 1) / np.float(16)
# XXX X_ORIGIN since ORIGIN[0] == 0
ORIGIN = (0, 2)
class BuckelsDither(DitherPattern):
"""Default dither from bmp2dhr."""
# 0 * 2 1
@ -37,11 +49,130 @@ class JarvisDither(DitherPattern):
ORIGIN = (0, 2)
class NoDither(DitherPattern):
"""Floyd-Steinberg dither."""
# 0 * 7
# 3 5 1
PATTERN = np.array(((0, 0), (0, 0)),
dtype=np.float32).reshape(2, 2, 1) / np.float(16)
# XXX X_ORIGIN since ORIGIN[0] == 0
ORIGIN = (0, 1)
class TestDither(DitherPattern):
"""Jarvis dithering."""
# 0 0 X 7 5
# 3 5 7 5 3
# 1 3 5 3 1
PATTERN = np.array((
(0, 0, 0, 31, 29, 27, 25),
(3, 5, 7, 5, 3, 1, 0),
(1, 3, 5, 3, 1, 0, 0)), dtype=np.float32).reshape(3, 7, 1)
PATTERN /= np.sum(PATTERN)
ORIGIN = (0, 2)
class xTestDither(DitherPattern):
"""Jarvis dithering."""
# 0 0 X 7 5
# 3 5 7 5 3
# 1 3 5 3 1
PATTERN = np.array((
(0, 0, 15, 15, 15, 15),
(3, 3, 5, 5, 1, 1)), dtype=np.float32).reshape(2, 6, 1)
PATTERN /= np.sum(PATTERN)
ORIGIN = (0, 1)
class TestDither(DitherPattern):
"""Jarvis dithering."""
# 0 0 X 7 5
# 3 5 7 5 3
# 1 3 5 3 1
PATTERN = np.array((
(0, 0, 0, 7, 7, 7, 7),
(3, 5, 7, 5, 3, 1, 0),
(1, 3, 5, 3, 1, 0, 0)), dtype=np.float32).reshape(3, 7, 1)
PATTERN /= np.sum(PATTERN)
ORIGIN = (0, 2)
# !!
class TestDither(DitherPattern):
"""Jarvis dithering."""
# 0 0 X 7 5
# 3 5 7 5 3
# 1 3 5 3 1
PATTERN = np.array((
(0, 0, 0, 9, 7, 5, 3),
(3, 5, 7, 5, 3, 1, 0),
(1, 3, 5, 3, 1, 0, 0)), dtype=np.float32).reshape(3, 7, 1)
PATTERN /= np.sum(PATTERN)
ORIGIN = (0, 2)
# !!!
class TestDither(DitherPattern):
"""Jarvis dithering."""
# 0 0 X 7 5
# 3 5 7 5 3
# 1 3 5 3 1
PATTERN = np.array((
(0, 0, 0, 15, 11, 7, 3),
(3, 5, 7, 5, 3, 1, 0),
(1, 3, 5, 3, 1, 0, 0)), dtype=np.float32).reshape(3, 7, 1)
PATTERN /= np.sum(PATTERN)
ORIGIN = (0, 2)
class xTestDither(DitherPattern):
"""Jarvis dithering."""
# 0 0 X 7 5
# 3 5 7 5 3
# 1 3 5 3 1
PATTERN = np.array((
(0, 0, 0, 9, 9,9,9),
(3, 5, 7, 5, 3, 1, 0),
(1, 3, 5, 3, 1, 0, 0)), dtype=np.float32).reshape(3, 7, 1)
PATTERN /= np.sum(PATTERN)
ORIGIN = (0, 2)
class xTestDither(DitherPattern):
"""Jarvis dithering."""
# 0 0 X 7 5
# 3 5 7 5 3
# 1 3 5 3 1
PATTERN = np.array((
(0, 0, 0, 15,13,11,9),
(3, 5, 7, 5, 3, 1, 0),
(1, 3, 5, 3, 1, 0, 0)), dtype=np.float32).reshape(3, 7, 1)
PATTERN /= np.sum(PATTERN)
ORIGIN = (0, 2)
class xTestDither(DitherPattern):
"""Jarvis dithering."""
# 0 0 X 7 5
# 3 5 7 5 3
# 1 3 5 3 1
PATTERN = np.array((
(0, 0, 15,),
(3, 5, 1,)), dtype=np.float32).reshape(2, 3, 1)
PATTERN /= np.sum(PATTERN)
ORIGIN = (0, 1)
PATTERNS = {
'floyd': FloydSteinbergDither,
'floyd2': FloydSteinbergDither2,
'floyd-steinberg': FloydSteinbergDither,
'buckels': BuckelsDither,
'jarvis': JarvisDither
'jarvis': JarvisDither,
'test': TestDither,
'none': NoDither
}
DEFAULT_PATTERN = 'jarvis'
DEFAULT_PATTERN = 'jarvis'

View File

@ -34,6 +34,7 @@ def resize(image: Image, x_res, y_res, srgb_output: bool = False) -> Image:
# Convert to linear RGB before rescaling so that colour interpolation is
# in linear space
linear = srgb_to_linear(np.asarray(image)).astype(np.uint8)
# linear = np.asarray(image).astype(np.uint8)
res = Image.fromarray(linear).resize((x_res, y_res), Image.LANCZOS)
if srgb_output:
return Image.fromarray(

View File

@ -14,13 +14,13 @@ class Palette:
dtype=np.uint8, shape=(16777216, 16))
# Default bmp2dhr palette
RGB = {
sRGB = {
0: np.array((0, 0, 0)), # Black
8: np.array((148, 12, 125)), # Magenta
4: np.array((99, 77, 0)), # Brown
12: np.array((249, 86, 29)), # Orange
2: np.array((51, 111, 0)), # Dark green
10: np.array((126, 126, 125)), # Grey2
10: np.array((126, 126, 126)), # Grey2
6: np.array((67, 200, 0)), # Green
14: np.array((221, 206, 23)), # Yellow
1: np.array((32, 54, 212)), # Dark blue

View File

@ -1,4 +1,5 @@
import dither
import image
import palette as palette_py
import colour.difference
import numpy as np
@ -7,19 +8,19 @@ COLOURS = 256
def rgb_to_lab(rgb: np.ndarray):
srgb = np.clip(
dither.linear_to_srgb_array(rgb.astype(np.float32) / 255), 0.0,
image.linear_to_srgb_array(rgb.astype(np.float32) / 255), 0.0,
1.0)
xyz = colour.sRGB_to_XYZ(srgb)
return colour.XYZ_to_Lab(xyz)
def nearest_colours():
def nearest_colours(palette):
diffs = np.empty((COLOURS ** 3, 16), dtype=np.float32)
all_rgb = np.array(tuple(np.ndindex(COLOURS, COLOURS, COLOURS)),
dtype=np.uint8)
all_lab = rgb_to_lab(all_rgb)
for i, palette_rgb in dither.RGB.items():
for i, palette_rgb in palette.RGB.items():
print(i)
palette_lab = rgb_to_lab(palette_rgb)
diffs[:, i] = colour.difference.delta_E_CIE2000(all_lab, palette_lab)
@ -28,7 +29,13 @@ def nearest_colours():
return (diffs / norm * 255).astype(np.uint8)
n = nearest_colours()
out = np.memmap(filename="distances.npy", mode="w+", dtype=np.uint8,
shape=n.shape)
out[:] = n[:]
def main():
palette = palette_py.Palette()
n = nearest_colours(palette)
out = np.memmap(filename="distances_default.npy", mode="w+", dtype=np.uint8,
shape=n.shape)
out[:] = n[:]
if __name__ == "__main__":
main()