Picsha AI

Photo Retail & Photo Labs

A photo retailer lives on four orders: the damaged print somebody wants fixed, the camera roll that should become a book, the batch of 4×6s, and the holiday card. Each one used to need a scanner, a retoucher, a page-layout operator, or a template picker — and most customers gave up before the order was placed. The industry's own number is that 70% of photo-book projects are abandoned.

Picsha AI collapses that work into API calls. Every photo a customer uploads is analyzed once at ingest — sharpness, exposure, faces, subjects, capture time, place — and then rendered on demand at whatever exact size the lab needs, with restoration, cleanup, and compositing applied at the edge. The customer approves a preview; the lab receives a print-ready file that is pixel-identical to it.

The clearest proof is our partner Graphx, which ships a family of white-label storefronts for photo retailers built entirely on Picsha AI: Picsha Retouch, Picsha Books, Picsha Prints, and Picsha Cards, tied together by Picsha Hub. Each runs at the retailer's own address (yourstore.prints.picsha.com), with the retailer's logo, sizes, and prices, on the web, on an iPad, and in kiosk mode on the touchscreen PC in the store. This guide walks through the Picsha primitives behind each of those products so you can build the same workflows into your own lab software, ordering site, or kiosk.

Core Value Proposition

  1. One upload, every product. A customer's camera roll is ingested once (POST /assets) and every downstream product — book, prints, card, restoration — reads the same analysis and renders from the same master. Set expires_at on the upload and customer photos auto-delete after your retention window, so there are no accounts to manage and nothing to clean up.
  2. Shot selection built in. Every image is scored at ingest with deterministic quality metrics (sharpness, brightness, contrast, clipped highlights and shadows) plus per-face attributes (eyes open, smile, head pose). Pull them for a whole photo set in one call with POST /assets/analysis and cull blinks, blurs, and near-duplicates before a page is laid out. No per-metric billing.
  3. Print-exact rendering at the edge. Ask the render endpoint for the true aspect of the cut size (ar=3:2&fit=cover&pos=face) and it returns a crop framed around the people in the shot. Save each cut size as a Named Transformation and the lab file is one URL per order line.
  4. Preview free, pay to keep — with no re-roll. Generative results are cached as a base render; watermarks are composited as derivatives of that base. Serve a tiled-watermark preview, then serve the identical URL without the watermark after payment: the unlocked file is pixel-identical to what the customer approved, and the generation is billed once.
  5. Restoration and cleanup as tuned instructions, not a prompt box. Map each control your customer sees — repair damage, restore color, colorize, enhance faces — to a MIMI instruction you tune and test once. trim=auto shaves the scanner or phone-photo border so a snapshot of a print comes back like a flatbed scan.
  6. Multi-tenant by header. Pass the customer's order or session id as x-external-user-id on every request and Picsha scopes uploads, analysis, and renders to that customer. One API key serves every store you run.

How Graphx maps its products onto Picsha

StorefrontWhat the customer doesPicsha primitives behind it
Picsha RetouchSnaps a phone photo of a torn or faded print; picks fixes, not prompts; previews free and pays to keep; orders prints, mats, and frames from the lab.mimi restoration instructions, trim=auto, wm_mode=tiled preview → clean unlock from the derivative cache, upscale=4k free export, exp-limited signed download.
Picsha BooksUploads 30–120 photos, a size, and a title; approves a finished, captioned book in about a minute; the lab prints a 300-dpi PDF with bleed.POST /assets/analysis (quality, faces, capture time, labels, embeddings) for culling and chapter order; summaryShort for captions; face-aware renders per layout slot.
Picsha PrintsUploads up to 50 photos, taps a size, sees an exact-ratio crop with a DPI warning before paying; the lab downloads print-ready files at its cut sizes.Named Transformations per cut size, ar + fit=cover + pos=face, no_enlarge, explicit crop=x,y,w,h from the editor, download=true for the lab zip.
Picsha CardsAdds up to 12 photos and (optionally) the occasion; gets several finished cards back with faces framed and a greeting drafted; orders flat or folded.Labels and faces for occasion detection and photo picks; pos=face openings; Backgrounds Library and bg_asset for artwork compositing; POST /assets/generate for new artwork.
Picsha HubOne branded address and kiosk home screen listing every storefront the store carries; one order view.Ingest attribution by API key name (metadataField=apiKeyName) and x-external-user-id keep every store's and every customer's assets separate under one organization.

