The encoder is the one part of jevimage you are meant to replace. Everything above it (choice, score, noul, heads, training, the HTTP routes) works on unit-norm vectors and never asks where they came from. So the encoder is what decides your accuracy, your latency, your embedding width, your licence obligations, and whether a single-caption noul is available at all. There are four ways to get one:
How the examples on this page were run. Every block below ran against a stand-in encoder over a folder of plain red and blue PNGs (ds/red/*.png, ds/blue/*.png), the twelve squares examples/make_fixtures.py writes. The stand-ins are MeanColour and Grey, both written out in full under Writing your own encoder (Grey exists only so the ensemble examples have a second member), and Toy, a deterministic 16-dimensional hash encoder of the kind the package’s own self-tests use (demo() in jevimage/server.py); ToyCalibrated is that same toy with logit_scale and logit_bias filled in, to exercise the calibrated path. The outputs are real and unedited. They show the plumbing, not SigLIP’s accuracy: a 3-dimensional colour space saturates to p=1.000, which a real encoder does not. No throughput number on this page was measured, and none is quoted.

The registry

jevimage.available() is the list; jev encoders prints it with the current default marked. Both work in an API-only install, because listing encoders should not require the stack that runs them:
Set $JEV_ENCODER and the marker follows it. That variable is what jevimage.load(None) reads, so the listing and the loader cannot disagree:
Relative cost follows the checkpoint name, not this page. A ViT spends its time on image tokens, and the token count is (resolution / patch)²: 224/16 gives 196 tokens, 384/16 gives 576, so the same backbone at 384px does roughly three times the vision work of the 224px one. Across rows, base < so400m < giant in the vision tower, and clip-vit-l-14 (ViT-L/14 at 224 → 256 tokens) sits below metaclip2-huge (ViT-H/14 at 224, so the same token count but a wider, deeper tower) and dfn5b-h-14-384 (ViT-H/14 at 378 → 729 tokens). That is arithmetic off the names. It is not a benchmark, and it says nothing about which one is more accurate on your images. For that, label a hundred of them and read the per-class table from train. The “sigmoid noul” column is the checkpoint’s own claim, not a setting. HFCLIP reads logit_scale and logit_bias off the model when it loads it, and leaves them None when the model has no such parameter (jevimage/encoders.py). SigLIP-family models were trained with a pairwise sigmoid loss and carry both; CLIP and MetaCLIP carry a scale only; the OpenCLIP wrapper sets logit_bias = None outright, because an open_clip contrastive model has no pairwise bias to publish. A single-caption noul needs both, so it is a SigLIP-family feature. The siglip2-base-224 row is measured; the rest of the column is read from the loader’s rule and the models’ training objective. One line confirms it on yours:
Registry keys are identifiers, not descriptions. A head records the encoder’s .name, and load() stamps the registry key onto whatever it loads, so that name is the key for anything that came out of the registry. Renaming an entry would orphan every head trained under it, so the rule in the source is add, do not rename. dfn5b-h-14-384 is the visible scar: Apple’s checkpoint is 378px, and the repository was renamed from -384 to -378, but the key keeps the old spelling because heads in the field point at it. A registry entry is a model plus its handling, not just a repository id. The SigLIP entries tokenise captions to a fixed 64 tokens, because SigLIP was trained on fixed-length captions and measurably degrades with dynamic padding; the CLIP and MetaCLIP entries want the opposite and use dynamic padding up to 77. You never set that, but it is why pointing HFCLIP at some new repository id means knowing which of the two it expects. Those numbers are a ceiling, not an error: a caption longer than the entry’s token limit is truncated to fit, so the tail of a very long caption is not read. Criteria are descriptions (“a red front door”), not paragraphs, so this is a limit you have to go looking for - but if you are generating captions, that is where they stop mattering. available() lists registry entries only. Ensembles are formed on demand and never appear.

What changes when you swap

Swapping the encoder is one argument, but five things move with it. 1. The temperature moves. A raw cosine lives in a narrow band and would softmax to nearly uniform, so the engine divides by a temperature, and it takes that temperature from the encoder: 1 / logit_scale when the encoder publishes one, otherwise 0.01, the CLIP-family convention.
The consequence: the same question over the same image gives differently shaped distributions under two encoders, even when both pick the same option. If you threshold on a probability, re-check the threshold after a swap. load(..., temperature=...) overrides it when you want one number across encoders. 2. dim moves, so cached embeddings die. If you keep embeddings from embed() in a vector store, they belong to one encoder. Nothing recomputes them for you. 3. Trained heads stop. See A head belongs to the encoder that fitted it. 4. Single-caption noul may vanish. On an encoder without both calibration numbers it raises rather than inventing a probability; the {"true": ..., "false": ...} pair form works everywhere. See Question types. 5. Device and dtype are the built-in wrappers’ business. HFCLIP and OpenCLIP pick $JEV_DEVICE, else CUDA if present, else CPU, and run bf16 on GPU and fp32 on CPU. A custom encoder decides for itself; nothing in the framework will move your model.

Ensembles: '+'

Any two or more registry names joined by + load as one encoder:

What it computes

Ensemble concatenates the members’ embeddings and renormalises. Because each member is already unit-norm, the cosine of the concatenation is exactly the mean of the members’ cosines. An ensemble is an average of opinions, not a longer vector with some arbitrary weighting. That is worth checking rather than believing:
Two consequences fall straight out of that. Every member gets equal weight, whatever its dim. The members are normalised before concatenation, so a 1152-dimensional member does not outvote a 768-dimensional one, and there is no knob to change that. And with three members you get the mean of three cosines, not a vote: one averaged similarity that the usual softmax reads, so a member that is confidently wrong drags the average rather than being outvoted.

What it costs, and what it loses

The latency is the sum of the members’ latency; both models run on every image and on every caption. There is no early exit. A two-member ensemble of large encoders is the slowest configuration in this library. It does lose the sigmoid, permanently and by design:
Both members were calibrated and the ensemble still refuses the single-caption noul, because logit_bias is None on every ensemble. That is correct: a bias fitted against one model’s own negatives says nothing about the average of two models’ cosines, and pretending otherwise would return a number that looks like a probability and is not one. The scale does survive. logit_scale is the mean of the members’ scales when every member publishes one, and None (so the 0.01 CLIP-family fallback) as soon as one does not. Both stand-ins here carry 100.0, so the 0.01 printed above is 1 / 100, the ensemble’s own temperature rather than the fallback; an ensemble of SigLIPs reads at a SigLIP temperature. When it is worth it: you have measured that one encoder is systematically wrong on a slice of your images in a way the other is not, your latency budget has room for both, and you do not need the single-caption noul. Otherwise prefer one better encoder, or a trained head on the cheap one, which costs a matmul instead of a second forward pass.

Ensemble names

The name is the joined string, and order is part of it:
Two ensembles of the same members in different orders are two different encoder names, so a head fitted under one is refused under the other, even though the embeddings differ only in column order. Pick an order and keep it. A malformed name is rejected rather than repaired. A leading or trailing +, a doubled ++, a + +, and a bare + all raise; none of them quietly collapses to a smaller ensemble:

Writing your own encoder

Nothing needs to subclass anything. An encoder is any object with four members: Both return torch.Tensor. Both are called with lists and must preserve order; the engine zips rows back to labels positionally. Two optional attributes, and only these two, make the single-caption noul available: Leave them off entirely, or set them to None, and the framework reads them as absent (getattr, not attribute access — a duck-typed encoder that publishes neither must load, not crash). Without them, a single-caption noul refuses:
With them, the same question reads straight off the sigmoid:
Those two numbers are wiring, not calibration: they were picked here to land the output somewhere readable. Publish logit_scale and logit_bias only when they came from fitting a sigmoid on this model’s cosines. SigLIP has them because it was trained with that loss. Two numbers chosen by taste give a confident, wrong probability, which is the one thing a probability-first library must not do. The {"true": ..., "false": ...} pair form needs no calibration and works on every encoder.

A complete encoder

This is the whole thing. No weights, no download, and it answers colour questions:
jevimage.encoders.unit() is the helper the built-in encoders use: it pulls the tensor out of whatever a model returned, mean-pools a token-level (n, tokens, dim) output down to one row per item, casts to fp32 and L2-normalises with a clamp so a zero row becomes zeros rather than nan. Always fp32: bf16 has too few mantissa bits for a stable softmax, and a probability that changes with the dtype is not a probability. Hand the instance straight to load(), no registration needed:
That 1.000 is the stand-in, not the library: three dimensions and a hand-made palette separate red from blue completely, and with no logit_scale the temperature is 0.01, which sharpens it further. A real encoder in 768 dimensions spreads the mass out.

Registering it under a name

register(name, factory) adds it to the same registry the built-ins live in. The factory is called with no arguments the first time load(name) asks for it, so a class is itself a valid factory:
Registering buys three things: load("name") by string, membership in '+' ensembles, and the key itself as the provenance string in a head record. It does not make the name visible to the jev command, which never imports your code:
A registry name may not contain +, which is reserved for ensembles:
And the key you register under becomes the encoder’s .name: load() stamps it onto whatever the factory returned, so the string you load by and the string a head records are always the same one.
The factory’s own .name survives only when you skip the registry and hand an instance to load() yourself. MeanColour().name is still mean-colour there. What that leaves you to avoid is registering one model under two keys: those are two provenance strings, and a head fitted under one is refused under the other. One key per model.

A wrapper is an encoder too

The cheapest useful custom encoder is a wrapper around a model you already have, because .img() and .txt() are the only two things the engine calls. Here is prompt ensembling, the classic CLIP trick: each caption is embedded under several templates and averaged before it ever meets an image.
That ran here over a deterministic stand-in registered as toy; substitute load_encoder("siglip2-base-224") for the real thing. Note logit_scale = logit_bias = None: averaging four text vectors invalidates the sigmoid the model was trained with, so the wrapper drops the calibration rather than passing on a number that no longer means what it says. Single-caption noul then refuses, which is the correct outcome. Measured by the library’s author on a 350-image, 7-class expression set (not re-run for these docs), that wrapper lifted zero-shot accuracy from 22.3% to 32.3% on exactly the same embeddings and the same base captions. It costs four text encodes per label instead of one, and the engine caches caption vectors per string, so the cost is paid once. You can also skip the registry and hand an instance straight to load():

Serving your encoder

jev serve can only name built-in encoders, for the same reason jev ask --encoder cannot: it never imports your code. Build the app yourself instead. build_app(jev) takes a Jev you already made, and everything else about the server is unchanged:
Give it an explicit heads_dir. Without one the server publishes ~/.jev/heads — whatever is in it, including heads trained under a different encoder, which it will then refuse to answer with. The "heads":0 above is an empty directory, not a guarantee. The client side does not change at all, and does not need your encoder module, torch, or any weights. This ran in a venv holding nothing but jevimage and pillow:
batch=True is worth setting on a server and not in a script: it holds concurrent requests for a few milliseconds so they ride one forward pass, and skips the wait entirely while traffic is thin, so a lone caller pays nothing. It only pays off if your encoder is cheaper per image in batches. A neural network is; MeanColour is not. Pass api_keys=[...] to build_app to require a bearer token; see Serving.

If your encoder returns the wrong thing

The engine checks the norm before it computes anything, because a rescaled embedding saturates every softmax to exactly 1.0 and 0.0 — the one number this API must never emit by accident:
The tolerance is 1e-2, so ordinary float error passes and a genuine scaling bug does not. txt() is not norm-checked; an unnormalised text row silently reweights that one option against the others, so normalise both sides. Subclassing jevimage.Encoder gets you a readable __repr__ and logit_scale / logit_bias defaulting to None; duck-typing the four members is equally supported.

A head belongs to the encoder that fitted it

A head is a matrix in one encoder’s embedding space. Run it on another encoder’s embeddings and the answer is confident nonsense whenever the widths happen to match, so the framework checks the encoder name, not just the shape, and refuses:
Four checks run, in this order, and each has its own message: that an encoder is recorded at all, then that it is this one, then the embedding width, then the class count against the weight columns. A head saved with no recorded encoder is refused outright rather than assumed compatible. Provenance that cannot be checked is not provenance. The same rule catches the quieter case: an ensemble is a distinct encoder, and one of its own members is not a substitute for it.
By the same string comparison, b+a is not a+b either, even though the two embedding spaces differ only in column order. Migrating a head to a new encoder means retraining it. There is no conversion. The head file does keep the embeddings it was fitted on, but those are in the old encoder’s space, which is exactly what the new one cannot read, so you need the original labelled images again. In practice:
  1. Keep the old encoder loadable until the new heads exist. Two Jev instances can share one heads directory; only the mismatched head is refused, not the directory.
  2. Retrain under the new encoder — jev train NAME FOLDER --encoder NEW, or jev.train() from a Jev loaded with it. Under a different head name if you want to A/B them.
  3. Read the per-class comparison before you cut over. A bigger encoder is not automatically better on your classes; that is what compare() is for.
  4. jev rm OLD when the new head wins.
Keeping labelled folders around is therefore part of using heads. Treat the head file as a cache of a fit, not as the only copy of your data.

Caching and instance reuse

jevimage.encoders.load(name) caches one instance per name, for the life of the process:
What follows from that:
  • Weights load once. Two jevimage.load("siglip2-base-224") calls in one process share one model. Two processes share nothing but the huggingface disk cache.
  • Ensemble members are the same objects as the standalone entries, so holding "a" and "a+b" costs one copy of a, not two.
  • Nothing is ever evicted. Loading four registry names in one process keeps four models resident. If you are probing encoders in a loop, do it in separate processes.
  • An instance you pass to jevimage.load(MyEncoder()) is not cached at all — it never went through the registry. You own its lifetime.
  • register() drops the cached instance for that name, so re-registering during development takes effect on the next load(). It does not touch instances already handed out, and it does not retroactively fix an ensemble that already holds the old member.
The text side has its own cache, inside the engine rather than the encoder: every caption is embedded once per Jev and reused across questions and requests, bounded at 50,000 strings, oldest evicted first. That is why a fixed question set gets cheaper after the first call, and why ask_embedding() on an embedding you already hold does no image encoding at all.

Timings

Two sets of numbers, from two machines. Both are a shape, not a benchmark, and not a prediction about yours. Measured here, siglip2-base-224 on CPU with 4 torch threads, each question a choice with two fresh captions: That is the whole economics of this library in five rows, and the cold rows are the half that gets skipped. A caption is a forward pass through the text tower the first time the process sees it; after that it is a cached vector and the question is a matmul. So the amortisation claim is about a repeated question set, not about questions in general. Thirty-two fresh captions (16 two-option questions) cost more than nine image encodes; the same sixteen questions asked again cost 0.4% of one. The regime where this design loses is therefore: captions supplied per request by the user, a new question set on every call, or a process that restarts often. None of those ever warm the cache, and each pays the text tower every time. If your questions are fixed and your images are not, you are in the regime the design is for. Measured by the library’s author on other hardware, and kept because nothing else records them. The two question rows are read as captions-cached: 32 captions through a text tower cannot come in under a 13.58 ms image encode. The last two are the encoder-choice claim: a 12.5x spread between two rows of the registry table, on the same CPU. Time the two or three encoders you are choosing between on your own hardware; that is the only ratio that governs your bill.

Failure modes on this page


Next: Question types for what each encoder choice changes about noul, Training a head for beating prompts without swapping models, and Serving for running one for other people. Source: jevimage/encoders.py.
← Training a head · Docs index · Serving →