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}