@@ -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+
3274interface 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+
205263export 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' ) }
0 commit comments