Skip to main content

chromaframe_sdk/
types.rs

1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3use std::collections::BTreeSet;
4use std::fmt;
5use thiserror::Error;
6
7#[derive(Debug, Error, PartialEq)]
8pub enum SdkError {
9    #[error("explicit consent is required")]
10    ConsentRequired,
11    #[error("{field} must be finite and within [{min}, {max}], got {value}")]
12    NumericRange {
13        field: &'static str,
14        value: f32,
15        min: f32,
16        max: f32,
17    },
18    #[error("image bytes must be non-empty")]
19    EmptyImage,
20    #[error("at least one candidate color is required")]
21    EmptyCandidates,
22    #[error("candidate color '{name}' has invalid sRGB channel")]
23    InvalidCandidateColor { name: String },
24    #[error("candidate '{candidate}' intervention is not allowed")]
25    InterventionNotAllowed { candidate: String },
26}
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
29pub enum ConsentState {
30    ExplicitlyGranted,
31}
32
33#[derive(
34    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
35)]
36pub enum AllowedIntervention {
37    Clothing,
38    Accessory,
39    Background,
40    Makeup,
41    HairColor,
42}
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
45pub enum MeasurementStatus {
46    Complete,
47    InsufficientData,
48}
49
50#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, JsonSchema)]
51pub enum MeasurementMode {
52    IccNormalized,
53    SrgbAssumed,
54    ApparentColorProfileUnsupported,
55    AllowedApparentFallback,
56}
57
58impl MeasurementMode {
59    #[must_use]
60    pub const fn factor(self) -> f32 {
61        match self {
62            Self::IccNormalized => 1.00,
63            Self::SrgbAssumed => 0.94,
64            Self::ApparentColorProfileUnsupported => 0.78,
65            Self::AllowedApparentFallback => 0.70,
66        }
67    }
68}
69
70#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
71pub struct GoalVector {
72    pub contrast_target: f32,
73    pub warmth_target: f32,
74    pub chroma_target: f32,
75    pub feature_readability_target: f32,
76    pub artificiality_tolerance: f32,
77}
78
79impl GoalVector {
80    pub fn parse(self) -> Result<Self, SdkError> {
81        finite_range("contrast_target", self.contrast_target, 0.0, 1.0)?;
82        finite_range("warmth_target", self.warmth_target, -1.0, 1.0)?;
83        finite_range("chroma_target", self.chroma_target, 0.0, 1.0)?;
84        finite_range(
85            "feature_readability_target",
86            self.feature_readability_target,
87            0.0,
88            1.0,
89        )?;
90        finite_range(
91            "artificiality_tolerance",
92            self.artificiality_tolerance,
93            0.0,
94            1.0,
95        )?;
96        Ok(self)
97    }
98}
99
100impl Default for GoalVector {
101    fn default() -> Self {
102        Self {
103            contrast_target: 0.30,
104            warmth_target: 0.0,
105            chroma_target: 0.45,
106            feature_readability_target: 0.30,
107            artificiality_tolerance: 0.5,
108        }
109    }
110}
111
112#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
113pub struct CandidateColor {
114    pub name: String,
115    pub srgb: [u8; 3],
116    pub intervention: AllowedIntervention,
117}
118
119impl CandidateColor {
120    #[must_use]
121    pub fn new(name: impl Into<String>, srgb: [u8; 3], intervention: AllowedIntervention) -> Self {
122        Self {
123            name: name.into(),
124            srgb,
125            intervention,
126        }
127    }
128}
129
130#[derive(Clone, Serialize, Deserialize, JsonSchema)]
131pub struct AnalyzeRequest {
132    pub consent: ConsentState,
133    #[serde(skip_serializing)]
134    pub image_bytes: Vec<u8>,
135    pub goal_vector: GoalVector,
136    pub candidates: Vec<CandidateColor>,
137    pub allowed_interventions: BTreeSet<AllowedIntervention>,
138    pub retain_metadata: bool,
139    pub allow_apparent_color_fallback: bool,
140}
141
142impl fmt::Debug for AnalyzeRequest {
143    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
144        formatter
145            .debug_struct("AnalyzeRequest")
146            .field("consent", &self.consent)
147            .field(
148                "image_bytes",
149                &format_args!("[REDACTED; len={}]", self.image_bytes.len()),
150            )
151            .field("goal_vector", &self.goal_vector)
152            .field("candidates", &self.candidates)
153            .field("allowed_interventions", &self.allowed_interventions)
154            .field("retain_metadata", &self.retain_metadata)
155            .field(
156                "allow_apparent_color_fallback",
157                &self.allow_apparent_color_fallback,
158            )
159            .finish()
160    }
161}
162
163impl AnalyzeRequest {
164    pub fn parse(self) -> Result<Self, SdkError> {
165        if self.consent != ConsentState::ExplicitlyGranted {
166            return Err(SdkError::ConsentRequired);
167        }
168        if self.image_bytes.is_empty() {
169            return Err(SdkError::EmptyImage);
170        }
171        if self.candidates.is_empty() {
172            return Err(SdkError::EmptyCandidates);
173        }
174        self.goal_vector.clone().parse()?;
175        for candidate in &self.candidates {
176            if candidate.name.trim().is_empty() {
177                return Err(SdkError::InvalidCandidateColor {
178                    name: candidate.name.clone(),
179                });
180            }
181            if !self.allowed_interventions.contains(&candidate.intervention) {
182                return Err(SdkError::InterventionNotAllowed {
183                    candidate: candidate.name.clone(),
184                });
185            }
186        }
187        Ok(Self {
188            retain_metadata: false,
189            ..self
190        })
191    }
192}
193
194#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
195#[serde(tag = "status", rename_all = "snake_case")]
196pub enum QualityCheck<T> {
197    Measured { value: T, deduction: f32 },
198    NotMeasured { reason: String, deduction: f32 },
199}
200
201#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
202pub struct CaptureQualityReport {
203    pub decode_status: String,
204    pub orientation_applied: bool,
205    pub dimensions: (u32, u32),
206    pub icc_status: String,
207    pub metadata_retained: bool,
208    pub over_clip_fraction: f32,
209    pub under_clip_fraction: f32,
210    pub white_balance: QualityCheck<f32>,
211    pub blur: QualityCheck<f32>,
212    pub shadow: QualityCheck<f32>,
213    pub face_angle: QualityCheck<f32>,
214    pub filters_or_makeup: QualityCheck<bool>,
215    pub occlusion: QualityCheck<bool>,
216    pub calibration_card: QualityCheck<bool>,
217}
218
219#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
220pub struct SubjectColorProfile {
221    pub skin_lab: crate::color::Lab,
222    pub skin_ita: f32,
223    pub skin_depth_proxy: f32,
224}
225
226#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
227pub struct ContrastMap {
228    pub contrasts: Vec<crate::color::DirectionalDelta>,
229}
230
231#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
232pub struct CandidateRanking {
233    pub name: String,
234    pub score: f32,
235    pub confidence: f32,
236    pub harshness: f32,
237    pub label: String,
238    pub components: crate::score::ScoreComponents,
239}
240
241#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
242pub struct EvidenceCard {
243    pub kind: String,
244    pub summary: String,
245    pub confidence: f32,
246}
247
248#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
249pub struct AgentBrief {
250    pub summary: String,
251    pub uncertainty: String,
252}
253
254pub fn finite_range(field: &'static str, value: f32, min: f32, max: f32) -> Result<(), SdkError> {
255    if value.is_finite() && value >= min && value <= max {
256        return Ok(());
257    }
258    Err(SdkError::NumericRange {
259        field,
260        value,
261        min,
262        max,
263    })
264}