Skip to content

Advanced Usage

LIQA offers optional advanced methods that can make embedding LIQA more seamless and provide more control over the integration. Use them after the Quick Start is working and you know which user journey your product needs.

Use caseFeature to use
Keep LIQA isolated from complex host-page CSS, scripts, or strict CSP rules when your application needs a clearer embed boundary.Iframe Integration
Use your own upload button or native file picker while still validating the selected image in LIQA.Programmatic upload from host UI
Check whether the participant's current device is good enough for LIQA capture before showing the capture step.Device capability check
Start loading LIQA resources before the user reaches the capture step, so the visible loading time feels shorter.Preload LIQA resources
Return a front-camera image that matches the mirrored preview the user saw during capture, when your downstream UI expects that orientation.Mirror image from front camera
Convert captured photos to Base64 when your backend, native bridge, or upload API does not accept browser Blob objects directly.Convert captured image to Base64
Use anonymized face-image variants when your workflow requires stronger privacy protection while preserving the data needed for processing.Anonymized image processing pipeline
Batch-process existing photos for a quality verdict and/or anonymized output without showing the LIQA UI at all.Headless image processing

Iframe Integration

By default, LIQA runs as a web component rendered inside Shadow DOM (and uses an isolated JavaScript execution context). This keeps the LIQA UI isolated from your application while still allowing you to theme it via the styles attribute.

use-iframe mode renders LIQA inside a sandboxed <iframe> that is loaded from the same LIQA origin as liqa.js. This provides a harder isolation boundary and is useful when your integration needs clearer separation from the host page.

When to use it

  • You need stronger isolation from host page CSS/JS (e.g., large apps with global patches or unpredictable style inheritance).
  • You have a restrictive CSP and want LIQA to run in its own document (you will still need to allow the LIQA origin in frame-src).
  • You want to avoid debugging styling conflicts and treat LIQA as an embedded surface customized only via LIQA config.

When not to use it

  • You rely on globally loaded web fonts and want them to apply to LIQA automatically (use default mode).
  • You cannot allow embedding frames in CSP (frame-src / child-src).

Enable it

html
<hautai-liqa license="LICENSE_KEY_PROVIDED_BY_HAUT.AI" use-iframe></hautai-liqa>

Key differences vs default mode

  • Same API surface: all configuration is still done via <hautai-liqa> attributes and all events (ready, captures, etc.) are dispatched from the host element.
  • Styles: selectors from your app’s global CSS do not reach into LIQA in both modes. In default mode, inherited properties and CSS variables from the host can still influence the UI; in use-iframe mode, inheritance stops at the iframe boundary, so rely on styles for customization.
  • UI scale: in use-iframe mode, inherited host styles do not cross the iframe boundary. If your host app uses a custom root font-size, LIQA can appear smaller or larger than in default mode.
  • :host selectors: in use-iframe mode, :host selectors from styles are translated to :root inside the iframe, so the same styles string works. Avoid relying on selectors like :host([data-theme="dark"]) — provide theme-specific styles from the host app instead.
  • Fonts: fonts loaded in your app (<link rel="stylesheet" ...>) are available in default mode, but not automatically inside the iframe. Load custom fonts via LIQA styles (e.g. @import / @font-face) and set --font-family. See Styles.
  • Basic theming: LIQA propagates these host CSS variables into the iframe (if set on <hautai-liqa>): --background-color, --color, --color-brand, --font-family.
  • Hosting: use-iframe loads liqa-iframe.html from the LIQA origin. If you self-host LIQA assets, make sure this file is available next to liqa.js.
  • CSP: you must allow the LIQA origin in frame-src / child-src in addition to the regular LIQA CSP directives. See Set up CSP.

Troubleshooting

If you see cross-origin errors after enabling use-iframe, upgrade to LIQA 6.25.1 or later.

If LIQA looks scaled after enabling use-iframe, set the base font size explicitly via styles:

html
<hautai-liqa
  use-iframe="true"
  styles="
    :host { font-size: HOST_FONT_SIZE_PX; }
  "
></hautai-liqa>

HOST_FONT_SIZE_PX should match your host app root font size.

Programmatic upload from host UI

Use liqa.upload(file) when your application owns the upload button (or a native file picker) and you only need LIQA to validate the selected image. The file is fed straight into LIQA's Preview/quality flow and delivered through the usual captures event — the same as a camera capture.

