Eight complete scripts for things people build with jevimage. Each one is the whole file: paste it, run it, get the output shown. They are ordered roughly from “read one image” to “operate this in production”, and each ends with the thing that will bite you. If you have not read Question types yet, start there; this page assumes you know what choice, score, noul and head mean.

How these were run

Every block below was executed and its output pasted back verbatim. The machine had no network for model weights, so all of them ran against a deterministic stand-in encoder instead of SigLIP. That is toy.py, which hashes the pixel bytes and knows nothing about anything:
That means the probabilities on this page are noise. A hash of the pixel bytes knows nothing about redness or clutter, so jevimage.load(Toy()) calls a plain red square a screenshot and is very sure about it. What is real: the shapes, the field names, the control flow, the errors, and the relative cost of one encode versus many questions. Swap jevimage.load(Toy()) for jevimage.load() and the same scripts run on siglip2-base-224 with answers that mean something. The images are the 12 flat 64x64 squares in ds/, laid out the way read_folder expects:
Write them with examples/make_fixtures.py in the directory you run these scripts from. The stand-in encoder hashes the pixel bytes, so every probability below is a number about those squares: with your own images the shapes and the control flow are identical and the numbers are not. The timings were measured on an otherwise quiet 16-thread laptop CPU (12th Gen Core i7-12650H, OMP_NUM_THREADS=1, no GPU). They are microseconds of arithmetic, so they move with the machine; the ratios between them are the part to read. Recipe 7 also needs a server. Anything that speaks the API will do:

1. Screen one upload against every policy at once

Problem: a user uploads an image and five separate policies want an opinion on it before it goes live.
Five policies, one forward pass. The captions are embedded once and cached per string for the life of the process, which is why the second identical ask costs almost nothing beyond the encode. Adding a sixth policy costs one more caption embedding the first time and a matmul thereafter, so policies are cheap to add, and there is no reason to batch them into one vague question. The 8x gap between the two asks is the stand-in encoder’s: on siglip2-base-224 a cold caption costs a real text-tower pass, and the same split measured 1020 ms cold against 0.39 ms cached for 16 questions. A fixed policy list is what makes this pattern pay (timings). Watch out: a noul pair is a contrast, not an absolute. 0.920 for medical means “closer to the true caption than the false one”, not “92% likely to be a medical photo”, so the false caption does real work. "no weapon of any kind" is a much better opposite than "" or "something else". One ask() takes at most 64 questions; past that it raises rather than truncating.

2. Tag a product shot on every axis at once

Problem: one photo, four independent catalogue fields, one row out.
repr() is the short form for logs; json.dumps is the full shape, because an Answer is a dict subclass and needs no conversion step. A score is the expected level index. 1.07 on a three-level rubric means “a few props, leaning very slightly towards crowded”, which is a more useful number for sorting than an argmax would be. Watch out: shot used a bare list, so the label is the caption and the value you store is a whole sentence. Use a {label: description} object whenever the label lands in a database column. A bare list with two identical captions raises rather than silently collapsing to one option.

3. Auto-approve what is confident, queue the rest

Problem: you want the model to handle the easy 90% and a human to see the rest.
Threshold confidence, not max(probabilities). The last two lines are why: a top probability of 0.5 is a coin flip over two options and a strong signal over fifty. confidence is (max - 1/K) / (1 - 1/K), so 0 always means uniform and 1 always means certain, and one threshold survives you adding a third option to the question. Watch out: confidence measures how peaked the distribution is, not how right it is. The stand-in encoder above is confidently wrong on five of six images. Pick the threshold by running a few hundred labelled examples through and looking at where the errors sit. Remember that an image whose true answer is not among your options still comes back confident, because the distribution only covers the options you gave.

4. Cache the embedding, ask new questions later

Problem: the images were encoded last week; today someone wants a new field, and re-encoding the archive is the expensive part.
An embedding is dim floats (768 for siglip2-base-224, so 1.5 KB in fp16) and it is the entire cost of a request. Store it next to the row, keep the original image somewhere cold, and any future question is a matmul. The 3x here is small only because the stand-in encoder is a hash function; with a real encoder on a GPU the ratio is the whole forward pass against a few microseconds. Watch out: do not key, hash or diff on the bytes of an embedding. It is stable for a fixed encoder, device, dtype and batch composition, and not otherwise: encoding the same image alone and inside a batch of four on siglip2-base-224/CPU differed by 1.8e-07, which left the answer unchanged at six decimals but not the vector. jev serve runs with batching on, so what an image embeds to depends on who else was in flight. Compare with a tolerance. Watch out: an embedding is meaningful only in the encoder that produced it, so store encoder.name beside it and check it, as above; nothing downstream will notice otherwise, and two encoders of the same dim will silently produce nonsense. ask_embedding() exists only on a local Jev: its whole purpose is skipping the network, so connect() does not have it. Remote.embed() still gives you the vectors: a torch.Tensor if torch is installed, a plain list of lists on an API-only install, where .half() and .float() do not exist.

