1use std::{borrow::Cow, path::PathBuf};
2
3use chromaframe_sdk::{GoalVector, Lab};
4use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
5use serde::{Deserialize, Deserializer, Serialize};
6
7pub const DEFAULT_CONFIDENCE: f32 = 0.65;
8pub const DEFAULT_RANKING_LIMIT: usize = 5;
9pub const MAX_RANKING_LIMIT: usize = 25;
10pub const MAX_CANDIDATE_COUNT: usize = 64;
11pub const MAX_CANDIDATE_NAME_BYTES: usize = 80;
12pub const MAX_PATH_BYTES: usize = 4096;
13pub const MAX_ENCODED_IMAGE_BYTES: u64 = 64 * 1024 * 1024;
14
15#[derive(Debug, Clone, Deserialize)]
16#[serde(deny_unknown_fields)]
17pub struct ReadinessInput {
18 #[serde(default)]
19 pub include_warnings: bool,
20}
21
22impl JsonSchema for ReadinessInput {
23 fn schema_name() -> Cow<'static, str> {
24 "ReadinessInput".into()
25 }
26
27 fn schema_id() -> Cow<'static, str> {
28 concat!(module_path!(), "::ReadinessInput").into()
29 }
30
31 fn json_schema(_generator: &mut SchemaGenerator) -> Schema {
32 json_schema!({
33 "type": "object",
34 "additionalProperties": false,
35 "properties": {
36 "include_warnings": { "type": "boolean", "default": false }
37 }
38 })
39 }
40}
41
42#[derive(Debug, Clone, Deserialize)]
43#[serde(deny_unknown_fields)]
44pub struct ManualRankInput {
45 pub skin_lab: LabInput,
46
47 #[serde(default)]
48 pub brow_lab: Option<LabInput>,
49
50 #[serde(default)]
51 pub iris_lab: Option<LabInput>,
52
53 #[serde(default)]
54 pub sclera_lab: Option<LabInput>,
55
56 #[serde(default)]
57 pub lip_lab: Option<LabInput>,
58
59 #[serde(default)]
60 pub hair_lab: Option<LabInput>,
61
62 #[serde(default)]
63 pub beard_lab: Option<LabInput>,
64
65 #[serde(default)]
66 pub goal_vector: GoalVectorInput,
67
68 #[serde(default = "default_confidence")]
69 pub confidence: f32,
70
71 #[serde(default = "default_ranking_limit")]
72 pub limit: usize,
73
74 pub candidates: Vec<CandidateInput>,
75}
76
77impl JsonSchema for ManualRankInput {
78 fn schema_name() -> Cow<'static, str> {
79 "ManualRankInput".into()
80 }
81
82 fn schema_id() -> Cow<'static, str> {
83 concat!(module_path!(), "::ManualRankInput").into()
84 }
85
86 fn json_schema(generator: &mut SchemaGenerator) -> Schema {
87 let lab_schema = generator.subschema_for::<LabInput>();
88 let candidate_schema = generator.subschema_for::<CandidateInput>();
89 let goal_schema = generator.subschema_for::<GoalVectorInput>();
90 json_schema!({
91 "type": "object",
92 "additionalProperties": false,
93 "required": ["skin_lab", "candidates"],
94 "properties": {
95 "skin_lab": lab_schema,
96 "brow_lab": lab_schema,
97 "iris_lab": lab_schema,
98 "sclera_lab": lab_schema,
99 "lip_lab": lab_schema,
100 "hair_lab": lab_schema,
101 "beard_lab": lab_schema,
102 "goal_vector": goal_schema,
103 "confidence": { "type": "number", "minimum": 0.0, "maximum": 1.0, "default": DEFAULT_CONFIDENCE },
104 "limit": { "type": "integer", "minimum": 1, "maximum": MAX_RANKING_LIMIT, "default": DEFAULT_RANKING_LIMIT },
105 "candidates": { "type": "array", "minItems": 1, "maxItems": MAX_CANDIDATE_COUNT, "items": candidate_schema }
106 }
107 })
108 }
109}
110
111#[derive(Debug, Clone)]
112pub struct AnalyzeImageInput {
113 pub image_path: PathBuf,
114 pub goal_vector: GoalVectorInput,
115 pub limit: usize,
116 pub candidates: Vec<CandidateInput>,
117}
118
119impl<'de> Deserialize<'de> for AnalyzeImageInput {
120 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
121 where
122 D: Deserializer<'de>,
123 {
124 #[derive(Deserialize)]
125 #[serde(deny_unknown_fields)]
126 struct RawAnalyzeImageInput {
127 image_path: String,
128 #[serde(default)]
129 goal_vector: GoalVectorInput,
130 #[serde(default = "default_ranking_limit")]
131 limit: usize,
132 candidates: Vec<CandidateInput>,
133 }
134
135 let raw = RawAnalyzeImageInput::deserialize(deserializer)?;
136 let image_path = parse_non_blank_path(raw.image_path, "image_path")?;
137
138 Ok(Self {
139 image_path,
140 goal_vector: raw.goal_vector,
141 limit: raw.limit,
142 candidates: raw.candidates,
143 })
144 }
145}
146
147impl JsonSchema for AnalyzeImageInput {
148 fn schema_name() -> Cow<'static, str> {
149 "AnalyzeImageInput".into()
150 }
151
152 fn schema_id() -> Cow<'static, str> {
153 concat!(module_path!(), "::AnalyzeImageInput").into()
154 }
155
156 fn json_schema(generator: &mut SchemaGenerator) -> Schema {
157 let candidate_schema = generator.subschema_for::<CandidateInput>();
158 let goal_schema = generator.subschema_for::<GoalVectorInput>();
159 json_schema!({
160 "type": "object",
161 "additionalProperties": false,
162 "required": ["image_path", "candidates"],
163 "properties": {
164 "image_path": { "type": "string", "minLength": 1, "maxLength": MAX_PATH_BYTES, "pattern": ".*\\S.*" },
165 "goal_vector": goal_schema,
166 "limit": { "type": "integer", "minimum": 1, "maximum": MAX_RANKING_LIMIT, "default": DEFAULT_RANKING_LIMIT },
167 "candidates": { "type": "array", "minItems": 1, "maxItems": MAX_CANDIDATE_COUNT, "items": candidate_schema }
168 }
169 })
170 }
171}
172
173#[derive(Debug, Clone, Copy, Deserialize, Serialize, JsonSchema, PartialEq)]
174#[serde(deny_unknown_fields)]
175pub struct LabInput {
176 pub l: f32,
177 pub a: f32,
178 pub b: f32,
179}
180
181impl From<LabInput> for Lab {
182 fn from(value: LabInput) -> Self {
183 Self {
184 l: value.l,
185 a: value.a,
186 b: value.b,
187 }
188 }
189}
190
191impl From<Lab> for LabInput {
192 fn from(value: Lab) -> Self {
193 Self {
194 l: value.l,
195 a: value.a,
196 b: value.b,
197 }
198 }
199}
200
201#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
202#[serde(deny_unknown_fields)]
203pub struct GoalVectorInput {
204 #[serde(default = "default_contrast_target")]
205 pub contrast_target: f32,
206 #[serde(default)]
207 pub warmth_target: f32,
208 #[serde(default = "default_chroma_target")]
209 pub chroma_target: f32,
210 #[serde(default = "default_feature_readability_target")]
211 pub feature_readability_target: f32,
212 #[serde(default = "default_artificiality_tolerance")]
213 pub artificiality_tolerance: f32,
214}
215
216impl JsonSchema for GoalVectorInput {
217 fn schema_name() -> Cow<'static, str> {
218 "GoalVectorInput".into()
219 }
220
221 fn schema_id() -> Cow<'static, str> {
222 concat!(module_path!(), "::GoalVectorInput").into()
223 }
224
225 fn json_schema(_generator: &mut SchemaGenerator) -> Schema {
226 json_schema!({
227 "type": "object",
228 "additionalProperties": false,
229 "properties": {
230 "contrast_target": { "type": "number", "minimum": 0.0, "maximum": 1.0, "default": default_contrast_target() },
231 "warmth_target": { "type": "number", "minimum": -1.0, "maximum": 1.0, "default": 0.0 },
232 "chroma_target": { "type": "number", "minimum": 0.0, "maximum": 1.0, "default": default_chroma_target() },
233 "feature_readability_target": { "type": "number", "minimum": 0.0, "maximum": 1.0, "default": default_feature_readability_target() },
234 "artificiality_tolerance": { "type": "number", "minimum": 0.0, "maximum": 1.0, "default": default_artificiality_tolerance() }
235 }
236 })
237 }
238}
239
240impl Default for GoalVectorInput {
241 fn default() -> Self {
242 let goal = GoalVector::default();
243 Self {
244 contrast_target: goal.contrast_target,
245 warmth_target: goal.warmth_target,
246 chroma_target: goal.chroma_target,
247 feature_readability_target: goal.feature_readability_target,
248 artificiality_tolerance: goal.artificiality_tolerance,
249 }
250 }
251}
252
253impl From<GoalVectorInput> for GoalVector {
254 fn from(value: GoalVectorInput) -> Self {
255 Self {
256 contrast_target: value.contrast_target,
257 warmth_target: value.warmth_target,
258 chroma_target: value.chroma_target,
259 feature_readability_target: value.feature_readability_target,
260 artificiality_tolerance: value.artificiality_tolerance,
261 }
262 }
263}
264
265#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
266#[serde(deny_unknown_fields)]
267pub struct CandidateInput {
268 pub name: String,
269 pub srgb: [u8; 3],
270}
271
272impl<'de> Deserialize<'de> for CandidateInput {
273 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
274 where
275 D: Deserializer<'de>,
276 {
277 #[derive(Deserialize)]
278 #[serde(deny_unknown_fields)]
279 struct RawCandidateInput {
280 name: String,
281 srgb: [u8; 3],
282 }
283
284 let raw = RawCandidateInput::deserialize(deserializer)?;
285 let name = parse_candidate_name::<D::Error>(raw.name)?;
286 Ok(Self {
287 name,
288 srgb: raw.srgb,
289 })
290 }
291}
292
293impl JsonSchema for CandidateInput {
294 fn schema_name() -> Cow<'static, str> {
295 "CandidateInput".into()
296 }
297
298 fn schema_id() -> Cow<'static, str> {
299 concat!(module_path!(), "::CandidateInput").into()
300 }
301
302 fn json_schema(_generator: &mut SchemaGenerator) -> Schema {
303 json_schema!({
304 "type": "object",
305 "additionalProperties": false,
306 "required": ["name", "srgb"],
307 "properties": {
308 "name": { "type": "string", "minLength": 1, "maxLength": MAX_CANDIDATE_NAME_BYTES, "pattern": ".*\\S.*" },
309 "srgb": {
310 "type": "array",
311 "minItems": 3,
312 "maxItems": 3,
313 "items": { "type": "integer", "minimum": 0, "maximum": 255 }
314 }
315 }
316 })
317 }
318}
319
320#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
321#[serde(deny_unknown_fields)]
322pub struct ReadinessOutput {
323 pub sdk_available: bool,
324 pub vision_helper_available: bool,
325 pub python_version: Option<String>,
326 pub missing_packages: Vec<String>,
327 pub missing_models: Vec<String>,
328 pub warnings: Vec<String>,
329}
330
331#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
332#[serde(deny_unknown_fields)]
333pub struct ManualRankOutput {
334 pub status: String,
335 pub subject: SubjectSummary,
336 pub rankings: Vec<CandidateRankingSummary>,
337 pub uncertainty: String,
338}
339
340#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
341#[serde(deny_unknown_fields)]
342pub struct AnalyzeImageOutput {
343 pub image: ImageSummary,
344 pub extraction: ExtractionSummary,
345 pub measurement: MeasurementSummary,
346 pub rankings: Vec<CandidateRankingSummary>,
347 pub evidence_cards: Vec<EvidenceCardSummary>,
348 pub warnings: Vec<String>,
349 pub limitations: Vec<String>,
350}
351
352#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
353#[serde(deny_unknown_fields)]
354pub struct ImageSummary {
355 pub dimensions: [u32; 2],
356 pub measurement_mode: String,
357 pub icc_status: String,
358 pub metadata_retained: bool,
359}
360
361#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
362#[serde(deny_unknown_fields)]
363pub struct ExtractionSummary {
364 pub backend: String,
365 pub faces_detected: u32,
366 pub selected_face_index: Option<u32>,
367 pub regions: Vec<RegionSummary>,
368}
369
370#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
371#[serde(deny_unknown_fields)]
372pub struct RegionSummary {
373 pub kind: String,
374 pub status: String,
375 pub source: String,
376 pub confidence: f32,
377 pub sample_hint: Option<usize>,
378 pub reason: Option<String>,
379}
380
381#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
382#[serde(deny_unknown_fields)]
383pub struct MeasurementSummary {
384 pub status: String,
385 pub confidence: Option<f32>,
386 pub subject: Option<SubjectSummary>,
387 pub contrasts: Vec<ContrastSummary>,
388}
389
390#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
391#[serde(deny_unknown_fields)]
392pub struct SubjectSummary {
393 pub skin_lab: LabInput,
394 pub skin_ita: f32,
395 pub skin_depth_proxy: f32,
396}
397
398#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
399#[serde(deny_unknown_fields)]
400pub struct ContrastSummary {
401 pub from: String,
402 pub to: String,
403 pub delta_l: f32,
404 pub delta_a: f32,
405 pub delta_b: f32,
406 pub delta_e00: f32,
407 pub michelson_lightness_contrast: f32,
408}
409
410#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
411#[serde(deny_unknown_fields)]
412pub struct CandidateRankingSummary {
413 pub name: String,
414 pub score: f32,
415 pub confidence: f32,
416 pub harshness: f32,
417 pub label: String,
418}
419
420#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
421#[serde(deny_unknown_fields)]
422pub struct EvidenceCardSummary {
423 pub kind: String,
424 pub summary: String,
425}
426
427fn parse_non_blank_path<E>(raw_path: String, field: &'static str) -> Result<PathBuf, E>
428where
429 E: serde::de::Error,
430{
431 let trimmed = raw_path.trim();
432 if trimmed.is_empty() {
433 return Err(E::custom(format!("`{field}` cannot be blank")));
434 }
435 if trimmed.len() > MAX_PATH_BYTES {
436 return Err(E::custom(format!(
437 "`{field}` exceeds {MAX_PATH_BYTES} UTF-8 bytes"
438 )));
439 }
440
441 Ok(PathBuf::from(trimmed))
442}
443
444fn parse_candidate_name<E>(raw_name: String) -> Result<String, E>
445where
446 E: serde::de::Error,
447{
448 let trimmed = raw_name.trim();
449 if trimmed.is_empty() {
450 return Err(E::custom("candidate `name` cannot be blank"));
451 }
452 if trimmed.len() > MAX_CANDIDATE_NAME_BYTES {
453 return Err(E::custom(format!(
454 "candidate `name` exceeds {MAX_CANDIDATE_NAME_BYTES} UTF-8 bytes"
455 )));
456 }
457
458 Ok(trimmed.to_string())
459}
460
461const fn default_confidence() -> f32 {
462 DEFAULT_CONFIDENCE
463}
464
465const fn default_ranking_limit() -> usize {
466 DEFAULT_RANKING_LIMIT
467}
468
469fn default_contrast_target() -> f32 {
470 GoalVector::default().contrast_target
471}
472
473fn default_chroma_target() -> f32 {
474 GoalVector::default().chroma_target
475}
476
477fn default_feature_readability_target() -> f32 {
478 GoalVector::default().feature_readability_target
479}
480
481fn default_artificiality_tolerance() -> f32 {
482 GoalVector::default().artificiality_tolerance
483}
484
485pub fn validate_limit(limit: usize) -> Result<usize, String> {
486 if !(1..=MAX_RANKING_LIMIT).contains(&limit) {
487 return Err(format!("limit must be between 1 and {MAX_RANKING_LIMIT}"));
488 }
489
490 Ok(limit)
491}
492
493pub fn validate_candidate_count(count: usize) -> Result<usize, String> {
494 if !(1..=MAX_CANDIDATE_COUNT).contains(&count) {
495 return Err(format!(
496 "candidate count must be between 1 and {MAX_CANDIDATE_COUNT}"
497 ));
498 }
499
500 Ok(count)
501}