Guide

Reproducing the evaluation

The exact protocol behind the reported FID, CLIP, SSIM and LPIPS, and what has to match before two runs can be compared at all.

This is the exact procedure behind the FMoW-RGB row in the paper:

FID ↓ CLIP ↑ SSIM ↑ LPIPS ↓
FlowSat, as submitted 31.10 0.3016 0.1600 0.6853
FlowSat, this release 28.74 0.3019 0.1564 0.6574

Read the second row. The released checkpoint and the code in this repository produce FID 28.74, not the 31.10 printed in the paper. Both are given here rather than one.

Measured on 10,000 FMoW test samples at 512 px, 20 Euler steps, text guidance 2.5, seed 42. One script produces all four numbers:

python -m flowsat.evaluation.evaluate_fmow \
          --checkpoint      Djisgod/flowsat-fmow-512 \
          --pretrained_sana Efficient-Large-Model/Sana_600M_512px_diffusers \
          --fmow_test_root  /path/to/fmow-full/test \
          --caption_root    /path/to/fmow_captions_test \
          --output_dir      evaluations/flowsat-125k
      

Every other flag defaults to the published setting, so there is nothing else to match. The script prints its protocol and a hash of it, writes metrics.json, and finishes by printing the measured numbers next to the published ones.

What you need

pip install -e ".[eval,data]"
      

eval brings in torchmetrics, torch-fidelity and scipy (FID, Inception Score, LPIPS, SSIM). data brings in rasterio, which reads FMoW's GeoTIFFs — without it the loader falls back to PIL and the 16-bit files come out nearly black, so install it.

Four inputs:

Checkpoint The released FlowSat weights. A directory holding model_0.pt.
Sana snapshot Efficient-Large-Model/Sana_600M_512px_diffusers — supplies the DC-AE, the Gemma-2 tokenizer and the text encoder. Pass a local path if you are offline.
FMoW test split The RGB test split, one directory per sequence: <seq_id>/<stem>_rgb.tif beside <stem>_rgb.json.
Captions One .txt per image, mirroring that layout under <caption_root>/test/<seq_id>/<stem>.txt. These are the VLM captions described in Captioning a dataset; the numbers above were measured with them.

A 10,000-sample run takes about an hour on one A100, dominated by sampling. Read the test images from local disk rather than network storage — streaming GeoTIFFs over NFS inside the loop starves the data loader and can stall a run outright.

What the four metrics mean

FID and CLIP are the generation-quality metrics. FID compares the distribution of the 10,000 generated images against the distribution of the 10,000 real images they were conditioned on. CLIP score is the cosine similarity between each generated image and its caption, under openai/clip-vit-base-patch16. The script reports it ×100 (30.19); the tables on this page report the fraction (0.3019).

SSIM and LPIPS are paired: each generated image is scored against the one specific real image whose caption and metadata produced it. They measure whether the conditioning recovers that particular acquisition — not image quality. Read them against a floor measured the same way:

SSIM LPIPS
FlowSat vs. its source image 0.156 0.657
Two real images of the same place 0.214 0.425

Two genuine FMoW acquisitions of one location, taken months apart, only reach SSIM 0.214 through this same preprocessing. That is the ceiling, not 1.0. Quoting either number as generation quality overstates what it measures.

Inception Score is also reported. Inception is ImageNet-trained and overhead imagery sits far outside that domain, so treat it as a weak signal. FID is primary.

What makes two runs comparable

FID is a property of a protocol as much as of a model. These fields all move it, several of them by more than the gap between two models, so the script hashes them into protocol_hash and writes the hash into metrics.json. If two runs disagree on that hash, their FID cannot be compared, whatever else is true.

field published value
num_samples 10000
num_steps 20
guidance_scale 2.5
uncond_metadata zero
max_caption_len 256
resolution 512
seed 42
dtype bf16
reference set FMoW test split, first 10,000 captioned samples in (seq_id, stem) order
preprocessing 2–98 percentile stretch, centre crop, bilinear resize to 512 px

Sample count deserves particular care. FID fits a 2048-dimensional covariance; below a few thousand samples that estimate is rank-deficient and inflated. FID between two halves of a single identical pool measures 189 at N = 66. Small-N runs are useful for ranking checkpoints against each other and for nothing else. The script warns when --num_samples is below 10,000.

You can see that dependence directly. --curve_at reads every metric at intermediate sample counts during a single generation pass:

python -m flowsat.evaluation.evaluate_fmow ... \
          --num_samples 10000 --curve_at 5000,6000,7000,8000,9000
      

Each point costs one metric read, not another pass — the metrics accumulate state and reading them does not disturb it, so the whole curve falls out of the run that produces the headline number. The curve lands in metrics.json under curve, and the shape to expect is FID falling as N grows, because the bias is upward at small N. That is worth internalising before comparing against a published FID measured at an unstated N: a lower number is not automatically a better model.

This is the curve the released checkpoint actually produces, every row read from the same generation pass:

N scored FID ↓ CLIP ↑ SSIM ↑ LPIPS ↓
5,007 35.93 0.3021 0.1585 0.6575
6,007 33.64 0.3017 0.1581 0.6579
7,007 31.93 0.3017 0.1574 0.6582
8,007 30.43 0.3019 0.1575 0.6568
9,007 29.42 0.3018 0.1578 0.6572
9,999 28.74 0.3019 0.1564 0.6574

