Skip to content

Commit 0d4b5cd

Browse files
committed
feat(pixelate): add standalone merge-nearby-colors tab
Made-with: Cursor
1 parent 11732b8 commit 0d4b5cd

2 files changed

Lines changed: 145 additions & 2 deletions

File tree

frontend/src/components/ImagePixelate.tsx

Lines changed: 121 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,48 @@ function f(t: number): number {
2929
return t > 0.008856 ? t ** (1 / 3) : 7.787 * t + 16 / 116
3030
}
3131

32+
/** 与上方 rgbToLab 同一套线性 RGB → Lab 定义的可逆变换 */
33+
function fInv(ft: number): number {
34+
const cube = ft * ft * ft
35+
return cube > 0.008856 ? cube : (ft - 16 / 116) / 7.787
36+
}
37+
38+
function labToRgb(l: number, a: number, b: number): [number, number, number] {
39+
const fy = (l + 16) / 116
40+
const fx = a / 500 + fy
41+
const fz = fy - b / 200
42+
const rLin = fInv(fx) * (95.047 / 100)
43+
const gLin = fInv(fy)
44+
const bLin = fInv(fz) * (108.883 / 100)
45+
const toSrgbByte = (u: number) => {
46+
const c = Math.max(0, Math.min(1, u))
47+
const x = c <= 0.0031308 ? c * 12.92 : 1.055 * c ** (1 / 2.4) - 0.055
48+
return Math.max(0, Math.min(255, Math.round(x * 255)))
49+
}
50+
return [toSrgbByte(rLin), toSrgbByte(gLin), toSrgbByte(bLin)]
51+
}
52+
53+
/** 在 LAB 空间按网格对齐相近颜色;strength 0~100,越大合并越强 */
54+
function applyMergeNearbyLab(imageData: ImageData, strength: number): void {
55+
if (strength <= 0) return
56+
const t = strength / 100
57+
const stepL = 2 + t * 26
58+
const stepA = 1.5 + t * 14
59+
const stepB = 1.5 + t * 14
60+
const d = imageData.data
61+
for (let i = 0; i < d.length; i += 4) {
62+
if (d[i + 3]! < 128) continue
63+
let [L, la, lb] = rgbToLab(d[i]!, d[i + 1]!, d[i + 2]!)
64+
L = Math.round(L / stepL) * stepL
65+
la = Math.round(la / stepA) * stepA
66+
lb = Math.round(lb / stepB) * stepB
67+
const [r, g, b] = labToRgb(L, la, lb)
68+
d[i] = r
69+
d[i + 1] = g
70+
d[i + 2] = b
71+
}
72+
}
73+
3274
interface Color16Options {
3375
method: 'rgb' | 'lab'
3476
dither: boolean
@@ -202,14 +244,31 @@ function pixelateImage(img: HTMLImageElement, pixelSize: number): Promise<Blob>
202244
})
203245
}
204246

247+
function mergeNearbyColorsImage(img: HTMLImageElement, strength: number): Promise<Blob> {
248+
const w = img.naturalWidth
249+
const h = img.naturalHeight
250+
const canvas = document.createElement('canvas')
251+
canvas.width = w
252+
canvas.height = h
253+
const ctx = canvas.getContext('2d')!
254+
ctx.drawImage(img, 0, 0)
255+
const imageData = ctx.getImageData(0, 0, w, h)
256+
applyMergeNearbyLab(imageData, strength)
257+
ctx.putImageData(imageData, 0, 0)
258+
return new Promise<Blob>((resolve, reject) => {
259+
canvas.toBlob((b) => (b ? resolve(b) : reject(new Error('blob'))), 'image/png')
260+
})
261+
}
262+
205263
export default function ImagePixelate() {
206264
const { t } = useLanguage()
207-
const [activeTab, setActiveTab] = useState<'pixelate' | 'color16' | 'advanced'>('pixelate')
265+
const [activeTab, setActiveTab] = useState<'pixelate' | 'mergeNearby' | 'color16' | 'advanced'>('pixelate')
208266
const [color16Method, setColor16Method] = useState<'rgb' | 'lab'>('lab')
209267
const [color16Dither, setColor16Dither] = useState(true)
210268
const [file, setFile] = useState<File | null>(null)
211269
const [originalUrl, setOriginalUrl] = useState<string | null>(null)
212270
const [pixelSize, setPixelSize] = useState(8)
271+
const [mergeNearbyStrength, setMergeNearbyStrength] = useState(40)
213272
const [advUpscale, setAdvUpscale] = useState(5)
214273
const [advColors, setAdvColors] = useState(32)
215274
const [advScaleResult, setAdvScaleResult] = useState(1)
@@ -298,6 +357,38 @@ export default function ImagePixelate() {
298357
}
299358
}
300359