liqa.upload(file) does not require upload in sources; the sources attribute only controls which source buttons and fallbacks LIQA renders itself. It can be called at any time, including before the ready event — the file is buffered until LIQA has booted.

Avoiding the camera prompt. For a camera-only configuration LIQA acquires the camera at startup. If your integration is upload-driven and should never request the camera, include upload in sources (for example sources="front_camera,upload", or sources="upload" for upload only). LIQA then keeps the camera idle until the user explicitly selects it, while your own button drives liqa.upload(file).

LIQA accepts PNG, JPEG, and WebP uploads. On Safari, LIQA additionally accepts the HEIC/HEIF family (image/heic, image/heif, .heic, .heif). LIQA's built-in file picker applies this browser-specific list automatically. If your host application owns the picker, use the portable list below or append image/heic,image/heif,.heic,.heif only for Safari users.

html
<input
  id="selfie-upload"
  type="file"
  accept="image/png,image/jpeg,image/webp,.png,.jpg,.jpeg,.webp"
/>
<hautai-liqa license="LICENSE_KEY_PROVIDED_BY_HAUT.AI" sources="front_camera,upload"></hautai-liqa>

<script type="module">
  import "SOURCE_URL_PROVIDED_BY_HAUT_AI/liqa.js"

  const input = document.querySelector("#selfie-upload")
  const liqa = document.querySelector("hautai-liqa")

  input.addEventListener("change", () => {
    const file = input.files?.[0]
    if (file) liqa.upload(file)
  })
</script>

Device capability check

Use checkDeviceCapability() to check, before you show LIQA, whether the participant's phone is good enough to capture a good-quality photo. It resolves to a small result object — capable plus the list of reasons for any failed checks — so you can catch unsupported devices early and tell the participant exactly what to do next, instead of running into problems mid-study.