Which text encoder

Gemma-2 can be loaded two ways, and they give measurably different numbers. The choice is a flag, and it is part of the protocol hash:

--text_encoder bf16-causal   # what the paper submission used
      --text_encoder fp32-eager    # the default; matches training
      
bf16-causal fp32-eager
class AutoModelForCausalLM AutoModel
dtype the run dtype (bf16) float32
attention eager eager
read-out hidden_states[-1] last_hidden_state
mask the tokenizer's built from pad ids, all-pad rows guarded

Measured on transformers 4.49, three of those five rows turn out not to matter: AutoModel and AutoModelForCausalLM return bit-identical hidden states (max difference 0.0), hidden_states[-1] is last_hidden_state, and with padding="max_length" the two masks agree except on an all-pad row. The only difference that actually moves the numbers is the dtype.

Both paths request eager attention explicitly, and that is load-bearing. Gemma-2 soft-caps its attention logits, and sdpa + soft-capping in bf16 returns 100% NaN on this model — NaN conditioning decodes to a black image rather than raising, so the metrics would be computed on black frames. Older transformers defaulted Gemma-2 to eager for exactly this reason; 4.49 defaults to sdpa. The original run produced sane metrics, so it cannot have been on sdpa. Leaving the attention implementation to the library is what broke, not the choice of head or dtype.

Measured directly, at 256 tokens on an A100:

load result
AutoModelForCausalLM, bf16, sdpa (the 4.49 default) all NaN
AutoModelForCausalLM, bf16, eager finite, absmax 75.5, std 3.947
AutoModelForCausalLM, fp32, eager finite, absmax 75.7, std 3.944
AutoModel, fp32, eager identical to the row above

fp32-eager is the default because it matches training and avoids half precision in a model that soft-caps. The run aborts on non-finite conditioning either way rather than reporting a number measured on black images.

If you are checking the published row, use bf16-causal. If you are measuring a new model, use the default and say so.

Two things that will silently ruin a run

Black images. Gemma-2 soft-caps its attention logits, and in half precision off the eager attention path that overflows to NaN — NaN conditioning, NaN latents, and images that decode to pure black. Nothing raises; the metrics score the black frames happily. The script therefore loads the text encoder in fp32 with attn_implementation="eager" (matching training), casts the conditioning to the transformer's dtype at the boundary, and aborts if the first batch comes back NaN or all-black. The tell-tale signature, if it ever reappears, is Inception Score exactly 1.0 on a run large enough that it should not be — identical inputs, zero KL — together with FID stuck near 750 regardless of sample count. (A handful of images give IS 1.0 for ordinary reasons; it is only diagnostic at scale.) Look at the images before believing any metric that pairs those two.

Partial checkpoint loads. The backbone is built from the real Sana config, not a hand-written one, because the two differ slightly and strict=False would quietly discard the mismatched tensors and leave randomly initialised weights in place. The script aborts if more than a handful of state-dict keys are missing or unexpected, and specifically if any metadata-pathway key is missing — that one would produce a complete, plausible-looking run describing a metadata-blind model.

Notes on the test split

FMoW's test sidecars carry gsd, cloud_cover and timestamp, but no coordinates — unlike the train sidecars they have neither raw_location nor a bounding_box polygon. Longitude and latitude therefore fall back to (0, 0) for every test sample. This is identical for every model scored through this pipeline, so the comparison is fair, but it does mean the quality table exercises the date, GSD and cloud-cover pathways and not the geographic one. Geographic control is measured separately, by the controllability sweeps on the project page.

Expect 9,999 samples scored out of 10,000 requested: one GeoTIFF in that prefix of the split is unreadable. The script counts and reports dropped samples rather than failing on them — a corrupt file should not end a two-hour run.

The sample list is cached under ~/.cache/flowsat/ because enumerating the split is slow on network storage. Set FLOWSAT_NO_SAMPLE_CACHE=1 to force a rescan after adding or removing captions.

Getting the weights

--checkpoint takes a local directory, a local weight file, or a Hugging Face repo id:

--checkpoint Djisgod/flowsat-fmow-512        # downloads once into the HF cache
      --checkpoint Djisgod/flowsat-fmow-512@v1.0   # a specific revision
      --checkpoint checkpoints/flowsat-fmow-512  # a local directory
      

The checkpoint is 2.3 GiB, which is past every GitHub limit (100 MB per git file, 2 GiB per release asset, 1 GB on the free LFS tier), so the Hub is where it will live; the upload happens with the weights release, and until then only a local path works. tools/publish_weights.py is what puts it there: it verifies the state dict is a complete FlowSat checkpoint before uploading anything, records the sha256, and renders the model card.

Evaluating a different model

flowsat/evaluation/eval_common.py holds everything that decides what a number means: sample discovery and ordering, the image preprocessing path, and the metric definitions. Scoring a baseline through that module makes it comparable to the table above; scoring it any other way does not. If you change anything in that file, every number on this page becomes incomparable, including the published ones.