[!NOTE] The "Picsha brain" in these products — the layout engine that decides chapters, picks hero images, and writes greetings — is application code Graphx built on top of the analysis and rendering primitives below. Picsha AI supplies the signals and the pixels; your product supplies the taste.

Integration & Workflows

The examples below use the REST API from a Node.js backend. Everything here also works from the Node SDK, the Python SDK, or the React SDK for the customer-facing pieces.

1. Ingesting a customer's camera roll

Upload each photo with the order (or session) id as x-external-user-id, an expiry that matches your privacy promise, and metadata that ties the asset back to the store and order. Quality metrics are on by default; leave them on — they are free and they drive every selection step that follows.

const PICSHA = 'https://api.picsha.ai/v1';
const headers = (orderId) => ({
  'Authorization': 'Bearer sk_your_api_key',
  'x-external-user-id': `order_${orderId}`,   // scopes everything to this customer
});

async function ingestCustomerPhoto(orderId, storeId, file) {
  const form = new FormData();
  form.append('file', file, file.name);
  form.append('config', JSON.stringify({
    quality_metrics: true,                                        // sharpness, exposure, face attributes (default)
    expires_at: new Date(Date.now() + 30 * 86400e3).toISOString(), // auto-delete after 30 days
    render_on_upload: 'w=400&fmt=webp',                            // pre-warm the thumbnail the picker shows
  }));
  form.append('tags', JSON.stringify([`store:${storeId}`, `order:${orderId}`]));
  form.append('metadata', JSON.stringify({ storeId, orderId }));

  const res = await fetch(`${PICSHA}/assets`, { method: 'POST', headers: headers(orderId), body: form });
  const { id } = await res.json();   // returns immediately; analysis runs in the background
  return id;
}

Ingest is asynchronous. For a kiosk or a "designing your book…" screen, listen on the Server-Sent Events stream (GET /notifications/stream) scoped to the same x-external-user-id, or register an asset.processed webhook and branch on data.status === "completed". For phone uploads on flaky store Wi-Fi, the TUS resumable endpoint survives a dropped connection.

2. Photo restoration: preview free, pay to keep (the Retouch pattern)

A customer photographs a damaged print with their phone. Your UI offers a fixed set of fixes. Each fix maps to an instruction you have tuned — there is no prompt box for the customer, which is what keeps results consistent enough to put a store's name on.

// Your tuned instruction set — edit and re-test these, never expose them as a free-text prompt
const FIXES = {
  repair:   'Restore this damaged photograph: repair tears, creases, scratches, and stains. Keep every face, object, and detail faithful to the original. Do not add or remove anything.',
  color:    'Restore the faded colors to how the print looked when new: correct the color cast, recover contrast, keep skin tones natural.',
  colorize: 'Colorize this black-and-white photograph with realistic, period-appropriate colors.',
  faces:    'Gently enhance faces: sharpen eyes and features and remove noise, without changing anyone\'s appearance.',
};

function restorationPrompt(selected) {
  return selected.map((k) => FIXES[k]).join(' ');
}

Build the preview and the unlock from one parameter set. The preview adds tiled-watermark parameters; the unlock removes them and adds the free 4K export, a forced download, and an expiry. Both URLs resolve to the same billed-once base render, so the customer receives exactly the image they approved.

