Skip to content

Commit e873dbf

Browse files
authored
Feature/improve cpc plus mode0 hue diversity (#311)
* feat(quantization): improve hue diversity for CPC Plus mode 0 - Reduce hue bucket size from 45° to 30° (12 families instead of 8) - Lower minimum hue distance from 35° to 25° for more nuances - Remove mega-family grouping to allow one representative per bucket - Update tests to reflect new behavior This allows mode 0 (16 colors) to use a much more diverse palette covering all major hues: red, orange, yellow, chartreuse, green, cyan-green, cyan, blue-cyan, blue, violet, magenta, and pink. * feat(resize): add Cover (crop to fill) resize mode - Add new 'cover' resize mode that scales image to fill target dimensions while cropping excess (like CSS object-fit: cover) - Image is centered, cropping is symmetric on both sides - Preserves source image aspect ratio - Update quantization to use croppedImageAtom for all modes (better color sampling at high resolution) - Support cover mode in EGX and Mode R pipelines - Update UI with new radio button option
1 parent 9e8c5dc commit e873dbf

File tree

12 files changed

+233
-69
lines changed

12 files changed

+233
-69
lines changed

src/app/store/config/resize-types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export type CPCMode = 0 | 1 | 2
1616
export type ResizeMode =
1717
| 'auto' // Smart resize with CPC aspect ratio correction (RECOMMENDED)
1818
| 'origin' // Keep original selection size (pixel-perfect, no scaling)
19+
| 'cover' // Scale to fill target dimensions, cropping excess (crop cover)
1920

2021
/**
2122
* Resize configuration (simplified - no manual dimensions)

src/app/store/preview/egx/egx-image.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,8 +78,8 @@ export const egxNormalizedImageAtom = atom(async (get) => {
7878
// 2. Resize based on mode
7979
let resizedImageData: ImageData
8080

81-
if (resizeMode === 'origin') {
82-
// Mode origin: use applyResize which handles the pixel ratio
81+
if (resizeMode === 'origin' || resizeMode === 'cover') {
82+
// Mode origin/cover: use applyResize which handles the pixel ratio
8383
const relativeSelection: Selection = {
8484
sx: 0,
8585
sy: 0,

src/app/store/preview/mode-r/mode-r-image.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,7 @@ export const modeRSourceImageAtom = atom(async (get) => {
169169
// In 'origin' mode, use the cropped image BEFORE the standard resize pipeline
170170
// because the standard pipeline compresses to Mode 0 dimensions (160×200)
171171
// but Mode R needs the full 320×200 resolution
172-
// In 'auto' mode, use resizedImageAtom (NOT smoothedImageAtom) to skip horizontal smoothing
172+
// In 'auto' and 'cover' modes, use resizedImageAtom (NOT smoothedImageAtom) to skip horizontal smoothing
173173
// Mode R has its own sub-pixel resolution, horizontal smoothing would blur it
174174
const sourceImage =
175175
resizeMode === 'origin'
@@ -200,7 +200,7 @@ export const modeRSourceImageAtom = atom(async (get) => {
200200
// Resize to Mode R target dimensions
201201
// Use different resize strategy based on resize mode:
202202
// - origin: pixel-perfect 1:1 mapping (like Mode 1)
203-
// - auto: fit with aspect ratio preservation
203+
// - auto/cover: fit with aspect ratio preservation (cover already cropped by resizedImageAtom)
204204
const modeRImage =
205205
resizeMode === 'origin'
206206
? resizeForModeROrigin(

src/app/store/preview/pipeline/preview-image.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,10 +41,10 @@ export const normalizedImageAtom = atom(async (get) => {
4141

4242
if (!processed) return null
4343

44-
// In origin mode, image is already at correct CPC dimensions
44+
// In origin and cover modes, image is already at correct CPC dimensions
4545
// In auto mode, normalize to CPC dimensions
4646
const normalized =
47-
resizeMode === 'origin'
47+
resizeMode === 'origin' || resizeMode === 'cover'
4848
? processed
4949
: getVisualRegionNormalized(processed, modeConfig)
5050

@@ -162,7 +162,7 @@ export const previewImageAtom = atom(async (get) => {
162162
normalized.height
163163
)
164164

165-
// Positioning for auto mode (origin handles its own centering)
165+
// Positioning for auto mode (origin and cover handle their own positioning)
166166
// In auto mode, getVisualRegionNormalized returns variable-sized ImageData (scaledW × scaledH)
167167
// that must be placed in canvas at target size (160x200 or 320x200)
168168
if (resizeMode === 'auto') {

src/app/store/preview/pipeline/quantization-source.spec.ts

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -48,16 +48,34 @@ describe('quantizationSourceImageAtom', () => {
4848
}
4949
})
5050

51-
it('should use smoothedImageAtom in auto mode', async () => {
51+
it('should use croppedImageAtom in auto mode for better color sampling', async () => {
52+
// All modes now use croppedImageAtom for consistent high-resolution
53+
// color sampling. This ensures auto and cover modes produce
54+
// identical palettes for images that fit CPC proportions.
5255
store.set(resizeModeAtom, 'auto')
5356

5457
const sourceImage = await store.get(quantizationSourceImageAtom)
5558

56-
// In auto mode, we expect the full smoothed image (160x200)
59+
// In auto mode, we now use the cropped image (same as origin)
60+
// for better color sampling at high resolution
5761
expect(sourceImage).toBeDefined()
5862
if (sourceImage) {
59-
expect(sourceImage.width).toBe(160)
60-
expect(sourceImage.height).toBe(200)
63+
expect(sourceImage.width).toBe(2)
64+
expect(sourceImage.height).toBe(5)
65+
}
66+
})
67+
68+
it('should use croppedImageAtom in cover mode for consistent palette', async () => {
69+
// Cover mode should produce the same palette as auto for images
70+
// that exactly match CPC proportions
71+
store.set(resizeModeAtom, 'cover')
72+
73+
const sourceImage = await store.get(quantizationSourceImageAtom)
74+
75+
expect(sourceImage).toBeDefined()
76+
if (sourceImage) {
77+
expect(sourceImage.width).toBe(2)
78+
expect(sourceImage.height).toBe(5)
6179
}
6280
})
6381
})

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

Lines changed: 8 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -20,14 +20,13 @@ import {
2020
autoDistinctMappingAtom,
2121
cpcHardwareAtom,
2222
effectiveModeConfigAtom,
23-
paletteStrategyAtom,
24-
resizeModeAtom
23+
paletteStrategyAtom
2524
} from '../../config/config'
2625
import {
2726
lockedEmptySlotsCountAtom,
2827
lockedVectorsAtom
2928
} from '../../palette/palette'
30-
import { croppedImageAtom, smoothedImageAtom } from './image-pipeline'
29+
import { croppedImageAtom } from './image-pipeline'
3130

3231
// ============================================================================
3332
// BUFFER EXTRACTION
@@ -38,19 +37,14 @@ import { croppedImageAtom, smoothedImageAtom } from './image-pipeline'
3837
*
3938
* IMPORTANT: In 'origin' mode, we use croppedImageAtom (before resize/padding)
4039
* to avoid the black padding pixels from dominating the palette.
41-
* In 'auto' mode, we use smoothedImageAtom (post-resize, post-smooth).
40+
* In 'auto' and 'cover' modes, we also use croppedImageAtom for better color sampling
41+
* at high resolution. This ensures consistent palette extraction regardless of resize mode.
4242
*/
4343
export const quantizationSourceImageAtom = atom(async (get) => {
44-
const resizeMode = get(resizeModeAtom)
45-
46-
// In origin mode, use cropped image BEFORE padding to get true colors
47-
// This prevents black padding from dominating the palette
48-
if (resizeMode === 'origin') {
49-
return await get(croppedImageAtom)
50-
}
51-
52-
// In auto mode, use smoothed image (standard behavior)
53-
return await get(smoothedImageAtom)
44+
// Use cropped image (before resize) for all modes
45+
// This provides better color sampling at high resolution
46+
// and ensures consistent results between auto/cover for images that fit perfectly
47+
return await get(croppedImageAtom)
5448
})
5549

5650
/**

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

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,9 +38,9 @@ export function ResizeSettingsView({
3838
</h3>
3939
<p className={styles.description}>
4040
<Trans>
41-
Auto: redimensionnement intelligent avec correction du ratio CPC
42-
(recommandé). Origin: conserve la taille de sélection originale
43-
(pixel-perfect, pas de mise à l'échelle).
41+
Auto: redimensionne pour que l'image tienne entièrement
42+
(recommandé). Cover: remplit les dimensions cibles en rognant
43+
l'excédent. Origin: conserve la taille originale (pixel-perfect).
4444
</Trans>
4545
</p>
4646

@@ -60,6 +60,13 @@ export function ResizeSettingsView({
6060
onChange={() => onResizeModeChange('auto')}
6161
label={t`Auto (Smart CPC adapt)`}
6262
/>
63+
<Radio
64+
name='resizeMode'
65+
value='cover'
66+
checked={resizeMode === 'cover'}
67+
onChange={() => onResizeModeChange('cover')}
68+
label={t`Cover (Crop to fill)`}
69+
/>
6370
<Radio
6471
name='resizeMode'
6572
value='origin'

src/libs/pixsaur-adapter/adapters/__tests__/regl-quantizer-mode0-selection.spec.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,13 +31,13 @@ vi.mock('@/core', async (importOriginal) => {
3131
}
3232
})
3333

34-
// Constantes de regl-quantizer.ts (version c244923)
34+
// Constantes de color-selection-helpers.ts
3535
const CPC_MODE_1_MAX_COLORS = 4
3636
const SATURATION_THRESHOLD_FOR_HUE = 0.2
3737
const SATURATION_THRESHOLD_HIGH = 0.3
3838
const DELTA_MIN_FOR_HUE = 0.01
39-
const HUE_BUCKET_SIZE_DEGREES = 45
40-
const MIN_HUE_DISTANCE_MODE_0 = 30
39+
const HUE_BUCKET_SIZE_DEGREES = 30 // 12 familles pour plus de diversité
40+
const MIN_HUE_DISTANCE_MODE_0 = 25 // Réduit pour permettre plus de nuances
4141
const MIN_RGB_DISTANCE_MODE_0 = 20
4242
const MIN_RGB_DISTANCE_MODE_1_2 = 80
4343
const HUE_HALF_RANGE = 180

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

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -58,8 +58,8 @@ describe('color-selection-helpers', () => {
5858
expect(MIN_RGB_DISTANCE_MODE_1_2).toBe(200)
5959
})
6060

61-
it('should export MIN_HUE_DISTANCE_MODE_0 as 35', () => {
62-
expect(MIN_HUE_DISTANCE_MODE_0).toBe(35)
61+
it('should export MIN_HUE_DISTANCE_MODE_0 as 25', () => {
62+
expect(MIN_HUE_DISTANCE_MODE_0).toBe(25)
6363
})
6464

6565
it('should export SATURATION_THRESHOLD_FOR_HUE as 0.2', () => {
@@ -70,8 +70,8 @@ describe('color-selection-helpers', () => {
7070
expect(DELTA_MIN_FOR_HUE).toBe(0.01)
7171
})
7272

73-
it('should export HUE_BUCKET_SIZE_DEGREES as 45', () => {
74-
expect(HUE_BUCKET_SIZE_DEGREES).toBe(45)
73+
it('should export HUE_BUCKET_SIZE_DEGREES as 30', () => {
74+
expect(HUE_BUCKET_SIZE_DEGREES).toBe(30)
7575
})
7676
})
7777

@@ -389,11 +389,12 @@ describe('color-selection-helpers', () => {
389389
// selectBucketRepresentativesWithLightness
390390
// ============================================================================
391391
describe('selectBucketRepresentativesWithLightness', () => {
392-
it('should limit representatives per mega-family', () => {
393-
// Buckets 0 and 1 are in mega-family 0
392+
it('should select one representative per bucket for maximum hue diversity', () => {
393+
// Each bucket now gets its own representative (no mega-families)
394+
// With 12 buckets (30° each), this provides better hue coverage for mode 0
394395
const buckets: HueBucket[] = [
395396
{
396-
bucket: 0, // Mega-family 0
397+
bucket: 0, // Rouge
397398
colors: [
398399
{
399400
index: 0,
@@ -405,7 +406,7 @@ describe('color-selection-helpers', () => {
405406
totalFreq: 0.5
406407
},
407408
{
408-
bucket: 1, // Mega-family 0 (same as bucket 0)
409+
bucket: 1, // Orange
409410
colors: [
410411
{
411412
index: 1,
@@ -417,13 +418,13 @@ describe('color-selection-helpers', () => {
417418
totalFreq: 0.4
418419
},
419420
{
420-
bucket: 2, // Mega-family 1
421+
bucket: 2, // Jaune
421422
colors: [
422423
{
423424
index: 2,
424425
frequency: 0.3,
425-
color: [0, 255, 0],
426-
converted: [0, 255, 0]
426+
color: [255, 255, 0],
427+
converted: [255, 255, 0]
427428
}
428429
],
429430
totalFreq: 0.3
@@ -432,11 +433,11 @@ describe('color-selection-helpers', () => {
432433

433434
const reps = selectBucketRepresentativesWithLightness(buckets, 10)
434435

435-
// Should skip bucket 1 because mega-family 0 is already represented
436-
expect(reps.length).toBe(2)
436+
// All three buckets should be represented (no mega-family restrictions)
437+
expect(reps.length).toBe(3)
437438
expect(reps.map((r) => r.index)).toContain(0)
439+
expect(reps.map((r) => r.index)).toContain(1)
438440
expect(reps.map((r) => r.index)).toContain(2)
439-
expect(reps.map((r) => r.index)).not.toContain(1)
440441
})
441442

442443
it('should allow gray bucket to have representatives', () => {

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

Lines changed: 13 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,8 @@ export const MIN_RGB_DISTANCE_MODE_0 = 100
4040
/** Distance RGB minimale pour modes 1-2 (2-4 couleurs) - contraste plus élevé */
4141
export const MIN_RGB_DISTANCE_MODE_1_2 = 200
4242

43-
/** Distance de teinte minimale pour mode 0 (degrés) - augmenté pour plus de diversité */
44-
export const MIN_HUE_DISTANCE_MODE_0 = 35
43+
/** Distance de teinte minimale pour mode 0 (degrés) - réduit pour permettre plus de nuances */
44+
export const MIN_HUE_DISTANCE_MODE_0 = 25
4545

4646
/** Seuil de saturation pour considérer une couleur comme "saturée" */
4747
export const SATURATION_THRESHOLD_FOR_HUE = 0.2
@@ -52,8 +52,8 @@ export const SATURATION_THRESHOLD_HIGH = 0.3
5252
/** Delta minimum pour calcul de teinte (évite les erreurs sur les gris) */
5353
export const DELTA_MIN_FOR_HUE = 0.01
5454

55-
/** Taille des buckets de teinte en degrés (~8 familles: 360/45 = 8) */
56-
export const HUE_BUCKET_SIZE_DEGREES = 45
55+
/** Taille des buckets de teinte en degrés (~12 familles: 360/30 = 12) */
56+
export const HUE_BUCKET_SIZE_DEGREES = 30
5757

5858
/** Demi-range de teinte pour normalisation */
5959
export const HUE_HALF_RANGE = 180
@@ -367,37 +367,26 @@ export function selectBucketRepresentativesWithLightness(
367367
): ColorFrequencyItem[] {
368368
const representatives: ColorFrequencyItem[] = []
369369
const bucketIndices: number[] = [] // Pour savoir de quel bucket vient chaque rep
370-
const usedMegaFamilies = new Set<number>() // Méga-familles déjà utilisées
371-
372-
// Définir les méga-familles (regroupement de 2 buckets adjacents = 90°)
373-
// Bucket 0-1 (0-90°) = rouge/orange/jaune
374-
// Bucket 2-3 (90-180°) = vert/cyan
375-
// Bucket 4-5 (180-270°) = cyan/bleu
376-
// Bucket 6-7 (270-360°) = violet/magenta/rouge
377-
const getMegaFamily = (bucket: number | 'gray'): number => {
378-
if (bucket === 'gray') return -1 // Gray est sa propre famille
379-
return Math.floor(bucket / 2) // 0-1→0, 2-3→1, 4-5→2, 6-7→3
380-
}
370+
const usedBuckets = new Set<number | 'gray'>() // Buckets déjà utilisés
381371

382-
// Phase 1: Sélectionner le plus fréquent de chaque bucket, mais limiter les méga-familles
372+
// Phase 1: Sélectionner le plus fréquent de chaque bucket distinct
373+
// Avec 12 buckets (30° chacun), on couvre bien les 16 couleurs du mode 0
374+
// Buckets: 0=rouge, 1=orange, 2=jaune, 3=chartreuse, 4=vert, 5=cyan-vert,
375+
// 6=cyan, 7=bleu-cyan, 8=bleu, 9=violet, 10=magenta, 11=rose
383376
for (let i = 0; i < sortedBuckets.length; i++) {
384377
const { bucket, colors } = sortedBuckets[i]
385378
if (representatives.length >= maxRepresentatives || colors.length === 0)
386379
continue
387380

388-
const megaFamily = getMegaFamily(bucket)
389-
390-
// Gray peut avoir plusieurs représentants (nuances de gris importantes)
391-
// Les autres méga-familles : max 1 représentant dans les 8 premiers
392-
if (megaFamily !== -1 && usedMegaFamilies.has(megaFamily)) {
381+
// Un représentant par bucket (pas de méga-familles)
382+
// Cela permet plus de diversité de teintes
383+
if (usedBuckets.has(bucket)) {
393384
continue
394385
}
395386

396387
representatives.push(colors[0])
397388
bucketIndices.push(i)
398-
if (megaFamily !== -1) {
399-
usedMegaFamilies.add(megaFamily)
400-
}
389+
usedBuckets.add(bucket)
401390
}
402391

403392
// Compter les couleurs claires parmi les représentants

0 commit comments

Comments
 (0)