Rewriten history to make me look less of an idiot. The first version worthy to be seen.

This commit is contained in:
Andrew Stephens
2022-12-17 20:14:41 -05:00
parent ee6a9dc1a5
commit 8fe3b3020e
6 changed files with 300 additions and 59 deletions

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
.DS_Store

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2022 andrewstephens75
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

65
README.md Normal file
View File

@@ -0,0 +1,65 @@
# 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 dispay. 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 prossessing a certain charm that on-paper "better" dithers cannot match.
I wanted to bring this to the web.
## Why Do This Client Side?
You can pre-dither your images but this gives bad results because dithering relies on the precise corrispondence between the source and out pixels. Unless you can guarentee that your image will be displayed at exactly the same size (in pixels) as it was output, the pixels of the image will not align with the pixels of your screen and the results will be either blury or fulled with weird patterns. The technical term for this is aliasing but whatever you call it, it ruins your image.
The only way to get really crisp results in a web browser is to dither the source image to the exact pixel size of the element when it is displayed.
## Why as-dithered-image?
There is other javascript floating around out there that does much the same thing. as-dithered-image has a few advantages:
* I put some effort into getting really crisp results even on high-DPI displays. Most of the other code looks slightly blurry due to not taking this into account.
* Resizing is completely supported, allowing for dithered images in responsive designs.
* Accessibility is supported with the **alt** tag.
* Some control over the look of the dither is supported with the **crunch** attribute.
## Usage
Example usage:
```
<script src="as-dithered-image.js"></script>
...
<style>
.canvasstyle {
display: inline-block;
width: 90%;
aspect-ratio: 640 / 954;
min-width: 90%;
padding: auto;
margin: auto;
}
</style>
...
<as-dithered-image src="mypicture.jpg" alt="Description of the image for screen readers" crunch="2" class="canvasstyle"></as-dithered-image>
```
as-dithered-image takes 3 attributes:
* **src** the url of the image
* **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:
* 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.
## Downsides and Future Improvements
As it stands, as-dithered-image as a few warts. The size of the source image is not taken into account, you must set the size in the CSS which means you have to know the apsect ratio of your image up front.
Dithering is not free, processing the image takes time. Currently this is done on the main UI thread of the browser which can lead to poor UI performance. This processing could be moved to a background thread but I got lazy.
## Legal
See LICENSE file for usage.

199
as-dithered-image.js Normal file
View File

