AWS Rekognition · 2026-11-04 · Rekognition arc

AWS Rekognition Vision APIs for MCP Servers: Object Detection, Face Collections, Text OCR, and Content Moderation

AliveMCP probes every registered MCP endpoint every 60 seconds — and the vision-related failure patterns we see cluster around four Rekognition APIs that most MCP server builders treat as simpler than they are: a DetectLabels integration that works in development but produces wrong bounding-box overlays in production because the developer assumed pixel coordinates and Rekognition returns fractions of image dimensions; a face collection that indexes successfully but never finds matches because the similarity threshold is left at the API default of 80, which is calibrated for photo-grouping rather than identity verification, so users who enrolled with a well-lit selfie are rejected when they present a darker webcam shot; a DetectText OCR tool that randomly fails to find text in rotated images because the code sorts detected lines by BoundingBox.Top rather than the top-left Polygon point, misplacing 45°-rotated blocks relative to their horizontal neighbours; and a content moderation gate that blocks legitimate fitness and medical images because it gates on the top-level "Suggestive" category rather than the specific subcategories that are actually unacceptable on the platform. Four Rekognition APIs close the gap between a demo vision integration and a production-grade image intelligence layer for MCP servers. DetectLabels identifies objects, scenes, and activities using a hierarchical label taxonomy where every label carries both ancestry (via Parents[]) and spatial localization (via Instances[] with fractional bounding boxes); understanding the taxonomy lets you match at a category level ("any Animal") without enumerating every breed, and understanding the coordinate system prevents the production bounding-box arithmetic bug. Rekognition face collections are persistent stores of facial feature vectors — not stored images — that power workflows from "has this person enrolled?" to "find all photos containing this individual"; the key non-obvious decisions are using ExternalImageId as the bridge between Rekognition's internal FaceId UUID and your application identity, keeping a user_id → face_id mapping in your own database for GDPR erasure, and choosing FaceMatchThreshold based on the specific security requirements of the workflow rather than the API default. DetectText returns a flat array of LINE and WORD blocks linked by ParentId — extracting only LINE blocks with a 0.02-tolerance top-position sort gives correct reading order for horizontal text, while rotated text requires sorting by the top-left Polygon point rather than the axis-aligned BoundingBox.Top; and RegionsOfInterest applied server-side eliminates false detections from background textures without any client-side filtering. DetectModerationLabels classifies images against a two-tier taxonomy of harmful content — the production pattern uses two separate thresholds (a hard-block threshold of 80 for automatic rejection and a review threshold of 50 passed as MinConfidence to surface borderline labels), gates on subcategories for allow-lists rather than top-level categories to avoid blocking legitimate fitness and medical content, and uses HumanLoopConfig to route borderline images to A2I reviewers rather than auto-blocking them. This guide synthesizes all four Rekognition patterns into a complete vision intelligence stack. Pattern 1 covers DetectLabels end to end. Pattern 2 covers face collection lifecycle and similarity threshold design. Pattern 3 covers DetectText block structure and reading-order reconstruction. Pattern 4 covers moderation gate design and A2I integration.

TL;DR

Pattern 1: DetectLabels — object detection with hierarchical taxonomy and fractional bounding boxes

When an AI agent needs to understand the visual content of an image — "what objects are in this photo?", "is there a person in this frame?", "what's the dominant scene type?" — DetectLabels is the entry point. The API classifies images against a large label vocabulary and returns each detection with a hierarchical taxonomy that makes category-level matching practical without hardcoding every possible specific label.

Label structure: Names, Parents, Instances, Categories

The response's Labels[] array mixes labels at different taxonomy levels for the same image. For a photo of a golden retriever on a beach, the response includes "Golden Retriever" (with Parents: Dog → Canine → Pet → Animal), "Beach" (with Parents: Outdoors → Nature), and "Sports" (no parents). Three fields determine which level to work at:

MinConfidence and MaxLabels tuning

The API defaults (MinConfidence: 55, MaxLabels: 10) are calibrated for general exploration, not production use cases. Five tunings cover the main scenarios:

