mirror of
https://github.com/andrewstephens75/as-dithered-image.git
synced 2026-04-15 12:59:30 -07:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f69a82577a | ||
|
|
a48a658205 | ||
|
|
88b75eec78 |
16
README.md
16
README.md
@@ -1,8 +1,8 @@
|
||||
# Custom HTML Element for Client-side Atkinson Dithering
|
||||
|
||||
There are many dithering algorithms to crush multi-colored images down to black and which but the one I like best was introduce with the original Apple Macintosh for its crisp 512x342 monochrome display. One of Apple's engineers, [Bill Atkinson](https://en.wikipedia.org/wiki/Bill_Atkinson), developed what came to be known as Atkinson Dithering, a good trade-off between fast and accurate with results possessing a certain charm that on-paper "better" dithers cannot match.
|
||||
There are many dithering algorithms to crush multi-colored images down to black and white but the one I like best was introduced with the original Apple Macintosh for its crisp 512x342 monochrome display. One of Apple's engineers, [Bill Atkinson](https://en.wikipedia.org/wiki/Bill_Atkinson), developed what came to be known as Atkinson Dithering, a good trade-off between fast and accurate with results possessing a certain charm that on-paper "better" dithers cannot match.
|
||||
|
||||
I wanted to bring this to the web. For some examples of how to use this project see [my blog post on the subject](https://sheep.horse/2022/12/pixel_accurate_atkinson_dithering_for_images_in_ht.html).
|
||||
I wanted to bring this to the web. For some examples of how to use this project and **an interactive demo** see [my blog post on the subject](https://sheep.horse/2023/1/improved_web_component_for_pixel-accurate_atkinson.html).
|
||||
|
||||
## Why Do This Client Side?
|
||||
|
||||
@@ -36,15 +36,17 @@ Example usage:
|
||||
<as-dithered-image src="mypicture.jpg" alt="Description of the image for screen readers"></as-dithered-image>
|
||||
```
|
||||
|
||||
as-dithered-image takes 4 attributes:
|
||||
as-dithered-image takes 6 attributes:
|
||||
|
||||
* **src** the url of the image. Can be a data url.
|
||||
* **alt** the alt text, important for screen readers.
|
||||
* **crunch** controls the size of the logical pixels the image is dithered into. May be one of:
|
||||
* **src** (required) the url of the image. Can be a data url.
|
||||
* **alt** (strictly speaking optional, but it is rude not to) the alt text, important for screen readers.
|
||||
* **crunch** (optional) controls the size of the logical pixels the image is dithered into. May be one of:
|
||||
* an integer, where 1 means dither to logical css pixels no matter what the DPI. 2 makes the logical pixels twice the size, for a coarser look. 3 is really blocky.
|
||||
* **auto** (the default) attempts to give good results on very high-DPI screens (like iPhones) which have such small pixels that standard dithering just looks grey. It is equivalent of 1 on most displays and 2 on devices where the ratio of screen to css pixels is 3 or more.
|
||||
* **pixel** dither to screen pixels. This can either look amazing or be completely wasted depending on the size of the screen but you paid for all the pixels so you might as well use them.
|
||||
* **cutoff** a float between 0 and 1, defaulting to 0.5. Controls the cutoff the dithering algorithm uses to determine whether a pixel is black or white. Modifying this will produce either a lighter or darker image.
|
||||
* **cutoff** (optional) a float between 0 and 1, defaulting to 0.5. Controls the cutoff the dithering algorithm uses to determine whether a pixel is black or white. Modifying this will produce either a lighter or darker image.
|
||||
* **darkrgba** (optional) a string of the form `"rgba(0, 0, 0, 255)"` (and only this form because I am lazy). Sets the RGBA value for dark pixels, defaulting to fully opaque black, useful for matching the image to the colors of the surrounding page or making transparent areas. Note that this just controls the output color, the dithering is still performed based on the closeness of a pixel to black or white.
|
||||
* **lightrgba** (optional) a string of the form `"rgba(255, 255, 255, 255)"`. Similar to the above but for the light pixels.
|
||||
|
||||
## Legal
|
||||
|
||||
|
||||
@@ -21,6 +21,8 @@ class ASDitheredImage extends HTMLElement {
|
||||
this.ignore_next_resize_ = false
|
||||
this.worker_ = new Worker("ditherworker.js")
|
||||
this.cutoff_ = 0.5
|
||||
this.darkrgba_ = [0, 0, 0, 255]
|
||||
this.lightrgba_ = [255, 255, 255, 255]
|
||||
|
||||
this.worker_.onmessage = ((e) => {
|
||||
const imageData = e.data.imageData
|
||||
@@ -93,7 +95,7 @@ class ASDitheredImage extends HTMLElement {
|
||||
this.requestUpdate()
|
||||
}
|
||||
|
||||
static get observedAttributes() { return ["src", "crunch", "alt", "cutoff"] }
|
||||
static get observedAttributes() { return ["src", "crunch", "alt", "cutoff", "darkrgba", "lightrgba"] }
|
||||
|
||||
|
||||
attributeChangedCallback(name, oldValue, newValue) {
|
||||
@@ -132,7 +134,18 @@ class ASDitheredImage extends HTMLElement {
|
||||
this.cutoff_ = Math.min(1.0, Math.max(0.0, this.cutoff_))
|
||||
this.force_refresh_ = true
|
||||
this.requestUpdate()
|
||||
} else if (name === "darkrgba") {
|
||||
// must be in the form "rgba(10, 10, 10, 255)"
|
||||
this.darkrgba_ = this.parseRGBA(newValue)
|
||||
this.force_refresh_ = true
|
||||
this.requestUpdate()
|
||||
}
|
||||
else if (name === "lightrgba") {
|
||||
this.lightrgba_ = this.parseRGBA(newValue)
|
||||
this.force_refresh_ = true
|
||||
this.requestUpdate()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// The crunch factor defaults 1 css pixel to 1 dither pixel which I think looks best when the device pixel ratio is 1 or 2
|
||||
@@ -215,6 +228,16 @@ class ASDitheredImage extends HTMLElement {
|
||||
}).bind(this))
|
||||
}
|
||||
|
||||
parseRGBA(s) {
|
||||
var matches = s.match(/^rgba\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\,\s*(\d+)\s*\)$/i);
|
||||
if (matches) {
|
||||
return [matches[1], matches[2], matches[3], matches[4]]
|
||||
}
|
||||
else {
|
||||
return [255, 255, 255, 255]
|
||||
}
|
||||
}
|
||||
|
||||
repaintImage() {
|
||||
const rect = this.canvas_.getBoundingClientRect()
|
||||
let screenPixelsToBackingStorePixels = this.getDevicePixelRatio()
|
||||
@@ -241,7 +264,10 @@ class ASDitheredImage extends HTMLElement {
|
||||
(this.last_draw_state_.height == calculatedHeight) &&
|
||||
(this.last_draw_state_.adjustedPixelSize == adjustedPixelSize) &&
|
||||
(this.last_draw_state_.imageSrc == this.original_image_.currentSrc) &&
|
||||
(this.last_draw_state_.cutoff == this.cutoff_)) {
|
||||
(this.last_draw_state_.cutoff == this.cutoff_) &&
|
||||
(this.last_draw_state_.darkrgba == this.darkrgba_) &&
|
||||
(this.last_draw_state_.lightrgba == this.lightrgba_)
|
||||
) {
|
||||
return; // nothing to do
|
||||
}
|
||||
|
||||
@@ -253,18 +279,22 @@ class ASDitheredImage extends HTMLElement {
|
||||
this.last_draw_state_.adjustedPixelSize = adjustedPixelSize
|
||||
this.last_draw_state_.imageSrc = this.original_image_.currentSrc
|
||||
this.last_draw_state_.cutoff = this.cutoff_
|
||||
this.last_draw_state_.darkrgba = this.darkrgba_
|
||||
this.last_draw_state_.lightrgba = this.lightrgba_
|
||||
|
||||
|
||||
this.context_.imageSmoothingEnabled = true
|
||||
this.context_.drawImage(this.original_image_, 0, 0, this.canvas_.width / adjustedPixelSize, this.canvas_.height / adjustedPixelSize)
|
||||
const originalData = this.context_.getImageData(0, 0, this.canvas_.width / adjustedPixelSize, this.canvas_.height / adjustedPixelSize)
|
||||
this.context_.fillStyle = "white"
|
||||
this.context_.fillRect(0, 0, this.canvas_.width, this.canvas_.height)
|
||||
this.context_.clearRect(0, 0, this.canvas_.width, this.canvas_.height)
|
||||
// TODO: look at transferring the data in a different datastructure to prevent copying
|
||||
// unfortunately Safari has poor support for createImageBitmap - using it with ImageData doesn't work
|
||||
const msg = {}
|
||||
msg.imageData = originalData
|
||||
msg.pixelSize = adjustedPixelSize
|
||||
msg.cutoff = this.cutoff_
|
||||
msg.blackRGBA = this.darkrgba_
|
||||
msg.whiteRGBA = this.lightrgba_
|
||||
this.worker_.postMessage(msg)
|
||||
|
||||
this.force_refresh_ = false
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
onmessage = function (e) {
|
||||
|
||||
const result = dither(e.data.imageData, e.data.pixelSize, e.data.cutoff)
|
||||
const result = dither(e.data.imageData, e.data.pixelSize, e.data.cutoff, e.data.blackRGBA, e.data.whiteRGBA)
|
||||
const reply = {}
|
||||
reply.imageData = result
|
||||
reply.pixelSize = e.data.pixelSize
|
||||
@@ -8,7 +8,17 @@ onmessage = function (e) {
|
||||
postMessage(reply)
|
||||
}
|
||||
|
||||
function dither(imageData, scaleFactor, cutoff) {
|
||||
function getRGBAArrayBuffer(color) {
|
||||
let buffer = new ArrayBuffer(4)
|
||||
for (let i = 0; i < 4; ++i) {
|
||||
buffer[i] = color[i]
|
||||
}
|
||||
return buffer
|
||||
}
|
||||
|
||||
function dither(imageData, scaleFactor, cutoff, blackRGBA, whiteRGBA) {
|
||||
const blackRGBABuffer = getRGBAArrayBuffer(blackRGBA)
|
||||
const whiteRGBABuffer = getRGBAArrayBuffer(whiteRGBA)
|
||||
let output = new ImageData(imageData.width * scaleFactor, imageData.height * scaleFactor)
|
||||
for (let i = 0; i < imageData.data.length; i += 4) {
|
||||
imageData.data[i] = imageData.data[i + 1] = imageData.data[i + 2] = Math.floor(imageData.data[i] * 0.3 + imageData.data[i + 1] * 0.59 + imageData.data[i + 2] * 0.11)
|
||||
@@ -42,12 +52,15 @@ function dither(imageData, scaleFactor, cutoff) {
|
||||
// this is stupid but we have to do the pixel scaling ourselves because safari insists on interpolating putImageData
|
||||
// which gives us blurry pixels (and it doesn't support the createImageBitmap call with an ImageData instance which
|
||||
// would make this easy)
|
||||
let rgba = (monoValue == 0) ? blackRGBABuffer : whiteRGBABuffer
|
||||
|
||||
for (let scaleY = 0; scaleY < scaleFactor; ++scaleY) {
|
||||
let pixelOffset = (((y * scaleFactor + scaleY) * output.width) + (x * scaleFactor)) * 4
|
||||
for (let scaleX = 0; scaleX < scaleFactor; ++scaleX) {
|
||||
output.data[pixelOffset] = output.data[pixelOffset + 1] = output.data[pixelOffset + 2] = monoValue
|
||||
output.data[pixelOffset + 3] = 255
|
||||
output.data[pixelOffset] = rgba[0]
|
||||
output.data[pixelOffset + 1] = rgba[1]
|
||||
output.data[pixelOffset + 2] = rgba[2]
|
||||
output.data[pixelOffset + 3] = rgba[3]
|
||||
pixelOffset += 4
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user