DEV Community

Cover image for Framejury, a culling assistant that works offline and explains every photo it rejects
Carlos José Castro Galante
Carlos José Castro Galante

Posted on

Framejury, a culling assistant that works offline and explains every photo it rejects

Hacktoberfest Open-Source AI Challenge Week 2: Side Hustle Submission 💸

This is a submission for the Hacktoberfest Open-Source AI Challenge Week 2: Side Hustle

What I Built

Framejury looks through a folder of event photos, groups the frames that were shot seconds apart and look nearly the same, and marks the best one of each group to keep, while every other frame is rejected with a sentence that says why, the numbers behind that sentence are one click away, and a group that is too close to call gets flagged so the photographer makes the final choice. Any decision can be changed by the photographer and undone later, and nothing is uploaded anywhere, because the vision model runs on the same computer through Ollama.

The person who would pay for this is the photographer who delivers events such as graduations, weddings or school sports days, where a single afternoon can produce thousands of frames and long bursts of the same moment, and picking the keeper from each burst is hours of unbilled work. Paid tools that automate that work already exist, mostly as monthly subscriptions and with some of the cloud ones charging for each exported photo, so Framejury goes after the same hours with a different deal, since it's free and MIT licensed, it keeps client photos on the photographer's disk and it doesn't ask to be trusted, because every decision comes with its evidence.

The value Framejury offers is attention more than speed, because on my laptop, which has an RTX 4050 with 6 GB of video memory, the analysis takes between 27 and 36 seconds for seven photos, and in raw time that's about as fast as a person culling at four seconds per photo. The difference is that the machine can work while the photographer does something else and then ask for a human look only at the photos that deserve one. The results page estimates the time saved by assuming four seconds of manual work per photo, an assumption the user can edit, and it subtracts the analysis time, so on my small test folders it plainly reports none, and I left it that way because an inflated number would be worse than no number.

Demo

The video lasts one minute and 18 seconds, has no voice and tells the story with captions, with the analysis stage sped up, and I switched the internet connection off while the analysis ran, so what you see is the offline behavior. The sample photos are by Trougnouf (Benoit Brummer) from the 2023 ULB graduation ceremony, published on Wikimedia Commons under CC BY-SA 4.0, and they aren't part of the repository, while the frames below show the interface as a photographer would use it.

A group of five photos with one Keep and four Reject decisions

One burst, one keeper, and a reason under every photo.

The Evidence panel open on a rejected photo

The Evidence panel shows the sharpness relative to the best frame, the clipped areas and what the model observed.

The export panel with CSV, folder copy and XMP options

Exports only write copies or new companion files.

A notice explaining that Ollama isn't running

When Ollama is off, the interface explains what happened, offers a command to copy and a retry button.

Code

GitHub logo carlosjcastro / framejury

A local photo culling tool for event photographers that groups near identical shots, picks the best one of each group with Gemma 4 through Ollama and explains every decision in plain words, while your photos never leave your computer.

Framejury, local photo culling that explains every decision

Framejury

Framejury is a photo culling tool for event photographers that looks through a folder of photos, groups the shots that are nearly identical, picks the best one of each group and explains the choice in words you can read, check against the evidence and change with a single click. Everything runs on your own computer, which means your photos are never uploaded anywhere and the vision model that compares them runs locally through Ollama.

Demo

A minute and a half with captions and no sound needed. Watch it on YouTube.

Sample photos in the demo are by Trougnouf (Benoit Brummer), from the 2023 ULB graduation ceremony, via Wikimedia Commons, under CC BY-SA 4.0.

Why Framejury exists

After a wedding, a concert or a company event, a photographer comes home with thousands of frames, and a large share of them are bursts of the same moment, so choosing the…

The repository has a Python package called framejury in backend, a Next.js interface in frontend, and the MIT license together with a security policy and a contributing guide. It needs Python 3.10 or newer, Node.js 20.9 or newer and Ollama, and after pulling the model with ollama pull gemma4:e4b-it-qat the two parts start in separate terminals.

uvicorn framejury.api:app --host 127.0.0.1 --port 8000
npm run dev
Enter fullscreen mode Exit fullscreen mode

A graphics card speeds up the model step, although Ollama also runs on a processor alone, only more slowly, and the analysis can skip the model and work from measurements only. The engine also runs without the interface through python -m framejury analyze <folder>, which writes a report and plain lists of the photos to keep, reject and review, and the backend has 42 tests that use a simulated Ollama connection, so they run without the model, while the history in the repository starts on October 13.

How I Built It

Framejury runs as two local processes, a FastAPI service that handles jobs, thumbnails, manual overrides and exports, and a Next.js 16 interface with React 19 and Tailwind 4 that talks to it, while the open pieces are Gemma 4, in the quantized build gemma4:e4b-it-qat, and Ollama, which serves it on localhost to the engine through a small httpx client.

A group of photos starts with ordinary image analysis, where sharpness is the variance of the Laplacian computed on a six by six grid of tiles over an image reduced to 1,024 pixels and the score is the 90th percentile of those tiles, so a face in focus against a soft background still counts as sharp. Exposure counts the pixels that are nearly white or nearly black, a 64 bit difference hash gives each photo a visual fingerprint, and the capture time comes from EXIF with the file's modified date as a fallback.

A photo joins the current group when it was taken within 12 seconds of the previous one and its hash differs by at most 24 of 64 bits from at least one photo already in the group, both limits being settings in the interface, and by default only the four sharpest photos of each group go on to the model while the rest are rejected right away with their sharpness relative to the best frame, which keeps the cost of a group bounded.