tsx
tsx
import { checkDeviceCapability } from "SOURCE_URL_PROVIDED_BY_HAUT_AI/liqa.js"   const { capable, reasons } = await checkDeviceCapability()   if (capable) { // Good to go — show LIQA } else { // Show your own message based on `reasons` }
tsx
import { checkDeviceCapability } from "SOURCE_URL_PROVIDED_BY_HAUT_AI/liqa.js"   const { capable, reasons } = await checkDeviceCapability()   if (capable) { // Good to go — show LIQA } else { // Show your own message based on `reasons` }

The default check is static — it does not open the camera or ask for any permission, so it's safe to run as soon as the page loads. It verifies that the participant is on a phone running a supported iOS or Android version, in a browser with the features LIQA needs, and — where the browser reports it — with enough device memory for the analysis.

Each entry in reasons names one failed check:

ReasonMeaningSuggested message to the participant
unsupported-deviceNot a phone (desktop, tablet, or unrecognized device)."Please open this link on your phone."
unsupported-osA phone, but not iOS or Android."Please use an iOS or Android phone."
outdated-osThe OS version is below the supported floor (or couldn't be read)."Please update your phone and try again."
missing-browser-featuresThe browser lacks features LIQA requires."Please try a different or updated browser."
insufficient-memoryThe phone reports too little memory for the analysis."This phone can't run the capture experience."
insufficient-cameraThe front camera is missing or its resolution is too low."This phone's camera isn't sufficient."
camera-unavailableThe camera couldn't be verified — see below. Retryable."Please allow camera access and try again."

Verifying the camera

To also verify the front (selfie) camera — the camera LIQA captures with — pass { camera: true }:

tsx
tsx
import { checkDeviceCapability } from "SOURCE_URL_PROVIDED_BY_HAUT_AI/liqa.js"   const { capable, reasons } = await checkDeviceCapability({ camera: true })   if (!capable && reasons.includes("camera-unavailable")) { // Not a hardware verdict: the participant denied the prompt, the camera was // busy, or the page blocks camera access. Ask them to allow access and retry. }
tsx
import { checkDeviceCapability } from "SOURCE_URL_PROVIDED_BY_HAUT_AI/liqa.js"   const { capable, reasons } = await checkDeviceCapability({ camera: true })   if (!capable && reasons.includes("camera-unavailable")) { // Not a hardware verdict: the participant denied the prompt, the camera was // busy, or the page blocks camera access. Ask them to allow access and retry. }

On Android this opens the camera once (so it asks for camera permission) and checks the front camera's resolution. The two camera outcomes are deliberately distinct: insufficient-camera is a hardware verdict, while camera-unavailable only means the check couldn't run — treat it as retryable rather than as an unsupported device. On iOS the camera is not probed: every iPhone that passes the OS check has a sufficient front camera.

You can also call the check on a mounted element: await document.querySelector("hautai-liqa").checkDeviceCapability().

Browser-only

checkDeviceCapability() inspects the current device, so it must run in the participant's browser. Calling it during server-side rendering throws.

Best frame selection

Best frame selection is available for the face and face-180 presets. It evaluates multiple live-video frames and keeps the sharpest candidate for the final capture. The feature is disabled by default and must be enabled explicitly:

tsx
tsx
<hautai-liqa license="LICENSE_KEY_PROVIDED_BY_HAUT.AI" preset="face" best-frame-selection="true" ></hautai-liqa>
tsx
<hautai-liqa license="LICENSE_KEY_PROVIDED_BY_HAUT.AI" preset="face" best-frame-selection="true" ></hautai-liqa>

Performance with max-quality

Best frame selection adds image allocation and blur-scoring work to the live camera loop. When it is enabled together with mode="max-quality", that work competes with the high-resolution camera pipeline and may materially reduce FPS, especially on mobile devices. Keep this combination disabled unless you have benchmarked it on the devices used by your participants. The combined path requires further performance optimization before it can be recommended as a default.

preload({ preset: "face" }) and preload({ preset: "face-180" }) do not speculatively download the best-frame WASM dependency. When a mounted LIQA session explicitly enables best-frame-selection, its config-aware prefetch loads that dependency automatically.

Preload LIQA resources before LIQA session

Preloading is useful when your product has a step before capture, such as a questionnaire, onboarding screen, consent step, or product recommendation flow. LIQA can download resources during that time so the user sees less of the LIQA loading screen when the capture step begins.

You can achieve this by leveraging the preload API. LIQA supports preloading both preset resources and feature-specific assets like tutorial videos.

Preloading Presets

The following code example illustrates the initiation of the Face preset preloading:

tsx
tsx
import { preload } from "SOURCE_URL_PROVIDED_BY_HAUT_AI/liqa.js"   // Initiate the preloading of the "face" preset's resource in advance // to ensure LIQA is ready when the user encounters it void preload({ preset: "face" })
tsx
import { preload } from "SOURCE_URL_PROVIDED_BY_HAUT_AI/liqa.js"   // Initiate the preloading of the "face" preset's resource in advance // to ensure LIQA is ready when the user encounters it void preload({ preset: "face" })

Preloading Tutorial Videos

To eliminate loading screens and flickering when users access the tutorial, you can preload the tutorial videos:

tsx
tsx
import { preload, FEATURE } from "SOURCE_URL_PROVIDED_BY_HAUT_AI/liqa.js"   // Preload tutorial videos to ensure smooth playback without loading screens void preload({ feature: FEATURE.TUTORIAL })
tsx
import { preload, FEATURE } from "SOURCE_URL_PROVIDED_BY_HAUT_AI/liqa.js"   // Preload tutorial videos to ensure smooth playback without loading screens void preload({ feature: FEATURE.TUTORIAL })

Preloading Both Preset and Tutorial

You can also preload both preset resources and tutorial videos simultaneously:

tsx
tsx
import { preload, FEATURE } from "SOURCE_URL_PROVIDED_BY_HAUT_AI/liqa.js"   // Preload both preset resources and tutorial videos void preload({ preset: "face", feature: FEATURE.TUTORIAL })
tsx
import { preload, FEATURE } from "SOURCE_URL_PROVIDED_BY_HAUT_AI/liqa.js"   // Preload both preset resources and tutorial videos void preload({ preset: "face", feature: FEATURE.TUTORIAL })

Recommendations for usage

  • If your page contains some activity required before the LIQA session (e.g. filling a questionnaire/survey), it's best to execute the preloading before this user-time-consuming activity.
  • Tutorial preloading is especially beneficial when users are likely to access the tutorial feature, as it eliminates the initial loading screen and prevents video playback issues.
  • Considering the tiny size of the liqa.js script (about 5 KB gzipped), it's safe to put the import of the preload function at the page's head without concern for its impact on the page's loading speed.
  • If the resources have not been fully loaded before the user starts interacting with LIQA, LIQA will efficiently reuse the previously downloaded chunks, avoiding unnecessary resource reloading.
  • For tutorial-heavy workflows, preloading tutorial videos can significantly improve user experience by providing instant video playback without buffering or loading screens.

preload() returns a promise. Repeated or concurrent calls for the same resource share the same request; after a failed request, a later call retries it. Treat preloading as an optimization rather than a navigation gate: browsers can deprioritize or skip prefetch hints when Data Saver, Low Data Mode, battery-saving policies, or network heuristics are active. The examples intentionally use void preload(...) so these policies cannot block the participant from entering LIQA.

In use-iframe mode, preloading in the host page warms the browser's HTTP cache, not the iframe's JavaScript module registry. The iframe can reuse responses only when the exact resource URL, request mode, cache headers, and the browser's cache-partitioning policy allow it. Consequently, preloading still helps iframe integrations, but it does not guarantee that every iframe resource is served without another network validation.

Mirror image from front camera

Use this option when your downstream display, comparison, or analysis UI must match the orientation the user saw during the selfie experience.

It is a common pattern for camera applications to display the video from front device camera horizontally flipped (mirrored) to ease the user interaction. It is also common to return the final image as "camera sees" (not mirrored).

LIQA adopts this UX pattern and returns not mirrored image from LIQA sessions that used the front camera. This means that the image retrieved from the ImageCapture API looks flipped compared to what end-user saw during the interaction with LIQA.

To overwrite this default process, LIQA also provides methods to invert or re-apply mirroring to final image. You can achieve it by leveraging two ImageCapture APIs: transform and source. The following code example illustrates how the transformation is applied to the captured image with a conditional logic based on source of the image:

ts
ts
if (capture.source === "front_camera") capture = capture.transform({ horizontalFlip: true })   const imageBlob = await capture.blob()
ts
if (capture.source === "front_camera") capture = capture.transform({ horizontalFlip: true })   const imageBlob = await capture.blob()

Recommendations for usage

  • Please, note, that if the image from the front camera is mirrored (the code above is applied), the right side of the face on image would represent the left side of the face in reality. This should be taken into account when SaaS Face Metrics 2.0 are displayed per face area.
  • The available sources for image capturing can be configured. Please, follow the Image Sources Customization Guideline.

Convert captured image to Base64-encoded string

Use this option when the next system in your workflow expects Base64 instead of a browser Blob, for example when sending captures to the upload HautAI SaaS API or forwarding an image through a native WebView bridge.

By default, LIQA emits the captured image as Blob, encoded with JPEG with 100% quality, using the blob API. The following code example provides a utility function blobToBase64 that can convert data from Blob to Base64-encoded string:

ts
ts
function blobToBase64(blob) { return new Promise((resolve) => { const reader = new FileReader() reader.onloadend = () => resolve(reader.result) reader.readAsDataURL(blob) }) }
ts
function blobToBase64(blob) { return new Promise((resolve) => { const reader = new FileReader() reader.onloadend = () => resolve(reader.result) reader.readAsDataURL(blob) }) }

Recommendations for usage

Add the utility function from above to the mentioned in a Quick Start section handleImageCapture event listener for the "capture" event as follows:

ts
ts
async function handleImageCapture(event) { const capture = event.detail   const blob = await capture.blob() // returns the captured image as Blob   const base64 = await blobToBase64(blob) // converts the captured image to Base64 string   // ... app logic handling the captured image ... }
ts
async function handleImageCapture(event) { const capture = event.detail   const blob = await capture.blob() // returns the captured image as Blob   const base64 = await blobToBase64(blob) // converts the captured image to Base64 string   // ... app logic handling the captured image ... }

Access anonymized image processing pipeline

Use anonymization when your client workflow needs to minimize identifiable visual data while preserving the skin information needed for analysis, auditing, or privacy-sensitive processing.

Haut.AI's patented Skin Atlas anonymization method ensures the highest data privacy standards while optimizing analysis quality. The process begins with detecting facial keypoints to standardize the image, followed by separating skin pixels from non-skin pixels. The system then generates photo-realistic skin patterns to replace non-skin areas, such as hair and background, producing a fully anonymized image. This eliminates irrelevant data and ensures that only pertinent skin information is retained for analysis. By transforming the images into the Skin Atlas format, anonymization not only safeguards privacy but also enables rapid, secure, and efficient processing of facial images.

LIQA leverages the Skin Atlas method to anonymize images directly on the user’s device, adding an extra layer of security by ensuring that sensitive data never leaves the device unprotected. This approach supports privacy-first workflows, enables reliable audit trails, and strengthens compliance with strict data protection standards, all while maintaining the integrity and quality of image analysis.

Enabling Anonymization

To enable anonymization, you must configure LIQA with the postprocessing parameter set to "anonymized":

html
<hautai-liqa
  license="xxx-xxx-xxx"
  preset="face"
  postprocessing="anonymized"
>
</hautai-liqa>

Or when using the JavaScript API:

ts
ts
const liqa = new Liqa({ license: "xxx-xxx-xxx", preset: "face", postprocessing: "anonymized", target: "body" })
ts
const liqa = new Liqa({ license: "xxx-xxx-xxx", preset: "face", postprocessing: "anonymized", target: "body" })

On iOS, very large uploaded images may exceed the platform canvas size limits used during anonymization.

Available options:

  • "original" (default) – Returns the captured image without anonymization
  • "anonymized" – Enables anonymization and provides access to the full processing pipeline

For complete configuration details, see the postprocessing parameter in the API Reference.

Face-180 preset support: This feature is available for the face-180 preset, providing anonymized image variants for each captured angle - front, left, and right sides of the face. When using face-180, you'll receive anonymized data for each of the three captured images.

Single capture example (Face preset):

ts
ts
async function handleImageCapture(event) { const captures = event.detail   // For face preset, you'll typically get a single capture for (const capture of captures) { // Get anonymized data including all processing stages const anonymized = await capture.anonymized()   console.log(`Processing capture from ${capture.source}`)   // Access different image variants anonymized.blobs.forEach((blobData) => { console.log(`${blobData.type}:`, blobData.data) // Process each image variant as needed })   // Access processing metadata console.log('Transform sequence:', anonymized.transformSequence) console.log('Face mesh data:', anonymized.mesh) } }
ts
async function handleImageCapture(event) { const captures = event.detail   // For face preset, you'll typically get a single capture for (const capture of captures) { // Get anonymized data including all processing stages const anonymized = await capture.anonymized()   console.log(`Processing capture from ${capture.source}`)   // Access different image variants anonymized.blobs.forEach((blobData) => { console.log(`${blobData.type}:`, blobData.data) // Process each image variant as needed })   // Access processing metadata console.log('Transform sequence:', anonymized.transformSequence) console.log('Face mesh data:', anonymized.mesh) } }

Multiple capture example (Face-180 preset):

ts
ts
async function handleImageCapture(event) { const captures = event.detail   // Process each capture (face-180 provides 3 captures: front, left, right) for (const capture of captures) { // Get anonymized data including all processing stages const anonymized = await capture.anonymized()   console.log(`Processing ${capture.source} capture`)   // Access different image variants anonymized.blobs.forEach((blobData) => { console.log(`${blobData.type}:`, blobData.data) // Process each image variant as needed })   // Access processing metadata console.log('Transform sequence:', anonymized.transformSequence) console.log('Face mesh data:', anonymized.mesh) } }
ts
async function handleImageCapture(event) { const captures = event.detail   // Process each capture (face-180 provides 3 captures: front, left, right) for (const capture of captures) { // Get anonymized data including all processing stages const anonymized = await capture.anonymized()   console.log(`Processing ${capture.source} capture`)   // Access different image variants anonymized.blobs.forEach((blobData) => { console.log(`${blobData.type}:`, blobData.data) // Process each image variant as needed })   // Access processing metadata console.log('Transform sequence:', anonymized.transformSequence) console.log('Face mesh data:', anonymized.mesh) } }

Available Image Variants

The anonymized() method returns an object containing multiple image processing stages:

  • originalImage – The unprocessed captured image as received from the camera or upload
  • imageRestored – Image after initial restoration and enhancement processing
  • segmentationMask – Binary mask showing detected face and skin regions for privacy processing
  • anonymizedImage – Final anonymized image with embedded metadata for traceability

Processing Metadata

In addition to image variants, you also get access to processing metadata:

  • transformSequence – Array of transformations applied during the anonymization process
  • mesh – Face mesh coordinates and landmark data used for processing

EXIF Metadata

The anonymized image includes EXIF metadata containing the mesh and transform sequence data, providing full traceability of the anonymization process. This ensures that the processing pipeline can be audited and reproduced if needed.

Recommendations for usage

  • Use the segmentationMask to understand which areas of the image were processed for privacy
  • The originalImage and anonymizedImage pair allows for before/after comparisons
  • Store the transformSequence and mesh data if you need to reproduce or validate the anonymization process
  • The color-corrected variant can be useful for applications requiring consistent image appearance across different lighting conditions
  • For face-180 preset, process each capture separately as they represent different angles (front, left, right) of the same person
  • For face-180 preset, consider the capture.source property to identify which angle each anonymized dataset corresponds to

Headless image processing

Use processImages() to run LIQA's offline quality check and/or anonymization over photos you already have — without mounting the LIQA UI. There is no camera, no capture flow, and no DOM events: you pass image Blobs in and get the same quality verdict and anonymized output the widget produces for uploads.

This is the right tool when a participant's photos already exist (an archive, a bulk import, a re-processing job) and routing each one through the interactive widget would be impractical.

tsx
tsx
import { processImages } from "SOURCE_URL_PROVIDED_BY_HAUT_AI/liqa.js"   const results = await processImages({ license: "xxx-xxx-xxx", images: [fileA, fileB], postprocessing: "anonymized", })   for (const [index, result] of results.entries()) { if (result.error) { console.warn(`Image ${index} could not be processed:`, result.error.message) continue }   console.log(`Image ${index}: ${result.quality?.image_quality}, score ${result.score}`)   // Present only when `postprocessing` includes "anonymized" and a face was found if (result.anonymized) { // A PNG of the aligned face crop, transparent outside the skin mask, // carrying the same metadata payload as `ImageCapture.anonymized()` console.log(result.anonymized.image) } }
tsx
import { processImages } from "SOURCE_URL_PROVIDED_BY_HAUT_AI/liqa.js"   const results = await processImages({ license: "xxx-xxx-xxx", images: [fileA, fileB], postprocessing: "anonymized", })   for (const [index, result] of results.entries()) { if (result.error) { console.warn(`Image ${index} could not be processed:`, result.error.message) continue }   console.log(`Image ${index}: ${result.quality?.image_quality}, score ${result.score}`)   // Present only when `postprocessing` includes "anonymized" and a face was found if (result.anonymized) { // A PNG of the aligned face crop, transparent outside the skin mask, // carrying the same metadata payload as `ImageCapture.anonymized()` console.log(result.anonymized.image) } }

The options (ProcessImagesOptions) reuse the widget's configuration vocabulary:

OptionDefaultMeaning
license— (required)Your LIQA license key, validated exactly as the widget validates it.
images— (required)A Blob/File or an array of them. results[i] always describes images[i].
qualityCheck"normal"Same values as the quality-check attribute; "off" skips quality measurement.
occlusionDetection"off"Deprecated — prefer qualityCheck + ignoredQualityChecks. Same values as the occlusion-detection attribute; only meaningful when qualityCheck is "off".
postprocessing"original"Same values as the postprocessing attribute — add "anonymized" to receive the anonymized PNG.
ignoredQualityChecks[]Same values as the ignored-quality-checks attribute; mutes checks in the verdict without re-measuring.

Each entry of the resolved array (ProcessedImageResult) carries:

FieldMeaning
qualityThe collapsed verdict the widget writes into capture metadata, or null when qualityCheck is "off".
qualityDetailsThe full per-metric verdict (ImageQualityDetails), after muting.
scoreThe overall 0–100 quality score, or null unless the full quality check ran.
anonymizedThe anonymized PNG plus its mesh and transformSequence (AnonymizedImageResult).
errorSet when this image could not be processed at all (e.g. an unsupported format) — it never fails the whole batch.

A few properties of the headless flow are deliberate:

  • It is not the widget. No UI is mounted, no captures/analytics DOM events fire, and no analytics are sent. The license option is validated the same way the widget validates its license attribute.
  • It is deterministic. The same image with the same options always produces the same verdict. Processing runs on the face preset over the upload source path.
  • Images are processed sequentially. The CV models load once and stay warm for the lifetime of the page, so throughput improves after the first image.

Browser-only

processImages() decodes and analyses images with browser CV APIs, so it must run in a browser page. Calling it during server-side rendering throws.