Initial commit
This commit is contained in:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Vendored
+51
@@ -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[]
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user