logit-classifier 0.2.1__tar.gz → 0.4.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {logit_classifier-0.2.1 → logit_classifier-0.4.0}/FINDINGS.md +26 -1
- {logit_classifier-0.2.1 → logit_classifier-0.4.0}/PKG-INFO +90 -6
- {logit_classifier-0.2.1 → logit_classifier-0.4.0}/README.md +88 -4
- {logit_classifier-0.2.1 → logit_classifier-0.4.0}/pyproject.toml +3 -3
- {logit_classifier-0.2.1 → logit_classifier-0.4.0}/src/logit_classifier/_version.py +1 -1
- {logit_classifier-0.2.1 → logit_classifier-0.4.0}/src/logit_classifier/backends/comfy_clip.py +25 -1
- {logit_classifier-0.2.1 → logit_classifier-0.4.0}/src/logit_classifier/classifier.py +25 -1
- {logit_classifier-0.2.1 → logit_classifier-0.4.0}/src/logit_classifier/prompt.py +3 -3
- logit_classifier-0.4.0/src/logit_classifier/tags.py +36 -0
- logit_classifier-0.4.0/src/logit_classifier/toolkit/__init__.py +5 -0
- logit_classifier-0.4.0/src/logit_classifier/toolkit/comfyui/__init__.py +50 -0
- logit_classifier-0.4.0/src/logit_classifier/toolkit/comfyui/classifier.py +39 -0
- logit_classifier-0.4.0/src/logit_classifier/toolkit/comfyui/generate.py +146 -0
- logit_classifier-0.4.0/src/logit_classifier/toolkit/comfyui/progress.py +86 -0
- logit_classifier-0.4.0/src/logit_classifier/toolkit/comfyui/scopes.py +77 -0
- logit_classifier-0.4.0/src/logit_classifier/toolkit/comfyui/tagger.py +395 -0
- logit_classifier-0.4.0/src/logit_classifier/toolkit/tags.py +389 -0
- logit_classifier-0.4.0/tests/test_toolkit.py +1477 -0
- {logit_classifier-0.2.1 → logit_classifier-0.4.0}/tests/test_unit.py +294 -218
- logit_classifier-0.4.0/tests-AB/comfy_env.py +167 -0
- logit_classifier-0.2.1/src/logit_classifier/tags.py +0 -171
- {logit_classifier-0.2.1 → logit_classifier-0.4.0}/.gitignore +0 -0
- {logit_classifier-0.2.1 → logit_classifier-0.4.0}/LICENSE +0 -0
- {logit_classifier-0.2.1 → logit_classifier-0.4.0}/examples/compare_models.py +0 -0
- {logit_classifier-0.2.1 → logit_classifier-0.4.0}/examples/http_client.py +0 -0
- {logit_classifier-0.2.1 → logit_classifier-0.4.0}/examples/images.py +0 -0
- {logit_classifier-0.2.1 → logit_classifier-0.4.0}/examples/own_backend.py +0 -0
- {logit_classifier-0.2.1 → logit_classifier-0.4.0}/examples/question_types.py +0 -0
- {logit_classifier-0.2.1 → logit_classifier-0.4.0}/examples/quickstart.py +0 -0
- {logit_classifier-0.2.1 → logit_classifier-0.4.0}/src/logit_classifier/__init__.py +0 -0
- {logit_classifier-0.2.1 → logit_classifier-0.4.0}/src/logit_classifier/__main__.py +0 -0
- {logit_classifier-0.2.1 → logit_classifier-0.4.0}/src/logit_classifier/backends/__init__.py +0 -0
- {logit_classifier-0.2.1 → logit_classifier-0.4.0}/src/logit_classifier/backends/_torch_window.py +0 -0
- {logit_classifier-0.2.1 → logit_classifier-0.4.0}/src/logit_classifier/backends/base.py +0 -0
- {logit_classifier-0.2.1 → logit_classifier-0.4.0}/src/logit_classifier/backends/hf.py +0 -0
- {logit_classifier-0.2.1 → logit_classifier-0.4.0}/src/logit_classifier/calibrate.py +0 -0
- {logit_classifier-0.2.1 → logit_classifier-0.4.0}/src/logit_classifier/cli.py +0 -0
- {logit_classifier-0.2.1 → logit_classifier-0.4.0}/src/logit_classifier/config.py +0 -0
- {logit_classifier-0.2.1 → logit_classifier-0.4.0}/src/logit_classifier/deps.py +0 -0
- {logit_classifier-0.2.1 → logit_classifier-0.4.0}/src/logit_classifier/errors.py +0 -0
- {logit_classifier-0.2.1 → logit_classifier-0.4.0}/src/logit_classifier/labels.py +0 -0
- {logit_classifier-0.2.1 → logit_classifier-0.4.0}/src/logit_classifier/py.typed +0 -0
- {logit_classifier-0.2.1 → logit_classifier-0.4.0}/src/logit_classifier/schema.py +0 -0
- {logit_classifier-0.2.1 → logit_classifier-0.4.0}/src/logit_classifier/scoring.py +0 -0
- {logit_classifier-0.2.1 → logit_classifier-0.4.0}/src/logit_classifier/service.py +0 -0
- {logit_classifier-0.2.1 → logit_classifier-0.4.0}/src/logit_classifier/vision.py +0 -0
- {logit_classifier-0.2.1 → logit_classifier-0.4.0}/src/logit_classifier/web/index.html +0 -0
- {logit_classifier-0.2.1 → logit_classifier-0.4.0}/tests/fixtures/banking77_test.json +0 -0
- {logit_classifier-0.2.1 → logit_classifier-0.4.0}/tests/fixtures/eval_set.json +0 -0
- {logit_classifier-0.2.1 → logit_classifier-0.4.0}/tests/fixtures/many_options_request.json +0 -0
- {logit_classifier-0.2.1 → logit_classifier-0.4.0}/tests/fixtures/quickstart_request.json +0 -0
- {logit_classifier-0.2.1 → logit_classifier-0.4.0}/tests/test_model.py +0 -0
- {logit_classifier-0.2.1 → logit_classifier-0.4.0}/tests-AB/ab_branch_packing.py +0 -0
- {logit_classifier-0.2.1 → logit_classifier-0.4.0}/tests-AB/ab_determinism_scope.py +0 -0
- {logit_classifier-0.2.1 → logit_classifier-0.4.0}/tests-AB/ab_env.py +0 -0
- {logit_classifier-0.2.1 → logit_classifier-0.4.0}/tests-AB/ab_math_sdp_reduction.py +0 -0
- {logit_classifier-0.2.1 → logit_classifier-0.4.0}/tests-AB/ab_multi_label.py +0 -0
- {logit_classifier-0.2.1 → logit_classifier-0.4.0}/tests-AB/ab_noul_wording.py +0 -0
- {logit_classifier-0.2.1 → logit_classifier-0.4.0}/tests-AB/ab_temperature.py +0 -0
- {logit_classifier-0.2.1 → logit_classifier-0.4.0}/tests-AB/banking77.py +0 -0
- {logit_classifier-0.2.1 → logit_classifier-0.4.0}/tests-AB/benchmark.py +0 -0
- {logit_classifier-0.2.1 → logit_classifier-0.4.0}/tests-AB/evaluate.py +0 -0
- {logit_classifier-0.2.1 → logit_classifier-0.4.0}/tests-AB/inputs/_full.png +0 -0
- {logit_classifier-0.2.1 → logit_classifier-0.4.0}/tests-AB/inputs/_sheet.png +0 -0
- {logit_classifier-0.2.1 → logit_classifier-0.4.0}/tests-AB/inputs/low-L.png +0 -0
- {logit_classifier-0.2.1 → logit_classifier-0.4.0}/tests-AB/inputs/low-R.png +0 -0
- {logit_classifier-0.2.1 → logit_classifier-0.4.0}/tests-AB/inputs/mid-L.png +0 -0
- {logit_classifier-0.2.1 → logit_classifier-0.4.0}/tests-AB/inputs/mid-R.png +0 -0
- {logit_classifier-0.2.1 → logit_classifier-0.4.0}/tests-AB/inputs/top-L.png +0 -0
- {logit_classifier-0.2.1 → logit_classifier-0.4.0}/tests-AB/inputs/top-R.png +0 -0
- {logit_classifier-0.2.1 → logit_classifier-0.4.0}/tests-AB/tune_groups.py +0 -0
|
@@ -295,6 +295,31 @@ dispatcher reaches the math backend, which builds the full attention matrix. Mea
|
|
|
295
295
|
`HFBackend` probes the backends at startup, at the model's own dtype, and pins the working
|
|
296
296
|
ones. `benchmark.py`.
|
|
297
297
|
|
|
298
|
+
## One presence wording, two threshold pairs
|
|
299
|
+
|
|
300
|
+
Correct tags per tile at a pooled precision, over 905 judged tile candidates on 23 tiles,
|
|
301
|
+
from the Logit Tagger's `tests-AB/ab_presence_wording.py` (round 4).
|
|
302
|
+
|
|
303
|
+
| Wording | P 0.95 | P 0.97 | P 0.98 | P 0.99 |
|
|
304
|
+
|---|---|---|---|---|
|
|
305
|
+
| "This image visibly contains {TAG}" | 17.43 | 12.78 | 9.87 | 6.04 |
|
|
306
|
+
| "Is there a {TAG} in this image?" (0.3.0, article by rule) | 17.35 | 11.30 | 2.39 | 2.09 |
|
|
307
|
+
| "Present in this image: {TAG}" | 17.61 | 8.74 | 8.13 | 6.74 |
|
|
308
|
+
|
|
309
|
+
The shipped fp8 tile pass cut offline, per tile and prompt over 69 runs, from
|
|
310
|
+
ContextAnchoredTileRefine's `tests-AB/ab_tags_paired.py`.
|
|
311
|
+
|
|
312
|
+
| Thresholds | Correct | Wrong or vague | Precision |
|
|
313
|
+
|---|---|---|---|
|
|
314
|
+
| broad (0.999, 0.9999) | 12.32 | 0.54 | 0.958 |
|
|
315
|
+
| strict (0.99998, 0.99998) | 7.33 | 0.17 | 0.977 |
|
|
316
|
+
|
|
317
|
+
Every paired interval against "This image visibly contains {TAG}" at 0.95 and 0.98 spans zero.
|
|
318
|
+
It needs no grammar, and the tile pass already asked it, so its thresholds carry over. On
|
|
319
|
+
declared COCO tag lists "Is there a {TAG} in this image?" (0.3.0, article by rule) kept more
|
|
320
|
+
correct tags at precision 0.98 (3.79 against 1.90 per image), which is the cost of one
|
|
321
|
+
wording.
|
|
322
|
+
|
|
298
323
|
## Bugs found and fixed
|
|
299
324
|
|
|
300
325
|
| Bug | Effect | Fix |
|
|
@@ -329,6 +354,6 @@ and 23.66 GB against 15.12 GB, because a cropped cache is a non-contiguous view.
|
|
|
329
354
|
|
|
330
355
|
## Layout
|
|
331
356
|
|
|
332
|
-
`tests/` holds the pytest suite,
|
|
357
|
+
`tests/` holds the pytest suite, the unit tests and 14 tests that need the GPU. `tests-AB/` holds
|
|
333
358
|
the measurement harnesses, matching ComfyUI-ContextAnchoredTileRefine. Fixtures live in
|
|
334
359
|
`tests/fixtures/` and `tests-AB/ab_env.py` points at them.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: logit-classifier
|
|
3
|
-
Version: 0.
|
|
4
|
-
Summary: Local zero-shot classifier for text and images. Declares options
|
|
3
|
+
Version: 0.4.0
|
|
4
|
+
Summary: Local zero-shot classifier for text and images. Declares options and reads a calibrated probability for each from the logits. Accepts the TypeSafe System One request format, and ships a toolkit of image tag tools and ComfyUI node tools.
|
|
5
5
|
Project-URL: Repository, https://github.com/Blakeem/logit-classifier
|
|
6
6
|
Project-URL: Bug Tracker, https://github.com/Blakeem/logit-classifier/issues
|
|
7
7
|
Author: Blake
|
|
@@ -124,6 +124,14 @@ print(response.to_dict())
|
|
|
124
124
|
`load_model` loads the weights through the `hf` extra and returns a `Backend`.
|
|
125
125
|
`Classifier(Config())` calls it for you when you pass no backend.
|
|
126
126
|
|
|
127
|
+
`classifier.nouls` asks a list of statements about one state and returns one probability
|
|
128
|
+
per statement. It sends more than 256 statements as several requests.
|
|
129
|
+
|
|
130
|
+
```python
|
|
131
|
+
probabilities = classifier.nouls(["The message is urgent", "The message is polite"],
|
|
132
|
+
state="the payment failed again")
|
|
133
|
+
```
|
|
134
|
+
|
|
127
135
|
Loading each model yourself is what lets one script compare several. The fitted
|
|
128
136
|
temperature follows the backend, so every model is scored with its own value.
|
|
129
137
|
|
|
@@ -170,7 +178,7 @@ the model.
|
|
|
170
178
|
|
|
171
179
|
```json
|
|
172
180
|
{
|
|
173
|
-
"model": "logit-classifier-0.
|
|
181
|
+
"model": "logit-classifier-0.4.0",
|
|
174
182
|
"answers": {
|
|
175
183
|
"department": {
|
|
176
184
|
"type": "choice",
|
|
@@ -342,6 +350,82 @@ shape `[1, H, W, 3]`. An empty state asks about the image alone. Questions share
|
|
|
342
350
|
pass of up to 4,096 tokens, and the image counts toward that limit. Any text encoder other
|
|
343
351
|
than Qwen3-VL raises `UnsupportedModelError`.
|
|
344
352
|
|
|
353
|
+
## Toolkit
|
|
354
|
+
|
|
355
|
+
`logit_classifier.toolkit` holds tools built on the classifier with the settings our
|
|
356
|
+
measurements found best. A project imports them to get good results without working out the
|
|
357
|
+
wording, thresholds and host details itself. The toolkit uses the classifier's public API,
|
|
358
|
+
and the classifier never imports the toolkit.
|
|
359
|
+
|
|
360
|
+
| Package | Runs in | Holds |
|
|
361
|
+
|---|---|---|
|
|
362
|
+
| `logit_classifier.toolkit.tags` | any Python process | text tools for image tags and prompts |
|
|
363
|
+
| `logit_classifier.toolkit.comfyui` | a ComfyUI node | a classifier over the workflow's CLIP, text generation, a progress display and an image tagger |
|
|
364
|
+
|
|
365
|
+
### Tag Text Tools
|
|
366
|
+
|
|
367
|
+
| Tool | Does |
|
|
368
|
+
|---|---|
|
|
369
|
+
| `parse_candidates` | splits a model's tag reply into clean tags with no duplicates |
|
|
370
|
+
| `split_prompt` | splits an image prompt into its fragments |
|
|
371
|
+
| `drop_subsets` | drops a tag whose words all appear in a longer tag |
|
|
372
|
+
| `prompt_word_forms`, `in_prompt` | test whether a tag's words appear in a prompt, singular or plural |
|
|
373
|
+
| `merge_candidates` | merges proposed tags with a prompt's tags under a cap |
|
|
374
|
+
| `presence_statement` | writes "This image visibly contains car" with the tag as given |
|
|
375
|
+
| `BROAD_THRESHOLDS`, `STRICT_THRESHOLDS` | the scores a tag must reach, broad for more tags and strict for fewer wrong ones |
|
|
376
|
+
|
|
377
|
+
```python
|
|
378
|
+
from logit_classifier.toolkit.tags import drop_subsets, parse_candidates
|
|
379
|
+
|
|
380
|
+
tags = drop_subsets(parse_candidates("red car, car, wooden bench, bench"))
|
|
381
|
+
# ['red car', 'wooden bench']
|
|
382
|
+
```
|
|
383
|
+
|
|
384
|
+
`drop_subsets(tags, rule="head-noun")` drops a tag only into a longer tag with the same head
|
|
385
|
+
noun. The head noun is the last word before "of", "in", "on", "with" or "reading". So "cat"
|
|
386
|
+
stays beside "cat ear", and "signage" is dropped beside "signage in chinese".
|
|
387
|
+
|
|
388
|
+
### ComfyUI Tools
|
|
389
|
+
|
|
390
|
+
These run inside a ComfyUI node over the CLIP the workflow loaded. Each one imports comfy and
|
|
391
|
+
torch only when it is called. `logit_classifier.toolkit.comfyui` exports every tool. The table
|
|
392
|
+
names the module of each tool it lists.
|
|
393
|
+
|
|
394
|
+
| Module | Tool | Does |
|
|
395
|
+
|---|---|---|
|
|
396
|
+
| `classifier` | `comfy_classifier` | builds a classifier over the CLIP, with errors that name the node and the Load CLIP fix |
|
|
397
|
+
| `generate` | `generate_text` | generates one greedy reply with the Qwen chat template, and stops early on a condition |
|
|
398
|
+
| `generate` | `clip_generate` | runs core's generate, then clears the CUDA graphs it captured |
|
|
399
|
+
| `scopes` | `shared_vision_encode` | runs the vision tower once per image for every generate and classify inside the block |
|
|
400
|
+
| `scopes` | `skip_resident_loads` | skips core's model load while the CLIP is already on the GPU |
|
|
401
|
+
| `progress` | `routed_progress_bars`, `send_status`, `run_outcome` | show one progress bar per run and a status line under the node |
|
|
402
|
+
| `tagger` | `fit_image` | downscales an image to a pixel cap |
|
|
403
|
+
| `tagger` | `tag_prompt` | lists the physical things a prompt names |
|
|
404
|
+
| `tagger` | `tag_image` | tags an image, with a prompt's things added as candidates |
|
|
405
|
+
| `tagger` | `TagSettings` | holds every wording, budget, cap and threshold of the tagger |
|
|
406
|
+
|
|
407
|
+
The tagger generates candidate tags over the CLIP and verifies each one with the classifier.
|
|
408
|
+
|
|
409
|
+
```python
|
|
410
|
+
from logit_classifier.toolkit.comfyui import TagSettings, comfy_classifier, fit_image, tag_image
|
|
411
|
+
|
|
412
|
+
classifier = comfy_classifier(clip, node="My Tagger")
|
|
413
|
+
trace = tag_image(clip, classifier, fit_image(image, 1024 * 1024), settings=TagSettings(), thing_cache={})
|
|
414
|
+
tags = trace.kept
|
|
415
|
+
```
|
|
416
|
+
|
|
417
|
+
`thing_cache` holds the thing check's answers across a run, so each tag is asked once. The
|
|
418
|
+
`TagSettings` defaults are the current measured values, and a later release may change them.
|
|
419
|
+
Pass every value your output depends on to keep it fixed.
|
|
420
|
+
|
|
421
|
+
For fewer wrong tags, pass `STRICT_THRESHOLDS` as `verify_thresholds`.
|
|
422
|
+
|
|
423
|
+
```python
|
|
424
|
+
from logit_classifier.toolkit.tags import STRICT_THRESHOLDS
|
|
425
|
+
|
|
426
|
+
settings = TagSettings(verify_thresholds=STRICT_THRESHOLDS)
|
|
427
|
+
```
|
|
428
|
+
|
|
345
429
|
## Differences From Jev
|
|
346
430
|
|
|
347
431
|
This project matches the System One request and response format and the documented limits.
|
|
@@ -360,13 +444,13 @@ Jev publishes status codes but no error body. The error shape here is our own.
|
|
|
360
444
|
|
|
361
445
|
```
|
|
362
446
|
uv sync --all-extras
|
|
363
|
-
uv run pytest tests
|
|
364
|
-
uv run pytest tests/test_model.py -m model
|
|
447
|
+
uv run python -m pytest tests -m "not model"
|
|
448
|
+
uv run python -m pytest tests/test_model.py -m model
|
|
365
449
|
uv run ruff check .
|
|
366
450
|
uv run mypy
|
|
367
451
|
```
|
|
368
452
|
|
|
369
|
-
The unit tests need no weights. The model tests load the GPU and take
|
|
453
|
+
The unit tests need no weights. The model tests load the GPU and take 24 to 126 seconds, the longer run being the first of a session.
|
|
370
454
|
|
|
371
455
|
`FINDINGS.md` is the measurement record. Every number there names the script in
|
|
372
456
|
`tests-AB/` that produced it.
|
|
@@ -91,6 +91,14 @@ print(response.to_dict())
|
|
|
91
91
|
`load_model` loads the weights through the `hf` extra and returns a `Backend`.
|
|
92
92
|
`Classifier(Config())` calls it for you when you pass no backend.
|
|
93
93
|
|
|
94
|
+
`classifier.nouls` asks a list of statements about one state and returns one probability
|
|
95
|
+
per statement. It sends more than 256 statements as several requests.
|
|
96
|
+
|
|
97
|
+
```python
|
|
98
|
+
probabilities = classifier.nouls(["The message is urgent", "The message is polite"],
|
|
99
|
+
state="the payment failed again")
|
|
100
|
+
```
|
|
101
|
+
|
|
94
102
|
Loading each model yourself is what lets one script compare several. The fitted
|
|
95
103
|
temperature follows the backend, so every model is scored with its own value.
|
|
96
104
|
|
|
@@ -137,7 +145,7 @@ the model.
|
|
|
137
145
|
|
|
138
146
|
```json
|
|
139
147
|
{
|
|
140
|
-
"model": "logit-classifier-0.
|
|
148
|
+
"model": "logit-classifier-0.4.0",
|
|
141
149
|
"answers": {
|
|
142
150
|
"department": {
|
|
143
151
|
"type": "choice",
|
|
@@ -309,6 +317,82 @@ shape `[1, H, W, 3]`. An empty state asks about the image alone. Questions share
|
|
|
309
317
|
pass of up to 4,096 tokens, and the image counts toward that limit. Any text encoder other
|
|
310
318
|
than Qwen3-VL raises `UnsupportedModelError`.
|
|
311
319
|
|
|
320
|
+
## Toolkit
|
|
321
|
+
|
|
322
|
+
`logit_classifier.toolkit` holds tools built on the classifier with the settings our
|
|
323
|
+
measurements found best. A project imports them to get good results without working out the
|
|
324
|
+
wording, thresholds and host details itself. The toolkit uses the classifier's public API,
|
|
325
|
+
and the classifier never imports the toolkit.
|
|
326
|
+
|
|
327
|
+
| Package | Runs in | Holds |
|
|
328
|
+
|---|---|---|
|
|
329
|
+
| `logit_classifier.toolkit.tags` | any Python process | text tools for image tags and prompts |
|
|
330
|
+
| `logit_classifier.toolkit.comfyui` | a ComfyUI node | a classifier over the workflow's CLIP, text generation, a progress display and an image tagger |
|
|
331
|
+
|
|
332
|
+
### Tag Text Tools
|
|
333
|
+
|
|
334
|
+
| Tool | Does |
|
|
335
|
+
|---|---|
|
|
336
|
+
| `parse_candidates` | splits a model's tag reply into clean tags with no duplicates |
|
|
337
|
+
| `split_prompt` | splits an image prompt into its fragments |
|
|
338
|
+
| `drop_subsets` | drops a tag whose words all appear in a longer tag |
|
|
339
|
+
| `prompt_word_forms`, `in_prompt` | test whether a tag's words appear in a prompt, singular or plural |
|
|
340
|
+
| `merge_candidates` | merges proposed tags with a prompt's tags under a cap |
|
|
341
|
+
| `presence_statement` | writes "This image visibly contains car" with the tag as given |
|
|
342
|
+
| `BROAD_THRESHOLDS`, `STRICT_THRESHOLDS` | the scores a tag must reach, broad for more tags and strict for fewer wrong ones |
|
|
343
|
+
|
|
344
|
+
```python
|
|
345
|
+
from logit_classifier.toolkit.tags import drop_subsets, parse_candidates
|
|
346
|
+
|
|
347
|
+
tags = drop_subsets(parse_candidates("red car, car, wooden bench, bench"))
|
|
348
|
+
# ['red car', 'wooden bench']
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
`drop_subsets(tags, rule="head-noun")` drops a tag only into a longer tag with the same head
|
|
352
|
+
noun. The head noun is the last word before "of", "in", "on", "with" or "reading". So "cat"
|
|
353
|
+
stays beside "cat ear", and "signage" is dropped beside "signage in chinese".
|
|
354
|
+
|
|
355
|
+
### ComfyUI Tools
|
|
356
|
+
|
|
357
|
+
These run inside a ComfyUI node over the CLIP the workflow loaded. Each one imports comfy and
|
|
358
|
+
torch only when it is called. `logit_classifier.toolkit.comfyui` exports every tool. The table
|
|
359
|
+
names the module of each tool it lists.
|
|
360
|
+
|
|
361
|
+
| Module | Tool | Does |
|
|
362
|
+
|---|---|---|
|
|
363
|
+
| `classifier` | `comfy_classifier` | builds a classifier over the CLIP, with errors that name the node and the Load CLIP fix |
|
|
364
|
+
| `generate` | `generate_text` | generates one greedy reply with the Qwen chat template, and stops early on a condition |
|
|
365
|
+
| `generate` | `clip_generate` | runs core's generate, then clears the CUDA graphs it captured |
|
|
366
|
+
| `scopes` | `shared_vision_encode` | runs the vision tower once per image for every generate and classify inside the block |
|
|
367
|
+
| `scopes` | `skip_resident_loads` | skips core's model load while the CLIP is already on the GPU |
|
|
368
|
+
| `progress` | `routed_progress_bars`, `send_status`, `run_outcome` | show one progress bar per run and a status line under the node |
|
|
369
|
+
| `tagger` | `fit_image` | downscales an image to a pixel cap |
|
|
370
|
+
| `tagger` | `tag_prompt` | lists the physical things a prompt names |
|
|
371
|
+
| `tagger` | `tag_image` | tags an image, with a prompt's things added as candidates |
|
|
372
|
+
| `tagger` | `TagSettings` | holds every wording, budget, cap and threshold of the tagger |
|
|
373
|
+
|
|
374
|
+
The tagger generates candidate tags over the CLIP and verifies each one with the classifier.
|
|
375
|
+
|
|
376
|
+
```python
|
|
377
|
+
from logit_classifier.toolkit.comfyui import TagSettings, comfy_classifier, fit_image, tag_image
|
|
378
|
+
|
|
379
|
+
classifier = comfy_classifier(clip, node="My Tagger")
|
|
380
|
+
trace = tag_image(clip, classifier, fit_image(image, 1024 * 1024), settings=TagSettings(), thing_cache={})
|
|
381
|
+
tags = trace.kept
|
|
382
|
+
```
|
|
383
|
+
|
|
384
|
+
`thing_cache` holds the thing check's answers across a run, so each tag is asked once. The
|
|
385
|
+
`TagSettings` defaults are the current measured values, and a later release may change them.
|
|
386
|
+
Pass every value your output depends on to keep it fixed.
|
|
387
|
+
|
|
388
|
+
For fewer wrong tags, pass `STRICT_THRESHOLDS` as `verify_thresholds`.
|
|
389
|
+
|
|
390
|
+
```python
|
|
391
|
+
from logit_classifier.toolkit.tags import STRICT_THRESHOLDS
|
|
392
|
+
|
|
393
|
+
settings = TagSettings(verify_thresholds=STRICT_THRESHOLDS)
|
|
394
|
+
```
|
|
395
|
+
|
|
312
396
|
## Differences From Jev
|
|
313
397
|
|
|
314
398
|
This project matches the System One request and response format and the documented limits.
|
|
@@ -327,13 +411,13 @@ Jev publishes status codes but no error body. The error shape here is our own.
|
|
|
327
411
|
|
|
328
412
|
```
|
|
329
413
|
uv sync --all-extras
|
|
330
|
-
uv run pytest tests
|
|
331
|
-
uv run pytest tests/test_model.py -m model
|
|
414
|
+
uv run python -m pytest tests -m "not model"
|
|
415
|
+
uv run python -m pytest tests/test_model.py -m model
|
|
332
416
|
uv run ruff check .
|
|
333
417
|
uv run mypy
|
|
334
418
|
```
|
|
335
419
|
|
|
336
|
-
The unit tests need no weights. The model tests load the GPU and take
|
|
420
|
+
The unit tests need no weights. The model tests load the GPU and take 24 to 126 seconds, the longer run being the first of a session.
|
|
337
421
|
|
|
338
422
|
`FINDINGS.md` is the measurement record. Every number there names the script in
|
|
339
423
|
`tests-AB/` that produced it.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "logit-classifier"
|
|
3
3
|
dynamic = ["version"]
|
|
4
|
-
description = "Local zero-shot classifier for text and images. Declares options
|
|
4
|
+
description = "Local zero-shot classifier for text and images. Declares options and reads a calibrated probability for each from the logits. Accepts the TypeSafe System One request format, and ships a toolkit of image tag tools and ComfyUI node tools."
|
|
5
5
|
requires-python = ">=3.12"
|
|
6
6
|
license = "GPL-3.0-or-later"
|
|
7
7
|
license-files = ["LICENSE"]
|
|
@@ -136,8 +136,8 @@ show_error_codes = true
|
|
|
136
136
|
[[tool.mypy.overrides]]
|
|
137
137
|
# transformers publishes no complete stubs, and accelerate none at all. torch is
|
|
138
138
|
# listed so the type gate still runs where the hf extra is absent, such as CI.
|
|
139
|
-
# The ComfyUI host supplies comfy, and neither this venv nor CI has
|
|
140
|
-
module = ["transformers.*", "accelerate.*", "torch.*", "comfy.*"]
|
|
139
|
+
# The ComfyUI host supplies comfy, server and comfy_execution, and neither this venv nor CI has them.
|
|
140
|
+
module = ["transformers.*", "accelerate.*", "torch.*", "comfy.*", "server", "server.*", "comfy_execution.*"]
|
|
141
141
|
ignore_missing_imports = true
|
|
142
142
|
|
|
143
143
|
[[tool.mypy.overrides]]
|
{logit_classifier-0.2.1 → logit_classifier-0.4.0}/src/logit_classifier/backends/comfy_clip.py
RENAMED
|
@@ -101,6 +101,29 @@ def _check_image(image: Any) -> None:
|
|
|
101
101
|
raise ImageError(f"a ComfyUI IMAGE of shape [1, H, W, 3] is required, got shape {shape}")
|
|
102
102
|
|
|
103
103
|
|
|
104
|
+
def _resident(clip: Any) -> bool:
|
|
105
|
+
"""Whether the CLIP sits where its own load_model call leaves it, so loading again is a no-op.
|
|
106
|
+
|
|
107
|
+
Core's load_models_gpu has no fast path for a loaded model and costs about 0.1 s per call.
|
|
108
|
+
"""
|
|
109
|
+
try:
|
|
110
|
+
import comfy.model_management as model_management
|
|
111
|
+
|
|
112
|
+
patcher = clip.patcher
|
|
113
|
+
loaded = model_management.current_loaded_models
|
|
114
|
+
# Another model's load always inserts at the head, and a changed LoRA patch set or an
|
|
115
|
+
# offloaded CLIP must reload.
|
|
116
|
+
return bool(
|
|
117
|
+
loaded
|
|
118
|
+
and loaded[0].model is patcher
|
|
119
|
+
and patcher.model.device == patcher.load_device
|
|
120
|
+
and patcher.model.current_weight_patches_uuid == patcher.patches_uuid
|
|
121
|
+
and patcher.model.model_loaded_weight_memory > 0
|
|
122
|
+
)
|
|
123
|
+
except (ImportError, AttributeError):
|
|
124
|
+
return False
|
|
125
|
+
|
|
126
|
+
|
|
104
127
|
def _pack_passes(prefix_length: int, suffix_lengths: list[int], batch_branches: bool) -> list[list[int]]:
|
|
105
128
|
"""Group suffixes in request order into passes that stay under PACKED_TOKEN_CEILING.
|
|
106
129
|
|
|
@@ -284,7 +307,8 @@ class ComfyClipBackend:
|
|
|
284
307
|
prefix_tokens[vision["pad_index"]] = {"type": "image", "data": vision["image"], "original_type": "image"}
|
|
285
308
|
with _determinism(), torch.inference_mode():
|
|
286
309
|
encoder.reset_clip_options()
|
|
287
|
-
clip
|
|
310
|
+
if not _resident(clip):
|
|
311
|
+
clip.load_model({self._tokens_key: [prefix_tokens]})
|
|
288
312
|
device = clip.patcher.load_device
|
|
289
313
|
encoder.set_clip_options({"layer": None, "execution_device": device})
|
|
290
314
|
# BaseGenerate.generate picks its execution dtype this way, at llama.py:1115-1120.
|
|
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import hashlib
|
|
6
6
|
import random
|
|
7
|
+
from collections.abc import Sequence
|
|
7
8
|
from dataclasses import dataclass, field, replace
|
|
8
9
|
from typing import Any
|
|
9
10
|
|
|
@@ -11,7 +12,7 @@ import numpy as np
|
|
|
11
12
|
|
|
12
13
|
from .backends.base import Backend, BackendContractError, BranchLogits
|
|
13
14
|
from .calibrate import PriorStore
|
|
14
|
-
from .config import ANSWER_PREFILL, Config, fitted_temperature
|
|
15
|
+
from .config import ANSWER_PREFILL, JEV_MAX_QUESTIONS, Config, fitted_temperature
|
|
15
16
|
from .deps import MissingDependencyError
|
|
16
17
|
from .prompt import (
|
|
17
18
|
PROMPT_VERSION,
|
|
@@ -25,6 +26,7 @@ from .schema import (
|
|
|
25
26
|
Answer,
|
|
26
27
|
ChoiceAnswer,
|
|
27
28
|
ChoiceQuestion,
|
|
29
|
+
JSONContent,
|
|
28
30
|
NoulAnswer,
|
|
29
31
|
NoulQuestion,
|
|
30
32
|
Question,
|
|
@@ -279,6 +281,28 @@ class Classifier:
|
|
|
279
281
|
)
|
|
280
282
|
return response, diagnostics
|
|
281
283
|
|
|
284
|
+
def nouls(self, statements: Sequence[str], *, image: Any = None, state: JSONContent = "") -> list[float]:
|
|
285
|
+
"""Return each statement's noul about the state, in statement order.
|
|
286
|
+
|
|
287
|
+
The statements are split into requests of JEV_MAX_QUESTIONS, one classify call each, and no
|
|
288
|
+
statements make no request. An empty state with an image is the image-only state.
|
|
289
|
+
"""
|
|
290
|
+
keys = [f"s{index}" for index in range(len(statements))]
|
|
291
|
+
questions: dict[str, Question] = {}
|
|
292
|
+
nouls: list[float] = []
|
|
293
|
+
|
|
294
|
+
for start in range(0, len(statements), JEV_MAX_QUESTIONS):
|
|
295
|
+
window = slice(start, start + JEV_MAX_QUESTIONS)
|
|
296
|
+
questions = {key: NoulQuestion(instructions=statement)
|
|
297
|
+
for key, statement in zip(keys[window], statements[window], strict=True)}
|
|
298
|
+
response, _diagnostics = self.classify(SystemOneRequest(state=state, questions=questions), image=image)
|
|
299
|
+
for key in questions:
|
|
300
|
+
answer = response.answers[key]
|
|
301
|
+
if not isinstance(answer, NoulAnswer):
|
|
302
|
+
raise TypeError(f"question {key} got a {answer.type} answer, not a noul")
|
|
303
|
+
nouls.append(answer.noul)
|
|
304
|
+
return nouls
|
|
305
|
+
|
|
282
306
|
def _round_answers(self, questions: dict[str, Question], branches: list[Branch],
|
|
283
307
|
probabilities: list[np.ndarray]) -> dict[str, Answer]:
|
|
284
308
|
answers: dict[str, Answer] = {}
|
|
@@ -52,7 +52,7 @@ class Branch:
|
|
|
52
52
|
suffix_text: str
|
|
53
53
|
|
|
54
54
|
|
|
55
|
-
def
|
|
55
|
+
def inert(text: str) -> str:
|
|
56
56
|
# The backends' tokenizers read a <|...|> spelling in plain text as a control token, so
|
|
57
57
|
# client text could otherwise forge a chat turn or a second image pad.
|
|
58
58
|
return text.replace("<|", "<\u200b|")
|
|
@@ -67,7 +67,7 @@ def _option_lines(entries: list[tuple[str, str]]) -> str:
|
|
|
67
67
|
|
|
68
68
|
|
|
69
69
|
def _question_block(prompt_line: str, entries: list[tuple[str, str]]) -> str:
|
|
70
|
-
return
|
|
70
|
+
return inert(f"{prompt_line}\nOptions:\n{_option_lines(entries)}")
|
|
71
71
|
|
|
72
72
|
|
|
73
73
|
def _choice_branches(qid: str, question: ChoiceQuestion, plan: BranchPlan,
|
|
@@ -152,7 +152,7 @@ def build_branches(questions: dict[str, Question], score_method: str = "joint",
|
|
|
152
152
|
def prefix_content(state: Any, has_image: bool = False) -> str:
|
|
153
153
|
"""Build the user-message body every branch shares, image marker included."""
|
|
154
154
|
marker = f"{IMAGE_MARKER}\n" if has_image else ""
|
|
155
|
-
return f"Context:\n{marker}{
|
|
155
|
+
return f"Context:\n{marker}{inert(render_content(state))}\n\n"
|
|
156
156
|
|
|
157
157
|
|
|
158
158
|
def branch_content(state: Any, branch: Branch, has_image: bool = False) -> str:
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""The tag text tools, which moved to logit_classifier.toolkit.tags in 0.3.0.
|
|
2
|
+
|
|
3
|
+
This path stays for code built on 0.2.x, which PyPI published. It raises no warning, so that code keeps a quiet log.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from .toolkit.tags import (
|
|
9
|
+
MAX_CANDIDATES,
|
|
10
|
+
MAX_REPEAT_BLOCK,
|
|
11
|
+
MAX_TAG_CHARS,
|
|
12
|
+
MAX_TAG_WORDS,
|
|
13
|
+
clean_item,
|
|
14
|
+
complete_tags,
|
|
15
|
+
drop_subsets,
|
|
16
|
+
drop_unfinished_tag,
|
|
17
|
+
normalize_item,
|
|
18
|
+
parse_candidates,
|
|
19
|
+
repeated_block,
|
|
20
|
+
split_prompt,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"MAX_CANDIDATES",
|
|
25
|
+
"MAX_REPEAT_BLOCK",
|
|
26
|
+
"MAX_TAG_CHARS",
|
|
27
|
+
"MAX_TAG_WORDS",
|
|
28
|
+
"clean_item",
|
|
29
|
+
"complete_tags",
|
|
30
|
+
"drop_subsets",
|
|
31
|
+
"drop_unfinished_tag",
|
|
32
|
+
"normalize_item",
|
|
33
|
+
"parse_candidates",
|
|
34
|
+
"repeated_block",
|
|
35
|
+
"split_prompt",
|
|
36
|
+
]
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""The tools for a ComfyUI node that holds a loaded CLIP.
|
|
2
|
+
|
|
3
|
+
Every module here imports comfy, server, torch and comfy_execution inside the functions that need them, so importing
|
|
4
|
+
this package in a plain Python process loads none of them.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from ... import COMFY_SOCKET_TYPE
|
|
10
|
+
from ...backends.comfy_clip import PACKED_TOKEN_CEILING, ComfyClipBackend
|
|
11
|
+
from .classifier import comfy_classifier
|
|
12
|
+
from .generate import IMAGE_SLOT, chat, clip_generate, generate_text, stop_when
|
|
13
|
+
from .progress import routed_progress_bars, run_outcome, send_status, token_fill
|
|
14
|
+
from .scopes import shared_vision_encode, skip_resident_loads
|
|
15
|
+
from .tagger import (
|
|
16
|
+
ImageTagTrace,
|
|
17
|
+
PromptTagTrace,
|
|
18
|
+
TagSettings,
|
|
19
|
+
fit_image,
|
|
20
|
+
tag_image,
|
|
21
|
+
tag_prompt,
|
|
22
|
+
thing_scores,
|
|
23
|
+
to_english,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"COMFY_SOCKET_TYPE",
|
|
28
|
+
"IMAGE_SLOT",
|
|
29
|
+
"PACKED_TOKEN_CEILING",
|
|
30
|
+
"ComfyClipBackend",
|
|
31
|
+
"ImageTagTrace",
|
|
32
|
+
"PromptTagTrace",
|
|
33
|
+
"TagSettings",
|
|
34
|
+
"chat",
|
|
35
|
+
"clip_generate",
|
|
36
|
+
"comfy_classifier",
|
|
37
|
+
"fit_image",
|
|
38
|
+
"generate_text",
|
|
39
|
+
"routed_progress_bars",
|
|
40
|
+
"run_outcome",
|
|
41
|
+
"send_status",
|
|
42
|
+
"shared_vision_encode",
|
|
43
|
+
"skip_resident_loads",
|
|
44
|
+
"stop_when",
|
|
45
|
+
"tag_image",
|
|
46
|
+
"tag_prompt",
|
|
47
|
+
"thing_scores",
|
|
48
|
+
"to_english",
|
|
49
|
+
"token_fill",
|
|
50
|
+
]
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""One Classifier over the CLIP a ComfyUI workflow already loaded."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from ...backends.base import UnsupportedModelError, VisionUnsupportedError
|
|
8
|
+
from ...backends.comfy_clip import ComfyClipBackend
|
|
9
|
+
from ...classifier import Classifier
|
|
10
|
+
from ...config import Config
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def comfy_classifier(
|
|
14
|
+
clip: Any, *, node: str | None = None, needs_images: bool = True, batch_branches: bool = True
|
|
15
|
+
) -> Classifier:
|
|
16
|
+
"""One Classifier over the workflow's CLIP, with no running prior and no calibration file.
|
|
17
|
+
|
|
18
|
+
Each error starts with the node's name when one is given, and names the Load CLIP fix.
|
|
19
|
+
"""
|
|
20
|
+
prefix = f"{node}: " if node else ""
|
|
21
|
+
backend: ComfyClipBackend | None = None
|
|
22
|
+
|
|
23
|
+
try:
|
|
24
|
+
backend = ComfyClipBackend(clip, batch_branches=batch_branches)
|
|
25
|
+
except UnsupportedModelError as error:
|
|
26
|
+
raise UnsupportedModelError(
|
|
27
|
+
f"{prefix}this CLIP is not a Qwen3-VL text encoder, so the node cannot read it. Load a Qwen3-VL text "
|
|
28
|
+
"encoder, such as qwen3vl_4b_fp8_scaled.safetensors, with a Load CLIP type that keeps its vision tower, "
|
|
29
|
+
"such as krea2."
|
|
30
|
+
) from error
|
|
31
|
+
if needs_images and not backend.sees_images:
|
|
32
|
+
raise VisionUnsupportedError(
|
|
33
|
+
f"{prefix}this CLIP was loaded without its vision tower, so the node cannot read images. Load the "
|
|
34
|
+
"Qwen3-VL text encoder with a Load CLIP type that keeps it, such as krea2. The flux and flux2 types "
|
|
35
|
+
"drop it."
|
|
36
|
+
)
|
|
37
|
+
# A node reuses one classifier across a batch, and the running prior would make an item's answer depend on the
|
|
38
|
+
# items before it.
|
|
39
|
+
return Classifier(Config(use_prior_debias=False, calibration_path=None), backend)
|