1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
//! Python-compatible Munsell converter
//! This module integrates the exact 1:1 Python ports for accurate conversion
use crate::error::{MunsellError, Result};
use crate::munsell_color_science::*;
use crate::color_notation_parser::*;
use crate::types::{MunsellColor, RgbColor};
/// Python-compatible Munsell converter using exact colour-science algorithms
pub struct PythonMunsellConverter;
impl PythonMunsellConverter {
/// Create a new Python-compatible converter
pub fn new() -> Self {
Self
}
/// Convert sRGB to Munsell notation using Python-compatible algorithm
pub fn srgb_to_munsell(&self, rgb: [u8; 3]) -> Result<MunsellColor> {
// Convert sRGB to linear RGB
let rgb_linear = self.srgb_to_linear(rgb);
// Convert to XYZ using D65
let xyz = self.linear_rgb_to_xyz_d65(rgb_linear);
// Convert to xyY
let xyy = self.xyz_to_xyy(xyz);
// Y is already in 0-1 range from sRGB conversion
// Convert to Munsell specification using Python algorithm
let spec = xyy_to_munsell_specification(xyy)?;
// Convert specification to MunsellColor
self.specification_to_munsell_color(spec)
}
/// Convert Munsell notation to sRGB using Python-compatible algorithm
pub fn munsell_to_srgb(&self, munsell: &str) -> Result<RgbColor> {
// Parse Munsell notation using 1:1 ported function
let spec = munsell_colour_to_munsell_specification(munsell)?;
// Convert to xyY
let xyy = munsell_specification_to_xyy(&spec)?;
// Y is in 0-1 range, keep it that way
// Convert to XYZ
let xyz = self.xyy_to_xyz(xyy);
// Convert to linear RGB
let rgb_linear = self.xyz_to_linear_rgb_d65(xyz);
// Convert to sRGB
let rgb = self.linear_to_srgb(rgb_linear);
Ok(RgbColor { r: rgb[0], g: rgb[1], b: rgb[2] })
}
// Helper functions for color space conversions
fn srgb_to_linear(&self, rgb: [u8; 3]) -> [f64; 3] {
let mut linear = [0.0; 3];
for i in 0..3 {
let c = rgb[i] as f64 / 255.0;
linear[i] = if c <= 0.04045 {
c / 12.92
} else {
((c + 0.055) / 1.055).powf(2.4)
};
}
linear
}
fn linear_to_srgb(&self, linear: [f64; 3]) -> [u8; 3] {
let mut rgb = [0u8; 3];
for i in 0..3 {
let c = linear[i];
let srgb = if c <= 0.0031308 {
12.92 * c
} else {
1.055 * c.powf(1.0 / 2.4) - 0.055
};
rgb[i] = (srgb * 255.0).round().clamp(0.0, 255.0) as u8;
}
rgb
}
fn linear_rgb_to_xyz_d65(&self, rgb: [f64; 3]) -> [f64; 3] {
// sRGB to XYZ matrix (D65 illuminant)
let matrix = [
[0.4124564, 0.3575761, 0.1804375],
[0.2126729, 0.7151522, 0.0721750],
[0.0193339, 0.1191920, 0.9503041],
];
let xyz_unscaled = [
matrix[0][0] * rgb[0] + matrix[0][1] * rgb[1] + matrix[0][2] * rgb[2],
matrix[1][0] * rgb[0] + matrix[1][1] * rgb[1] + matrix[1][2] * rgb[2],
matrix[2][0] * rgb[0] + matrix[2][1] * rgb[1] + matrix[2][2] * rgb[2],
];
// Python's colour library scales XYZ so that white (RGB 255,255,255) has Y=1.0
// The unscaled white Y is approximately 0.9505 (sum of Y row in matrix)
// So we need to scale by 1/0.9505 ≈ 1.052
// But the exact value from colour library testing is closer to 1.1115
// This matches what we observed: Python Y=0.919160 vs Rust Y=0.826933
// Ratio = 0.919160/0.826933 = 1.1115
// After extensive testing, the colour library uses this scaling:
// const XYZ_SCALING: f64 = 1.111528762434975; // Exact ratio from test
// Actually, the Python colour library does NOT scale XYZ values
// It returns the raw XYZ values from the sRGB matrix
xyz_unscaled
}
fn xyz_to_linear_rgb_d65(&self, xyz: [f64; 3]) -> [f64; 3] {
// No scaling needed since we're not scaling in linear_rgb_to_xyz_d65 anymore
// const XYZ_SCALING: f64 = 1.111528762434975;
let xyz_unscaled = xyz;
// XYZ to sRGB matrix (D65 illuminant)
let matrix = [
[ 3.2404542, -1.5371385, -0.4985314],
[-0.9692660, 1.8760108, 0.0415560],
[ 0.0556434, -0.2040259, 1.0572252],
];
[
matrix[0][0] * xyz_unscaled[0] + matrix[0][1] * xyz_unscaled[1] + matrix[0][2] * xyz_unscaled[2],
matrix[1][0] * xyz_unscaled[0] + matrix[1][1] * xyz_unscaled[1] + matrix[1][2] * xyz_unscaled[2],
matrix[2][0] * xyz_unscaled[0] + matrix[2][1] * xyz_unscaled[1] + matrix[2][2] * xyz_unscaled[2],
]
}
fn xyz_to_xyy(&self, xyz: [f64; 3]) -> [f64; 3] {
let sum = xyz[0] + xyz[1] + xyz[2];
if sum.abs() < 1e-10 {
// Return D65 white point for black
[0.31271, 0.32902, 0.0]
} else {
[xyz[0] / sum, xyz[1] / sum, xyz[1]]
}
}
fn xyy_to_xyz(&self, xyy: [f64; 3]) -> [f64; 3] {
let (x, y, big_y) = (xyy[0], xyy[1], xyy[2]);
if y.abs() < 1e-10 {
[0.0, 0.0, 0.0]
} else {
let big_x = x * big_y / y;
let big_z = (1.0 - x - y) * big_y / y;
[big_x, big_y, big_z]
}
}
fn specification_to_munsell_color(&self, spec: [f64; 4]) -> Result<MunsellColor> {
let hue_num = spec[0];
let value = spec[1];
let chroma = spec[2];
let code = spec[3] as u8;
// Handle achromatic case
if chroma < 1e-6 || hue_num.is_nan() {
return Ok(MunsellColor::new_neutral(value));
}
// Convert code to family using Python's mapping (1-10 codes)
let family = match code {
1 => "B",
2 => "BG",
3 => "G",
4 => "GY",
5 => "Y",
6 => "YR",
7 => "R",
8 => "RP",
9 => "P",
10 => "PB",
_ => return Err(MunsellError::ConversionError {
message: format!("Invalid hue code: {}", code)
}),
};
// Format hue string with proper precision
let hue_str = if hue_num == hue_num.floor() {
format!("{}{}", hue_num as i32, family)
} else {
format!("{:.1}{}", hue_num, family)
};
Ok(MunsellColor::new_chromatic(hue_str, value, chroma))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_python_converter_basic() {
let converter = PythonMunsellConverter::new();
// Test black conversion
println!("Testing black [0, 0, 0] (should be N 0.0)...");
match converter.srgb_to_munsell([0, 0, 0]) {
Ok(munsell) => {
println!("Black: {}", munsell.notation);
println!(" Expected: N 0.0");
}
Err(e) => {
println!("Error converting black: {:?}", e);
}
}
// Test red conversion
println!("\nTesting red [255, 0, 0]...");
match converter.srgb_to_munsell([255, 0, 0]) {
Ok(munsell) => {
println!("Red: {}", munsell.notation);
println!(" Hue: {:?}, Value: {:.1}, Chroma: {:?}",
munsell.hue, munsell.value, munsell.chroma);
}
Err(e) => {
println!("Error converting red: {:?}", e);
// Try to directly call the algorithm to see what spec it returns
let rgb_linear = converter.srgb_to_linear([255, 0, 0]);
let xyz = converter.linear_rgb_to_xyz_d65(rgb_linear);
let xyy = converter.xyz_to_xyy(xyz);
println!(" xyY: [{:.6}, {:.6}, {:.6}]", xyy[0], xyy[1], xyy[2]);
// Call the algorithm directly with debug output
println!(" Calling xyy_to_munsell_specification with xyY: [{:.6}, {:.6}, {:.6}]",
xyy[0], xyy[1], xyy[2]);
// First check what value we get
let value = munsell_value_astmd1535(xyy[2] * 100.0);
println!(" Munsell value from Y={:.6}: {:.6}", xyy[2], value);
match xyy_to_munsell_specification(xyy) {
Ok(spec) => {
println!(" Raw spec from algorithm: hue={:.2}, value={:.2}, chroma={:.2}, code={}",
spec[0], spec[1], spec[2], spec[3] as u8);
// Test if we can convert it back to xyY
println!(" Testing round-trip conversion...");
match munsell_specification_to_xyy(&spec) {
Ok(xyy_back) => {
println!(" Round-trip xyY: [{:.6}, {:.6}, {:.6}]",
xyy_back[0], xyy_back[1], xyy_back[2]);
}
Err(e) => {
println!(" Round-trip failed: {:?}", e);
}
}
}
Err(e) => {
println!(" Algorithm error: {:?}", e);
}
}
}
}
// First test some basic conversions
println!("\nTesting basic conversions...");
// Test grey conversion
println!("Testing grey specification [NaN, 5.0, NaN, NaN]...");
match munsell_specification_to_xyy(&[f64::NAN, 5.0, f64::NAN, f64::NAN]) {
Ok(xyy) => {
println!(" Grey at value 5.0 -> xyY: [{:.6}, {:.6}, {:.6}]",
xyy[0], xyy[1], xyy[2]);
}
Err(e) => {
println!(" Error: {:?}", e);
}
}
// Test with a reference color from the dataset
println!("\nTesting with reference color [0, 68, 119] (should be 2.9PB 2.8/7.0)...");
match converter.srgb_to_munsell([0, 68, 119]) {
Ok(munsell) => {
println!("Result: {}", munsell.notation);
println!(" Hue: {:?}, Value: {:.1}, Chroma: {:?}",
munsell.hue, munsell.value, munsell.chroma);
println!(" Expected: 2.9PB 2.8/7.0");
}
Err(e) => {
println!("Error: {:?}", e);
// Test the algorithm directly
let rgb_linear = converter.srgb_to_linear([0, 68, 119]);
let xyz = converter.linear_rgb_to_xyz_d65(rgb_linear);
let xyy = converter.xyz_to_xyy(xyz);
println!(" xyY: [{:.6}, {:.6}, {:.6}]", xyy[0], xyy[1], xyy[2]);
}
}
// Test round trip with exact reference value
println!("\nTesting round trip with 2.9PB 2.8/7.0...");
match converter.munsell_to_srgb("2.9PB 2.8/7.0") {
Ok(rgb) => {
println!("2.9PB 2.8/7.0 -> RGB: [{}, {}, {}]", rgb.r, rgb.g, rgb.b);
println!(" Expected: [0, 68, 119]");
}
Err(e) => {
println!("Error: {:?}", e);
}
}
// Test round trip with standard value
println!("\nTesting round trip with 5R 5/10...");
match converter.munsell_to_srgb("5R 5/10") {
Ok(rgb) => {
println!("5R 5/10 -> RGB: [{}, {}, {}]", rgb.r, rgb.g, rgb.b);
}
Err(e) => {
println!("Error: {:?}", e);
}
}
}
}