| """ |
| 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 |
|
|
| |
| 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, |
| ) |
|
|
| |
| _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: |
| |
| target_h = h |
| target_w = int(target_h * target_ratio) |
| img = img.resize((target_w, target_h), Image.LANCZOS) |
| |
| left = (target_w - (target_w)) // 2 |
| |
| |
| 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: |
| |
| 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) |
|
|
|
|
| |
|
|
|
|
| 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). |
| """ |
| |
| cvd_grid = [] |
| for img_bytes, label in gallery: |
| try: |
| img = Image.open(io.BytesIO(img_bytes)).convert('RGB') |
| except Exception: |
| |
| 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) |