openapi: 3.1.0
info:
  title: Rigr AI Media Examiner API
  version: "2026-09-04"
  summary: Image moderation and apparent-age estimation in a single call.
  description: |
    Media Examiner detects nudity, exposed intimate anatomy and sexual activity while
    estimating apparent age and developmental stage — all from one image analysis.

    Every field in this document is taken from the production service implementation.

    **Limitation.** Apparent-age and developmental-stage outputs are visual estimates.
    They do not verify identity or establish a person's legal age. High-risk and
    borderline decisions should include appropriate human review.
  contact:
    name: Rigr AI
    email: info@rigr.ai
    url: https://rigr.ai/contact
servers:
  - url: https://api.mes.rigr.ai
    description: Hosted production service

security:
  - ApiKeyHeader: []
  - BearerToken: []

tags:
  - name: Analysis
    description: Image analysis endpoints.
  - name: Age
    description: Age-focused endpoints, including a drop-in for the standalone age service.

paths:
  /classify:
    post:
      tags: [Analysis]
      operationId: classifyImage
      summary: Classify an image by severity, with detections and optional apparent age
      description: |
        Returns an image-level severity classification with the per-object detections that
        justify it. With `estimate_age=true`, every face detection additionally carries an
        apparent age and its calibrated uncertainty — one image, one call.
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              $ref: '#/components/schemas/AnalysisRequest'
      responses:
        '200':
          description: Classification result.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ClassifyResponse' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '413': { $ref: '#/components/responses/PayloadTooLarge' }
        '415': { $ref: '#/components/responses/UnsupportedMediaType' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerError' }

  /predict:
    post:
      tags: [Analysis]
      operationId: predictDetections
      summary: Return raw object detections for an image
      description: |
        Object-level detections without the severity roll-up. Use `/classify` when you want
        the image triaged; use `/predict` when you want the boxes. `/upload` is a legacy alias
        for this operation.
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              $ref: '#/components/schemas/AnalysisRequest'
      responses:
        '200':
          description: Detection result.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PredictResponse' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '413': { $ref: '#/components/responses/PayloadTooLarge' }
        '415': { $ref: '#/components/responses/UnsupportedMediaType' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ServerError' }

  /detect-age:
    post:
      tags: [Age]
      operationId: detectAge
      summary: Detect faces and estimate their ages
      description: |
        Runs detection, filters to faces and estimates ages. The response schema matches the
        standalone Rigr age estimation service, so an existing client can move across by
        changing the URL rather than the parsing code.
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              $ref: '#/components/schemas/DetectAgeRequest'
      responses:
        '200':
          description: Per-frame face age results.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/DetectAgeResponse' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '413': { $ref: '#/components/responses/PayloadTooLarge' }
        '415': { $ref: '#/components/responses/UnsupportedMediaType' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503': { $ref: '#/components/responses/AgeModelUnavailable' }

  /estimate-age:
    post:
      tags: [Age]
      operationId: estimateAge
      summary: Estimate the age of a pre-cropped face
      description: For callers that already hold a face crop and want no object detection.
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file:
                  type: string
                  format: binary
                  description: A cropped face image.
      responses:
        '200':
          description: Age estimate for the supplied crop.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/EstimateAgeResponse' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503': { $ref: '#/components/responses/AgeModelUnavailable' }

components:
  securitySchemes:
    ApiKeyHeader:
      type: apiKey
      in: header
      name: X-API-KEY
      description: 'Either this header or `Authorization: Bearer <key>` is accepted.'
    BearerToken:
      type: http
      scheme: bearer
      description: Either this or the `X-API-KEY` header is accepted.

  schemas:
    AnalysisRequest:
      type: object
      required: [file]
      properties:
        file:
          type: string
          format: binary
          description: |
            One image. JPEG, PNG, GIF, WebP, BMP, TIFF, AVIF, HEIC and HEIF are accepted,
            including animated PNG and GIF, which are analysed frame by frame.
            Maximum 50 MB.
        model:
          type: string
          enum: [VisualyzeV2, FullBodyLarge]
          default: VisualyzeV2
          description: Detection profile.
        score:
          type: number
          format: float
          default: 0.25
          minimum: 0
          maximum: 1
          description: Detection score threshold. Detections below this are not returned.
        iou:
          type: number
          format: float
          default: 0.45
          minimum: 0
          maximum: 1
          description: IoU threshold for non-maximum suppression.
        topk:
          type: integer
          default: 100
          minimum: 1
          description: Maximum detections returned per frame.
        estimate_age:
          type: string
          enum: ["true", "false"]
          default: "false"
          description: |
            Set to `true` to run facial age estimation on detected faces in the same
            inference pass. Age is opt-in; it is not run unless requested.

    DetectAgeRequest:
      type: object
      required: [file]
      properties:
        file: { type: string, format: binary }
        model:
          type: string
          enum: [VisualyzeV2, FullBodyLarge]
          default: VisualyzeV2
        score:
          type: number
          format: float
          default: 0.6
          description: |
            Face-detection score threshold. The age path defaults higher than `/predict`
            because quality is carried by the per-face uncertainty rather than by a hard gate.
        iou: { type: number, format: float, default: 0.45 }
        topk: { type: integer, default: 100 }

    BBox:
      type: object
      description: |
        Normalised bounding box, **top-left origin, x/y/width/height** — not a corner pair.
        All values are fractions of the frame's width or height, in the range 0 to 1.
      required: [x, y, w, h]
      properties:
        x: { type: number, format: float, minimum: 0, maximum: 1 }
        y: { type: number, format: float, minimum: 0, maximum: 1 }
        w: { type: number, format: float, minimum: 0, maximum: 1 }
        h: { type: number, format: float, minimum: 0, maximum: 1 }

    AgeFields:
      type: object
      description: Present on face detections only, and only when `estimate_age=true`.
      properties:
        age:
          type: number
          format: float
          description: Apparent age in years.
        age_uncertainty:
          type: number
          format: float
          description: |
            Calibrated standard deviation of the estimate, in years. Lower is more confident.
            Use it to route: a tight interval well clear of your threshold can be decided
            automatically, a wide one straddling it should go to a human.
        original_class_name:
          type: string
          description: |
            Present only when the age model's bracket disagreed with the detector's label and
            overrode it. Carries the detector's original label, for audit.

    Detection:
      allOf:
        - type: object
          required: [class_id, class_name, score, bbox]
          properties:
            class_id: { type: integer }
            class_name:
              type: string
              description: Detected class, e.g. `Adult Female Face`, `Adult Breast`, `Intercourse`.
            score: { type: number, format: float, minimum: 0, maximum: 1 }
            bbox: { $ref: '#/components/schemas/BBox' }
            frame_index:
              type: integer
              description: Frame that produced this detection. Still images always report 0.
        - $ref: '#/components/schemas/AgeFields'

    ClassifiedDetection:
      allOf:
        - type: object
          required: [class_name, score, bbox]
          properties:
            class_name: { type: string }
            score: { type: number, format: float, minimum: 0, maximum: 1 }
            bbox: { $ref: '#/components/schemas/BBox' }
            ucs_sexual_content:
              type: [string, 'null']
              enum: [Not Sexual, Exploitative, Sexualized Situation, Overtly Sexualized Posing, Non-Penetrative Sexual Activity, Penetrative Sexual Activity, null]
              description: |
                Abstract sexual-content category this class maps to. `null` for classes that
                carry no sexual-content meaning of their own — faces, clothing, jewellery,
                life-stage classes — which contribute no severity.
            ucs_flags:
              type: array
              items: { $ref: '#/components/schemas/Flag' }
        - $ref: '#/components/schemas/AgeFields'

    Flag:
      type: string
      enum: [Self-Generated, Sadomasochism, CG Elements, Bodily Fluids]
      description: Contextual indicator carried by a detected class.

    Classification:
      type: object
      required: [key, display_name, severity]
      properties:
        key:
          type: string
          enum: [rigr-none, rigr-exploitative, rigr-posing, rigr-non-penetrative, rigr-penetrative]
          description: Machine-readable identifier. Stable; treat as the value to switch on.
        display_name:
          type: string
          description: Human-readable label. Presentation only — do not key logic on it.
        severity:
          type: integer
          enum: [0, 2, 3, 4, 5]
          description: |
            Highest severity implied by any detection in the image.
            **The scale is 0, 2, 3, 4, 5 — the classifier never emits 1.**
            Level 1 exists in the wider review scale as an analyst verdict
            (explicit adult material judged benign on review), which no model assigns.
            0 no sexual content · 2 exploitative or suggestive · 3 overt sexualised posing ·
            4 non-penetrative sexual activity · 5 penetrative sexual activity.
            This is a triage scale, not a statutory one; thresholds differ by jurisdiction.

    ClassifyResponse:
      type: object
      required: [classification, flags, detections]
      properties:
        classification: { $ref: '#/components/schemas/Classification' }
        flags:
          type: array
          items: { $ref: '#/components/schemas/Flag' }
          description: Sorted union of the contextual flags across all detections.
        detections:
          type: array
          items: { $ref: '#/components/schemas/ClassifiedDetection' }
          description: The supporting rationale for the classification.
        filename: { type: string }
        model: { type: [string, 'null'] }
        sha256:
          type: string
          description: SHA-256 of the uploaded bytes, for your own audit trail.

    PredictResponse:
      type: object
      properties:
        filename: { type: string }
        model: { type: [string, 'null'] }
        sha256: { type: string }
        det_count: { type: integer }
        top_labels:
          type: array
          items: { type: string }
        detections:
          type: array
          items: { $ref: '#/components/schemas/Detection' }
        animated: { type: boolean }
        frame_count: { type: integer }
        processing_ms:
          type: number
          format: float
          description: Server-side preprocessing plus inference time. Excludes transport.
        frames:
          type: array
          description: Per-frame detections plus preprocessing metadata for overlay rendering.
          items:
            type: object
            properties:
              frame_index: { type: integer }
              det_count: { type: integer }
              detections:
                type: array
                items: { $ref: '#/components/schemas/Detection' }
              meta:
                type: object
                properties:
                  orig_w: { type: integer }
                  orig_h: { type: integer }
                  scale: { type: number, format: float }
                  pad_w: { type: integer }
                  pad_h: { type: integer }
                  input_size: { type: integer }

    DetectAgeResponse:
      type: object
      properties:
        error: { type: [string, 'null'] }
        results:
          type: array
          description: One entry per frame. A still image has a single entry at idx 0.
          items:
            type: object
            properties:
              idx: { type: integer }
              error: { type: [string, 'null'] }
              results:
                type: array
                items:
                  type: object
                  properties:
                    idx: { type: integer }
                    age: { type: number, format: float }
                    uncertainty: { type: number, format: float }
                    bbox:
                      type: array
                      description: "Pixel coordinates [x_min, y_min, x_max, y_max] — note this endpoint differs from /classify and /predict, which return normalised x/y/w/h."
                      items: { type: integer }
                      minItems: 4
                      maxItems: 4
                    score: { type: number, format: float }
                    source:
                      type: string
                      description: Face class name after any age-model override.

    EstimateAgeResponse:
      type: object
      properties:
        age: { type: number, format: float }
        uncertainty: { type: number, format: float }
        age_bracket:
          type: string
          enum: [Infant, Toddler, Prepubescent, Pubescent, Adult]
          description: |
            Apparent developmental stage. Boundaries: Infant 0–0.9, Toddler 1–4.9,
            Prepubescent 5–12.9, Pubescent 13–17.9, Adult 18+.

    Error:
      type: object
      properties:
        error: { type: string }
        detail: { type: string }

  responses:
    BadRequest:
      description: Empty upload, or the image could not be decoded.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    Unauthorized:
      description: '`missing_api_key` when no key was sent, `invalid_api_key` when it was rejected.'
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    PayloadTooLarge:
      description: Upload exceeds the 50 MB limit.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    UnsupportedMediaType:
      description: File extension or content type is not a supported raster image.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    RateLimited:
      description: |
        Key exceeded its quota — 120 requests per 60 seconds by default.
        Back off exponentially (1s, 2s, 4s, up to 30s) before retrying.
      headers:
        x-rate-limit-remaining:
          schema: { type: integer }
          description: Requests left in the current window. Returned on successful responses too.
      content:
        application/json:
          schema:
            allOf:
              - $ref: '#/components/schemas/Error'
              - type: object
                properties:
                  retry_after_seconds: { type: integer }
    ServerError:
      description: '`inference_unavailable` — a transient container problem. Retry with backoff.'
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    AgeModelUnavailable:
      description: The facial age estimation model is not loaded on this deployment.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