@@ -0,0 +1,199 @@
const DITHERED_IMAGE_STYLE = `
.ditheredImageStyle {
width: 100%;
height: 100%;
padding: 0;
margin: 0;
}
`
class ASDitheredImage extends HTMLElement {
constructor() {
super()
// the canvas API is confusing if you want pixel accurate drawing. The canvas backing store must be set to the screen size * the devicePixelRatio
// The crunch factor is how "chunky" the dither should be, ie how many css pixels to dithered pixels
this.crunchFactor = this.getAutoCrunchFactor()
this.drawTimestamp = 0
this.drawRect = undefined
this.altText = ""
}
connectedCallback() {
if (!this.isConnected) {
return
}
const shadowDOM = this.attachShadow({ mode: "open" })
const style = document.createElement("style")
style.innerHTML = DITHERED_IMAGE_STYLE
shadowDOM.appendChild(style)
this.canvas = document.createElement("canvas")
this.canvas.setAttribute("role", "image")
this.canvas.setAttribute("aria-label", this.altText)
this.canvas.classList.add("ditheredImageStyle")
shadowDOM.appendChild(this.canvas)
this.context = this.canvas.getContext("2d")
const resizeObserver = new ResizeObserver((entries) => {
for (const e of entries) {
if (e.contentBoxSize) {
this.requestUpdate()
}
}
})
resizeObserver.observe(this.canvas)
this.requestUpdate()
}
static get observedAttributes() { return ["src", "crunch", "alt"] }
attributeChangedCallback(name, oldValue, newValue) {
if (oldValue === newValue) return
if ((name === "src")) {
this.src = newValue
this.requestUpdate()
} else if (name === "crunch") {
if (newValue === "auto") {
this.crunchFactor = this.getAutoCrunchFactor()
} else if (newValue === "pixel") {
this.crunchFactor = 1.0 / window.devicePixelRatio
} else {
this.crunchFactor = parseInt(newValue, 10)
if (isNaN(this.crunchFactor)) {
this.crunchFactor = this.getAutoCrunchFactor()
}
}
this.requestUpdate()
} else if (name === "alt") {
this.altText = newValue;
if (this.canvas != undefined) {
let currentAltText = this.canvas.getAttribute("aria-label")
if (currentAltText != this.altText) {
this.canvas.setAttribute("aria-label", this.altText)
}
}
}
}
// all drawing is funneled through requestUpdate so that multiple calls are coalesced to prevent
// processing the image multiple times for no good reason
requestUpdate() {
window.requestAnimationFrame(((timestamp) => {
if (this.drawTimestamp != timestamp) {
this.drawImage()
this.drawTimestamp = timestamp
}
}).bind(this))
}
// 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
// If the pixel ratio is 3 or above (like on my iPhone) then even css pixels are too small to make dithering
// look effective, so I double the pixels again
getAutoCrunchFactor() {
if (window.devicePixelRatio < 3) {
return 1
} else {
return 2
}
}
drawImage() {
if ((this.canvas === undefined) || (this.src === undefined)) {
return
}
const rect = this.canvas.getBoundingClientRect()
if ((this.drawRect != undefined) && (rect.width == this.drawRect.width) && (rect.height == this.drawRect.height)) {
return // already drawn the image at this size
}
this.drawRect = rect;
// to get really crisp pixels on retina-type displays (window.devicePixelRatio > 1) we have to set the
// canvas backing store to the element size times the devicePixelRatio
// Then, once the image has loaded we draw it manually scaled to only part of the canvas (since the canvas is bigger than the element)
// The dithering algorythm will scale up the image to the canvas size
const logicalPixelSize = window.devicePixelRatio * this.crunchFactor
this.canvas.width = rect.width * window.devicePixelRatio
this.canvas.height = rect.height * window.devicePixelRatio
const image = new Image()
image.onload = (() => {
this.context.imageSmoothingEnabled = true
this.context.drawImage(image, 0, 0, this.canvas.width / logicalPixelSize, this.canvas.height / logicalPixelSize)
const original = this.context.getImageData(0, 0, this.canvas.width / logicalPixelSize, this.canvas.height / logicalPixelSize)
const dithered = this.dither(original, logicalPixelSize)
this.context.imageSmoothingEnabled = false
this.context.putImageData(dithered, 0, 0)
}).bind(this)
image.src = this.src
}
dither(imageData, scaleFactor) {
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)
}
// most implementations I see just distibute error into the existing image, wrapping around edge pixels
// this implementation uses a sliding window of floats for more accuracy (probably not needed really)
let slidingErrorWindow = [new Float32Array(imageData.width), new Float32Array(imageData.width), new Float32Array(imageData.width)]
const offsets = [[1, 0], [2, 0], [-1, 1], [0, 1], [1, 1], [0, 2]]
for (let y = 0, limY = imageData.height; y < limY; ++y) {
for (let x = 0, limX = imageData.width; x < limX; ++x) {
let i = ((y * limX) + x) * 4;
let accumulatedError = Math.floor(slidingErrorWindow[0][x])
let expectedMono = imageData.data[i] + accumulatedError
let monoValue = expectedMono
if (monoValue <= 127) {
monoValue = 0
} else {
monoValue = 255
}
let error = (expectedMono - monoValue) / 8.0
for (let q = 0; q < offsets.length; ++q) {
let offsetX = offsets[q][0] + x
let offsetY = offsets[q][1] + y
if ((offsetX >= 0) && (offsetX < slidingErrorWindow[0].length))
slidingErrorWindow[offsets[q][1]][offsetX] += error
}
// this is stupid but we have to do the pixel scaling ourselves because safari insists on interpolating putImageData
// which gives us blury pixels (and it doesn't support the createImageBitmap call with an ImageData instance which
// would make this easy)
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
pixelOffset += 4
}
}
}
// move the sliding window
slidingErrorWindow.push(slidingErrorWindow.shift())
slidingErrorWindow[2].fill(0, 0, slidingErrorWindow[2].length)
}
return output
}
}
window.customElements.define('as-dithered-image', ASDitheredImage);

