Initial commit

This commit is contained in:
daylily
2025-03-30 13:03:07 -04:00
commit 6be8e7a185
23 changed files with 2270 additions and 0 deletions
+272
View File
@@ -0,0 +1,272 @@
<script lang="ts">
import { Transform, withCtx, withTransform } from './image/transform'
import { Quantizer, type DitheringKernel } from './image/quantizer'
import { Scaler, type ScaleMode } from './image/scaler'
interface Props {
onchange: (bitmap: number[] | null) => void
}
const scaler = new Scaler(200, 200)
const { onchange }: Props = $props()
let scaledCanvasEl: HTMLCanvasElement
let unscaledCanvasEl: HTMLCanvasElement
let fileList: FileList | null = $state(null)
let imageBitmap: ImageBitmap | null = $state(null)
let backgroundColor: number = $state(255)
let imageState: Transform = $state(new Transform())
let scalingMethod: ScaleMode = $state('fit')
let dither = $state(true)
let ditheringKernel: DitheringKernel = $state('FloydSteinberg')
let saturation = $state(0)
let bias = $state(0)
const quantizer = $derived(
new Quantizer({
ditheringKernel: dither ? ditheringKernel : null,
saturation,
bias,
}),
)
function imageNonSquare() {
if (imageBitmap === null) return false
return imageBitmap.height !== imageBitmap.width
}
function restoreDefaultImageSettings() {
backgroundColor = 255
imageState = new Transform()
scalingMethod = 'fit'
dither = true
ditheringKernel = 'FloydSteinberg'
saturation = 0
bias = 0
}
function freshContext() {
const ctx = scaledCanvasEl.getContext('2d', {
willReadFrequently: true,
})!
ctx.clearRect(0, 0, 200, 200)
return ctx
}
function drawQuantizedData(ctx: CanvasRenderingContext2D, data: number[]) {
withCtx(
ctx,
() => {
ctx.imageSmoothingEnabled = false
},
() => {
for (let y = 0; y < 200; y++) {
for (let x = 0; x < 200; x++) {
const color = data.at(y * 200 + x)
ctx.fillStyle = color === 0 ? '#fff' : '#000'
ctx.fillRect(x, y, 1, 1)
}
}
},
)
}
async function updateImageBitmap() {
if (fileList === null || fileList.length < 1) {
imageBitmap = null
return
}
const imageFile = fileList[0]
try {
imageBitmap = await createImageBitmap(imageFile)
} catch (e) {
console.error(e)
imageBitmap = null
}
imageState = new Transform()
}
async function drawToCanvas() {
const ctx = freshContext()
if (imageBitmap === null) {
onchange(null)
return
}
withTransform(ctx, imageState, () => {
if (imageBitmap === null) return
ctx.fillStyle = `rgb(${backgroundColor} ${backgroundColor} ${backgroundColor})`
ctx.fillRect(0, 0, 200, 200)
const { dx, dy, dWidth, dHeight } = scaler.scale(imageBitmap, scalingMethod)
ctx.drawImage(imageBitmap, dx, dy, dWidth, dHeight)
})
const quantizedData = quantizer.reduce(ctx)
drawQuantizedData(ctx, quantizedData)
drawQuantizedData(unscaledCanvasEl.getContext('2d')!, quantizedData)
onchange(quantizedData)
}
$effect(() => {
updateImageBitmap()
})
$effect(() => {
drawToCanvas()
})
</script>
<div class="canvas-container">
<div>
<input type="file" id="image-file" name="image_file" accept="image/*" bind:files={fileList} />
<canvas class="scaled" bind:this={scaledCanvasEl} height={200} width={200}></canvas>
<canvas bind:this={unscaledCanvasEl} height={200} width={200}></canvas>
</div>
<div>
{#if imageNonSquare()}
<div>
<h2>Scaling</h2>
You need to choose how to scale your image since it is not square.
<br />
<select name="scaling_method" id="scaling-method-select" bind:value={scalingMethod}>
<option value="fit">Fit</option>
<option value="crop">Crop</option>
<option value="distort">Distort</option>
</select>
</div>
{/if}
<div class="transforms">
<h2>Transform</h2>
<button
onclick={() => {
imageState = imageState.cw()
}}
name="Rotate clockwise"
>
</button>
<button
onclick={() => {
imageState = imageState.ccw()
}}
name="Rotate counter-clockwise"
>
</button>
<button
onclick={() => {
imageState = imageState.h()
}}
name="Flip horizontally"
>
</button>
<button
onclick={() => {
imageState = imageState.v()
}}
name="Flip vertically"
>
</button>
</div>
<div>
<h2>Rendering</h2>
Background Color:<input
type="range"
name="background_color"
id="background-color-input"
bind:value={backgroundColor}
min={0}
max={255}
step={1}
/>
{backgroundColor}
<br />
<input type="checkbox" name="dither" id="dither-checkbox" bind:checked={dither} /> Dither
{#if dither}
<br />
Dithering Kernel:
<select name="dithering_kernel" id="dithering-kernel-select" bind:value={ditheringKernel}>
<option value="FloydSteinberg">Floyd-Steinberg</option>
<option value="FalseFloydSteinberg">False Floyd-Steinberg</option>
<option value="Stucki">Stucki</option>
<option value="Atkinson">Atkinson</option>
<option value="Jarvis">Jarvis</option>
<option value="Burkes">Burkes</option>
<option value="Sierra">Sierra</option>
<option value="TwoSierra">2-Sierra</option>
<option value="SierraLite">Sierra Lite</option>
</select>
<br />
Saturation:
<input
type="range"
name="saturation"
id="saturation-input"
bind:value={saturation}
min={0}
max={1}
step={0.01}
/>
{Math.floor(saturation * 100)}%
{/if}
{#if !dither || saturation !== 0}
<br />
Bias:
<input type="range" name="bias" id="bias-input" bind:value={bias} min={-1} max={1} step={0.01} />
{Math.floor(bias * 100)}%
{/if}
<br />
(white = {Math.floor(quantizer.white)}, black = {Math.floor(quantizer.black)})
</div>
<div>
<h2>Reset</h2>
<button onclick={restoreDefaultImageSettings}>Reset All</button>
</div>
</div>
</div>
<style>
canvas {
display: block;
image-rendering: pixelated;
image-rendering: crisp-edges;
}
.scaled {
width: 400px;
height: 400px;
}
.canvas-container {
display: flex;
gap: 20px;
}
.transforms button {
font-family: sans-serif;
font-size: 32px;
}
h2 {
font-size: 1.3em;
}
</style>
+35
View File
@@ -0,0 +1,35 @@
<script lang="ts">
const hid = navigator.hid
interface Props {
onconnect: (device: HIDDevice) => void
device: HIDDevice | null
}
const { onconnect, device }: Props = $props()
async function requestDevice() {
const devs = await hid.requestDevice({
filters: [
{
vendorId: 0xc0de,
productId: 0xcafe,
},
],
})
if (devs == undefined || devs[0] == undefined) {
return
}
onconnect(devs[0])
}
</script>
<button onclick={requestDevice}>
{#if device == null}
Choose device
{:else}
Connect to another device
{/if}
</button>
+49
View File
@@ -0,0 +1,49 @@
<script lang="ts">
interface Props {
device: HIDDevice | null
data: number[] | null
onprogress: (inPropgress: boolean) => void
}
const { device, data, onprogress }: Props = $props()
let inProgress = $state(false)
let disabled = $derived(device === null || data === null || inProgress)
async function connectAndWrite() {
if (device === null || data === null) return
if (!device.opened) {
try {
await device.open()
} catch (e) {
if (!(e instanceof Error)) throw e
alert('Unable to open device: ' + e.toString())
}
}
const buffer = new Uint8Array(5000)
for (let y = 0; y < 200; y++) {
for (let xStride = 0; xStride < 25; xStride++) {
let cell = 0x0
for (let xStroll = 0; xStroll < 8; xStroll++) {
const index = y * 200 + xStride * 8 + xStroll
cell |= data[index] << xStroll
}
buffer[y * 25 + xStride] = cell
}
}
inProgress = true
setTimeout(() => {
inProgress = false
}, 2000)
await device.sendReport(0, buffer)
}
$effect(() => {
onprogress(inProgress)
})
</script>
<button onclick={connectAndWrite} {disabled}>Write pattern to device</button>
+37
View File
@@ -0,0 +1,37 @@
import RgbQuant from 'rgbquant'
import type { DitheringKernel, RgbQuantImage } from 'rgbquant'
export type { DitheringKernel, RgbQuantImage } from 'rgbquant'
export type QuantizerOptions = {
ditheringKernel: DitheringKernel | null
saturation: number
bias: number
}
export class Quantizer {
public readonly black: number
public readonly white: number
private readonly rgbquant: RgbQuant
constructor({ ditheringKernel, saturation, bias }: QuantizerOptions) {
const saturationAdjustment = ditheringKernel === null ? 127 : 127 * saturation
this.white = 255 + (bias - 1) * saturationAdjustment
this.black = (1 + bias) * saturationAdjustment
this.rgbquant = new RgbQuant({
colors: 2,
dithKern: ditheringKernel,
palette: [
[this.white, this.white, this.white],
[this.black, this.black, this.black],
],
useCache: false,
})
}
reduce(image: RgbQuantImage): number[] {
return this.rgbquant.reduce(image, 2)
}
}
+51
View File
@@ -0,0 +1,51 @@
declare module 'rgbquant' {
type DitheringKernel =
| 'FloydSteinberg'
| 'FalseFloydSteinberg'
| 'Stucki'
| 'Atkinson'
| 'Jarvis'
| 'Burkes'
| 'Sierra'
| 'TwoSierra'
| 'SierraLite'
type Rgb = [number, number, number]
type RgbQuantOptions = {
colors?: number
method?: 1 | 2
boxSize?: [number, number]
boxPxls?: number
initColors?: number
minHueCols?: number
dithKern?: DitheringKernel | null
dithDelta?: number
dithSerp?: boolean
palette?: Rgb[]
reIndex?: boolean
useCache?: boolean
cacheFreq?: number
colorDist?: 'euclidean' | 'manhattan'
}
type RgbQuantImage =
| HTMLImageElement
| HTMLCanvasElement
| CanvasRenderingContext2D
| ImageData
| Uint8Array
| Uint8ClampedArray
| Uint32Array
| number[]
export default class RgbQuant {
constructor(opts?: RgbQuantOptions)
sample(image: RgbQuantImage, width?: number): void
palette(tuples: true, noSort?: boolean = false): Rgb[]
palette(tuples: false, noSort?: boolean = false): Uint8Array
palette(): Uint8Array
reduce(image: RgbQuantImage, retType: 1, dithKern?: DitheringKernel | null, dithSerp?: boolean): Uint8Array
reduce(image: RgbQuantImage, retType: 2, dithKern?: DitheringKernel | null, dithSerp?: boolean): number[]
}
}
+86
View File
@@ -0,0 +1,86 @@
export type DrawParameters = {
dx: number
dy: number
dWidth: number
dHeight: number
}
export type ScaleMode = 'fit' | 'crop' | 'distort'
export class Scaler {
private readonly canvasAspectRatio: number
constructor(private readonly canvasWidth: number, private readonly canvasHeight: number) {
this.canvasAspectRatio = canvasWidth / canvasHeight
}
fitScale(image: ImageBitmap): DrawParameters {
const { width, height } = image
const aspectRatio = width / height
let dx = 0
let dy = 0
let dWidth = this.canvasWidth
let dHeight = this.canvasHeight
if (aspectRatio > this.canvasAspectRatio) {
dHeight = dWidth / aspectRatio
dy = (this.canvasHeight - dHeight) / 2
} else if (aspectRatio < this.canvasAspectRatio) {
dWidth = dHeight * aspectRatio
dx = (this.canvasWidth - dWidth) / 2
}
return {
dx,
dy,
dWidth,
dHeight,
}
}
cropScale(image: ImageBitmap): DrawParameters {
const { width, height } = image
const aspectRatio = width / height
let dx = 0
let dy = 0
let dWidth = this.canvasWidth
let dHeight = this.canvasHeight
if (aspectRatio > this.canvasAspectRatio) {
dWidth = dHeight * aspectRatio
dx = (this.canvasWidth - dWidth) / 2
} else if (aspectRatio < this.canvasAspectRatio) {
dHeight = dWidth / aspectRatio
dy = (this.canvasHeight - dHeight) / 2
}
return {
dx,
dy,
dWidth,
dHeight,
}
}
distortScale(): DrawParameters {
return {
dx: 0,
dy: 0,
dWidth: this.canvasWidth,
dHeight: this.canvasHeight,
}
}
scale(image: ImageBitmap, mode: ScaleMode) {
switch (mode) {
case 'crop':
return this.cropScale(image)
case 'fit':
return this.fitScale(image)
case 'distort':
return this.distortScale()
}
}
}
+60
View File
@@ -0,0 +1,60 @@
export type Rotation = 0 | 90 | 180 | 270
export type Side = 'obverse' | 'reverse'
/**
* A transform of a square image is represented by first rotating it by a multiple of 90 degrees, and then optionally
* flipping it horizontally.
*
* Algebraically, this is the dihedral group of order 8 (D₄).
*/
export class Transform {
constructor(public readonly side: Side = 'obverse', public readonly rotation: Rotation = 0) {}
cw(): Transform {
const rotationAngle = this.side === 'obverse' ? 90 : 270
const newAngle = (this.rotation + rotationAngle) % 360
return new Transform(this.side, newAngle as Rotation)
}
ccw(): Transform {
const rotationAngle = this.side === 'obverse' ? 270 : 90
const newAngle = (this.rotation + rotationAngle) % 360
return new Transform(this.side, newAngle as Rotation)
}
h(): Transform {
const newSide = this.side == 'obverse' ? 'reverse' : 'obverse'
return new Transform(newSide, this.rotation)
}
v(): Transform {
const newSide = this.side == 'obverse' ? 'reverse' : 'obverse'
const newAngle = (this.rotation + 180) % 360
return new Transform(newSide, newAngle as Rotation)
}
}
export function withCtx<T>(ctx: CanvasRenderingContext2D, pre: () => void, action: () => T): T {
ctx.save()
try {
pre()
return action()
} finally {
ctx.restore()
}
}
export function withTransform<T>(ctx: CanvasRenderingContext2D, transform: Transform, action: () => T): T {
return withCtx(
ctx,
() => {
ctx.translate(100, 100)
if (transform.side === 'reverse') {
ctx.scale(-1, 1)
}
ctx.rotate((transform.rotation / 180) * Math.PI)
ctx.translate(-100, -100)
},
action
)
}