Use caseMinConfidenceMaxLabelsWhy
Content classification gate8020High precision; only confident, prominent labels matter for routing decisions
Search indexing / tagging60100Recall-favoured; edge-case labels improve findability in long-tail queries
Alt-text generation7050Balanced; enough labels for a descriptive sentence without noise
Object presence check905Fast path — only highly confident, top-5 labels needed for a binary decision
Debug / exploration501000See everything Rekognition found regardless of confidence

MinConfidence is applied server-side — you're not paying to transfer low-confidence labels you'd filter client-side. Set it as high as the use case allows. Both parameters should be set explicitly in every production call; never depend on API defaults that may change.

Fractional bounding-box arithmetic

Every bounding box in Rekognition's response — for labels, faces, text, and moderation — uses fractional coordinates, not pixel offsets. BoundingBox.Left is the left edge as a fraction of image width (0.0–1.0), not a pixel count. This resolution-independent format is lossless but requires a conversion step before overlaying boxes in a UI:

// BoundingBox { Left: 0.12, Top: 0.35, Width: 0.44, Height: 0.52 } on a 1920×1080 image:
function toPixelBox(bb: BoundingBox, imageWidth: number, imageHeight: number) {
  return {
    x: Math.round(bb.Left * imageWidth),     // 0.12 × 1920 = 230
    y: Math.round(bb.Top * imageHeight),     // 0.35 × 1080 = 378
    w: Math.round(bb.Width * imageWidth),    // 0.44 × 1920 = 845
    h: Math.round(bb.Height * imageHeight),  // 0.52 × 1080 = 562
  };
}

// Enhanced MCP tool that returns both fractional and pixel boxes
server.tool('detect_labels_with_positions', {
  image_source: z.union([
    z.object({ s3_uri: z.string() }),
    z.object({ base64: z.string() }),
  ]),
  image_width: z.number().int().optional(),
  image_height: z.number().int().optional(),
  min_confidence: z.number().default(70),
}, async ({ image_source, image_width, image_height, min_confidence }) => {
  const image = resolveImage(image_source);
  const { Labels = [] } = await rekognition.send(new DetectLabelsCommand({
    Image: image,
    MinConfidence: min_confidence,
    MaxLabels: 100,
    Features: ['GENERAL_LABELS'],
  }));

  return {
    content: [{
      type: 'text',
      text: JSON.stringify(
        Labels.map(label => ({
          name: label.Name,
          confidence: label.Confidence,
          parents: label.Parents?.map(p => p.Name) ?? [],
          instances: label.Instances?.map(inst => ({
            bounding_box: inst.BoundingBox,
            pixel_box: image_width && image_height
              ? toPixelBox(inst.BoundingBox!, image_width, image_height)
              : null,
          })) ?? [],
        })),
        null, 2
      ),
    }],
  };
});

When the agent doesn't know image dimensions ahead of time, return the raw fractional values and let the calling application convert. The fractional format is lossless regardless of display resolution.

ImageProperties for dominant colours

Adding 'IMAGE_PROPERTIES' to the Features array returns dominant colour information alongside labels — no extra API call, same request. Each DominantColor entry has HexCode (e.g., '#4A7FC1'), CSSColor (e.g., 'Blue'), and SimplifiedColor which collapses the full RGB space into a 10-word vocabulary: Red, Orange, Yellow, Green, Blue, Purple, Pink, Black, White, Grey. The simplified vocabulary is useful for image-search filtering ("show me blue products") without building a custom colour classifier.

Async video label detection

For video files stored in S3, DetectLabels is unavailable — use the async pair. The two-tool MCP pattern starts the job and returns a JobId, then a second tool polls for results:

// Tool 1: start the job
server.tool('start_video_label_detection', {
  s3_uri: z.string(),
}, async ({ s3_uri }) => {
  const url = new URL(s3_uri);
  const { JobId } = await rekognition.send(new StartLabelDetectionCommand({
    Video: { S3Object: { Bucket: url.hostname, Name: url.pathname.slice(1) } },
    MinConfidence: 70,
  }));
  return { content: [{ type: 'text', text: JSON.stringify({ job_id: JobId }) }] };
});

