use crate::constants::{get_color_ish, get_achromatic_color_number, is_achromatic_hue, get_color_by_number, color_entry_to_metadata, get_polygon_definitions};
use crate::error::MunsellError;
use crate::mechanical_wedges::MechanicalWedgeSystem;
use geo::Polygon;
use std::collections::HashMap;
use std::sync::{Arc, RwLock};
#[derive(Debug, Clone)]
pub struct ColorMetadata {
pub iscc_nbs_color_name: String,
pub iscc_nbs_formatter: Option<String>,
pub alt_color_name: String,
pub color_shade: String,
}
impl ColorMetadata {
pub fn iscc_nbs_descriptor(&self) -> String {
if let Some(formatter) = &self.iscc_nbs_formatter {
Self::construct_descriptor(formatter, &self.iscc_nbs_color_name)
} else {
self.iscc_nbs_color_name.clone()
}
}
pub fn alt_color_descriptor(&self) -> String {
if let Some(formatter) = &self.iscc_nbs_formatter {
Self::construct_descriptor(formatter, &self.alt_color_name)
} else {
self.alt_color_name.clone()
}
}
pub fn shade(&self) -> &str {
&self.color_shade
}
pub fn construct_descriptor(formatter: &str, color_name: &str) -> String {
let color_name_ish = get_color_ish(color_name);
formatter
.replace("{0}", color_name)
.replace("{1}", color_name_ish)
}
}
#[derive(Debug, Clone)]
pub struct IsccNbsColor {
pub color_number: u16,
pub polygon_group: u8,
pub hue_range: (String, String),
pub polygon: Polygon<f64>,
}
pub struct IsccNbsClassifier {
pub wedge_system: crate::mechanical_wedges::MechanicalWedgeSystem,
color_metadata: HashMap<u16, ColorMetadata>,
cache: Arc<RwLock<HashMap<(String, i32, i32), Option<u16>>>>,
cache_max_size: usize,
}
impl IsccNbsClassifier {
pub fn new() -> Result<Self, MunsellError> {
let (colors, color_metadata) = Self::load_embedded_iscc_data()?;
let mut wedge_system = MechanicalWedgeSystem::new();
for polygon in colors {
wedge_system.distribute_polygon(polygon)?;
}
Ok(IsccNbsClassifier {
wedge_system,
color_metadata,
cache: Arc::new(RwLock::new(HashMap::new())),
cache_max_size: 256,
})
}
pub fn from_csv(csv_path: &str) -> Result<Self, MunsellError> {
let (colors, color_metadata) = Self::load_iscc_data(csv_path)?;
let mut wedge_system = MechanicalWedgeSystem::new();
for polygon in colors {
wedge_system.distribute_polygon(polygon)?;
}
Ok(IsccNbsClassifier {
wedge_system,
color_metadata,
cache: Arc::new(RwLock::new(HashMap::new())),
cache_max_size: 256,
})
}
#[inline]
fn is_achromatic(&self, hue: &str) -> bool {
is_achromatic_hue(hue)
}
#[inline]
fn get_achromatic_color_number(&self, value: f64) -> Option<u16> {
let color_number = get_achromatic_color_number(value)?;
if self.color_metadata.contains_key(&color_number) {
Some(color_number)
} else {
None
}
}
#[inline]
fn classify_achromatic(&self, value: f64) -> Option<u16> {
self.get_achromatic_color_number(value)
}
#[inline]
fn build_result(&self, color_number: u16) -> Option<ColorMetadata> {
self.color_metadata.get(&color_number).cloned()
}
pub fn classify_munsell(
&self,
hue: &str,
value: f64,
chroma: f64,
) -> Result<Option<ColorMetadata>, MunsellError> {
if self.is_achromatic(hue) {
if let Some(color_number) = self.classify_achromatic(value) {
return Ok(self.build_result(color_number));
}
return Ok(None);
}
let rounded_value = (value * 10000.0).round() / 10000.0;
let rounded_chroma = (chroma * 10000.0).round() / 10000.0;
let cache_key = (
hue.to_string(), (rounded_value * 10000.0) as i32,
(rounded_chroma * 10000.0) as i32,
);
{
let cache = self.cache.read().unwrap();
if let Some(&cached_color_number) = cache.get(&cache_key) {
return Ok(cached_color_number.and_then(|num| self.build_result(num)));
}
}
if let Some(color) = self
.wedge_system
.classify_color(hue, rounded_value, rounded_chroma)
{
self.cache_result(cache_key, Some(color.color_number));
return Ok(self.build_result(color.color_number));
}
self.cache_result(cache_key, None);
Ok(None)
}
pub fn find_all_colors_at_point(
&self,
hue: &str,
value: f64,
chroma: f64,
) -> Result<Vec<u16>, MunsellError> {
if self.is_achromatic(hue) {
if let Some(color_number) = self.get_achromatic_color_number(value) {
return Ok(vec![color_number]);
}
return Ok(vec![]);
}
let rounded_value = (value * 10000.0).round() / 10000.0;
let rounded_chroma = (chroma * 10000.0).round() / 10000.0;
let colors = self
.wedge_system
.find_all_colors_at_point(hue, rounded_value, rounded_chroma);
Ok(colors)
}
pub fn classify(
&self,
hue: &str,
value: f64,
chroma: f64,
) -> Option<ColorMetadata> {
self.classify_munsell(hue, value, chroma).ok().flatten()
}
pub fn classify_with_details(
&self,
hue: &str,
value: f64,
chroma: f64,
) -> Option<(ColorMetadata, String)> {
match self.classify_munsell(hue, value, chroma) {
Ok(Some(metadata)) => {
let details = format!("Classified Munsell {}:{}/{} successfully", hue, value, chroma);
Some((metadata, details))
}
Ok(None) => None,
Err(_) => None,
}
}
fn cache_result(&self, key: (String, i32, i32), result: Option<u16>) {
let mut cache = self.cache.write().unwrap();
if cache.len() >= self.cache_max_size {
if let Some(first_key) = cache.keys().next().cloned() {
cache.remove(&first_key);
}
}
cache.insert(key, result);
}
pub fn get_polygon_in_wedge(
&self,
hue: &str,
expected_descriptor: &str,
) -> Option<&IsccNbsColor> {
let wedge_key = self.wedge_system.find_wedge_for_hue(hue)?;
self.wedge_system
.get_wedge_polygons(&wedge_key)?
.iter()
.find(|polygon| {
if let Some(metadata) = self.color_metadata.get(&polygon.color_number) {
let constructed_descriptor = metadata.iscc_nbs_descriptor();
constructed_descriptor.to_lowercase() == expected_descriptor.to_lowercase()
} else {
false
}
})
}
pub fn classify_munsell_color(
&self,
munsell: &crate::types::MunsellColor,
) -> Result<Option<ColorMetadata>, MunsellError> {
if let (Some(hue), Some(chroma)) = (&munsell.hue, munsell.chroma) {
self.classify_munsell(hue, munsell.value, chroma)
} else {
Ok(None)
}
}
#[deprecated(
since = "1.2.0",
note = "Use ColorClassifier::classify_srgb() for unified color naming. This method will be removed in v2.0.0."
)]
pub fn classify_srgb(&self, rgb: [u8; 3]) -> Result<Option<ColorMetadata>, MunsellError> {
use crate::MunsellConverter;
let converter = MunsellConverter::new()?;
let munsell = converter.srgb_to_munsell(rgb)?;
self.classify_munsell_color(&munsell)
}
#[deprecated(
since = "1.2.0",
note = "Use ColorClassifier::classify_lab() for unified color naming. This method will be removed in v2.0.0."
)]
pub fn classify_lab(&self, lab: [f64; 3]) -> Result<Option<ColorMetadata>, MunsellError> {
use crate::MunsellConverter;
let converter = MunsellConverter::new()?;
let munsell = converter.lab_to_munsell(lab)?;
self.classify_munsell_color(&munsell)
}
#[deprecated(
since = "1.2.0",
note = "Use ColorClassifier::classify_hex() for unified color naming. This method will be removed in v2.0.0."
)]
#[allow(deprecated)] pub fn classify_hex(&self, hex: &str) -> Result<Option<ColorMetadata>, MunsellError> {
let rgb = self.parse_hex_to_rgb(hex)?;
self.classify_srgb(rgb)
}
fn parse_hex_to_rgb(&self, hex: &str) -> Result<[u8; 3], MunsellError> {
let hex = hex.trim_start_matches('#');
let rgb = if hex.len() == 3 {
let r = u8::from_str_radix(&hex[0..1].repeat(2), 16).map_err(|_| {
MunsellError::ConversionError {
message: format!("Invalid hex color format: {}", hex),
}
})?;
let g = u8::from_str_radix(&hex[1..2].repeat(2), 16).map_err(|_| {
MunsellError::ConversionError {
message: format!("Invalid hex color format: {}", hex),
}
})?;
let b = u8::from_str_radix(&hex[2..3].repeat(2), 16).map_err(|_| {
MunsellError::ConversionError {
message: format!("Invalid hex color format: {}", hex),
}
})?;
[r, g, b]
} else if hex.len() == 6 {
let r =
u8::from_str_radix(&hex[0..2], 16).map_err(|_| MunsellError::ConversionError {
message: format!("Invalid hex color format: {}", hex),
})?;
let g =
u8::from_str_radix(&hex[2..4], 16).map_err(|_| MunsellError::ConversionError {
message: format!("Invalid hex color format: {}", hex),
})?;
let b =
u8::from_str_radix(&hex[4..6], 16).map_err(|_| MunsellError::ConversionError {
message: format!("Invalid hex color format: {}", hex),
})?;
[r, g, b]
} else {
return Err(MunsellError::ConversionError {
message: format!(
"Invalid hex color length: {}. Expected 3 or 6 characters after #",
hex.len()
),
});
};
Ok(rgb)
}
fn data_error<S: Into<String>>(msg: S) -> MunsellError {
MunsellError::ReferenceDataError {
message: msg.into(),
}
}
fn parse_embedded_polygon_data() -> Result<Vec<IsccNbsColor>, MunsellError> {
use geo::{Coord, LineString};
let mut colors = Vec::new();
for polygon_def in get_polygon_definitions() {
if polygon_def.points.len() < 3 {
return Err(Self::data_error(format!(
"Insufficient points for polygon: color {} polygon_group {} has {} points",
polygon_def.color_number,
polygon_def.polygon_group,
polygon_def.points.len()
)));
}
let mut coords: Vec<Coord<f64>> = polygon_def.points.iter()
.map(|p| Coord { x: p.chroma, y: p.value })
.collect();
if !coords.is_empty() && coords.first() != coords.last() {
coords.push(coords[0]);
}
let exterior = LineString::new(coords);
let polygon = geo::Polygon::new(exterior, vec![]);
colors.push(IsccNbsColor {
color_number: polygon_def.color_number,
polygon_group: polygon_def.polygon_group,
hue_range: (polygon_def.hue1.to_string(), polygon_def.hue2.to_string()),
polygon,
});
}
Ok(colors)
}
fn load_embedded_iscc_data() -> Result<(Vec<IsccNbsColor>, HashMap<u16, ColorMetadata>), MunsellError> {
let polygons = Self::parse_embedded_polygon_data()?;
let mut color_metadata: HashMap<u16, ColorMetadata> = HashMap::new();
for &color_number in crate::constants::get_all_color_numbers().iter() {
if let Some(entry) = get_color_by_number(color_number) {
let metadata = color_entry_to_metadata(entry);
color_metadata.insert(color_number, metadata);
}
}
Ok((polygons, color_metadata))
}
fn load_iscc_data(
polygon_csv_path: &str,
) -> Result<(Vec<IsccNbsColor>, HashMap<u16, ColorMetadata>), MunsellError> {
use std::fs;
use std::path::Path;
let polygon_csv_content =
fs::read_to_string(polygon_csv_path).map_err(|e| MunsellError::ReferenceDataError {
message: format!("Failed to read polygon CSV file: {}", e),
})?;
let polygon_path = Path::new(polygon_csv_path);
let color_csv_path = polygon_path
.parent()
.unwrap_or(Path::new("."))
.join("ISCC-NBS-Colors.csv");
let color_csv_content =
fs::read_to_string(&color_csv_path).map_err(|e| MunsellError::ReferenceDataError {
message: format!(
"Failed to read color metadata CSV file {}: {}",
color_csv_path.display(),
e
),
})?;
let polygons = Self::parse_polygon_csv_data(&polygon_csv_content)?;
let color_metadata = Self::parse_color_metadata_csv(&color_csv_content)?;
Ok((polygons, color_metadata))
}
fn parse_color_metadata_csv(
csv_content: &str,
) -> Result<HashMap<u16, ColorMetadata>, MunsellError> {
use csv::Reader;
let mut reader = Reader::from_reader(csv_content.as_bytes());
let mut color_metadata: HashMap<u16, ColorMetadata> = HashMap::new();
for result in reader.records() {
let record = result.map_err(|e| MunsellError::ReferenceDataError {
message: format!("CSV parsing error: {}", e),
})?;
let color_number: u16 = record
.get(0)
.ok_or_else(|| Self::data_error("Missing color_number".to_string()))?
.parse()
.map_err(|e| Self::data_error(format!("Invalid color_number: {}", e)))?;
let iscc_nbs_color_name = record
.get(1)
.ok_or_else(|| Self::data_error("Missing iscc_nbs_color_name".to_string()))?
.to_string();
let iscc_nbs_formatter = record
.get(2)
.filter(|s| !s.is_empty())
.map(|s| s.to_string());
let alt_color_name = record
.get(3)
.ok_or_else(|| Self::data_error("Missing alt_color_name".to_string()))?
.to_string();
let color_shade = record
.get(4)
.ok_or_else(|| Self::data_error("Missing color_shade".to_string()))?
.to_string();
color_metadata.insert(
color_number,
ColorMetadata {
iscc_nbs_color_name,
iscc_nbs_formatter,
alt_color_name,
color_shade,
},
);
}
Ok(color_metadata)
}
fn parse_polygon_csv_data(csv_content: &str) -> Result<Vec<IsccNbsColor>, MunsellError> {
use csv::Reader;
use geo::{Coord, LineString};
let mut reader = Reader::from_reader(csv_content.as_bytes());
let mut color_groups: std::collections::HashMap<(u16, u8), Vec<(f64, f64)>> =
std::collections::HashMap::new();
let mut polygon_metadata: std::collections::HashMap<(u16, u8), (String, String)> =
std::collections::HashMap::new();
for result in reader.records() {
let record = result.map_err(|e| MunsellError::ReferenceDataError {
message: format!("CSV parsing error: {}", e),
})?;
let color_number: u16 = record
.get(0)
.ok_or_else(|| Self::data_error("Missing color_number".to_string()))?
.parse()
.map_err(|e| Self::data_error(format!("Invalid color_number: {}", e)))?;
let polygon_id: u8 = record
.get(1)
.ok_or_else(|| Self::data_error("Missing polygon_id".to_string()))?
.parse()
.map_err(|e| Self::data_error(format!("Invalid polygon_id: {}", e)))?;
let hue1 = record
.get(3)
.ok_or_else(|| Self::data_error("Missing hue1".to_string()))?
.to_string();
let hue2 = record
.get(4)
.ok_or_else(|| Self::data_error("Missing hue2".to_string()))?
.to_string();
let chroma: f64 = record
.get(5)
.ok_or_else(|| Self::data_error("Missing chroma".to_string()))?
.parse()
.map_err(|e| Self::data_error(format!("Invalid chroma: {}", e)))?;
let value: f64 = record
.get(6)
.ok_or_else(|| Self::data_error("Missing value".to_string()))?
.parse()
.map_err(|e| Self::data_error(format!("Invalid value: {}", e)))?;
let key = (color_number, polygon_id);
color_groups
.entry(key)
.or_insert_with(Vec::new)
.push((value, chroma));
if !polygon_metadata.contains_key(&key) {
polygon_metadata.insert(key, (hue1, hue2));
}
}
let mut colors = Vec::new();
for ((color_number, polygon_id), points) in color_groups {
if points.len() < 3 {
return Err(Self::data_error(format!(
"Insufficient points for polygon: color {} polygon_id {} has {} points",
color_number,
polygon_id,
points.len()
)));
}
let (hue1, hue2) = polygon_metadata
.get(&(color_number, polygon_id))
.ok_or_else(|| Self::data_error("Missing polygon metadata".to_string()))?
.clone();
let mut coords: Vec<Coord<f64>> = points
.into_iter()
.map(|(value, chroma)| Coord {
x: chroma,
y: value,
})
.collect();
if coords.first() != coords.last() {
if let Some(first) = coords.first().cloned() {
coords.push(first);
}
}
let exterior = LineString::from(coords);
let polygon = Polygon::new(exterior, vec![]);
colors.push(IsccNbsColor {
color_number,
polygon_group: polygon_id,
hue_range: (hue1, hue2),
polygon,
});
}
Ok(colors)
}
}
pub mod validation {
use super::{IsccNbsColor, ValidationError};
use geo::Intersects;
pub fn validate_polygons(colors: &[IsccNbsColor]) -> Vec<ValidationError> {
let mut errors = Vec::new();
for color in colors {
if let Err(mut angle_errors) = validate_right_angles(&color.polygon) {
for error in &mut angle_errors {
if let ValidationError::InvalidAngle { color_number, .. } = error {
*color_number = color.color_number;
}
}
errors.extend(angle_errors);
}
}
for i in 0..colors.len() {
for j in (i + 1)..colors.len() {
if colors[i].polygon.intersects(&colors[j].polygon) {
errors.push(ValidationError::Intersection {
color1: colors[i].color_number,
color2: colors[j].color_number,
});
}
}
}
errors
}
fn validate_right_angles(polygon: &geo::Polygon<f64>) -> Result<(), Vec<ValidationError>> {
let exterior = polygon.exterior();
let coords: Vec<_> = exterior.coords().collect();
if coords.len() < 4 {
return Ok(()); }
let mut errors = Vec::new();
for i in 1..coords.len() - 1 {
let p1 = coords[i - 1];
let p2 = coords[i];
let p3 = coords[i + 1];
let v1 = (p1.x - p2.x, p1.y - p2.y);
let v2 = (p3.x - p2.x, p3.y - p2.y);
let dot = v1.0 * v2.0 + v1.1 * v2.1;
let cross = v1.0 * v2.1 - v1.1 * v2.0;
let angle = cross.atan2(dot).abs() * 180.0 / std::f64::consts::PI;
let tolerance = 1.0; let is_right_angle =
(angle - 90.0).abs() < tolerance || (angle - 270.0).abs() < tolerance;
if !is_right_angle {
errors.push(ValidationError::InvalidAngle {
color_number: 0, point_index: i,
angle,
});
}
}
if errors.is_empty() {
Ok(())
} else {
Err(errors)
}
}
}
#[derive(Debug, Clone)]
pub enum ValidationError {
InvalidAngle {
color_number: u16,
point_index: usize,
angle: f64,
},
Intersection {
color1: u16,
color2: u16,
},
Gap {
hue_slice: String,
region: String,
},
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
use std::thread;
#[test]
fn test_boundary_disambiguation() {
}
#[test]
fn test_staircase_classification() {
}
#[test]
fn test_thread_safety_concurrent_classification() {
let classifier = Arc::new(IsccNbsClassifier::new().expect("Failed to create classifier"));
let mut handles = vec![];
let test_colors = vec![
("5R", 6.0, 14.0), ("10YR", 7.0, 6.0), ("5Y", 8.0, 12.0), ("5G", 5.0, 8.0), ("5B", 4.0, 6.0), ("5P", 3.0, 10.0), ("N", 5.0, 0.0), ("N", 9.0, 0.0), ("N", 2.0, 0.0), ];
for thread_id in 0..8 {
let classifier_clone = Arc::clone(&classifier);
let test_colors_clone = test_colors.clone();
let handle = thread::spawn(move || {
let mut results = Vec::new();
for iteration in 0..10 {
for (i, &(hue, value, chroma)) in test_colors_clone.iter().enumerate() {
let adjusted_value = value + (thread_id as f64 * 0.01) + (iteration as f64 * 0.001);
let adjusted_chroma = if chroma > 0.0 { chroma + (i as f64 * 0.01) } else { 0.0 };
match classifier_clone.classify_munsell(hue, adjusted_value, adjusted_chroma) {
Ok(Some(metadata)) => {
results.push((hue.to_string(), adjusted_value, adjusted_chroma, metadata.iscc_nbs_color_name.clone()));
}
Ok(None) => {
results.push((hue.to_string(), adjusted_value, adjusted_chroma, "unclassified".to_string()));
}
Err(e) => {
panic!("Classification error in thread {}: {:?}", thread_id, e);
}
}
}
}
(thread_id, results.len())
});
handles.push(handle);
}
let mut total_classifications = 0;
for handle in handles {
let (thread_id, count) = handle.join().expect("Thread panicked");
println!("Thread {} completed {} classifications", thread_id, count);
total_classifications += count;
}
let expected_total = 8 * 10 * test_colors.len(); assert_eq!(total_classifications, expected_total,
"Expected {} total classifications, got {}", expected_total, total_classifications);
}
#[test]
fn test_thread_safety_cache_behavior() {
let classifier = Arc::new(IsccNbsClassifier::new().expect("Failed to create classifier"));
let mut handles = vec![];
let test_color = ("5R", 6.0, 14.0);
for thread_id in 0..4 {
let classifier_clone = Arc::clone(&classifier);
let handle = thread::spawn(move || {
let mut cache_hits = 0;
let mut results = Vec::new();
for _ in 0..50 {
match classifier_clone.classify_munsell(test_color.0, test_color.1, test_color.2) {
Ok(Some(metadata)) => {
results.push(metadata.iscc_nbs_color_name.clone());
cache_hits += 1;
}
Ok(None) => {
}
Err(e) => {
panic!("Classification error in thread {}: {:?}", thread_id, e);
}
}
}
(thread_id, cache_hits, results)
});
handles.push(handle);
}
for handle in handles {
let (thread_id, cache_hits, results) = handle.join().expect("Thread panicked");
println!("Thread {} got {} cache hits", thread_id, cache_hits);
if !results.is_empty() {
let first_result = &results[0];
for result in &results {
assert_eq!(result, first_result,
"Thread {} got inconsistent results", thread_id);
}
}
}
}
#[test]
fn test_send_sync_traits() {
fn assert_send<T: Send>() {}
fn assert_sync<T: Sync>() {}
assert_send::<IsccNbsClassifier>();
assert_sync::<IsccNbsClassifier>();
assert_send::<Arc<IsccNbsClassifier>>();
assert_sync::<Arc<IsccNbsClassifier>>();
}
}