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,
})
}