View File

@@ -3,75 +3,30 @@
<style> <style>
.canvasstyle { .canvasstyle {
display: inline-block;
width: 90%; width: 90%;
object-fit: contain; aspect-ratio: 640 / 954;
border: 1px solid black; min-width: 90%;
padding: auto;
margin: auto;
} }
</style> </style>
<script src="as-dithered-image.js"></script>
<script> <script>
function dither(imageData) {
let output = new ImageData(imageData.width, imageData.height)
for (i = 0; i < imageData.data.length; i += 4) {
output.data[i] = output.data[i + 1] = output.data[i + 2] = Math.floor(imageData.data[i] * 0.3 + imageData.data[i + 1] * 0.59 + imageData.data[i + 2] * 0.11)
output.data[i + 3] = imageData.data[i + 3]
}
console.log("grey")
let slidingErrorWindow = [new Float32Array(imageData.width), new Float32Array(imageData.width), new Float32Array(imageData.width)]
const offsets = [[1, 0], [2, 0], [-1, 1], [0, 1], [1, 1], [0, 2]]
for (let y = 0, limY = imageData.height; y < limY; ++y) {
for (let x = 0, limX = imageData.width; x < limX; ++x) {
let i = ((y * limX) + x) * 4;
let accumulatedError = Math.floor(slidingErrorWindow[0][x])
let expectedMono = output.data[i] + accumulatedError
let monoValue = expectedMono
if (monoValue <= 127) {
monoValue = 0
} else {
monoValue = 255
}
let error = (expectedMono - monoValue) / 8.0
for (let q = 0; q < offsets.length; ++q) {
let offsetX = offsets[q][0] + x
let offsetY = offsets[q][1] + y
if ((offsetX >= 0) && (offsetX < slidingErrorWindow[0].length))
slidingErrorWindow[offsets[q][1]][offsetX] += error
}
output.data[i] = output.data[i + 1] = output.data[i + 2] = monoValue
}
// move the sliding window
slidingErrorWindow.push(slidingErrorWindow.shift())
slidingErrorWindow[2].fill(0, 0, slidingErrorWindow[2].length)
}
console.log("done")
return output
}
function setImage(src, target) {
let image = new Image()
image.onload = () => {
let gc = target.getContext("2d")
gc.drawImage(image, 0, 0, target.width, target.height)
let original = gc.getImageData(0, 0, target.width, target.height)
let dithered = dither(original)
gc.putImageData(dithered, 0, 0)
}
image.src = src
}
window.addEventListener("load", (e) => { window.addEventListener("load", (e) => {
setImage("test.jpg", document.getElementById("drawing")) const c = document.getElementById("pixel")
c.innerText = window.devicePixelRatio
}) })
</script> </script>
<body> <body>
<canvas id="drawing" class="canvasstyle" width="800" height="600"></canvas> <!-- <canvas id="drawing" class="canvasstyle" width="1024" height="768"></canvas> -->
<as-dithered-image src="monalisa.jpg" class="canvasstyle" crunch="1"></as-dithered-image>
<as-dithered-image src="monalisa.jpg" class="canvasstyle" crunch="2"></as-dithered-image>
<as-dithered-image src="monalisa.jpg" class="canvasstyle" crunch="pixel"></as-dithered-image>
The device pixel ratio = <span id="pixel"></span>
</body> </body>
</html> </html>

BIN
test.jpg

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 MiB