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 squaresexamples/make_fixtures.pywrites. The stand-ins areMeanColourandGrey, both written out in full under Writing your own encoder (Greyexists only so the ensemble examples have a second member), andToy, a deterministic 16-dimensional hash encoder of the kind the package’s own self-tests use (demo()injevimage/server.py);ToyCalibratedis that same toy withlogit_scaleandlogit_biasfilled 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 top=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:
$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:
.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.
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:
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: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:+, 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:
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:
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:
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:
+, which is reserved for ensembles:
.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.
.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.
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:
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 exactly1.0 and 0.0 — the one number this API must never
emit by accident:
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: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:
- Keep the old encoder loadable until the new heads exist. Two
Jevinstances can share one heads directory; only the mismatched head is refused, not the directory. - Retrain under the new encoder —
jev train NAME FOLDER --encoder NEW, orjev.train()from aJevloaded with it. Under a different head name if you want to A/B them. - 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. jev rm OLDwhen the new head wins.
Caching and instance reuse
jevimage.encoders.load(name) caches one instance per name, for the life of the process:
- 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 ofa, 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 nextload(). It does not touch instances already handed out, and it does not retroactively fix an ensemble that already holds the old member.
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 →