Merge pull request #5 from andrewstephens75/version2

Version2
This commit is contained in:
andrewstephens75
2023-01-16 16:54:28 -05:00
committed by GitHub
5 changed files with 346 additions and 157 deletions

View File

@@ -14,50 +14,37 @@ The only way to get really crisp results in a web browser is to dither the sourc
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.
* I put some effort into getting really crisp results even on high-DPI displays. Most of the other code out there looks slightly blurry due to not taking this into account.
* Resizing is completely supported, allowing for dithered images in responsive designs. The element automatically adjusts its aspect ratio based on the image.
* Dithering is performed in a web worker so as to not block rendering of the rest of the page.
* as-dithered-image elements that are completely offscreen are not dithered until they are just about to scroll into view
* Accessibility is supported with the **alt** tag.
* Some control over the look of the dither is supported with the **crunch** attribute.
* Some control over the look of the dither is supported with the **crunch** and **cutoff** attributes.
## Usage
You will need to copy the following two files into your web project, they should be placed together in the same directory.
* as-dithered-image.js
* ditherworker.js
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 src="mypicture.jpg" alt="Description of the image for screen readers"></as-dithered-image>
```
as-dithered-image takes 3 attributes:
as-dithered-image takes 4 attributes:
* **src** the url of the image
* **alt** the alt text, important for screen readers
* **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:
* 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 aspect 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.
* **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.
## Legal

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

View File

@@ -4,6 +4,7 @@ const DITHERED_IMAGE_STYLE = `
height: 100%;
padding: 0;
margin: 0;
image-rendering: crisp-edges;
}
`
@@ -11,14 +12,24 @@ 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.drawCrunchFactor = undefined
this.drawSrc = undefined
this.altText = ""
this.original_image_ = undefined
this.force_refresh_ = false
this.crunchFactor_ = this.getAutoCrunchFactor()
this.canvas_ = undefined
this.context_ = undefined
this.image_loading_ = false
this.ignore_next_resize_ = false
this.worker_ = new Worker("ditherworker.js")
this.cutoff_ = 0.5
this.worker_.onmessage = ((e) => {
const imageData = e.data.imageData
this.context_.putImageData(imageData, 0, 0)
}).bind(this)
this.resizing_timeout_ = undefined
this.last_draw_state_ = { width: 0, height: 0, crunchFactor: 0, imageSrc: "" }
}
connectedCallback() {
@@ -32,69 +43,96 @@ class ASDitheredImage extends HTMLElement {
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.canvas_ = document.createElement("canvas")
this.canvas_.setAttribute("role", "image")
this.canvas_.setAttribute("aria-label", this.getAttribute("alt"))
this.canvas_.classList.add("ditheredImageStyle")
shadowDOM.appendChild(this.canvas_)
this.context = this.canvas.getContext("2d")
this.context_ = this.canvas_.getContext("2d", { willReadFrequently: true })
const resizeObserver = new ResizeObserver((entries) => {
for (const e of entries) {
if (e.contentBoxSize) {
const resizeObserver = new ResizeObserver(((entries) => {
// browsers generated lots of resize events but we don't want to start refreshing until
// the user has stopped resizing the page
if (entries.length > 0) {
if (entries[0].contentBoxSize) {
if (this.ignore_next_resize_ == true) {
this.ignore_next_resize_ = false
return
}
if (this.resizing_timeout_ != undefined) {
clearTimeout(this.resizing_timeout_)
}
this.resizing_timeout_ = setTimeout((() => {
this.resizing_timeout_ = undefined
this.force_refresh_ = true
this.requestUpdate()
}).bind(this), 200)
}
}
}).bind(this))
resizeObserver.observe(this.canvas_)
// since we avoid drawing the image if the element of far offscreen we need to use
// an IntersectionObserver to notify use when the element is likely to be displayed
const intersectionObserver = new IntersectionObserver(((intersections) => {
if (intersections.length > 0) {
if (intersections[0].isIntersecting) {
this.force_refresh_ = true
this.requestUpdate()
}
}
})
resizeObserver.observe(this.canvas)
}).bind(this), { root: null, rootMargin: "1000px", threshold: [0] })
intersectionObserver.observe(this)
this.force_refresh_ = true
this.requestUpdate()
}
static get observedAttributes() { return ["src", "crunch", "alt"] }
static get observedAttributes() { return ["src", "crunch", "alt", "cutoff"] }
attributeChangedCallback(name, oldValue, newValue) {
if (oldValue === newValue) return
if ((name === "src")) {
this.src = newValue
this.force_refresh_ = true
this.original_image_ = undefined
this.requestUpdate()
} else if (name === "crunch") {
if (newValue === "auto") {
this.crunchFactor = this.getAutoCrunchFactor()
this.crunchFactor_ = this.getAutoCrunchFactor()
} else if (newValue === "pixel") {
this.crunchFactor = 1.0 / this.getDevicePixelRatio()
this.crunchFactor_ = 1.0 / this.getDevicePixelRatio()
} else {
this.crunchFactor = parseInt(newValue, 10)
if (isNaN(this.crunchFactor)) {
this.crunchFactor = this.getAutoCrunchFactor()
this.crunchFactor_ = parseInt(newValue, 10)
if (isNaN(this.crunchFactor_)) {
this.crunchFactor_ = this.getAutoCrunchFactor()
}
}
this.force_refresh_ = true
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)
if (currentAltText != newValue) {
this.canvas.setAttribute("aria-label", newValue)
}
}
}
}
// 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
} else if (name === "cutoff") {
this.cutoff_ = parseFloat(newValue)
if (isNaN(this.cutoff_)) {
this.cutoff_ = 0.5
}
}).bind(this))
this.cutoff_ = Math.min(1.0, Math.max(0.0, this.cutoff_))
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
@@ -107,105 +145,130 @@ class ASDitheredImage extends HTMLElement {
return 2
}
}
getDevicePixelRatio() {
// this should always be an integer for the dithering code to work
return Math.floor(window.devicePixelRatio)
}
drawImage() {
if ((this.canvas === undefined) || (this.src === undefined)) {
return
}
const rect = this.canvas.getBoundingClientRect()
// we only want to draw the image if something has actually changed (usually the size)
if ((this.drawRect != undefined) && (rect.width == this.drawRect.width) && (rect.height == this.drawRect.height) &&
((this.drawCrunchFactor != undefined) && (this.crunchFactor === this.drawCrunchFactor)) &&
((this.drawSrc != undefined && this.src === this.drawSrc))) {
return
}
this.drawRect = rect
this.drawCrunchFactor = this.crunchFactor
this.drawSrc = this.src
// 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 algorithm will scale up the image to the canvas size
const logicalPixelSize = this.getDevicePixelRatio() * this.crunchFactor
this.canvas.width = rect.width * this.getDevicePixelRatio()
this.canvas.height = rect.height * this.getDevicePixelRatio()
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
getDevicePixelRatio() {
// this should always be an integer for the dithering code to work
return window.devicePixelRatio
}
isInOrNearViewport() {
// this only handles vertical scrolling, could be extended later to handle horizontal
// but it probably doesn't matter
const margin = 1500
const r = this.getBoundingClientRect()
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)
}
const viewHeight = Math.max(document.documentElement.clientHeight, window.innerHeight)
const above = r.bottom + margin < 0
const below = r.top - margin > viewHeight
// most implementations I see just distribute error into the existing image, wrapping around edge pixels
// this implementation uses a sliding window of floats for more accuracy (probably not needed really)
return (!above && !below)
}
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]]
// all drawing is funneled through requestUpdate so that multiple calls are coalesced to prevent
// processing the image multiple times for no good reason
requestUpdate() {
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 blurry 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
}
}
if (this.original_image_ != undefined) {
if (this.isInOrNearViewport() == false) {
return // suppress update, the intersection observer will call us back as the element scrolls into view
}
// move the sliding window
slidingErrorWindow.push(slidingErrorWindow.shift())
slidingErrorWindow[2].fill(0, 0, slidingErrorWindow[2].length)
}
return output
window.requestAnimationFrame(((timestamp) => {
if ((this.force_refresh_ == false)) {
return
}
if (this.original_image_ == undefined) {
this.loadImage()
return
}
if (this.force_refresh_) {
this.repaintImage()
}
}).bind(this))
}
loadImage() {
if (this.image_loading_ == true) {
return
}
this.image_loading_ = true
const image = new Image()
image.src = this.getAttribute("src")
// image.onerror is old and (literally) busted - it does not file on decode errors (ie if the src does not point to a valid image)
// The new way is promise based - possibly better
image.decode().then((() => {
this.original_image_ = image
this.ignore_next_resize_ = true
this.canvas_.style.aspectRatio = this.original_image_.width + "/" + this.original_image_.height
this.force_refresh_ = true
this.requestUpdate()
}).bind(this))
.catch(((decodeError) => {
console.log("Error decoding image: ", decodeError)
this.original_image_ = undefined
}).bind(this))
.finally((() => {
this.image_loading_ = false
}).bind(this))
}
repaintImage() {
const rect = this.canvas_.getBoundingClientRect()
let screenPixelsToBackingStorePixels = this.getDevicePixelRatio()
let fractionalPart = screenPixelsToBackingStorePixels - Math.floor(screenPixelsToBackingStorePixels)
// that's it! I am officially giving up on trying to account for all the weird pixelDeviceRatios that Chrome likes
// to serve up at different zoom levels. I can understand nice fractions like 2.5 but 1.110004 and 0.89233 are just stupid
// If the fractional part doesn't make sense then just ignore it. This will give incorrect results but they still look
// pretty good if you don't look too closely.
if ((1.0 / fractionalPart) > 3) {
fractionalPart = 0
screenPixelsToBackingStorePixels = Math.round(screenPixelsToBackingStorePixels)
}
if (fractionalPart != 0) {
screenPixelsToBackingStorePixels = Math.round(screenPixelsToBackingStorePixels * Math.round(1.0 / fractionalPart))
}
const calculatedWidth = Math.round(rect.width * screenPixelsToBackingStorePixels)
const calculatedHeight = Math.round(rect.height * screenPixelsToBackingStorePixels)
let adjustedPixelSize = screenPixelsToBackingStorePixels * this.crunchFactor_
// double check - we may have already painted this image
if ((this.last_draw_state_.width == calculatedWidth) &&
(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_)) {
return; // nothing to do
}
this.canvas_.width = calculatedWidth
this.canvas_.height = calculatedHeight
this.last_draw_state_.width = this.canvas_.width
this.last_draw_state_.height = this.canvas_.height
this.last_draw_state_.adjustedPixelSize = adjustedPixelSize
this.last_draw_state_.imageSrc = this.original_image_.currentSrc
this.last_draw_state_.cutoff = this.cutoff_
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)
// 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_
this.worker_.postMessage(msg)
this.force_refresh_ = false
}
}
window.customElements.define('as-dithered-image', ASDitheredImage);

60
ditherworker.js Normal file
View File

@@ -0,0 +1,60 @@
onmessage = function (e) {
const result = dither(e.data.imageData, e.data.pixelSize, e.data.cutoff)
const reply = {}
reply.imageData = result
reply.pixelSize = e.data.pixelSize
reply.cutoff = e.data.cutoff
postMessage(reply)
}
function dither(imageData, scaleFactor, cutoff) {
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 distribute 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 <= Math.floor(cutoff * 255)) {
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 blurry 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
}

79
test.html Normal file
View File

@@ -0,0 +1,79 @@
<!DOCTYPE html>
<html>
<head>
<title>as-dithered-image.js Test Page</title>
</head>
<body>
<script src="as-dithered-image.js"></script>
<p>A short, hacky demo page - used only for internal testing. See the blog post for a better one.</p>
<as-dithered-image id="picture" src="Van_Gogh_-_Starry_Night_-_Google_Art_Project.jpg"></as-dithered-image>
<div>devicePixelRatio = <span id=dpr>0</span></p>
<select id="crunchselect">
<option value="auto">Automatic</option>
<option value="pixel">Pixel</option>
<option value="1">1</option>
<option value="2">2</option>
<option value="3">3</option>
<option value="4">4</option>
</select>
<input id="cutoff" type="range" min="0.0" max="1.0" step="0.05" value="any" />
<input id="choosefile" type="file" />
Drag and drop works as well
</div>
</body>
<script>
function displayFromFile(file) {
if (!file.type.startsWith("image/")) {
return
}
const reader = new FileReader();
reader.onload = (e) => {
document.getElementById("picture").setAttribute("src", e.target.result)
}
reader.readAsDataURL(file)
}
document.getElementById("dpr").innerText = window.devicePixelRatio
let select = document.getElementById("crunchselect")
select.addEventListener("change", e => {
console.log("Crunch = ", e.target.value)
document.getElementById("picture").setAttribute("crunch", e.target.value)
})
document.getElementById("cutoff").addEventListener("change", e => {
console.log("Value = ", e.target.value)
document.getElementById("picture").setAttribute("cutoff", e.target.value)
})
document.getElementById("choosefile").addEventListener("change", e => {
const files = e.target.files
if (files.length == 0) {
return
}
displayFromFile(files[0])
}, false)
const pictureElement = document.getElementById("picture")
pictureElement.addEventListener("drop", e => {
e.preventDefault()
if (e.dataTransfer.files.length == 0) {
return
}
displayFromFile(e.dataTransfer.files[0])
})
pictureElement.addEventListener("dragover", e => {
e.preventDefault() // need this to disable the default
})
</script>
</html>