DEV Community

Cover image for I Upscaled 550 Yearbook Scans for a School Archive. Total Cost: $0.
Monie Spiller
Monie Spiller

Posted on

I Upscaled 550 Yearbook Scans for a School Archive. Total Cost: $0.

I volunteered to help a local school digitize 550 yearbook photos. Most scans were 400–800 pixels wide — too small to print, too small to crop, too small to display. The upscaling itself was the easy part and cost nothing. The hard part was triage and quality control, so I wrote scripts for those. Final tally: 431 photos recovered, 62 already fine, 19 unsalvageable, $0 spent.


The school had done the scanning already. A parent with a flatbed scanner and a lot of patience had worked through fifty years of yearbooks over a summer. What came out was 550 JPEGs, and what came out was mostly unusable.

Not damaged — just small. The average scan was around 600 pixels on the long edge, because that's roughly what you get scanning a 2-inch yearbook photo at a sensible DPI, and because the originals are tiny. A 1974 class photo printed at thumbnail size contains exactly as much detail as a thumbnail.

This is the part nobody warns you about with archive projects: digitizing doesn't create resolution. It captures what's physically there. If the physical object is two inches wide, your scan is a high-quality image of something very small.

So the project had a problem I couldn't solve by rescanning, and a budget of exactly zero — it's a volunteer effort, there's no line item, and whatever I used had to be free or nothing.

Here's how it actually went.

Step 0: Stop Looking for Better Originals

My first instinct was the reasonable one: surely higher-resolution versions of these exist somewhere.

They don't. The yearbook photo is the original. There's no negative in a drawer, no higher-res master at the district office. This sounds like a special case but it's the default condition for school archives, local history collections, and most scanned physical material. The small file is the best version that exists, and once you accept that, the question changes from "where do I find a bigger one" to "how do I make this one bigger."

Step 1: Triage Before You Touch Anything

The mistake I nearly made was pointing an upscaler at all 550 files. That would have wasted hours on photos that were already fine, and quietly ruined photos that were too far gone to save.

Triage first. Every image got measured and sorted into one of four buckets based on the target — 3508 pixels on the long edge, which is A4 at 300 DPI, the smallest output I considered acceptable:

"""Triage a scanned archive before upscaling anything."""
from dataclasses import dataclass
from pathlib import Path
import csv

from PIL import Image

TARGET = 3508          # A4 at 300 DPI, long edge
MAX_UPSCALE = 3.0      # beyond this, you're storing hallucinated detail
REVIEW_KEYWORDS = ("class", "team", "group", "portrait")  # likely faces -> review


@dataclass
class Scan:
    path: Path
    width: int
    height: int

    @property
    def long_edge(self) -> int:
        return max(self.width, self.height)


def load_scans(root: Path) -> list[Scan]:
    scans = []
    for p in sorted(root.rglob("*.jpg")):
        with Image.open(p) as img:
            scans.append(Scan(p, img.width, img.height))
    return scans


def triage(scan: Scan) -> dict:
    scale = TARGET / scan.long_edge

    if scale <= 1.0:
        action = "keep"                      # already meets target
    elif scale <= MAX_UPSCALE:
        action = "upscale"
    else:
        action = "reject"                    # would need >3x; keep the original

    needs_review = any(k in scan.path.name.lower() for k in REVIEW_KEYWORDS)

    return {
        "path": str(scan.path),
        "source_long_edge": scan.long_edge,
        "target": TARGET,
        "scale": round(scale, 2),
        "action": action,
        "review": needs_review and action == "upscale",
    }


if __name__ == "__main__":
    rows = [triage(s) for s in load_scans(Path("archive/raw"))]
    with open("triage.csv", "w", newline="") as fh:
        writer = csv.DictWriter(fh, fieldnames=list(rows[0].keys()))
        writer.writeheader()
        writer.writerows(rows)

    counts = {}
    for r in rows:
        counts[r["action"]] = counts.get(r["action"], 0) + 1
    print(counts)
Enter fullscreen mode Exit fullscreen mode

Two design decisions in there that saved me later:

The 3x cap. Beyond roughly three times the source resolution, you're not recovering detail, you're commissioning it. Those files got flagged as reject rather than quietly turned into plausible-looking fiction at 8K.

The review flag. Any filename containing "class", "team", "group", or "portrait" got marked for human review, because those are the photos with many small faces — exactly where upscaling hallucinates most confidently. It's filename-based and crude. It was also the single most useful line in the script.

Step 2: The Actual Upscaling

Here's where I expected the hard part and found the easy one.

The constraint was total: free, browser-based, no account, no installation. That rules out most professional tooling regardless of quality, and for a volunteer project with no budget, "requires a license" and "doesn't exist" are the same sentence. A browser-based 8k photo upscaler AI with a free tier was what fit: open the page, drop a file, download the result.

What it doesn't do is batch. So the workflow became: run the upscale queue in the evenings, in chunks the free tier allowed, while the scripts handled everything around it. Roughly 40–60 photos a night over about two weeks.

The bottleneck was never the upscaling. It was knowing which files to feed it and verifying what came back.

Step 3: QA Everything That Comes Back

Never trust an output because it's larger. A file can be the right dimensions and still be wrong — wrong aspect ratio, truncated write, or an upscale that quietly didn't apply.