The model receives that shortlist as images and answers with JSON that a schema forces into shape, giving for each photo the number of people, how many of them have closed eyes, an expression score and a subject focus score from 1 to 5, plus the photo it would keep, with thinking turned off and the temperature set to 0. Python rules in decision.py, a file of about 200 lines, then turn those observations and the measurements into a score that follows these weights.

What counts Points
Sharpness relative to the best photo of the group up to 60
Subject focus reported by the model up to 15
Expression reported by the model up to 15
The model's pick, when both passes agree 5
Hard flag, which means under 60% of the best sharpness, subject focus of 2 or less, any closed eyes, more than 25% blown highlights or more than 40% crushed shadows minus 50
Soft flag, which means an expression of 2 or less minus 15

The model's vote is worth 5 points out of a possible 95, so Gemma works as a witness whose observations feed the rules and never as the judge, and a keeper that carries a hard flag becomes Review instead of Keep, while a group whose two best scores are within 5 points is flagged as needing a look and the runner up's reason starts with a note that it's very close to the kept photo.

Language models tend to favor the image that comes first, so each group is judged twice with the order reversed and the model's pick only counts when both passes agree, and when they disagree the rules decide on the measurements alone.

The most instructive bug came from the notes, because Gemma kept writing sentences such as "the second image has closed eyes", which mean nothing on the card of a single photo, so I changed the prompt to ask for a description of each photo on its own and added a filter that discards any sentence pointing at a photo by position, in English or in Spanish, which makes the card fall back to the reason written by the rules.

The same distrust applies to the closing note that the model can write after an analysis, since the engine gives it only a JSON of facts and discards the answer if it mentions a file name or a number that isn't in those facts, letting a template write a plain note instead, which is also what happens when Ollama can't be reached.

Because the engine reads private photos, it listens only on 127.0.0.1, refuses any host name other than localhost and 127.0.0.1, rejects requests that change something when they come from another origin, and checks that every thumbnail path stays inside the folder the user chose.

Nothing about the analysis is locked in, since the advanced options let the user choose which criteria count among sharpness, exposure, closed eyes, subject focus and expression, limit the file types to JPEG, PNG, WebP or RAW, include subfolders, skip files by pattern, adjust the time window, the similarity threshold, the number of candidates and the strictness about soft shots, and compare a selection chosen by hand as one group, with the reasons written in English or in Spanish.

On the interface side, the home page checks that Ollama and the model are ready before anything starts and every failure becomes a plain message with a command to copy, each decision is shown with an icon and a corner mark that differ between Keep, Review and Reject so color is never the only signal, buttons expose their pressed state and are at least 44 pixels tall on phones, and animations follow the reduced motion setting unless the user opts in with a switch in the header, while a print view turns the results into a PDF that can be sent to a client.

Exports copy the Keep and Review photos into a framejury-export folder next to the originals, bringing along RAW and JPEG pairs and existing sidecar files and adding the rejects too if the user asks, a CSV lists every decision with its reason and measurements, and optional XMP sidecars carry a color label, green for Keep and red for Reject, without overwriting any sidecar that already exists, an option that is marked experimental in the interface because Lightroom reads those labels for RAW files and ignores them for JPEG, and I haven't opened the sidecars in Lightroom myself.

I used Claude as an assistant for guidance, code review and fixing errors, including drafts of some of the fixes, and I ran, tested and committed every change myself, and Framejury also depends on the open source work of others, namely Gemma from Google, Ollama, Pillow, NumPy, OpenCV, rawpy, httpx, FastAPI, Uvicorn, Next.js, React and Tailwind, as well as on the sample photos credited above.

The small Gemma model has the weaknesses of a small model, since the notes it writes are often generic, closed eyes are harder to detect behind glasses or on small faces and RAW files are judged from their embedded preview and not from the full sensor data, while the Review status is rare because close calls stay as Keep or Reject with a flag on the group, which is also why the header can say zero reviews while the results page counts photos that deserve a look. One burst in the Wikimedia set also split into three groups because its frames had been cropped differently, and I would fix these points first.

Why Does Open Innovation Matter?

A cloud service needs the photos to leave the photographer's computer, and for client work that decision isn't always the photographer's to make, whereas with Gemma running through Ollama the only network use is the single download of the model, plus the packages and fonts needed to install and start the interface, so the analysis itself works in a venue or on a train without a connection.

Cost follows the same logic, since a group can send up to eight image evaluations to the model, four photos in two passes, and a hosted vision API would bill each of them on every run, while a local model costs electricity on a machine the photographer already owns.

Control is the third reason, because the engine lists the models installed in Ollama and flags which ones can see images, so a photographer with a stronger graphics card can pick a larger model in the advanced options and one with an older computer can pick a smaller one without waiting for me, and a pinned local model at temperature 0 also behaves next month the way it does today, while a hosted model can change underneath the tool and quietly alter the decisions on a folder that was already delivered.

The rules are the last piece, and they're ordinary Python that anyone can read and change, so much so that the contributing guide asks that new criteria arrive as readable rules in decision.py and not as hidden behavior inside a prompt, and a closed API would have given me a faster first version but not a tool I could ask a photographer to trust with a client's photos.

Prize Categories

I'm entering Best Use of Gemma, because Framejury runs Gemma 4 locally through Ollama to judge closed eyes, expression and subject focus with structured output, asks it twice in opposite order to reduce position bias, and lets the user swap the model for any other vision model installed on their machine.

If you shoot events, which decisions would you still want to make yourself, and what would a culling tool have to show you before you trusted its keepers?

Top comments (0)