Skip to main content

chromaframe_sdk/
score.rs

1use crate::color::{Lab, delta_e00, lab_to_lch, michelson_lightness_contrast};
2use crate::types::{GoalVector, MeasurementMode, QualityCheck};
3use schemars::JsonSchema;
4use serde::{Deserialize, Serialize};
5use thiserror::Error;
6
7pub const MODE_FACTOR_ICC_NORMALIZED: f32 = 1.00;
8pub const MODE_FACTOR_SRGB_ASSUMED: f32 = 0.94;
9pub const MODE_FACTOR_PROFILE_UNSUPPORTED: f32 = 0.78;
10pub const MODE_FACTOR_ALLOWED_APPARENT_FALLBACK: f32 = 0.70;
11pub const HARSHNESS_MAX: f32 = 0.15;
12pub const REDNESS_AMPLIFICATION_MAX: f32 = 0.10;
13pub const SALLOWNESS_AMPLIFICATION_MAX: f32 = 0.10;
14pub const CONTRAST_COLLAPSE_MAX: f32 = 0.10;
15pub const ARTIFICIALITY_MAX: f32 = 0.05;
16pub const TOTAL_PENALTY_MAX: f32 = 0.50;
17
18#[derive(Debug, Error, Clone, PartialEq)]
19pub enum ScoreError {
20    #[error("{field} must be finite")]
21    NonFinite { field: &'static str },
22    #[error("{field} interval must have distinct finite bounds")]
23    InvalidInterval { field: &'static str },
24}
25
26#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, JsonSchema)]
27pub struct ScoreComponents {
28    pub skin_quality: f32,
29    pub feature_readability: f32,
30    pub eye_support: f32,
31    pub lip_skin_harmony: f32,
32    pub hair_brow_coherence: f32,
33    pub goal_alignment: f32,
34    pub total_penalty: f32,
35}
36
37#[derive(Debug, Clone)]
38pub struct CandidateScoreInput {
39    pub skin_lab: Lab,
40    pub candidate_lab: Lab,
41    pub lip_lab: Option<Lab>,
42    pub iris_lab: Option<Lab>,
43    pub sclera_lab: Option<Lab>,
44    pub hair_lab: Option<Lab>,
45    pub brow_lab: Option<Lab>,
46    pub baseline_feature_michelson: Option<f32>,
47    pub goal_vector: GoalVector,
48    pub confidence: f32,
49}
50
51pub fn try_clamp01(x: f32) -> Result<f32, ScoreError> {
52    finite_value("clamp01", x).map(|value| value.clamp(0.0, 1.0))
53}
54pub fn try_ramp(value: f32, start: f32, end: f32) -> Result<f32, ScoreError> {
55    let value = finite_value("ramp value", value)?;
56    let start = finite_value("ramp start", start)?;
57    let end = finite_value("ramp end", end)?;
58    if start == end {
59        return Err(ScoreError::InvalidInterval { field: "ramp" });
60    }
61    try_clamp01((value - start) / (end - start))
62}
63pub fn try_inverse_ramp(value: f32, start: f32, end: f32) -> Result<f32, ScoreError> {
64    Ok(1.0 - try_ramp(value, start, end)?)
65}
66pub fn try_target_score(
67    value: f32,
68    target: f32,
69    tolerance: f32,
70    max_distance: f32,
71) -> Result<f32, ScoreError> {
72    finite_value("target value", value)?;
73    finite_value("target target", target)?;
74    try_inverse_ramp((value - target).abs(), tolerance, max_distance)
75}
76pub fn try_interval_score(
77    value: f32,
78    low: f32,
79    high: f32,
80    falloff_low: f32,
81    falloff_high: f32,
82) -> Result<f32, ScoreError> {
83    finite_value("interval value", value)?;
84    finite_value("interval low", low)?;
85    finite_value("interval high", high)?;
86    finite_value("interval falloff_low", falloff_low)?;
87    finite_value("interval falloff_high", falloff_high)?;
88    if value >= low && value <= high {
89        return Ok(1.0);
90    }
91    if value < low {
92        return try_ramp(value, falloff_low, low);
93    }
94    try_inverse_ramp(value, high, falloff_high)
95}
96
97pub fn try_confidence(
98    mode: MeasurementMode,
99    quality_factor: f32,
100    region_factor: f32,
101    sample_factor: f32,
102) -> Result<f32, ScoreError> {
103    let quality_factor = finite_value("quality_factor", quality_factor)?;
104    let region_factor = finite_value("region_factor", region_factor)?;
105    let sample_factor = finite_value("sample_factor", sample_factor)?;
106    try_clamp01(mode.factor() * quality_factor * region_factor * sample_factor)
107}
108
109pub fn quality_factor(report: &crate::types::CaptureQualityReport) -> f32 {
110    let exposure = exposure_deduction(report.over_clip_fraction.max(report.under_clip_fraction));
111    let white = measured_deduction(&report.white_balance);
112    let blur = measured_deduction(&report.blur);
113    let shadow = measured_deduction(&report.shadow);
114    1.0 - 0.30_f32.min(exposure + white + blur + shadow)
115}
116
117pub fn exposure_deduction(fraction: f32) -> f32 {
118    if fraction >= 0.10 {
119        0.15
120    } else if fraction >= 0.05 {
121        0.08
122    } else if fraction >= 0.02 {
123        0.03
124    } else {
125        0.0
126    }
127}
128pub fn white_balance_deduction(imbalance: f32) -> f32 {
129    if imbalance >= 0.20 {
130        0.10
131    } else if imbalance >= 0.12 {
132        0.05
133    } else if imbalance >= 0.08 {
134        0.02
135    } else {
136        0.0
137    }
138}
139pub fn blur_deduction(blur_score: f32) -> f32 {
140    if blur_score < 0.08 {
141        0.10
142    } else if blur_score < 0.15 {
143        0.05
144    } else {
145        0.0
146    }
147}
148pub fn shadow_deduction(unevenness: f32) -> f32 {
149    if unevenness >= 0.25 {
150        0.08
151    } else if unevenness >= 0.15 {
152        0.04
153    } else {
154        0.0
155    }
156}
157
158pub fn region_factor(available_region_weight: f32, applicable_region_weight: f32) -> f32 {
159    (available_region_weight / applicable_region_weight.max(0.001)).max(0.50)
160}
161
162pub fn sample_factor(sample_counts: &[(&str, usize)]) -> f32 {
163    let mut any_below_min = false;
164    let mut any_below_half = false;
165    for (region, count) in sample_counts {
166        let minimum = if *region == "skin" { 50 } else { 20 };
167        if *count == 0 {
168            continue;
169        }
170        any_below_min |= *count < minimum;
171        any_below_half |= *count < minimum / 2;
172    }
173    if any_below_half {
174        return 0.70;
175    }
176    if any_below_min {
177        return 0.85;
178    }
179    1.00
180}
181
182pub fn score_candidate(input: CandidateScoreInput) -> Result<(f32, ScoreComponents), ScoreError> {
183    validate_score_input(&input)?;
184    let candidate_skin_delta_e00 = delta_e00(input.candidate_lab, input.skin_lab);
185    let candidate_skin_michelson =
186        michelson_lightness_contrast(input.candidate_lab, input.skin_lab);
187    let harshness = harshness(candidate_skin_delta_e00)?;
188    let redness = redness_amplification(input.skin_lab, input.candidate_lab)?;
189    let sallowness = sallowness_amplification(input.skin_lab, input.candidate_lab)?;
190    let contrast_collapse =
191        contrast_collapse(candidate_skin_michelson, input.baseline_feature_michelson)?;
192    let candidate_chroma = lab_to_lch(input.candidate_lab).c;
193    let artificiality = artificiality(
194        candidate_chroma,
195        candidate_skin_delta_e00,
196        input.goal_vector.artificiality_tolerance,
197    )?;
198    let skin_quality = try_clamp01(
199        1.0 - 0.40 * (harshness / HARSHNESS_MAX)
200            - 0.30 * (redness / REDNESS_AMPLIFICATION_MAX)
201            - 0.30 * (sallowness / SALLOWNESS_AMPLIFICATION_MAX),
202    )?;
203    let feature_readability = try_interval_score(candidate_skin_michelson, 0.10, 0.45, 0.03, 0.75)?
204        * try_target_score(
205            candidate_skin_michelson,
206            input.goal_vector.feature_readability_target,
207            0.10,
208            0.45,
209        )?;
210    let eye_support = eye_support(input.candidate_lab, input.iris_lab, input.sclera_lab)?;
211    let lip_skin_harmony = lip_skin_harmony(input.candidate_lab, input.lip_lab)?;
212    let hair_brow_coherence =
213        hair_brow_coherence(input.candidate_lab, input.hair_lab, input.brow_lab)?;
214    let goal_alignment = goal_alignment(
215        input.candidate_lab,
216        candidate_skin_delta_e00,
217        candidate_skin_michelson,
218        input.goal_vector,
219    )?;
220    let weighted = 0.20 * skin_quality
221        + 0.20 * feature_readability
222        + 0.15 * eye_support
223        + 0.15 * lip_skin_harmony
224        + 0.10 * hair_brow_coherence
225        + 0.20 * goal_alignment;
226    let total_penalty = (harshness + redness + sallowness + contrast_collapse + artificiality)
227        .min(TOTAL_PENALTY_MAX);
228    let score = (100.0 * weighted * input.confidence - 100.0 * total_penalty).clamp(0.0, 100.0);
229    Ok((
230        score,
231        ScoreComponents {
232            skin_quality,
233            feature_readability,
234            eye_support,
235            lip_skin_harmony,
236            hair_brow_coherence,
237            goal_alignment,
238            total_penalty,
239        },
240    ))
241}
242
243pub(crate) fn clamp01(x: f32) -> f32 {
244    try_clamp01(x).expect("internal scoring input must be finite")
245}
246
247pub fn harshness(candidate_skin_delta_e00: f32) -> Result<f32, ScoreError> {
248    Ok(HARSHNESS_MAX * try_ramp(candidate_skin_delta_e00, 28.0, 55.0)?)
249}
250pub fn redness_amplification(skin: Lab, candidate: Lab) -> Result<f32, ScoreError> {
251    finite_lab("skin", skin)?;
252    finite_lab("candidate", candidate)?;
253    if skin.a >= 18.0 && candidate.a > skin.a {
254        Ok(REDNESS_AMPLIFICATION_MAX * try_ramp(candidate.a - skin.a, 6.0, 18.0)?)
255    } else {
256        Ok(0.0)
257    }
258}
259pub fn sallowness_amplification(skin: Lab, candidate: Lab) -> Result<f32, ScoreError> {
260    finite_lab("skin", skin)?;
261    finite_lab("candidate", candidate)?;
262    if skin.b >= 22.0 && candidate.b > skin.b {
263        Ok(SALLOWNESS_AMPLIFICATION_MAX * try_ramp(candidate.b - skin.b, 8.0, 22.0)?)
264    } else {
265        Ok(0.0)
266    }
267}
268pub fn contrast_collapse(
269    candidate_skin_michelson: f32,
270    baseline_feature_michelson: Option<f32>,
271) -> Result<f32, ScoreError> {
272    finite_value("candidate_skin_michelson", candidate_skin_michelson)?;
273    let Some(baseline) = baseline_feature_michelson else {
274        return Ok(0.0);
275    };
276    finite_value("baseline_feature_michelson", baseline)?;
277    Ok(CONTRAST_COLLAPSE_MAX
278        * ((0.65 - candidate_skin_michelson / baseline.max(0.001)) / 0.35).clamp(0.0, 1.0))
279}
280pub fn artificiality(
281    candidate_chroma: f32,
282    candidate_skin_delta_e00: f32,
283    tolerance: f32,
284) -> Result<f32, ScoreError> {
285    finite_value("candidate_chroma", candidate_chroma)?;
286    finite_value("candidate_skin_delta_e00", candidate_skin_delta_e00)?;
287    finite_value("tolerance", tolerance)?;
288    if tolerance <= 0.30 {
289        Ok(ARTIFICIALITY_MAX
290            * try_ramp(candidate_chroma, 70.0, 100.0)?.max(try_ramp(
291                candidate_skin_delta_e00,
292                45.0,
293                70.0,
294            )?))
295    } else {
296        Ok(0.0)
297    }
298}
299
300fn goal_alignment(
301    candidate_lab: Lab,
302    candidate_skin_delta_e00: f32,
303    candidate_skin_michelson: f32,
304    goal: GoalVector,
305) -> Result<f32, ScoreError> {
306    let candidate_lch = lab_to_lch(candidate_lab);
307    let candidate_warmth = (candidate_lab.b / 60.0).clamp(-1.0, 1.0);
308    let candidate_chroma_normalized = try_clamp01(candidate_lch.c / 100.0)?;
309    let artificiality_proxy = try_ramp(candidate_lch.c, 70.0, 100.0)?.max(try_ramp(
310        candidate_skin_delta_e00,
311        45.0,
312        70.0,
313    )?);
314    Ok(
315        0.35 * try_target_score(candidate_skin_michelson, goal.contrast_target, 0.10, 0.50)?
316            + 0.25 * try_target_score(candidate_warmth, goal.warmth_target, 0.20, 1.00)?
317            + 0.25 * try_target_score(candidate_chroma_normalized, goal.chroma_target, 0.15, 1.00)?
318            + 0.15
319                * try_target_score(
320                    artificiality_proxy,
321                    goal.artificiality_tolerance,
322                    0.15,
323                    1.00,
324                )?,
325    )
326}
327
328fn eye_support(candidate: Lab, iris: Option<Lab>, sclera: Option<Lab>) -> Result<f32, ScoreError> {
329    finite_lab("candidate", candidate)?;
330    match (iris, sclera) {
331        (Some(iris), Some(sclera)) => {
332            finite_lab("iris", iris)?;
333            finite_lab("sclera", sclera)?;
334            Ok(0.60 * try_ramp(delta_e00(candidate, iris), 5.0, 24.0)?
335                + 0.40 * try_inverse_ramp((candidate.b - sclera.b).abs(), 4.0, 16.0)?)
336        }
337        (Some(iris), None) => {
338            finite_lab("iris", iris)?;
339            try_ramp(delta_e00(candidate, iris), 5.0, 24.0)
340        }
341        _ => Ok(0.50),
342    }
343}
344fn lip_skin_harmony(candidate: Lab, lip: Option<Lab>) -> Result<f32, ScoreError> {
345    finite_lab("candidate", candidate)?;
346    let Some(lip) = lip else {
347        return Ok(0.50);
348    };
349    finite_lab("lip", lip)?;
350    Ok(
351        0.50 * try_target_score(delta_e00(candidate, lip), 18.0, 8.0, 35.0)?
352            + 0.50 * try_inverse_ramp((candidate.a - lip.a).abs(), 4.0, 18.0)?,
353    )
354}
355fn hair_brow_coherence(
356    candidate: Lab,
357    hair: Option<Lab>,
358    brow: Option<Lab>,
359) -> Result<f32, ScoreError> {
360    finite_lab("candidate", candidate)?;
361    let mut deltas = Vec::new();
362    for (name, lab) in [("hair", hair), ("brow", brow)] {
363        if let Some(lab) = lab {
364            finite_lab(name, lab)?;
365            deltas.push(delta_e00(candidate, lab));
366        }
367    }
368    let Some(min_delta) = deltas.into_iter().min_by(f32::total_cmp) else {
369        return Ok(0.50);
370    };
371    try_interval_score(min_delta, 8.0, 38.0, 2.0, 65.0)
372}
373fn measured_deduction<T>(check: &QualityCheck<T>) -> f32 {
374    match check {
375        QualityCheck::Measured { deduction, .. } | QualityCheck::NotMeasured { deduction, .. } => {
376            *deduction
377        }
378    }
379}
380fn validate_score_input(input: &CandidateScoreInput) -> Result<(), ScoreError> {
381    finite_lab("skin_lab", input.skin_lab)?;
382    finite_lab("candidate_lab", input.candidate_lab)?;
383    if let Some(value) = input.baseline_feature_michelson {
384        finite_value("baseline_feature_michelson", value)?;
385    }
386    finite_value("confidence", input.confidence)?;
387    input
388        .goal_vector
389        .clone()
390        .parse()
391        .map_err(|_| ScoreError::NonFinite {
392            field: "goal_vector",
393        })?;
394    Ok(())
395}
396
397fn finite_lab(field: &'static str, lab: Lab) -> Result<(), ScoreError> {
398    finite_value(field, lab.l)?;
399    finite_value(field, lab.a)?;
400    finite_value(field, lab.b)?;
401    Ok(())
402}
403
404fn finite_value(field: &'static str, value: f32) -> Result<f32, ScoreError> {
405    if value.is_finite() {
406        return Ok(value);
407    }
408    Err(ScoreError::NonFinite { field })
409}