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}