"""Verify upscaled derivatives actually match the intended target."""
from pathlib import Path
import csv

from PIL import Image

TARGET = 3508
TOLERANCE = 0.02   # allow 2% under target; catches silent no-ops


def qa(derivative: Path, source_long_edge: int) -> dict:
    with Image.open(derivative) as img:
        long_edge = max(img.width, img.height)
        ratio = img.width / img.height

    passed = long_edge >= TARGET * (1 - TOLERANCE)

    return {
        "path": str(derivative),
        "source_long_edge": source_long_edge,
        "output_long_edge": long_edge,
        "aspect_ratio": round(ratio, 3),
        "effective_scale": round(long_edge / source_long_edge, 2),
        "passed": passed,
        "note": "" if passed else "below target - re-run",
    }


if __name__ == "__main__":
    manifest = list(csv.DictReader(open("triage.csv")))
    pending = [m for m in manifest if m["action"] == "upscale"]

    results = []
    for row in pending:
        out = Path("archive/derived") / Path(row["path"]).name
        if out.exists():
            results.append(qa(out, int(row["source_long_edge"])))

    failed = [r for r in results if not r["passed"]]
    print(f"{len(results)} checked, {len(failed)} failed")
Enter fullscreen mode Exit fullscreen mode

This caught eleven files that came back at the original size — the upload had silently not processed. Without the check, those would have shipped into the archive as "upscaled" while being nothing of the kind.

The Results

Bucket Count What happened
Adequate as-is 62 Already met the 3508 px target, left untouched
Upscaled, QA passed 431 Hit target, aspect ratio preserved
Upscaled, flagged for review 38 Group shots with small faces — a human looked at each
Rejected 19 Would have needed more than 3x; originals kept and labeled
Total 550 $0 spent

The review pile is worth a note. Of the 38 group photos flagged, I ended up discarding four upscales. In each case the model had invented symmetrical, plausible, entirely fictional faces where the original had five pixels of blur. They looked better than the originals. That's precisely the problem.

Those four are now stored with their originals and a note saying the enhanced version is not to be used as a record of who was in the photo.

What I'd Do Differently

Triage harder on noise. Grainy scans upscale badly — the model dutifully enlarges the grain along with everything else. Denoising before upscaling would have improved maybe thirty files.

Shoot the archive at higher DPI than feels necessary. The scans were done at 300 DPI, which sounded right and wasn't: these are 2-inch prints, so 300 DPI yields ~600 pixels. For small originals, scan at 1200 or 2400 DPI. More source pixels beats any amount of upscaling, and it's free at capture time in a way it isn't afterward.

Name files for the content, not the year. My review flag was a filename heuristic. It worked because the scanner happened to name files descriptively. If I'd been doing this from scratch, the manifest would have had a has_faces column filled in during the first pass.

The Honest Limits

Upscaled detail is predicted, not recovered. On a clean scan of a building, a landscape, or a single portrait, the prediction is usually good enough that nobody can tell. On group photos, fine text, and badly damaged material, it confidently produces things that were never there.

For a school archive, that distinction has teeth. An upscaled yearbook photo is partially synthetic. It's fine for a display case, a slideshow, or a print that makes someone's grandmother recognizable. It is not acceptable as a documentary record of who was in a class, and treating it as one is the kind of thing that quietly corrupts an archive.

The rule I settled on: upscale for legibility, cite from the original. Keep both, label the derivative, never let the enhanced version become the only copy.

FAQ

Was this actually free, or just a trial?

Actually free, but not unlimited — the constraint was throughput, not money. Free tiers usually cap file size or daily volume, so 550 photos became a two-week evening queue instead of a single batch job. For a volunteer project, trading calendar time for money is the correct trade.

Why not just run a local model?

I could have — ESRGAN and friends are a pip install away. But self-hosting means GPU time, and the whole point was zero budget. If I were doing this at ten times the volume, self-hosting would start winning. At 550 files, the free tier was strictly better economics.

Wouldn't rescanning at higher DPI have been better?

Yes, by a wide margin. More source pixels always beat synthesized pixels. But the yearbooks had already been returned to storage, and for small originals even 300 DPI scanning was the wrong call the first time — 1200+ DPI would have made most of this unnecessary.

How do I know when an upscale is lying?

Check faces and text at 100% zoom. Those are where hallucination shows up first and most confidently. If the output looks better than the source in a way that feels suspiciously clean, it probably is.

Can I use the upscaled images on a school website?

For display, absolutely. Just keep the originals and don't present enhanced images as archival records. Label derivatives clearly so nobody downstream mistakes predicted detail for documented fact.

The Bottom Line

The interesting result isn't that the photos got bigger. It's that the project was possible at all.

A year or two ago, this would have required either a software budget the project didn't have, or accepting that 550 photos of local history stay too small to use. The free tier changed which of those was the default — and for volunteer, grant-funded, and bootstrapped education work, that's not a small difference. It's the difference between the archive existing and the archive existing badly.

Final tally: 431 photos people can actually look at, 19 that no tool could save, about two weeks of evenings, and zero dollars.

The scripts are above. If you're doing something similar, triage first — it's the step that decides whether the rest is worth doing.

Top comments (0)