chromaframe-sdk 0.1.1

Deterministic, privacy-preserving color measurement and ranking SDK
Documentation
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::collections::BTreeSet;
use std::fmt;
use thiserror::Error;

#[derive(Debug, Error, PartialEq)]
pub enum SdkError {
    #[error("explicit consent is required")]
    ConsentRequired,
    #[error("{field} must be finite and within [{min}, {max}], got {value}")]
    NumericRange {
        field: &'static str,
        value: f32,
        min: f32,
        max: f32,
    },
    #[error("image bytes must be non-empty")]
    EmptyImage,
    #[error("at least one candidate color is required")]
    EmptyCandidates,
    #[error("candidate color '{name}' has invalid sRGB channel")]
    InvalidCandidateColor { name: String },
    #[error("candidate '{candidate}' intervention is not allowed")]
    InterventionNotAllowed { candidate: String },
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub enum ConsentState {
    ExplicitlyGranted,
}

#[derive(
    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
)]
pub enum AllowedIntervention {
    Clothing,
    Accessory,
    Background,
    Makeup,
    HairColor,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub enum MeasurementStatus {
    Complete,
    InsufficientData,
}

#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, JsonSchema)]
pub enum MeasurementMode {
    IccNormalized,
    SrgbAssumed,
    ApparentColorProfileUnsupported,
    AllowedApparentFallback,
}

impl MeasurementMode {
    #[must_use]
    pub const fn factor(self) -> f32 {
        match self {
            Self::IccNormalized => 1.00,
            Self::SrgbAssumed => 0.94,
            Self::ApparentColorProfileUnsupported => 0.78,
            Self::AllowedApparentFallback => 0.70,
        }
    }
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct GoalVector {
    pub contrast_target: f32,
    pub warmth_target: f32,
    pub chroma_target: f32,
    pub feature_readability_target: f32,
    pub artificiality_tolerance: f32,
}

impl GoalVector {
    pub fn parse(self) -> Result<Self, SdkError> {
        finite_range("contrast_target", self.contrast_target, 0.0, 1.0)?;
        finite_range("warmth_target", self.warmth_target, -1.0, 1.0)?;
        finite_range("chroma_target", self.chroma_target, 0.0, 1.0)?;
        finite_range(
            "feature_readability_target",
            self.feature_readability_target,
            0.0,
            1.0,
        )?;
        finite_range(
            "artificiality_tolerance",
            self.artificiality_tolerance,
            0.0,
            1.0,
        )?;
        Ok(self)
    }
}

impl Default for GoalVector {
    fn default() -> Self {
        Self {
            contrast_target: 0.30,
            warmth_target: 0.0,
            chroma_target: 0.45,
            feature_readability_target: 0.30,
            artificiality_tolerance: 0.5,
        }
    }
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct CandidateColor {
    pub name: String,
    pub srgb: [u8; 3],
    pub intervention: AllowedIntervention,
}

impl CandidateColor {
    #[must_use]
    pub fn new(name: impl Into<String>, srgb: [u8; 3], intervention: AllowedIntervention) -> Self {
        Self {
            name: name.into(),
            srgb,
            intervention,
        }
    }
}

#[derive(Clone, Serialize, Deserialize, JsonSchema)]
pub struct AnalyzeRequest {
    pub consent: ConsentState,
    #[serde(skip_serializing)]
    pub image_bytes: Vec<u8>,
    pub goal_vector: GoalVector,
    pub candidates: Vec<CandidateColor>,
    pub allowed_interventions: BTreeSet<AllowedIntervention>,
    pub retain_metadata: bool,
    pub allow_apparent_color_fallback: bool,
}

impl fmt::Debug for AnalyzeRequest {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter
            .debug_struct("AnalyzeRequest")
            .field("consent", &self.consent)
            .field(
                "image_bytes",
                &format_args!("[REDACTED; len={}]", self.image_bytes.len()),
            )
            .field("goal_vector", &self.goal_vector)
            .field("candidates", &self.candidates)
            .field("allowed_interventions", &self.allowed_interventions)
            .field("retain_metadata", &self.retain_metadata)
            .field(
                "allow_apparent_color_fallback",
                &self.allow_apparent_color_fallback,
            )
            .finish()
    }
}

impl AnalyzeRequest {
    pub fn parse(self) -> Result<Self, SdkError> {
        if self.consent != ConsentState::ExplicitlyGranted {
            return Err(SdkError::ConsentRequired);
        }
        if self.image_bytes.is_empty() {
            return Err(SdkError::EmptyImage);
        }
        if self.candidates.is_empty() {
            return Err(SdkError::EmptyCandidates);
        }
        self.goal_vector.clone().parse()?;
        for candidate in &self.candidates {
            if candidate.name.trim().is_empty() {
                return Err(SdkError::InvalidCandidateColor {
                    name: candidate.name.clone(),
                });
            }
            if !self.allowed_interventions.contains(&candidate.intervention) {
                return Err(SdkError::InterventionNotAllowed {
                    candidate: candidate.name.clone(),
                });
            }
        }
        Ok(Self {
            retain_metadata: false,
            ..self
        })
    }
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(tag = "status", rename_all = "snake_case")]
pub enum QualityCheck<T> {
    Measured { value: T, deduction: f32 },
    NotMeasured { reason: String, deduction: f32 },
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct CaptureQualityReport {
    pub decode_status: String,
    pub orientation_applied: bool,
    pub dimensions: (u32, u32),
    pub icc_status: String,
    pub metadata_retained: bool,
    pub over_clip_fraction: f32,
    pub under_clip_fraction: f32,
    pub white_balance: QualityCheck<f32>,
    pub blur: QualityCheck<f32>,
    pub shadow: QualityCheck<f32>,
    pub face_angle: QualityCheck<f32>,
    pub filters_or_makeup: QualityCheck<bool>,
    pub occlusion: QualityCheck<bool>,
    pub calibration_card: QualityCheck<bool>,
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct SubjectColorProfile {
    pub skin_lab: crate::color::Lab,
    pub skin_ita: f32,
    pub skin_depth_proxy: f32,
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct ContrastMap {
    pub contrasts: Vec<crate::color::DirectionalDelta>,
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct CandidateRanking {
    pub name: String,
    pub score: f32,
    pub confidence: f32,
    pub harshness: f32,
    pub label: String,
    pub components: crate::score::ScoreComponents,
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct EvidenceCard {
    pub kind: String,
    pub summary: String,
    pub confidence: f32,
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct AgentBrief {
    pub summary: String,
    pub uncertainty: String,
}

pub fn finite_range(field: &'static str, value: f32, min: f32, max: f32) -> Result<(), SdkError> {
    if value.is_finite() && value >= min && value <= max {
        return Ok(());
    }
    Err(SdkError::NumericRange {
        field,
        value,
        min,
        max,
    })
}