color-ux-access / backend_api.py
Carlos Salgado
add server app and tests (#20)
5b4acc3
Raw
History Blame Contribute Delete
6.23 kB
"""
backend_api.py β€” Pure Python wrappers for CVD gallery + VLM pipeline.
All I/O is bytes + JSON only (no Gradio components).
Reuses core logic from app.py but adds 4:3 thumbnail resizing.
"""
from __future__ import annotations
import io
import base64
from typing import List, Tuple, Dict, Any
from PIL import Image
import numpy as np
from daltonlens import simulate
# Reuse simulators and config from app.py (read-only import)
from app import (
deficiency_config,
simulator as _machado_sim,
severe_simulator as _vienot_sim,
tritan_simulator as _brettel_sim,
analyze_all_perspectives as _analyze_all_perspectives,
format_wcag_report as _format_wcag_report,
_call_minicpm_endpoint,
_VLM_CVD_PROMPTS,
_ACCESSIBILITY_SYSTEM_PROMPT,
)
# Map deficiency_config keys to simulator + deficiency enum
_CVD_LABEL_MAP: Dict[str, str] = {
'protanopia': 'protanopia',
'severe_protanopia': 'severe_protanopia',
'deuteranopia': 'deuteranopia',
'severe_deuteranopia': 'severe_deuteranopia',
'tritanopia': 'tritanopia',
'protanomaly': 'protanomaly',
'deuteranomaly': 'deuteranomaly',
'tritanomaly': 'tritanomaly',
}
def _get_simulator_and_deficiency(cvd_name: str):
"""Return (simulator, deficiency_enum) for a CVD type."""
cfg = deficiency_config[cvd_name]
return cfg['simulator'], cfg['deficiency'], cfg['severity']
def _cvd_name_to_label(cvd_name: str) -> str:
"""Human-readable label for a CVD deficiency name."""
labels = {
'protanopia': 'Protanopia (red-blind)',
'severe_protanopia': 'Severe Protanopia (red-blind)',
'deuteranopia': 'Deuteranopia (green-blind)',
'severe_deuteranopia': 'Severe Deuteranopia (green-blind)',
'tritanopia': 'Tritanopia (blue-blind)',
'protanomaly': 'Protanomaly (red-weak)',
'deuteranomaly': 'Deuteranomaly (green-weak)',
'tritanomaly': 'Tritanomaly (blue-weak)',
}
return labels.get(cvd_name, cvd_name)
def _resize_to_4_3(img: Image.Image) -> Image.Image:
"""Resize image to 4:3 aspect ratio with center crop.
Steps:
1. Calculate target height for given width (4:3).
2. If image is taller than target, crop top/bottom (center crop).
3. If image is wider than target, resize height to match (letterbox-style fit).
4. Final size is always (width, round(width * 3/4)) or proportional.
"""
w, h = img.size
target_ratio = 4 / 3
current_ratio = w / h
if current_ratio > target_ratio:
# Image is wider than 4:3 β€” resize to target height, crop width
target_h = h
target_w = int(target_h * target_ratio)
img = img.resize((target_w, target_h), Image.LANCZOS)
# Center crop to exact 4:3
left = (target_w - (target_w)) // 2 # already correct
# Actually when current_ratio > target, img is resized to target_h
# Then we center-crop to 4:3 width
left = (target_w - int(target_h * target_ratio)) // 2
right = left + int(target_h * target_ratio)
img = img.crop((left, 0, right, target_h))
else:
# Image is taller/narrower than 4:3 β€” resize to target width
target_w = w
target_h = int(target_w / target_ratio)
img = img.resize((target_w, target_h), Image.LANCZOS)
return img
def _simulate_cvd_image(original: Image.Image, cvd_name: str) -> Image.Image:
"""Simulate a specific CVD type on a PIL Image and resize to 4:3."""
sim, deficiency, severity = _get_simulator_and_deficiency(cvd_name)
arr = np.asarray(original.convert('RGB'))
cvd_arr = sim.simulate_cvd(arr, deficiency, severity)
cvd_img = Image.fromarray(cvd_arr)
return _resize_to_4_3(cvd_img)
# ── Public API ────────────────────────────────────────────────────────────────
def api_generate_gallery_from_bytes(image_bytes: bytes) -> List[Tuple[bytes, str]]:
"""
Given raw screenshot bytes, return a list of (image_bytes, label) for all 8 CVD variants.
Each simulated image is:
- Processed through the correct CVD simulator (Machado/Vienot/Brettel)
- Resized to 4:3 aspect ratio (center-cropped or letterboxed)
- PNG-encoded and returned as bytes
Returns:
List of 8 (png_bytes, label_str) tuples.
"""
try:
original = Image.open(io.BytesIO(image_bytes)).convert('RGB')
except Exception as e:
raise ValueError(f"Could not open image: {e}") from e
gallery = []
for cvd_name in deficiency_config:
label = _cvd_name_to_label(cvd_name)
simulated = _simulate_cvd_image(original, cvd_name)
buf = io.BytesIO()
simulated.save(buf, format='PNG')
gallery.append((buf.getvalue(), label))
return gallery
def api_analyze_cvd_grid_from_bytes(
gallery: List[Tuple[bytes, str]]
) -> Dict[str, Any]:
"""
Given a list of (image_bytes, label) tuples, reconstruct PIL images and run
analyze_all_perspectives. Return the merged JSON result.
Args:
gallery: List of (png_bytes, label_str) as produced by api_generate_gallery_from_bytes.
Returns:
Dict with keys: 'findings', 'summary', 'passes' (same structure as VLM JSON).
"""
# Reconstruct PIL images for analyze_all_perspectives
cvd_grid = []
for img_bytes, label in gallery:
try:
img = Image.open(io.BytesIO(img_bytes)).convert('RGB')
except Exception:
# If image bytes are corrupted, skip this item
continue
cvd_grid.append((img, label))
if not cvd_grid:
return {
'error': 'No valid images in gallery',
'findings': [],
'passes': False,
}
return _analyze_all_perspectives(cvd_grid)
def api_report_from_json(vlm_result: Dict[str, Any]) -> str:
"""
Return the markdown accessibility report using format_wcag_report.
Args:
vlm_result: Dict with 'findings', 'summary', 'passes' (and optionally 'error').
Returns:
Markdown string.
"""
return _format_wcag_report(vlm_result)