// Tool 2: get results (paginate with NextToken)
server.tool('get_video_label_results', {
  job_id: z.string(),
}, async ({ job_id }) => {
  const allLabels: any[] = [];
  let nextToken: string | undefined;

  do {
    const result = await rekognition.send(new GetLabelDetectionCommand({
      JobId: job_id,
      SortBy: 'TIMESTAMP',
      MaxResults: 1000,
      NextToken: nextToken,
    }));

    if (result.JobStatus === 'FAILED') {
      return { content: [{ type: 'text', text: JSON.stringify({ error: result.StatusMessage }) }] };
    }
    if (result.JobStatus !== 'SUCCEEDED') {
      return { content: [{ type: 'text', text: JSON.stringify({ status: 'in_progress' }) }] };
    }

    allLabels.push(...(result.Labels ?? []));
    nextToken = result.NextToken;
  } while (nextToken);

  // Timestamp (ms from video start) enables temporal queries
  const summary = allLabels.map(l => ({
    timestamp_ms: l.Timestamp,
    label: l.Label?.Name,
    confidence: l.Label?.Confidence,
  }));
  return { content: [{ type: 'text', text: JSON.stringify({ labels: summary }) }] };
});

The Timestamp field (milliseconds from video start) enables queries like "when does a person first appear?" or "at what timestamps does a car enter the frame?" — build a temporal index by grouping labels by Timestamp.

Pattern 2: Face collections — IndexFaces, similarity threshold design, and GDPR lifecycle

Rekognition face collections are persistent, indexed stores of facial feature vectors — not stored images. The service computes a numerical vector from each face and stores only that vector. An MCP server uses a collection to build workflows from "is this person already registered?" to "find all photos containing this individual" to "verify that the person presenting this document matches their enrollment photo."

Collection creation and ExternalImageId as the application bridge

Collections are regional resources — a collection in us-east-1 is not accessible from eu-west-1. The collection model version is pinned at creation time; to use a newer face model, create a new collection and re-index all faces. Guard ResourceAlreadyExistsException for idempotent provisioning:

async function ensureCollection(collectionId: string): Promise<void> {
  try {
    await rekognition.send(new CreateCollectionCommand({ CollectionId: collectionId }));
  } catch (err: any) {
    if (err.name !== 'ResourceAlreadyExistsException') throw err;
  }
}

// Inspect an existing collection
const { FaceCount, FaceModelVersion } = await rekognition.send(
  new DescribeCollectionCommand({ CollectionId: 'users-collection' })
);
console.log(`${FaceCount} faces indexed, model v${FaceModelVersion}`);

When indexing faces, ExternalImageId is the most important parameter — it's the bridge between Rekognition's internal FaceId UUID and your application's user/photo identity. Store the returned FaceId in your own database alongside the user record; without it, finding the vector for a specific user at GDPR erasure time requires scanning the entire collection:

server.tool('index_face', {
  collection_id: z.string(),
  s3_bucket: z.string(),
  s3_key: z.string(),
  external_id: z.string().max(255),  // your user UUID or photo ID
}, async ({ collection_id, s3_bucket, s3_key, external_id }) => {
  const { FaceRecords = [], UnindexedFaces = [] } = await rekognition.send(
    new IndexFacesCommand({
      CollectionId: collection_id,
      Image: { S3Object: { Bucket: s3_bucket, Name: s3_key } },
      ExternalImageId: external_id,
      MaxFaces: 1,          // only index the largest/most prominent face
      QualityFilter: 'AUTO', // skip blurry, occluded, or extreme-pose faces
    })
  );

  if (FaceRecords.length === 0) {
    // Log UnindexedFaces[0].Reasons for troubleshooting
    const reason = UnindexedFaces[0]?.Reasons?.join(', ') ?? 'unknown';
    return { content: [{ type: 'text', text: JSON.stringify({ success: false, reason }) }] };
  }

  return {
    content: [{ type: 'text', text: JSON.stringify({
      success: true,
      face_id: FaceRecords[0].Face?.FaceId,     // store this in your database
      external_id: FaceRecords[0].Face?.ExternalImageId,
      confidence: FaceRecords[0].Face?.Confidence,
    }) }],
  };
});

QualityFilter: 'AUTO' rejects faces with LOW_BRIGHTNESS, LOW_SHARPNESS, EXTREME_POSE, or EXCEEDS_MAX_FACES. Log UnindexedFaces[].Reasons[] — users sometimes upload passport scans that are too dark or selfies with extreme head tilt. If you expected one face but got FaceRecords.length === 0 without logging the reason, the user sees a silent failure.

