mirror of
https://github.com/andrewstephens75/as-dithered-image.git
synced 2026-04-14 12:29:30 -07:00
Simplified code
This commit is contained in:
BIN
Van_Gogh_-_Starry_Night_-_Google_Art_Project.jpg
Normal file
BIN
Van_Gogh_-_Starry_Night_-_Google_Art_Project.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
223
as-dithered-image-old.js
Normal file
223
as-dithered-image-old.js
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
const DITHERED_IMAGE_STYLE = `
|
||||||
|
.ditheredImageStyle {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
image-rendering: crisp-edges;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
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.forceRedraw = false
|
||||||
|
this.originalImage = new Image(100, 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
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.loadImage()
|
||||||
|
} else if (name === "crunch") {
|
||||||
|
if (newValue === "auto") {
|
||||||
|
this.crunchFactor = this.getAutoCrunchFactor()
|
||||||
|
} else if (newValue === "pixel") {
|
||||||
|
this.crunchFactor = 1.0 / this.getDevicePixelRatio()
|
||||||
|
} 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 (this.getDevicePixelRatio() < 3) {
|
||||||
|
return 1
|
||||||
|
} else {
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
loadImage() {
|
||||||
|
const image = new Image()
|
||||||
|
image.onload = (() => {
|
||||||
|
this.originalImage = image
|
||||||
|
this.forceRedraw = true
|
||||||
|
this.style.aspectRatio = this.originalImage.width + "/" + this.originalImage.height
|
||||||
|
this.forceRedraw = true
|
||||||
|
console.log(this.width, " x ", this.height)
|
||||||
|
requestUpdate()
|
||||||
|
}).bind(this)
|
||||||
|
image.src = this.src
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshImage() {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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 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 <= 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 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);
|
||||||
|
|
||||||
@@ -4,6 +4,7 @@ const DITHERED_IMAGE_STYLE = `
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
image-rendering: crisp-edges;
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -11,17 +12,19 @@ class ASDitheredImage extends HTMLElement {
|
|||||||
constructor() {
|
constructor() {
|
||||||
super()
|
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
|
this.original_image_ = undefined
|
||||||
// The crunch factor is how "chunky" the dither should be, ie how many css pixels to dithered pixels
|
this.force_refresh_ = false
|
||||||
this.crunchFactor = this.getAutoCrunchFactor()
|
this.crunchFactor = this.getAutoCrunchFactor()
|
||||||
this.drawTimestamp = 0
|
this.canvas_ = undefined
|
||||||
this.drawRect = undefined
|
this.context_ = undefined
|
||||||
this.drawCrunchFactor = undefined
|
this.image_loading_ = false
|
||||||
this.drawSrc = undefined
|
this.ignore_next_resize_ = false
|
||||||
this.altText = ""
|
|
||||||
|
this.resizing_timeout_ = undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
connectedCallback() {
|
connectedCallback() {
|
||||||
|
console.log("connectedCallback")
|
||||||
if (!this.isConnected) {
|
if (!this.isConnected) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -32,24 +35,41 @@ class ASDitheredImage extends HTMLElement {
|
|||||||
style.innerHTML = DITHERED_IMAGE_STYLE
|
style.innerHTML = DITHERED_IMAGE_STYLE
|
||||||
shadowDOM.appendChild(style)
|
shadowDOM.appendChild(style)
|
||||||
|
|
||||||
this.canvas = document.createElement("canvas")
|
this.canvas_ = document.createElement("canvas")
|
||||||
this.canvas.setAttribute("role", "image")
|
this.canvas_.setAttribute("role", "image")
|
||||||
this.canvas.setAttribute("aria-label", this.altText)
|
this.canvas_.setAttribute("aria-label", this.getAttribute("alt"))
|
||||||
this.canvas.classList.add("ditheredImageStyle")
|
this.canvas_.classList.add("ditheredImageStyle")
|
||||||
shadowDOM.appendChild(this.canvas)
|
shadowDOM.appendChild(this.canvas_)
|
||||||
|
|
||||||
this.context = this.canvas.getContext("2d")
|
this.context_ = this.canvas_.getContext("2d")
|
||||||
|
|
||||||
const resizeObserver = new ResizeObserver((entries) => {
|
const resizeObserver = new ResizeObserver(((entries) => {
|
||||||
for (const e of entries) {
|
// browsers generated lots of resize events but we don't want to start refreshing until
|
||||||
if (e.contentBoxSize) {
|
// the user has stopped resizing the page
|
||||||
this.requestUpdate()
|
|
||||||
|
if (entries.length > 0) {
|
||||||
|
if (entries[0].contentBoxSize) {
|
||||||
|
console.log("contentRect=", entries[0].contentRect)
|
||||||
|
|
||||||
|
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)
|
|
||||||
|
|
||||||
|
resizeObserver.observe(this.canvas_)
|
||||||
|
this.force_refresh_ = true
|
||||||
this.requestUpdate()
|
this.requestUpdate()
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -61,17 +81,16 @@ class ASDitheredImage extends HTMLElement {
|
|||||||
if (oldValue === newValue) return
|
if (oldValue === newValue) return
|
||||||
|
|
||||||
if ((name === "src")) {
|
if ((name === "src")) {
|
||||||
this.src = newValue
|
|
||||||
this.requestUpdate()
|
this.requestUpdate()
|
||||||
} else if (name === "crunch") {
|
} else if (name === "crunch") {
|
||||||
if (newValue === "auto") {
|
if (newValue === "auto") {
|
||||||
this.crunchFactor = this.getAutoCrunchFactor()
|
this.crunchFactor_ = this.getAutoCrunchFactor()
|
||||||
} else if (newValue === "pixel") {
|
} else if (newValue === "pixel") {
|
||||||
this.crunchFactor = 1.0 / this.getDevicePixelRatio()
|
this.crunchFactor_ = 1.0 / this.getDevicePixelRatio()
|
||||||
} else {
|
} else {
|
||||||
this.crunchFactor = parseInt(newValue, 10)
|
this.crunchFactor_ = parseInt(newValue, 10)
|
||||||
if (isNaN(this.crunchFactor)) {
|
if (isNaN(this.crunchFactor)) {
|
||||||
this.crunchFactor = this.getAutoCrunchFactor()
|
this.crunchFactor_ = this.getAutoCrunchFactor()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.requestUpdate()
|
this.requestUpdate()
|
||||||
@@ -79,24 +98,13 @@ class ASDitheredImage extends HTMLElement {
|
|||||||
this.altText = newValue;
|
this.altText = newValue;
|
||||||
if (this.canvas != undefined) {
|
if (this.canvas != undefined) {
|
||||||
let currentAltText = this.canvas.getAttribute("aria-label")
|
let currentAltText = this.canvas.getAttribute("aria-label")
|
||||||
if (currentAltText != this.altText) {
|
if (currentAltText != newValue) {
|
||||||
this.canvas.setAttribute("aria-label", this.altText)
|
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
|
|
||||||
}
|
|
||||||
}).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
|
// 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
|
// 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
|
// look effective, so I double the pixels again
|
||||||
@@ -108,104 +116,70 @@ class ASDitheredImage extends HTMLElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
getDevicePixelRatio() {
|
getDevicePixelRatio() {
|
||||||
// this should always be an integer for the dithering code to work
|
// this should always be an integer for the dithering code to work
|
||||||
return Math.floor(window.devicePixelRatio)
|
return Math.floor(window.devicePixelRatio)
|
||||||
}
|
}
|
||||||
|
|
||||||
drawImage() {
|
// all drawing is funneled through requestUpdate so that multiple calls are coalesced to prevent
|
||||||
if ((this.canvas === undefined) || (this.src === undefined)) {
|
// processing the image multiple times for no good reason
|
||||||
|
requestUpdate() {
|
||||||
|
console.log("requestUpdate")
|
||||||
|
window.requestAnimationFrame(((timestamp) => {
|
||||||
|
console.log(this.force_refresh_, this.isConnected)
|
||||||
|
if ((this.force_refresh_ == false)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
console.log("update happening")
|
||||||
|
if (this.original_image_ == undefined) {
|
||||||
|
this.loadImage()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (this.force_refresh_) {
|
||||||
|
this.repaintImage()
|
||||||
|
}
|
||||||
|
|
||||||
|
}).bind(this))
|
||||||
|
}
|
||||||
|
|
||||||
|
loadImage() {
|
||||||
|
if (this.image_loading_ == true) {
|
||||||
return
|
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()
|
const image = new Image()
|
||||||
image.onload = (() => {
|
image.onload = (() => {
|
||||||
this.context.imageSmoothingEnabled = true
|
this.image_loading_ = false
|
||||||
this.context.drawImage(image, 0, 0, this.canvas.width / logicalPixelSize, this.canvas.height / logicalPixelSize)
|
this.original_image_ = image
|
||||||
const original = this.context.getImageData(0, 0, this.canvas.width / logicalPixelSize, this.canvas.height / logicalPixelSize)
|
this.ignore_next_resize_ = true
|
||||||
|
console.log("set aspect ratio")
|
||||||
const dithered = this.dither(original, logicalPixelSize)
|
this.style.aspectRatio = this.original_image_.width + "/" + this.original_image_.height
|
||||||
this.context.imageSmoothingEnabled = false
|
this.force_refresh_ = true
|
||||||
this.context.putImageData(dithered, 0, 0)
|
this.requestUpdate()
|
||||||
|
console.log("Imaged Loaded")
|
||||||
}).bind(this)
|
}).bind(this)
|
||||||
|
image.onerror = (() => {
|
||||||
image.src = this.src
|
this.image_loading_ == false
|
||||||
|
this.original_image_ = undefined
|
||||||
|
}).bind(this)
|
||||||
|
this.image_loading_ = true
|
||||||
|
console.log("Loading ", this.getAttribute("src"))
|
||||||
|
image.src = this.getAttribute("src")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
repaintImage() {
|
||||||
|
const rect = this.canvas_.getBoundingClientRect()
|
||||||
|
|
||||||
dither(imageData, scaleFactor) {
|
const screenPixelsToBackingStorePixels = this.getDevicePixelRatio()
|
||||||
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 has to change for fractional device pixel ratios
|
||||||
// this implementation uses a sliding window of floats for more accuracy (probably not needed really)
|
this.canvas_.width = rect.width * screenPixelsToBackingStorePixels
|
||||||
|
this.canvas_.height = rect.height * screenPixelsToBackingStorePixels
|
||||||
|
|
||||||
let slidingErrorWindow = [new Float32Array(imageData.width), new Float32Array(imageData.width), new Float32Array(imageData.width)]
|
this.context_.imageSmoothingEnabled = false
|
||||||
const offsets = [[1, 0], [2, 0], [-1, 1], [0, 1], [1, 1], [0, 2]]
|
this.context_.drawImage(this.original_image_, 0, 0, this.canvas_.width, this.canvas_.height)
|
||||||
|
console.log("Repainted")
|
||||||
for (let y = 0, limY = imageData.height; y < limY; ++y) {
|
this.force_refresh_ = false
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 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);
|
window.customElements.define('as-dithered-image', ASDitheredImage);
|
||||||
|
|
||||||
|
|||||||
26
test.html
Normal file
26
test.html
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<title>Test Page</title>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<script src="as-dithered-image.js"></script>
|
||||||
|
<style>
|
||||||
|
.setSize {
|
||||||
|
width: 80%;
|
||||||
|
min-width: 80%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<as-dithered-image class="setSize" src="Van_Gogh_-_Starry_Night_-_Google_Art_Project.jpg"></as-dithered-image>
|
||||||
|
|
||||||
|
<p>devicePixelRatio = <span id=dpr>0</span></p>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.getElementById("dpr").innerText = window.devicePixelRatio
|
||||||
|
</script>
|
||||||
|
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user