Skip to content

Commit dcdb9c5

Browse files
authored
feat(quantization): add color diversity slider for CPC Plus Mode 0 (#315)
- Add colorDiversityAtom (0-100, default 50) for adjustable hue diversity - Implement getColorDiversityParams() mapping slider to internal params: - minHueDistance: 10° → 45° - minRgbDistance: 50 → 150 - hueBucketSize: 45° → 20° - Add "Color Diversity" section with TuningSlider in dithering settings - Connect slider to quantization pipeline via config - Only visible for CPC Plus Mode 0 (16 colors) - Include reset button to default value (50) - Add translations for EN, DE, ES
1 parent 6297e1a commit dcdb9c5

File tree

16 files changed

+332
-71
lines changed

16 files changed

+332
-71
lines changed

src/app/store/config/config.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,9 +102,11 @@ export {
102102
// Processing
103103
export {
104104
autoDistinctMappingAtom,
105+
colorDiversityAtom,
105106
horizontalSmoothingAtom,
106107
paletteStrategyAtom,
107108
processorTypeAtom,
109+
setColorDiversityAtom,
108110
setPaletteStrategyAtom,
109111
setProcessorTypeAtom,
110112
smoothingAtom

src/app/store/config/processing.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,14 @@ export const paletteStrategyAtom = atom<PaletteStrategy>('exhaustive-contrast')
3939
*/
4040
export const autoDistinctMappingAtom = atom<boolean>(false)
4141

42+
/**
43+
* Color diversity level for CPC Plus Mode 0 quantization (0-100)
44+
* 0 = Similar shades (prioritize frequency, allow close hues)
45+
* 50 = Balanced (default)
46+
* 100 = Distinct hues (maximize color variety)
47+
*/
48+
export const colorDiversityAtom = atom<number>(50)
49+
4250
// ============================================================================
4351
// SETTERS
4452
// ============================================================================
@@ -62,3 +70,13 @@ export const setPaletteStrategyAtom = atom(
6270
set(paletteStrategyAtom, payload)
6371
}
6472
)
73+
74+
/**
75+
* Setter for color diversity
76+
*/
77+
export const setColorDiversityAtom = atom(
78+
null,
79+
(_get, set, payload: number) => {
80+
set(colorDiversityAtom, Math.max(0, Math.min(100, payload)))
81+
}
82+
)

src/app/store/preview/pipeline/quantization.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { getPaletteForHardware } from '@/palettes/cpc-palette'
1818
import { paletteProcessorAtom } from '../../adapters/processors'
1919
import {
2020
autoDistinctMappingAtom,
21+
colorDiversityAtom,
2122
cpcHardwareAtom,
2223
effectiveModeConfigAtom,
2324
paletteStrategyAtom
@@ -164,11 +165,14 @@ export const reducedPaletteRawAtom = atom(async (get) => {
164165
const paletteStrategy = get(paletteStrategyAtom)
165166
// Read autoDistinctMapping to create dependency (triggers re-processing when toggle changes)
166167
const autoDistinctMapping = get(autoDistinctMappingAtom)
168+
// Read colorDiversity to create dependency (triggers re-processing when slider changes)
169+
const colorDiversity = get(colorDiversityAtom)
167170

168171
logger.info('[Preview] Quantizing palette', {
169172
targetColors,
170173
paletteStrategy,
171174
autoDistinctMapping,
175+
colorDiversity,
172176
hardware: cpcHardware
173177
})
174178

src/components/settings-panel/sections/dithering-settings/dithering-settings-view.tsx

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,13 @@
22
* Dithering settings view (dumb component)
33
*/
44

5+
import { msg } from '@lingui/core/macro'
6+
import { useLingui } from '@lingui/react'
57
import { Trans } from '@lingui/react/macro'
68
import { useId } from 'react'
79
import Flex from '@/components/ui/flex'
810
import { Switch } from '@/components/ui/switch'
11+
import { TuningSlider } from '../../shared/tuning-slider'
912
import styles from '../../tabs/tab.module.css'
1013
import { DitheringControls } from './dithering-controls'
1114
import { PaletteStrategySelector } from './palette-strategy-selector/palette-strategy-selector'
@@ -21,6 +24,9 @@ type DitheringSettingsViewProps = Readonly<{
2124
sourceUniqueColors: number | null
2225
rasterEnabled: boolean
2326
isPaletteStrategyDisabled: boolean
27+
showColorDiversity: boolean
28+
colorDiversity: number
29+
onColorDiversityChange: (value: number) => void
2430
}>
2531

2632
export function DitheringSettingsView({
@@ -33,10 +39,14 @@ export function DitheringSettingsView({
3339
isDistinctMappingDisabled,
3440
sourceUniqueColors,
3541
rasterEnabled,
36-
isPaletteStrategyDisabled
42+
isPaletteStrategyDisabled,
43+
showColorDiversity,
44+
colorDiversity,
45+
onColorDiversityChange
3746
}: DitheringSettingsViewProps) {
3847
const smoothingId = useId()
3948
const distinctMappingId = useId()
49+
const { _ } = useLingui()
4050

4151
return (
4252
<>
@@ -60,6 +70,52 @@ export function DitheringSettingsView({
6070
<PaletteStrategySelector />
6171
</div>
6272

73+
{showColorDiversity && (
74+
<>
75+
<div className={styles.separator} />
76+
77+
<div className={styles.section}>
78+
<h3 className={styles.sectionTitle}>
79+
<Trans>Diversité des couleurs</Trans>
80+
</h3>
81+
<p className={styles.description}>
82+
<Trans>
83+
Ajustez l'équilibre entre fidélité aux couleurs fréquentes et
84+
variété des teintes (CPC Plus Mode 0).
85+
</Trans>
86+
</p>
87+
88+
<TuningSlider
89+
min={0}
90+
max={100}
91+
step={1}
92+
value={colorDiversity}
93+
defaultValue={50}
94+
onChange={onColorDiversityChange}
95+
label={_(
96+
msg({
97+
id: 'color.diversity.label',
98+
message: 'Diversité'
99+
})
100+
)}
101+
description={_(
102+
msg({
103+
id: 'color.diversity.description',
104+
message: 'Nuances similaires (0) ◄► Teintes distinctes (100)'
105+
})
106+
)}
107+
format={(v) => `${v}`}
108+
resetTitle={_(
109+
msg({
110+
id: 'color.diversity.reset',
111+
message: 'Réinitialiser à 50'
112+
})
113+
)}
114+
/>
115+
</div>
116+
</>
117+
)}
118+
63119
<div className={styles.separator} />
64120

65121
<div

src/components/settings-panel/sections/dithering-settings/dithering-settings.tsx

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,12 @@
55
import { useAtom, useAtomValue, useSetAtom } from 'jotai'
66
import {
77
autoDistinctMappingAtom,
8+
colorDiversityAtom,
89
cpcHardwareAtom,
910
effectiveModeConfigAtom,
1011
horizontalSmoothingAtom,
11-
setAutoDistinctMappingAtom
12+
setAutoDistinctMappingAtom,
13+
setColorDiversityAtom
1214
} from '@/app/store/config/config'
1315
import { sourceUniqueColorsCountAtom } from '@/app/store/preview/preview'
1416
import { rasterEnabledAtom } from '@/app/store/raster/raster'
@@ -21,6 +23,8 @@ export function DitheringSettings() {
2123
)
2224
const autoDistinctMapping = useAtomValue(autoDistinctMappingAtom)
2325
const setAutoDistinctMapping = useSetAtom(setAutoDistinctMappingAtom)
26+
const colorDiversity = useAtomValue(colorDiversityAtom)
27+
const setColorDiversity = useSetAtom(setColorDiversityAtom)
2428
const rasterEnabled = useAtomValue(rasterEnabledAtom)
2529
const isPaletteStrategyDisabled = usePaletteStrategyDisabled()
2630
const cpcHardware = useAtomValue(cpcHardwareAtom)
@@ -39,6 +43,10 @@ export function DitheringSettings() {
3943
const isDistinctMappingActive =
4044
autoDistinctMapping && showDistinctMapping && !isDistinctMappingDisabled
4145

46+
// Color diversity slider: only for CPC Plus Mode 0 (16 colors)
47+
const showColorDiversity =
48+
cpcHardware === 'plus' && modeConfig.nColors === 16 && !rasterEnabled
49+
4250
return (
4351
<DitheringSettingsView
4452
horizontalSmoothing={horizontalSmoothing}
@@ -51,6 +59,9 @@ export function DitheringSettings() {
5159
sourceUniqueColors={sourceUniqueColors}
5260
rasterEnabled={rasterEnabled}
5361
isPaletteStrategyDisabled={isPaletteStrategyDisabled}
62+
showColorDiversity={showColorDiversity}
63+
colorDiversity={colorDiversity}
64+
onColorDiversityChange={setColorDiversity}
5465
/>
5566
)
5667
}

src/libs/pixsaur-adapter/adapters/color-selection-helpers.ts

Lines changed: 64 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,55 @@ export const HUE_BONUS_WEIGHT = 2
6868
export const VALUE_THRESHOLD_BRIGHT = 0.5
6969

7070
/** Nombre minimum de couleurs claires parmi les représentants de bucket */
71+
72+
// ============================================================================
73+
// Color Diversity Configuration
74+
// ============================================================================
75+
76+
/**
77+
* Parameters derived from the color diversity slider (0-100)
78+
*/
79+
export interface ColorDiversityParams {
80+
/** Minimum hue distance in degrees (10-45) */
81+
minHueDistance: number
82+
/** Minimum RGB distance (50-150) */
83+
minRgbDistance: number
84+
/** Hue bucket size in degrees (20-45) */
85+
hueBucketSize: number
86+
}
87+
88+
/**
89+
* Maps the color diversity slider (0-100) to internal quantization parameters.
90+
*
91+
* - 0 = "Similar shades": Allows more similar colors, prioritizes frequency
92+
* - 50 = "Balanced": Current default behavior
93+
* - 100 = "Distinct hues": Maximizes color variety
94+
*
95+
* @param diversity - Slider value from 0 to 100
96+
* @returns Parameters for color selection algorithms
97+
*/
98+
export function getColorDiversityParams(
99+
diversity: number
100+
): ColorDiversityParams {
101+
// Clamp to 0-100
102+
const d = Math.max(0, Math.min(100, diversity))
103+
104+
// Linear interpolation for each parameter
105+
// diversity 0 -> 100 maps to:
106+
// - minHueDistance: 10° -> 45° (low = allow similar hues, high = require distinct)
107+
// - minRgbDistance: 50 -> 150 (low = allow close RGB, high = require contrast)
108+
// - hueBucketSize: 45° -> 20° (low = fewer families, high = more families)
109+
110+
const minHueDistance = 10 + (d / 100) * 35 // 10 to 45
111+
const minRgbDistance = 50 + (d / 100) * 100 // 50 to 150
112+
const hueBucketSize = 45 - (d / 100) * 25 // 45 to 20
113+
114+
return {
115+
minHueDistance: Math.round(minHueDistance),
116+
minRgbDistance: Math.round(minRgbDistance),
117+
hueBucketSize: Math.round(hueBucketSize)
118+
}
119+
}
71120
export const MIN_BRIGHT_BUCKET_REPRESENTATIVES = 2
72121

73122
// ============================================================================
@@ -115,24 +164,28 @@ export interface HueBucket {
115164
* @param frequencyBudget - Nombre max de couleurs à sélectionner
116165
* @param targetColors - Nombre total de couleurs cible (pour détecter mode 0 vs 1-2)
117166
* @param calculateDistance - Fonction de distance RGB
167+
* @param diversityParams - Optional diversity parameters from slider
118168
*/
119169
export function selectFrequentColorsWithDiversity(
120170
colorFrequency: ColorFrequencyItem[],
121171
selectedConverted: Vector[],
122172
result: number[],
123173
frequencyBudget: number,
124174
targetColors: number | undefined,
125-
calculateDistance: DistanceFunction
175+
calculateDistance: DistanceFunction,
176+
diversityParams?: ColorDiversityParams
126177
): void {
127178
// Distance minimale adaptative selon la taille de la palette cible
179+
// Use diversity params if provided, otherwise use defaults
128180
const minDistance =
129181
targetColors && targetColors <= CPC_MODE_1_MAX_COLORS
130182
? MIN_RGB_DISTANCE_MODE_1_2
131-
: MIN_RGB_DISTANCE_MODE_0
183+
: (diversityParams?.minRgbDistance ?? MIN_RGB_DISTANCE_MODE_0)
132184

133185
// Pour le mode 0, également exiger une diversité de teinte
134186
const isMode0 = targetColors && targetColors > CPC_MODE_1_MAX_COLORS
135-
const minHueDistance = MIN_HUE_DISTANCE_MODE_0
187+
const minHueDistance =
188+
diversityParams?.minHueDistance ?? MIN_HUE_DISTANCE_MODE_0
136189

137190
for (
138191
let i = 1;
@@ -209,7 +262,8 @@ export function selectMaxMinDistanceColors(
209262
selectedConverted: Vector[],
210263
result: number[],
211264
targetColors: number,
212-
calculateDistance: DistanceFunction
265+
calculateDistance: DistanceFunction,
266+
_diversityParams?: ColorDiversityParams
213267
): void {
214268
const remaining = colorFrequency.filter((c) => !result.includes(c.index))
215269
const additionalColors = targetColors - result.length
@@ -283,21 +337,24 @@ export function selectMaxMinDistanceColors(
283337
* Utilise des buckets de 45° (~8 familles principales + gris)
284338
*
285339
* @param colorFrequency - Couleurs candidates
340+
* @param diversityParams - Optional diversity parameters from slider
286341
* @returns Map des buckets avec leurs couleurs
287342
*/
288343
export function createHueBuckets(
289-
colorFrequency: ColorFrequencyItem[]
344+
colorFrequency: ColorFrequencyItem[],
345+
diversityParams?: ColorDiversityParams
290346
): Map<number | 'gray', ColorFrequencyItem[]> {
291347
const hueBuckets = new Map<number | 'gray', ColorFrequencyItem[]>()
348+
const bucketSize = diversityParams?.hueBucketSize ?? HUE_BUCKET_SIZE_DEGREES
292349

293350
for (const candidate of colorFrequency) {
294351
const sat = calculateSaturation(candidate.converted)
295352
const hue = calculateHue(candidate.converted, DELTA_MIN_FOR_HUE)
296353

297-
// Buckets pour avoir ~8 familles principales
354+
// Buckets pour avoir ~8-12 familles principales depending on diversity
298355
const bucketKey: number | 'gray' =
299356
sat > SATURATION_THRESHOLD_FOR_HUE && hue >= 0
300-
? Math.floor(hue / HUE_BUCKET_SIZE_DEGREES)
357+
? Math.floor(hue / bucketSize)
301358
: 'gray'
302359

303360
if (!hueBuckets.has(bucketKey)) {

src/libs/pixsaur-adapter/adapters/regl-processor.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import type REGL from 'regl'
99
// Import pour accéder à l'atome de stratégie de palette et auto distinct-mapping
1010
import {
1111
autoDistinctMappingAtom,
12+
colorDiversityAtom,
1213
paletteStrategyAtom
1314
} from '@/app/store/config/config'
1415
import { adapterLogger, paletteLogger } from '@/core'
@@ -769,6 +770,7 @@ export class ReGLProcessor implements ImageProcessor {
769770
paletteStrategy:
770771
paletteStrategy || getDefaultStore().get(paletteStrategyAtom),
771772
autoDistinctMapping: getDefaultStore().get(autoDistinctMappingAtom),
773+
colorDiversity: getDefaultStore().get(colorDiversityAtom),
772774
gpuOptions: {
773775
minPixelsForGPU: 128 * 128 // GPU avantageux pour images moyennes+
774776
}

0 commit comments

Comments
 (0)