Upscaling AI Generated Images Before Your Users Download or Print Them

Posted on
A pixelated image passes through an upscale frame and becomes sharper

A user of your app generates an illustration, likes it, and wants it on their wall. They click Download and send the file to a print shop. The file is the size the model rendered, and the illustration below came out of an image model at 1672 x 941 pixels. That fills a laptop screen. At the 300 pixels per inch many print shops ask for, it prints 5.6 inches wide. To upscale AI generated images to a size people can print, you either render bigger, which the model may not offer, or enlarge the file after it’s made.

Filestack Image Upscaling does the second. One upscale segment in an image editing API URL doubles the image on the CDN. We built a small Node service around it that makes each download size once, the first time someone asks for it, and sends every later download to the stored file.

An isometric illustration of gray image tiles passing through an archway, with two tiles highlighted in coral, rendered by an image model at 1672 by 941 pixels
An isometric illustration of gray image tiles passing through an archway, with two tiles highlighted in coral, rendered by an image model at 1672 by 941 pixels

 

Key takeaways

  • upscale doubles the width and height of an image, in the format it arrived in. Two passes with a resize between them take a 1672 x 941 render to 5330 x 3000.
  • The task accepts inputs up to 4,000,000 pixels, so the second pass needs the image shrunk to fit first. fit:max does that without changing the shape.
  • On the line art in this article, style:artwork keeps the outlines about as dark as the model drew them, and the default photo style draws them slightly softer.
  • Upscale when a user asks for a size, store the result, and serve the stored file with ?dl=true from then on.
  • resize enlarges the pixels that are already there, so it blurs the outlines that upscale keeps sharp.

Why generated images come out too small to print

Image models usually render at a fixed set of sizes, and the larger sizes cost more per image and take longer. For a screen that’s fine. A poster, a canvas or a sticker sheet is different. The print shop works in inches and pixels per inch, and 1672 pixels runs out quickly:

Width in pixels Print width at 300 PPI Print width at 150 PPI
1672, as rendered 5.6 in 11.1 in
3344, one upscale 11.1 in 22.3 in
5330, two upscales 17.8 in 35.5 in

The service below offers the last two rows as download sizes, and calls them large and print.

Four ways to make a bigger file

Option What it does The catch
Render at the model’s largest size No extra step Larger renders cost more and take longer, for every image, including the ones nobody downloads
resize to a bigger width Instant Enlarges the pixels that are there, so edges blur
Run an open source upscaler on your own GPU Full control over the model You run the GPU, the queue and the model updates
upscale Doubles the image on the CDN, per URL Accepts inputs up to 4,000,000 pixels, so big renders are shrunk first

We built the service around the last option. It renders at the small size and upscales only the images people download, so the cost lands on the images that get used.

Upscale with one URL segment

upscale is a path segment in front of the image’s handle, and it runs on Start, Grow and Scale:

https://cdn.filestackcontent.com/upscale/HANDLE

For the illustration above, that returns a 3344 x 1882 PNG. The output keeps the input’s format, and upscale/output=format:jpg returns a JPEG if the print shop wants one. The upscale parameters and limits post covers the noise and upscale parameters, which a clean render doesn’t need.

Here’s what that does to the illustration’s edges. The figure below starts from the illustration shrunk to half its width, doubles it back three ways, and compares each with the original render. The number under each crop is PSNR, where higher means closer to the original.

The same 200 pixel detail of the illustration four times, the original render, then the half-size copy doubled by resize at 28.8 dB, by upscale at 35.7 dB and by upscale with style artwork at 35.5 dB
The same 200 pixel detail of the illustration four times, the original render, then the half-size copy doubled by resize at 28.8 dB, by upscale at 35.7 dB and by upscale with style artwork at 35.5 dB

 

On this illustration, resize lands at 28.8 dB and both upscale styles at about 35.6. The blur on the resized tile’s outline is the difference you see.

Filestack Discord

Artwork style or photo style for generated art

The figure also shows a choice the service makes for every image. style takes photo, the default, or artwork. The two score almost the same against the original here. What differs is the line. Edge contrast, the average difference between neighboring pixels, is 2.12 on the original illustration, 2.18 with style:artwork and 1.94 with the default. A second line-art illustration from the same model follows the same pattern, 2.41 against 2.47 and 2.22. So on this art, artwork keeps the outlines about as dark as the model drew them and photo softens them slightly.

Your app already knows which style to use. If the user chose an illustration, line art or sticker preset when they generated the image, use artwork. If they chose a photographic preset, use photo. That’s why the service stores the style on each image record, and the illustration’s record says artwork.

Keep large renders under the input limit

One upscale gets the illustration to the large size. The print size needs a second pass, and that’s where the input limit comes in. One upscale of a 1672 x 941 render gives 3344 x 1882, which is 6.3 million pixels. A second upscale straight after it fails, because the task accepts inputs up to 4,000,000 pixels:

HTTP 400
... The input image is too large. Maximum allowed size for upscale task is 4000000 pixels (failed task index 1)

The failed task index counts from 0, so index 1 is the second upscale. So we put a resize between the passes to shrink the image to fit, and fit:max keeps the shape inside a width and height box. upscaleTasks in upscale.mjs works out the chain for any render size. The same file signs the request and stores the result, which the next section uses:

import { createHmac } from 'node:crypto';

const CDN = 'https://cdn.filestackcontent.com';
const LIMIT = 4_000_000; // largest input upscale accepts, in pixels

