mirror of
https://github.com/KrisKennaway/ii-pix.git
synced 2024-12-22 09:29:30 +00:00
Add support for hi-res conversions (#11)
Hi-Res is essentially a more constrained version of Double Hi-Res, in which only about half of the 560 horizontal screen pixels can be independently addressed. In particular an 8 bit byte in screen memory controls 14 or 15 screen pixels. Bits 0-7 are doubled, and bit 8 shifts these 14 dots to the right if enabled. In this case bit 7 of the previous byte is repeated a third time. This means that we have to optimize all 8 bits at once and move forward in increments of 14 screen pixels. There's also a timing difference that results in a phase shift of the NTSC colour signal, which means the mappings from dot patterns to effective colours are rotated. Error diffusion seems to give best results if we only distribute about 2/3 of the quantization error according to the dither pattern.
This commit is contained in:
parent
6573bad509
commit
3aa29f2d2c
55
README.md
55
README.md
@ -1,6 +1,6 @@
|
||||
# ][-pix 2.1
|
||||
|
||||
][-pix is an image conversion utility targeting Apple II graphics modes, currently Double Hi-Res
|
||||
][-pix is an image conversion utility targeting Apple II graphics modes, currently Hi-Res (all models), Double Hi-Res
|
||||
(enhanced //e, //c, //gs) and Super Hi-Res (//gs).
|
||||
|
||||
## Installation
|
||||
@ -39,7 +39,11 @@ To convert an image, the basic command is:
|
||||
python convert.py <mode> [<flags>] <input> <output>
|
||||
```
|
||||
where
|
||||
* `mode` is `dhr` for Double Hi-Res Colour (560x192), `dhr_mono` for Double Hi-Res Mono (560x192), or `shr` for Super Hi-Res (320x200)
|
||||
* `mode` is one of the following:
|
||||
* `hgr` for Hi-Res Colour (560x192 but only half of the horizontal pixels may be independently controlled)
|
||||
* `dhr` for Double Hi-Res Colour (560x192)
|
||||
* `dhr_mono` for Double Hi-Res Mono (560x192)
|
||||
* `shr` for Super Hi-Res (320x200)
|
||||
* `input` is the source image file to convert (e.g. `my-image.jpg`)
|
||||
* `output` is the output filename to produce (e.g. `my-image.dhr`)
|
||||
|
||||
@ -51,7 +55,23 @@ The following flags are supported in all modes:
|
||||
* `--verbose` Show progress during conversion (default: False)
|
||||
* `--gamma-correct` Gamma-correct image by this value (default: 2.4)
|
||||
|
||||
See below for DHR- and SHR- specific instructions.
|
||||
For other available options, use `python convert.py <mode> --help`
|
||||
|
||||
See below for mode-specific instructions.
|
||||
|
||||
## Hi-Res
|
||||
|
||||
To convert an image to Hi-Res the simplest usage is:
|
||||
|
||||
```buildoutcfg
|
||||
python convert.py hgr <input> <output.hgr>
|
||||
```
|
||||
|
||||
`<output.hgr>` contains the hires image data in a form suitable for transfer to an Apple II disk image.
|
||||
|
||||
TODO: document flags
|
||||
|
||||
TODO: add more details about HGR - resolution and colour model.
|
||||
|
||||
## Double Hi-Res
|
||||
|
||||
@ -65,8 +85,6 @@ python convert.py dhr --palette ntsc <input> <output.dhr>
|
||||
|
||||
By default, a preview image will be shown after conversion, and saved as `<output>-preview.png`
|
||||
|
||||
For other available options, use `python convert.py --help`
|
||||
|
||||
TODO: document flags
|
||||
|
||||
For more details about Double Hi-Res graphics and the conversion process, see [here](docs/dhr.md).
|
||||
@ -89,6 +107,27 @@ TODO: link to KansasFest 2022 talk slides/video for more details
|
||||
|
||||
# Examples
|
||||
|
||||
## Hi-Res
|
||||
|
||||
This image was generated using
|
||||
|
||||
```buildoutcfg
|
||||
python convert.py hgr examples/hgr/mandarin-duck.jpg examples/hgr/mandarin-duck.bin
|
||||
```
|
||||
The image on the right is a screenshot taken from OpenEmulator.
|
||||
|
||||
| ![Mandarin duck](examples/hgr/mandarin-duck.jpg) | ![Mandarin duck](examples/hgr/mandarin-duck-openemulator.png) |
|
||||
|--------------------------------------------------|---------------------------------------------------------------|
|
||||
|
||||
(Source: [Adrian Pingstone](https://commons.wikimedia.org/wiki/File:Mandarin.duck.arp.jpg), public domain, via Wikimedia Commons)
|
||||
|
||||
| ![Portrait](examples/hgr/portrait.jpg) | ![Portrait](examples/hgr/portrait-openemulator.png) |
|
||||
|---|---|
|
||||
|
||||
(Source: [Devanath](https://www.pikist.com/free-photo-srmda/fr), public domain)
|
||||
|
||||
TODO: add more hi-res images
|
||||
|
||||
## Double Hi-Res
|
||||
|
||||
See [here](examples/dhr/gallery.md) for more sample Double Hi-Res image conversions.
|
||||
@ -150,14 +189,16 @@ python convert.py shr examples/shr/rabbit-kitten-original.png examples/shr/rabbi
|
||||
|
||||
* Supporting lo-res and double lo-res graphics modes, and super hi-res 3200 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.
|
||||
|
||||
* Super hi-res 640 mode would also likely require some investigation, since it is a more highly constrained optimization problem than 320 mode.
|
||||
|
||||
* 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](https://github.com/KrisKennaway/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.
|
||||
|
||||
# Version history
|
||||
|
||||
## v2.2 (2023-02-03)
|
||||
|
||||
* Added support for HGR colour conversions
|
||||
|
||||
## v2.1 (2023-01-21)
|
||||
|
||||
* Added support for DHGR mono conversions
|
||||
|
73
convert.py
73
convert.py
@ -3,6 +3,7 @@
|
||||
import argparse
|
||||
import numpy as np
|
||||
|
||||
import convert_hgr as convert_hgr_py
|
||||
import convert_dhr as convert_dhr_py
|
||||
import convert_shr as convert_shr_py
|
||||
import dither_pattern
|
||||
@ -43,32 +44,13 @@ def add_common_args(parser):
|
||||
)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
subparsers = parser.add_subparsers(required=True)
|
||||
|
||||
dhr_parser = subparsers.add_parser("dhr")
|
||||
add_common_args(dhr_parser)
|
||||
|
||||
def validate_lookahead(arg: int) -> int:
|
||||
try:
|
||||
int_arg = int(arg)
|
||||
except Exception:
|
||||
raise argparse.ArgumentTypeError("--lookahead must be an integer")
|
||||
if int_arg < 1:
|
||||
raise argparse.ArgumentTypeError("--lookahead must be at least 1")
|
||||
return int_arg
|
||||
|
||||
dhr_parser.add_argument(
|
||||
"--lookahead", type=validate_lookahead, default=8,
|
||||
help=("How many pixels to look ahead to compensate for NTSC colour "
|
||||
"artifacts (default: 8)"))
|
||||
dhr_parser.add_argument(
|
||||
def add_dhr_hgr_args(parser):
|
||||
parser.add_argument(
|
||||
'--dither', type=str, choices=list(dither_pattern.PATTERNS.keys()),
|
||||
default=dither_pattern.DEFAULT_PATTERN,
|
||||
help="Error distribution pattern to apply when dithering (default: "
|
||||
+ dither_pattern.DEFAULT_PATTERN + ")")
|
||||
dhr_parser.add_argument(
|
||||
parser.add_argument(
|
||||
'--palette', type=str, choices=list(set(palette_py.PALETTES.keys())),
|
||||
default=palette_py.DEFAULT_PALETTE,
|
||||
help='RGB colour palette to dither to. "ntsc" blends colours over 8 '
|
||||
@ -76,16 +58,53 @@ def main():
|
||||
'use/emulate NTSC, but can be substantially slower. Other '
|
||||
'palettes determine colours based on 4 pixel sequences '
|
||||
'(default: ' + palette_py.DEFAULT_PALETTE + ")")
|
||||
dhr_parser.add_argument(
|
||||
parser.add_argument(
|
||||
'--show-palette', type=str, choices=list(palette_py.PALETTES.keys()),
|
||||
help="RGB colour palette to use when --show_output (default: "
|
||||
"value of --palette)")
|
||||
|
||||
|
||||
def validate_lookahead(arg: int) -> int:
|
||||
try:
|
||||
int_arg = int(arg)
|
||||
except Exception:
|
||||
raise argparse.ArgumentTypeError("--lookahead must be an integer")
|
||||
if int_arg < 1:
|
||||
raise argparse.ArgumentTypeError("--lookahead must be at least 1")
|
||||
return int_arg
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
subparsers = parser.add_subparsers(required=True)
|
||||
|
||||
# Hi-res
|
||||
hgr_parser = subparsers.add_parser("hgr")
|
||||
add_common_args(hgr_parser)
|
||||
add_dhr_hgr_args(hgr_parser)
|
||||
hgr_parser.add_argument(
|
||||
'--error_fraction', type=float, default = 0.7,
|
||||
help="Fraction of quantization error to distribute to neighbouring "
|
||||
"pixels according to dither pattern"
|
||||
)
|
||||
hgr_parser.set_defaults(func=convert_hgr)
|
||||
|
||||
# Double Hi-res
|
||||
dhr_parser = subparsers.add_parser("dhr")
|
||||
add_common_args(dhr_parser)
|
||||
add_dhr_hgr_args(dhr_parser)
|
||||
dhr_parser.add_argument(
|
||||
"--lookahead", type=validate_lookahead, default=8,
|
||||
help=("How many pixels to look ahead to compensate for NTSC colour "
|
||||
"artifacts (default: 8)"))
|
||||
dhr_parser.set_defaults(func=convert_dhr)
|
||||
|
||||
# Double Hi-Res mono
|
||||
dhr_mono_parser = subparsers.add_parser("dhr_mono")
|
||||
add_common_args(dhr_mono_parser)
|
||||
dhr_mono_parser.set_defaults(func=convert_dhr_mono)
|
||||
|
||||
# Super Hi-Res 320x200
|
||||
shr_parser = subparsers.add_parser("shr")
|
||||
add_common_args(shr_parser)
|
||||
shr_parser.add_argument(
|
||||
@ -119,6 +138,14 @@ def prepare_image(image_filename: str, show_input: bool, screen,
|
||||
gamma=gamma_correct)
|
||||
|
||||
|
||||
def convert_hgr(args):
|
||||
palette = palette_py.PALETTES[args.palette]()
|
||||
screen = screen_py.HGRNTSCScreen(palette)
|
||||
image = prepare_image(args.input, args.show_input, screen,
|
||||
args.gamma_correct)
|
||||
convert_hgr_py.convert(screen, image, args)
|
||||
|
||||
|
||||
def convert_dhr(args):
|
||||
palette = palette_py.PALETTES[args.palette]()
|
||||
screen = screen_py.DHGRNTSCScreen(palette)
|
||||
|
@ -10,15 +10,15 @@ import screen as screen_py
|
||||
import image as image_py
|
||||
|
||||
|
||||
def _output(out_image: Image, args):
|
||||
if args.show_output:
|
||||
def _output(out_image: Image, args):
|
||||
if args.show_output:
|
||||
out_image.show()
|
||||
|
||||
if args.save_preview:
|
||||
# Save Double hi-res image
|
||||
outfile = os.path.join(
|
||||
os.path.splitext(args.output)[0] + "-preview.png")
|
||||
out_image.save(outfile, "PNG")
|
||||
if args.save_preview:
|
||||
# Save Double hi-res image
|
||||
outfile = os.path.join(
|
||||
os.path.splitext(args.output)[0] + "-preview.png")
|
||||
out_image.save(outfile, "PNG")
|
||||
|
||||
|
||||
def _write(screen: screen_py.DHGRScreen, bitmap: np.ndarray, args):
|
||||
@ -28,6 +28,7 @@ def _write(screen: screen_py.DHGRScreen, bitmap: np.ndarray, args):
|
||||
f.write(bytes(screen.main))
|
||||
|
||||
|
||||
# TODO: unify with convert_hgr.convert()
|
||||
def convert(screen: screen_py.DHGRNTSCScreen, image: Image, args):
|
||||
rgb = np.array(image).astype(np.float32) / 255
|
||||
|
||||
@ -38,7 +39,7 @@ def convert(screen: screen_py.DHGRNTSCScreen, image: Image, args):
|
||||
os.path.join(base_dir, "data/rgb24_to_cam16ucs.npy"))
|
||||
|
||||
dither = dither_pattern.PATTERNS[args.dither]()
|
||||
bitmap = dither_dhr_pyx.dither_image(
|
||||
bitmap, _ = dither_dhr_pyx.dither_image(
|
||||
screen, rgb, dither, args.lookahead, args.verbose, rgb24_to_cam16ucs)
|
||||
|
||||
# Show output image by rendering in target palette
|
||||
@ -57,6 +58,7 @@ def convert(screen: screen_py.DHGRNTSCScreen, image: Image, args):
|
||||
_output(out_image, args)
|
||||
_write(screen, bitmap, args)
|
||||
|
||||
|
||||
def convert_mono(screen: screen_py.DHGRScreen, image: Image, args):
|
||||
image = image.convert("1")
|
||||
|
||||
|
59
convert_hgr.py
Normal file
59
convert_hgr.py
Normal file
@ -0,0 +1,59 @@
|
||||
import os.path
|
||||
|
||||
from PIL import Image
|
||||
import numpy as np
|
||||
|
||||
import dither_dhr as dither_dhr_pyx
|
||||
import dither_pattern
|
||||
import palette as palette_py
|
||||
import screen as screen_py
|
||||
import image as image_py
|
||||
|
||||
|
||||
def _output(out_image: Image, args):
|
||||
if args.show_output:
|
||||
out_image.show()
|
||||
|
||||
if args.save_preview:
|
||||
# Save Hi-res image
|
||||
outfile = os.path.join(
|
||||
os.path.splitext(args.output)[0] + "-preview.png")
|
||||
out_image.save(outfile, "PNG")
|
||||
|
||||
|
||||
def _write(screen: screen_py.HGRNTSCScreen, linear_bytemap: np.ndarray, args):
|
||||
screen.pack_bytes(linear_bytemap)
|
||||
with open(args.output, "wb") as f:
|
||||
f.write(bytes(screen.main))
|
||||
|
||||
|
||||
# TODO: unify with convert_dhr.convert()
|
||||
def convert(screen: screen_py.HGRNTSCScreen, image: Image, args):
|
||||
rgb = np.array(image).astype(np.float32) / 255
|
||||
|
||||
# Conversion matrix from RGB to CAM16UCS colour values. Indexed by
|
||||
# 24-bit RGB value
|
||||
base_dir = os.path.dirname(__file__)
|
||||
rgb24_to_cam16ucs = np.load(
|
||||
os.path.join(base_dir, "data/rgb24_to_cam16ucs.npy"))
|
||||
|
||||
dither = dither_pattern.PATTERNS[args.dither](
|
||||
error_fraction = args.error_fraction)
|
||||
bitmap, linear_bytemap = dither_dhr_pyx.dither_image(
|
||||
screen, rgb, dither, 8, args.verbose, rgb24_to_cam16ucs)
|
||||
|
||||
# Show output image by rendering in target palette
|
||||
output_palette_name = args.show_palette or args.palette
|
||||
output_palette = palette_py.PALETTES[output_palette_name]()
|
||||
output_screen = screen_py.HGRNTSCScreen(output_palette)
|
||||
if output_palette_name == "ntsc":
|
||||
output_srgb = output_screen.bitmap_to_image_ntsc(bitmap)
|
||||
else:
|
||||
output_srgb = image_py.linear_to_srgb(
|
||||
output_screen.bitmap_to_image_rgb(bitmap)).astype(np.uint8)
|
||||
out_image = image_py.resize(
|
||||
Image.fromarray(output_srgb), screen.X_RES, screen.Y_RES * 2,
|
||||
srgb_output=True)
|
||||
|
||||
_output(out_image, args)
|
||||
_write(screen, linear_bytemap, args)
|
127
dither_dhr.pyx
127
dither_dhr.pyx
@ -9,6 +9,8 @@ from libc.stdlib cimport malloc, free
|
||||
|
||||
cimport common
|
||||
|
||||
import screen as screen_py
|
||||
|
||||
|
||||
# TODO: use a cdef class
|
||||
# C representation of dither_pattern.DitherPattern data, for efficient access.
|
||||
@ -75,6 +77,42 @@ cdef inline unsigned char shift_pixel_window(
|
||||
return ((last_pixels >> shift_right_by) | shifted_next_pixels) & window_mask
|
||||
|
||||
|
||||
# Given a byte to store on the hi-res screen, compute the sequence of 560-resolution pixels that will be displayed.
|
||||
# Hi-res graphics works like this:
|
||||
# - Each of the low 7 bits in screen_byte results in enabling or disabling two sequential 560-resolution pixels.
|
||||
# - pixel screen order is from LSB to MSB
|
||||
# - if bit 8 (the "palette bit) is set then the 14-pixel sequence is shifted one position to the right, and the
|
||||
# left-most pixel is filled in by duplicating the right-most pixel controlled by the previous screen byte (i.e. bit 7)
|
||||
# - this gives a 15 or 14 pixel sequence depending on whether or not the palette bit is set.
|
||||
cdef unsigned int compute_fat_pixels(unsigned int screen_byte, unsigned char last_pixels) nogil:
|
||||
cdef int i, bit, fat_bit
|
||||
cdef unsigned int result = 0
|
||||
|
||||
for i in range(7):
|
||||
bit = (screen_byte >> i) & 0b1
|
||||
fat_bit = bit << 1 | bit
|
||||
result |= (fat_bit) << (2 * i)
|
||||
if screen_byte & 0x80:
|
||||
# Palette bit shifts to the right
|
||||
result <<= 1
|
||||
result |= (last_pixels >> 7)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# Context parametrizes the differences between DHGR and HGR image optimization
|
||||
cdef struct Context:
|
||||
# How many bit positions to lookahead when optimizing
|
||||
unsigned char bit_lookahead
|
||||
# How many screen pixels produced by bit_lookahead. This is 1:1 for DHGR but for HGR 8 bits in memory produce
|
||||
# 14 or 15 screen pixels (see compute_fat_pixels above)
|
||||
unsigned char pixel_lookahead
|
||||
# HGR has a NTSC phase shift relative to DHGR which rotates the effective mappings from screen pixels to colours
|
||||
unsigned char phase_shift
|
||||
# Non-zero for HGR optimization
|
||||
unsigned char is_hgr
|
||||
|
||||
|
||||
# Look ahead a number of pixels and compute choice for next pixel with lowest total squared error after dithering.
|
||||
#
|
||||
# Args:
|
||||
@ -90,20 +128,20 @@ cdef inline unsigned char shift_pixel_window(
|
||||
#
|
||||
# Returns: index from 0 .. 2**lookahead into options_nbit representing best available choice for position (x,y)
|
||||
#
|
||||
cdef int dither_lookahead(Dither* dither, float[:, :, ::1] palette_cam16, float[:, :, ::1] palette_rgb,
|
||||
float[:, :, ::1] image_rgb, int x, int y, int lookahead, unsigned char last_pixels,
|
||||
int x_res, float[:,::1] rgb_to_cam16ucs, unsigned char palette_depth) nogil:
|
||||
cdef int candidate_pixels, i, j
|
||||
cdef int dither_lookahead(Dither* dither, unsigned char palette_depth, float[:, :, ::1] palette_cam16,
|
||||
float[:, :, ::1] palette_rgb, float[:, :, ::1] image_rgb, int x, int y, unsigned char last_pixels,
|
||||
int x_res, float[:,::1] rgb_to_cam16ucs, Context context) nogil:
|
||||
cdef int candidate, next_pixels, i, j
|
||||
cdef float[3] quant_error
|
||||
cdef int best
|
||||
cdef float best_error = 2**31-1
|
||||
cdef float total_error
|
||||
cdef unsigned char next_pixels
|
||||
cdef unsigned char current_pixels
|
||||
cdef int phase
|
||||
cdef float[::1] lah_cam16ucs
|
||||
|
||||
# Don't bother dithering past the lookahead horizon or edge of screen.
|
||||
cdef int xxr = min(x + lookahead, x_res)
|
||||
cdef int xxr = min(x + context.pixel_lookahead, x_res)
|
||||
|
||||
cdef int lah_shape1 = xxr - x
|
||||
cdef int lah_shape2 = 3
|
||||
@ -114,34 +152,42 @@ cdef int dither_lookahead(Dither* dither, float[:, :, ::1] palette_cam16, float[
|
||||
# given pixel (dependent on the state already chosen for pixels to the left), we need to look beyond local minima.
|
||||
# i.e. it might be better to make a sub-optimal choice for this pixel if it allows access to much better pixel
|
||||
# colours at later positions.
|
||||
for candidate_pixels in range(1 << lookahead):
|
||||
for candidate in range(1 << context.bit_lookahead):
|
||||
# Working copy of input pixels
|
||||
for i in range(xxr - x):
|
||||
for j in range(3):
|
||||
lah_image_rgb[i * lah_shape2 + j] = image_rgb[y, x+i, j]
|
||||
|
||||
total_error = 0
|
||||
|
||||
if context.is_hgr:
|
||||
# A HGR screen byte controls 14 or 15 screen pixels
|
||||
next_pixels = compute_fat_pixels(candidate, last_pixels)
|
||||
else:
|
||||
# DHGR pixels are 1:1 with memory bits
|
||||
next_pixels = candidate
|
||||
|
||||
# Apply dithering to lookahead horizon or edge of screen
|
||||
for i in range(xxr - x):
|
||||
xl = dither_bounds_xl(dither, i)
|
||||
xr = dither_bounds_xr(dither, xxr - x, i)
|
||||
phase = (x + i) % 4
|
||||
phase = (x + i + context.phase_shift) % 4
|
||||
|
||||
next_pixels = shift_pixel_window(
|
||||
last_pixels, next_pixels=candidate_pixels, shift_right_by=i+1, window_width=palette_depth)
|
||||
current_pixels = shift_pixel_window(
|
||||
last_pixels, next_pixels=next_pixels, shift_right_by=i+1, window_width=palette_depth)
|
||||
|
||||
# We don't update the input at position x (since we've already chosen fixed outputs), but we do propagate
|
||||
# quantization errors to positions >x so we can compensate for how good/bad these choices were. i.e. the
|
||||
# next_pixels choices are fixed, but we can still distribute quantization error from having made these
|
||||
# current_pixels choices are fixed, but we can still distribute quantization error from having made these
|
||||
# choices, in order to compute the total error.
|
||||
for j in range(3):
|
||||
quant_error[j] = lah_image_rgb[i * lah_shape2 + j] - palette_rgb[next_pixels, phase, j]
|
||||
quant_error[j] = lah_image_rgb[i * lah_shape2 + j] - palette_rgb[current_pixels, phase, j]
|
||||
apply_one_line(dither, xl, xr, i, lah_image_rgb, lah_shape2, quant_error)
|
||||
|
||||
lah_cam16ucs = common.convert_rgb_to_cam16ucs(
|
||||
rgb_to_cam16ucs, lah_image_rgb[i*lah_shape2], lah_image_rgb[i*lah_shape2+1],
|
||||
lah_image_rgb[i*lah_shape2+2])
|
||||
total_error += common.colour_distance_squared(lah_cam16ucs, palette_cam16[next_pixels, phase])
|
||||
total_error += common.colour_distance_squared(lah_cam16ucs, palette_cam16[current_pixels, phase])
|
||||
|
||||
if total_error >= best_error:
|
||||
# No need to continue
|
||||
@ -149,7 +195,7 @@ cdef int dither_lookahead(Dither* dither, float[:, :, ::1] palette_cam16, float[
|
||||
|
||||
if total_error < best_error:
|
||||
best_error = total_error
|
||||
best = candidate_pixels
|
||||
best = candidate
|
||||
|
||||
free(lah_image_rgb)
|
||||
return best
|
||||
@ -232,10 +278,9 @@ def dither_image(
|
||||
screen, float[:, :, ::1] image_rgb, dither, int lookahead, unsigned char verbose, float[:,::1] rgb_to_cam16ucs):
|
||||
cdef int y, x
|
||||
cdef unsigned char i, j, pixels_nbit, phase
|
||||
# cdef float[3] input_pixel_rgb
|
||||
cdef float[3] quant_error
|
||||
cdef unsigned char output_pixel_nbit
|
||||
cdef unsigned char best_next_pixels
|
||||
cdef unsigned int next_pixels
|
||||
cdef float[3] output_pixel_rgb
|
||||
|
||||
# Hoist some python attribute accesses into C variables for efficient access during the main loop
|
||||
@ -273,22 +318,52 @@ def dither_image(
|
||||
# dot positions are used to determine the colour of a given pixel.
|
||||
cdef (unsigned char)[:, ::1] image_nbit = np.empty((image_rgb.shape[0], image_rgb.shape[1]), dtype=np.uint8)
|
||||
|
||||
cdef Context context
|
||||
if screen.MODE == screen_py.Mode.HI_RES:
|
||||
context.is_hgr = 1
|
||||
context.bit_lookahead = 8
|
||||
context.pixel_lookahead = 15
|
||||
# HGR and DHGR have a timing phase shift which rotates the effective mappings from screen dots to colours
|
||||
context.phase_shift = 3
|
||||
else:
|
||||
context.is_hgr = 0
|
||||
context.bit_lookahead = lookahead
|
||||
context.pixel_lookahead = lookahead
|
||||
context.phase_shift = 0
|
||||
|
||||
cdef (unsigned char)[:, ::1] linear_bytemap = np.zeros((192, 40), dtype=np.uint8)
|
||||
|
||||
# After performing lookahead, move ahead this many pixels at once.
|
||||
cdef int apply_batch_size
|
||||
if context.is_hgr:
|
||||
# For HGR we have to apply an entire screen byte at a time, which controls 14 or 15 pixels (see
|
||||
# compute_fat_pixels above). This is because the high bit shifts this entire group of 14 pixels at once,
|
||||
# so we have to make a single decision about whether or not to enable it.
|
||||
apply_batch_size = 14
|
||||
else:
|
||||
# For DHGR we can choose each pixel state independently, so we get better results if we apply one pixel at
|
||||
# a time.
|
||||
apply_batch_size = 1
|
||||
|
||||
for y in range(yres):
|
||||
if verbose:
|
||||
print("%d/%d" % (y, yres))
|
||||
output_pixel_nbit = 0
|
||||
for x in range(xres):
|
||||
# Compute all possible 2**N choices of n-bit pixel colours for positions x .. x + lookahead
|
||||
# lookahead_palette_choices_nbit = lookahead_options(lookahead, output_pixel_nbit)
|
||||
# Apply error diffusion for each of these 2**N choices, and compute which produces the closest match
|
||||
# to the source image over the succeeding N pixels
|
||||
best_next_pixels = dither_lookahead(
|
||||
&cdither, palette_cam16, palette_rgb, image_rgb, x, y, lookahead, output_pixel_nbit, xres,
|
||||
rgb_to_cam16ucs, palette_depth)
|
||||
if x % apply_batch_size == 0:
|
||||
# Compute all possible 2**N choices of n-bit pixel colours for positions x .. x + lookahead
|
||||
# Apply error diffusion for each of these 2**N choices, and compute which produces the closest match
|
||||
# to the source image over the succeeding N pixels
|
||||
next_pixels = dither_lookahead(
|
||||
&cdither, palette_depth, palette_cam16, palette_rgb, image_rgb, x, y, output_pixel_nbit, xres,
|
||||
rgb_to_cam16ucs, context)
|
||||
if context.is_hgr:
|
||||
linear_bytemap[y, x // 14] = next_pixels
|
||||
next_pixels = compute_fat_pixels(next_pixels, output_pixel_nbit)
|
||||
|
||||
# Apply best choice for next 1 pixel
|
||||
output_pixel_nbit = shift_pixel_window(
|
||||
output_pixel_nbit, best_next_pixels, shift_right_by=1, window_width=palette_depth)
|
||||
|
||||
output_pixel_nbit, next_pixels, shift_right_by=x % apply_batch_size + 1, window_width=palette_depth)
|
||||
# Apply error diffusion from chosen output pixel value
|
||||
for i in range(3):
|
||||
output_pixel_rgb[i] = palette_rgb[output_pixel_nbit, x % 4, i]
|
||||
@ -301,4 +376,4 @@ def dither_image(
|
||||
image_rgb[y, x, i] = output_pixel_rgb[i]
|
||||
|
||||
free(cdither.pattern)
|
||||
return image_nbit_to_bitmap(image_nbit, xres, yres, palette_depth)
|
||||
return image_nbit_to_bitmap(image_nbit, xres, yres, palette_depth), linear_bytemap
|
||||
|
@ -7,6 +7,9 @@ class DitherPattern:
|
||||
PATTERN = None
|
||||
ORIGIN = None
|
||||
|
||||
def __init__(self, error_fraction=1.0):
|
||||
self.PATTERN *= error_fraction
|
||||
|
||||
|
||||
class NoDither(DitherPattern):
|
||||
"""No dithering."""
|
||||
@ -84,7 +87,7 @@ PATTERNS = {
|
||||
'buckels': BuckelsDither,
|
||||
'jarvis': JarvisDither,
|
||||
'jarvis-mod': JarvisModifiedDither,
|
||||
'none': NoDither
|
||||
'none': NoDither,
|
||||
}
|
||||
|
||||
DEFAULT_PATTERN = 'floyd'
|
||||
|
BIN
examples/hgr/mandarin-duck-openemulator.png
Normal file
BIN
examples/hgr/mandarin-duck-openemulator.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 554 KiB |
BIN
examples/hgr/mandarin-duck-preview.png
Normal file
BIN
examples/hgr/mandarin-duck-preview.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 428 KiB |
BIN
examples/hgr/mandarin-duck.bin
Normal file
BIN
examples/hgr/mandarin-duck.bin
Normal file
Binary file not shown.
BIN
examples/hgr/mandarin-duck.jpg
Normal file
BIN
examples/hgr/mandarin-duck.jpg
Normal file
Binary file not shown.
After Width: | Height: | Size: 90 KiB |
BIN
examples/hgr/portrait-openemulator.png
Normal file
BIN
examples/hgr/portrait-openemulator.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 484 KiB |
BIN
examples/hgr/portrait-preview.png
Normal file
BIN
examples/hgr/portrait-preview.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 357 KiB |
BIN
examples/hgr/portrait.bin
Normal file
BIN
examples/hgr/portrait.bin
Normal file
Binary file not shown.
BIN
examples/hgr/portrait.jpg
Normal file
BIN
examples/hgr/portrait.jpg
Normal file
Binary file not shown.
After Width: | Height: | Size: 39 KiB |
1
image.py
1
image.py
@ -46,5 +46,6 @@ def resize(
|
||||
else:
|
||||
return res
|
||||
|
||||
|
||||
def resize_mono(image: Image, x_res, y_res) -> Image:
|
||||
return image.resize((x_res, y_res), Image.LANCZOS)
|
||||
|
133
screen.py
133
screen.py
@ -1,14 +1,26 @@
|
||||
"""Representation of Apple II screen memory."""
|
||||
|
||||
import math
|
||||
from enum import Enum
|
||||
import numpy as np
|
||||
import palette as palette_py
|
||||
|
||||
|
||||
class Mode(Enum):
|
||||
LO_RES = 1
|
||||
DOUBLE_LO_RES = 2
|
||||
HI_RES = 3
|
||||
DOUBLE_HI_RES = 4
|
||||
SUPER_HI_RES_320 = 5
|
||||
SUPER_HI_RES_640 = 6
|
||||
SUPER_HI_RES_3200 = 7
|
||||
|
||||
|
||||
class SHR320Screen:
|
||||
X_RES = 320
|
||||
Y_RES = 200
|
||||
|
||||
MODE = Mode.SUPER_HI_RES_320
|
||||
|
||||
def __init__(self):
|
||||
self.palettes = {k: np.zeros((16, 3), dtype=np.uint8) for k in
|
||||
range(16)}
|
||||
@ -67,14 +79,7 @@ class SHR320Screen:
|
||||
self.memory = dump
|
||||
|
||||
|
||||
class DHGRScreen:
|
||||
X_RES = 560
|
||||
Y_RES = 192
|
||||
|
||||
def __init__(self):
|
||||
self.main = np.zeros(8192, dtype=np.uint8)
|
||||
self.aux = np.zeros(8192, dtype=np.uint8)
|
||||
|
||||
class BaseDHGRScreen:
|
||||
@staticmethod
|
||||
def y_to_base_addr(y: int) -> int:
|
||||
"""Maps y coordinate to screen memory base address."""
|
||||
@ -85,6 +90,17 @@ class DHGRScreen:
|
||||
|
||||
return 1024 * c + 128 * b + 40 * a
|
||||
|
||||
|
||||
class DHGRScreen(BaseDHGRScreen):
|
||||
X_RES = 560
|
||||
Y_RES = 192
|
||||
|
||||
MODE = Mode.DOUBLE_HI_RES
|
||||
|
||||
def __init__(self):
|
||||
self.main = np.zeros(8192, dtype=np.uint8)
|
||||
self.aux = np.zeros(8192, dtype=np.uint8)
|
||||
|
||||
def pack(self, bitmap: np.ndarray):
|
||||
"""Packs an image into memory format (8k AUX + 8K MAIN)."""
|
||||
# The DHGR display encodes 7 pixels across interleaved 4-byte sequences
|
||||
@ -111,36 +127,16 @@ class DHGRScreen:
|
||||
self.main[addr:addr + 40] = main_col[y, :]
|
||||
return
|
||||
|
||||
class DHGRNTSCScreen(DHGRScreen):
|
||||
def __init__(self, palette: palette_py.Palette):
|
||||
self.palette = palette
|
||||
super(DHGRNTSCScreen, self).__init__()
|
||||
|
||||
def bitmap_to_image_rgb(self, bitmap: np.ndarray) -> np.ndarray:
|
||||
"""Convert our 2-bit bitmap image into a RGB image.
|
||||
class NTSCScreen:
|
||||
NTSC_PHASE_SHIFT = None
|
||||
|
||||
Colour at every pixel is determined by the value of an n-bit sliding
|
||||
window and x % 4, which give the index into our RGB palette.
|
||||
"""
|
||||
image_rgb = np.empty((self.Y_RES, self.X_RES, 3), dtype=np.uint8)
|
||||
for y in range(self.Y_RES):
|
||||
bitmap_window = [False] * self.palette.PALETTE_DEPTH
|
||||
for x in range(self.X_RES):
|
||||
# Maintain a sliding window of pixels of width PALETTE_DEPTH
|
||||
bitmap_window = bitmap_window[1:] + [bitmap[y, x]]
|
||||
image_rgb[y, x, :] = self.palette.RGB[
|
||||
self.palette.bitmap_to_idx(
|
||||
np.array(bitmap_window, dtype=bool)), x % 4]
|
||||
return image_rgb
|
||||
|
||||
@staticmethod
|
||||
def _sin(pos, phase0=0):
|
||||
x = pos % 12 + phase0
|
||||
def _sin(self, pos):
|
||||
x = pos % 12 + self.NTSC_PHASE_SHIFT * 3
|
||||
return np.sin(x * 2 * np.pi / 12)
|
||||
|
||||
@staticmethod
|
||||
def _cos(pos, phase0=0):
|
||||
x = pos % 12 + phase0
|
||||
def _cos(self, pos):
|
||||
x = pos % 12 + self.NTSC_PHASE_SHIFT * 3
|
||||
return np.cos(x * 2 * np.pi / 12)
|
||||
|
||||
def _read(self, line, pos):
|
||||
@ -203,3 +199,70 @@ class DHGRNTSCScreen(DHGRScreen):
|
||||
out_rgb[y, x, :] = (r, g, b)
|
||||
|
||||
return out_rgb
|
||||
|
||||
def bitmap_to_image_rgb(self, bitmap: np.ndarray) -> np.ndarray:
|
||||
"""Convert our 2-bit bitmap image into a RGB image.
|
||||
|
||||
Colour at every pixel is determined by the value of an n-bit sliding
|
||||
window and x % 4, which give the index into our RGB palette.
|
||||
"""
|
||||
image_rgb = np.empty((self.Y_RES, self.X_RES, 3), dtype=np.uint8)
|
||||
for y in range(self.Y_RES):
|
||||
bitmap_window = [False] * self.palette.PALETTE_DEPTH
|
||||
for x in range(self.X_RES):
|
||||
# Maintain a sliding window of pixels of width PALETTE_DEPTH
|
||||
bitmap_window = bitmap_window[1:] + [bitmap[y, x]]
|
||||
|
||||
image_rgb[y, x, :] = self.palette.RGB[
|
||||
self.palette.bitmap_to_idx(
|
||||
# Mapping from bit pattern to colour is rotated by
|
||||
# NTSC phase shift
|
||||
np.roll(
|
||||
np.array(bitmap_window, dtype=bool),
|
||||
self.NTSC_PHASE_SHIFT
|
||||
)
|
||||
), x % 4]
|
||||
return image_rgb
|
||||
|
||||
|
||||
class DHGRNTSCScreen(DHGRScreen, NTSCScreen):
|
||||
def __init__(self, palette: palette_py.Palette):
|
||||
self.palette = palette
|
||||
super(DHGRNTSCScreen, self).__init__()
|
||||
|
||||
NTSC_PHASE_SHIFT = 0
|
||||
|
||||
|
||||
class HGRNTSCScreen(BaseDHGRScreen, NTSCScreen):
|
||||
# Hi-Res really is 560 pixels horizontally, not 280 - but unlike DHGR
|
||||
# you can only independently control about half of the pixels.
|
||||
#
|
||||
# In more detail, hi-res graphics works like this:
|
||||
# - Each of the low 7 bits in a byte of screen memory results in
|
||||
# enabling or disabling two sequential 560-resolution pixels.
|
||||
# - pixel screen order is from LSB to MSB
|
||||
# - if bit 8 (the "palette bit") is set then the 14-pixel sequence is
|
||||
# shifted one position to the right, and the left-most pixel is filled
|
||||
# in by duplicating the right-most pixel produced by the previous
|
||||
# screen byte (i.e. bit 7)
|
||||
# - thus each byte produces a 15 or 14 pixel sequence depending on
|
||||
# whether or not the palette bit is set.
|
||||
X_RES = 560
|
||||
Y_RES = 192
|
||||
|
||||
MODE = Mode.HI_RES
|
||||
|
||||
NTSC_PHASE_SHIFT = 3
|
||||
|
||||
def __init__(self, palette: palette_py.Palette):
|
||||
self.main = np.zeros(8192, dtype=np.uint8)
|
||||
self.palette = palette
|
||||
super(HGRNTSCScreen, self).__init__()
|
||||
|
||||
def pack_bytes(self, linear_bytemap: np.ndarray):
|
||||
"""Packs an image into memory format (8K main)."""
|
||||
|
||||
for y in range(self.Y_RES):
|
||||
addr = self.y_to_base_addr(y)
|
||||
self.main[addr:addr + 40] = linear_bytemap[y, :]
|
||||
return
|
||||
|
Loading…
Reference in New Issue
Block a user