Skip to content

Commit

Permalink
feat: ordered bayer (#27)
Browse files Browse the repository at this point in the history
* feat: ordered bayer

* feat: ordered dithering
  • Loading branch information
LuciNyan committed Apr 27, 2024
1 parent 3ef5a43 commit 01a6c6e
Show file tree
Hide file tree
Showing 18 changed files with 240 additions and 26 deletions.
6 changes: 4 additions & 2 deletions packages/pixel-profile-server/src/github-stats.ts
Expand Up @@ -18,7 +18,8 @@ githubStats.get('/', async (c) => {
show,
username,
theme,
avatar_border
avatar_border,
dithering
} = req.query()

res.headers.set('Content-Type', 'image/png')
Expand Down Expand Up @@ -53,7 +54,8 @@ githubStats.get('/', async (c) => {
pixelateAvatar: parseBoolean(pixelate_avatar),
theme: parseString(theme),
screenEffect: parseBoolean(screen_effect),
avatarBorder: parseBoolean(avatar_border)
avatarBorder: parseBoolean(avatar_border),
dithering: parseBoolean(dithering)
}

const result = await renderStats(stats, options)
Expand Down
18 changes: 11 additions & 7 deletions packages/pixel-profile/src/cards/stats.ts
@@ -1,6 +1,6 @@
import { addBorder, curve, pixelate } from '../shaders'
import { orderedBayer } from '../shaders/dithering'
// import { glow } from '../shaders/glow'
import { halftone } from '../shaders/halftone'
import { scanline } from '../shaders/scanline'
import {
AVATAR_SIZE,
Expand Down Expand Up @@ -40,6 +40,7 @@ type Options = {
includeAllCommits?: boolean
pixelateAvatar?: boolean
avatarBorder?: boolean
dithering?: boolean
}

export async function renderStats(stats: Stats, options: Options = {}): Promise<Buffer> {
Expand All @@ -54,7 +55,8 @@ export async function renderStats(stats: Stats, options: Options = {}): Promise<
pixelateAvatar = true,
screenEffect = false,
avatarBorder,
theme = ''
theme = '',
dithering = false
} = options

const applyAvatarBorder = avatarBorder !== undefined ? avatarBorder : theme !== ''
Expand Down Expand Up @@ -149,12 +151,12 @@ export async function renderStats(stats: Stats, options: Options = {}): Promise<

let { pixels } = await getPixelsFromPngBuffer(pngBuffer)

if (theme === 'green_phosphor') {
pixels = halftone(pixels, width, height)
if (dithering) {
pixels = orderedBayer(pixels, width, height)
}

if (screenEffect) {
if (theme !== 'green_phosphor') {
if (!dithering) {
pixels = scanline(pixels, width, height)
}
// pixels = glow(pixels, width, height)
Expand Down Expand Up @@ -183,8 +185,10 @@ async function makeAvatar(url: string, pixelateAvatar: boolean, applyAvatarBorde
frameWidthRatio: 0.025
})
}
} else if (applyAvatarBorder) {
pixels = addBorder(pixels, width, height, { frameWidthRatio: 0.0167, enabledCornerRemoval: false })
} else {
if (applyAvatarBorder) {
pixels = addBorder(pixels, width, height, { frameWidthRatio: 0.0167, enabledCornerRemoval: false })
}
}

return await getBase64FromPixels(pixels, width, height)
Expand Down
107 changes: 107 additions & 0 deletions packages/pixel-profile/src/shaders/dithering.ts
@@ -0,0 +1,107 @@
import { render } from '../renderer'
import { hslToRgb, rgbToHsl } from '../utils'
import { Vec3 } from '../utils/math'

const palette: Vec3[] = [
[0.1498, 0.7753, 0.8255],
[0.1024, 0.9387, 0.6804],
[0.0699, 0.6976, 0.598],
[0.9567, 0.2212, 0.4431],
[0.8718, 0.2321, 0.2196],
[0.3833, 0.3623, 0.2706],
[0.2965, 0.3277, 0.4608],
[0.175, 0.5405, 0.5647],
[0.5355, 0.5341, 0.6549],
[0.5996, 0.368, 0.5098],
[0.6795, 0.134, 0.3804],
[0.5333, 0.1643, 0.5824],
[0.0764, 0.2791, 0.8314],
[0, 0.8324, 0.649],
[0.9194, 0.4819, 0.3784],
[0.9423, 0.7605, 0.6725]
]

/* eslint-disable prettier/prettier */
const ditherTable: number[] = [
0, 48, 12, 60, 3, 51, 15, 63,
32, 16, 44, 28, 35, 19, 47, 31,
8, 56, 4, 52, 11, 59, 7, 55,
40, 24, 36, 20, 43, 27, 39, 23,
2, 50, 14, 62, 1, 49, 13, 61,
34, 18, 46, 30, 33, 17, 45, 29,
10, 58, 6, 54, 9, 57, 5, 53,
42, 26, 38, 22, 41, 25, 37, 21
]
/* eslint-enable prettier/prettier */

const lightnessSteps = 4
const saturationSteps = 4

function hueDistance(h1: number, h2: number): number {
const diff = Math.abs(h1 - h2)

return Math.min(Math.abs(1 - diff), diff)
}

function lightnessStep(l: number): number {
return Math.floor(0.5 + l * lightnessSteps) / lightnessSteps
}

function saturationStep(s: number): number {
return Math.floor(0.5 + s * saturationSteps) / saturationSteps
}

function closestColors(hue: number): [[number, number, number], [number, number, number]] {
let closest: [number, number, number] = [-2, 0, 0]
let secondClosest: [number, number, number] = [-2, 0, 0]

for (const color of palette) {
const tempDistance = hueDistance(color[0], hue)
if (tempDistance < hueDistance(closest[0], hue)) {
secondClosest = closest
closest = color
} else if (tempDistance < hueDistance(secondClosest[0], hue)) {
secondClosest = color
}
}

return [closest, secondClosest]
}

// 抖动和颜色量化
function dither(pos: [number, number], color: [number, number, number]): [number, number, number] {
const x = Math.floor(pos[0] % 8)
const y = Math.floor(pos[1] % 8)
const index = x + y * 8

const bias = 0.11
const limit = (ditherTable[index] + 1) / 64 + bias

const [closest, secondClosest] = closestColors(color[0])

const hueDiff = hueDistance(color[0], closest[0]) / hueDistance(secondClosest[0], closest[0])

const l1 = lightnessStep(Math.max(color[2] - 0.125, 0))
const l2 = lightnessStep(Math.min(color[2] + 0.124, 1))
const lightnessDiff = (color[2] - l1) / (l2 - l1)

const resultColor: [number, number, number] = hueDiff < limit ? [...closest] : [...secondClosest]
resultColor[2] = lightnessDiff < limit ? l1 : l2

const s1 = saturationStep(Math.max(color[1] - 0.125, 0))
const s2 = saturationStep(Math.min(color[1] + 0.124, 1))
const saturationDiff = (color[1] - s1) / (s2 - s1)

resultColor[1] = saturationDiff < limit ? s1 : s2

return hslToRgb(resultColor)
}

export function orderedBayer(source: Buffer, width: number, height: number): Buffer {
return render(source, width, height, (pixelCoords, texture2D) => {
const color = texture2D(pixelCoords)
const ditheredColor = dither(pixelCoords, rgbToHsl(color))

return [...ditheredColor, color[3]]
})
}
4 changes: 0 additions & 4 deletions packages/pixel-profile/src/theme/index.ts
Expand Up @@ -59,10 +59,6 @@ export const THEME: Theme = {
backgroundImage: `url(${IMG_ROAD_TRIP})`,
backgroundSize: '1226px 430px',
backgroundRepeat: 'no-repeat'
},
green_phosphor: {
color: 'white',
background: 'linear-gradient(to bottom right, #74dcc4, #4597e9)'
}
}

Expand Down
76 changes: 75 additions & 1 deletion packages/pixel-profile/src/utils/converter.ts
@@ -1,4 +1,6 @@
import { RGBA } from '../renderer'
import { isBase64PNG } from './is'
import { Vec3 } from './math'
import axios from 'axios'
import Jimp from 'jimp'

Expand All @@ -13,7 +15,7 @@ export async function getPixelsFromPngBuffer(png: Buffer): Promise<{
const height = image.getHeight()
const pixels = Buffer.alloc(width * height * 4)

image.scan(0, 0, width, height, (x, y, idx) => {
image.scan(0, 0, width, height, (_x, _y, idx) => {
pixels[idx] = image.bitmap.data[idx]
pixels[idx + 1] = image.bitmap.data[idx + 1]
pixels[idx + 2] = image.bitmap.data[idx + 2]
Expand Down Expand Up @@ -66,3 +68,75 @@ export async function getPngBufferFromURL(url: string): Promise<Buffer> {
})
}
}

export function rgbToHsl([r, g, b]: Vec3 | RGBA): Vec3 {
r /= 255
g /= 255
b /= 255

const max = Math.max(r, g, b)
const min = Math.min(r, g, b)

let h = 0
let s

const l = (max + min) / 2

if (max === min) {
h = s = 0
} else {
const d = max - min
s = l > 0.5 ? d / (2 - max - min) : d / (max + min)
switch (max) {
case r:
h = (g - b) / d + (g < b ? 6 : 0)
break
case g:
h = (b - r) / d + 2
break
case b:
h = (r - g) / d + 4
break
}
h /= 6
}

return [h, s, l]
}

export function hslToRgb([h, s, l]: Vec3): Vec3 {
let r, g, b

if (s === 0) {
r = g = b = l
} else {
const q = l < 0.5 ? l * (1 + s) : l + s - l * s
const p = 2 * l - q
r = hue2rgb(p, q, h + 1 / 3)
g = hue2rgb(p, q, h)
b = hue2rgb(p, q, h - 1 / 3)
}

return [r * 255, g * 255, b * 255]
}

function hue2rgb(p: number, q: number, t: number): number {
if (t < 0) t += 1
if (t > 1) t -= 1
if (t < 1 / 6) return p + (q - p) * 6 * t
if (t < 1 / 2) return q
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6

return p
}

// function convert(strArray: string[]): Vec3[] {
// return strArray.map((str) => {
// str = str.slice(2)
// const r = parseInt(str.slice(0, 2), 16)
// const g = parseInt(str.slice(2, 4), 16)
// const b = parseInt(str.slice(4, 6), 16)
//
// return rgbToHsl([r, g, b])
// })
// }
2 changes: 1 addition & 1 deletion packages/pixel-profile/src/utils/index.ts
@@ -1,5 +1,5 @@
export { compare } from './compare'
export { getBase64FromPixels, getPixelsFromPngBuffer, getPngBufferFromPixels } from './converter'
export { getBase64FromPixels, getPixelsFromPngBuffer, getPngBufferFromPixels, hslToRgb, rgbToHsl } from './converter'
export { add2, clamp, dot2, kFormatter, prod2, subtract2, type Vec2 } from './math'
export { type Rank, rank } from './rank'
export { request } from './request'
Expand Down
12 changes: 12 additions & 0 deletions packages/pixel-profile/src/utils/math.ts
Expand Up @@ -53,6 +53,12 @@ export function mix3(v1: Vec3 | RGBA, v2: Vec3 | RGBA, t: number): Vec3 {
return [v1[0] * (1 - t) + v2[0] * t, v1[1] * (1 - t) + v2[1] * t, v1[2] * (1 - t) + v2[2] * t]
}

export function floor3(pixel: Vec3 | RGBA): RGBA {
const [r, g, b, a = 255] = pixel

return [Math.floor(r), Math.floor(g), Math.floor(b), a]
}

export function fract(x: number): number {
return x - Math.floor(x)
}
Expand All @@ -66,3 +72,9 @@ export function smoothstep(edge0: number, edge1: number, x: number): number {
export function luminance(color: Vec3 | RGBA): number {
return dot3(color, [0.2126, 0.7152, 0.0722])
}

export function pow(pixel: Vec3 | RGBA, exponent: number): RGBA {
const [r, g, b, a = 255] = pixel

return [Math.pow(r, exponent), Math.pow(g, exponent), Math.pow(b, exponent), a]
}
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file not shown.
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file not shown.
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
41 changes: 30 additions & 11 deletions packages/pixel-profile/test/theme.test.ts
Expand Up @@ -53,21 +53,32 @@ describe('Theme', () => {
expect(png).toMatchImageSnapshot()
})

it('Render card with journey theme and dithering', async () => {
const png = await renderStats(
{ ...stats, avatarUrl: DARK_GREEN_AVATAR },
{ theme: 'journey', pixelateAvatar: false, dithering: true }
)
expect(png).toMatchImageSnapshot()
})

it('Render card with fuji theme', async () => {
const png = await renderStats({ ...stats, avatarUrl: LUCI_AVATAR }, { theme: 'fuji', pixelateAvatar: false })
expect(png).toMatchImageSnapshot()
})

it('Render card with road trip theme', async () => {
it('Render card with fuji theme and dithering', async () => {
const png = await renderStats(
{ ...stats, avatarUrl: PIXEL_DOG_AVATAR },
{ theme: 'road_trip', pixelateAvatar: false }
{ ...stats, avatarUrl: LUCI_AVATAR },
{ theme: 'fuji', pixelateAvatar: false, dithering: true }
)
expect(png).toMatchImageSnapshot()
})

it('Render card with green phosphor theme', async () => {
const png = await renderStats({ ...stats, avatarUrl: PURPLE_AVATAR }, { theme: 'green_phosphor' })
it('Render card with road trip theme', async () => {
const png = await renderStats(
{ ...stats, avatarUrl: PIXEL_DOG_AVATAR },
{ theme: 'road_trip', pixelateAvatar: false }
)
expect(png).toMatchImageSnapshot()
})
})
Expand Down Expand Up @@ -119,6 +130,14 @@ describe('Theme with screen effect', () => {
expect(png).toMatchImageSnapshot()
})

it('Render card with journey theme and dithering', async () => {
const png = await renderStats(
{ ...stats, avatarUrl: DARK_GREEN_AVATAR },
{ theme: 'journey', pixelateAvatar: false, dithering: true, screenEffect: true }
)
expect(png).toMatchImageSnapshot()
})

it('Render card with fuji theme', async () => {
const png = await renderStats(
{ ...stats, avatarUrl: LUCI_AVATAR },
Expand All @@ -127,18 +146,18 @@ describe('Theme with screen effect', () => {
expect(png).toMatchImageSnapshot()
})

it('Render card with road trip theme', async () => {
it('Render card with fuji theme and dithering', async () => {
const png = await renderStats(
{ ...stats, avatarUrl: PIXEL_DOG_AVATAR },
{ theme: 'road_trip', pixelateAvatar: false, screenEffect: true }
{ ...stats, avatarUrl: LUCI_AVATAR },
{ theme: 'fuji', pixelateAvatar: false, dithering: true, screenEffect: true }
)
expect(png).toMatchImageSnapshot()
})

it('Render card with green phosphor theme', async () => {
it('Render card with road trip theme', async () => {
const png = await renderStats(
{ ...stats, avatarUrl: PURPLE_AVATAR },
{ theme: 'green_phosphor', screenEffect: true }
{ ...stats, avatarUrl: PIXEL_DOG_AVATAR },
{ theme: 'road_trip', pixelateAvatar: false, screenEffect: true }
)
expect(png).toMatchImageSnapshot()
})
Expand Down

0 comments on commit 01a6c6e

Please sign in to comment.