// Doubles the image `passes` times, shrinking it under the limit before any pass that needs it
export function upscaleTasks(width, height, passes, style) {
  const tasks = [];
  for (let i = 0; i < passes; i++) {
    if (width * height > LIMIT) {
      const scale = Math.sqrt(LIMIT / (width * height));
      const w = Math.floor(width * scale);
      const h = Math.floor(height * scale);
      tasks.push(`resize=width:${w},height:${h},fit:max`);
      [width, height] = [w, h];
    }
    tasks.push(`upscale=style:${style}`);
    [width, height] = [width * 2, height * 2];
  }
  return { tasks: tasks.join('/'), width, height };
}

function security() {
  const expiry = Math.floor(Date.now() / 1000) + 300;
  const policy = Buffer.from(JSON.stringify({ call: ['read', 'convert', 'store'], expiry }))
    .toString('base64')
    .replace(/\+/g, '-')
    .replace(/\//g, '_');
  const signature = createHmac('sha256', process.env.FILESTACK_APP_SECRET).update(policy).digest('hex');
  return `security=p:${policy},s:${signature}`;
}

// Runs the chain once and keeps the result as a file of its own
export async function storeUpscaled(handle, tasks) {
  const res = await fetch(`${CDN}/${security()}/${tasks}/store/${handle}`);
  if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
  return (await res.json()).handle;
}

export const downloadUrl = (handle) => `${CDN}/${handle}?dl=true`;

For the illustration, two passes give upscale=style:artwork/resize=width:2665,height:1500,fit:max/upscale=style:artwork and a 5330 x 3000 file. A square 1024 render gives 4000 x 4000, and a render already over the limit, such as 2048 x 2048, is shrunk before the first pass.

Store each size the first time someone asks

At this point the service knows which chain to run for each size. What it doesn’t have yet is a file to hand the user.

Upscaled output delivered by URL is cached at the CDN for 31 days. A download button needs more than that: one file per size that stays put, and a filename the browser saves instead of opens. Chaining store after the tasks keeps the result as a file of its own, and its policy needs the store call alongside read and convert. Adding ?dl=true to the stored file’s URL sends it with Content-Disposition: attachment.

server.mjs puts that together. It makes each size the first time someone asks for it, then redirects every later request to the stored file. It assumes each generated image is already stored in your Filestack application, with its handle, width and height on the image record. It’s plain Node with no packages, and needs Node 20.6 or later for --env-file and a .env file holding the app secret from the Developer Portal:

FILESTACK_APP_SECRET=YOUR_APP_SECRET
import { createServer } from 'node:http';
import { upscaleTasks, storeUpscaled, downloadUrl } from './upscale.mjs';

// Stand-in for your images table: one row per generated image
const images = new Map([
  ['img_7Qx', { handle: 'kxqdtjH1RFyA1rNZyZZs', width: 1672, height: 941, style: 'artwork', files: {} }],
]);
const SIZES = { large: 1, print: 2 }; // upscale passes per download size
const pending = new Map();

async function fileFor(id, size) {
  const image = images.get(id);
  if (image.files[size]) return image.files[size];

  const key = `${id}:${size}`;
  if (!pending.has(key)) {
    const { tasks, width, height } = upscaleTasks(image.width, image.height, SIZES[size], image.style);
    pending.set(key, storeUpscaled(image.handle, tasks).then((handle) => {
      image.files[size] = { handle, width, height };
      console.log(id, size, tasks, '->', handle, `${width}x${height}`);
      return image.files[size];
    }).finally(() => pending.delete(key)));
  }
  return pending.get(key);
}

createServer(async (req, res) => {
  const { pathname, searchParams } = new URL(req.url, 'http://localhost');
  const match = pathname.match(/^\/images\/(\w+)\/download$/);
  const size = searchParams.get('size') ?? 'large';
  if (!match || !images.has(match[1]) || !Object.hasOwn(SIZES, size)) {
    res.writeHead(404).end();
    return;
  }
  try {
    const file = await fileFor(match[1], size);
    res.writeHead(302, { Location: downloadUrl(file.handle) }).end();
  } catch (err) {
    console.error(match[1], size, err.message);
    res.writeHead(502).end('Could not prepare the download');
  }
}).listen(8789, () => console.log('downloads on :8789'));

images stands in for your generated images table, with one row for the illustration, img_7Qx. The first request for a size waits for the upscale. For this illustration that’s about 10 seconds for large and about 30 for print, so show the user a “preparing your file” state rather than a spinning button. pending makes three quick clicks on the same button share one upscale and one stored file. Requests after that redirect straight away.

Run it

The policy is signed with your app secret, so the img_7Qx row needs an image stored in your own application. Put that image’s handle, width and height in the row, start the service, then ask for the print size:

node --env-file=.env server.mjs
curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' "http://127.0.0.1:8789/images/img_7Qx/download?size=print"

curl prints 302 https://cdn.filestackcontent.com/HANDLE?dl=true, and the service logs the chain it ran and the size it made. For the 1672 x 941 illustration, that’s:

img_7Qx print upscale=style:artwork/resize=width:2665,height:1500,fit:max/upscale=style:artwork -> HANDLE 5330x3000

Run the same curl again and it redirects to the stored file straight away, with no new upscale and no new log line. An unknown image or size returns 404, and a failed upscale returns 502 without storing anything, so the next click tries again.

What upscaling every download costs

Image Upscaling includes 1,000 images a month, then $0.025 an image. With the service above, an image is upscaled only when someone downloads it, and only once per size. Images that are viewed and never downloaded cost nothing extra.

To show upscaled images on a page as well, use the CDN URL without store. The smart image processing page covers the other image tasks. For a front end that shows the before and after, the upscaling in a React app walkthrough builds one.

Next, swap images for your generated images table, set style from the preset each image was generated with, and add a size for each product you print.

Sign up free for Filestack

Read More →