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. F

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. 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." 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) 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. 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. 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") 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. 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. 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. 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. 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. 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. 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. 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. 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 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.
Key Takeaways
- •I volunteered to help a local school digitize 550 yearbook photos
- •This story was reported by Dev.to, covering developments in the dev space.
- •AI advancements continue to reshape industries — read the full article on Dev.to for complete coverage.
📖 Continue reading the full article:
Read Full Article on Dev.to →