Similarity threshold design

Similarity is a cosine-based score (0–100) measuring how close two face vectors are in the high-dimensional feature space. The right threshold depends on the specific security requirements of the workflow:

ThresholdUse caseTrade-off
99+High-security identity verificationVery few false positives; misses valid matches from aging, lighting, angle changes
95Standard auth (login, access control)Low false positive rate; recommended for most authentication workflows
80 (API default)Photo grouping, de-duplicationCatches the same person across varied conditions; some false positives among similar-looking people
60–70Soft suggestions ("you might know…")High recall; expect false positives; always show similarity score to user

Identical twins typically score 97–99.5. The same person across a 10-year age gap typically scores 85–95. For MCP server auth workflows, FaceMatchThreshold: 95 is the right default — lower thresholds designed for photo grouping will produce unacceptable false-positive rates in an authentication context.

Direct comparison without a collection

CompareFacesCommand compares two images without requiring a collection — useful for one-off identity checks (ID document vs selfie) where you don't need persistent storage of the reference face:

server.tool('compare_two_faces', {
  source_s3_uri: z.string(),   // reference image: exactly one face (e.g., ID document)
  target_s3_uri: z.string(),   // verification image: may contain multiple faces
  similarity_threshold: z.number().default(95),
}, async ({ source_s3_uri, target_s3_uri, similarity_threshold }) => {
  const parseS3 = (uri: string) => {
    const url = new URL(uri);
    return { S3Object: { Bucket: url.hostname, Name: url.pathname.slice(1) } };
  };

  const { FaceMatches = [], UnmatchedFaces = [] } = await rekognition.send(
    new CompareFacesCommand({
      SourceImage: parseS3(source_s3_uri),
      TargetImage: parseS3(target_s3_uri),
      SimilarityThreshold: similarity_threshold,
    })
  );

  const topMatch = FaceMatches.sort((a, b) => (b.Similarity ?? 0) - (a.Similarity ?? 0))[0];
  return {
    content: [{ type: 'text', text: JSON.stringify({
      match: FaceMatches.length > 0,
      similarity: topMatch?.Similarity ?? null,
      unmatched_faces_in_target: UnmatchedFaces.length,  // other people in the target photo
    }) }],
  };
});

UnmatchedFaces tells you there are other people in the target image — useful for detecting "this photo contains the reference person plus someone else" in contexts where that matters (e.g., group photos presented as solo selfies for identity verification).

GDPR erasure: face lifecycle management

Rekognition stores only the numerical feature vector, not any image pixels. DeleteFacesCommand removes the vector permanently. If your application stored a copy of the indexed image in S3, that must be deleted separately. Keep a database table mapping user_id → face_id[] so erasure requests can be processed without scanning the collection:

// GDPR right-to-erasure: delete the face vector by FaceId
await rekognition.send(new DeleteFacesCommand({
  CollectionId: 'users-collection',
  FaceIds: ['face-uuid-1', 'face-uuid-2'],  // up to 1000 per call
}));
// Also delete the original image from S3 separately — Rekognition does not track that

Unlike many AWS services, Rekognition collection operations support resource-level ARN scoping: arn:aws:rekognition:us-east-1:123456789012:collection/users-*. Use this to give different MCP server instances access to different collections without sharing a single broad policy.

Pattern 3: DetectText OCR — LINE/WORD blocks, reading-order sort, and RegionsOfInterest

DetectText is Rekognition's OCR API for natural-scene text: signs, screenshots, packaging labels, licence plates, text overlaid on video frames. Unlike Textract (which is optimised for multi-page scanned documents), DetectText handles curved text on cans, neon signs at oblique angles, and in-scene text at arbitrary orientations — but caps at 100 words per image.

Block structure: LINE and WORD linked by ParentId

The TextDetections[] response is a flat array mixing two block types. Each WORD has a ParentId pointing to its parent LINE. For most use cases, extract only LINE entries — they contain the full line text and don't require reassembly:

server.tool('extract_text_from_image', {
  s3_bucket: z.string(),
  s3_key: z.string(),
  min_confidence: z.number().default(80),
  reading_order: z.boolean().default(true),
}, async ({ s3_bucket, s3_key, min_confidence, reading_order }) => {
  const { TextDetections = [] } = await rekognition.send(new DetectTextCommand({
    Image: { S3Object: { Bucket: s3_bucket, Name: s3_key } },
    Filters: { WordFilter: { MinConfidence: min_confidence } },
  }));

  let lines = TextDetections
    .filter(d => d.Type === 'LINE' && (d.Confidence ?? 0) >= min_confidence)
    .map(d => ({
      text: d.DetectedText ?? '',
      confidence: Math.round((d.Confidence ?? 0) * 10) / 10,
      bounding_box: d.Geometry?.BoundingBox,
    }));

  if (reading_order) {
    lines = lines.sort((a, b) => {
      const topDiff = (a.bounding_box?.Top ?? 0) - (b.bounding_box?.Top ?? 0);
      // 0.02 tolerance groups text at the same visual row before left-to-right sort
      if (Math.abs(topDiff) > 0.02) return topDiff;
      return (a.bounding_box?.Left ?? 0) - (b.bounding_box?.Left ?? 0);
    });
  }

  return {
    content: [{
      type: 'text',
      text: JSON.stringify({ full_text: lines.map(l => l.text).join('\n'), line_count: lines.length, lines }, null, 2),
    }],
  };
});

The 0.02 tolerance is the key detail. Without it, fractional-pixel differences in top position scatter words from the same visual row into separate sort groups and produce garbled reading order. For dense tables, tighten to 0.01; for large-print signage, loosen to 0.05.

Use WORD blocks when you need per-word confidence — for example, to reject lines where any single word falls below your threshold (useful for serial numbers, licence plates, or any context where every character must be reliable):

const wordsByLine = new Map<number, any[]>();
for (const d of TextDetections) {
  if (d.Type === 'WORD' && d.ParentId !== undefined) {
    if (!wordsByLine.has(d.ParentId)) wordsByLine.set(d.ParentId, []);
    wordsByLine.get(d.ParentId)!.push(d);
  }
}

// Reject lines where any word falls below threshold
const reliableLines = TextDetections
  .filter(d => d.Type === 'LINE')
  .filter(line => {
    const words = wordsByLine.get(line.Id!) ?? [];
    return words.every(w => (w.Confidence ?? 0) >= 85);
  })
  .map(line => line.DetectedText);

BoundingBox vs Polygon for rotated text

Every detection has two geometry representations. BoundingBox is an axis-aligned rectangle — it always has horizontal/vertical edges regardless of text orientation, so a 45°-rotated block has a large inflated bounding box that misrepresents its true position. Polygon is a 4-point quadrilateral that hugs the actual text orientation:

// Estimate rotation angle from Polygon (useful for deskewing)
function estimateTextAngle(polygon: Array<{X: number; Y: number}>): number {
  const [tl, tr] = polygon; // first two points = top edge
  const dx = tr.X - tl.X;
  const dy = tr.Y - tl.Y;
  return Math.atan2(dy, dx) * (180 / Math.PI);
}

// Convert fractional polygon to pixel coordinates
function polygonToPixels(polygon: Array<{X: number; Y: number}>, w: number, h: number) {
  return polygon.map(pt => ({ x: Math.round(pt.X * w), y: Math.round(pt.Y * h) }));
}

// For rotated text, sort by Polygon[0].Y (top-left point), not BoundingBox.Top
const sortedByPolygon = TextDetections
  .filter(d => d.Type === 'LINE')
  .sort((a, b) => {
    const topA = a.Geometry?.Polygon?.[0]?.Y ?? a.Geometry?.BoundingBox?.Top ?? 0;
    const topB = b.Geometry?.Polygon?.[0]?.Y ?? b.Geometry?.BoundingBox?.Top ?? 0;
    return topA - topB;
  });

For standard horizontal text, BoundingBox sort is correct and simpler. Switch to Polygon[0].Y sorting only when processing images with known rotation (angled documents, in-scene signage at oblique angles, text on tilted objects).

RegionsOfInterest for fixed-layout images

For fixed-layout images where text appears in predictable zones — a camera feed where licence plates are always in the lower-right quadrant, a form template where data fields are in known positions — RegionsOfInterest restricts detection server-side, reducing both latency and noise:

const { TextDetections = [] } = await rekognition.send(new DetectTextCommand({
  Image: { S3Object: { Bucket: 'traffic-cam-frames', Name: 'frame-00123.jpg' } },
  Filters: {
    RegionsOfInterest: [
      {
        BoundingBox: { Left: 0.60, Top: 0.65, Width: 0.40, Height: 0.35 },
        // right 40%, bottom 35% — where the licence plate zone always appears
      },
    ],
    WordFilter: { MinConfidence: 85 },
  },
}));

Multiple regions are supported — pass an array for header + footer extraction from fixed-format forms. Text outside the specified regions is not evaluated, eliminating false detections from background textures or unrelated text in the frame without any client-side filtering.

When to escalate to Textract

Rekognition DetectText is designed for in-scene text. When you hit its limits, switch to AWS Textract:

CriterionRekognition DetectTextAWS Textract
Max words per image100Unlimited
Table/form extractionNoYes (KEY_VALUE_SET, TABLE blocks)
Video supportYes (async)No
Rotated/angled textYes (Polygon geometry)Limited (expects horizontal text)
Multi-page PDFsNoYes (up to 3,000 pages async)
Price (per page)$0.0015 / image$0.0015–$0.065 depending on features

The 100-word limit is a hard ceiling — dense documents, forms with dozens of fields, and anything resembling a structured page hit it immediately. For in-scene photos of signs, labels, screenshots, and video frames, Rekognition is the right choice and is priced identically to Textract's cheapest tier.

Pattern 4: Content moderation — two-threshold gate, subcategory allow-lists, and A2I human review

DetectModerationLabels classifies images against a two-tier taxonomy of harmful content. The main production anti-pattern is using a single threshold for both auto-block and auto-allow decisions — in practice, there's a wide band of borderline content where automated decisions have unacceptable error rates in both directions, and that band belongs in a human review queue rather than being decided by a threshold.

Two-tier taxonomy: categories and subcategories

The response's ModerationLabels[] uses a two-level hierarchy. Top-level labels have empty ParentName (e.g., "Explicit Nudity"). Subcategory labels have a filled ParentName (e.g., "Barechested Male" with ParentName: "Suggestive"). The 10 top-level categories and their representative subcategories:

Category (ParentName = "")Example subcategories
Explicit NudityGraphic Male Nudity, Graphic Female Nudity, Sexual Activity, Illustrated Explicit Nudity
SuggestiveBarechested Male, Female Swimwear or Underwear, Revealing Clothes
ViolenceGraphic Violence or Gore, Physical Violence, Weapon Violence, Weapons
Visually DisturbingEmaciated Bodies, Corpses, Hanging
Rude GesturesMiddle Finger
DrugsDrug Products, Drug Use, Pills, Drug Paraphernalia
TobaccoTobacco Products, Smoking
AlcoholAlcoholic Beverages, Drinking
GamblingGaming, Slot Machines
Hate SymbolsNazi Party, White Supremacy, Extremist

Subcategory allow-lists are the mechanism for context-aware moderation. A fitness platform blocks "Explicit Nudity" but allows "Barechested Male" (a child of "Suggestive"). A news site blocks "Graphic Violence or Gore" but allows "Weapons" (sports photography with firearms). A bar platform allows "Alcoholic Beverages". Implement the allow-list at the subcategory level — blocking the entire "Suggestive" category blocks legitimate fitness and medical images, which is exactly the false-positive class that generates the most user complaints.

Two-threshold gate: auto-block + human review

The production pattern uses two thresholds and a single MinConfidence set to the lower (review) threshold so the API surfaces borderline labels:

const HARD_BLOCK_THRESHOLD = 80;  // auto-reject at this confidence
const REVIEW_THRESHOLD = 50;      // passed as MinConfidence — surfaces borderline labels

const HARD_BLOCK_CATEGORIES = new Set(['Explicit Nudity', 'Violence', 'Visually Disturbing', 'Hate Symbols']);
const ALLOW_LISTED_SUBCATEGORIES = new Set(['Barechested Male', 'Weapons', 'Alcoholic Beverages']);