const base = {
  mimi: restorationPrompt(['repair', 'color']),
  mimi_mode: 'standard',   // 2K render, ~25–30 s; the preview is the print
  trim: 'auto',            // shave the table / scanner border off the phone snapshot
  w: 2048, fit: 'inside',
  fmt: 'jpeg', q: 92,
};

async function sign(assetId, orderId, queryParams) {
  const res = await fetch(`${PICSHA}/assets/${assetId}/sign-delivery`, {
    method: 'POST',
    headers: { ...headers(orderId), 'Content-Type': 'application/json' },
    body: JSON.stringify({ urlPath: `/render/${assetId}`, queryParams }),
  });
  const { signedUrl } = await res.json();
  return `https://cdn.picsha.ai${signedUrl}`;
}

// 1. Free watermarked preview — the first load runs the generation (under a minute)
const previewUrl = await sign(assetId, orderId, {
  ...base,
  wm_mode: 'tiled', wm_text: 'FERNWOOD PHOTO', wm_opacity: 0.35, wm_density: 4,
});

// 2. After Stripe confirms payment: same base, no watermark, 4K export, 24-hour link
const unlockUrl = await sign(assetId, orderId, {
  ...base,
  upscale: '4k',                                 // free, preview-faithful upscale of the identical render
  download: true,
  exp: Math.floor(Date.now() / 1000) + 86400,   // link dies after 24 h
});

Billed once, at the 2K MIMI rate, on the first preview. The watermark composite, the clean unlock, and the 4K upscale are derivatives of the cached base and bill no new generation. Because sig covers the whole query, a customer cannot strip wm_* from the preview URL — any change breaks the signature. See Watermarks & the Derivative Cache.

[!TIP] Keep every non-watermark parameter identical between the preview and the unlock. A different w, q, or trim is a different render — and a different bill. If you would rather not hold a browser request open during generation, kick the render off server-side with async=true and poll cdn.picsha.ai/render/status/:jobId; see Asynchronous Processing.

Production retouching. The same engine handles volume work for school, sports, and studio photographers: ingest with "remove_background": true for a stored cutout per subject, then composite every subject over the same stored scene with bg_asset=<backgroundId> — a pure-CPU composite, no generative model in the request path, cached at the edge. gen_rem="exit sign" erases a distraction; mimi_bg generates a new backdrop from a prompt. See Background Operations.

3. Culling and ordering a photo set (the Books pattern)

Fetch the analysis for the whole upload in one request — up to 200 assets per call — and make the "two hundred decisions" a customer would otherwise abandon: drop blurs and blinks, collapse near-duplicates, order the story, and detect the occasion.

async function analyzeSet(orderId, assetIds) {
  const res = await fetch(`${PICSHA}/assets/analysis`, {
    method: 'POST',
    headers: { ...headers(orderId), 'Content-Type': 'application/json' },
    body: JSON.stringify({ ids: assetIds, includeEmbeddings: true }), // embeddings ≈ 8 KB per asset
  });
  const { assets, requested, returned } = await res.json();
  if (returned < requested) console.warn('some assets still processing or unauthorized');
  return assets.filter((a) => a.status === 'completed');
}

const cosine = (a, b) => {
  let dot = 0, na = 0, nb = 0;
  for (let i = 0; i < a.length; i++) { dot += a[i] * b[i]; na += a[i] * a[i]; nb += b[i] * b[i]; }
  return dot / Math.sqrt(na * nb);
};

function cull(assets) {
  const keep = [];
  const rejected = [];
  // Story order first: capture time from EXIF, filename as the tie-breaker when timestamps are missing
  const ordered = [...assets].sort((a, b) =>
    (a.captureDate ?? '').localeCompare(b.captureDate ?? '') || a.originalName.localeCompare(b.originalName));

  for (const a of ordered) {
    const q = a.ai?.quality ?? {};
    const faces = a.ai?.faceDetails ?? [];
    if (q.sharpness !== undefined && q.sharpness < 35) { rejected.push([a.id, 'blurry']); continue; }
    if (faces.length && faces.every((f) => f.eyesOpen === false)) { rejected.push([a.id, 'eyes closed']); continue; }
    // Near-duplicate: same burst, same embedding neighborhood — keep the sharper one already chosen
    const twin = keep.find((k) => k.embedding && a.embedding && cosine(k.embedding, a.embedding) > 0.97);
    if (twin) { rejected.push([a.id, `near-duplicate of ${twin.id}`]); continue; }
    keep.push(a);
  }
  return { keep, rejected };   // show `rejected` with reasons so the customer can review the designer's picks
}

