use crate::color::{Lab, delta_e00, lab_to_lch, michelson_lightness_contrast};
use crate::types::{GoalVector, MeasurementMode, QualityCheck};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use thiserror::Error;
pub const MODE_FACTOR_ICC_NORMALIZED: f32 = 1.00;
pub const MODE_FACTOR_SRGB_ASSUMED: f32 = 0.94;
pub const MODE_FACTOR_PROFILE_UNSUPPORTED: f32 = 0.78;
pub const MODE_FACTOR_ALLOWED_APPARENT_FALLBACK: f32 = 0.70;
pub const HARSHNESS_MAX: f32 = 0.15;
pub const REDNESS_AMPLIFICATION_MAX: f32 = 0.10;
pub const SALLOWNESS_AMPLIFICATION_MAX: f32 = 0.10;
pub const CONTRAST_COLLAPSE_MAX: f32 = 0.10;
pub const ARTIFICIALITY_MAX: f32 = 0.05;
pub const TOTAL_PENALTY_MAX: f32 = 0.50;
#[derive(Debug, Error, Clone, PartialEq)]
pub enum ScoreError {
#[error("{field} must be finite")]
NonFinite { field: &'static str },
#[error("{field} interval must have distinct finite bounds")]
InvalidInterval { field: &'static str },
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct ScoreComponents {
pub skin_quality: f32,
pub feature_readability: f32,
pub eye_support: f32,
pub lip_skin_harmony: f32,
pub hair_brow_coherence: f32,
pub goal_alignment: f32,
pub total_penalty: f32,
}
#[derive(Debug, Clone)]
pub struct CandidateScoreInput {
pub skin_lab: Lab,
pub candidate_lab: Lab,
pub lip_lab: Option<Lab>,
pub iris_lab: Option<Lab>,
pub sclera_lab: Option<Lab>,
pub hair_lab: Option<Lab>,
pub brow_lab: Option<Lab>,
pub baseline_feature_michelson: Option<f32>,
pub goal_vector: GoalVector,
pub confidence: f32,
}
pub fn try_clamp01(x: f32) -> Result<f32, ScoreError> {
finite_value("clamp01", x).map(|value| value.clamp(0.0, 1.0))
}
pub fn try_ramp(value: f32, start: f32, end: f32) -> Result<f32, ScoreError> {
let value = finite_value("ramp value", value)?;
let start = finite_value("ramp start", start)?;
let end = finite_value("ramp end", end)?;
if start == end {
return Err(ScoreError::InvalidInterval { field: "ramp" });
}
try_clamp01((value - start) / (end - start))
}
pub fn try_inverse_ramp(value: f32, start: f32, end: f32) -> Result<f32, ScoreError> {
Ok(1.0 - try_ramp(value, start, end)?)
}
pub fn try_target_score(
value: f32,
target: f32,
tolerance: f32,
max_distance: f32,
) -> Result<f32, ScoreError> {
finite_value("target value", value)?;
finite_value("target target", target)?;
try_inverse_ramp((value - target).abs(), tolerance, max_distance)
}
pub fn try_interval_score(
value: f32,
low: f32,
high: f32,
falloff_low: f32,
falloff_high: f32,
) -> Result<f32, ScoreError> {
finite_value("interval value", value)?;
finite_value("interval low", low)?;
finite_value("interval high", high)?;
finite_value("interval falloff_low", falloff_low)?;
finite_value("interval falloff_high", falloff_high)?;
if value >= low && value <= high {
return Ok(1.0);
}
if value < low {
return try_ramp(value, falloff_low, low);
}
try_inverse_ramp(value, high, falloff_high)
}
pub fn try_confidence(
mode: MeasurementMode,
quality_factor: f32,
region_factor: f32,
sample_factor: f32,
) -> Result<f32, ScoreError> {
let quality_factor = finite_value("quality_factor", quality_factor)?;
let region_factor = finite_value("region_factor", region_factor)?;
let sample_factor = finite_value("sample_factor", sample_factor)?;
try_clamp01(mode.factor() * quality_factor * region_factor * sample_factor)
}
pub fn quality_factor(report: &crate::types::CaptureQualityReport) -> f32 {
let exposure = exposure_deduction(report.over_clip_fraction.max(report.under_clip_fraction));
let white = measured_deduction(&report.white_balance);
let blur = measured_deduction(&report.blur);
let shadow = measured_deduction(&report.shadow);
1.0 - 0.30_f32.min(exposure + white + blur + shadow)
}
pub fn exposure_deduction(fraction: f32) -> f32 {
if fraction >= 0.10 {
0.15
} else if fraction >= 0.05 {
0.08
} else if fraction >= 0.02 {
0.03
} else {
0.0
}
}
pub fn white_balance_deduction(imbalance: f32) -> f32 {
if imbalance >= 0.20 {
0.10
} else if imbalance >= 0.12 {
0.05
} else if imbalance >= 0.08 {
0.02
} else {
0.0
}
}
pub fn blur_deduction(blur_score: f32) -> f32 {
if blur_score < 0.08 {
0.10
} else if blur_score < 0.15 {
0.05
} else {
0.0
}
}
pub fn shadow_deduction(unevenness: f32) -> f32 {
if unevenness >= 0.25 {
0.08
} else if unevenness >= 0.15 {
0.04
} else {
0.0
}
}
pub fn region_factor(available_region_weight: f32, applicable_region_weight: f32) -> f32 {
(available_region_weight / applicable_region_weight.max(0.001)).max(0.50)
}
pub fn sample_factor(sample_counts: &[(&str, usize)]) -> f32 {
let mut any_below_min = false;
let mut any_below_half = false;
for (region, count) in sample_counts {
let minimum = if *region == "skin" { 50 } else { 20 };
if *count == 0 {
continue;
}
any_below_min |= *count < minimum;
any_below_half |= *count < minimum / 2;
}
if any_below_half {
return 0.70;
}
if any_below_min {
return 0.85;
}
1.00
}
pub fn score_candidate(input: CandidateScoreInput) -> Result<(f32, ScoreComponents), ScoreError> {
validate_score_input(&input)?;
let candidate_skin_delta_e00 = delta_e00(input.candidate_lab, input.skin_lab);
let candidate_skin_michelson =
michelson_lightness_contrast(input.candidate_lab, input.skin_lab);
let harshness = harshness(candidate_skin_delta_e00)?;
let redness = redness_amplification(input.skin_lab, input.candidate_lab)?;
let sallowness = sallowness_amplification(input.skin_lab, input.candidate_lab)?;
let contrast_collapse =
contrast_collapse(candidate_skin_michelson, input.baseline_feature_michelson)?;
let candidate_chroma = lab_to_lch(input.candidate_lab).c;
let artificiality = artificiality(
candidate_chroma,
candidate_skin_delta_e00,
input.goal_vector.artificiality_tolerance,
)?;
let skin_quality = try_clamp01(
1.0 - 0.40 * (harshness / HARSHNESS_MAX)
- 0.30 * (redness / REDNESS_AMPLIFICATION_MAX)
- 0.30 * (sallowness / SALLOWNESS_AMPLIFICATION_MAX),
)?;
let feature_readability = try_interval_score(candidate_skin_michelson, 0.10, 0.45, 0.03, 0.75)?
* try_target_score(
candidate_skin_michelson,
input.goal_vector.feature_readability_target,
0.10,
0.45,
)?;
let eye_support = eye_support(input.candidate_lab, input.iris_lab, input.sclera_lab)?;
let lip_skin_harmony = lip_skin_harmony(input.candidate_lab, input.lip_lab)?;
let hair_brow_coherence =
hair_brow_coherence(input.candidate_lab, input.hair_lab, input.brow_lab)?;
let goal_alignment = goal_alignment(
input.candidate_lab,
candidate_skin_delta_e00,
candidate_skin_michelson,
input.goal_vector,
)?;
let weighted = 0.20 * skin_quality
+ 0.20 * feature_readability
+ 0.15 * eye_support
+ 0.15 * lip_skin_harmony
+ 0.10 * hair_brow_coherence
+ 0.20 * goal_alignment;
let total_penalty = (harshness + redness + sallowness + contrast_collapse + artificiality)
.min(TOTAL_PENALTY_MAX);
let score = (100.0 * weighted * input.confidence - 100.0 * total_penalty).clamp(0.0, 100.0);
Ok((
score,
ScoreComponents {
skin_quality,
feature_readability,
eye_support,
lip_skin_harmony,
hair_brow_coherence,
goal_alignment,
total_penalty,
},
))
}
pub(crate) fn clamp01(x: f32) -> f32 {
try_clamp01(x).expect("internal scoring input must be finite")
}
pub fn harshness(candidate_skin_delta_e00: f32) -> Result<f32, ScoreError> {
Ok(HARSHNESS_MAX * try_ramp(candidate_skin_delta_e00, 28.0, 55.0)?)
}
pub fn redness_amplification(skin: Lab, candidate: Lab) -> Result<f32, ScoreError> {
finite_lab("skin", skin)?;
finite_lab("candidate", candidate)?;
if skin.a >= 18.0 && candidate.a > skin.a {
Ok(REDNESS_AMPLIFICATION_MAX * try_ramp(candidate.a - skin.a, 6.0, 18.0)?)
} else {
Ok(0.0)
}
}
pub fn sallowness_amplification(skin: Lab, candidate: Lab) -> Result<f32, ScoreError> {
finite_lab("skin", skin)?;
finite_lab("candidate", candidate)?;
if skin.b >= 22.0 && candidate.b > skin.b {
Ok(SALLOWNESS_AMPLIFICATION_MAX * try_ramp(candidate.b - skin.b, 8.0, 22.0)?)
} else {
Ok(0.0)
}
}
pub fn contrast_collapse(
candidate_skin_michelson: f32,
baseline_feature_michelson: Option<f32>,
) -> Result<f32, ScoreError> {
finite_value("candidate_skin_michelson", candidate_skin_michelson)?;
let Some(baseline) = baseline_feature_michelson else {
return Ok(0.0);
};
finite_value("baseline_feature_michelson", baseline)?;
Ok(CONTRAST_COLLAPSE_MAX
* ((0.65 - candidate_skin_michelson / baseline.max(0.001)) / 0.35).clamp(0.0, 1.0))
}
pub fn artificiality(
candidate_chroma: f32,
candidate_skin_delta_e00: f32,
tolerance: f32,
) -> Result<f32, ScoreError> {
finite_value("candidate_chroma", candidate_chroma)?;
finite_value("candidate_skin_delta_e00", candidate_skin_delta_e00)?;
finite_value("tolerance", tolerance)?;
if tolerance <= 0.30 {
Ok(ARTIFICIALITY_MAX
* try_ramp(candidate_chroma, 70.0, 100.0)?.max(try_ramp(
candidate_skin_delta_e00,
45.0,
70.0,
)?))
} else {
Ok(0.0)
}
}
fn goal_alignment(
candidate_lab: Lab,
candidate_skin_delta_e00: f32,
candidate_skin_michelson: f32,
goal: GoalVector,
) -> Result<f32, ScoreError> {
let candidate_lch = lab_to_lch(candidate_lab);
let candidate_warmth = (candidate_lab.b / 60.0).clamp(-1.0, 1.0);
let candidate_chroma_normalized = try_clamp01(candidate_lch.c / 100.0)?;
let artificiality_proxy = try_ramp(candidate_lch.c, 70.0, 100.0)?.max(try_ramp(
candidate_skin_delta_e00,
45.0,
70.0,
)?);
Ok(
0.35 * try_target_score(candidate_skin_michelson, goal.contrast_target, 0.10, 0.50)?
+ 0.25 * try_target_score(candidate_warmth, goal.warmth_target, 0.20, 1.00)?
+ 0.25 * try_target_score(candidate_chroma_normalized, goal.chroma_target, 0.15, 1.00)?
+ 0.15
* try_target_score(
artificiality_proxy,
goal.artificiality_tolerance,
0.15,
1.00,
)?,
)
}
fn eye_support(candidate: Lab, iris: Option<Lab>, sclera: Option<Lab>) -> Result<f32, ScoreError> {
finite_lab("candidate", candidate)?;
match (iris, sclera) {
(Some(iris), Some(sclera)) => {
finite_lab("iris", iris)?;
finite_lab("sclera", sclera)?;
Ok(0.60 * try_ramp(delta_e00(candidate, iris), 5.0, 24.0)?
+ 0.40 * try_inverse_ramp((candidate.b - sclera.b).abs(), 4.0, 16.0)?)
}
(Some(iris), None) => {
finite_lab("iris", iris)?;
try_ramp(delta_e00(candidate, iris), 5.0, 24.0)
}
_ => Ok(0.50),
}
}
fn lip_skin_harmony(candidate: Lab, lip: Option<Lab>) -> Result<f32, ScoreError> {
finite_lab("candidate", candidate)?;
let Some(lip) = lip else {
return Ok(0.50);
};
finite_lab("lip", lip)?;
Ok(
0.50 * try_target_score(delta_e00(candidate, lip), 18.0, 8.0, 35.0)?
+ 0.50 * try_inverse_ramp((candidate.a - lip.a).abs(), 4.0, 18.0)?,
)
}
fn hair_brow_coherence(
candidate: Lab,
hair: Option<Lab>,
brow: Option<Lab>,
) -> Result<f32, ScoreError> {
finite_lab("candidate", candidate)?;
let mut deltas = Vec::new();
for (name, lab) in [("hair", hair), ("brow", brow)] {
if let Some(lab) = lab {
finite_lab(name, lab)?;
deltas.push(delta_e00(candidate, lab));
}
}
let Some(min_delta) = deltas.into_iter().min_by(f32::total_cmp) else {
return Ok(0.50);
};
try_interval_score(min_delta, 8.0, 38.0, 2.0, 65.0)
}
fn measured_deduction<T>(check: &QualityCheck<T>) -> f32 {
match check {
QualityCheck::Measured { deduction, .. } | QualityCheck::NotMeasured { deduction, .. } => {
*deduction
}
}
}
fn validate_score_input(input: &CandidateScoreInput) -> Result<(), ScoreError> {
finite_lab("skin_lab", input.skin_lab)?;
finite_lab("candidate_lab", input.candidate_lab)?;
if let Some(value) = input.baseline_feature_michelson {
finite_value("baseline_feature_michelson", value)?;
}
finite_value("confidence", input.confidence)?;
input
.goal_vector
.clone()
.parse()
.map_err(|_| ScoreError::NonFinite {
field: "goal_vector",
})?;
Ok(())
}
fn finite_lab(field: &'static str, lab: Lab) -> Result<(), ScoreError> {
finite_value(field, lab.l)?;
finite_value(field, lab.a)?;
finite_value(field, lab.b)?;
Ok(())
}
fn finite_value(field: &'static str, value: f32) -> Result<f32, ScoreError> {
if value.is_finite() {
return Ok(value);
}
Err(ScoreError::NonFinite { field })
}