const { ModerationLabels = [] } = await rekognition.send(
  new DetectModerationLabelsCommand({
    Image: { S3Object: { Bucket: s3_bucket, Name: s3_key } },
    MinConfidence: REVIEW_THRESHOLD,  // returns labels in 50–100 band
    HumanLoopConfig: {
      HumanLoopName: `review-${Date.now()}-${s3_key.replace(/\//g, '-')}`,
      FlowDefinitionArn: 'arn:aws:sagemaker:us-east-1:123456789012:flow-definition/image-review',
      DataAttributes: {
        ContentClassifiers: ['FreeOfPersonallyIdentifiableInformation'],
      },
    },
  })
);

// Application-code branching on the two thresholds
const hardBlockLabels = ModerationLabels.filter(l => {
  const isBlocked = HARD_BLOCK_CATEGORIES.has(l.ParentName || l.Name || '');
  const isAllowListed = ALLOW_LISTED_SUBCATEGORIES.has(l.Name || '');
  return isBlocked && !isAllowListed && (l.Confidence ?? 0) >= HARD_BLOCK_THRESHOLD;
});

const reviewLabels = ModerationLabels.filter(l => {
  const c = l.Confidence ?? 0;
  return c >= REVIEW_THRESHOLD && c < HARD_BLOCK_THRESHOLD;
});

if (hardBlockLabels.length > 0) {
  return { action: 'block', reason: 'Image violates content policy' };  // don't expose label names
} else if (reviewLabels.length > 0) {
  return { action: 'pending_review' };  // A2I loop triggered above
} else {
  return { action: 'allow' };
}

Never expose specific label names in user-facing rejection messages — exposing category names teaches evasion. Log them internally for moderation analytics, but return a generic policy violation message to the end user.

A2I human review integration

HumanLoopConfig integrates with Amazon Augmented AI (A2I) to route borderline images to human reviewers rather than auto-blocking them. The flow definition is configured in the AWS Console or via the SageMaker API — it specifies the workforce (Mechanical Turk, private team, or vendor workforce), the task UI template, and the work team.

HumanLoopName must be unique per request (per AWS account per region). Using a timestamp plus a sanitised S3 key provides uniqueness without a UUID library dependency.

DataAttributes.ContentClassifiers affects workforce eligibility:

Pre-upload quarantine-then-move gate

The key architectural decision for content moderation is when moderation runs relative to permanent storage. The safe pattern is: upload to a quarantine prefix first (not publicly accessible), moderate while still in quarantine, move to permanent storage only on pass:

server.tool('upload_image_with_safety_check', {
  quarantine_bucket: z.string(),
  quarantine_key: z.string(),   // e.g. 'quarantine/user-123/photo.jpg'
  final_bucket: z.string(),
  final_key: z.string(),        // e.g. 'uploads/user-123/photo.jpg'
}, async ({ quarantine_bucket, quarantine_key, final_bucket, final_key }) => {
  const { ModerationLabels = [] } = await rekognition.send(
    new DetectModerationLabelsCommand({
      Image: { S3Object: { Bucket: quarantine_bucket, Name: quarantine_key } },
      MinConfidence: 60,
    })
  );

  const violations = ModerationLabels.filter(
    l => HARD_BLOCK_CATEGORIES.has(l.ParentName || l.Name || '')
  );

  if (violations.length > 0) {
    // Delete from quarantine — image never reaches permanent storage
    await s3.send(new DeleteObjectCommand({ Bucket: quarantine_bucket, Key: quarantine_key }));
    return { content: [{ type: 'text', text: JSON.stringify({ success: false, reason: 'Image violates content policy' }) }] };
  }

  // Safe — move to permanent storage
  await s3.send(new CopyObjectCommand({
    CopySource: `${quarantine_bucket}/${quarantine_key}`,
    Bucket: final_bucket, Key: final_key,
  }));
  await s3.send(new DeleteObjectCommand({ Bucket: quarantine_bucket, Key: quarantine_key }));

  return { content: [{ type: 'text', text: JSON.stringify({ success: true, stored_at: `s3://${final_bucket}/${final_key}` }) }] };
});

Add an S3 lifecycle rule to auto-delete quarantine objects older than 24 hours — this prevents quarantine bucket accumulation if the moderation step or the move step fails without cleanup. The quarantine prefix should have no public access policy.

