Upscaling one image is easy: open a desktop app, drop the file, save the result. Upscaling a few hundred images from inside a pipeline is a different job. The typical cases: a supplier feed full of 600×600 product photos when the marketplace wants larger ones, AI-generated images that are too small for print, avatars and thumbnails pulled by a scraper that look soft at full width.
What you want there is boring: a list of URLs in, a list of larger files out, the new width and height for each, and a clear signal for the ones that failed. This post compares the usual ways to increase image resolution in bulk, then shows how to do it with one API call from curl, Python, JavaScript, n8n and an AI agent.
Why bulk upscaling gets annoying
Self-hosting a super-resolution model. Open models such as Real-ESRGAN give you full control and no per-image fee. In exchange, you need a GPU, a queue, a way to move files in and out, and someone to keep it running. It pays off at high, steady volume.
Desktop apps like Upscayl. Free, open source and good at the job, folders included. They run on the machine of whoever clicks the button, with that machine's GPU, which makes them a poor fit for a server-side pipeline.
Hosted model APIs. Providers such as Replicate or fal remove the GPU. You still sign up, manage a provider key and write the glue: one request per image, a concurrency limit, retries, storage for the results, matching each output to its input, and a plan for when image 37 of 200 fails.
SaaS upscalers. Polished web apps, usually sold as a subscription or credit packs, and designed around someone uploading files in a browser.
| Option | GPU on your side | What you manage | A list of URLs in one call | Billing |
|---|---|---|---|---|
| Self-hosted model | Yes | Model, queue, storage, uptime | If you build it | Your infrastructure |
| Desktop app (Upscayl) | Yes, local | Running it by hand | Folders, from the app | Free, open source |
| Hosted model API | No | Provider key, per-image loop, retries | Usually one image per request | Pay per use |
| SaaS upscaler | No | Account, uploads | Depends on the product | Usually subscription or credits |
| Apify actor (below) | No | Apify token | Yes | Per image delivered, failures free |
A batch image upscaler behind one API call
What the first four rows leave open is hosted convenience (no GPU, no model-provider key) without writing the per-image glue yourself. That's the case the actor below covers. It's Image Upscaler API on the Apify Store (sherwood/image-upscaler-batch): you send a list of image URLs, it runs each one through SeedVR2, an AI super-resolution model, and writes one result per image to a dataset.
Disclosure: I maintain the Apify actor used below.
The only credential you handle is your Apify API token, and there's no subscription to the actor.
Step 1: get a token
Create an Apify account, copy your API token from Settings > API & Integrations in the Apify Console, and export it as APIFY_TOKEN; every example below reads it from there.
Step 2: the input
{
"imageUrls": [
"https://example.com/products/chair-600.jpg",
"https://example.com/products/lamp-600.png"
],
"scale": "2",
"outputFormat": "jpg",
"maxOutputMegapixels": 16
}
-
imageUrls: public JPG, PNG or WebP URLs. The aliasesimageUrl,urls,imagesandstartUrlsare merged into it and duplicates are removed, which helps when the list comes from another tool. -
scale:"2"doubles width and height,"4"quadruples them. It's a string in the input schema, and Apify validates API input against the schema, so send"4", not4. -
outputFormat:jpg(default),png(keeps transparency) orwebp. -
maxOutputMegapixels: a cap on output size, default 16. An image whose upscaled size would exceed it is upscaled to the cap instead. For reference, 4 MP is about 2000×2000. -
maxImages: stop after this many images, default 100.
Every field is optional: an empty input upscales two sample images, a quick way to see the output shape.
Step 3: call it with curl
curl -X POST \
"https://api.apify.com/v2/actors/sherwood~image-upscaler-batch/run-sync-get-dataset-items?maxTotalChargeUsd=1" \
-H "Authorization: Bearer $APIFY_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"imageUrls": [
"https://example.com/products/chair-600.jpg",
"https://example.com/products/lamp-600.png"
],
"scale": "2"
}'
run-sync-get-dataset-items starts the run, waits for it, and returns the dataset items as a JSON array. The maxTotalChargeUsd query parameter caps what the run can cost: when the cap is reached, the actor stops cleanly and the remaining images are reported, not processed.
One constraint: synchronous endpoints wait at most 300 seconds, then return HTTP 408 while the run carries on. Processing typically takes 5 to 25 seconds per image, 4 images in parallel, so keep synchronous calls to a few dozen images. For bigger lists, use a client library: it waits as long as the run takes.
Step 4: Python
pip install apify-client. This targets version 3 of the client, where call() returns a typed Run object. Older examples that read run["defaultDatasetId"] were written for the 1.x and 2.x clients, which returned dicts.
import os
from apify_client import ApifyClient
client = ApifyClient(os.environ["APIFY_TOKEN"])
run = client.actor("sherwood/image-upscaler-batch").call(
run_input={
"imageUrls": [
"https://example.com/products/chair-600.jpg",
"https://example.com/products/lamp-600.png",
],
"scale": "4",
"outputFormat": "png",
}
)
if run is None or run.status != "SUCCEEDED":
raise RuntimeError("The actor run did not finish successfully")
items = client.dataset(run.default_dataset_id).list_items().items
for item in sorted(items, key=lambda i: i["index"]):
if item["status"] == "ok":
print(f'{item["inputUrl"]} -> {item["outputUrl"]} ({item["width"]}x{item["height"]})')
else:
print(f'FAILED {item["inputUrl"]}: {item["error"]}')
Sorting by index puts the results back in input order. To cap the cost from Python, pass max_total_charge_usd=Decimal("2") (from the decimal module) to call().
Step 5: JavaScript
npm install apify-client, then run this as an ES module (node upscale.mjs):
import { ApifyClient } from 'apify-client';
const client = new ApifyClient({ token: process.env.APIFY_TOKEN });
const run = await client.actor('sherwood/image-upscaler-batch').call(
{
imageUrls: ['https://example.com/products/chair-600.jpg'],
scale: '2',
outputFormat: 'webp',
},
{ maxTotalChargeUsd: 1 },
);
if (run.status !== 'SUCCEEDED') throw new Error(`Run ended with status ${run.status}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
for (const item of items) {
if (item.status === 'ok') console.log(item.outputUrl, `${item.width}x${item.height}`);
else console.log('FAILED', item.inputUrl, item.error);
}
What comes back
One item per image, like this example from the README:
{
"index": 0,
"inputUrl": "https://example.com/product-small.jpg",
"outputUrl": "https://api.apify.com/v2/key-value-stores/abc123/records/upscaled-0000.jpg",
"width": 2048,
"height": 2048,
"megapixels": 4.19,
"scale": "2",
"format": "jpg",
"model": "fal-ai/seedvr/upscale/image",
"durationMs": 9120,
"status": "ok",
"error": null
}
index is the image's position in your input list, so matching results to inputs never depends on order. outputUrl, width, height and megapixels describe the upscaled file; model and durationMs tell you what ran and how long it took.
Failed images come back as items, and are not charged
An unreachable URL, a file that isn't an image, or an input over 25 MB doesn't break the batch. That image comes back with "status": "error" and the reason in error, and it is never charged. In practice: filter on status, log the failures, and re-run just those URLs if the cause was temporary.
Using it from n8n
With the Apify node. Install the Apify community node (@apify/n8n-nodes-apify; on n8n Cloud it's listed among the verified community nodes) and add your token as an Apify API credential. Then:
- An Apify node with the Run Actor operation: actor
sherwood/image-upscaler-batch, the JSON above as custom input, Wait for finish on. - A second Apify node that reads the dataset items, with Dataset ID set to the
defaultDatasetIdfield returned by the first node.
With the HTTP Request node. POST to the run-sync URL from the curl example, with a Header Auth credential (name Authorization, value Bearer <your token>) and the input as the JSON body. n8n turns the returned array into one item per image, so the next node runs once per upscaled file.
If your URLs arrive as separate n8n items, put an Aggregate node in front; otherwise n8n sends one request per item instead of one list. If you set the node's Timeout option, give it at least 300,000 ms.
Using it from an AI agent (MCP)
Apify's MCP server can expose a single actor as a tool. Add it to your MCP client with a tools filter:
{
"mcpServers": {
"image-upscaler": {
"url": "https://mcp.apify.com?tools=sherwood/image-upscaler-batch"
}
}
}
On first connection, your browser opens so you can sign in to Apify. To skip the sign-in, send your token in an Authorization: Bearer header instead. With tools= set, the agent only gets this actor, as a tool named sherwood--image-upscaler-batch with the input schema above, plus helpers the server adds on its own (get-actor-run, get-dataset-items, get-key-value-store-record, abort-actor-run). Calling the actor tool returns the run metadata and a dataset ID; the agent then calls get-dataset-items to read the URLs.
Pricing at the time of writing
From the actor's README:
- Image upscaled: $0.012 per image delivered, output up to 4 MP included.
- Extra megapixel: $0.002 per started megapixel above 4 MP (a 4x upscale of a 1000×1000 photo is 16 MP, so 12 extra).
- Failed, unreachable or non-image URLs: free.
Worked through: a 2x upscale of a 1000×1000 photo gives 2000×2000, which is 4 MP and costs $0.012. The 4x version costs $0.012 + 12 × $0.002 = $0.036. The example item above, 2048×2048 at 4.19 MP, starts a fifth megapixel, so it costs $0.014. maxOutputMegapixels bounds the extra-megapixel part, and a maximum cost per run bounds the total.
Limits
- Inputs must be reachable over public http(s) URLs, up to 25 MB each. Local files need to go somewhere reachable first, for example a pre-signed URL from your bucket.
- Output files live in the run's key-value store and follow your account's data retention, so copy the ones you want to keep.
- Processing is typically 5 to 25 seconds per image, with 4 images in parallel.
-
maxImagesdefaults to 100, and the input schema allows up to 1,000 per run.
When I wouldn't use it
At high, steady volume, a self-hosted GPU can be cheaper per image, and it's the only option when images can't leave your network. If you already have an account with a model provider and the per-image glue written, the actor adds little: the model field shows the SeedVR2 endpoint it calls. For ten images and a laptop with a decent GPU, Upscayl does the job.
The actor is for the case in between: lists of URLs inside a pipeline or an agent, where you want a result or an error per image without running anything yourself. Actor page: apify.com/sherwood/image-upscaler-batch.
Top comments (0)