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
- DetectLabels — call
DetectLabelsCommandwithMinConfidence: 70andMaxLabels: 50as production defaults; bounding boxes are fractions (Left × imageWidth→ pixel x), not pixels; useParents[]to match at category level (label.Parents.some(p => p.Name === 'Animal')catches Dog, Cat, Bird etc. without hardcoding breeds); add'IMAGE_PROPERTIES'toFeaturesto getDominantColors[]withHexCode,CSSColor, andSimplifiedColor(10-word vocabulary); for video stored in S3 use the asyncStartLabelDetection/GetLabelDetectionpair — labels includeTimestamp(ms) so you can query "when does X first appear": S3 input accepts up to 15 MB; bytes input accepts up to 5 MB.Features: ['GENERAL_LABELS']is the default;'IMAGE_PROPERTIES'adds colour palette at no extra API call.MinConfidenceis applied server-side — set it as high as the use case allows to avoid paying for low-confidence labels you would filter client-side. For object-presence checks,MinConfidence: 90andMaxLabels: 5is a fast path. For search indexing,MinConfidence: 60andMaxLabels: 100maximises recall. API default isMinConfidence: 55andMaxLabels: 10— always set both explicitly to avoid depending on API defaults that can change. Video pagination: loopGetLabelDetectionwithNextTokenuntil undefined; sort bySortBy: 'TIMESTAMP'to get temporal order;MaxResults: 1000per call. IAM:rekognition:DetectLabels+rekognition:StartLabelDetection+rekognition:GetLabelDetection+s3:GetObjecton the image bucket. - Face collections — create once with
CreateCollectionCommand, guardResourceAlreadyExistsException; index withIndexFacesCommandusingExternalImageId(your user/photo UUID) +QualityFilter: 'AUTO'+MaxFaces: 1; search withSearchFacesByImageCommandusingFaceMatchThreshold: 95for identity verification or80for photo grouping; compare two images without a collection usingCompareFacesCommand; delete vectors withDeleteFacesCommand(FaceIds[])for GDPR erasure — this removes the numerical vector but not any copy of the image you stored in S3:ExternalImageIdis the bridge: store the returnedFaceIdUUID in your database alongside the user record so you can callDeleteFacesCommandwithout scanning the collection.QualityFilter: 'AUTO'rejects blurry, occluded, or extreme-pose faces — logUnindexedFaces[].Reasons[]for troubleshooting. Similarity is cosine-based: identical twins score 97–99.5; same person across a 10-year age gap scores 85–95;FaceMatchThreshold: 99+for high-security /95for standard auth /80for grouping /60–70for soft suggestions.CompareFacessource image = reference (must contain exactly one face); target image = verification photo;UnmatchedFacestells you there are other people in the target. Collection model version is pinned at creation — to upgrade, create a new collection and re-index all faces. IAM:rekognition:CreateCollection+rekognition:IndexFaces+rekognition:SearchFacesByImage+rekognition:CompareFaces+rekognition:DeleteFacesscoped toarn:aws:rekognition:*:*:collection/your-prefix-*. - DetectText OCR — extract only
LINEblocks (d.Type === 'LINE') for complete readable strings; sort byBoundingBox.TopthenLeftusing a 0.02 tolerance to group text at the same vertical position before left-to-right ordering; for rotated text useGeometry.Polygon[0].Y(top-left point) for row grouping rather thanBoundingBox.Topwhich inflates for angled text; useFilters.RegionsOfInterestto restrict detection to known text zones (applied server-side, reduces latency and noise); cap at 100 words per image — if you need more or need tables/key-value pairs from documents, switch to Textract:WORDblocks link to parentLINEviaParentId— use them when you need per-word confidence scores (e.g., reject lines where any WORD falls below your threshold).BoundingBoxis axis-aligned (always horizontal);Polygonis a 4-point quadrilateral that hugs rotated text — the first two points are the top edge, soMath.atan2(tr.Y - tl.Y, tr.X - tl.X) × 180/πgives the rotation angle. For standard horizontal text,BoundingBoxsort is correct and simpler.RegionsOfInterestaccepts multiple bounding boxes (e.g., header + footer of a fixed-format form). Video text detection: asyncStartTextDetection/GetTextDetection; each result includesTimestamp(ms); groupLINEblocks by timestamp to get a text timeline; deduplicate consecutive identical strings if you want unique text appearances. IAM:rekognition:DetectText+rekognition:StartTextDetection+rekognition:GetTextDetection. - Content moderation — call
DetectModerationLabelsCommandwithMinConfidence: 50(the review threshold) to surface borderline labels; gate on categories with emptyParentNamefor hard blocks; gate on subcategories for allow-lists (e.g., allow "Barechested Male" on fitness platforms but block all of "Explicit Nudity"); use two thresholds in application code — confidence ≥ 80 auto-block, 50–79 route to A2I human review viaHumanLoopConfig; never store images permanently before passing the moderation gate — upload to quarantine prefix first, check, then copy to permanent storage and delete from quarantine:ModerationModelVersionis returned in the response — log it for audit trail and to correlate false-positive rate changes with model updates.HumanLoopNamemust be unique per request — useDate.now()plus sanitised S3 key.DataAttributes.ContentClassifiers: ['FreeOfPersonallyIdentifiableInformation']enables Mechanical Turk workforce eligibility; use'FreeOfAdultContent'for content without explicit images but possibly other PII. The quarantine pattern: image uploaded toquarantine/prefix (not publicly accessible),DetectModerationLabelscalled on quarantine copy, violation →DeleteObjectCommandfrom quarantine (never reaches permanent storage), safe →CopyObjectCommandto permanent key + delete from quarantine; S3 lifecycle rule auto-deletes quarantine objects older than 24 hours to prevent accumulation. For video:StartContentModerationCommand/GetContentModerationCommand;first_violation_at_mslets you jump to the offending moment for human review without scanning the entire clip.
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:
Parents[]— hierarchical ancestors, most-specific first. Uselabel.Parents.some(p => p.Name === 'Animal')to match any animal without enumerating every species and breed. The parent chain also gives you confidence at a coarser level — if "Golden Retriever" at 98.4% has the parent chain Dog → Canine → Pet → Animal, you implicitly know the image contains an animal at 98.4% confidence without asking for the "Animal" label separately.Instances[]— only populated for localizable objects (people, animals, vehicles). Each instance has aBoundingBoxgiving the spatial extent of that individual object within the image. Scene labels like "Beach" have emptyInstances[]. If you need to count how many dogs are in an image, countLabels.find(l => l.Name === 'Dog')?.Instances?.length ?? 0.Categories[]— coarse groupings (Animals and Pets, Nature and Outdoors, Activities and Events). Useful for a quick content-category classification pass without inspecting every label name.
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 case | MinConfidence | MaxLabels | Why |
|---|---|---|---|
| Content classification gate | 80 | 20 | High precision; only confident, prominent labels matter for routing decisions |
| Search indexing / tagging | 60 | 100 | Recall-favoured; edge-case labels improve findability in long-tail queries |
| Alt-text generation | 70 | 50 | Balanced; enough labels for a descriptive sentence without noise |
| Object presence check | 90 | 5 | Fast path — only highly confident, top-5 labels needed for a binary decision |
| Debug / exploration | 50 | 1000 | See 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:
| Threshold | Use case | Trade-off |
|---|---|---|
| 99+ | High-security identity verification | Very few false positives; misses valid matches from aging, lighting, angle changes |
| 95 | Standard auth (login, access control) | Low false positive rate; recommended for most authentication workflows |
| 80 (API default) | Photo grouping, de-duplication | Catches the same person across varied conditions; some false positives among similar-looking people |
| 60–70 | Soft 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:
| Criterion | Rekognition DetectText | AWS Textract |
|---|---|---|
| Max words per image | 100 | Unlimited |
| Table/form extraction | No | Yes (KEY_VALUE_SET, TABLE blocks) |
| Video support | Yes (async) | No |
| Rotated/angled text | Yes (Polygon geometry) | Limited (expects horizontal text) |
| Multi-page PDFs | No | Yes (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 Nudity | Graphic Male Nudity, Graphic Female Nudity, Sexual Activity, Illustrated Explicit Nudity |
| Suggestive | Barechested Male, Female Swimwear or Underwear, Revealing Clothes |
| Violence | Graphic Violence or Gore, Physical Violence, Weapon Violence, Weapons |
| Visually Disturbing | Emaciated Bodies, Corpses, Hanging |
| Rude Gestures | Middle Finger |
| Drugs | Drug Products, Drug Use, Pills, Drug Paraphernalia |
| Tobacco | Tobacco Products, Smoking |
| Alcohol | Alcoholic Beverages, Drinking |
| Gambling | Gaming, Slot Machines |
| Hate Symbols | Nazi 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:
'FreeOfPersonallyIdentifiableInformation'— reviewers can safely view the image (no faces, identifiers, or personal data). Required for Mechanical Turk eligibility.'FreeOfAdultContent'— content does not contain adult images. Required for Mechanical Turk on non-explicit moderation tasks.- Private teams can review any content without these classifiers.
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
| # | Check | Details |
|---|---|---|
| 1 | Bounding-box arithmetic uses fractions not pixels | Left × imageWidth → pixel x. All Rekognition bounding boxes (labels, faces, text, moderation) are fractional. |
| 2 | DetectLabels: MinConfidence and MaxLabels set explicitly | Never rely on API defaults (55 and 10). Set both on every call per the tuning table. |
| 3 | Label matching uses Parents[] not just Name | label.Parents.some(p => p.Name === 'Animal') catches Dog, Cat, Bird etc. without enumerating every breed. |
| 4 | Face collection: ExternalImageId is your user/photo UUID | Store the returned FaceId alongside the user record in your database. Without it, GDPR erasure requires scanning the whole collection. |
| 5 | FaceMatchThreshold is set for the specific workflow | 95 for identity verification; 80 for photo grouping. The API default (80) is not safe for authentication contexts. |
| 6 | QualityFilter: 'AUTO' and UnindexedFaces logged | Auto-rejects blurry/extreme-pose faces. Log UnindexedFaces[].Reasons[] — silent failures confuse users who uploaded low-quality images. |
| 7 | DeleteFacesCommand called on GDPR erasure | Removes the face vector. Separately delete any copy of the original image in S3 — Rekognition does not track that. |
| 8 | DetectText reading-order sort uses 0.02 tolerance | if (Math.abs(topDiff) > 0.02) return topDiff; return leftDiff; — without tolerance, fractional-pixel differences scatter same-row words into wrong sort groups. |
| 9 | Rotated text sorted by Polygon[0].Y not BoundingBox.Top | Axis-aligned BoundingBox inflates for rotated text, misplacing it relative to horizontal neighbours. Use Polygon[0].Y (top-left corner) for correct row grouping. |
| 10 | Content moderation gates on subcategories for allow-lists | Never allow-list at the category level. Allow "Barechested Male" (subcategory), not "Suggestive" (category). Allow "Weapons" not "Violence". |
| 11 | Two 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. |
| 12 | Pre-upload quarantine-then-move pattern for permanent storage | Image stays in quarantine until moderation passes. Violations deleted from quarantine before they reach permanent storage. Add 24-hour lifecycle auto-delete on quarantine prefix. |
| 13 | ModerationModelVersion logged for audit trail | Returned in every DetectModerationLabels response. Log alongside detection results to correlate false-positive rate changes with model version updates. |
| 14 | Video jobs: paginate GetLabelDetection / GetTextDetection / GetContentModeration | All 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. |