function detectOccasion(assets) {
  const votes = {};
  for (const a of assets) for (const l of a.ai?.labels ?? []) votes[l.name] = (votes[l.name] ?? 0) + l.confidence;
  const top = Object.entries(votes).sort((x, y) => y[1] - x[1]).slice(0, 8).map(([n]) => n);
  if (top.some((n) => /Wedding|Bride|Groom|Bouquet/i.test(n))) return 'wedding';
  if (top.some((n) => /Birthday|Cake|Balloon/i.test(n)))         return 'birthday';
  if (top.some((n) => /Beach|Mountain|Landmark|Boat/i.test(n)))   return 'travel';
  return 'general';
}

Split chapters where the gap between consecutive captureDate values jumps (a few hours between getting-ready and reception, a day between legs of a trip), and title the cover from the date range and locationString. For captions, each asset carries aiAnalysis.summaryShort — a one-line description of the moment written by Claude at ingest, served free from the stored record. POST /assets/{id}/summarize returns the long form if you want a chapter opener.

Hero images for a chapter are the kept assets with the highest sharpness and a smiling, front-facing subject (faceDetails[].smile === true, small poseYaw). Because quality scores are projected into the search index, you can also let AI Search rank "the best photo of the cake" directly.

4. Print-ready files at exact cut sizes (the Prints pattern)

Register one Named Transformation per cut size in your lab's catalog. At 300 dpi a 4×6 is 1800×1200 pixels; the alias fixes the ratio, the resampling kernel, and the lab's file format once, so an order line is a single URL.

// One-time setup per lab (or per store, if their cut sizes differ)
const CUT_SIZES = {
  print_4x6:  { w: 1800, h: 1200 },
  print_5x7:  { w: 2100, h: 1500 },
  print_8x10: { w: 3000, h: 2400 },
};

for (const [alias, size] of Object.entries(CUT_SIZES)) {
  await fetch(`${PICSHA}/transformations`, {
    method: 'POST',
    headers: { 'Authorization': 'Bearer sk_your_api_key', 'Content-Type': 'application/json' },
    body: JSON.stringify({
      name: `Lab ${alias}`,
      alias,
      commands: { ...size, fit: 'cover', pos: 'face', k: 'lanczos3', fmt: 'jpeg', q: 95, no_enlarge: true },
    }),
  });
}

For each photo the customer selects, compute the DPI guardrail from the dimensions Picsha extracted at ingest, orient the crop to the photo, and build the preview and the lab file from the same alias:

function dpiFor(asset, inchesW, inchesH) {
  const landscape = asset.width >= asset.height;
  const [pw, ph] = landscape ? [inchesW, inchesH] : [inchesH, inchesW];
  // fit=cover keeps the short side; the long side is cropped to ratio
  return Math.floor(Math.min(asset.width / pw, asset.height / ph));
}

function orderLine(asset, alias, inches, customCrop /* {x,y,w,h} in source px from your editor */) {
  const dpi = dpiFor(asset, ...inches);
  const warning = dpi < 150 ? `This photo prints at ${dpi} dpi and may look soft at ${inches.join('×')}.` : null;

  const params = new URLSearchParams({ t: alias });
  if (asset.height > asset.width) {                     // portrait: swap the alias' dimensions
    params.set('w', String(CUT_SIZES[alias].h));
    params.set('h', String(CUT_SIZES[alias].w));
  }
  if (customCrop) params.set('crop', `${customCrop.x},${customCrop.y},${customCrop.w},${customCrop.h}`);

  return {
    previewUrl: `https://cdn.picsha.ai/render/${asset.id}?${params}&w=600&fmt=webp`,  // inline params override the alias
    labUrl:     `https://cdn.picsha.ai/render/${asset.id}?${params}&download=true`,
    dpi, warning,
  };
}