5. Sweep a directory and route it into folders

Problem: a shoot lands as a flat directory and needs sorting into bins, with the unusable frames set aside. Here shoot/ holds the same 12 images as ds/, flat and unlabelled, which is how they actually arrive. Flattening two label folders means two 0.pngs, so the blue ones are prefixed: ds/red/0-5.png become 0-5.png, and ds/blue/0-5.png become b0-b5.png.
embed(list) hands the whole list to the encoder in one go; a loop of ask() calls would hand it one image at a time and waste the GPU. Encode in bulk, then read each embedding. The loop that follows does no model work at all, so it can do arbitrary routing logic without thinking about cost. Watch out: Jev.embed() has no size cap, so a directory of 50,000 images is 50,000 decoded bitmaps in RAM at once, so slice it into chunks of a few hundred. Going through a server, Remote.embed() chunks for you at 8 images per request, which is the server’s own limit. IMAGE_SUFFIXES is deliberately short (.jpg .jpeg .png .webp); a .gif or a .tif in the folder is skipped here and refused with a 415 by a server.

6. Train a head when the prompts plateau

Problem: zero-shot prompts get one class right and the other wrong, and you have a folder of labelled examples.
Read the table before you ship the head. Overall it won by 8 points, which looks like a success, and it got there by taking red from 0.167 to 0.500 while pulling blue down from 0.667 to 0.500. On this data you would keep the prompts for blue and the head for red, or go and find more blue examples. That per-class split is the reason the comparison exists; an overall delta alone would have hidden it. Watch out: accuracy is stratified k-fold with k = min(5, thinnest class), so six examples per class means folds of one or two images and a number with enormous error bars. 0.500 on 12 examples is a direction, not a measurement. The baseline uses "a photo of {}" unless you pass template=, as this script does; comparing a tuned head against an untuned prompt you would never have written is how a head looks better than it is. Training on a server works the same way (Remote.train), but it has no template parameter, so the baseline there is always the default one.

7. The same script against a local encoder and a server

Problem: develop against a server your team already runs, then move the encoder in-process for a batch job, without touching the code in between.
screen() takes whichever object it is handed. Training goes over the wire too: the labelled images upload, the encoder that fits the head is the server’s, and what comes back is the same Head object with the same comparison; the weights never leave. delete_head returns True/False on both sides rather than raising on the second call, because that is what the local one does. Watch out: two gaps are deliberate and both are visible above. ask_embedding is local-only, because skipping the network is its entire point, and health() is remote-only, because there is no server to report on locally. A third asymmetry is quieter and this script does not show it: Jev.train(template=) and Jev.compare(template=) have no remote equivalent. The numbers agree only because both sides run the same encoder, so check health()["encoder"] before you assume a server’s answers are comparable with your local ones, and remember heads live where the encoder is, so a head you trained locally is not on the server.

8. The failures you will hit

Problem: knowing in advance what jevimage refuses to do, and what it says when it refuses.
Five refusals, all of them things a working system will hit eventually. The head/encoder check is the one that matters most: two encoders with the same dim would otherwise produce plausible nonsense, so a head records the encoder that fitted it and refuses to run under another. The single-caption noul needs both logit_scale and logit_bias: SigLIP publishes both, CLIP neither, and an Ensemble averages its members’ scale but has no bias. The {"true", "false"} pair form works everywhere and is better calibrated, so prefer it. Watch out: catch ValueError. Locally that is what the engine raises; remotely JevError is a subclass of it, carrying .status and the server’s own .detail, so one except ValueError covers both and an existing handler keeps working when a load() line becomes a connect() line. A dead server and a rejected request are both JevError; branch on .status is None if you want to retry only the former. Two things ask() can raise are not refusals of the question and are outside ValueError: a list passed as image is a TypeError, and a head file that is present but will not load is a KeyError — see ask() takes one image and A head file that will not load.

Where to go next


← HTTP reference · Docs index · Troubleshooting →