360+
const runMergeNearby = async () => {
361+
if (!file) return
362+
if (mergeNearbyStrength <= 0) {
363+
message.warning(t('pixelateMergeNearbyNeedStrength'))
364+
return
365+
}
366+
setLoading(true)
367+
setResultUrl((old) => {
368+
if (old) URL.revokeObjectURL(old)
369+
return null
370+
})
371+
setResultBlob(null)
372+
try {
373+
const url = URL.createObjectURL(file)
374+
const img = await new Promise<HTMLImageElement>((res, rej) => {
375+
const i = new Image()
376+
i.onload = () => res(i)
377+
i.onerror = () => rej(new Error('load'))
378+
i.src = url
379+
})
380+
URL.revokeObjectURL(url)
381+
const blob = await mergeNearbyColorsImage(img, mergeNearbyStrength)
382+
setResultBlob(blob)
383+
setResultUrl(URL.createObjectURL(blob))
384+
message.success(t('pixelateMergeNearbySuccess'))
385+
} catch (e) {
386+
message.error(t('pixelateMergeNearbyFailed') + ': ' + String(e))
387+
} finally {
388+
setLoading(false)
389+
}
390+
}
391+
301392
const run16Color = async () => {
302393
if (!file) return
303394
setLoading(true)
@@ -339,7 +430,7 @@ export default function ImagePixelate() {
339430
<Space direction="vertical" size="large" style={{ width: '100%' }}>
340431
<Tabs
341432
activeKey={activeTab}
342-
onChange={(k) => setActiveTab(k as 'pixelate' | 'color16' | 'advanced')}
433+
onChange={(k) => setActiveTab(k as 'pixelate' | 'mergeNearby' | 'color16' | 'advanced')}
343434
items={[
344435
{
345436
key: 'pixelate',
@@ -364,6 +455,29 @@ export default function ImagePixelate() {
364455
</>
365456
),
366457
},
458+
{
459+
key: 'mergeNearby',
460+
label: t('pixelateTabMergeNearby'),
461+
children: (
462+
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
463+
<Text type="secondary" style={{ display: 'block' }}>{t('pixelateMergeNearbyModuleHint')}</Text>
464+
<div>
465+
<Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>{t('pixelateMergeNearbyStrength')}</Text>
466+
<Space wrap>
467+
<Slider
468+
min={1}
469+
max={100}
470+
value={mergeNearbyStrength}
471+
onChange={setMergeNearbyStrength}
472+
style={{ width: 200, marginRight: 16 }}
473+
/>
474+
<InputNumber min={1} max={100} value={mergeNearbyStrength} onChange={(v) => setMergeNearbyStrength(v ?? 40)} style={{ width: 90 }} />
475+
</Space>
476+
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginTop: 4 }}>{t('pixelateMergeNearbyHint')}</Text>
477+
</div>
478+
</Space>
479+
),
480+
},
367481
{
368482
key: 'color16',
369483
label: t('pixelateTab16Color'),
@@ -501,6 +615,11 @@ export default function ImagePixelate() {
501615
{t('pixelateApply')}
502616
</Button>
503617
)}
618+
{activeTab === 'mergeNearby' && (
619+
<Button type="primary" loading={loading} onClick={runMergeNearby} disabled={!file}>
620+
{t('pixelateMergeNearbyApply')}
621+
</Button>
622+
)}
504623
{activeTab === 'color16' && (
505624
<Button type="primary" loading={loading} onClick={run16Color} disabled={!file}>
506625
{t('pixelate16ColorApply')}

frontend/src/i18n/locales.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -602,6 +602,14 @@ export const locales: Record<Lang, Record<string, string>> = {
602602
pixelateHint: '上传图片,设置像素块大小,生成像素风效果',
603603
pixelateSize: '像素块大小',
604604
pixelateSizeHint: '数值越大,像素块越粗',
605+
pixelateTabMergeNearby: '合并临近色',
606+
pixelateMergeNearbyModuleHint: '独立功能:不改变分辨率与像素块,仅在原图上按感知距离合并相近颜色,适合压平渐变与杂色。',
607+
pixelateMergeNearbyStrength: '合并强度',
608+
pixelateMergeNearbyHint: '在 LAB 空间对齐颜色,数值越大合并越强',
609+
pixelateMergeNearbyApply: '应用合并',
610+
pixelateMergeNearbySuccess: '合并临近色完成',
611+
pixelateMergeNearbyFailed: '合并临近色失败',
612+
pixelateMergeNearbyNeedStrength: '请将合并强度设为大于 0',
605613
pixelateApply: '像素化',
606614
pixelateSuccess: '像素化完成',
607615
pixelateFailed: '像素化失败',
@@ -1523,6 +1531,14 @@ export const locales: Record<Lang, Record<string, string>> = {
15231531
pixelateHint: 'Upload image, set pixel block size, generate pixel art effect',
15241532
pixelateSize: 'Pixel block size',
15251533
pixelateSizeHint: 'Larger value = coarser blocks',
1534+
pixelateTabMergeNearby: 'Merge nearby colors',
1535+
pixelateMergeNearbyModuleHint: 'Standalone: keeps resolution; merges similar colors in LAB space. Good for flattening gradients and noise.',
1536+
pixelateMergeNearbyStrength: 'Merge strength',
1537+
pixelateMergeNearbyHint: 'LAB grid snap; higher merges more aggressively',
1538+
pixelateMergeNearbyApply: 'Apply merge',
1539+
pixelateMergeNearbySuccess: 'Merge nearby colors done',
1540+
pixelateMergeNearbyFailed: 'Merge nearby colors failed',
1541+
pixelateMergeNearbyNeedStrength: 'Set merge strength above 0',
15261542
pixelateApply: 'Pixelate',
15271543
pixelateSuccess: 'Pixelation complete',
15281544
pixelateFailed: 'Pixelation failed',
@@ -2436,6 +2452,14 @@ export const locales: Record<Lang, Record<string, string>> = {
24362452
pixelateHint: '画像をアップロードし、ピクセルブロックサイズを設定してピクセル風に変換',
24372453
pixelateSize: 'ピクセルブロックサイズ',
24382454
pixelateSizeHint: '大きいほど粗いブロックに',
2455+
pixelateTabMergeNearby: '近い色をまとめる',
2456+
pixelateMergeNearbyModuleHint: '単体機能:解像度やピクセルブロックは変えず、LAB 上で近い色を統合。グラデーションやノイズの平坦化向け。',
2457+
pixelateMergeNearbyStrength: '統合の強さ',
2458+
pixelateMergeNearbyHint: 'LAB の格子に寄せるほど強くまとまります',
2459+
pixelateMergeNearbyApply: '適用',
2460+
pixelateMergeNearbySuccess: '近い色の統合が完了しました',
2461+
pixelateMergeNearbyFailed: '近い色の統合に失敗しました',
2462+
pixelateMergeNearbyNeedStrength: '強さを 0 より大きくしてください',
24392463
pixelateApply: 'ピクセル化',
24402464
pixelateSuccess: 'ピクセル化完了',
24412465
pixelateFailed: 'ピクセル化失敗',

0 commit comments

Comments
 (0)