The customer's editor can pass through brightness, contrast, saturation, and rotation as bri, con, sat, and rot; every edit is re-rendered from the full-resolution master for the lab, never from the on-screen preview. Because renders are edge-cached and immutable, the lab file the operator downloads three days later is the same bytes the customer approved. Group the lines into one order with POST /v1/dam/groups (see step 6) so the lab can pull the whole order as a set.

[!NOTE] If your store enables Strict Transformations, sign each labUrl and previewUrl with sign-delivery exactly as in step 2; include download=true in the signed query. Under the default policy plain transforms like these are publicly deliverable without a signature.

5. Photo cards that design themselves (the Cards pattern)

A card is a small book with artwork: the same POST /assets/analysis call picks the best of up to 12 photos and reads the occasion off the labels (a birthday cake, a graduation gown, a newborn), and each photo opening is rendered with pos=face so nobody's forehead lands on the trim line. Two Picsha features do the artwork:

  • Backgrounds Library + bg_asset. Register each card design's backdrop once with POST /backgrounds, then composite a customer's photo cutout over it with ?bg_asset=<id> — the same pure-CPU scene composite that powers e-commerce personalization. Ingest the card photos with "remove_background": true so the cutout already exists and the composite is publicly deliverable without a signature from the first preview.
  • POST /assets/generate. Generate new artwork on demand ("watercolor holly border, cream paper, no text", aspect_ratio: "5:4"). The result is ingested as a normal asset, so it can be reused across every card in the catalog and composited like any stored background.

Compose the final card in your renderer from those pieces — photo openings as pos=face renders at the opening's exact pixel size, the greeting as your own typography, artwork from the library — so the on-screen preview and the 300-dpi imposed PDF for the lab come from the same code path.

6. Orders, audit trail, and the lab

Wrap the assets of an order in a DAM group so the lab, the admin view, and support all see one thing, and record fulfillment milestones as events on the assets:

// One group per order
const { id: groupId } = await fetch(`${PICSHA}/dam/groups`, {
  method: 'POST',
  headers: { ...headers(orderId), 'Content-Type': 'application/json' },
  body: JSON.stringify({ name: `Order ${orderId}`, metadata: { storeId, orderId, product: 'prints' } }),
}).then((r) => r.json());

// Fulfillment milestone on the hero asset
await fetch(`${PICSHA}/dam/events`, {
  method: 'POST',
  headers: { ...headers(orderId), 'Content-Type': 'application/json' },
  body: JSON.stringify({
    assetId: heroAssetId,
    eventType: 'ORDER_PRINTED',
    eventData: { orderId, station: 'Noritsu 2', operator: 'sam' },
  }),
});

If you run several stores on one API key, give each store its own key so uploads are attributed automatically; GET /assets?metadataField=apiKeyName&metadataValue=fernwood-prints then lists one store's assets, and the billing summary's groupBy: "userId" breaks usage down per customer. See Ingest attribution.

Privacy. Customer photos should not outlive the order. expires_at on the upload deletes them on schedule; anything deleted early via DELETE /assets/{id} sits in a 30-day trash first, so a "wait, I want that reprint" is a POST /assets/{id}/restore away. No customer account is needed anywhere in this flow — x-external-user-id is the only identity Picsha ever sees.

[!NOTE] Want the finished product instead of the primitives? Graphx's storefronts are free to set up, run at your own address with your logo and prices, and are fulfilled in your own lab. Try them as a customer would at the Fernwood Photo & Print demo store from graphx.com/products.