Async video content moderation

For video files, use the async pair. The key addition over image moderation is the first_violation_at_ms timestamp — it lets you jump directly to the offending moment for human review rather than scanning the entire clip:

server.tool('get_video_moderation_results', {
  job_id: z.string(),
}, async ({ job_id }) => {
  const result = await rekognition.send(new GetContentModerationCommand({
    JobId: job_id, SortBy: 'TIMESTAMP', MaxResults: 1000,
  }));

  if (result.JobStatus !== 'SUCCEEDED') {
    return { content: [{ type: 'text', text: JSON.stringify({ status: result.JobStatus }) }] };
  }

  const violations = (result.ModerationLabels ?? []).filter(
    d => HARD_BLOCK_CATEGORIES.has(d.ModerationLabel?.ParentName || d.ModerationLabel?.Name || '')
  );

  return { content: [{ type: 'text', text: JSON.stringify({
    safe: violations.length === 0,
    first_violation_at_ms: violations[0]?.Timestamp ?? null,  // jump to this moment in the clip
    violation_count: violations.length,
    violation_timeline: violations.slice(0, 20).map(v => ({
      timestamp_ms: v.Timestamp,
      category: v.ModerationLabel?.ParentName || v.ModerationLabel?.Name,
      confidence: v.ModerationLabel?.Confidence,
    })),
  }) }] };
});

For video platforms, use first_violation_at_ms to auto-generate a clip of the flagged segment as the human review artifact, rather than submitting the entire video to A2I. This reduces reviewer time and A2I costs.

Production checklist

#CheckDetails
1Bounding-box arithmetic uses fractions not pixelsLeft × imageWidth → pixel x. All Rekognition bounding boxes (labels, faces, text, moderation) are fractional.
2DetectLabels: MinConfidence and MaxLabels set explicitlyNever rely on API defaults (55 and 10). Set both on every call per the tuning table.
3Label matching uses Parents[] not just Namelabel.Parents.some(p => p.Name === 'Animal') catches Dog, Cat, Bird etc. without enumerating every breed.
4Face collection: ExternalImageId is your user/photo UUIDStore the returned FaceId alongside the user record in your database. Without it, GDPR erasure requires scanning the whole collection.
5FaceMatchThreshold is set for the specific workflow95 for identity verification; 80 for photo grouping. The API default (80) is not safe for authentication contexts.
6QualityFilter: 'AUTO' and UnindexedFaces loggedAuto-rejects blurry/extreme-pose faces. Log UnindexedFaces[].Reasons[] — silent failures confuse users who uploaded low-quality images.
7DeleteFacesCommand called on GDPR erasureRemoves the face vector. Separately delete any copy of the original image in S3 — Rekognition does not track that.
8DetectText reading-order sort uses 0.02 toleranceif (Math.abs(topDiff) > 0.02) return topDiff; return leftDiff; — without tolerance, fractional-pixel differences scatter same-row words into wrong sort groups.
9Rotated text sorted by Polygon[0].Y not BoundingBox.TopAxis-aligned BoundingBox inflates for rotated text, misplacing it relative to horizontal neighbours. Use Polygon[0].Y (top-left corner) for correct row grouping.
10Content moderation gates on subcategories for allow-listsNever allow-list at the category level. Allow "Barechested Male" (subcategory), not "Suggestive" (category). Allow "Weapons" not "Violence".
11Two thresholds: MinConfidence=50 (review), application branching at 80 (block)Pass 50 as MinConfidence to surface borderline labels. Branch in application code: ≥80 auto-block, 50–79 route to A2I, no labels auto-allow.
12Pre-upload quarantine-then-move pattern for permanent storageImage stays in quarantine until moderation passes. Violations deleted from quarantine before they reach permanent storage. Add 24-hour lifecycle auto-delete on quarantine prefix.
13ModerationModelVersion logged for audit trailReturned in every DetectModerationLabels response. Log alongside detection results to correlate false-positive rate changes with model version updates.
14Video jobs: paginate GetLabelDetection / GetTextDetection / GetContentModerationAll three return at most 1000 entries per call. Loop with NextToken. Use first_violation_at_ms (content moderation) and Timestamp (labels/text) for temporal queries.