use crate::color::{Lab, directional_delta, srgb_to_lab};
use crate::quality::ImageError;
use crate::score::{
ScoreError, harshness, quality_factor, region_factor, sample_factor, score_candidate,
try_confidence,
};
use crate::types::{
CandidateColor, CandidateRanking, CaptureQualityReport, ContrastMap, GoalVector,
MeasurementStatus, SdkError, SubjectColorProfile,
};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::fmt;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum MeasureError {
#[error(transparent)]
Image(#[from] ImageError),
#[error("region '{0}' has zero area or no accepted pixels")]
EmptyRegion(&'static str),
#[error("region '{0}' is out of bounds")]
RegionOutOfBounds(&'static str),
#[error("skin measurement is required for ranking")]
MissingSkin,
#[error(transparent)]
Validation(#[from] SdkError),
#[error("quality report contains non-finite value in {0}")]
NonFiniteQuality(&'static str),
#[error(transparent)]
Score(#[from] ScoreError),
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(tag = "status", rename_all = "snake_case")]
pub enum RegionMeasurement {
Measured { lab: Lab, accepted_pixels: usize },
Missing { reason: String },
}
#[derive(Clone)]
pub struct ManualRegionSamples {
pub skin: Option<Vec<[u8; 3]>>,
pub brow: Option<Vec<[u8; 3]>>,
pub iris: Option<Vec<[u8; 3]>>,
pub sclera: Option<Vec<[u8; 3]>>,
pub lip: Option<Vec<[u8; 3]>>,
pub hair: Option<Vec<[u8; 3]>>,
pub beard: Option<Vec<[u8; 3]>>,
pub clothing: Option<Vec<[u8; 3]>>,
}
impl fmt::Debug for ManualRegionSamples {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("ManualRegionSamples")
.field("skin", &redacted_sample_count(&self.skin))
.field("brow", &redacted_sample_count(&self.brow))
.field("iris", &redacted_sample_count(&self.iris))
.field("sclera", &redacted_sample_count(&self.sclera))
.field("lip", &redacted_sample_count(&self.lip))
.field("hair", &redacted_sample_count(&self.hair))
.field("beard", &redacted_sample_count(&self.beard))
.field("clothing", &redacted_sample_count(&self.clothing))
.finish()
}
}
#[derive(Clone)]
pub struct MeasurementInput {
pub quality: CaptureQualityReport,
pub mode: crate::types::MeasurementMode,
pub goal_vector: GoalVector,
pub candidates: Vec<CandidateColor>,
pub samples: ManualRegionSamples,
}
impl fmt::Debug for MeasurementInput {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("MeasurementInput")
.field("quality", &self.quality)
.field("mode", &self.mode)
.field("goal_vector", &self.goal_vector)
.field("candidates", &self.candidates)
.field("samples", &self.samples)
.finish()
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct MeasurementReport {
pub status: MeasurementStatus,
pub quality: CaptureQualityReport,
pub subject: Option<SubjectColorProfile>,
pub contrast_map: ContrastMap,
pub rankings: Vec<CandidateRanking>,
}
pub struct MeasurementEngine;
impl MeasurementEngine {
pub fn measure(input: &MeasurementInput) -> Result<MeasurementReport, MeasureError> {
validate_measurement_input(input)?;
let skin = measure_region("skin", input.samples.skin.as_deref(), true)?;
let brow = measure_region("brow", input.samples.brow.as_deref(), false)?;
let iris = measure_region("iris", input.samples.iris.as_deref(), false)?;
let sclera = measure_region("sclera", input.samples.sclera.as_deref(), false)?;
let lip = measure_region("lip", input.samples.lip.as_deref(), false)?;
let hair = measure_region("hair", input.samples.hair.as_deref(), false)?;
let beard = measure_region("beard", input.samples.beard.as_deref(), false)?;
let clothing = measure_region("clothing", input.samples.clothing.as_deref(), false)?;
let RegionMeasurement::Measured {
lab: skin_lab,
accepted_pixels: skin_count,
} = skin
else {
return Ok(MeasurementReport {
status: MeasurementStatus::InsufficientData,
quality: input.quality.clone(),
subject: None,
contrast_map: ContrastMap {
contrasts: Vec::new(),
},
rankings: Vec::new(),
});
};
let mut contrasts = Vec::new();
for (name, region) in [
("brow", &brow),
("iris", &iris),
("sclera", &sclera),
("lip", &lip),
("hair", &hair),
("beard", &beard),
("clothing", &clothing),
] {
if let RegionMeasurement::Measured { lab, .. } = region {
contrasts.push(directional_delta("skin", skin_lab, name, *lab));
}
}
let baseline_feature_michelson = contrasts
.iter()
.map(|d| d.michelson_lightness_contrast)
.max_by(f32::total_cmp);
let available_weight =
0.40 + if !input.candidates.is_empty() {
0.20
} else {
0.0
} + if matches!(iris, RegionMeasurement::Measured { .. }) {
0.10
} else {
0.0
} + if matches!(lip, RegionMeasurement::Measured { .. }) {
0.10
} else {
0.0
} + if matches!(hair, RegionMeasurement::Measured { .. })
|| matches!(brow, RegionMeasurement::Measured { .. })
|| matches!(beard, RegionMeasurement::Measured { .. })
{
0.10
} else {
0.0
} + if matches!(clothing, RegionMeasurement::Measured { .. }) {
0.10
} else {
0.0
};
let confidence_value = try_confidence(
input.mode,
quality_factor(&input.quality),
region_factor(available_weight, 1.0),
sample_factor(&[
("skin", skin_count),
("brow", count(&brow)),
("iris", count(&iris)),
("lip", count(&lip)),
("hair", count(&hair)),
("beard", count(&beard)),
("clothing", count(&clothing)),
]),
)?;
let mut rankings = input
.candidates
.iter()
.enumerate()
.map(|(index, candidate)| {
let candidate_lab = srgb_to_lab(candidate.srgb);
let candidate_harshness =
harshness(crate::color::delta_e00(candidate_lab, skin_lab))?;
let (score, components) = score_candidate(crate::score::CandidateScoreInput {
skin_lab,
candidate_lab,
lip_lab: lab(&lip),
iris_lab: lab(&iris),
sclera_lab: lab(&sclera),
hair_lab: lab(&hair),
brow_lab: lab(&brow),
baseline_feature_michelson,
goal_vector: input.goal_vector.clone(),
confidence: confidence_value,
})?;
Ok((
index,
CandidateRanking {
name: candidate.name.clone(),
score,
confidence: confidence_value,
harshness: candidate_harshness,
label: "Goal-specific fit with measured uncertainty".to_string(),
components,
},
))
})
.collect::<Result<Vec<_>, MeasureError>>()?;
sort_rankings_by_contract(&mut rankings);
let rankings = rankings.into_iter().map(|(_, ranking)| ranking).collect();
Ok(MeasurementReport {
status: MeasurementStatus::Complete,
quality: input.quality.clone(),
subject: Some(SubjectColorProfile {
skin_lab,
skin_ita: crate::color::ita_degrees(skin_lab),
skin_depth_proxy: crate::color::depth_proxy(skin_lab),
}),
contrast_map: ContrastMap { contrasts },
rankings,
})
}
}
fn validate_measurement_input(input: &MeasurementInput) -> Result<(), MeasureError> {
input.goal_vector.clone().parse()?;
if input.candidates.is_empty() {
return Err(SdkError::EmptyCandidates.into());
}
for candidate in &input.candidates {
if candidate.name.trim().is_empty() {
return Err(SdkError::InvalidCandidateColor {
name: candidate.name.clone(),
}
.into());
}
}
validate_quality_report(&input.quality)
}
fn sort_rankings_by_contract(rankings: &mut [(usize, CandidateRanking)]) {
rankings.sort_by(|left, right| {
right
.1
.score
.total_cmp(&left.1.score)
.then_with(|| right.1.confidence.total_cmp(&left.1.confidence))
.then_with(|| left.1.harshness.total_cmp(&right.1.harshness))
.then_with(|| left.0.cmp(&right.0))
});
}
fn validate_quality_report(report: &CaptureQualityReport) -> Result<(), MeasureError> {
finite_quality("over_clip_fraction", report.over_clip_fraction)?;
finite_quality("under_clip_fraction", report.under_clip_fraction)?;
finite_quality_check("white_balance", &report.white_balance)?;
finite_quality_check("blur", &report.blur)?;
finite_quality_check("shadow", &report.shadow)?;
finite_quality_check("face_angle", &report.face_angle)?;
finite_bool_quality_check("filters_or_makeup", &report.filters_or_makeup)?;
finite_bool_quality_check("occlusion", &report.occlusion)?;
finite_bool_quality_check("calibration_card", &report.calibration_card)?;
Ok(())
}
fn finite_quality(field: &'static str, value: f32) -> Result<(), MeasureError> {
if value.is_finite() {
return Ok(());
}
Err(MeasureError::NonFiniteQuality(field))
}
fn finite_quality_check(
field: &'static str,
check: &crate::types::QualityCheck<f32>,
) -> Result<(), MeasureError> {
match check {
crate::types::QualityCheck::Measured { value, deduction } => {
finite_quality(field, *value)?;
finite_quality(field, *deduction)
}
crate::types::QualityCheck::NotMeasured { deduction, .. } => {
finite_quality(field, *deduction)
}
}
}
fn finite_bool_quality_check(
field: &'static str,
check: &crate::types::QualityCheck<bool>,
) -> Result<(), MeasureError> {
match check {
crate::types::QualityCheck::Measured { deduction, .. }
| crate::types::QualityCheck::NotMeasured { deduction, .. } => {
finite_quality(field, *deduction)
}
}
}
fn redacted_sample_count(samples: &Option<Vec<[u8; 3]>>) -> String {
samples.as_ref().map_or_else(
|| "[MISSING]".to_string(),
|values| format!("[REDACTED; count={}]", values.len()),
)
}
fn measure_region(
name: &'static str,
samples: Option<&[[u8; 3]]>,
required: bool,
) -> Result<RegionMeasurement, MeasureError> {
let Some(samples) = samples else {
return Ok(RegionMeasurement::Missing {
reason: "not_provided".to_string(),
});
};
if samples.is_empty() && required {
return Err(MeasureError::EmptyRegion(name));
}
if samples.is_empty() {
return Ok(RegionMeasurement::Missing {
reason: "zero_accepted_pixels".to_string(),
});
}
let labs: Vec<_> = samples.iter().map(|rgb| srgb_to_lab(*rgb)).collect();
if labs
.iter()
.any(|lab| !lab.l.is_finite() || !lab.a.is_finite() || !lab.b.is_finite())
{
return Err(MeasureError::EmptyRegion(name));
}
let count = labs.len();
let (l, a, b) = labs.iter().fold((0.0, 0.0, 0.0), |acc, lab| {
(acc.0 + lab.l, acc.1 + lab.a, acc.2 + lab.b)
});
Ok(RegionMeasurement::Measured {
lab: Lab {
l: l / count as f32,
a: a / count as f32,
b: b / count as f32,
},
accepted_pixels: count,
})
}
fn count(region: &RegionMeasurement) -> usize {
if let RegionMeasurement::Measured {
accepted_pixels, ..
} = region
{
*accepted_pixels
} else {
0
}
}
fn lab(region: &RegionMeasurement) -> Option<Lab> {
if let RegionMeasurement::Measured { lab, .. } = region {
Some(*lab)
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::score::ScoreComponents;
fn ranking(name: &str, score: f32, confidence: f32, harshness: f32) -> CandidateRanking {
CandidateRanking {
name: name.to_string(),
score,
confidence,
harshness,
label: "test".to_string(),
components: ScoreComponents {
skin_quality: 0.5,
feature_readability: 0.5,
eye_support: 0.5,
lip_skin_harmony: 0.5,
hair_brow_coherence: 0.5,
goal_alignment: 0.5,
total_penalty: 0.0,
},
}
}
#[test]
fn ranking_sort_uses_lower_harshness_then_stable_order() {
let mut rankings = vec![
(0, ranking("higher_harshness", 42.0, 0.8, 0.10)),
(1, ranking("lower_harshness", 42.0, 0.8, 0.01)),
(2, ranking("stable_a", 40.0, 0.8, 0.01)),
(3, ranking("stable_b", 40.0, 0.8, 0.01)),
];
sort_rankings_by_contract(&mut rankings);
let names: Vec<_> = rankings
.iter()
.map(|(_, ranking)| ranking.name.as_str())
.collect();
assert_eq!(
names,
vec![
"lower_harshness",
"higher_harshness",
"stable_a",
"stable_b"
]
);
}
}