feat(models): Florence-2 processor, task prompts, and location tokens - #1069
Conversation
Completes the Florence-2 request path on top of the fused model from #854: a task marker and an image go in, and a structured result with coordinates in original-image pixels comes out. Florence-2 selects its behavior with a marker such as `<OD>` or `<CAPTION>`, and none of those markers is in the vocabulary. The processor expands each one into the English sentence the model was actually trained on ("Locate the objects with category name in the image."), tokenizes that, preprocesses the image, generates, and parses the answer's `<loc_N>` tokens back into boxes, quadrilaterals, and polygons. New modules under `src/models/florence2/`: - `tasks.rs`: the fifteen task markers, their prompt expansions copied byte for byte from the checkpoint, and the routing to a post-processing kind. Seven tasks interpolate a caller-supplied input; supplying one to a task that takes none, or omitting one that requires it, is an error rather than upstream's silent gap. - `coords.rs`: `Florence2ImageSize` (named fields, because upstream's docstring says "height x width" while its code reads `(width, height)`), the box and quad and polygon types, and the bin-to-pixel dequantizers. Also the inverse, so a caller can build the `<loc_a><loc_b><loc_c><loc_d>` region string the region-input tasks expect. - `scan.rs`: the literal string scanners (sequence-marker stripping, leading-phrase extraction, `<loc_N>` reading) and the phrase-grounding stopword blacklist. - `parse.rs`: the four reachable parsers, keyed off compiled-once patterns. - `postprocess.rs`: `Florence2TaskResult` and the task dispatch. `Florence2TaskResult::parse` is model-free, so an answer from elsewhere can be parsed without loading a checkpoint. - `processor.rs`: `Florence2Processor::run`, the end-to-end entry point. `src/vision/processors/florence2.rs` reads `preprocessor_config.json` rather than hard-coding it, and rejects a configuration it would silently mishandle (center cropping, a non-bicubic resample, a zero standard deviation). The mean and standard deviation are ImageNet values despite the `CLIPImageProcessor` class name, and `size` carries an explicit height and width, so the resize does not preserve aspect ratio. `Florence2TextConfig` moved from `mod.rs` to `checkpoint.rs`, next to the `Florence2Config` that contains it. Pure relocation, no behavior change; it keeps `mod.rs` under the 500-line limit now that the module list has grown. Validation against the real checkpoint and the real upstream Python, not a reimplementation: - 32 post-processing cases pinned against the checkpoint's own `processing_florence2.py`, imported unmodified. All match, including the edge cases where upstream behaves surprisingly. - Preprocessing on `tests/fixtures/test_image.png` against the checkpoint's `CLIPImageProcessor`: maximum absolute deviation of exactly 0.0 across all 1,769,472 values. - Task prompt tokenization: exact id match on all six tested tasks. - End-to-end generation against the upstream mlx-vlm florence2 model with weights cast bf16 to f16: byte-identical answers on all six tasks, and identical parsed coordinates for `<OD>` and `<OCR_WITH_REGION>`. Refs #850. Closes #855.
…ropping them `location_bins` documented that a `<loc_N>` digit run too wide for `i32` is "accepted here rather than rejected, matching upstream's plain `int()`", but the code did the opposite: `parse::<i32>()` failed and the bin was silently discarded while the scan advanced past the token. Discarding one bin re-pairs every bin after it in the same chunk, so `box_bins`' four-at-a-time grouping and the eight-token OCR record would both misassign coordinates rather than produce one wrong number. `parse_ocr` had the same shape, dropping the whole record on a failed parse where Python keeps it. Both now go through a shared `scan::parse_bin`, which saturates to `i32::MAX`. Python integers are unbounded so no `i32` value can match upstream exactly at that width, but keeping the token counted preserves the grouping, which is the part that affects every other coordinate in the chunk. Unreachable on real output: the checkpoint vocabulary carries only `<loc_0>` .. `<loc_999>`, and the parity suite is unchanged by this. Validated with `cargo clippy --profile test-fast --features metal,accelerate --lib --tests -- -D warnings` (clean), `cargo fmt --check`, `--lib florence2` (132 tests), `--test florence2_postprocess_parity` (6), and `--test florence2_processor_parity` (4, against the real `models/Florence-2-base-ft-bf16` checkpoint including the end-to-end task run). Refs #855
Implementation Review SummaryIntentPort the Florence-2 processor layer for #855: the fifteen task markers and their prompt expansions, 768x768 image preprocessing, tokenizer wiring, and Verification performedI checked the port line by line against the checkpoint's own
Findings addressed
Remaining items (reported, not fixed)
Verification checklist
|
…ic-safety Security review follow-up on the Florence-2 processor layer. `size` in `preprocessor_config.json` is the only field in the image processor that becomes an allocation: `preprocess_with_sizes` sizes its host buffer as `batch * 3 * height * width` f32 before it reads a pixel. It was validated as non-zero but had no upper bound, so a hostile checkpoint could turn it into a multi-gigabyte allocation and an out-of-memory abort, and a value past `usize::MAX / 12` wraps that product in a release build and leaves a short buffer for a write loop that still runs to the configured extent. `MAX_SIZE_EDGE` caps either edge at 8192, mirroring the `MAX_LAYERS` and `MAX_POSITION_EMBEDDINGS` bounds already applied to `text_config` in `src/models/florence2/checkpoint.rs`. Real exports ship 768. The answer parsers read decoder output, which is attacker influenced through the image and the task input string in a served deployment, and the scanners in `scan.rs` walk byte offsets rather than characters. `adversarial_answers_do_not_panic` pins that: multi-byte characters against every boundary a scanner slices at (the leading-whitespace cut, the stop-marker lookahead, the `<loc_` digit run, the bare `loc_N>` prefix strip), malformed location tokens with no digits or no terminator, nested `<loc_` opens that exercise the scanner's advance rule, digit runs far wider than i32, unbalanced polygon and separator markers, and long repetition runs. Every case goes through all four parsers in both `allow_empty_phrase` modes and through `post_process` for all fifteen tasks. No behavior change; the review found no reachable panic or backtracking blowup and this is the regression guard for that. Validated with `cargo test --profile test-fast --features metal,accelerate -p mlxcel --lib florence2` (133 passed), `--test florence2_processor_parity` and `--test florence2_postprocess_parity` against the real checkpoint, plus clippy and fmt. Refs #855
Security and performance reviewThreat model applied: the parsed text is decoder output, which is attacker influenced in a served deployment because the image and the task input string are user supplied, so all parser input was treated as untrusted. Scope was the diff under Fixed (a862e2e)MEDIUM - unbounded allocation from Regression guard - Verified cleanCatastrophic backtracking: not reachable. UTF-8 boundary slicing: safe at every site. No panics in the parse path. No indexing, Complexity of the hand-rolled scanners is linear. Resource use on the hot path. All seven regexes are Reported, not fixed (LOW)
For the follow-up wiring (#856)
Also worth noting for that issue: |
…rgence Florence2TextConfig moved from mod.rs into checkpoint.rs (mod.rs was at the 500-line file cap), but its rustdoc links to encoder::Florence2Encoder::from_weights, decoder::Florence2Decoder::from_weights, and layers::additive_causal_mask still resolved relative to the old mod.rs location and were dead from checkpoint.rs. Repointed all three at super::encoder, super::decoder, and super::layers respectively and confirmed with cargo doc --no-deps --document-private-items that no unresolved-link warning remains for any florence2 file. Documented the RGBA-to-RGB divergence at the resize site in src/vision/processors/florence2.rs: the Rust path resizes in the source color type and then flattens through to_rgb8 unconditionally, while upstream skips convert_to_rgb entirely because Florence-2's preprocessor_config.json ships do_convert_rgb: null, so a non-opaque RGBA source keeps its alpha through resize on the Python side. The comment names the chosen behavior as the deliberate, safer default and cites the upstream file by its GitHub URL. Added preserves_nchw_offsets_for_a_non_square_target to florence2_tests.rs. The existing preprocessing tests either use a square target resolution or check a single axis, so a transposed y/x (or H/W) multiplier in the flat NCHW index b*C*H*W + c*H*W + y*W + x would not surface. The new test uses an 80x40 source and an 8x4 target (source, target, and both target axes all pairwise distinct), marking the column half in the R channel and the row half in the G channel so a swap on either axis flips a distinct assertion. Validation (DEVELOPER_DIR=/Applications/Xcode-26.6.0.app/Contents/Developer, --profile test-fast --features metal,accelerate throughout): - cargo clippy --lib --tests -- -D warnings: clean - cargo fmt --check: clean - cargo test -p mlxcel --lib florence2: 134 passed (133 pre-existing + 1 new) - cargo test -p mlxcel --test florence2_processor_parity: 4 passed - cargo doc --no-deps --document-private-items -p mlxcel: no unresolved-link warnings under src/models/florence2 or src/vision/processors/florence2.rs Refs #855
Summary
Completes the Florence-2 request path on top of the fused model from #854. A task marker and an image go in; a structured result with coordinates in original-image pixels comes out.
Florence-2 selects its behavior with a marker such as
<OD>or<CAPTION>, and none of those markers is in the vocabulary. The processor expands each one into the English sentence the model was actually trained on (<OD>becomes "Locate the objects with category name in the image."), tokenizes that, preprocesses the image to 768x768, generates, and parses the answer's<loc_N>tokens back into boxes, quadrilaterals, and polygons against the image's pre-resize extent.The integration entry point is
Florence2Processor::run(&model, task, input, image, max_new_tokens) -> Florence2Output, which is what #856 will reach from the CLI.What changed
New, under
src/models/florence2/tasks.rs: the fifteen task markers, their prompt expansions copied byte for byte from the checkpoint, and routing to a post-processing kind. Seven tasks interpolate a caller-supplied input.coords.rs:Florence2ImageSize,Florence2BoundingBox,Florence2QuadBox,Florence2Polygon, the bin-to-pixel dequantizers, and the inverse so a caller can build the<loc_a><loc_b><loc_c><loc_d>region string the region-input tasks expect.scan.rs: literal string scanners (sequence-marker stripping, leading-phrase extraction,<loc_N>reading) plus the phrase-grounding stopword blacklist.parse.rs: the four reachable parsers over compiled-once patterns.postprocess.rs:Florence2TaskResultand the task dispatch.Florence2TaskResult::parseis model-free, so an answer obtained elsewhere parses without loading a checkpoint.processor.rs:Florence2Processor, tokenizer plus image processor plus the end-to-endrun.New elsewhere
src/vision/processors/florence2.rs:Florence2ImageProcessor, readingpreprocessor_config.jsonrather than hard-coding it, and refusing configurations it would silently mishandle (center cropping, a non-bicubic resample, a zero standard deviation, a zero size).tests/florence2_postprocess_parity.rs,tests/florence2_processor_parity.rs.Moved
Florence2TextConfigfromsrc/models/florence2/mod.rstosrc/models/florence2/checkpoint.rs, next to theFlorence2Configthat contains it. Pure relocation, no behavior change; it keepsmod.rsunder the 500-line limit now that the module list has grown.Design notes
Florence2BoundingBoxis deliberately not a reuse ofrt_detr_v2::Detection. That type carries a confidence score and alabel: usizeindex into a fixed class list; Florence-2 emits no per-box score and its labels are open-vocabulary strings decoded from the answer, so reusing it would mean two fabricated fields on every box. There is no polygon or quadrilateral type anywhere in the tree to reuse.Florence2ImageSizehas named fields rather than being a(u32, u32)tuple because upstream's own docstring disagrees with its code about the order (see corrections below), and passing the 768x768 resized extent instead of the original silently scales every coordinate.Two upstream regexes were replaced with hand-rolled scanners, each with its equivalence argument recorded in the code. The phrase-string pattern
^\s*(.*?)(?=<od>|...|<loc_)becomes a forward scan for the first stop marker; the two behaviors that make it non-trivial (.not matching a newline, and<sep>not being in the stop list) are reproduced and tested.<loc_(\d+)>becomes a digit scan, since it is a fixed literal on the innermost path of every parser. Everything else usesfancy-regex, which matches Python's backtracking semantics.Corrections to the issue text
rt_detr_v2::Detection, and there is no polygon type. Nothing was reusable.<REGION_PROPOSAL>routes to thebboxespost-processing kind, notdescription_with_bboxes. Same parser, but withallow_empty_phraseset, which matters: its answers carry no category names.Corrections to upstream
Verified against the checkpoint's own
processing_florence2.pyand the mlx-vlm sources.post_process_generation's docstring is wrong about coordinate order. It documentsimage_sizeas "height x width", but every call site unpacks it asimage_width, image_height = image_size. It is(width, height). This is why the Rust type uses named fields.odparse task's configured PATTERN can never match. It isr'([a-zA-Z0-9 ]+)<loc_(\\d+)>...', and the doubled backslash inside a raw string matches a literal\followed byd. It is unreachable in practice: no task routes to'od', and every parser except the OCR one overwrites itspatternargument on its first lines, so only the OCR pattern is ever read from config.area_threshold > 0and the shippedAREA_THRESHOLDis0.00.with_box_at_startis false at every call site reachable frompost_process_generation, so that polygon branch is dead.<ground>/<obj>prefix strip is a no-op twice over. The second assignment readspharse_textrather than the result of the first, discarding the<ground>strip, and neither marker is in the checkpoint's vocabulary.^\s*(.*?)(?=...)is not multi-line and.does not match a newline, so if a newline sits between the leading whitespace and the first location token,re.searchfails and the chunk is dropped. Reproduced deliberately and pinned byod_newline.[^<]+can start one byte inside a<loc_N>token. A bare location run fed to the labelled parser therefore yields the literal labelloc_1>and a box shifted by one bin, rather than no result. Reproduced, pinned byod_bare_locs, and the reason<REGION_PROPOSAL>needs the empty-phrase parser.model.eval(). mlx'snn.Modulestarts in training mode and upstream's DaViTDropPathonly short-circuits onnot self.training. Without it the vision tower output is not reproducible. Recorded in the parity test's module docs so the next person does not lose an hour to it.AutoTokenizer.from_pretrainedfails on this checkpoint, because it routes throughAutoConfig, which does not know the customflorence2_languagemodel type.BartTokenizerFastdirectly works.ModelConfigreadsimage_feature_source,image_pos_embedandvisual_temporal_embeddingfrom the top level ofconfig.json, but real checkpoints store them undervision_config, and the dataclass default forimage_feature_sourceis the reverse order.Deliberately not implemented
Each is unreachable from
post_process_generation, and each is documented inparse.rswith the reason rather than silently omitted:parse_od_from_text_and_spansand the PATTERN config indirection, the OCR area filter, thewith_box_at_startpolygon branch, and the<ground>/<obj>prefix strip.decode_with_spansis also not ported; its character spans exist only for score attribution, which no reachable task uses.One intentional deviation:
Florence2Task::expandrejects an input supplied to a task that takes none, and a missing input on a task that requires one. Upstream produces"What is the region ?"in the second case and lets the model answer nonsense, which is indistinguishable from a real prompt after the fact.One knowingly accepted difference:
$in the polygon-run pattern matches only at end of string, while Python's also matches just before a trailing newline. Location runs are not emitted with trailing newlines.Validation
Against the real checkpoint (
models/Florence-2-base-ft-bf16) and the real upstream Python, in a scratch virtualenv withtorch,transformers,mlx,numpyandPillow. The post-processing reference imports the checkpoint's ownprocessing_florence2.pyunmodified, so it is upstream behavior rather than a reimplementation agreeing with itself.tests/fixtures/test_image.pngthrough the checkpoint'sCLIPImageProcessorversus the Rust processor. Maximum absolute deviation exactly 0.0 across all 1,769,472 values; theimagecrate'sCatmullRomand PIL'sBICUBICagree bit for bit on this 224 to 768 upscale. The test tolerance is kept loose anyway, since that agreement is a property of two independent resampling implementations.<OD>producingposter<loc_0><loc_0><loc_998><loc_998>and<OCR_WITH_REGION>producing a full eight-token quadrilateral, and identical parsed coordinates for both.No unexplained Rust-versus-Python divergence was found anywhere in this work.
Test plan
cargo check --profile test-fast --features metal,accelerate --lib --testscargo clippy --profile test-fast --features metal,accelerate --lib --tests -- -D warnings(zero warnings)cargo test --profile test-fast --features metal,accelerate -p mlxcel --lib florence2cargo test --profile test-fast --features metal,accelerate -p mlxcel --test florence2_postprocess_paritycargo test --profile test-fast --features metal,accelerate -p mlxcel --test florence2_processor_paritycargo test --profile test-fast --features metal,accelerate -p mlxcel --test florence2_fusion_parity(unchanged by theFlorence2TextConfigmove)cargo fmt --checkpython3 scripts/ci/check_cross_repo_refs.pyCloses #855