Nadir v2: element head, inner-page data, runtime fixes
Browse filesWeights, quants, runtime, extension, reports and README swapped to v2.
- .gitattributes +6 -0
- README.md +257 -143
- config.json +54 -20
- demos/addtoany.jpg +0 -0
- demos/geogebra.jpg +3 -0
- demos/hackernews.jpg +3 -0
- demos/mathworld.jpg +3 -0
- demos/pypi.jpg +3 -0
- demos/python.jpg +3 -0
- demos/wikipedia.jpg +3 -0
- model.py +38 -7
- model.safetensors +2 -2
- nadir-extension.zip +2 -2
- onnx/nadir-elem-fp32.onnx +3 -0
- onnx/nadir-fp32.onnx +2 -2
- onnx/nadir-int4.onnx +2 -2
- onnx/nadir-int8.onnx +2 -2
- reports/elem_eval.json +171 -0
- reports/eval_judge.json +0 -0
- reports/export_report.json +34 -12
- reports/ortweb_timing_m4max.json +31 -41
- reports/quant_report.json +25 -27
- reports/train_log.json +299 -433
- runtime/darkreader-nadir.js +26 -5
- runtime/extract.js +112 -7
- runtime/nadir-infer.js +107 -22
- runtime/nadir-map.js +21 -44
- training/build_ds.py +140 -68
- training/crawl.py +276 -0
- training/elem_eval.py +110 -0
- training/elem_label.py +259 -0
- training/elem_sheets.py +408 -0
- training/export.py +63 -115
- training/ext_render.py +87 -0
- training/haiku_gen.py +1 -1
- training/judge.py +6 -2
- training/make_urls2.py +71 -0
- training/model.py +38 -7
- training/predict.py +24 -6
- training/quantize.py +21 -14
- training/safety.py +82 -10
- training/train.py +123 -35
.gitattributes
CHANGED
|
@@ -33,3 +33,9 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
demos/geogebra.jpg filter=lfs diff=lfs merge=lfs -text
|
| 37 |
+
demos/hackernews.jpg filter=lfs diff=lfs merge=lfs -text
|
| 38 |
+
demos/mathworld.jpg filter=lfs diff=lfs merge=lfs -text
|
| 39 |
+
demos/pypi.jpg filter=lfs diff=lfs merge=lfs -text
|
| 40 |
+
demos/python.jpg filter=lfs diff=lfs merge=lfs -text
|
| 41 |
+
demos/wikipedia.jpg filter=lfs diff=lfs merge=lfs -text
|
README.md
CHANGED
|
@@ -14,207 +14,321 @@ tags:
|
|
| 14 |
|
| 15 |
# Nadir
|
| 16 |
|
| 17 |
-
**Nadir** is a small on-device model that gives light websites a tasteful, readable dark mode, with the
|
| 18 |
-
|
| 19 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
|
| 21 |
Nadir follows three rules:
|
| 22 |
|
| 23 |
1. **Already dark? Leave it alone.** Pages that are already dark are detected and never touched.
|
| 24 |
-
2. **Has its own dark mode? Use it.** Nadir switches on the site's own dark theme
|
| 25 |
-
|
| 26 |
-
3. **No dark mode? The model designs one.** Nadir reads the page's colors and images and
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
|
| 34 |
## How it works
|
| 35 |
|
| 36 |
-
Nadir doesn't paint pixels. It redesigns the page's **color system**
|
| 37 |
-
|
| 38 |
-
1. **Extraction (`runtime/extract.js`).** One synchronous pass over the DOM collects every distinct color
|
| 39 |
-
|
| 40 |
-
features: lightness, chroma, hue, area, text size and weight, where it appears
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
- The page canvas is always dark.
|
| 54 |
-
- Every text and icon color keeps WCAG contrast on *every* surface it actually sits on. It never needs
|
| 55 |
-
|
|
|
|
| 56 |
- Brand colors keep their hue (±45°).
|
| 57 |
-
- Things that were invisible in the light design stay invisible
|
| 58 |
-
|
| 59 |
-
- Colors
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
hard plumbing: stylesheets, inline styles, shadow DOM and dynamically inserted content. It asks Nadir for
|
| 75 |
-
every color it rewrites. Colors the model didn't see (new content) are mapped by nearest-neighbor
|
| 76 |
-
lookup in the predicted palette.
|
| 77 |
|
| 78 |
## Model
|
| 79 |
|
| 80 |
| | |
|
| 81 |
|---|---|
|
| 82 |
-
| Parameters |
|
| 83 |
| Width / depth / heads | 384 / 8 layers / 6 heads |
|
| 84 |
-
| Inputs | ≤256 color tokens, ≤32 images,
|
| 85 |
-
| Outputs | OKLab color + alpha per token
|
| 86 |
| Runtime | onnxruntime-web 1.29 (WASM SIMD, multi-threaded) |
|
|
|
|
| 87 |
|
| 88 |
### Files
|
| 89 |
|
| 90 |
-
| File |
|
| 91 |
-
|---|---|---|---|---|
|
| 92 |
-
| `onnx/nadir-fp32.onnx` | fp32 | 63.7 MB | 0 / 0 | 100% |
|
| 93 |
-
| `onnx/nadir-int8.onnx` | 8-bit weights | 20.6 MB | 0.00015 / 0.025 | 99.99% |
|
| 94 |
-
| `onnx/nadir-int4.onnx` | 4-bit weights (8-bit input/output layers) | 13.3 MB | 0.00036 / 0.071 | 99.76% |
|
| 95 |
-
|
| 96 |
-
Fidelity is measured on 400 held-out pages. A difference of 0.02 in OKLab is about the smallest one you can see.
|
| 97 |
-
|
| 98 |
-
Speed in onnxruntime-web 1.29 (Chrome, Apple M4 Max, 4 WASM threads), per page:
|
| 99 |
-
|
| 100 |
-
| File | Load | Inference |
|
| 101 |
|---|---|---|
|
| 102 |
-
|
|
| 103 |
-
|
|
| 104 |
-
|
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
- Storage format:
|
| 116 |
-
- 4-bit layers use ONNX `MatMulNBits` (block 32).
|
| 117 |
-
- 8-bit layers store uint8 codes with a per-block scale and zero point, dequantized in the graph by standard
|
| 118 |
-
ops (`Cast`/`Sub`/`Mul`/`Reshape`). onnxruntime folds these into the weights when it loads the model, so
|
| 119 |
-
the 8-bit layers run as exact fp32 math with no special kernels. Both files are tested on onnxruntime 1.22
|
| 120 |
-
(CPU) and onnxruntime-web 1.29.
|
| 121 |
-
- Each quant is verified three ways: packing parity against a PyTorch fake-quant model (≤2e-6), fidelity
|
| 122 |
-
against fp32, and quality against the training targets.
|
| 123 |
-
- They run in onnxruntime-web with outputs matching onnxruntime CPU to ~1e-5.
|
| 124 |
-
- Activation-quantized int8 (ORT dynamic/static) was rejected: worst-case color error up to 0.99.
|
| 125 |
-
|
| 126 |
-
`model.safetensors` + `model.py` hold the PyTorch weights and architecture.
|
| 127 |
|
| 128 |
## Training data
|
| 129 |
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 138 |
- **Quality control.**
|
| 139 |
-
- Designs that missed ids, kept a light canvas, or left
|
| 140 |
-
- The rest were passed through the same contrast rule the runtime
|
| 141 |
-
- The
|
| 142 |
-
|
| 143 |
-
|
|
|
|
| 144 |
|
| 145 |
## Evaluation
|
| 146 |
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
|
| 151 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 152 |
|---|---|---|---|
|
| 153 |
-
| Dark Reader 4.9 (dynamic mode) | **
|
| 154 |
-
|
|
| 155 |
-
|
|
|
|
| 156 |
|
| 157 |
-
|
| 158 |
-
design rules rather than from the model itself. Before those rules, the model and its teacher were even (63 vs 67).
|
| 159 |
-
² The rules for light background textures, SVG logos and SVG masks.
|
| 160 |
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 164 |
|
| 165 |
-
|
| 166 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 167 |
|
| 168 |
## Use it
|
| 169 |
|
| 170 |
### Browser extension (Chrome, Edge, Brave, Arc, Aside and other Chromium browsers)
|
| 171 |
|
| 172 |
-
1. Download `nadir-extension.zip`
|
|
|
|
| 173 |
2. Open `chrome://extensions`, enable **Developer mode**, click **Load unpacked**, and choose the folder.
|
| 174 |
3. Browse. The toolbar badge shows what Nadir did on each site:
|
| 175 |
- `AI`: model-designed dark mode.
|
| 176 |
- `ON`: the site's own dark mode.
|
| 177 |
- No badge: already dark.
|
| 178 |
|
| 179 |
-
The popup can turn Nadir off for a site.
|
| 180 |
-
|
| 181 |
-
The extension works alongside content blockers such as uBlock Origin Lite: removed ads simply aren't part
|
| 182 |
-
of the palette, and ads that remain are treated as images (never recolored).
|
| 183 |
|
| 184 |
### In your own code
|
| 185 |
|
| 186 |
```js
|
| 187 |
import * as ort from 'onnxruntime-web';
|
| 188 |
// runtime/extract.js, runtime/nadir-infer.js and runtime/nadir-map.js define the globals used below
|
| 189 |
-
const page = await __nadirExtract({describe: false, thumbs: false});
|
| 190 |
// pixel stats + thumbnails for the images (any context that can fetch them, e.g. an extension page)
|
| 191 |
const st = await __nadirImageStats(page.images, {dpr: devicePixelRatio});
|
| 192 |
page.images.forEach((im, i) => { Object.assign(im, st[i]); im.f = __nadirImageFeatures(im, page.vw, page.vh, page.docH); });
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 193 |
const built = NadirInfer.buildInputs(page);
|
| 194 |
-
const
|
| 195 |
-
const
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 201 |
```
|
| 202 |
|
| 203 |
## Limitations
|
| 204 |
|
| 205 |
-
- Nadir recolors CSS
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
-
|
|
|
|
|
|
|
|
|
|
| 215 |
|
| 216 |
## License
|
| 217 |
|
| 218 |
Apache-2.0 for the model, weights and Nadir code. The bundled page engine is a modified
|
| 219 |
-
[Dark Reader](https://github.com/darkreader/darkreader) (MIT License, © Dark Reader Ltd.); its license is
|
| 220 |
-
|
|
|
|
| 14 |
|
| 15 |
# Nadir
|
| 16 |
|
| 17 |
+
**Nadir** is a small on-device model that gives light websites a tasteful, readable dark mode, with the care of a
|
| 18 |
+
real dark theme rather than a filter. It runs entirely in your browser (onnxruntime-web, WASM) and ships with a
|
| 19 |
+
ready-to-load Chromium extension. The extension is open source:
|
| 20 |
+
**[github.com/SSHdotCodes/nadir](https://github.com/SSHdotCodes/nadir)**.
|
| 21 |
+
|
| 22 |
+
Left: the page as published. Right: the same page with Nadir. These are first visits in a fresh profile, rendered by [`training/ext_render.py`](https://huggingface.co/ProCreations/nadir/blob/main/training/ext_render.py).
|
| 23 |
+
|
| 24 |
+

|
| 25 |
+
|
| 26 |
+
**GeoGebra.** The purple brand color and the illustration keep their colors; surfaces and text go dark.
|
| 27 |
+
|
| 28 |
+

|
| 29 |
+
|
| 30 |
+
**python.org.** The brand's blue and yellow stay, and the code sample keeps its syntax colors.
|
| 31 |
+
|
| 32 |
+

|
| 33 |
+
|
| 34 |
+
**Wolfram MathWorld.** Formula images are recolored so they read on the dark page; the rendered sphere keeps its colors.
|
| 35 |
+
|
| 36 |
+

|
| 37 |
+
|
| 38 |
+
**Hacker News.** The orange header stays; the beige page becomes a warm dark grey.
|
| 39 |
+
|
| 40 |
+

|
| 41 |
+
|
| 42 |
+
**AddToAny.** The light sunburst texture behind the hero is removed, the dark wordmark turns light, and the share buttons keep their brand colors.
|
| 43 |
+
|
| 44 |
+

|
| 45 |
+
|
| 46 |
+
**PyPI.** Badges, code blocks and the blue header keep their colors and hierarchy.
|
| 47 |
+
|
| 48 |
+

|
| 49 |
+
|
| 50 |
+
**Wikipedia** has its own dark theme, so Nadir switches that on instead of designing one.
|
| 51 |
|
| 52 |
Nadir follows three rules:
|
| 53 |
|
| 54 |
1. **Already dark? Leave it alone.** Pages that are already dark are detected and never touched.
|
| 55 |
+
2. **Has its own dark mode? Use it.** Nadir switches on the site's own dark theme: `prefers-color-scheme` media
|
| 56 |
+
rules, or `dark`/`theme-dark` class and attribute toggles it detects in the page's CSS.
|
| 57 |
+
3. **No dark mode? The model designs one.** Nadir reads the page's colors and images and designs a dark theme
|
| 58 |
+
for that specific site:
|
| 59 |
+
- brand colors stay recognizable and surfaces keep their hierarchy;
|
| 60 |
+
- text stays readable;
|
| 61 |
+
- every image, CSS background, sprite and icon is shown the way a designer would choose.
|
| 62 |
+
|
| 63 |
+
Photos and videos are never recolored.
|
| 64 |
+
|
| 65 |
+
**v2 (2026-10-15)** adds an **element head**: the model now decides for itself how to show every image, CSS
|
| 66 |
+
background, sprite icon and `::before` icon, including ones that load after the page. On 23,532 held-out elements
|
| 67 |
+
its choice is acceptable to the teacher 94.4% of the time, against 89.3% for v1's hand-written rules. On 190 held-out live pages, a blind
|
| 68 |
+
Claude Haiku 5.5 judge prefers v2 to Dark Reader 126 to 56 and to v1 91 to 59. Weights, quants and the extension in
|
| 69 |
+
this repository are v2.
|
| 70 |
+
|
| 71 |
+
## What's new in v2
|
| 72 |
+
|
| 73 |
+
- **An element head.** v1 decided how to show the first 32 images of a page with a 5-way image head; everything
|
| 74 |
+
else (CSS backgrounds, sprites, icons, images that load later) fell to hand-written rules, and the rules got logos,
|
| 75 |
+
icons and light textures wrong often enough to notice. v2 removes those rules. Every visual element is classified by
|
| 76 |
+
the model itself:
|
| 77 |
+
- **how to show it:** `keep`, `invert`, `adapt` or `remove`;
|
| 78 |
+
- **what it is:** one of 9 roles;
|
| 79 |
+
- **with a calibrated confidence.**
|
| 80 |
+
|
| 81 |
+
Three fixed rules remain: photos and people keep their colors, a background is only removed when the head is at
|
| 82 |
+
least 70% sure, and an element whose surface stays light in the dark design is shown as designed. The element head is a separate 1.2M-parameter graph that reuses the page's design context, so
|
| 83 |
+
the extension can decide on an image that appears minutes after the page loaded (Gmail icons, lazy-loaded logos,
|
| 84 |
+
CSS sprites, `::before` icons) in about a millisecond.
|
| 85 |
+
- **Element supervision from Claude Haiku 5.5.** About 80,000 contact sheets were rendered and judged by Haiku 5.5.
|
| 86 |
+
Each sheet shows elements on their page's real light surface and on the dark surface Haiku designed for that spot,
|
| 87 |
+
each in every rendering the runtime can produce (kept, inverted, adapted, removed). Haiku picked the best option and
|
| 88 |
+
marked every acceptable one, which gave 500,785 element judgments. Each option is rendered with the same pixel
|
| 89 |
+
transforms the extension uses, so what Haiku judged matches what the extension shows.
|
| 90 |
+
- **More pages, and inner pages.** 14,196 new Haiku 5.5 designs, mostly of inner pages: docs, dashboards, inboxes,
|
| 91 |
+
articles, stores and settings, not just home pages. Together with v1's data that makes 33,615 designs and 6,434
|
| 92 |
+
native dark-theme pairs.
|
| 93 |
+
- **Runtime fixes found by end-to-end testing.**
|
| 94 |
+
- Images, backgrounds and icons that appear after the first pass are measured against the page's original light
|
| 95 |
+
styles.
|
| 96 |
+
- Long inline (`data:`) images are now measured and classified.
|
| 97 |
+
- Hotlink-protected images are fetched with the page's origin as the Referer.
|
| 98 |
+
- A "keep" decision is never overridden by the engine's solid-color heuristic, which misreads SVGs without a
|
| 99 |
+
fixed size.
|
| 100 |
+
- SVG logos are recolored shape by shape.
|
| 101 |
+
- See-through light panels (frosted cards, glass buttons) turn dark.
|
| 102 |
+
- Opaque surfaces stay opaque. A see-through promo bar would show a hidden header stacked beneath it.
|
| 103 |
+
- Icons from an external SVG sprite that already follow the page's text color are not inverted a second time.
|
| 104 |
+
- Logos drawn as CSS masks (the shape painted with a background color) are recolored as ink, like mask icons.
|
| 105 |
+
- Tabs opened together right after the browser starts no longer miss the model while it is still loading.
|
| 106 |
|
| 107 |
## How it works
|
| 108 |
|
| 109 |
+
Nadir doesn't paint pixels. It redesigns the page's **color system**, and makes a decision for every **visual element**.
|
| 110 |
+
|
| 111 |
+
1. **Extraction (`runtime/extract.js`).** One synchronous pass over the DOM collects every distinct color *in its role*:
|
| 112 |
+
background, text, border, shadow, gradient, SVG fill and SVG stroke.
|
| 113 |
+
- Each color token gets 49 features: lightness, chroma, hue, area, text size and weight, where it appears
|
| 114 |
+
(header, nav, link, form, code, footer), what it sits on, and the original contrast.
|
| 115 |
+
- It also records four relations between tokens: text-on-surface, surface-on-surface, border-on-surface and
|
| 116 |
+
same-element.
|
| 117 |
+
- Every visual element gets 41 features plus a 32×32 thumbnail. Visual elements are `<img>`, CSS backgrounds,
|
| 118 |
+
one icon cut out of a sprite sheet, `::before`/`::after` content icons, and SVG images.
|
| 119 |
+
- Ad slots are excluded.
|
| 120 |
+
2. **Model (`NadirNet`, 16.0M parameters).** A relation-biased transformer over `[page] + colors + images`. Each
|
| 121 |
+
attention head gets a learned bias from 11 relation channels, so the model knows which text sits on which
|
| 122 |
+
surface. It has three heads:
|
| 123 |
+
- **Palette head.** For every color token it predicts an OKLab color as a parameterized edit of the input:
|
| 124 |
+
lightness, chroma scale, hue rotation, tint and alpha. This keeps brand hues stable and makes impossible
|
| 125 |
+
colors rare.
|
| 126 |
+
- **Page context head.** A 32-number summary of the page's design.
|
| 127 |
+
- **Element head** (new in v2). For any visual element it predicts:
|
| 128 |
+
- how to show it: `keep`, `invert` (hue-preserving), `adapt` (dark text and lines turn light, brand colors stay,
|
| 129 |
+
a white box is removed) or `remove` (a light texture behind content);
|
| 130 |
+
- what it is: photo, illustration, diagram, logo, icon, text/formula, backdrop, avatar or UI decoration.
|
| 131 |
+
|
| 132 |
+
It reads the element's thumbnail and features, the page context, the light surface under the element, and the
|
| 133 |
+
dark surface the palette head designed for that spot. It is exported as its own 1.2M-parameter graph, so the
|
| 134 |
+
extension can classify elements that appear later (lazy-loaded images, icons in web apps, sprites) without
|
| 135 |
+
re-running the transformer. Its confidence is calibrated with a fitted temperature.
|
| 136 |
+
3. **Design guards (`runtime/nadir-infer.js`, `runtime/nadir-map.js`).** Deterministic rules on top of the colors:
|
| 137 |
- The page canvas is always dark.
|
| 138 |
+
- Every text and icon color keeps WCAG contrast on *every* surface it actually sits on. It never needs more
|
| 139 |
+
contrast than the original design had.
|
| 140 |
+
- A light surface whose text no longer reads is darkened within its own hue.
|
| 141 |
- Brand colors keep their hue (±45°).
|
| 142 |
+
- Things that were invisible in the light design stay invisible (text fades, borders and SVG shapes that matched
|
| 143 |
+
their surface).
|
| 144 |
+
- Colors over video and photos are left alone when they already read as dark mode.
|
| 145 |
+
|
| 146 |
+
For images there are three fixed rules:
|
| 147 |
+
- Anything the element head itself recognizes as a photo or a person keeps its colors.
|
| 148 |
+
- A background is only removed when the head is at least 70% sure; otherwise its next choice is used, because
|
| 149 |
+
taking an image away is the costliest mistake.
|
| 150 |
+
- An element on a surface that is light in the original and stays light in the dark design (a white carousel
|
| 151 |
+
button, a kept light card) is shown as designed; recolored, its dark ink would vanish.
|
| 152 |
+
|
| 153 |
+
Every other image decision is the model's.
|
| 154 |
+
4. **Engine.** The palette is applied through a patched [Dark Reader](https://github.com/darkreader/darkreader)
|
| 155 |
+
4.9.133 dynamic engine (MIT). The engine handles stylesheets, inline styles, shadow DOM and dynamically inserted
|
| 156 |
+
content, and asks Nadir for every color and every background image it rewrites. Colors the model didn't see are
|
| 157 |
+
mapped by nearest-neighbor lookup in the predicted palette. Background images it didn't see are measured and
|
| 158 |
+
sent to the element head.
|
|
|
|
|
|
|
|
|
|
| 159 |
|
| 160 |
## Model
|
| 161 |
|
| 162 |
| | |
|
| 163 |
|---|---|
|
| 164 |
+
| Parameters | 16.0M total, of which the element graph is 1.15M (shared image encoder + element head) |
|
| 165 |
| Width / depth / heads | 384 / 8 layers / 6 heads |
|
| 166 |
+
| Inputs | ≤256 color tokens, ≤32 images in the page pass, any number of elements in the element pass |
|
| 167 |
+
| Outputs | OKLab color + alpha per token; page context; per element: 4-way treatment + 9-way role (calibrated) |
|
| 168 |
| Runtime | onnxruntime-web 1.29 (WASM SIMD, multi-threaded) |
|
| 169 |
+
| Speed | 32 ms per page for the 8-bit page graph (4 WASM threads, Apple M4 Max; 114 ms on one thread), 1.2 s to load it once per browser session; about 0.7 ms per element for the element graph. Revisits use the cached palette. |
|
| 170 |
|
| 171 |
### Files
|
| 172 |
|
| 173 |
+
| File | Size | What |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 174 |
|---|---|---|
|
| 175 |
+
| `onnx/nadir-int8.onnx` | 20.6 MB | Page graph, 8-bit. **Shipped in the extension.** Mean color difference to fp32: OKLab ΔE 0.0002 (max 0.024). Its element decisions match fp32 on all 400 validation pages. 32 ms per page in onnxruntime-web (4 threads, M4 Max); loads in 1.2 s. |
|
| 176 |
+
| `onnx/nadir-int4.onnx` | 13.3 MB | Page graph, 4-bit (`MatMulNBits`, blocks of 32; input/output layers stay 8-bit, relation layers fp32). Mean ΔE to fp32: 0.0004 (max 0.11). Element decisions: 99.96% identical. 37 ms per page; loads in 0.9 s. |
|
| 177 |
+
| `onnx/nadir-fp32.onnx` | 63.5 MB | Page graph, full precision. Outputs `lab`, `alpha_logit`, `ctx`. 32 ms per page; loads in 5.6 s. |
|
| 178 |
+
| `onnx/nadir-elem-fp32.onnx` | 4.6 MB | Element graph (1.15M parameters). Inputs `e_f`, `e_thumb`, `under_light`, `under_dark`, `ctx`; outputs `treat_logits` (keep/invert/adapt/remove) and `role_logits` (9 roles). About 0.7 ms per call on a desktop CPU. |
|
| 179 |
+
| `model.safetensors`, `config.json`, `model.py` | 64 MB | PyTorch weights, configuration (including the element-head temperature) and the architecture. |
|
| 180 |
+
| `runtime/` | | The browser runtime: extraction (`extract.js`), decoding and design guards (`nadir-infer.js`), color mapping and image adaptation (`nadir-map.js`), the patched page engine (`darkreader-nadir.js`, MIT). |
|
| 181 |
+
| `training/` | | Crawl, teacher, contact-sheet, labelling, dataset, training, export, quantization and evaluation scripts. |
|
| 182 |
+
| `reports/` | | Training log, export and quantization reports, browser timing (`ortweb_timing_m4max.json`), the held-out element evaluation and the pairwise judge results (`eval_judge.json`). |
|
| 183 |
+
| `nadir-extension.zip` | | The browser extension, ready to load. |
|
| 184 |
+
| `demos/` | | The light vs Nadir comparisons shown above. |
|
| 185 |
+
|
| 186 |
+
Both quantizations are calibrated on 256 validation pages: per-block scales and zero points are fitted to the
|
| 187 |
+
pages' activations, and the layers that are most sensitive at 4 bits stay at 8 bits or fp32.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 188 |
|
| 189 |
## Training data
|
| 190 |
|
| 191 |
+
All teacher data comes from **Claude Haiku 5.5** (Anthropic Batch API, high effort). The total cost for v1 and v2
|
| 192 |
+
together was about $143.
|
| 193 |
+
|
| 194 |
+
- **Pages.**
|
| 195 |
+
- v1: a crawl of 47,569 home pages from the [Tranco](https://tranco-list.eu/) top list.
|
| 196 |
+
- v2 adds 20,375 **inner pages** (docs, dashboards, inboxes, articles, stores, settings) reached from those sites:
|
| 197 |
+
16,789 light pages, 2,299 with their own dark theme and 271 already dark.
|
| 198 |
+
- Every page was captured at 1280×800: in light mode, in emulated dark mode, and with dark-class toggles applied.
|
| 199 |
+
- **Palette designs.** Haiku 5.5 redesigned 33,759 light pages (19,563 in v1 and 14,196 new; 33,615 passed QC) as the site's own
|
| 200 |
+
product designer. It saw the screenshot, a contact sheet of the images, and the full color inventory with roles and
|
| 201 |
+
locations, and returned a dark palette in a strict JSON schema.
|
| 202 |
+
- **Native pairs.** 6,434 pages with a real built-in dark theme give human-designed light→dark pairs, aligned token by
|
| 203 |
+
token.
|
| 204 |
+
- **Element judgments (new).** 79,736 contact sheets were rendered by `training/elem_sheets.py`. Each sheet shows
|
| 205 |
+
elements on their page's real light surface and on the dark surface Haiku designed for that spot. Each element is
|
| 206 |
+
rendered every way the extension can show it (kept, inverted, adapted, removed), using the extension's own pixel
|
| 207 |
+
transforms. Haiku 5.5 picked the best rendering, marked every acceptable one and named the element's role:
|
| 208 |
+
500,785 judgments. On sheets shown to Haiku twice, its two answers agreed 95.5% of the time.
|
| 209 |
- **Quality control.**
|
| 210 |
+
- Designs that missed ids, kept a light canvas, or left too much text below 3:1 contrast were dropped.
|
| 211 |
+
- The rest were passed through the same contrast rule as the runtime (25,404 of 40,049 adjusted).
|
| 212 |
+
- The split is by site (hash of the hostname): 38,076 training pages and 1,973 validation pages. Validation
|
| 213 |
+
sites hold 23,532 judged elements.
|
| 214 |
+
- **Augmentation.** Random token dropout (it also simulates ad blockers and partial loads) and whole-design hue
|
| 215 |
+
rotation.
|
| 216 |
|
| 217 |
## Evaluation
|
| 218 |
|
| 219 |
+
All numbers are on held-out sites that the model never saw in training.
|
| 220 |
+
|
| 221 |
+
### End to end: pairwise judge
|
| 222 |
|
| 223 |
+
Each held-out page was rendered four ways in the same minute, all in a fresh browser profile: as published, with
|
| 224 |
+
Nadir v1, with Nadir v2, and with [Dark Reader](https://github.com/darkreader/darkreader) 4.9 (dynamic mode). Claude
|
| 225 |
+
Haiku 5.5 then saw the light page plus two dark versions in random order, as A and B. It judged readability first,
|
| 226 |
+
then breakage (glaring light patches, lost logos and icons, distorted photos), then design quality.
|
| 227 |
+
|
| 228 |
+
| Nadir v2 vs … (190 held-out pages) | Nadir v2 preferred | Other preferred | Ties |
|
| 229 |
|---|---|---|---|
|
| 230 |
+
| Dark Reader 4.9 (dynamic mode) | **126** | 56 | 8 |
|
| 231 |
+
| Nadir v1 | **91** | 59 | 39 |
|
| 232 |
+
|
| 233 |
+
v2 is preferred on 61% of the pages with a winner against v1 (91 vs 59) and on 69% against Dark Reader (126 vs 56). Counting only clear preferences (a score gap of at least 2 out of 10), v2 leads v1 39 to 23 and Dark Reader 64 to 27. The pages are live, so re-rendering them changes the counts by about ±5 (carousels, ads and cookie banners differ between renders). Four runtime changes came from reading the judge's reasons on these same pages: opaque surfaces, external SVG sprites, CSS-mask logos and the light-surface rule. Before them, a render batch had v2 and v1 even (77 to 77), so part of that gain may be specific to these pages. Pages that failed to load in any version are left out (189 and 190 of 200).
|
| 234 |
|
| 235 |
+
### Element decisions
|
|
|
|
|
|
|
| 236 |
|
| 237 |
+
For each of 23,532 held-out elements, Haiku 5.5 marked which renderings are acceptable and which one is best. The table
|
| 238 |
+
counts how often the extension's decision is one of the acceptable ones. For v2 that is the element head plus the
|
| 239 |
+
runtime's removal rule: a background is only removed at ≥ 70% confidence. The light-surface rule depends on the rendered
|
| 240 |
+
page, so it is not part of this number. For v1 it is v1's image head plus its
|
| 241 |
+
hand-written rules.
|
| 242 |
+
|
| 243 |
+
| Elements | n | v1 | **v2** |
|
| 244 |
+
|---|---|---|---|
|
| 245 |
+
| Logos | 3,485 | 80.1% | **93.0%** |
|
| 246 |
+
| Transparent images (icons, line art, formulas) | 7,507 | 87.5% | **93.0%** |
|
| 247 |
+
| Backdrops and textures | 2,111 | 77.7% | **89.6%** |
|
| 248 |
+
| Flat images | 10,181 | 95.9% | **96.8%** |
|
| 249 |
+
| Photos | 148 | 100% | 100% |
|
| 250 |
+
| CSS backgrounds (any type) | 3,952 | 81.7% | **90.3%** |
|
| 251 |
+
| **All** | 23,532 | 89.3% | **94.4%** |
|
| 252 |
|
| 253 |
+
- **Agreement with the teacher.** v2 picks Haiku's first choice 88.6% of the time; v1 did 78.6%.
|
| 254 |
+
- **Calibration.** Confidence is calibrated with a temperature of 1.30 (expected calibration error 0.029).
|
| 255 |
+
58% of decisions are made with ≥ 90% confidence, and those match Haiku's first choice 98.3% of the time.
|
| 256 |
+
- **Roles.** The element's role (9 classes) is right 78.4% of the time.
|
| 257 |
+
|
| 258 |
+
### Colors
|
| 259 |
+
|
| 260 |
+
Held-out color error against the targets (OKLab ΔE, lower is better):
|
| 261 |
+
|
| 262 |
+
| | Haiku-designed pages | Native dark themes |
|
| 263 |
+
|---|---|---|
|
| 264 |
+
| v2 | **0.0365** | **0.0966** |
|
| 265 |
+
| v1, on its own validation split | 0.0402 | 0.1031 |
|
| 266 |
+
|
| 267 |
+
**Size.** A 34M-parameter variant was no better in v1's evaluation, so v2 keeps 16.0M parameters. A from-scratch v2
|
| 268 |
+
run (30 epochs) reached 0.0370 / 0.0994 and 94.3% acceptable elements. The released model is warm-started from v1 and
|
| 269 |
+
trained for 18 epochs.
|
| 270 |
|
| 271 |
## Use it
|
| 272 |
|
| 273 |
### Browser extension (Chrome, Edge, Brave, Arc, Aside and other Chromium browsers)
|
| 274 |
|
| 275 |
+
1. Download `nadir-extension.zip` (here or from the [GitHub releases](https://github.com/SSHdotCodes/nadir/releases))
|
| 276 |
+
and unzip it.
|
| 277 |
2. Open `chrome://extensions`, enable **Developer mode**, click **Load unpacked**, and choose the folder.
|
| 278 |
3. Browse. The toolbar badge shows what Nadir did on each site:
|
| 279 |
- `AI`: model-designed dark mode.
|
| 280 |
- `ON`: the site's own dark mode.
|
| 281 |
- No badge: already dark.
|
| 282 |
|
| 283 |
+
The popup can turn Nadir off for a site. The extension works alongside content blockers such as uBlock Origin Lite.
|
|
|
|
|
|
|
|
|
|
| 284 |
|
| 285 |
### In your own code
|
| 286 |
|
| 287 |
```js
|
| 288 |
import * as ort from 'onnxruntime-web';
|
| 289 |
// runtime/extract.js, runtime/nadir-infer.js and runtime/nadir-map.js define the globals used below
|
| 290 |
+
const page = await __nadirExtract({describe: false, thumbs: false, maxImages: 120}); // in the page
|
| 291 |
// pixel stats + thumbnails for the images (any context that can fetch them, e.g. an extension page)
|
| 292 |
const st = await __nadirImageStats(page.images, {dpr: devicePixelRatio});
|
| 293 |
page.images.forEach((im, i) => { Object.assign(im, st[i]); im.f = __nadirImageFeatures(im, page.vw, page.vh, page.docH); });
|
| 294 |
+
const tensor = (data, dims) => new ort.Tensor(data instanceof BigInt64Array ? 'int64' : data instanceof Uint8Array ? 'bool' : 'float32', data, dims);
|
| 295 |
+
const feedsOf = (built) => Object.fromEntries(Object.entries(built.feeds).map(([k, [d, dims]]) => [k, tensor(d, dims)]));
|
| 296 |
+
|
| 297 |
+
// 1. page graph: the palette and the page context
|
| 298 |
+
const pageSess = await ort.InferenceSession.create('onnx/nadir-int8.onnx', {executionProviders: ['wasm']});
|
| 299 |
const built = NadirInfer.buildInputs(page);
|
| 300 |
+
const out = await pageSess.run(feedsOf(built));
|
| 301 |
+
const {colors, keep} = NadirInfer.decode(page, built, {lab: out.lab.data, alpha_logit: out.alpha_logit.data});
|
| 302 |
+
// colors[i] is the dark replacement for page.tokens[i] (null = keep as is)
|
| 303 |
+
|
| 304 |
+
// 2. element graph: how to show each image (any time, for any element)
|
| 305 |
+
const elemSess = await ort.InferenceSession.create('onnx/nadir-elem-fp32.onnx', {executionProviders: ['wasm']});
|
| 306 |
+
const {items, idx} = NadirInfer.pageElementItems(page, {lab: out.lab.data});
|
| 307 |
+
const eb = NadirInfer.buildElemInputs(items, out.ctx.data);
|
| 308 |
+
const eo = await elemSess.run(feedsOf(eb));
|
| 309 |
+
const decisions = NadirInfer.decodeElements(eo.treat_logits.data, eo.role_logits.data, eb.K, 1.30); // temperature from config.json
|
| 310 |
+
// decisions[k] = {t: 'keep'|'invert'|'adapt'|'remove', p, role, rp} for page.images[idx[k]]
|
| 311 |
+
// the extension then applies the three fixed image rules above (decide() in the extension's content.js)
|
| 312 |
```
|
| 313 |
|
| 314 |
## Limitations
|
| 315 |
|
| 316 |
+
- Nadir recolors CSS and decides per image; it never repaints pixels inside an image except when adapting one (dark
|
| 317 |
+
ink → light). Text baked into photos, canvas drawings and cross-origin iframes keep their colors; the iframes get
|
| 318 |
+
their own Nadir pass in the extension.
|
| 319 |
+
- Colors are mapped per (color, role). If one exact color is used both as a light card and as a light button, both
|
| 320 |
+
get the same dark color.
|
| 321 |
+
- The page pass reads at most 256 colors. On very colorful pages the rarest colors are mapped by nearest neighbor.
|
| 322 |
+
- Glyphs drawn with CSS instead of images (hamburger bars made of thin boxes, chevrons made of borders, checkmarks)
|
| 323 |
+
follow the per-color mapping, so a dark bar can stay dark on the dark page. White glyphs inside colored SVG icon
|
| 324 |
+
buttons can turn dark; the brand color stays, so they still read, except on black buttons.
|
| 325 |
+
- A sprite sheet drawn as a CSS background gets one decision for all its icons (the majority of the icons the
|
| 326 |
+
element head saw), because the engine rewrites the CSS rule, not each element.
|
| 327 |
+
- Teacher and judge are both LLMs (Claude Haiku 5.5). Their taste is good but not perfect, the model inherits it,
|
| 328 |
+
and the evaluation is automated, not a human study.
|
| 329 |
|
| 330 |
## License
|
| 331 |
|
| 332 |
Apache-2.0 for the model, weights and Nadir code. The bundled page engine is a modified
|
| 333 |
+
[Dark Reader](https://github.com/darkreader/darkreader) (MIT License, © Dark Reader Ltd.); its license is included in
|
| 334 |
+
the extension (`lib/DARKREADER-LICENSE`).
|
config.json
CHANGED
|
@@ -5,32 +5,66 @@
|
|
| 5 |
"ff": 4,
|
| 6 |
"drop": 0.1,
|
| 7 |
"architecture": "NadirNet",
|
|
|
|
| 8 |
"token_features": 49,
|
| 9 |
-
"image_features":
|
| 10 |
"relation_channels": 11,
|
| 11 |
"max_tokens": 256,
|
| 12 |
"max_images": 32,
|
| 13 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
"keep",
|
| 15 |
-
"dim",
|
| 16 |
"invert",
|
| 17 |
-
"
|
| 18 |
-
"
|
| 19 |
],
|
| 20 |
-
"
|
| 21 |
-
"
|
| 22 |
-
"
|
| 23 |
-
"
|
| 24 |
-
"
|
| 25 |
-
"
|
| 26 |
-
"
|
| 27 |
-
"
|
| 28 |
-
"
|
| 29 |
-
"
|
| 30 |
],
|
| 31 |
-
"
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
"
|
| 35 |
-
|
|
|
|
|
|
|
|
|
|
| 36 |
}
|
|
|
|
| 5 |
"ff": 4,
|
| 6 |
"drop": 0.1,
|
| 7 |
"architecture": "NadirNet",
|
| 8 |
+
"version": 2,
|
| 9 |
"token_features": 49,
|
| 10 |
+
"image_features": 41,
|
| 11 |
"relation_channels": 11,
|
| 12 |
"max_tokens": 256,
|
| 13 |
"max_images": 32,
|
| 14 |
+
"page_graph": {
|
| 15 |
+
"inputs": [
|
| 16 |
+
"tok_f",
|
| 17 |
+
"tok_role",
|
| 18 |
+
"tok_lab",
|
| 19 |
+
"img_f",
|
| 20 |
+
"img_thumb",
|
| 21 |
+
"page_f",
|
| 22 |
+
"src",
|
| 23 |
+
"rel",
|
| 24 |
+
"mask"
|
| 25 |
+
],
|
| 26 |
+
"outputs": [
|
| 27 |
+
"lab",
|
| 28 |
+
"alpha_logit",
|
| 29 |
+
"ctx"
|
| 30 |
+
]
|
| 31 |
+
},
|
| 32 |
+
"element_graph": {
|
| 33 |
+
"inputs": [
|
| 34 |
+
"e_f",
|
| 35 |
+
"e_thumb",
|
| 36 |
+
"under_light",
|
| 37 |
+
"under_dark",
|
| 38 |
+
"ctx"
|
| 39 |
+
],
|
| 40 |
+
"outputs": [
|
| 41 |
+
"treat_logits",
|
| 42 |
+
"role_logits"
|
| 43 |
+
]
|
| 44 |
+
},
|
| 45 |
+
"element_treatments": [
|
| 46 |
"keep",
|
|
|
|
| 47 |
"invert",
|
| 48 |
+
"adapt",
|
| 49 |
+
"remove"
|
| 50 |
],
|
| 51 |
+
"image_roles": [
|
| 52 |
+
"photo",
|
| 53 |
+
"illustration",
|
| 54 |
+
"diagram",
|
| 55 |
+
"logo",
|
| 56 |
+
"icon",
|
| 57 |
+
"text",
|
| 58 |
+
"backdrop",
|
| 59 |
+
"avatar",
|
| 60 |
+
"ui"
|
| 61 |
],
|
| 62 |
+
"context_dim": 32,
|
| 63 |
+
"element_temperature": 1.3000000000000007,
|
| 64 |
+
"element_calibration": {
|
| 65 |
+
"temperature": 1.3000000000000007,
|
| 66 |
+
"nll": 0.32924941182136536,
|
| 67 |
+
"ece": 0.028595900163054466,
|
| 68 |
+
"n": 23532
|
| 69 |
+
}
|
| 70 |
}
|
demos/addtoany.jpg
ADDED
|
demos/geogebra.jpg
ADDED
|
Git LFS Details
|
demos/hackernews.jpg
ADDED
|
Git LFS Details
|
demos/mathworld.jpg
ADDED
|
Git LFS Details
|
demos/pypi.jpg
ADDED
|
Git LFS Details
|
demos/python.jpg
ADDED
|
Git LFS Details
|
demos/wikipedia.jpg
ADDED
|
Git LFS Details
|
model.py
CHANGED
|
@@ -5,15 +5,24 @@ Attention logits get a learned per-head bias from the page's structure graph (te
|
|
| 5 |
surface-on-surface, border-on-surface, same-element, image-on-surface, same-colour), so the model
|
| 6 |
reasons about hierarchy and contrast the way a designer would. Outputs a dark colour per token
|
| 7 |
(OKLab, parameterised relative to the input so hue is kept unless the model chooses otherwise),
|
| 8 |
-
an alpha, and a
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
"""
|
| 10 |
import math
|
| 11 |
import torch
|
| 12 |
import torch.nn as nn
|
| 13 |
import torch.nn.functional as F
|
| 14 |
|
| 15 |
-
FT, FI, FP = 49,
|
| 16 |
-
N_ROLES
|
|
|
|
|
|
|
|
|
|
| 17 |
K_REL = 11
|
| 18 |
|
| 19 |
|
|
@@ -89,7 +98,19 @@ class NadirNet(nn.Module):
|
|
| 89 |
self.blocks = nn.ModuleList([Block(d, heads, ff, drop) for _ in range(layers)])
|
| 90 |
self.ln = nn.LayerNorm(d)
|
| 91 |
self.color_head = MLP(d + 3, d, 6)
|
| 92 |
-
self.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 93 |
|
| 94 |
def forward(self, tok_f, tok_role, tok_lab, img_f, img_thumb, page_f, src, rel, mask):
|
| 95 |
"""tok_f (B,N,FT) tok_role (B,N) long, tok_lab (B,N,3), img_f (B,M,FI), img_thumb (B,M,4,32,32) float 0-1,
|
|
@@ -98,7 +119,7 @@ class NadirNet(nn.Module):
|
|
| 98 |
M = img_f.shape[1]
|
| 99 |
tok = self.tok_in(torch.cat([tok_f, F.one_hot(tok_role, N_ROLES).to(tok_f.dtype), fourier_lab(tok_lab)], -1)) + self.typ.weight[1]
|
| 100 |
if M > 0:
|
| 101 |
-
img = self.
|
| 102 |
else:
|
| 103 |
img = tok.new_zeros(B, 0, tok.shape[-1])
|
| 104 |
cls = self.page_in(page_f)[:, None] + self.typ.weight[0]
|
|
@@ -117,8 +138,18 @@ class NadirNet(nn.Module):
|
|
| 117 |
b = r * a_in + s * b_in + 0.12 * torch.tanh(z[..., 4])
|
| 118 |
lab = torch.stack([L, a, b], -1)
|
| 119 |
alpha_logit = z[..., 5]
|
| 120 |
-
|
| 121 |
-
return lab, alpha_logit,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 122 |
|
| 123 |
|
| 124 |
# ---------- colour maths (differentiable) ----------
|
|
|
|
| 5 |
surface-on-surface, border-on-surface, same-element, image-on-surface, same-colour), so the model
|
| 6 |
reasons about hierarchy and contrast the way a designer would. Outputs a dark colour per token
|
| 7 |
(OKLab, parameterised relative to the input so hue is kept unless the model chooses otherwise),
|
| 8 |
+
an alpha, and a compact page context.
|
| 9 |
+
|
| 10 |
+
Element head (v2): decides how every image, CSS background, sprite or pseudo-element icon on the page is shown
|
| 11 |
+
(keep / invert / adapt / remove) and what it is (photo, logo, icon, formula...). It reuses the trunk's image
|
| 12 |
+
encoder and reads the page context plus the light surface under the element and the dark surface the palette
|
| 13 |
+
head designed for it, so it can run for any element at any time (late-loaded icons, sprites) in a tiny second
|
| 14 |
+
graph without re-running the transformer.
|
| 15 |
"""
|
| 16 |
import math
|
| 17 |
import torch
|
| 18 |
import torch.nn as nn
|
| 19 |
import torch.nn.functional as F
|
| 20 |
|
| 21 |
+
FT, FI, FP = 49, 41, 8
|
| 22 |
+
N_ROLES = 7
|
| 23 |
+
TREAT = ["keep", "invert", "adapt", "remove"]
|
| 24 |
+
IMG_ROLES = ["photo", "illustration", "diagram", "logo", "icon", "text", "backdrop", "avatar", "ui"]
|
| 25 |
+
N_TREAT, N_IROLE, N_CTX = len(TREAT), len(IMG_ROLES), 32
|
| 26 |
K_REL = 11
|
| 27 |
|
| 28 |
|
|
|
|
| 98 |
self.blocks = nn.ModuleList([Block(d, heads, ff, drop) for _ in range(layers)])
|
| 99 |
self.ln = nn.LayerNorm(d)
|
| 100 |
self.color_head = MLP(d + 3, d, 6)
|
| 101 |
+
self.ctx_head = nn.Linear(d, N_CTX)
|
| 102 |
+
self.elem_head = MLP(d + N_CTX + 72, d, N_TREAT + N_IROLE, drop=drop)
|
| 103 |
+
|
| 104 |
+
def embed_images(self, img_f, img_thumb):
|
| 105 |
+
return self.img_in(torch.cat([img_f, self.cnn(img_thumb)], -1))
|
| 106 |
+
|
| 107 |
+
def elements(self, e_f, e_thumb, under_light, under_dark, ctx):
|
| 108 |
+
"""e_f (B,K,FI), e_thumb (B,K,4,32,32), under_light/under_dark (B,K,3) OKLab, ctx (B,N_CTX)
|
| 109 |
+
-> treatment logits (B,K,N_TREAT), role logits (B,K,N_IROLE)."""
|
| 110 |
+
K = e_f.shape[1]
|
| 111 |
+
h = torch.cat([self.embed_images(e_f, e_thumb), ctx[:, None].expand(-1, K, -1), fourier_lab(under_light), fourier_lab(under_dark)], -1)
|
| 112 |
+
z = self.elem_head(h)
|
| 113 |
+
return z[..., :N_TREAT], z[..., N_TREAT:]
|
| 114 |
|
| 115 |
def forward(self, tok_f, tok_role, tok_lab, img_f, img_thumb, page_f, src, rel, mask):
|
| 116 |
"""tok_f (B,N,FT) tok_role (B,N) long, tok_lab (B,N,3), img_f (B,M,FI), img_thumb (B,M,4,32,32) float 0-1,
|
|
|
|
| 119 |
M = img_f.shape[1]
|
| 120 |
tok = self.tok_in(torch.cat([tok_f, F.one_hot(tok_role, N_ROLES).to(tok_f.dtype), fourier_lab(tok_lab)], -1)) + self.typ.weight[1]
|
| 121 |
if M > 0:
|
| 122 |
+
img = self.embed_images(img_f, img_thumb) + self.typ.weight[2]
|
| 123 |
else:
|
| 124 |
img = tok.new_zeros(B, 0, tok.shape[-1])
|
| 125 |
cls = self.page_in(page_f)[:, None] + self.typ.weight[0]
|
|
|
|
| 138 |
b = r * a_in + s * b_in + 0.12 * torch.tanh(z[..., 4])
|
| 139 |
lab = torch.stack([L, a, b], -1)
|
| 140 |
alpha_logit = z[..., 5]
|
| 141 |
+
ctx = self.ctx_head(x[:, 0])
|
| 142 |
+
return lab, alpha_logit, ctx
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
class ElemNet(nn.Module):
|
| 146 |
+
"""The element head as its own graph (shares NadirNet's weights): classify any set of page images."""
|
| 147 |
+
def __init__(self, net):
|
| 148 |
+
super().__init__()
|
| 149 |
+
self.net = net
|
| 150 |
+
|
| 151 |
+
def forward(self, e_f, e_thumb, under_light, under_dark, ctx):
|
| 152 |
+
return self.net.elements(e_f, e_thumb, under_light, under_dark, ctx)
|
| 153 |
|
| 154 |
|
| 155 |
# ---------- colour maths (differentiable) ----------
|
model.safetensors
CHANGED
|
@@ -1,3 +1,3 @@
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
-
oid sha256:
|
| 3 |
-
size
|
|
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:c6a783d4cafe79ea3ddae0cfa1bda3b9f378d8298ba98551863ad84f48134fad
|
| 3 |
+
size 64123972
|
nadir-extension.zip
CHANGED
|
@@ -1,3 +1,3 @@
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
-
oid sha256:
|
| 3 |
-
size
|
|
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:3a7da36bb63e9fa6967c41dde36d87f5fb4ff535fb77dfbda87ad2831e53adc7
|
| 3 |
+
size 27628972
|
onnx/nadir-elem-fp32.onnx
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:57f4e5e59a5cf9a45dc7c95cc38a88194469554d157dba7dc821567f5d4863fa
|
| 3 |
+
size 4615034
|
onnx/nadir-fp32.onnx
CHANGED
|
@@ -1,3 +1,3 @@
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
-
oid sha256:
|
| 3 |
-
size
|
|
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:b729c8633572ce062616cecc3ae0d2fd416e797b39f384a631963c8853755c75
|
| 3 |
+
size 63453840
|
onnx/nadir-int4.onnx
CHANGED
|
@@ -1,3 +1,3 @@
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
-
oid sha256:
|
| 3 |
-
size
|
|
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:d404b6eb6eb5cc494e8bef75486d34947a6b981776b4c2743bba765ec17e99a5
|
| 3 |
+
size 13271851
|
onnx/nadir-int8.onnx
CHANGED
|
@@ -1,3 +1,3 @@
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
-
oid sha256:
|
| 3 |
-
size
|
|
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:3db9718c191de95a527ff160f798e24401981236120019842ecc4d72022acee8
|
| 3 |
+
size 20585432
|
reports/elem_eval.json
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"temperature": 1.3000000000000007,
|
| 3 |
+
"overall": {
|
| 4 |
+
"v2": {
|
| 5 |
+
"n": 23532,
|
| 6 |
+
"acc": 0.8836,
|
| 7 |
+
"acceptable": 0.9422
|
| 8 |
+
},
|
| 9 |
+
"v1": {
|
| 10 |
+
"n": 23532,
|
| 11 |
+
"acc": 0.7859,
|
| 12 |
+
"acceptable": 0.8925
|
| 13 |
+
}
|
| 14 |
+
},
|
| 15 |
+
"by_bucket": {
|
| 16 |
+
"backdrop": {
|
| 17 |
+
"v2": {
|
| 18 |
+
"n": 2111,
|
| 19 |
+
"acc": 0.8015,
|
| 20 |
+
"acceptable": 0.8825
|
| 21 |
+
},
|
| 22 |
+
"v1": {
|
| 23 |
+
"n": 2111,
|
| 24 |
+
"acc": 0.6902,
|
| 25 |
+
"acceptable": 0.7774
|
| 26 |
+
}
|
| 27 |
+
},
|
| 28 |
+
"flat": {
|
| 29 |
+
"v2": {
|
| 30 |
+
"n": 10181,
|
| 31 |
+
"acc": 0.9376,
|
| 32 |
+
"acceptable": 0.9671
|
| 33 |
+
},
|
| 34 |
+
"v1": {
|
| 35 |
+
"n": 10181,
|
| 36 |
+
"acc": 0.9071,
|
| 37 |
+
"acceptable": 0.9587
|
| 38 |
+
}
|
| 39 |
+
},
|
| 40 |
+
"icon": {
|
| 41 |
+
"v2": {
|
| 42 |
+
"n": 100,
|
| 43 |
+
"acc": 0.79,
|
| 44 |
+
"acceptable": 0.91
|
| 45 |
+
},
|
| 46 |
+
"v1": {
|
| 47 |
+
"n": 100,
|
| 48 |
+
"acc": 0.69,
|
| 49 |
+
"acceptable": 0.91
|
| 50 |
+
}
|
| 51 |
+
},
|
| 52 |
+
"logo": {
|
| 53 |
+
"v2": {
|
| 54 |
+
"n": 3485,
|
| 55 |
+
"acc": 0.8482,
|
| 56 |
+
"acceptable": 0.9286
|
| 57 |
+
},
|
| 58 |
+
"v1": {
|
| 59 |
+
"n": 3485,
|
| 60 |
+
"acc": 0.7228,
|
| 61 |
+
"acceptable": 0.8009
|
| 62 |
+
}
|
| 63 |
+
},
|
| 64 |
+
"photo": {
|
| 65 |
+
"v2": {
|
| 66 |
+
"n": 148,
|
| 67 |
+
"acc": 1.0,
|
| 68 |
+
"acceptable": 1.0
|
| 69 |
+
},
|
| 70 |
+
"v1": {
|
| 71 |
+
"n": 148,
|
| 72 |
+
"acc": 1.0,
|
| 73 |
+
"acceptable": 1.0
|
| 74 |
+
}
|
| 75 |
+
},
|
| 76 |
+
"transparent": {
|
| 77 |
+
"v2": {
|
| 78 |
+
"n": 7507,
|
| 79 |
+
"acc": 0.8489,
|
| 80 |
+
"acceptable": 0.9307
|
| 81 |
+
},
|
| 82 |
+
"v1": {
|
| 83 |
+
"n": 7507,
|
| 84 |
+
"acc": 0.6748,
|
| 85 |
+
"acceptable": 0.8753
|
| 86 |
+
}
|
| 87 |
+
}
|
| 88 |
+
},
|
| 89 |
+
"by_kind": {
|
| 90 |
+
"bg": {
|
| 91 |
+
"v2": {
|
| 92 |
+
"n": 3952,
|
| 93 |
+
"acc": 0.8072,
|
| 94 |
+
"acceptable": 0.8957
|
| 95 |
+
},
|
| 96 |
+
"v1": {
|
| 97 |
+
"n": 3952,
|
| 98 |
+
"acc": 0.684,
|
| 99 |
+
"acceptable": 0.8171
|
| 100 |
+
}
|
| 101 |
+
},
|
| 102 |
+
"img": {
|
| 103 |
+
"v2": {
|
| 104 |
+
"n": 19534,
|
| 105 |
+
"acc": 0.8992,
|
| 106 |
+
"acceptable": 0.9517
|
| 107 |
+
},
|
| 108 |
+
"v1": {
|
| 109 |
+
"n": 19534,
|
| 110 |
+
"acc": 0.8069,
|
| 111 |
+
"acceptable": 0.9078
|
| 112 |
+
}
|
| 113 |
+
},
|
| 114 |
+
"pseudo": {
|
| 115 |
+
"v2": {
|
| 116 |
+
"n": 13,
|
| 117 |
+
"acc": 0.9231,
|
| 118 |
+
"acceptable": 0.9231
|
| 119 |
+
},
|
| 120 |
+
"v1": {
|
| 121 |
+
"n": 13,
|
| 122 |
+
"acc": 0.7692,
|
| 123 |
+
"acceptable": 0.9231
|
| 124 |
+
}
|
| 125 |
+
},
|
| 126 |
+
"svgimage": {
|
| 127 |
+
"v2": {
|
| 128 |
+
"n": 33,
|
| 129 |
+
"acc": 0.8182,
|
| 130 |
+
"acceptable": 0.8788
|
| 131 |
+
},
|
| 132 |
+
"v1": {
|
| 133 |
+
"n": 33,
|
| 134 |
+
"acc": 0.5758,
|
| 135 |
+
"acceptable": 0.8788
|
| 136 |
+
}
|
| 137 |
+
}
|
| 138 |
+
},
|
| 139 |
+
"calibration": {
|
| 140 |
+
"0.3": {
|
| 141 |
+
"n": 88,
|
| 142 |
+
"acc": 0.477
|
| 143 |
+
},
|
| 144 |
+
"0.4": {
|
| 145 |
+
"n": 599,
|
| 146 |
+
"acc": 0.422
|
| 147 |
+
},
|
| 148 |
+
"0.5": {
|
| 149 |
+
"n": 1989,
|
| 150 |
+
"acc": 0.598
|
| 151 |
+
},
|
| 152 |
+
"0.6": {
|
| 153 |
+
"n": 2682,
|
| 154 |
+
"acc": 0.76
|
| 155 |
+
},
|
| 156 |
+
"0.7": {
|
| 157 |
+
"n": 2175,
|
| 158 |
+
"acc": 0.812
|
| 159 |
+
},
|
| 160 |
+
"0.8": {
|
| 161 |
+
"n": 2292,
|
| 162 |
+
"acc": 0.884
|
| 163 |
+
},
|
| 164 |
+
"0.9": {
|
| 165 |
+
"n": 13707,
|
| 166 |
+
"acc": 0.983
|
| 167 |
+
}
|
| 168 |
+
},
|
| 169 |
+
"role_acc": 0.7843,
|
| 170 |
+
"n_double_labelled": 657
|
| 171 |
+
}
|
reports/eval_judge.json
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|
reports/export_report.json
CHANGED
|
@@ -1,20 +1,42 @@
|
|
| 1 |
{
|
| 2 |
-
"params":
|
| 3 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
"variants": {
|
| 5 |
"fp32": {
|
| 6 |
"dE_vs_fp32": 0.0,
|
| 7 |
"dE_max": 0.0,
|
| 8 |
-
"
|
| 9 |
-
"size_mb": 63.
|
| 10 |
-
"cpu_ms_median":
|
| 11 |
-
},
|
| 12 |
-
"fp16": {
|
| 13 |
-
"dE_vs_fp32": 0.0002421401860071152,
|
| 14 |
-
"dE_max": 0.23341728746891022,
|
| 15 |
-
"img_agree": 1.0,
|
| 16 |
-
"size_mb": 31.91,
|
| 17 |
-
"cpu_ms_median": 10.17
|
| 18 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
}
|
| 20 |
}
|
|
|
|
| 1 |
{
|
| 2 |
+
"params": 16027747,
|
| 3 |
+
"elem_params": 1147181,
|
| 4 |
+
"parity_max_abs": 2.7418136596679688e-06,
|
| 5 |
+
"elem_parity_max_abs": 1.9073486328125e-05,
|
| 6 |
+
"temperature": 1.3000000000000007,
|
| 7 |
+
"treat": [
|
| 8 |
+
"keep",
|
| 9 |
+
"invert",
|
| 10 |
+
"adapt",
|
| 11 |
+
"remove"
|
| 12 |
+
],
|
| 13 |
+
"img_roles": [
|
| 14 |
+
"photo",
|
| 15 |
+
"illustration",
|
| 16 |
+
"diagram",
|
| 17 |
+
"logo",
|
| 18 |
+
"icon",
|
| 19 |
+
"text",
|
| 20 |
+
"backdrop",
|
| 21 |
+
"avatar",
|
| 22 |
+
"ui"
|
| 23 |
+
],
|
| 24 |
+
"elem_val_sample": {
|
| 25 |
+
"n": 859,
|
| 26 |
+
"acc": 0.9068684516880093,
|
| 27 |
+
"ok": 0.9522700814901047
|
| 28 |
+
},
|
| 29 |
"variants": {
|
| 30 |
"fp32": {
|
| 31 |
"dE_vs_fp32": 0.0,
|
| 32 |
"dE_max": 0.0,
|
| 33 |
+
"ctx_max_abs": 0.0,
|
| 34 |
+
"size_mb": 63.45,
|
| 35 |
+
"cpu_ms_median": 10.48
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
}
|
| 37 |
+
},
|
| 38 |
+
"elem": {
|
| 39 |
+
"size_mb": 4.62,
|
| 40 |
+
"cpu_ms_median": 0.65
|
| 41 |
}
|
| 42 |
}
|
reports/ortweb_timing_m4max.json
CHANGED
|
@@ -1,67 +1,57 @@
|
|
| 1 |
{
|
| 2 |
-
"runtime": "onnxruntime-web 1.29 wasm, Chrome for Testing 141 headless, Apple M4 Max; t1/t4 = 1/4 threads; 12 held-out pages, median of 3 runs each",
|
| 3 |
"fp32": {
|
| 4 |
"t1": {
|
| 5 |
"isolated": false,
|
| 6 |
-
"load_ms":
|
| 7 |
-
"ms_median":
|
| 8 |
-
"max_abs_lab_vs_ort_cpu":
|
| 9 |
-
"
|
|
|
|
| 10 |
},
|
| 11 |
"t4": {
|
| 12 |
"isolated": false,
|
| 13 |
-
"load_ms":
|
| 14 |
-
"ms_median":
|
| 15 |
-
"max_abs_lab_vs_ort_cpu":
|
| 16 |
-
"
|
|
|
|
| 17 |
}
|
| 18 |
},
|
| 19 |
"int8": {
|
| 20 |
"t1": {
|
| 21 |
"isolated": false,
|
| 22 |
-
"load_ms":
|
| 23 |
-
"ms_median":
|
| 24 |
-
"max_abs_lab_vs_ort_cpu":
|
| 25 |
-
"
|
|
|
|
| 26 |
},
|
| 27 |
"t4": {
|
| 28 |
"isolated": false,
|
| 29 |
-
"load_ms":
|
| 30 |
-
"ms_median":
|
| 31 |
-
"max_abs_lab_vs_ort_cpu":
|
| 32 |
-
"
|
| 33 |
-
|
| 34 |
-
},
|
| 35 |
-
"int8_repeat": {
|
| 36 |
-
"t1": {
|
| 37 |
-
"isolated": false,
|
| 38 |
-
"load_ms": 1523.5,
|
| 39 |
-
"ms_median": 115.82,
|
| 40 |
-
"max_abs_lab_vs_ort_cpu": 1.9669532775878906e-06,
|
| 41 |
-
"max_abs_img_vs_ort_cpu": 2.9802322387695312e-06
|
| 42 |
-
},
|
| 43 |
-
"t4": {
|
| 44 |
-
"isolated": false,
|
| 45 |
-
"load_ms": 1519,
|
| 46 |
-
"ms_median": 32.68,
|
| 47 |
-
"max_abs_lab_vs_ort_cpu": 1.9669532775878906e-06,
|
| 48 |
-
"max_abs_img_vs_ort_cpu": 2.9802322387695312e-06
|
| 49 |
}
|
| 50 |
},
|
| 51 |
"int4": {
|
| 52 |
"t1": {
|
| 53 |
"isolated": false,
|
| 54 |
-
"load_ms":
|
| 55 |
-
"ms_median":
|
| 56 |
-
"max_abs_lab_vs_ort_cpu":
|
| 57 |
-
"
|
|
|
|
| 58 |
},
|
| 59 |
"t4": {
|
| 60 |
"isolated": false,
|
| 61 |
-
"load_ms":
|
| 62 |
-
"ms_median":
|
| 63 |
-
"max_abs_lab_vs_ort_cpu":
|
| 64 |
-
"
|
|
|
|
| 65 |
}
|
| 66 |
}
|
| 67 |
}
|
|
|
|
| 1 |
{
|
| 2 |
+
"runtime": "onnxruntime-web 1.29 wasm, Chrome for Testing 141 headless, Apple M4 Max; t1/t4 = 1/4 threads; 12 held-out pages, median of 3 runs each; outputs compared with onnxruntime CPU",
|
| 3 |
"fp32": {
|
| 4 |
"t1": {
|
| 5 |
"isolated": false,
|
| 6 |
+
"load_ms": 5602.2,
|
| 7 |
+
"ms_median": 112.13,
|
| 8 |
+
"max_abs_lab_vs_ort_cpu": 3.3974647521972656e-06,
|
| 9 |
+
"max_abs_alpha_logit_vs_ort_cpu": 1.4543533325195312e-05,
|
| 10 |
+
"max_abs_ctx_vs_ort_cpu": 3.5762786865234375e-06
|
| 11 |
},
|
| 12 |
"t4": {
|
| 13 |
"isolated": false,
|
| 14 |
+
"load_ms": 5554.2,
|
| 15 |
+
"ms_median": 31.8,
|
| 16 |
+
"max_abs_lab_vs_ort_cpu": 3.3974647521972656e-06,
|
| 17 |
+
"max_abs_alpha_logit_vs_ort_cpu": 1.4543533325195312e-05,
|
| 18 |
+
"max_abs_ctx_vs_ort_cpu": 3.5762786865234375e-06
|
| 19 |
}
|
| 20 |
},
|
| 21 |
"int8": {
|
| 22 |
"t1": {
|
| 23 |
"isolated": false,
|
| 24 |
+
"load_ms": 1246,
|
| 25 |
+
"ms_median": 113.62,
|
| 26 |
+
"max_abs_lab_vs_ort_cpu": 4.0531158447265625e-06,
|
| 27 |
+
"max_abs_alpha_logit_vs_ort_cpu": 2.86102294921875e-05,
|
| 28 |
+
"max_abs_ctx_vs_ort_cpu": 4.0531158447265625e-06
|
| 29 |
},
|
| 30 |
"t4": {
|
| 31 |
"isolated": false,
|
| 32 |
+
"load_ms": 1214.3,
|
| 33 |
+
"ms_median": 32.03,
|
| 34 |
+
"max_abs_lab_vs_ort_cpu": 4.0531158447265625e-06,
|
| 35 |
+
"max_abs_alpha_logit_vs_ort_cpu": 2.86102294921875e-05,
|
| 36 |
+
"max_abs_ctx_vs_ort_cpu": 4.0531158447265625e-06
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
}
|
| 38 |
},
|
| 39 |
"int4": {
|
| 40 |
"t1": {
|
| 41 |
"isolated": false,
|
| 42 |
+
"load_ms": 854.5,
|
| 43 |
+
"ms_median": 132.23,
|
| 44 |
+
"max_abs_lab_vs_ort_cpu": 6.377696990966797e-06,
|
| 45 |
+
"max_abs_alpha_logit_vs_ort_cpu": 3.719329833984375e-05,
|
| 46 |
+
"max_abs_ctx_vs_ort_cpu": 3.337860107421875e-06
|
| 47 |
},
|
| 48 |
"t4": {
|
| 49 |
"isolated": false,
|
| 50 |
+
"load_ms": 857.2,
|
| 51 |
+
"ms_median": 37.18,
|
| 52 |
+
"max_abs_lab_vs_ort_cpu": 6.377696990966797e-06,
|
| 53 |
+
"max_abs_alpha_logit_vs_ort_cpu": 3.719329833984375e-05,
|
| 54 |
+
"max_abs_ctx_vs_ort_cpu": 3.337860107421875e-06
|
| 55 |
}
|
| 56 |
}
|
| 57 |
}
|
reports/quant_report.json
CHANGED
|
@@ -17,7 +17,8 @@
|
|
| 17 |
"blocks.4.rel",
|
| 18 |
"blocks.5.rel",
|
| 19 |
"blocks.6.rel",
|
| 20 |
-
"blocks.7.rel"
|
|
|
|
| 21 |
],
|
| 22 |
"int4_8bit_edge_layers": [
|
| 23 |
"tok_in.net.0",
|
|
@@ -25,39 +26,36 @@
|
|
| 25 |
"img_in.net.0",
|
| 26 |
"img_in.net.3",
|
| 27 |
"color_head.net.0",
|
| 28 |
-
"color_head.net.3"
|
| 29 |
-
"img_head.net.0",
|
| 30 |
-
"img_head.net.3"
|
| 31 |
],
|
| 32 |
"variants": {
|
| 33 |
"fp32": {
|
| 34 |
-
"haiku_dE": 0.
|
| 35 |
-
"native_dE": 0.
|
| 36 |
-
"
|
| 37 |
-
"
|
| 38 |
-
"cpu_ms_median": 11.42
|
| 39 |
},
|
| 40 |
"int8": {
|
| 41 |
-
"dE_vs_fp32": 0.
|
| 42 |
-
"dE_max": 0.
|
| 43 |
-
"
|
| 44 |
-
"haiku_dE": 0.
|
| 45 |
-
"native_dE": 0.
|
| 46 |
-
"
|
| 47 |
-
"pack_parity_max_abs":
|
| 48 |
-
"size_mb": 20.
|
| 49 |
-
"cpu_ms_median": 9.
|
| 50 |
},
|
| 51 |
"int4": {
|
| 52 |
-
"dE_vs_fp32": 0.
|
| 53 |
-
"dE_max": 0.
|
| 54 |
-
"
|
| 55 |
-
"haiku_dE": 0.
|
| 56 |
-
"native_dE": 0.
|
| 57 |
-
"
|
| 58 |
-
"pack_parity_max_abs":
|
| 59 |
-
"size_mb": 13.
|
| 60 |
-
"cpu_ms_median":
|
| 61 |
}
|
| 62 |
}
|
| 63 |
}
|
|
|
|
| 17 |
"blocks.4.rel",
|
| 18 |
"blocks.5.rel",
|
| 19 |
"blocks.6.rel",
|
| 20 |
+
"blocks.7.rel",
|
| 21 |
+
"ctx_head"
|
| 22 |
],
|
| 23 |
"int4_8bit_edge_layers": [
|
| 24 |
"tok_in.net.0",
|
|
|
|
| 26 |
"img_in.net.0",
|
| 27 |
"img_in.net.3",
|
| 28 |
"color_head.net.0",
|
| 29 |
+
"color_head.net.3"
|
|
|
|
|
|
|
| 30 |
],
|
| 31 |
"variants": {
|
| 32 |
"fp32": {
|
| 33 |
+
"haiku_dE": 0.03649106619724383,
|
| 34 |
+
"native_dE": 0.07405353200621903,
|
| 35 |
+
"size_mb": 63.45,
|
| 36 |
+
"cpu_ms_median": 9.31
|
|
|
|
| 37 |
},
|
| 38 |
"int8": {
|
| 39 |
+
"dE_vs_fp32": 0.0001628809163776168,
|
| 40 |
+
"dE_max": 0.02378411777317524,
|
| 41 |
+
"ctx_max_abs": 0.013257980346679688,
|
| 42 |
+
"haiku_dE": 0.036490150559693575,
|
| 43 |
+
"native_dE": 0.074042251650244,
|
| 44 |
+
"elem_decisions_same_as_fp32": 1.0,
|
| 45 |
+
"pack_parity_max_abs": 1.7881393432617188e-06,
|
| 46 |
+
"size_mb": 20.59,
|
| 47 |
+
"cpu_ms_median": 9.77
|
| 48 |
},
|
| 49 |
"int4": {
|
| 50 |
+
"dE_vs_fp32": 0.00039874050049547804,
|
| 51 |
+
"dE_max": 0.10922706872224808,
|
| 52 |
+
"ctx_max_abs": 0.07820296287536621,
|
| 53 |
+
"haiku_dE": 0.03647379979180793,
|
| 54 |
+
"native_dE": 0.07408187076449395,
|
| 55 |
+
"elem_decisions_same_as_fp32": 0.9995768986672308,
|
| 56 |
+
"pack_parity_max_abs": 2.9206275939941406e-06,
|
| 57 |
+
"size_mb": 13.27,
|
| 58 |
+
"cpu_ms_median": 8.29
|
| 59 |
}
|
| 60 |
}
|
| 61 |
}
|
reports/train_log.json
CHANGED
|
@@ -1,501 +1,367 @@
|
|
| 1 |
{
|
| 2 |
"args": {
|
| 3 |
-
"data": "
|
| 4 |
-
"out": "
|
| 5 |
"d": 384,
|
| 6 |
"layers": 8,
|
| 7 |
"heads": 6,
|
| 8 |
-
"epochs":
|
| 9 |
"bs": 48,
|
| 10 |
-
"lr": 0.
|
| 11 |
"wd": 0.05,
|
| 12 |
"drop": 0.1,
|
| 13 |
"native_weight": 0.5,
|
| 14 |
"seed": 0,
|
| 15 |
-
"workers":
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
},
|
| 17 |
-
"params":
|
| 18 |
"log": [
|
| 19 |
{
|
| 20 |
"epoch": 0,
|
| 21 |
-
"step":
|
| 22 |
-
"secs":
|
| 23 |
-
"loss": 0.
|
| 24 |
-
"dE": 0.
|
| 25 |
-
"con": 0.
|
| 26 |
-
"
|
| 27 |
-
"
|
| 28 |
-
"
|
| 29 |
-
"
|
| 30 |
-
"
|
| 31 |
-
"
|
| 32 |
-
"
|
| 33 |
-
"
|
|
|
|
|
|
|
|
|
|
| 34 |
},
|
| 35 |
{
|
| 36 |
"epoch": 1,
|
| 37 |
-
"step":
|
| 38 |
-
"secs":
|
| 39 |
-
"loss": 0.
|
| 40 |
-
"dE": 0.
|
| 41 |
-
"con": 0.
|
| 42 |
-
"
|
| 43 |
-
"
|
| 44 |
-
"
|
| 45 |
-
"
|
| 46 |
-
"
|
| 47 |
-
"
|
| 48 |
-
"
|
| 49 |
-
"
|
|
|
|
|
|
|
|
|
|
| 50 |
},
|
| 51 |
{
|
| 52 |
"epoch": 2,
|
| 53 |
-
"step":
|
| 54 |
-
"secs":
|
| 55 |
-
"loss": 0.
|
| 56 |
-
"dE": 0.
|
| 57 |
-
"con": 0.
|
| 58 |
-
"
|
| 59 |
-
"
|
| 60 |
-
"
|
| 61 |
-
"
|
| 62 |
-
"
|
| 63 |
-
"
|
| 64 |
-
"
|
| 65 |
-
"
|
|
|
|
|
|
|
|
|
|
| 66 |
},
|
| 67 |
{
|
| 68 |
"epoch": 3,
|
| 69 |
-
"step":
|
| 70 |
-
"secs":
|
| 71 |
-
"loss": 0.
|
| 72 |
-
"dE": 0.
|
| 73 |
-
"con": 0.
|
| 74 |
-
"
|
| 75 |
-
"
|
| 76 |
-
"
|
| 77 |
-
"
|
| 78 |
-
"
|
| 79 |
-
"
|
| 80 |
-
"
|
| 81 |
-
"
|
|
|
|
|
|
|
|
|
|
| 82 |
},
|
| 83 |
{
|
| 84 |
"epoch": 4,
|
| 85 |
-
"step":
|
| 86 |
-
"secs":
|
| 87 |
-
"loss": 0.
|
| 88 |
-
"dE": 0.
|
| 89 |
-
"con": 0.
|
| 90 |
-
"
|
| 91 |
-
"
|
| 92 |
-
"
|
| 93 |
-
"
|
| 94 |
-
"
|
| 95 |
-
"
|
| 96 |
-
"
|
| 97 |
-
"
|
|
|
|
|
|
|
|
|
|
| 98 |
},
|
| 99 |
{
|
| 100 |
"epoch": 5,
|
| 101 |
-
"step":
|
| 102 |
-
"secs":
|
| 103 |
-
"loss": 0.
|
| 104 |
-
"dE": 0.
|
| 105 |
-
"con": 0.
|
| 106 |
-
"
|
| 107 |
-
"
|
| 108 |
-
"
|
| 109 |
-
"
|
| 110 |
-
"
|
| 111 |
-
"
|
| 112 |
-
"
|
| 113 |
-
"
|
|
|
|
|
|
|
|
|
|
| 114 |
},
|
| 115 |
{
|
| 116 |
"epoch": 6,
|
| 117 |
-
"step":
|
| 118 |
-
"secs":
|
| 119 |
-
"loss": 0.
|
| 120 |
-
"dE": 0.
|
| 121 |
-
"con": 0.
|
| 122 |
-
"
|
| 123 |
-
"
|
| 124 |
-
"
|
| 125 |
-
"
|
| 126 |
-
"
|
| 127 |
-
"
|
| 128 |
-
"
|
| 129 |
-
"
|
|
|
|
|
|
|
|
|
|
| 130 |
},
|
| 131 |
{
|
| 132 |
"epoch": 7,
|
| 133 |
-
"step":
|
| 134 |
-
"secs":
|
| 135 |
-
"loss": 0.
|
| 136 |
-
"dE": 0.
|
| 137 |
-
"con": 0.
|
| 138 |
-
"
|
| 139 |
-
"
|
| 140 |
-
"
|
| 141 |
-
"
|
| 142 |
-
"
|
| 143 |
-
"
|
| 144 |
-
"
|
| 145 |
-
"
|
|
|
|
|
|
|
|
|
|
| 146 |
},
|
| 147 |
{
|
| 148 |
"epoch": 8,
|
| 149 |
-
"step":
|
| 150 |
-
"secs":
|
| 151 |
-
"loss": 0.
|
| 152 |
-
"dE": 0.
|
| 153 |
-
"con": 0.
|
| 154 |
-
"
|
| 155 |
-
"
|
| 156 |
-
"
|
| 157 |
-
"
|
| 158 |
-
"
|
| 159 |
-
"
|
| 160 |
-
"
|
| 161 |
-
"
|
|
|
|
|
|
|
|
|
|
| 162 |
},
|
| 163 |
{
|
| 164 |
"epoch": 9,
|
| 165 |
-
"step":
|
| 166 |
-
"secs":
|
| 167 |
-
"loss": 0.
|
| 168 |
-
"dE": 0.
|
| 169 |
-
"con": 0.
|
| 170 |
-
"
|
| 171 |
-
"
|
| 172 |
-
"
|
| 173 |
-
"
|
| 174 |
-
"
|
| 175 |
-
"
|
| 176 |
-
"
|
| 177 |
-
"
|
|
|
|
|
|
|
|
|
|
| 178 |
},
|
| 179 |
{
|
| 180 |
"epoch": 10,
|
| 181 |
-
"step":
|
| 182 |
-
"secs":
|
| 183 |
-
"loss": 0.
|
| 184 |
-
"dE": 0.
|
| 185 |
-
"con": 0.
|
| 186 |
-
"
|
| 187 |
-
"
|
| 188 |
-
"
|
| 189 |
-
"
|
| 190 |
-
"
|
| 191 |
-
"
|
| 192 |
-
"
|
| 193 |
-
"
|
|
|
|
|
|
|
|
|
|
| 194 |
},
|
| 195 |
{
|
| 196 |
"epoch": 11,
|
| 197 |
-
"step":
|
| 198 |
-
"secs":
|
| 199 |
-
"loss": 0.
|
| 200 |
-
"dE": 0.
|
| 201 |
-
"con": 0.
|
| 202 |
-
"
|
| 203 |
-
"
|
| 204 |
-
"
|
| 205 |
-
"
|
| 206 |
-
"
|
| 207 |
-
"
|
| 208 |
-
"
|
| 209 |
-
"
|
|
|
|
|
|
|
|
|
|
| 210 |
},
|
| 211 |
{
|
| 212 |
"epoch": 12,
|
| 213 |
-
"step":
|
| 214 |
-
"secs":
|
| 215 |
-
"loss": 0.
|
| 216 |
-
"dE": 0.
|
| 217 |
-
"con": 0.
|
| 218 |
-
"
|
| 219 |
-
"
|
| 220 |
-
"
|
| 221 |
-
"
|
| 222 |
-
"
|
| 223 |
-
"
|
| 224 |
-
"
|
| 225 |
-
"
|
|
|
|
|
|
|
|
|
|
| 226 |
},
|
| 227 |
{
|
| 228 |
"epoch": 13,
|
| 229 |
-
"step":
|
| 230 |
-
"secs":
|
| 231 |
-
"loss": 0.
|
| 232 |
-
"dE": 0.
|
| 233 |
-
"con": 0.
|
| 234 |
-
"
|
| 235 |
-
"
|
| 236 |
-
"
|
| 237 |
-
"
|
| 238 |
-
"
|
| 239 |
-
"
|
| 240 |
-
"
|
| 241 |
-
"
|
|
|
|
|
|
|
|
|
|
| 242 |
},
|
| 243 |
{
|
| 244 |
"epoch": 14,
|
| 245 |
-
"step":
|
| 246 |
-
"secs":
|
| 247 |
-
"loss": 0.
|
| 248 |
-
"dE": 0.
|
| 249 |
-
"con": 0.
|
| 250 |
-
"
|
| 251 |
-
"
|
| 252 |
-
"
|
| 253 |
-
"
|
| 254 |
-
"
|
| 255 |
-
"
|
| 256 |
-
"
|
| 257 |
-
"
|
|
|
|
|
|
|
|
|
|
| 258 |
},
|
| 259 |
{
|
| 260 |
"epoch": 15,
|
| 261 |
-
"step":
|
| 262 |
-
"secs":
|
| 263 |
-
"loss": 0.
|
| 264 |
-
"dE": 0.
|
| 265 |
-
"con": 0.
|
| 266 |
-
"
|
| 267 |
-
"
|
| 268 |
-
"
|
| 269 |
-
"
|
| 270 |
-
"
|
| 271 |
-
"
|
| 272 |
-
"
|
| 273 |
-
"
|
|
|
|
|
|
|
|
|
|
| 274 |
},
|
| 275 |
{
|
| 276 |
"epoch": 16,
|
| 277 |
-
"step":
|
| 278 |
-
"secs":
|
| 279 |
-
"loss": 0.
|
| 280 |
-
"dE": 0.
|
| 281 |
-
"con": 0.
|
| 282 |
-
"
|
| 283 |
-
"
|
| 284 |
-
"
|
| 285 |
-
"
|
| 286 |
-
"
|
| 287 |
-
"
|
| 288 |
-
"
|
| 289 |
-
"
|
|
|
|
|
|
|
|
|
|
| 290 |
},
|
| 291 |
{
|
| 292 |
"epoch": 17,
|
| 293 |
-
"step":
|
| 294 |
-
"secs":
|
| 295 |
-
"loss": 0.
|
| 296 |
-
"dE": 0.
|
| 297 |
-
"con": 0.
|
| 298 |
-
"
|
| 299 |
-
"
|
| 300 |
-
"
|
| 301 |
-
"
|
| 302 |
-
"
|
| 303 |
-
"
|
| 304 |
-
"
|
| 305 |
-
"
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
"
|
| 309 |
-
"step": 8892,
|
| 310 |
-
"secs": 1296,
|
| 311 |
-
"loss": 0.12691,
|
| 312 |
-
"dE": 0.04379,
|
| 313 |
-
"con": 0.03646,
|
| 314 |
-
"img": 0.69736,
|
| 315 |
-
"s0_dE": 0.04175,
|
| 316 |
-
"s0_dL": 0.03993,
|
| 317 |
-
"s0_con": 0.02416,
|
| 318 |
-
"img_acc": 0.801,
|
| 319 |
-
"s1_dE": 0.10774,
|
| 320 |
-
"s1_dL": 0.10449,
|
| 321 |
-
"s1_con": 0.24435
|
| 322 |
-
},
|
| 323 |
-
{
|
| 324 |
-
"epoch": 19,
|
| 325 |
-
"step": 9360,
|
| 326 |
-
"secs": 1364,
|
| 327 |
-
"loss": 0.12499,
|
| 328 |
-
"dE": 0.04319,
|
| 329 |
-
"con": 0.036,
|
| 330 |
-
"img": 0.68816,
|
| 331 |
-
"s0_dE": 0.0413,
|
| 332 |
-
"s0_dL": 0.03945,
|
| 333 |
-
"s0_con": 0.02047,
|
| 334 |
-
"img_acc": 0.79813,
|
| 335 |
-
"s1_dE": 0.11372,
|
| 336 |
-
"s1_dL": 0.11037,
|
| 337 |
-
"s1_con": 0.20421
|
| 338 |
-
},
|
| 339 |
-
{
|
| 340 |
-
"epoch": 20,
|
| 341 |
-
"step": 9828,
|
| 342 |
-
"secs": 1432,
|
| 343 |
-
"loss": 0.12434,
|
| 344 |
-
"dE": 0.04334,
|
| 345 |
-
"con": 0.03581,
|
| 346 |
-
"img": 0.67546,
|
| 347 |
-
"s0_dE": 0.0409,
|
| 348 |
-
"s0_dL": 0.03905,
|
| 349 |
-
"s0_con": 0.01907,
|
| 350 |
-
"img_acc": 0.79483,
|
| 351 |
-
"s1_dE": 0.10692,
|
| 352 |
-
"s1_dL": 0.1036,
|
| 353 |
-
"s1_con": 0.22495
|
| 354 |
-
},
|
| 355 |
-
{
|
| 356 |
-
"epoch": 21,
|
| 357 |
-
"step": 10296,
|
| 358 |
-
"secs": 1499,
|
| 359 |
-
"loss": 0.12248,
|
| 360 |
-
"dE": 0.04287,
|
| 361 |
-
"con": 0.03581,
|
| 362 |
-
"img": 0.65995,
|
| 363 |
-
"s0_dE": 0.04105,
|
| 364 |
-
"s0_dL": 0.0392,
|
| 365 |
-
"s0_con": 0.02163,
|
| 366 |
-
"img_acc": 0.7999,
|
| 367 |
-
"s1_dE": 0.10461,
|
| 368 |
-
"s1_dL": 0.10127,
|
| 369 |
-
"s1_con": 0.18494
|
| 370 |
-
},
|
| 371 |
-
{
|
| 372 |
-
"epoch": 22,
|
| 373 |
-
"step": 10764,
|
| 374 |
-
"secs": 1567,
|
| 375 |
-
"loss": 0.12033,
|
| 376 |
-
"dE": 0.04222,
|
| 377 |
-
"con": 0.03395,
|
| 378 |
-
"img": 0.64253,
|
| 379 |
-
"s0_dE": 0.04075,
|
| 380 |
-
"s0_dL": 0.03893,
|
| 381 |
-
"s0_con": 0.02266,
|
| 382 |
-
"img_acc": 0.7999,
|
| 383 |
-
"s1_dE": 0.10178,
|
| 384 |
-
"s1_dL": 0.09855,
|
| 385 |
-
"s1_con": 0.23005
|
| 386 |
-
},
|
| 387 |
-
{
|
| 388 |
-
"epoch": 23,
|
| 389 |
-
"step": 11232,
|
| 390 |
-
"secs": 1635,
|
| 391 |
-
"loss": 0.11905,
|
| 392 |
-
"dE": 0.04192,
|
| 393 |
-
"con": 0.03691,
|
| 394 |
-
"img": 0.63201,
|
| 395 |
-
"s0_dE": 0.04024,
|
| 396 |
-
"s0_dL": 0.03843,
|
| 397 |
-
"s0_con": 0.01664,
|
| 398 |
-
"img_acc": 0.79049,
|
| 399 |
-
"s1_dE": 0.10458,
|
| 400 |
-
"s1_dL": 0.10125,
|
| 401 |
-
"s1_con": 0.1973
|
| 402 |
-
},
|
| 403 |
-
{
|
| 404 |
-
"epoch": 24,
|
| 405 |
-
"step": 11700,
|
| 406 |
-
"secs": 1703,
|
| 407 |
-
"loss": 0.11767,
|
| 408 |
-
"dE": 0.04169,
|
| 409 |
-
"con": 0.03462,
|
| 410 |
-
"img": 0.61761,
|
| 411 |
-
"s0_dE": 0.04043,
|
| 412 |
-
"s0_dL": 0.03861,
|
| 413 |
-
"s0_con": 0.02038,
|
| 414 |
-
"img_acc": 0.79379,
|
| 415 |
-
"s1_dE": 0.1025,
|
| 416 |
-
"s1_dL": 0.09926,
|
| 417 |
-
"s1_con": 0.2135
|
| 418 |
-
},
|
| 419 |
-
{
|
| 420 |
-
"epoch": 25,
|
| 421 |
-
"step": 12168,
|
| 422 |
-
"secs": 1770,
|
| 423 |
-
"loss": 0.11655,
|
| 424 |
-
"dE": 0.04131,
|
| 425 |
-
"con": 0.03564,
|
| 426 |
-
"img": 0.60858,
|
| 427 |
-
"s0_dE": 0.04068,
|
| 428 |
-
"s0_dL": 0.0389,
|
| 429 |
-
"s0_con": 0.01981,
|
| 430 |
-
"img_acc": 0.79715,
|
| 431 |
-
"s1_dE": 0.10372,
|
| 432 |
-
"s1_dL": 0.10038,
|
| 433 |
-
"s1_con": 0.20402
|
| 434 |
-
},
|
| 435 |
-
{
|
| 436 |
-
"epoch": 26,
|
| 437 |
-
"step": 12636,
|
| 438 |
-
"secs": 1838,
|
| 439 |
-
"loss": 0.11502,
|
| 440 |
-
"dE": 0.04106,
|
| 441 |
-
"con": 0.03415,
|
| 442 |
-
"img": 0.59825,
|
| 443 |
-
"s0_dE": 0.04032,
|
| 444 |
-
"s0_dL": 0.03852,
|
| 445 |
-
"s0_con": 0.01906,
|
| 446 |
-
"img_acc": 0.79691,
|
| 447 |
-
"s1_dE": 0.10293,
|
| 448 |
-
"s1_dL": 0.09969,
|
| 449 |
-
"s1_con": 0.22009
|
| 450 |
-
},
|
| 451 |
-
{
|
| 452 |
-
"epoch": 27,
|
| 453 |
-
"step": 13104,
|
| 454 |
-
"secs": 1906,
|
| 455 |
-
"loss": 0.11521,
|
| 456 |
-
"dE": 0.04109,
|
| 457 |
-
"con": 0.03603,
|
| 458 |
-
"img": 0.59315,
|
| 459 |
-
"s0_dE": 0.0404,
|
| 460 |
-
"s0_dL": 0.03861,
|
| 461 |
-
"s0_con": 0.02053,
|
| 462 |
-
"img_acc": 0.79721,
|
| 463 |
-
"s1_dE": 0.10302,
|
| 464 |
-
"s1_dL": 0.09978,
|
| 465 |
-
"s1_con": 0.2078
|
| 466 |
-
},
|
| 467 |
-
{
|
| 468 |
-
"epoch": 28,
|
| 469 |
-
"step": 13572,
|
| 470 |
-
"secs": 1973,
|
| 471 |
-
"loss": 0.11402,
|
| 472 |
-
"dE": 0.04082,
|
| 473 |
-
"con": 0.03683,
|
| 474 |
-
"img": 0.58722,
|
| 475 |
-
"s0_dE": 0.04034,
|
| 476 |
-
"s0_dL": 0.03856,
|
| 477 |
-
"s0_con": 0.01831,
|
| 478 |
-
"img_acc": 0.79098,
|
| 479 |
-
"s1_dE": 0.10383,
|
| 480 |
-
"s1_dL": 0.10053,
|
| 481 |
-
"s1_con": 0.181
|
| 482 |
-
},
|
| 483 |
-
{
|
| 484 |
-
"epoch": 29,
|
| 485 |
-
"step": 14040,
|
| 486 |
-
"secs": 2041,
|
| 487 |
-
"loss": 0.11321,
|
| 488 |
-
"dE": 0.04059,
|
| 489 |
-
"con": 0.03495,
|
| 490 |
-
"img": 0.5803,
|
| 491 |
-
"s0_dE": 0.04018,
|
| 492 |
-
"s0_dL": 0.03841,
|
| 493 |
-
"s0_con": 0.01978,
|
| 494 |
-
"img_acc": 0.79416,
|
| 495 |
-
"s1_dE": 0.10315,
|
| 496 |
-
"s1_dL": 0.09986,
|
| 497 |
-
"s1_con": 0.19971
|
| 498 |
}
|
| 499 |
],
|
| 500 |
-
"best_score": 0.
|
| 501 |
}
|
|
|
|
| 1 |
{
|
| 2 |
"args": {
|
| 3 |
+
"data": "ds_v2.pkl",
|
| 4 |
+
"out": "run_w",
|
| 5 |
"d": 384,
|
| 6 |
"layers": 8,
|
| 7 |
"heads": 6,
|
| 8 |
+
"epochs": 18,
|
| 9 |
"bs": 48,
|
| 10 |
+
"lr": 0.0003,
|
| 11 |
"wd": 0.05,
|
| 12 |
"drop": 0.1,
|
| 13 |
"native_weight": 0.5,
|
| 14 |
"seed": 0,
|
| 15 |
+
"workers": 5,
|
| 16 |
+
"elem_weight": 0.1,
|
| 17 |
+
"role_weight": 0.03,
|
| 18 |
+
"elem_balance": 0.3,
|
| 19 |
+
"init": "v1_best.pt"
|
| 20 |
},
|
| 21 |
+
"params": 16027747,
|
| 22 |
"log": [
|
| 23 |
{
|
| 24 |
"epoch": 0,
|
| 25 |
+
"step": 794,
|
| 26 |
+
"secs": 126,
|
| 27 |
+
"loss": 0.1623,
|
| 28 |
+
"dE": 0.04288,
|
| 29 |
+
"con": 0.03147,
|
| 30 |
+
"elem": 0.37817,
|
| 31 |
+
"e_ok": 0.94195,
|
| 32 |
+
"s0_dE": 0.04166,
|
| 33 |
+
"s0_dL": 0.03983,
|
| 34 |
+
"s0_con": 0.01493,
|
| 35 |
+
"e_acc": 0.87222,
|
| 36 |
+
"e_nll": 0.31791,
|
| 37 |
+
"r_acc": 0.74936,
|
| 38 |
+
"s1_dE": 0.11709,
|
| 39 |
+
"s1_dL": 0.11392,
|
| 40 |
+
"s1_con": 0.29847
|
| 41 |
},
|
| 42 |
{
|
| 43 |
"epoch": 1,
|
| 44 |
+
"step": 1588,
|
| 45 |
+
"secs": 246,
|
| 46 |
+
"loss": 0.14616,
|
| 47 |
+
"dE": 0.044,
|
| 48 |
+
"con": 0.03079,
|
| 49 |
+
"elem": 0.28204,
|
| 50 |
+
"e_ok": 0.94722,
|
| 51 |
+
"s0_dE": 0.04111,
|
| 52 |
+
"s0_dL": 0.03933,
|
| 53 |
+
"s0_con": 0.01486,
|
| 54 |
+
"e_acc": 0.88492,
|
| 55 |
+
"e_nll": 0.29707,
|
| 56 |
+
"r_acc": 0.76823,
|
| 57 |
+
"s1_dE": 0.11303,
|
| 58 |
+
"s1_dL": 0.10971,
|
| 59 |
+
"s1_con": 0.29463
|
| 60 |
},
|
| 61 |
{
|
| 62 |
"epoch": 2,
|
| 63 |
+
"step": 2382,
|
| 64 |
+
"secs": 371,
|
| 65 |
+
"loss": 0.14248,
|
| 66 |
+
"dE": 0.0433,
|
| 67 |
+
"con": 0.02879,
|
| 68 |
+
"elem": 0.2718,
|
| 69 |
+
"e_ok": 0.94794,
|
| 70 |
+
"s0_dE": 0.04045,
|
| 71 |
+
"s0_dL": 0.03863,
|
| 72 |
+
"s0_con": 0.01225,
|
| 73 |
+
"e_acc": 0.88913,
|
| 74 |
+
"e_nll": 0.28905,
|
| 75 |
+
"r_acc": 0.77894,
|
| 76 |
+
"s1_dE": 0.11051,
|
| 77 |
+
"s1_dL": 0.1072,
|
| 78 |
+
"s1_con": 0.26784
|
| 79 |
},
|
| 80 |
{
|
| 81 |
"epoch": 3,
|
| 82 |
+
"step": 3176,
|
| 83 |
+
"secs": 481,
|
| 84 |
+
"loss": 0.13929,
|
| 85 |
+
"dE": 0.04303,
|
| 86 |
+
"con": 0.0288,
|
| 87 |
+
"elem": 0.26188,
|
| 88 |
+
"e_ok": 0.94794,
|
| 89 |
+
"s0_dE": 0.04085,
|
| 90 |
+
"s0_dL": 0.03902,
|
| 91 |
+
"s0_con": 0.01576,
|
| 92 |
+
"e_acc": 0.88403,
|
| 93 |
+
"e_nll": 0.29122,
|
| 94 |
+
"r_acc": 0.78404,
|
| 95 |
+
"s1_dE": 0.12151,
|
| 96 |
+
"s1_dL": 0.118,
|
| 97 |
+
"s1_con": 0.13395
|
| 98 |
},
|
| 99 |
{
|
| 100 |
"epoch": 4,
|
| 101 |
+
"step": 3970,
|
| 102 |
+
"secs": 592,
|
| 103 |
+
"loss": 0.13736,
|
| 104 |
+
"dE": 0.04279,
|
| 105 |
+
"con": 0.029,
|
| 106 |
+
"elem": 0.25577,
|
| 107 |
+
"e_ok": 0.94722,
|
| 108 |
+
"s0_dE": 0.0397,
|
| 109 |
+
"s0_dL": 0.03794,
|
| 110 |
+
"s0_con": 0.01431,
|
| 111 |
+
"e_acc": 0.88994,
|
| 112 |
+
"e_nll": 0.29091,
|
| 113 |
+
"r_acc": 0.78723,
|
| 114 |
+
"s1_dE": 0.11205,
|
| 115 |
+
"s1_dL": 0.10875,
|
| 116 |
+
"s1_con": 0.24692
|
| 117 |
},
|
| 118 |
{
|
| 119 |
"epoch": 5,
|
| 120 |
+
"step": 4764,
|
| 121 |
+
"secs": 705,
|
| 122 |
+
"loss": 0.13486,
|
| 123 |
+
"dE": 0.04215,
|
| 124 |
+
"con": 0.0303,
|
| 125 |
+
"elem": 0.24835,
|
| 126 |
+
"e_ok": 0.95037,
|
| 127 |
+
"s0_dE": 0.03995,
|
| 128 |
+
"s0_dL": 0.03818,
|
| 129 |
+
"s0_con": 0.01163,
|
| 130 |
+
"e_acc": 0.89342,
|
| 131 |
+
"e_nll": 0.2904,
|
| 132 |
+
"r_acc": 0.78489,
|
| 133 |
+
"s1_dE": 0.10674,
|
| 134 |
+
"s1_dL": 0.1034,
|
| 135 |
+
"s1_con": 0.21942
|
| 136 |
},
|
| 137 |
{
|
| 138 |
"epoch": 6,
|
| 139 |
+
"step": 5558,
|
| 140 |
+
"secs": 816,
|
| 141 |
+
"loss": 0.13262,
|
| 142 |
+
"dE": 0.04164,
|
| 143 |
+
"con": 0.02965,
|
| 144 |
+
"elem": 0.24241,
|
| 145 |
+
"e_ok": 0.94998,
|
| 146 |
+
"s0_dE": 0.03888,
|
| 147 |
+
"s0_dL": 0.03715,
|
| 148 |
+
"s0_con": 0.01823,
|
| 149 |
+
"e_acc": 0.89142,
|
| 150 |
+
"e_nll": 0.29542,
|
| 151 |
+
"r_acc": 0.78795,
|
| 152 |
+
"s1_dE": 0.10818,
|
| 153 |
+
"s1_dL": 0.10477,
|
| 154 |
+
"s1_con": 0.13355
|
| 155 |
},
|
| 156 |
{
|
| 157 |
"epoch": 7,
|
| 158 |
+
"step": 6352,
|
| 159 |
+
"secs": 927,
|
| 160 |
+
"loss": 0.1297,
|
| 161 |
+
"dE": 0.04077,
|
| 162 |
+
"con": 0.02959,
|
| 163 |
+
"elem": 0.23773,
|
| 164 |
+
"e_ok": 0.94318,
|
| 165 |
+
"s0_dE": 0.03864,
|
| 166 |
+
"s0_dL": 0.03688,
|
| 167 |
+
"s0_con": 0.01412,
|
| 168 |
+
"e_acc": 0.88339,
|
| 169 |
+
"e_nll": 0.29541,
|
| 170 |
+
"r_acc": 0.7865,
|
| 171 |
+
"s1_dE": 0.10767,
|
| 172 |
+
"s1_dL": 0.10424,
|
| 173 |
+
"s1_con": 0.15202
|
| 174 |
},
|
| 175 |
{
|
| 176 |
"epoch": 8,
|
| 177 |
+
"step": 7146,
|
| 178 |
+
"secs": 1038,
|
| 179 |
+
"loss": 0.12712,
|
| 180 |
+
"dE": 0.0403,
|
| 181 |
+
"con": 0.02936,
|
| 182 |
+
"elem": 0.22914,
|
| 183 |
+
"e_ok": 0.94752,
|
| 184 |
+
"s0_dE": 0.03815,
|
| 185 |
+
"s0_dL": 0.03647,
|
| 186 |
+
"s0_con": 0.01527,
|
| 187 |
+
"e_acc": 0.89185,
|
| 188 |
+
"e_nll": 0.30129,
|
| 189 |
+
"r_acc": 0.78701,
|
| 190 |
+
"s1_dE": 0.10567,
|
| 191 |
+
"s1_dL": 0.10227,
|
| 192 |
+
"s1_con": 0.20398
|
| 193 |
},
|
| 194 |
{
|
| 195 |
"epoch": 9,
|
| 196 |
+
"step": 7940,
|
| 197 |
+
"secs": 1148,
|
| 198 |
+
"loss": 0.12518,
|
| 199 |
+
"dE": 0.0398,
|
| 200 |
+
"con": 0.0268,
|
| 201 |
+
"elem": 0.22563,
|
| 202 |
+
"e_ok": 0.94726,
|
| 203 |
+
"s0_dE": 0.03823,
|
| 204 |
+
"s0_dL": 0.03653,
|
| 205 |
+
"s0_con": 0.01809,
|
| 206 |
+
"e_acc": 0.88853,
|
| 207 |
+
"e_nll": 0.30672,
|
| 208 |
+
"r_acc": 0.78863,
|
| 209 |
+
"s1_dE": 0.10322,
|
| 210 |
+
"s1_dL": 0.09977,
|
| 211 |
+
"s1_con": 0.15727
|
| 212 |
},
|
| 213 |
{
|
| 214 |
"epoch": 10,
|
| 215 |
+
"step": 8734,
|
| 216 |
+
"secs": 1257,
|
| 217 |
+
"loss": 0.12307,
|
| 218 |
+
"dE": 0.03913,
|
| 219 |
+
"con": 0.02326,
|
| 220 |
+
"elem": 0.22178,
|
| 221 |
+
"e_ok": 0.94556,
|
| 222 |
+
"s0_dE": 0.03785,
|
| 223 |
+
"s0_dL": 0.03619,
|
| 224 |
+
"s0_con": 0.02286,
|
| 225 |
+
"e_acc": 0.88683,
|
| 226 |
+
"e_nll": 0.31044,
|
| 227 |
+
"r_acc": 0.78472,
|
| 228 |
+
"s1_dE": 0.0992,
|
| 229 |
+
"s1_dL": 0.09587,
|
| 230 |
+
"s1_con": 0.19078
|
| 231 |
},
|
| 232 |
{
|
| 233 |
"epoch": 11,
|
| 234 |
+
"step": 9528,
|
| 235 |
+
"secs": 1369,
|
| 236 |
+
"loss": 0.1209,
|
| 237 |
+
"dE": 0.03851,
|
| 238 |
+
"con": 0.02494,
|
| 239 |
+
"elem": 0.21819,
|
| 240 |
+
"e_ok": 0.94488,
|
| 241 |
+
"s0_dE": 0.03751,
|
| 242 |
+
"s0_dL": 0.03585,
|
| 243 |
+
"s0_con": 0.01708,
|
| 244 |
+
"e_acc": 0.88586,
|
| 245 |
+
"e_nll": 0.31137,
|
| 246 |
+
"r_acc": 0.79003,
|
| 247 |
+
"s1_dE": 0.09999,
|
| 248 |
+
"s1_dL": 0.09671,
|
| 249 |
+
"s1_con": 0.22857
|
| 250 |
},
|
| 251 |
{
|
| 252 |
"epoch": 12,
|
| 253 |
+
"step": 10322,
|
| 254 |
+
"secs": 1480,
|
| 255 |
+
"loss": 0.11961,
|
| 256 |
+
"dE": 0.03832,
|
| 257 |
+
"con": 0.02343,
|
| 258 |
+
"elem": 0.21455,
|
| 259 |
+
"e_ok": 0.94433,
|
| 260 |
+
"s0_dE": 0.03703,
|
| 261 |
+
"s0_dL": 0.03539,
|
| 262 |
+
"s0_con": 0.01577,
|
| 263 |
+
"e_acc": 0.88654,
|
| 264 |
+
"e_nll": 0.32437,
|
| 265 |
+
"r_acc": 0.78799,
|
| 266 |
+
"s1_dE": 0.10176,
|
| 267 |
+
"s1_dL": 0.09848,
|
| 268 |
+
"s1_con": 0.18905
|
| 269 |
},
|
| 270 |
{
|
| 271 |
"epoch": 13,
|
| 272 |
+
"step": 11116,
|
| 273 |
+
"secs": 1602,
|
| 274 |
+
"loss": 0.11784,
|
| 275 |
+
"dE": 0.03781,
|
| 276 |
+
"con": 0.02268,
|
| 277 |
+
"elem": 0.21099,
|
| 278 |
+
"e_ok": 0.94429,
|
| 279 |
+
"s0_dE": 0.03681,
|
| 280 |
+
"s0_dL": 0.03517,
|
| 281 |
+
"s0_con": 0.01501,
|
| 282 |
+
"e_acc": 0.88403,
|
| 283 |
+
"e_nll": 0.32853,
|
| 284 |
+
"r_acc": 0.78867,
|
| 285 |
+
"s1_dE": 0.09691,
|
| 286 |
+
"s1_dL": 0.09349,
|
| 287 |
+
"s1_con": 0.1905
|
| 288 |
},
|
| 289 |
{
|
| 290 |
"epoch": 14,
|
| 291 |
+
"step": 11910,
|
| 292 |
+
"secs": 1713,
|
| 293 |
+
"loss": 0.11638,
|
| 294 |
+
"dE": 0.03747,
|
| 295 |
+
"con": 0.02049,
|
| 296 |
+
"elem": 0.20897,
|
| 297 |
+
"e_ok": 0.94437,
|
| 298 |
+
"s0_dE": 0.03681,
|
| 299 |
+
"s0_dL": 0.03517,
|
| 300 |
+
"s0_con": 0.01695,
|
| 301 |
+
"e_acc": 0.88599,
|
| 302 |
+
"e_nll": 0.32894,
|
| 303 |
+
"r_acc": 0.78876,
|
| 304 |
+
"s1_dE": 0.10008,
|
| 305 |
+
"s1_dL": 0.09666,
|
| 306 |
+
"s1_con": 0.17253
|
| 307 |
},
|
| 308 |
{
|
| 309 |
"epoch": 15,
|
| 310 |
+
"step": 12704,
|
| 311 |
+
"secs": 1824,
|
| 312 |
+
"loss": 0.11504,
|
| 313 |
+
"dE": 0.03695,
|
| 314 |
+
"con": 0.02021,
|
| 315 |
+
"elem": 0.20677,
|
| 316 |
+
"e_ok": 0.94463,
|
| 317 |
+
"s0_dE": 0.03671,
|
| 318 |
+
"s0_dL": 0.03509,
|
| 319 |
+
"s0_con": 0.01521,
|
| 320 |
+
"e_acc": 0.88645,
|
| 321 |
+
"e_nll": 0.33489,
|
| 322 |
+
"r_acc": 0.78799,
|
| 323 |
+
"s1_dE": 0.09822,
|
| 324 |
+
"s1_dL": 0.09477,
|
| 325 |
+
"s1_con": 0.17304
|
| 326 |
},
|
| 327 |
{
|
| 328 |
"epoch": 16,
|
| 329 |
+
"step": 13498,
|
| 330 |
+
"secs": 1934,
|
| 331 |
+
"loss": 0.1141,
|
| 332 |
+
"dE": 0.03677,
|
| 333 |
+
"con": 0.02107,
|
| 334 |
+
"elem": 0.20373,
|
| 335 |
+
"e_ok": 0.94208,
|
| 336 |
+
"s0_dE": 0.03658,
|
| 337 |
+
"s0_dL": 0.03496,
|
| 338 |
+
"s0_con": 0.01736,
|
| 339 |
+
"e_acc": 0.88369,
|
| 340 |
+
"e_nll": 0.33225,
|
| 341 |
+
"r_acc": 0.78519,
|
| 342 |
+
"s1_dE": 0.09748,
|
| 343 |
+
"s1_dL": 0.09415,
|
| 344 |
+
"s1_con": 0.19973
|
| 345 |
},
|
| 346 |
{
|
| 347 |
"epoch": 17,
|
| 348 |
+
"step": 14292,
|
| 349 |
+
"secs": 2053,
|
| 350 |
+
"loss": 0.11284,
|
| 351 |
+
"dE": 0.03617,
|
| 352 |
+
"con": 0.02163,
|
| 353 |
+
"elem": 0.20522,
|
| 354 |
+
"e_ok": 0.94378,
|
| 355 |
+
"s0_dE": 0.03654,
|
| 356 |
+
"s0_dL": 0.03492,
|
| 357 |
+
"s0_con": 0.01713,
|
| 358 |
+
"e_acc": 0.88535,
|
| 359 |
+
"e_nll": 0.34029,
|
| 360 |
+
"r_acc": 0.78514,
|
| 361 |
+
"s1_dE": 0.09664,
|
| 362 |
+
"s1_dL": 0.09326,
|
| 363 |
+
"s1_con": 0.20492
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 364 |
}
|
| 365 |
],
|
| 366 |
+
"best_score": 0.0686828651548906
|
| 367 |
}
|
runtime/darkreader-nadir.js
CHANGED
|
@@ -3229,6 +3229,10 @@
|
|
| 3229 |
}
|
| 3230 |
: null;
|
| 3231 |
return {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3232 |
isDark: darkPixelsCount / opaquePixelsCount >= DARK_IMAGE_THRESHOLD,
|
| 3233 |
isLight:
|
| 3234 |
lightPixelsCount / opaquePixelsCount >= LIGHT_IMAGE_THRESHOLD,
|
|
@@ -3973,7 +3977,7 @@
|
|
| 3973 |
}
|
| 3974 |
}
|
| 3975 |
if (imageDetails) {
|
| 3976 |
-
const bgImageValue = getBgImageValue(
|
| 3977 |
imageDetails,
|
| 3978 |
theme
|
| 3979 |
);
|
|
@@ -4005,7 +4009,7 @@
|
|
| 4005 |
size.includes("100%");
|
| 4006 |
return !isTiled && !isStretched;
|
| 4007 |
};
|
| 4008 |
-
const getBgImageValue = (imageDetails, theme) => {
|
| 4009 |
const {
|
| 4010 |
isDark,
|
| 4011 |
isLight,
|
|
@@ -4022,7 +4026,9 @@
|
|
| 4022 |
if (nadirImageHook && theme.mode === 1) {
|
| 4023 |
let decision = null;
|
| 4024 |
try {
|
| 4025 |
-
|
|
|
|
|
|
|
| 4026 |
isDark,
|
| 4027 |
isLight,
|
| 4028 |
isTransparent,
|
|
@@ -4035,7 +4041,8 @@
|
|
| 4035 |
).toLowerCase(),
|
| 4036 |
repeat: (
|
| 4037 |
rule.style.backgroundRepeat || ""
|
| 4038 |
-
).toLowerCase()
|
|
|
|
| 4039 |
});
|
| 4040 |
} catch (err) {
|
| 4041 |
decision = null;
|
|
@@ -4043,6 +4050,14 @@
|
|
| 4043 |
if (decision === "none") {
|
| 4044 |
return "none";
|
| 4045 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4046 |
if (solidColor) {
|
| 4047 |
const darkColor = modifyBackgroundColor(
|
| 4048 |
solidColor,
|
|
@@ -6499,8 +6514,14 @@
|
|
| 6499 |
);
|
| 6500 |
const dataURL = `data:image/svg+xml;base64,${btoa(svgString)}`;
|
| 6501 |
getImageDetails(dataURL).then((details) => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6502 |
if (
|
| 6503 |
-
(
|
| 6504 |
(details.isLarge &&
|
| 6505 |
details.isLight &&
|
| 6506 |
!details.isTransparent)
|
|
|
|
| 3229 |
}
|
| 3230 |
: null;
|
| 3231 |
return {
|
| 3232 |
+
// Nadir: dark vs near-white pixels (a logo's white halo or plate is not ink; colours and anti-aliased
|
| 3233 |
+
// edges are neither), and the dark share of all opaque pixels
|
| 3234 |
+
inkDarkShare: darkPixelsCount / Math.max(1, darkPixelsCount + lightPixelsCount),
|
| 3235 |
+
darkShare: darkPixelsCount / Math.max(1, opaquePixelsCount),
|
| 3236 |
isDark: darkPixelsCount / opaquePixelsCount >= DARK_IMAGE_THRESHOLD,
|
| 3237 |
isLight:
|
| 3238 |
lightPixelsCount / opaquePixelsCount >= LIGHT_IMAGE_THRESHOLD,
|
|
|
|
| 3977 |
}
|
| 3978 |
}
|
| 3979 |
if (imageDetails) {
|
| 3980 |
+
const bgImageValue = await getBgImageValue(
|
| 3981 |
imageDetails,
|
| 3982 |
theme
|
| 3983 |
);
|
|
|
|
| 4009 |
size.includes("100%");
|
| 4010 |
return !isTiled && !isStretched;
|
| 4011 |
};
|
| 4012 |
+
const getBgImageValue = async (imageDetails, theme) => {
|
| 4013 |
const {
|
| 4014 |
isDark,
|
| 4015 |
isLight,
|
|
|
|
| 4026 |
if (nadirImageHook && theme.mode === 1) {
|
| 4027 |
let decision = null;
|
| 4028 |
try {
|
| 4029 |
+
// Nadir: the element head decides; for an image it has not seen yet the hook may return a
|
| 4030 |
+
// promise (measure + classify), or an adapted copy as {url}
|
| 4031 |
+
decision = await nadirImageHook(imageDetails.src, {
|
| 4032 |
isDark,
|
| 4033 |
isLight,
|
| 4034 |
isTransparent,
|
|
|
|
| 4041 |
).toLowerCase(),
|
| 4042 |
repeat: (
|
| 4043 |
rule.style.backgroundRepeat || ""
|
| 4044 |
+
).toLowerCase(),
|
| 4045 |
+
selector: rule.selectorText || ""
|
| 4046 |
});
|
| 4047 |
} catch (err) {
|
| 4048 |
decision = null;
|
|
|
|
| 4050 |
if (decision === "none") {
|
| 4051 |
return "none";
|
| 4052 |
}
|
| 4053 |
+
if (decision && typeof decision === "object" && decision.url) {
|
| 4054 |
+
return `url("${decision.url}")`;
|
| 4055 |
+
}
|
| 4056 |
+
if (decision === "keep") {
|
| 4057 |
+
// the element head chose to show it as it is (the solid-colour test below misreads
|
| 4058 |
+
// SVGs without an intrinsic size, e.g. a blue tile with a white glyph)
|
| 4059 |
+
return null;
|
| 4060 |
+
}
|
| 4061 |
if (solidColor) {
|
| 4062 |
const darkColor = modifyBackgroundColor(
|
| 4063 |
solidColor,
|
|
|
|
| 6514 |
);
|
| 6515 |
const dataURL = `data:image/svg+xml;base64,${btoa(svgString)}`;
|
| 6516 |
getImageDetails(dataURL).then((details) => {
|
| 6517 |
+
// Nadir: dark letters with a white halo or on a white plate are a dark logo too
|
| 6518 |
+
const darkInk =
|
| 6519 |
+
details.isDark ||
|
| 6520 |
+
(globalThis.__nadirFillSide &&
|
| 6521 |
+
details.inkDarkShare >= 0.55 &&
|
| 6522 |
+
details.darkShare >= 0.3);
|
| 6523 |
if (
|
| 6524 |
+
(darkInk && details.isTransparent) ||
|
| 6525 |
(details.isLarge &&
|
| 6526 |
details.isLight &&
|
| 6527 |
!details.isTransparent)
|
runtime/extract.js
CHANGED
|
@@ -228,9 +228,25 @@
|
|
| 228 |
return t;
|
| 229 |
}
|
| 230 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 231 |
async function extract(opts) {
|
| 232 |
opts = Object.assign({maxElements: 5000, maxRules: 40000, maxTokens: 384, maxImages: 48, describe: false,
|
| 233 |
-
thumbs: false, thumbSize: 32, bigThumbSize: 0, fetchImage: null, pseudo: true, time: true
|
|
|
|
| 234 |
const t0 = performance.now();
|
| 235 |
const vw = innerWidth, vh = innerHeight;
|
| 236 |
const sx = scrollX, sy = scrollY;
|
|
@@ -327,6 +343,23 @@
|
|
| 327 |
const rootRec = {surf: rootSurface, surfTok: rootTok, imgBg: false, region: 0, depth: 0, fixed: false};
|
| 328 |
const images = [];
|
| 329 |
const svgRoots = [];
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 330 |
let visibleCount = 0;
|
| 331 |
|
| 332 |
const ec = opts.elemColors ? [] : null;
|
|
@@ -411,7 +444,7 @@
|
|
| 411 |
}
|
| 412 |
const urls = [...bgImg.matchAll(/url\(\s*["']?([^"')]+)["']?\s*\)/g)].map(m => m[1]);
|
| 413 |
if (urls.length && vis && area > 64) {
|
| 414 |
-
images.push({el, kind: 'bg', src: urls[0], x, y, w, h, area, area1, rec});
|
| 415 |
if (area > 0.02 * vw * vh) rec.imgBg = true;
|
| 416 |
}
|
| 417 |
}
|
|
@@ -509,6 +542,10 @@
|
|
| 509 |
const href = el.getAttribute('href') || el.getAttribute('xlink:href');
|
| 510 |
if (href) images.push({el, kind: 'svgimage', src: new URL(href, location.href).href, x, y, w, h, area, area1, rec});
|
| 511 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 512 |
// pseudo elements
|
| 513 |
if (opts.pseudo && vis && !isSvg && els.length < 20000) {
|
| 514 |
for (const pseudo of ['::before', '::after']) {
|
|
@@ -518,6 +555,7 @@
|
|
| 518 |
const pb = parseColor(ps.backgroundColor);
|
| 519 |
const pw = parseFloat(ps.width) || 0, ph = parseFloat(ps.height) || 0;
|
| 520 |
const parea = Math.min(area, pw * ph);
|
|
|
|
| 521 |
if (pb) { const t = tok(R.bg, pb); flags(t); t.area += parea; addRel('bb', t, rec.surfTok, parea); }
|
| 522 |
if (ct !== '""' && ct !== "''") {
|
| 523 |
const pc = parseColor(ps.color);
|
|
@@ -637,7 +675,7 @@
|
|
| 637 |
if (imgOut.length >= opts.maxImages) break;
|
| 638 |
if (im.kind !== 'svg' && (!im.src || im.src.startsWith('data:image/gif;base64,R0lGOD'))) continue;
|
| 639 |
if (im.kind === 'svg') { im.src = 'svg:' + svgCount++; if (opts.markSvg) im.el.setAttribute('data-nadir-svg', im.src); }
|
| 640 |
-
const key = im.src;
|
| 641 |
if (imgSeen.has(key)) { const o = imgOut[imgSeen.get(key)]; o.count++; o.areaSum += im.area; continue; }
|
| 642 |
const el = im.el;
|
| 643 |
const a = im.rec.link;
|
|
@@ -647,12 +685,13 @@
|
|
| 647 |
const meta = im.kind === 'svg' ? (im.svgMeta || '') : ((el.getAttribute && (el.getAttribute('alt') || '')) + ' ' + (typeof el.className === 'string' ? el.className : '') + ' ' + (el.id || '') + ' ' + (im.src.split('?')[0].split('/').pop() || '')).toLowerCase();
|
| 648 |
const ext = im.kind === 'svg' ? 'svg' : (im.src.split('?')[0].match(/\.(svg|png|jpe?g|webp|gif|avif|ico)$/i) || [, ''])[1].toLowerCase();
|
| 649 |
const under = oklab(im.rec.surf);
|
| 650 |
-
const o = {src: im.src.slice(0,
|
| 651 |
nw: el.naturalWidth || 0, nh: el.naturalHeight || 0, homeLink, inLink: im.rec.inLink ? 1 : 0, header: (im.rec.region & 3) ? 1 : 0, footer: (im.rec.region & 4) ? 1 : 0,
|
| 652 |
logo: /logo|brand|wordmark/.test(meta) ? 1 : 0, icon: /icon|ico\b|glyph|arrow|chevron|caret|svg/.test(meta) ? 1 : 0, avatar: /avatar|profile|user/.test(meta) ? 1 : 0,
|
| 653 |
ext, underTok: im.rec.surfTok && im.rec.surfTok.i >= 0 ? im.rec.surfTok.i : -1, under,
|
| 654 |
surf: im.rec.surf.slice(0, 3).map(Math.round), vy: Math.round(im.y - sy), vx: Math.round(im.x - sx), useExt: im.useExt ? 1 : 0,
|
| 655 |
alt: opts.describe ? ((el.getAttribute && el.getAttribute('alt')) || '').slice(0, 60) : undefined,
|
|
|
|
| 656 |
stats: null, thumb: null};
|
| 657 |
if (im.kind === 'img' && el.complete === false) o.loading = 1;
|
| 658 |
imgSeen.set(key, imgOut.length);
|
|
@@ -661,7 +700,7 @@
|
|
| 661 |
if (opts.thumbs) await imageStats(imgOut, opts);
|
| 662 |
for (const o of imgOut) o.f = imageFeatures(o, vw, vh, docH);
|
| 663 |
const res = {
|
| 664 |
-
v:
|
| 665 |
scheme: schemeInfo(sheetScan), darkness, canvasDark,
|
| 666 |
tokens: out, rel: relOut, images: imgOut,
|
| 667 |
ec: ec || undefined,
|
|
@@ -714,9 +753,21 @@
|
|
| 714 |
try {
|
| 715 |
const img = await loadImage(o.src, opts.fetchImage);
|
| 716 |
if (!img) return;
|
| 717 |
-
|
| 718 |
o.nw = o.nw || IW; o.nh = o.nh || IH;
|
| 719 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 720 |
if (img.close) img.close();
|
| 721 |
} catch (e) { o.err = String(e).slice(0, 80); }
|
| 722 |
});
|
|
@@ -769,6 +820,7 @@
|
|
| 769 |
o.under ? o.under[0] : 1, o.under ? o.under[1] * 4 : 0, o.under ? o.under[2] * 4 : 0,
|
| 770 |
o.stats ? 1 : 0, s.transp || 0, s.semi || 0, s.meanL || 0, s.stdL || 0, (s.meanC || 0) * 4, s.white || 0, s.black || 0, s.ringA || 0, s.ringL || 0, s.colors || 0,
|
| 771 |
o.kind === 'svg' ? 1 : 0, o.useExt || 0,
|
|
|
|
| 772 |
]);
|
| 773 |
}
|
| 774 |
const sanitize = (f) => f.map((v) => (Number.isFinite(+v) ? +v : 0));
|
|
@@ -817,6 +869,59 @@
|
|
| 817 |
statsFromSource(o, c, c.width, c.height, opts);
|
| 818 |
}
|
| 819 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 820 |
globalThis.__nadirImageStats = imageStatsStandalone;
|
| 821 |
globalThis.__nadirImageFeatures = imageFeatures;
|
| 822 |
globalThis.__nadirExtract = extract;
|
|
|
|
| 228 |
return t;
|
| 229 |
}
|
| 230 |
|
| 231 |
+
// The part of a CSS background image that is shown, in CSS px: [x, y, w, h, displayed image w, h] (0 = natural size).
|
| 232 |
+
// Only plain px positions/sizes (icon sprites); keyword/percentage layouts show the whole image.
|
| 233 |
+
function bgCrop(cs, w, h) {
|
| 234 |
+
const pos = (cs.backgroundPosition || '').split(',')[0].trim().split(/\s+/);
|
| 235 |
+
const size = (cs.backgroundSize || 'auto').split(',')[0].trim().split(/\s+/);
|
| 236 |
+
if (pos.length !== 2 || !pos.every(v => /^-?[\d.]+px$/.test(v))) return null;
|
| 237 |
+
const px = parseFloat(pos[0]), py = parseFloat(pos[1]);
|
| 238 |
+
let sw = 0, sh = 0;
|
| 239 |
+
if (size.length === 2 && size.every(v => /^[\d.]+px$/.test(v))) { sw = parseFloat(size[0]); sh = parseFloat(size[1]); }
|
| 240 |
+
else if (size.length === 1 && /^[\d.]+px$/.test(size[0])) { sw = parseFloat(size[0]); sh = -1; } // height follows the aspect ratio
|
| 241 |
+
else if (!(size.length === 1 && size[0] === 'auto') && !(size.length === 2 && size[0] === 'auto' && size[1] === 'auto')) return null;
|
| 242 |
+
if (px >= 0 && py >= 0) return null; // drawn from its corner: the whole image (or a texture) is shown
|
| 243 |
+
return [Math.round(-px), Math.round(-py), Math.round(w), Math.round(h), sw, sh];
|
| 244 |
+
}
|
| 245 |
+
|
| 246 |
async function extract(opts) {
|
| 247 |
opts = Object.assign({maxElements: 5000, maxRules: 40000, maxTokens: 384, maxImages: 48, describe: false,
|
| 248 |
+
thumbs: false, thumbSize: 32, bigThumbSize: 0, fetchImage: null, pseudo: true, time: true,
|
| 249 |
+
srcMax: 600}, opts || {}); // crawl records keep 600 chars of a URL; the extension needs whole data: URLs to decode them
|
| 250 |
const t0 = performance.now();
|
| 251 |
const vw = innerWidth, vh = innerHeight;
|
| 252 |
const sx = scrollX, sy = scrollY;
|
|
|
|
| 343 |
const rootRec = {surf: rootSurface, surfTok: rootTok, imgBg: false, region: 0, depth: 0, fixed: false};
|
| 344 |
const images = [];
|
| 345 |
const svgRoots = [];
|
| 346 |
+
// an image drawn with `content: url(...)` in ::before/::after (icon fonts' image cousins, sprite icons)
|
| 347 |
+
function pseudoImage(el, parent, pseudo, ct, rect, x, y, area1, rec, vis) {
|
| 348 |
+
const pu = ct && /^url\(\s*["']?([^"')]+)["']?\s*\)/.exec(ct);
|
| 349 |
+
if (!pu) return;
|
| 350 |
+
let bx = x, by = y, bw = rect.width, bh = rect.height, cropPx = null;
|
| 351 |
+
const pe = parent && !(parent instanceof SVGElement) ? parent : null;
|
| 352 |
+
if ((bw > 72 || bh > 72) && pe) { // one icon of a sprite sheet: the element is shifted inside a small clipping box
|
| 353 |
+
const prc = pe.getBoundingClientRect();
|
| 354 |
+
if (prc.width <= 72 && prc.height <= 72 && prc.width >= 4 && prc.height >= 4 && getComputedStyle(pe).overflow !== 'visible') {
|
| 355 |
+
cropPx = [Math.round(prc.left + sx - x), Math.round(prc.top + sy - y), Math.round(prc.width), Math.round(prc.height), 0, 0];
|
| 356 |
+
bx = prc.left + sx; by = prc.top + sy; bw = prc.width; bh = prc.height;
|
| 357 |
+
}
|
| 358 |
+
}
|
| 359 |
+
if (!cropPx && !vis) return;
|
| 360 |
+
if (bw * bh <= 16 || bw > 400 || bh > 400) return;
|
| 361 |
+
try { images.push({el, kind: 'pseudo', src: new URL(pu[1], location.href).href, x: bx, y: by, w: bw, h: bh, area: bw * bh, area1: Math.min(area1 || bw * bh, bw * bh), rec, cropPx, pseudo}); } catch (e) {}
|
| 362 |
+
}
|
| 363 |
let visibleCount = 0;
|
| 364 |
|
| 365 |
const ec = opts.elemColors ? [] : null;
|
|
|
|
| 444 |
}
|
| 445 |
const urls = [...bgImg.matchAll(/url\(\s*["']?([^"')]+)["']?\s*\)/g)].map(m => m[1]);
|
| 446 |
if (urls.length && vis && area > 64) {
|
| 447 |
+
images.push({el, kind: 'bg', src: urls[0], x, y, w, h, area, area1, rec, cropPx: bgCrop(cs, w, h)});
|
| 448 |
if (area > 0.02 * vw * vh) rec.imgBg = true;
|
| 449 |
}
|
| 450 |
}
|
|
|
|
| 542 |
const href = el.getAttribute('href') || el.getAttribute('xlink:href');
|
| 543 |
if (href) images.push({el, kind: 'svgimage', src: new URL(href, location.href).href, x, y, w, h, area, area1, rec});
|
| 544 |
}
|
| 545 |
+
// sprite-sheet icons whose (huge, shifted) element box counts as off-page: only the clipped part shows
|
| 546 |
+
if (opts.pseudo && !vis && !hidden && !isSvg && (rect.width > 72 || rect.height > 72)) {
|
| 547 |
+
for (const pseudo of ['::before', '::after']) pseudoImage(el, parent, pseudo, getComputedStyle(el, pseudo).content, rect, x, y, area1, rec, false);
|
| 548 |
+
}
|
| 549 |
// pseudo elements
|
| 550 |
if (opts.pseudo && vis && !isSvg && els.length < 20000) {
|
| 551 |
for (const pseudo of ['::before', '::after']) {
|
|
|
|
| 555 |
const pb = parseColor(ps.backgroundColor);
|
| 556 |
const pw = parseFloat(ps.width) || 0, ph = parseFloat(ps.height) || 0;
|
| 557 |
const parea = Math.min(area, pw * ph);
|
| 558 |
+
pseudoImage(el, parent, pseudo, ct, rect, x, y, area1, rec, true);
|
| 559 |
if (pb) { const t = tok(R.bg, pb); flags(t); t.area += parea; addRel('bb', t, rec.surfTok, parea); }
|
| 560 |
if (ct !== '""' && ct !== "''") {
|
| 561 |
const pc = parseColor(ps.color);
|
|
|
|
| 675 |
if (imgOut.length >= opts.maxImages) break;
|
| 676 |
if (im.kind !== 'svg' && (!im.src || im.src.startsWith('data:image/gif;base64,R0lGOD'))) continue;
|
| 677 |
if (im.kind === 'svg') { im.src = 'svg:' + svgCount++; if (opts.markSvg) im.el.setAttribute('data-nadir-svg', im.src); }
|
| 678 |
+
const key = im.src + (im.cropPx ? '#nadir-crop=' + im.cropPx.join(',') : '');
|
| 679 |
if (imgSeen.has(key)) { const o = imgOut[imgSeen.get(key)]; o.count++; o.areaSum += im.area; continue; }
|
| 680 |
const el = im.el;
|
| 681 |
const a = im.rec.link;
|
|
|
|
| 685 |
const meta = im.kind === 'svg' ? (im.svgMeta || '') : ((el.getAttribute && (el.getAttribute('alt') || '')) + ' ' + (typeof el.className === 'string' ? el.className : '') + ' ' + (el.id || '') + ' ' + (im.src.split('?')[0].split('/').pop() || '')).toLowerCase();
|
| 686 |
const ext = im.kind === 'svg' ? 'svg' : (im.src.split('?')[0].match(/\.(svg|png|jpe?g|webp|gif|avif|ico)$/i) || [, ''])[1].toLowerCase();
|
| 687 |
const under = oklab(im.rec.surf);
|
| 688 |
+
const o = {src: im.src.slice(0, opts.srcMax), kind: im.kind, x: Math.round(im.x), y: Math.round(im.y), w: Math.round(im.w), h: Math.round(im.h), count: 1, areaSum: im.area,
|
| 689 |
nw: el.naturalWidth || 0, nh: el.naturalHeight || 0, homeLink, inLink: im.rec.inLink ? 1 : 0, header: (im.rec.region & 3) ? 1 : 0, footer: (im.rec.region & 4) ? 1 : 0,
|
| 690 |
logo: /logo|brand|wordmark/.test(meta) ? 1 : 0, icon: /icon|ico\b|glyph|arrow|chevron|caret|svg/.test(meta) ? 1 : 0, avatar: /avatar|profile|user/.test(meta) ? 1 : 0,
|
| 691 |
ext, underTok: im.rec.surfTok && im.rec.surfTok.i >= 0 ? im.rec.surfTok.i : -1, under,
|
| 692 |
surf: im.rec.surf.slice(0, 3).map(Math.round), vy: Math.round(im.y - sy), vx: Math.round(im.x - sx), useExt: im.useExt ? 1 : 0,
|
| 693 |
alt: opts.describe ? ((el.getAttribute && el.getAttribute('alt')) || '').slice(0, 60) : undefined,
|
| 694 |
+
cropPx: im.cropPx || undefined, pseudo: im.pseudo || undefined,
|
| 695 |
stats: null, thumb: null};
|
| 696 |
if (im.kind === 'img' && el.complete === false) o.loading = 1;
|
| 697 |
imgSeen.set(key, imgOut.length);
|
|
|
|
| 700 |
if (opts.thumbs) await imageStats(imgOut, opts);
|
| 701 |
for (const o of imgOut) o.f = imageFeatures(o, vw, vh, docH);
|
| 702 |
const res = {
|
| 703 |
+
v: 6, url: location.href, title: document.title.slice(0, 200), vw, vh, docH, docW,
|
| 704 |
scheme: schemeInfo(sheetScan), darkness, canvasDark,
|
| 705 |
tokens: out, rel: relOut, images: imgOut,
|
| 706 |
ec: ec || undefined,
|
|
|
|
| 753 |
try {
|
| 754 |
const img = await loadImage(o.src, opts.fetchImage);
|
| 755 |
if (!img) return;
|
| 756 |
+
let IW = img.naturalWidth || img.width, IH = img.naturalHeight || img.height, src = img;
|
| 757 |
o.nw = o.nw || IW; o.nh = o.nh || IH;
|
| 758 |
+
let crop = null;
|
| 759 |
+
if (o.cropPx) { // one icon of a sprite sheet: measure only the part that is shown
|
| 760 |
+
const [cx, cy, cw, ch, sw, sh] = o.cropPx;
|
| 761 |
+
const kx = sw > 0 ? IW / sw : 1, ky = sh > 0 ? IH / sh : (sh < 0 ? kx : 1);
|
| 762 |
+
crop = [cx * kx, cy * ky, cw * kx, ch * ky];
|
| 763 |
+
} else if (o.cropFrac) { const [fx, fy, fw, fh] = o.cropFrac; crop = [fx * IW, fy * IH, fw * IW, fh * IH]; }
|
| 764 |
+
if (crop) {
|
| 765 |
+
const c = document.createElement('canvas');
|
| 766 |
+
c.width = Math.max(1, Math.round(crop[2])); c.height = Math.max(1, Math.round(crop[3]));
|
| 767 |
+
c.getContext('2d').drawImage(img, crop[0], crop[1], crop[2], crop[3], 0, 0, c.width, c.height);
|
| 768 |
+
src = c; IW = c.width; IH = c.height;
|
| 769 |
+
}
|
| 770 |
+
statsFromSource(o, src, IW, IH, opts);
|
| 771 |
if (img.close) img.close();
|
| 772 |
} catch (e) { o.err = String(e).slice(0, 80); }
|
| 773 |
});
|
|
|
|
| 820 |
o.under ? o.under[0] : 1, o.under ? o.under[1] * 4 : 0, o.under ? o.under[2] * 4 : 0,
|
| 821 |
o.stats ? 1 : 0, s.transp || 0, s.semi || 0, s.meanL || 0, s.stdL || 0, (s.meanC || 0) * 4, s.white || 0, s.black || 0, s.ringA || 0, s.ringL || 0, s.colors || 0,
|
| 822 |
o.kind === 'svg' ? 1 : 0, o.useExt || 0,
|
| 823 |
+
o.kind === 'pseudo' ? 1 : 0, o.cropPx ? 1 : 0,
|
| 824 |
]);
|
| 825 |
}
|
| 826 |
const sanitize = (f) => f.map((v) => (Number.isFinite(+v) ? +v : 0));
|
|
|
|
| 869 |
statsFromSource(o, c, c.width, c.height, opts);
|
| 870 |
}
|
| 871 |
|
| 872 |
+
// ---------- one image found after the page was analysed (late-loaded, CSS background, sprite or pseudo icon) ----------
|
| 873 |
+
// Same record and features as extract() builds in its walk: the surface under it (backgrounds and gradients of the
|
| 874 |
+
// element and its ancestors composited over the canvas), link/region flags, logo/icon/avatar naming.
|
| 875 |
+
function surfaceOf(el) {
|
| 876 |
+
const chain = [];
|
| 877 |
+
for (let e = el; e && e.nodeType === 1; e = e.parentElement || (e.getRootNode && e.getRootNode().host) || null) chain.push(e);
|
| 878 |
+
const rootCS = getComputedStyle(document.documentElement);
|
| 879 |
+
const canvasDark = /dark/.test(rootCS.colorScheme) && !/light/.test(rootCS.colorScheme);
|
| 880 |
+
let surf = canvasDark ? [18, 18, 18, 1] : [255, 255, 255, 1];
|
| 881 |
+
for (let i = chain.length - 1; i >= 0; i--) {
|
| 882 |
+
const cs = getComputedStyle(chain[i]);
|
| 883 |
+
const bg = parseColor(cs.backgroundColor);
|
| 884 |
+
if (bg) surf = over(bg, surf);
|
| 885 |
+
const bi = cs.backgroundImage;
|
| 886 |
+
if (bi && bi !== 'none' && bi.includes('gradient')) {
|
| 887 |
+
const gc = colorsIn(bi);
|
| 888 |
+
if (gc.length) {
|
| 889 |
+
const avg = [0, 0, 0, 1];
|
| 890 |
+
for (const c of gc) { const o = over(c, surf); avg[0] += o[0] / gc.length; avg[1] += o[1] / gc.length; avg[2] += o[2] / gc.length; }
|
| 891 |
+
surf = avg;
|
| 892 |
+
}
|
| 893 |
+
}
|
| 894 |
+
}
|
| 895 |
+
return surf;
|
| 896 |
+
}
|
| 897 |
+
function recordFor(el, kind, src, cropPx, box) {
|
| 898 |
+
const vw = innerWidth, vh = innerHeight, sx = scrollX, sy = scrollY;
|
| 899 |
+
const docEl = document.documentElement;
|
| 900 |
+
const docH = Math.max(docEl.scrollHeight, document.body ? document.body.scrollHeight : 0, vh);
|
| 901 |
+
const r = box || el.getBoundingClientRect();
|
| 902 |
+
const x = r.left + sx, y = r.top + sy, w = r.width, h = r.height;
|
| 903 |
+
let region = 0;
|
| 904 |
+
for (let e = el; e && e.nodeType === 1; e = e.parentElement) {
|
| 905 |
+
const role = e.getAttribute && e.getAttribute('role');
|
| 906 |
+
if (e.tagName === 'HEADER' || role === 'banner') region |= 1;
|
| 907 |
+
if (e.tagName === 'NAV' || role === 'navigation') region |= 2;
|
| 908 |
+
if (e.tagName === 'FOOTER' || role === 'contentinfo') region |= 4;
|
| 909 |
+
}
|
| 910 |
+
const a = el.closest ? el.closest('a') : null;
|
| 911 |
+
let homeLink = 0;
|
| 912 |
+
try { if (a) { const u = new URL(a.getAttribute('href') || '', location.href); homeLink = (u.origin === location.origin && (u.pathname === '/' || u.pathname === '')) ? 1 : 0; } } catch (e) {}
|
| 913 |
+
const meta = (((el.getAttribute && el.getAttribute('alt')) || '') + ' ' + (typeof el.className === 'string' ? el.className : '') + ' ' + (el.id || '') + ' ' + (src.split('?')[0].split('/').pop() || '')).toLowerCase();
|
| 914 |
+
const ext = (src.split('?')[0].match(/\.(svg|png|jpe?g|webp|gif|avif|ico)$/i) || [, ''])[1].toLowerCase();
|
| 915 |
+
const surf = surfaceOf(el);
|
| 916 |
+
return {src, kind, x: Math.round(x), y: Math.round(y), w: Math.round(w), h: Math.round(h), count: 1, areaSum: w * h,
|
| 917 |
+
nw: el.naturalWidth || 0, nh: el.naturalHeight || 0, homeLink, inLink: a ? 1 : 0, header: (region & 3) ? 1 : 0, footer: (region & 4) ? 1 : 0,
|
| 918 |
+
logo: /logo|brand|wordmark/.test(meta) ? 1 : 0, icon: /icon|ico\b|glyph|arrow|chevron|caret|svg/.test(meta) ? 1 : 0, avatar: /avatar|profile|user/.test(meta) ? 1 : 0,
|
| 919 |
+
ext, underTok: -1, under: oklab(surf), surf: surf.slice(0, 3).map(Math.round), vy: Math.round(y - sy), vx: Math.round(x - sx), useExt: 0,
|
| 920 |
+
cropPx: cropPx || undefined, stats: null, thumb: null, dims: {vw, vh, docH}};
|
| 921 |
+
}
|
| 922 |
+
|
| 923 |
+
globalThis.__nadirImageRecord = recordFor;
|
| 924 |
+
globalThis.__nadirBgCrop = bgCrop;
|
| 925 |
globalThis.__nadirImageStats = imageStatsStandalone;
|
| 926 |
globalThis.__nadirImageFeatures = imageFeatures;
|
| 927 |
globalThis.__nadirExtract = extract;
|
runtime/nadir-infer.js
CHANGED
|
@@ -2,8 +2,10 @@
|
|
| 2 |
// Mirrors train.py collate() for a single page. Works in browsers (offscreen document) and Node (parity tests).
|
| 3 |
(function (root) {
|
| 4 |
'use strict';
|
| 5 |
-
const MAX_TOK = 256, MAX_IMG = 32, FT = 49, FI =
|
| 6 |
-
const TREAT = ['keep', '
|
|
|
|
|
|
|
| 7 |
const FG_ROLES = new Set([1, 5, 6]); // text, svg fill, svg stroke
|
| 8 |
const lin = (u) => { u /= 255; return u <= 0.04045 ? u / 12.92 : Math.pow((u + 0.055) / 1.055, 2.4); };
|
| 9 |
function oklab(r, g, b) {
|
|
@@ -21,8 +23,56 @@
|
|
| 21 |
}
|
| 22 |
const relLum = (rgb) => 0.2126 * lin(rgb[0]) + 0.7152 * lin(rgb[1]) + 0.0722 * lin(rgb[2]);
|
| 23 |
const contrast = (a, b) => { const x = relLum(a), y = relLum(b); return (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05); };
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
const hex = (rgb, a) => '#' + rgb.map(v => v.toString(16).padStart(2, '0')).join('') + (a < 0.999 ? Math.round(a * 255).toString(16).padStart(2, '0') : '');
|
| 25 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
function buildInputs(page) {
|
| 27 |
const toks = page.tokens.slice(0, MAX_TOK);
|
| 28 |
const N = Math.max(1, toks.length);
|
|
@@ -36,11 +86,8 @@
|
|
| 36 |
});
|
| 37 |
const img_f = new Float32Array(M * FI), thumbs = new Float32Array(M * 4 * 32 * 32);
|
| 38 |
imgs.forEach((im, j) => {
|
| 39 |
-
img_f.set(im.f
|
| 40 |
-
|
| 41 |
-
for (let y = 0; y < 32; y++) for (let x = 0; x < 32; x++) for (let c = 0; c < 4; c++)
|
| 42 |
-
thumbs[j * 4096 + c * 1024 + y * 32 + x] = im.thumb[(y * 32 + x) * 4 + c] / 255;
|
| 43 |
-
}
|
| 44 |
});
|
| 45 |
const st = page.stats || {};
|
| 46 |
const page_f = new Float32Array([page.vw / 1280, Math.min(8, page.docH / page.vh) / 8, toks.length / MAX_TOK, Mreal / MAX_IMG,
|
|
@@ -85,7 +132,7 @@
|
|
| 85 |
}};
|
| 86 |
}
|
| 87 |
|
| 88 |
-
// outputs: lab Float32Array(N*3), alpha_logit Float32Array(N),
|
| 89 |
// brand colours keep their hue: a coloured input whose output drifts more than 45 degrees is turned back to its
|
| 90 |
// own hue (keeping the predicted lightness and chroma); neutral inputs get at most a faint tint
|
| 91 |
function hueGuard(t, o) {
|
|
@@ -122,10 +169,28 @@
|
|
| 122 |
outputs[i] = {rgb, a: t.rgba[3], L: oklab(...rgb)[0]};
|
| 123 |
} else hueGuard(t, outputs[i]);
|
| 124 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 125 |
// safety: page canvas must be dark
|
| 126 |
toks.forEach((t, i) => {
|
| 127 |
if (t.f[24] > 0.5 && t.role === 0 && outputs[i].L > 0.35) { const o = oklab(...outputs[i].rgb); outputs[i].rgb = labToRgb(0.2, o[1] * 0.3, o[2] * 0.3); outputs[i].L = 0.2; }
|
| 128 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 129 |
// things that were invisible against their surface stay invisible: gradient stops with exactly a surface's
|
| 130 |
// colour (text fades) take that surface's dark colour, and so do borders that matched their surface
|
| 131 |
const keyOf = (t) => t.rgba[0] * 65536 + t.rgba[1] * 256 + t.rgba[2];
|
|
@@ -161,13 +226,12 @@
|
|
| 161 |
for (const [typ, a, b, w] of page.rel) {
|
| 162 |
if (typ !== 0 || a >= n || b >= n || toks[b].role !== 0 || outputs[b].a < 0.999) continue;
|
| 163 |
if (!surf.has(a)) surf.set(a, []);
|
| 164 |
-
surf.get(a).push([b, w +
|
| 165 |
}
|
| 166 |
for (const [i, lst] of surf) {
|
| 167 |
const t = toks[i];
|
| 168 |
if (keep.has(i) || !FG_ROLES.has(t.role) || outputs[i].a < 0.999) continue;
|
| 169 |
-
let need0 =
|
| 170 |
-
if (t.role === 1) { const fs = t.f[21] * 32, bold = t.f[23] > 0.5; need0 = (fs >= 24 || (fs >= 18.5 && bold)) ? 3 : 4.5; }
|
| 171 |
if (opts.minContrast) need0 = Math.min(need0, opts.minContrast);
|
| 172 |
const reqs = [];
|
| 173 |
for (const [j, w] of lst) {
|
|
@@ -182,24 +246,45 @@
|
|
| 182 |
let best = null;
|
| 183 |
for (let k = 0; k <= 50; k++) {
|
| 184 |
const Lx = k / 50, rgb = labToRgb(Lx, o[1], o[2]);
|
| 185 |
-
|
|
|
|
|
|
|
| 186 |
if (!best || c < best[0]) best = [c, rgb, Lx];
|
| 187 |
}
|
| 188 |
outputs[i].rgb = best[1]; outputs[i].L = best[2];
|
| 189 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 190 |
const colors = toks.map((t, i) => keep.has(i) ? null : hex(outputs[i].rgb, outputs[i].a));
|
| 191 |
-
|
| 192 |
-
const treatments = imgs.map((im, j) => {
|
| 193 |
-
let best = 0;
|
| 194 |
-
for (let c = 1; c < 5; c++) if (out.img_logits[j * 5 + c] > out.img_logits[j * 5 + best]) best = c;
|
| 195 |
-
const t = TREAT[best];
|
| 196 |
-
const u = im.underTok;
|
| 197 |
-
return root.NadirSafeTreatment ? root.NadirSafeTreatment(t, im, u != null && u >= 0 && u < n ? outputs[u].L : null) : t;
|
| 198 |
-
});
|
| 199 |
-
return {colors, keep: [...keep], treatments};
|
| 200 |
}
|
| 201 |
|
| 202 |
-
const api = {buildInputs, decode, oklab, labToRgb, TREAT, MAX_TOK, MAX_IMG};
|
| 203 |
root.NadirInfer = api;
|
| 204 |
if (typeof module !== 'undefined') module.exports = api;
|
| 205 |
})(typeof globalThis !== 'undefined' ? globalThis : this);
|
|
|
|
| 2 |
// Mirrors train.py collate() for a single page. Works in browsers (offscreen document) and Node (parity tests).
|
| 3 |
(function (root) {
|
| 4 |
'use strict';
|
| 5 |
+
const MAX_TOK = 256, MAX_IMG = 32, FT = 49, FI = 41, K = 11, N_CTX = 32;
|
| 6 |
+
const TREAT = ['keep', 'invert', 'adapt', 'remove'];
|
| 7 |
+
const IMG_ROLES = ['photo', 'illustration', 'diagram', 'logo', 'icon', 'text', 'backdrop', 'avatar', 'ui'];
|
| 8 |
+
const padF = (f) => { const o = new Array(FI).fill(0); for (let i = 0; i < Math.min(FI, f.length); i++) o[i] = Number.isFinite(f[i]) ? f[i] : 0; return o; };
|
| 9 |
const FG_ROLES = new Set([1, 5, 6]); // text, svg fill, svg stroke
|
| 10 |
const lin = (u) => { u /= 255; return u <= 0.04045 ? u / 12.92 : Math.pow((u + 0.055) / 1.055, 2.4); };
|
| 11 |
function oklab(r, g, b) {
|
|
|
|
| 23 |
}
|
| 24 |
const relLum = (rgb) => 0.2126 * lin(rgb[0]) + 0.7152 * lin(rgb[1]) + 0.0722 * lin(rgb[2]);
|
| 25 |
const contrast = (a, b) => { const x = relLum(a), y = relLum(b); return (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05); };
|
| 26 |
+
// WCAG target for a foreground token: 3:1 for large/bold text and graphics, 4.5:1 for body text
|
| 27 |
+
const needOf = (t) => {
|
| 28 |
+
if (t.role !== 1) return 3;
|
| 29 |
+
const fs = t.f[21] * 32, bold = t.f[23] > 0.5;
|
| 30 |
+
return (fs >= 24 || (fs >= 18.5 && bold)) ? 3 : 4.5;
|
| 31 |
+
};
|
| 32 |
const hex = (rgb, a) => '#' + rgb.map(v => v.toString(16).padStart(2, '0')).join('') + (a < 0.999 ? Math.round(a * 255).toString(16).padStart(2, '0') : '');
|
| 33 |
|
| 34 |
+
function putThumb(dst, j, thumb) { // RGBA bytes (32x32, row-major) -> planar floats
|
| 35 |
+
if (!thumb || thumb.length !== 4096) return;
|
| 36 |
+
for (let y = 0; y < 32; y++) for (let x = 0; x < 32; x++) for (let c = 0; c < 4; c++)
|
| 37 |
+
dst[j * 4096 + c * 1024 + y * 32 + x] = thumb[(y * 32 + x) * 4 + c] / 255;
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
// element head inputs: items [{f, thumb, ul: OKLab under (light), ud: OKLab under (dark)}], ctx Float32Array(32)
|
| 41 |
+
function buildElemInputs(items, ctx) {
|
| 42 |
+
const Kn = Math.max(1, items.length);
|
| 43 |
+
const e_f = new Float32Array(Kn * FI), e_thumb = new Float32Array(Kn * 4096), ul = new Float32Array(Kn * 3), ud = new Float32Array(Kn * 3);
|
| 44 |
+
items.forEach((it, k) => { e_f.set(padF(it.f), k * FI); putThumb(e_thumb, k, it.thumb); ul.set(it.ul, k * 3); ud.set(it.ud, k * 3); });
|
| 45 |
+
return {K: items.length, feeds: {e_f: [e_f, [1, Kn, FI]], e_thumb: [e_thumb, [1, Kn, 4, 32, 32]], under_light: [ul, [1, Kn, 3]],
|
| 46 |
+
under_dark: [ud, [1, Kn, 3]], ctx: [Float32Array.from(ctx), [1, N_CTX]]}};
|
| 47 |
+
}
|
| 48 |
+
// calibrated decisions: [{t: treatment, p: its probability, role, rp: role probability}]
|
| 49 |
+
function decodeElements(treatLogits, roleLogits, K, temperature) {
|
| 50 |
+
const T = temperature || 1, out = [];
|
| 51 |
+
for (let k = 0; k < K; k++) {
|
| 52 |
+
const z = Array.from(treatLogits.slice(k * 4, k * 4 + 4), (v) => v / T);
|
| 53 |
+
const mx = Math.max(...z), e = z.map((v) => Math.exp(v - mx)), s = e.reduce((a, b) => a + b, 0);
|
| 54 |
+
let best = 0; for (let c = 1; c < 4; c++) if (e[c] > e[best]) best = c;
|
| 55 |
+
const r = Array.from(roleLogits.slice(k * 9, k * 9 + 9));
|
| 56 |
+
const rm = Math.max(...r), re = r.map((v) => Math.exp(v - rm)), rs = re.reduce((a, b) => a + b, 0);
|
| 57 |
+
let rb = 0; for (let c = 1; c < 9; c++) if (re[c] > re[rb]) rb = c;
|
| 58 |
+
out.push({t: TREAT[best], p: +(e[best] / s).toFixed(3), probs: e.map((v) => +(v / s).toFixed(3)), role: IMG_ROLES[rb], rp: +(re[rb] / rs).toFixed(3)});
|
| 59 |
+
}
|
| 60 |
+
return out;
|
| 61 |
+
}
|
| 62 |
+
// the page's own images: the dark surface under each one is the palette head's raw output for its surface token
|
| 63 |
+
function pageElementItems(page, out) {
|
| 64 |
+
const toks = page.tokens.slice(0, MAX_TOK), n = toks.length;
|
| 65 |
+
let root = 0; toks.forEach((t, i) => { if (t.f[24] > toks[root].f[24]) root = i; });
|
| 66 |
+
const items = [], idx = [];
|
| 67 |
+
page.images.forEach((im, j) => {
|
| 68 |
+
if (!im.thumb || im.thumb.length !== 4096 || !im.f) return;
|
| 69 |
+
const u = im.underTok != null && im.underTok >= 0 && im.underTok < n ? im.underTok : root;
|
| 70 |
+
items.push({f: im.f, thumb: im.thumb, ul: [im.f[23], im.f[24] / 4, im.f[25] / 4], ud: Array.from(out.lab.slice(u * 3, u * 3 + 3)), u});
|
| 71 |
+
idx.push(j);
|
| 72 |
+
});
|
| 73 |
+
return {items, idx};
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
function buildInputs(page) {
|
| 77 |
const toks = page.tokens.slice(0, MAX_TOK);
|
| 78 |
const N = Math.max(1, toks.length);
|
|
|
|
| 86 |
});
|
| 87 |
const img_f = new Float32Array(M * FI), thumbs = new Float32Array(M * 4 * 32 * 32);
|
| 88 |
imgs.forEach((im, j) => {
|
| 89 |
+
img_f.set(padF(im.f), j * FI);
|
| 90 |
+
putThumb(thumbs, j, im.thumb);
|
|
|
|
|
|
|
|
|
|
| 91 |
});
|
| 92 |
const st = page.stats || {};
|
| 93 |
const page_f = new Float32Array([page.vw / 1280, Math.min(8, page.docH / page.vh) / 8, toks.length / MAX_TOK, Mreal / MAX_IMG,
|
|
|
|
| 132 |
}};
|
| 133 |
}
|
| 134 |
|
| 135 |
+
// outputs: lab Float32Array(N*3), alpha_logit Float32Array(N), ctx Float32Array(32)
|
| 136 |
// brand colours keep their hue: a coloured input whose output drifts more than 45 degrees is turned back to its
|
| 137 |
// own hue (keeping the predicted lightness and chroma); neutral inputs get at most a faint tint
|
| 138 |
function hueGuard(t, o) {
|
|
|
|
| 169 |
outputs[i] = {rgb, a: t.rgba[3], L: oklab(...rgb)[0]};
|
| 170 |
} else hueGuard(t, outputs[i]);
|
| 171 |
});
|
| 172 |
+
// a see-through light panel (frosted card, glass button: white at 40-99% opacity) stays see-through but turns
|
| 173 |
+
// dark; left mid-grey it is a surface no text colour reads on (the contrast pass below only sees opaque ones)
|
| 174 |
+
toks.forEach((t, i) => {
|
| 175 |
+
if (keep.has(i) || t.role !== 0 || t.rgba[3] >= 0.999 || t.rgba[3] < 0.4) return;
|
| 176 |
+
if (oklab(t.rgba[0], t.rgba[1], t.rgba[2])[0] < 0.85 || outputs[i].L <= 0.45) return;
|
| 177 |
+
const o = oklab(...outputs[i].rgb);
|
| 178 |
+
outputs[i].rgb = labToRgb(0.24, o[1] * 0.5, o[2] * 0.5); outputs[i].L = 0.24;
|
| 179 |
+
});
|
| 180 |
// safety: page canvas must be dark
|
| 181 |
toks.forEach((t, i) => {
|
| 182 |
if (t.f[24] > 0.5 && t.role === 0 && outputs[i].L > 0.35) { const o = oklab(...outputs[i].rgb); outputs[i].rgb = labToRgb(0.2, o[1] * 0.3, o[2] * 0.3); outputs[i].L = 0.2; }
|
| 183 |
});
|
| 184 |
+
// an opaque surface stays opaque: see-through, it would show whatever the design stacks beneath it (a hidden
|
| 185 |
+
// sticky header, the next slide). The predicted colour is composited over the dark canvas instead
|
| 186 |
+
const canvasI = toks.findIndex((t) => t.f[24] > 0.5 && t.role === 0);
|
| 187 |
+
const under = canvasI >= 0 ? outputs[canvasI].rgb : [18, 18, 20];
|
| 188 |
+
toks.forEach((t, i) => {
|
| 189 |
+
if (keep.has(i) || t.role !== 0 || t.rgba[3] < 0.999 || outputs[i].a >= 0.999) return;
|
| 190 |
+
const a = outputs[i].a;
|
| 191 |
+
outputs[i].rgb = outputs[i].rgb.map((c, k) => Math.round(c * a + under[k] * (1 - a)));
|
| 192 |
+
outputs[i].a = 1; outputs[i].L = oklab(...outputs[i].rgb)[0];
|
| 193 |
+
});
|
| 194 |
// things that were invisible against their surface stay invisible: gradient stops with exactly a surface's
|
| 195 |
// colour (text fades) take that surface's dark colour, and so do borders that matched their surface
|
| 196 |
const keyOf = (t) => t.rgba[0] * 65536 + t.rgba[1] * 256 + t.rgba[2];
|
|
|
|
| 226 |
for (const [typ, a, b, w] of page.rel) {
|
| 227 |
if (typ !== 0 || a >= n || b >= n || toks[b].role !== 0 || outputs[b].a < 0.999) continue;
|
| 228 |
if (!surf.has(a)) surf.set(a, []);
|
| 229 |
+
surf.get(a).push([b, Math.expm1(w) + 1]); // rels store log1p(weight)
|
| 230 |
}
|
| 231 |
for (const [i, lst] of surf) {
|
| 232 |
const t = toks[i];
|
| 233 |
if (keep.has(i) || !FG_ROLES.has(t.role) || outputs[i].a < 0.999) continue;
|
| 234 |
+
let need0 = needOf(t);
|
|
|
|
| 235 |
if (opts.minContrast) need0 = Math.min(need0, opts.minContrast);
|
| 236 |
const reqs = [];
|
| 237 |
for (const [j, w] of lst) {
|
|
|
|
| 246 |
let best = null;
|
| 247 |
for (let k = 0; k <= 50; k++) {
|
| 248 |
const Lx = k / 50, rgb = labToRgb(Lx, o[1], o[2]);
|
| 249 |
+
// moving away from the model's lightness costs: the surfaces a colour mostly sits on decide, and a few
|
| 250 |
+
// bright accent surfaces cannot drag it to a mid-grey that reads nowhere (they are rescued below)
|
| 251 |
+
const c = deficit(rgb) / W + Math.abs(Lx - o[0]);
|
| 252 |
if (!best || c < best[0]) best = [c, rgb, Lx];
|
| 253 |
}
|
| 254 |
outputs[i].rgb = best[1]; outputs[i].L = best[2];
|
| 255 |
}
|
| 256 |
+
// a light surface whose text no longer reads (its text colour follows the dark page) is darkened within its
|
| 257 |
+
// own hue until that text reads again: a yellow note becomes dark gold, not a glare
|
| 258 |
+
const fgOn = new Map();
|
| 259 |
+
for (const [typ, a, b, w] of page.rel) {
|
| 260 |
+
if (typ !== 0 || a >= n || b >= n || toks[b].role !== 0 || !FG_ROLES.has(toks[a].role)) continue;
|
| 261 |
+
if (!fgOn.has(b)) fgOn.set(b, []);
|
| 262 |
+
fgOn.get(b).push([a, Math.expm1(w) + 1]);
|
| 263 |
+
}
|
| 264 |
+
for (const [j, lst] of fgOn) {
|
| 265 |
+
const s = toks[j];
|
| 266 |
+
if (keep.has(j) || s.f[24] > 0.5 || outputs[j].a < 0.999 || outputs[j].L < 0.6) continue;
|
| 267 |
+
const reqs = [];
|
| 268 |
+
for (const [i, w] of lst) {
|
| 269 |
+
if (keep.has(i) || outputs[i].a < 0.999) continue;
|
| 270 |
+
const need = Math.min(needOf(toks[i]), contrast(toks[i].rgba, s.rgba));
|
| 271 |
+
if (need >= 1.5) reqs.push([outputs[i].rgb, need, w]);
|
| 272 |
+
}
|
| 273 |
+
if (!reqs.length) continue;
|
| 274 |
+
let W = 0; for (const r of reqs) W += r[2];
|
| 275 |
+
const fail = (rgb) => { let f = 0; for (const [fg, need, w] of reqs) if (contrast(fg, rgb) < need) f += w; return f / W; };
|
| 276 |
+
if (fail(outputs[j].rgb) <= 0.5) continue;
|
| 277 |
+
const o = oklab(...outputs[j].rgb);
|
| 278 |
+
for (let k = Math.floor(o[0] * 50); k >= 10; k--) {
|
| 279 |
+
const rgb = labToRgb(k / 50, o[1], o[2]);
|
| 280 |
+
if (fail(rgb) <= 0.2) { outputs[j].rgb = rgb; outputs[j].L = k / 50; break; }
|
| 281 |
+
}
|
| 282 |
+
}
|
| 283 |
const colors = toks.map((t, i) => keep.has(i) ? null : hex(outputs[i].rgb, outputs[i].a));
|
| 284 |
+
return {colors, keep: [...keep], Ls: outputs.map((o) => +o.L.toFixed(3))}; // Ls: the lightness each colour is shown at
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 285 |
}
|
| 286 |
|
| 287 |
+
const api = {buildInputs, decode, buildElemInputs, decodeElements, pageElementItems, oklab, labToRgb, TREAT, IMG_ROLES, MAX_TOK, MAX_IMG, FI};
|
| 288 |
root.NadirInfer = api;
|
| 289 |
if (typeof module !== 'undefined') module.exports = api;
|
| 290 |
})(typeof globalThis !== 'undefined' ? globalThis : this);
|
runtime/nadir-map.js
CHANGED
|
@@ -120,51 +120,15 @@
|
|
| 120 |
dim: 'filter: brightness(0.88) !important;',
|
| 121 |
plate: 'background-color: rgba(255,255,255,0.92) !important; box-shadow: 0 0 0 3px rgba(255,255,255,0.92) !important; border-radius: 4px !important;',
|
| 122 |
};
|
| 123 |
-
// Content images must never be colour-altered: only flat graphics (logos, icons, diagrams) may get a treatment.
|
| 124 |
-
// a logo on its own white rectangle: adapt it instead of leaving a white box. Only images the page marks as its
|
| 125 |
-
// logo (logo/brand/wordmark naming, or the link home); product shots, photos and other images are never touched
|
| 126 |
-
// Returns 'adapt:<fallback>': the page tries the adaptation (its pixel check confirms the white background)
|
| 127 |
-
// and uses the fallback treatment when there is nothing to adapt. Without pixel stats yet, logos still try.
|
| 128 |
-
function adaptable(im, s, surfL) {
|
| 129 |
-
if (!(im.logo || (im.homeLink && im.header)) || im.h > 200 || (surfL != null && surfL >= 0.4)) return false;
|
| 130 |
-
if (!s) return im.w * im.h <= 250000;
|
| 131 |
-
return s.transp < 0.05 && (s.ringA || 0) >= 0.95 && (s.ringL || 0) >= 0.85 && (s.white || 0) >= 0.3 && s.colors < 0.2;
|
| 132 |
-
}
|
| 133 |
-
function baseTreatment(t, im, surfL) {
|
| 134 |
-
const s = im && im.stats;
|
| 135 |
-
// a light, flat CSS background image (texture, pale wash, pattern) sits behind content whose text is now light:
|
| 136 |
-
// clear it so the dark surface designed for that element shows through. Photos and detailed images stay.
|
| 137 |
-
// (almost no lightness variation, so never a photo; SVG backgrounds can't be photos, so a smooth gradient qualifies).
|
| 138 |
-
// Logos drawn as backgrounds are never cleared.
|
| 139 |
-
if (im && im.kind === 'bg' && !im.logo && !im.homeLink && s && s.transp < 0.05 && s.meanL > 0.8 && im.w * im.h >= 40000 &&
|
| 140 |
-
s.stdL < (/\.svg$/i.test((im.src || '').split('?')[0]) ? 0.15 : 0.07)) return 'clear';
|
| 141 |
-
// a mostly dark graphic on transparency would vanish on a dark surface: hue-preserving inversion
|
| 142 |
-
if ((!t || t === 'keep') && s && s.transp > 0.15 && s.meanL < 0.35 && im.w * im.h < 200000 && (surfL == null || surfL < 0.4)) return 'invert';
|
| 143 |
-
if (!t || t === 'keep') return 'keep';
|
| 144 |
-
const photoLike = s ? (s.transp < 0.02 && s.colors > 0.18 && s.stdL > 0.12) : (im && im.w * im.h > 60000);
|
| 145 |
-
if (photoLike && t !== 'dim') return 'keep';
|
| 146 |
-
if (t === 'dim' && im && im.w * im.h < 20000) return 'keep';
|
| 147 |
-
// a transparent graphic that is already light reads fine on a dark surface: leave it alone
|
| 148 |
-
if ((t === 'plate' || t === 'light' || t === 'invert') && s && s.transp > 0.15 && s.meanL >= 0.55 && (s.black || 0) < 0.15 &&
|
| 149 |
-
(surfL == null || surfL < 0.4)) return 'keep';
|
| 150 |
-
// no light boxes on a dark page: a dark logo on transparency gets a hue-preserving inversion instead of a plate,
|
| 151 |
-
// an opaque one already carries its own background
|
| 152 |
-
if (t === 'plate') return (!s || s.transp > 0.15) ? 'invert' : 'keep';
|
| 153 |
-
return t;
|
| 154 |
-
}
|
| 155 |
-
function safeTreatment(t, im, surfL) {
|
| 156 |
-
const base = baseTreatment(t, im, surfL);
|
| 157 |
-
return base !== 'clear' && im && adaptable(im, im.stats, surfL) ? 'adapt:' + base : base;
|
| 158 |
-
}
|
| 159 |
-
|
| 160 |
// ---------- flat graphics on a white background (logos, icons, diagrams): adapt the image itself ----------
|
| 161 |
// The white background becomes transparent so the page's own dark surface shows through, neutral ink flips to
|
| 162 |
// light, and brand colours keep their hue and are lifted only as far as needed to read on dark.
|
| 163 |
function adaptPixels(d, w, h, info) {
|
| 164 |
info = info || {};
|
| 165 |
-
let n = 0, light = 0, sr = 0, sg = 0, sb = 0;
|
| 166 |
const ringPx = (p) => {
|
| 167 |
const i = p * 4;
|
|
|
|
| 168 |
if (d[i + 3] < 250) return;
|
| 169 |
n++;
|
| 170 |
const o = oklab(d[i], d[i + 1], d[i + 2]);
|
|
@@ -173,8 +137,22 @@
|
|
| 173 |
for (let x = 0; x < w; x++) { ringPx(x); ringPx((h - 1) * w + x); }
|
| 174 |
for (let y = 1; y < h - 1; y++) { ringPx(y * w); ringPx(y * w + w - 1); }
|
| 175 |
info.ring = n ? light / n : 0;
|
| 176 |
-
|
| 177 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 178 |
const cache = new Map();
|
| 179 |
for (let p = 0, P = w * h; p < P; p++) {
|
| 180 |
const i = p * 4;
|
|
@@ -183,12 +161,12 @@
|
|
| 183 |
let r = cache.get(key);
|
| 184 |
if (!r) {
|
| 185 |
const o = oklab(d[i], d[i + 1], d[i + 2]);
|
| 186 |
-
const dist = Math.hypot(o[0] - bg[0], o[1] - bg[1], o[2] - bg[2]);
|
| 187 |
-
const cov = Math.max(0, Math.min(1, (dist - 0.03) / 0.22)); // 0 = background, 1 = ink, between = anti-aliased edge
|
| 188 |
if (cov <= 0) r = [0, 0, 0, 0];
|
| 189 |
else {
|
| 190 |
// unmix the edge pixel from the background, then restyle the ink for a dark surface
|
| 191 |
-
let L = (o[0] - (1 - cov) * bg[0]) / cov, a = (o[1] - (1 - cov) * bg[1]) / cov, b = (o[2] - (1 - cov) * bg[2]) / cov;
|
| 192 |
L = Math.max(0, Math.min(1, L));
|
| 193 |
const C = Math.hypot(a, b);
|
| 194 |
if (C < 0.05) L = 0.94 - 0.75 * L; // black/gray ink -> light
|
|
@@ -240,5 +218,4 @@
|
|
| 240 |
globalThis.NadirMap = NadirMap;
|
| 241 |
globalThis.NadirAdaptImage = adaptImage;
|
| 242 |
globalThis.NadirImageCSS = TREAT_CSS;
|
| 243 |
-
globalThis.NadirSafeTreatment = safeTreatment;
|
| 244 |
})();
|
|
|
|
| 120 |
dim: 'filter: brightness(0.88) !important;',
|
| 121 |
plate: 'background-color: rgba(255,255,255,0.92) !important; box-shadow: 0 0 0 3px rgba(255,255,255,0.92) !important; border-radius: 4px !important;',
|
| 122 |
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
// ---------- flat graphics on a white background (logos, icons, diagrams): adapt the image itself ----------
|
| 124 |
// The white background becomes transparent so the page's own dark surface shows through, neutral ink flips to
|
| 125 |
// light, and brand colours keep their hue and are lifted only as far as needed to read on dark.
|
| 126 |
function adaptPixels(d, w, h, info) {
|
| 127 |
info = info || {};
|
| 128 |
+
let n = 0, light = 0, sr = 0, sg = 0, sb = 0, ringN = 0;
|
| 129 |
const ringPx = (p) => {
|
| 130 |
const i = p * 4;
|
| 131 |
+
ringN++;
|
| 132 |
if (d[i + 3] < 250) return;
|
| 133 |
n++;
|
| 134 |
const o = oklab(d[i], d[i + 1], d[i + 2]);
|
|
|
|
| 137 |
for (let x = 0; x < w; x++) { ringPx(x); ringPx((h - 1) * w + x); }
|
| 138 |
for (let y = 1; y < h - 1; y++) { ringPx(y * w); ringPx(y * w + w - 1); }
|
| 139 |
info.ring = n ? light / n : 0;
|
| 140 |
+
let bg = null;
|
| 141 |
+
if (n && light / n >= 0.5) bg = oklab(sr / light, sg / light, sb / light);
|
| 142 |
+
else if (n > 0.2 * ringN) { info.why = 'ring'; return false; } // no light background to remove (tight crops touch the edge)
|
| 143 |
+
else {
|
| 144 |
+
// on transparency: only worth it when neutral dark ink (wordmark text) would vanish on a dark surface
|
| 145 |
+
let opq = 0, ink = 0;
|
| 146 |
+
for (let p = 0, P = w * h; p < P; p++) {
|
| 147 |
+
const i = p * 4;
|
| 148 |
+
if (d[i + 3] < 128) continue;
|
| 149 |
+
opq++;
|
| 150 |
+
const o = oklab(d[i], d[i + 1], d[i + 2]);
|
| 151 |
+
if (o[0] < 0.5 && Math.hypot(o[1], o[2]) < 0.05) ink++;
|
| 152 |
+
}
|
| 153 |
+
info.ink = opq ? ink / opq : 0;
|
| 154 |
+
if (!opq || ink / opq < 0.08) { info.why = 'no-ink'; return false; }
|
| 155 |
+
}
|
| 156 |
const cache = new Map();
|
| 157 |
for (let p = 0, P = w * h; p < P; p++) {
|
| 158 |
const i = p * 4;
|
|
|
|
| 161 |
let r = cache.get(key);
|
| 162 |
if (!r) {
|
| 163 |
const o = oklab(d[i], d[i + 1], d[i + 2]);
|
| 164 |
+
const dist = bg ? Math.hypot(o[0] - bg[0], o[1] - bg[1], o[2] - bg[2]) : 1;
|
| 165 |
+
const cov = bg ? Math.max(0, Math.min(1, (dist - 0.03) / 0.22)) : 1; // 0 = background, 1 = ink, between = anti-aliased edge
|
| 166 |
if (cov <= 0) r = [0, 0, 0, 0];
|
| 167 |
else {
|
| 168 |
// unmix the edge pixel from the background, then restyle the ink for a dark surface
|
| 169 |
+
let L = bg ? (o[0] - (1 - cov) * bg[0]) / cov : o[0], a = bg ? (o[1] - (1 - cov) * bg[1]) / cov : o[1], b = bg ? (o[2] - (1 - cov) * bg[2]) / cov : o[2];
|
| 170 |
L = Math.max(0, Math.min(1, L));
|
| 171 |
const C = Math.hypot(a, b);
|
| 172 |
if (C < 0.05) L = 0.94 - 0.75 * L; // black/gray ink -> light
|
|
|
|
| 218 |
globalThis.NadirMap = NadirMap;
|
| 219 |
globalThis.NadirAdaptImage = adaptImage;
|
| 220 |
globalThis.NadirImageCSS = TREAT_CSS;
|
|
|
|
| 221 |
})();
|
training/build_ds.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
| 1 |
#!/usr/bin/env python3
|
| 2 |
-
"""Build Nadir training data: page features + per-token dark targets (Haiku designs and native dark-mode pairs)
|
|
|
|
| 3 |
import argparse, collections, difflib, gzip, hashlib, json, math, os, random, sys
|
| 4 |
from pathlib import Path
|
| 5 |
import numpy as np
|
|
@@ -7,8 +8,10 @@ import numpy as np
|
|
| 7 |
import safety
|
| 8 |
|
| 9 |
ROLES = ["bg", "text", "border", "shadow", "gradient", "fill", "stroke"]
|
| 10 |
-
TREAT = ["keep", "
|
| 11 |
-
|
|
|
|
|
|
|
| 12 |
|
| 13 |
|
| 14 |
def lin(u):
|
|
@@ -46,6 +49,62 @@ def over(top, bottom):
|
|
| 46 |
return [top[i] * a + bottom[i] * (1 - a) for i in range(3)] + [1.0]
|
| 47 |
|
| 48 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
def page_arrays(L):
|
| 50 |
toks = L["tokens"][:MAX_TOK]
|
| 51 |
n = len(toks)
|
|
@@ -54,7 +113,7 @@ def page_arrays(L):
|
|
| 54 |
rgba = np.array([t["rgba"] for t in toks], dtype=np.float32)
|
| 55 |
rel = np.array([r for r in L["rel"] if r[1] < n and r[2] < n], dtype=np.float32).reshape(-1, 4)
|
| 56 |
imgs = L["images"][:MAX_IMG]
|
| 57 |
-
IF = np.array([im["f"] for im in imgs], dtype=np.float32).reshape(len(imgs),
|
| 58 |
thumbs = np.zeros((len(imgs), 32, 32, 4), dtype=np.uint8)
|
| 59 |
for j, im in enumerate(imgs):
|
| 60 |
if im.get("thumb") and len(im["thumb"]) == 4096:
|
|
@@ -78,11 +137,7 @@ def haiku_targets(L, h, arrs, qc):
|
|
| 78 |
i = int(k)
|
| 79 |
if i < n:
|
| 80 |
tgt[i] = hex2rgba(v)
|
| 81 |
-
imgt = np.full((len(arrs["IF"]),), -1, dtype=np.int64)
|
| 82 |
-
for k, v in (h.get("images") or {}).items():
|
| 83 |
-
j = int(k)
|
| 84 |
-
if j < len(imgt) and v in TREAT:
|
| 85 |
-
imgt[j] = TREAT.index(v)
|
| 86 |
# ---- QC ----
|
| 87 |
asked = min(n, h.get("n_tok", n))
|
| 88 |
got = np.isfinite(tgt[:asked, 0]).sum()
|
|
@@ -143,6 +198,7 @@ def repair_contrast(arrs, tgt):
|
|
| 143 |
safety.hue_guard(toks[i], out[i])
|
| 144 |
safety.keep_invisible(toks, out, arrs["rel"].tolist(), keep)
|
| 145 |
safety.enforce_contrast(toks, out, arrs["rel"].tolist(), keep)
|
|
|
|
| 146 |
changed = 0
|
| 147 |
for i in range(n):
|
| 148 |
if out[i]["rgb"] != before[i]:
|
|
@@ -203,78 +259,94 @@ def native_targets(L, D, arrs, qc):
|
|
| 203 |
return tgt, np.full((len(arrs["IF"]),), -1, dtype=np.int64)
|
| 204 |
|
| 205 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 206 |
def main():
|
|
|
|
| 207 |
ap = argparse.ArgumentParser()
|
| 208 |
-
ap.add_argument("--
|
| 209 |
-
ap.add_argument("--
|
| 210 |
ap.add_argument("--out", required=True)
|
| 211 |
ap.add_argument("--val-frac", type=float, default=0.05)
|
|
|
|
| 212 |
args = ap.parse_args()
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
for
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
pass
|
| 221 |
-
samples = []
|
| 222 |
-
qc_h = collections.Counter(); qc_n = collections.Counter()
|
| 223 |
-
# Haiku-designed pages (source 0)
|
| 224 |
-
for hd in args.haiku:
|
| 225 |
-
for fp in Path(hd).glob("*.json"):
|
| 226 |
-
if fp.name in ("ledger.jsonl",):
|
| 227 |
-
continue
|
| 228 |
-
try:
|
| 229 |
-
h = json.loads(fp.read_text())
|
| 230 |
-
except Exception:
|
| 231 |
-
continue
|
| 232 |
-
if h.get("refusal") or "colors" not in h:
|
| 233 |
-
continue
|
| 234 |
-
pid = h["id"]
|
| 235 |
try:
|
| 236 |
-
|
|
|
|
| 237 |
except Exception:
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
L = json.load(gzip.open(crawl / "pages" / pid / "light.json.gz"))
|
| 256 |
-
D = json.load(gzip.open(crawl / "pages" / pid / "dark.json.gz"))
|
| 257 |
-
except Exception:
|
| 258 |
-
continue
|
| 259 |
-
arrs = page_arrays(L)
|
| 260 |
-
res = native_targets(L, D, arrs, qc_n)
|
| 261 |
-
if res is None:
|
| 262 |
-
continue
|
| 263 |
-
arrs["tgt"], arrs["imgt"] = res
|
| 264 |
-
arrs["src"] = 1
|
| 265 |
-
arrs["id"] = pid
|
| 266 |
-
arrs["host"] = r["url"].split("/")[2]
|
| 267 |
-
samples.append(arrs)
|
| 268 |
-
print("haiku QC", dict(qc_h), "native QC", dict(qc_n), "samples", len(samples), flush=True)
|
| 269 |
hosts = sorted(set(s["host"].replace("www.", "") for s in samples))
|
| 270 |
-
# stable split: a host's side never changes as the crawl grows
|
| 271 |
val_hosts = {h for h in hosts if int(hashlib.md5(h.encode()).hexdigest()[:8], 16) % 1000 < args.val_frac * 1000}
|
| 272 |
train = [s for s in samples if s["host"].replace("www.", "") not in val_hosts]
|
| 273 |
val = [s for s in samples if s["host"].replace("www.", "") in val_hosts]
|
| 274 |
import pickle
|
| 275 |
with open(args.out, "wb") as f:
|
| 276 |
-
pickle.dump({"train": train, "val": val, "roles": ROLES, "treat": TREAT}, f, protocol=4)
|
| 277 |
-
print("train", len(train), "val", len(val), "haiku", sum(s["src"] == 0 for s in samples), "native", sum(s["src"] == 1 for s in samples)
|
|
|
|
| 278 |
|
| 279 |
|
| 280 |
if __name__ == "__main__":
|
|
|
|
| 1 |
#!/usr/bin/env python3
|
| 2 |
+
"""Build Nadir training data: page features + per-token dark targets (Haiku designs and native dark-mode pairs),
|
| 3 |
+
plus element-head targets (how each image should render, and what it is) from Haiku's contact-sheet labels."""
|
| 4 |
import argparse, collections, difflib, gzip, hashlib, json, math, os, random, sys
|
| 5 |
from pathlib import Path
|
| 6 |
import numpy as np
|
|
|
|
| 8 |
import safety
|
| 9 |
|
| 10 |
ROLES = ["bg", "text", "border", "shadow", "gradient", "fill", "stroke"]
|
| 11 |
+
TREAT = ["keep", "invert", "adapt", "remove"]
|
| 12 |
+
LETTER = {"A": 0, "B": 1, "C": 2, "D": 3}
|
| 13 |
+
IMG_ROLES = ["photo", "illustration", "diagram", "logo", "icon", "text", "backdrop", "avatar", "ui"]
|
| 14 |
+
MAX_TOK, MAX_IMG, FI = 256, 32, 41
|
| 15 |
|
| 16 |
|
| 17 |
def lin(u):
|
|
|
|
| 49 |
return [top[i] * a + bottom[i] * (1 - a) for i in range(3)] + [1.0]
|
| 50 |
|
| 51 |
|
| 52 |
+
def pad_f(f):
|
| 53 |
+
"""extract.js v5 image features had 39 entries; v6 adds kind=pseudo and sprite-crop (both 0 for v5 pages)."""
|
| 54 |
+
return list(f) + [0.0] * (FI - len(f))
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def element_arrays(L, n_tok, labels):
|
| 58 |
+
"""Element-head samples for the labelled images of a page (any image, not only the 32 the trunk sees)."""
|
| 59 |
+
rows = []
|
| 60 |
+
for j, labs in sorted(labels.items()):
|
| 61 |
+
if j >= len(L["images"]):
|
| 62 |
+
continue
|
| 63 |
+
im = L["images"][j]
|
| 64 |
+
if not im.get("thumb") or len(im["thumb"]) != 4096:
|
| 65 |
+
continue
|
| 66 |
+
soft = np.zeros(len(TREAT), np.float32)
|
| 67 |
+
for lab in labs:
|
| 68 |
+
best = LETTER[lab["best"]]
|
| 69 |
+
ok = [LETTER[x] for x in lab["ok"] if x != lab["best"]]
|
| 70 |
+
soft[best] += 0.75 if ok else 1.0
|
| 71 |
+
for o in ok:
|
| 72 |
+
soft[o] += 0.25 / len(ok)
|
| 73 |
+
soft /= soft.sum()
|
| 74 |
+
role = IMG_ROLES.index(labs[0]["role"]) if labs[0]["role"] in IMG_ROLES else -1
|
| 75 |
+
u = im.get("underTok", -1)
|
| 76 |
+
rows.append((pad_f(im["f"]), np.array(im["thumb"], dtype=np.uint8).reshape(32, 32, 4), im.get("under") or [1.0, 0.0, 0.0],
|
| 77 |
+
u if 0 <= u < n_tok else -1, soft, role, j, im.get("kind", "img"), labs[0].get("bucket", ""), len(labs)))
|
| 78 |
+
K = len(rows)
|
| 79 |
+
E = dict(E_f=np.zeros((K, FI), np.float32), E_thumb=np.zeros((K, 32, 32, 4), np.uint8), E_ul=np.zeros((K, 3), np.float32),
|
| 80 |
+
E_ut=-np.ones(K, np.int64), E_soft=np.zeros((K, len(TREAT)), np.float32), E_role=-np.ones(K, np.int64), E_idx=np.zeros(K, np.int64),
|
| 81 |
+
E_kind=[], E_bucket=[], E_nlab=np.zeros(K, np.int64))
|
| 82 |
+
for k, (f, th, ul, ut, soft, role, j, kind, bucket, nl) in enumerate(rows):
|
| 83 |
+
E["E_f"][k] = f; E["E_thumb"][k] = th; E["E_ul"][k] = ul; E["E_ut"][k] = ut; E["E_soft"][k] = soft; E["E_role"][k] = role
|
| 84 |
+
E["E_idx"][k] = j; E["E_kind"].append(kind); E["E_bucket"].append(bucket); E["E_nlab"][k] = nl
|
| 85 |
+
E["E_f"] = np.nan_to_num(E["E_f"], nan=0.0, posinf=0.0, neginf=0.0)
|
| 86 |
+
return E
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def load_labels(paths):
|
| 90 |
+
"""labels.jsonl rows -> {(crawl tag, page id): {image index: [label, ...]}} (a second, independent label when present)."""
|
| 91 |
+
out = collections.defaultdict(lambda: collections.defaultdict(list))
|
| 92 |
+
n = 0
|
| 93 |
+
for p in paths:
|
| 94 |
+
for line in open(p):
|
| 95 |
+
if not line.strip():
|
| 96 |
+
continue
|
| 97 |
+
try:
|
| 98 |
+
s = json.loads(line)
|
| 99 |
+
except Exception: # a line still being written
|
| 100 |
+
continue
|
| 101 |
+
for r in s["rows"]:
|
| 102 |
+
out[(r["crawl"], r["page"])][r["img"]].append(r)
|
| 103 |
+
n += 1
|
| 104 |
+
print("element labels", n, "on", len(out), "pages", flush=True)
|
| 105 |
+
return out
|
| 106 |
+
|
| 107 |
+
|
| 108 |
def page_arrays(L):
|
| 109 |
toks = L["tokens"][:MAX_TOK]
|
| 110 |
n = len(toks)
|
|
|
|
| 113 |
rgba = np.array([t["rgba"] for t in toks], dtype=np.float32)
|
| 114 |
rel = np.array([r for r in L["rel"] if r[1] < n and r[2] < n], dtype=np.float32).reshape(-1, 4)
|
| 115 |
imgs = L["images"][:MAX_IMG]
|
| 116 |
+
IF = np.array([pad_f(im["f"]) for im in imgs], dtype=np.float32).reshape(len(imgs), FI)
|
| 117 |
thumbs = np.zeros((len(imgs), 32, 32, 4), dtype=np.uint8)
|
| 118 |
for j, im in enumerate(imgs):
|
| 119 |
if im.get("thumb") and len(im["thumb"]) == 4096:
|
|
|
|
| 137 |
i = int(k)
|
| 138 |
if i < n:
|
| 139 |
tgt[i] = hex2rgba(v)
|
| 140 |
+
imgt = np.full((len(arrs["IF"]),), -1, dtype=np.int64) # page-design image labels are superseded by the element head
|
|
|
|
|
|
|
|
|
|
|
|
|
| 141 |
# ---- QC ----
|
| 142 |
asked = min(n, h.get("n_tok", n))
|
| 143 |
got = np.isfinite(tgt[:asked, 0]).sum()
|
|
|
|
| 198 |
safety.hue_guard(toks[i], out[i])
|
| 199 |
safety.keep_invisible(toks, out, arrs["rel"].tolist(), keep)
|
| 200 |
safety.enforce_contrast(toks, out, arrs["rel"].tolist(), keep)
|
| 201 |
+
safety.rescue_surfaces(toks, out, arrs["rel"].tolist(), keep)
|
| 202 |
changed = 0
|
| 203 |
for i in range(n):
|
| 204 |
if out[i]["rgb"] != before[i]:
|
|
|
|
| 259 |
return tgt, np.full((len(arrs["IF"]),), -1, dtype=np.int64)
|
| 260 |
|
| 261 |
|
| 262 |
+
LABELS = {}
|
| 263 |
+
|
| 264 |
+
|
| 265 |
+
def build_one(task):
|
| 266 |
+
"""One page -> training sample (or None), with its QC outcome. Runs in a worker process."""
|
| 267 |
+
tag, cdir, kind, pid, hpath, url = task
|
| 268 |
+
qc = collections.Counter()
|
| 269 |
+
crawl = Path(cdir)
|
| 270 |
+
try:
|
| 271 |
+
L = json.load(gzip.open(crawl / "pages" / pid / "light.json.gz"))
|
| 272 |
+
arrs = page_arrays(L)
|
| 273 |
+
if kind == "haiku":
|
| 274 |
+
h = json.loads(Path(hpath).read_text())
|
| 275 |
+
if h.get("refusal") or "colors" not in h:
|
| 276 |
+
return None, qc
|
| 277 |
+
r = haiku_targets(L, h, arrs, qc)
|
| 278 |
+
if r is None:
|
| 279 |
+
return None, qc
|
| 280 |
+
arrs["tgt"], arrs["imgt"] = r
|
| 281 |
+
if repair_contrast(arrs, arrs["tgt"]):
|
| 282 |
+
qc["repaired"] += 1
|
| 283 |
+
src = 0
|
| 284 |
+
url = url or L["url"]
|
| 285 |
+
else:
|
| 286 |
+
D = json.load(gzip.open(crawl / "pages" / pid / "dark.json.gz"))
|
| 287 |
+
res = native_targets(L, D, arrs, qc)
|
| 288 |
+
if res is None:
|
| 289 |
+
return None, qc
|
| 290 |
+
arrs["tgt"], arrs["imgt"] = res
|
| 291 |
+
src = 1
|
| 292 |
+
arrs["src"] = src
|
| 293 |
+
arrs["id"] = pid
|
| 294 |
+
arrs["crawl"] = tag
|
| 295 |
+
arrs["host"] = url.split("/")[2]
|
| 296 |
+
arrs.update(element_arrays(L, len(arrs["role"]), LABELS.get((tag, pid), {})))
|
| 297 |
+
return arrs, qc
|
| 298 |
+
except Exception:
|
| 299 |
+
qc["error"] += 1
|
| 300 |
+
return None, qc
|
| 301 |
+
|
| 302 |
+
|
| 303 |
def main():
|
| 304 |
+
global LABELS
|
| 305 |
ap = argparse.ArgumentParser()
|
| 306 |
+
ap.add_argument("--src", action="append", required=True, help="TAG=CRAWL_DIR=HAIKU_DIR (repeatable; HAIKU_DIR may be empty)")
|
| 307 |
+
ap.add_argument("--labels", nargs="*", default=[], help="element label files (elem_label.py labels.jsonl)")
|
| 308 |
ap.add_argument("--out", required=True)
|
| 309 |
ap.add_argument("--val-frac", type=float, default=0.05)
|
| 310 |
+
ap.add_argument("--workers", type=int, default=16)
|
| 311 |
args = ap.parse_args()
|
| 312 |
+
LABELS = {k: dict(v) for k, v in load_labels(args.labels).items()}
|
| 313 |
+
tasks = []
|
| 314 |
+
for spec in args.src:
|
| 315 |
+
tag, cdir, hdir = spec.split("=")
|
| 316 |
+
crawl = Path(cdir)
|
| 317 |
+
index = {}
|
| 318 |
+
for line in (l for ip in sorted(crawl.glob("index*.jsonl")) for l in open(ip)):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 319 |
try:
|
| 320 |
+
r = json.loads(line)
|
| 321 |
+
index[r["id"]] = r
|
| 322 |
except Exception:
|
| 323 |
+
pass
|
| 324 |
+
for fp in (Path(hdir).glob("*.json") if hdir else []):
|
| 325 |
+
if len(fp.stem) == 16:
|
| 326 |
+
tasks.append((tag, cdir, "haiku", fp.stem, str(fp), index.get(fp.stem, {}).get("url")))
|
| 327 |
+
for pid, r in index.items():
|
| 328 |
+
if r.get("kind") in ("native_media", "native_class"):
|
| 329 |
+
tasks.append((tag, cdir, "native", pid, None, r["url"]))
|
| 330 |
+
print(len(tasks), "pages to build", flush=True)
|
| 331 |
+
import multiprocessing as mp
|
| 332 |
+
samples, qc = [], collections.Counter()
|
| 333 |
+
with mp.get_context("fork").Pool(args.workers) as pool:
|
| 334 |
+
for arrs, q in pool.imap_unordered(build_one, tasks, chunksize=32):
|
| 335 |
+
qc.update(q)
|
| 336 |
+
if arrs is not None:
|
| 337 |
+
samples.append(arrs)
|
| 338 |
+
samples.sort(key=lambda s: (s["crawl"], s["src"], s["id"]))
|
| 339 |
+
print("QC", dict(qc), "samples", len(samples), "elements", sum(len(s["E_ut"]) for s in samples), flush=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 340 |
hosts = sorted(set(s["host"].replace("www.", "") for s in samples))
|
| 341 |
+
# stable split: a host's side never changes as the crawl grows (and a site's inner pages share its side)
|
| 342 |
val_hosts = {h for h in hosts if int(hashlib.md5(h.encode()).hexdigest()[:8], 16) % 1000 < args.val_frac * 1000}
|
| 343 |
train = [s for s in samples if s["host"].replace("www.", "") not in val_hosts]
|
| 344 |
val = [s for s in samples if s["host"].replace("www.", "") in val_hosts]
|
| 345 |
import pickle
|
| 346 |
with open(args.out, "wb") as f:
|
| 347 |
+
pickle.dump({"train": train, "val": val, "roles": ROLES, "treat": TREAT, "img_roles": IMG_ROLES}, f, protocol=4)
|
| 348 |
+
print("train", len(train), "val", len(val), "haiku", sum(s["src"] == 0 for s in samples), "native", sum(s["src"] == 1 for s in samples),
|
| 349 |
+
"elements train", sum(len(s["E_ut"]) for s in train), "val", sum(len(s["E_ut"]) for s in val))
|
| 350 |
|
| 351 |
|
| 352 |
if __name__ == "__main__":
|
training/crawl.py
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Nadir crawler: render sites light + emulated dark, extract features, classify dark-mode support.
|
| 3 |
+
|
| 4 |
+
Output per page: OUT/pages/<id>/{light.json.gz, dark.json.gz?, shot.jpg, meta.json}
|
| 5 |
+
and a line in OUT/index.jsonl.
|
| 6 |
+
"""
|
| 7 |
+
import argparse, asyncio, base64, gzip, hashlib, json, os, random, re, sys, time, traceback
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
from playwright.async_api import async_playwright
|
| 10 |
+
|
| 11 |
+
HERE = Path(__file__).resolve().parent
|
| 12 |
+
EXTRACT_JS = (HERE / "extract.js").read_text()
|
| 13 |
+
|
| 14 |
+
NO_ANIM = """(() => { try { const s = new CSSStyleSheet(); s.replaceSync('*,*::before,*::after{transition:none!important;animation-duration:0s!important;animation-delay:0s!important;caret-color:auto}');
|
| 15 |
+
document.adoptedStyleSheets = [...document.adoptedStyleSheets, s]; window.__nadirNoAnim = s; } catch (e) {} })()"""
|
| 16 |
+
DROP_NO_ANIM = """(() => { try { document.adoptedStyleSheets = document.adoptedStyleSheets.filter(x => x !== window.__nadirNoAnim); } catch (e) {} })()"""
|
| 17 |
+
|
| 18 |
+
FETCH_SHIM = """window.__nadirFetchImage = async (src) => {
|
| 19 |
+
try { const r = await window.__nadirFetchB64(src); if (!r) return null;
|
| 20 |
+
const bin = atob(r.b64); const u = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) u[i] = bin.charCodeAt(i);
|
| 21 |
+
return new Blob([u], {type: r.ct || 'application/octet-stream'}); } catch (e) { return null; } };"""
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def page_id(url):
|
| 25 |
+
return hashlib.sha1(url.encode()).hexdigest()[:16]
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
async def settle(page, ms=600):
|
| 29 |
+
try:
|
| 30 |
+
await page.wait_for_timeout(ms)
|
| 31 |
+
except Exception:
|
| 32 |
+
pass
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
async def autoscroll(page):
|
| 36 |
+
# trigger lazy content, then return to top
|
| 37 |
+
try:
|
| 38 |
+
await page.evaluate("""async () => {
|
| 39 |
+
const H = Math.min(document.documentElement.scrollHeight, innerHeight * 6);
|
| 40 |
+
for (let y = 0; y < H; y += innerHeight * 0.8) { scrollTo(0, y); await new Promise(r => setTimeout(r, 120)); }
|
| 41 |
+
scrollTo(0, 0); await new Promise(r => setTimeout(r, 250)); }""")
|
| 42 |
+
except Exception:
|
| 43 |
+
pass
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
async def extract(page, **opts):
|
| 47 |
+
await page.evaluate(EXTRACT_JS)
|
| 48 |
+
o = dict(opts)
|
| 49 |
+
js = """async (o) => await __nadirExtract(o)"""
|
| 50 |
+
return await page.evaluate(js, o)
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
DARK_TOGGLE_JS = """(sel) => {
|
| 54 |
+
const html = document.documentElement, body = document.body;
|
| 55 |
+
const undo = [];
|
| 56 |
+
const m = sel.match(/^(html|body|:root)?(.*)$/);
|
| 57 |
+
const target = (m[1] === 'body') ? body : html;
|
| 58 |
+
const rest = m[2];
|
| 59 |
+
let cm = rest.match(/^\\.([\\w-]+)$/);
|
| 60 |
+
let am = rest.match(/^\\[([\\w-]+)(?:[~|^*$]?=\\s*["']?([\\w-]+)["']?)?\\s*(?:i\\s*)?\\]$/);
|
| 61 |
+
if (cm) { for (const t of [html, body]) { if (!t.classList.contains(cm[1])) { t.classList.add(cm[1]); undo.push(['c', t, cm[1]]); } } }
|
| 62 |
+
else if (am) { const v = am[2] || 'dark'; for (const t of [html, body]) { const old = t.getAttribute(am[1]); t.setAttribute(am[1], v); undo.push(['a', t, am[1], old]); } }
|
| 63 |
+
else return false;
|
| 64 |
+
window.__nadirUndo = undo; return true; }"""
|
| 65 |
+
UNDO_JS = """() => { for (const u of (window.__nadirUndo || [])) { if (u[0] === 'c') u[1].classList.remove(u[2]); else if (u[3] === null) u[1].removeAttribute(u[2]); else u[1].setAttribute(u[2], u[3]); } }"""
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
LINKS_JS = r"""() => { const o = location.origin, seen = new Set(), out = [];
|
| 69 |
+
for (const a of document.querySelectorAll('a[href]')) { let u; try { u = new URL(a.href); } catch (e) { continue; }
|
| 70 |
+
if (u.origin !== o || u.pathname.length < 2 || /\.(pdf|zip|png|jpe?g|gif|svg|mp4|xml|json)$/i.test(u.pathname) || /login|signin|signup|register|cart|checkout|account|logout|auth/i.test(u.pathname)) continue;
|
| 71 |
+
u.hash = ''; const k = u.pathname; if (seen.has(k)) continue; seen.add(k); out.push(u.href); if (out.length >= 12) break; }
|
| 72 |
+
return out; }"""
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
IMG_STATS_JS = """async (a) => {
|
| 76 |
+
const res = await __nadirImageStats(a.images, {shot: a.shot, bigThumbSize: 96, concurrency: 8});
|
| 77 |
+
a.images.forEach((o, i) => { Object.assign(o, res[i]); o.f = __nadirImageFeatures(o, a.vw, a.vh, a.docH); });
|
| 78 |
+
return a.images; }"""
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
async def image_stats(ctx, d, shot_png):
|
| 82 |
+
"""Pixel stats in a separate blank page (like the extension's offscreen document): no page CSP, no Python transfer."""
|
| 83 |
+
if not d["images"]:
|
| 84 |
+
return
|
| 85 |
+
helper = await ctx.new_page()
|
| 86 |
+
try:
|
| 87 |
+
await helper.evaluate(EXTRACT_JS)
|
| 88 |
+
shot = "data:image/png;base64," + base64.b64encode(shot_png).decode()
|
| 89 |
+
d["images"] = await asyncio.wait_for(helper.evaluate(IMG_STATS_JS, {"images": d["images"], "shot": shot, "vw": d["vw"], "vh": d["vh"], "docH": d["docH"]}), 40)
|
| 90 |
+
except Exception as e:
|
| 91 |
+
d["imgStatsError"] = str(e)[:200]
|
| 92 |
+
finally:
|
| 93 |
+
await helper.close()
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def is_dark(d):
|
| 97 |
+
dk = d["darkness"]
|
| 98 |
+
return dk["bgL"] < 0.45 and dk["darkFrac"] > 0.6 and (dk["textL"] == 0 or dk["textL"] > 0.55)
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
async def crawl_one(ctx, url, out_dir, args):
|
| 102 |
+
pid = page_id(url)
|
| 103 |
+
pdir = out_dir / "pages" / pid
|
| 104 |
+
rec = {"id": pid, "url": url, "t": time.time()}
|
| 105 |
+
page = await ctx.new_page()
|
| 106 |
+
try:
|
| 107 |
+
t0 = time.time()
|
| 108 |
+
resp = await page.goto(url, wait_until="domcontentloaded", timeout=args.nav_timeout * 1000)
|
| 109 |
+
rec["status"] = resp.status if resp else None
|
| 110 |
+
rec["final_url"] = page.url
|
| 111 |
+
try:
|
| 112 |
+
await page.wait_for_load_state("load", timeout=8000)
|
| 113 |
+
except Exception:
|
| 114 |
+
pass
|
| 115 |
+
await settle(page, 1200)
|
| 116 |
+
await page.evaluate(NO_ANIM)
|
| 117 |
+
await autoscroll(page)
|
| 118 |
+
await settle(page, 700)
|
| 119 |
+
light = await extract(page, describe=True, thumbs=False, elemColors=True, markSvg=True, maxImages=args.max_images)
|
| 120 |
+
shot_png = await page.screenshot(type="png")
|
| 121 |
+
await image_stats(ctx, light, shot_png)
|
| 122 |
+
rec["light_ms"] = light["stats"]["ms"]
|
| 123 |
+
rec["n_tokens"] = len(light["tokens"])
|
| 124 |
+
rec["n_images"] = len(light["images"])
|
| 125 |
+
rec["darkness"] = light["darkness"]
|
| 126 |
+
rec["scheme"] = light["scheme"]
|
| 127 |
+
if light["stats"]["visible"] < 15 or len(light["tokens"]) < 4:
|
| 128 |
+
rec["kind"] = "empty"
|
| 129 |
+
return rec
|
| 130 |
+
pdir.mkdir(parents=True, exist_ok=True)
|
| 131 |
+
# screenshot (top ~2 screens)
|
| 132 |
+
H = min(int(light["docH"]), args.shot_h)
|
| 133 |
+
await page.screenshot(path=str(pdir / "shot.jpg"), type="jpeg", quality=80, full_page=True,
|
| 134 |
+
clip={"x": 0, "y": 0, "width": 1280, "height": H})
|
| 135 |
+
light_dark = is_dark(light)
|
| 136 |
+
kind = "dark" if light_dark else "light"
|
| 137 |
+
dark = None
|
| 138 |
+
# 1) emulated prefers-color-scheme: dark
|
| 139 |
+
await page.emulate_media(color_scheme="dark")
|
| 140 |
+
await settle(page, 1500)
|
| 141 |
+
dm = await extract(page, describe=False, thumbs=False, elemColors=True, pseudo=True)
|
| 142 |
+
if not light_dark and is_dark(dm):
|
| 143 |
+
kind, dark = "native_media", dm
|
| 144 |
+
elif light_dark:
|
| 145 |
+
pass
|
| 146 |
+
else:
|
| 147 |
+
# 2) class / attribute toggles found in stylesheets
|
| 148 |
+
await page.emulate_media(color_scheme="light")
|
| 149 |
+
await settle(page, 400)
|
| 150 |
+
for sel, n in (light["scheme"]["darkSelectors"] or [])[:3]:
|
| 151 |
+
if n < 8:
|
| 152 |
+
continue
|
| 153 |
+
ok = await page.evaluate(DARK_TOGGLE_JS, sel)
|
| 154 |
+
if not ok:
|
| 155 |
+
continue
|
| 156 |
+
await settle(page, 900)
|
| 157 |
+
dc = await extract(page, describe=False, thumbs=False, elemColors=True)
|
| 158 |
+
if is_dark(dc):
|
| 159 |
+
kind, dark = "native_class", dc
|
| 160 |
+
rec["toggle"] = sel
|
| 161 |
+
break
|
| 162 |
+
await page.evaluate(UNDO_JS)
|
| 163 |
+
await settle(page, 300)
|
| 164 |
+
rec["kind"] = kind
|
| 165 |
+
try:
|
| 166 |
+
rec["links"] = await page.evaluate(LINKS_JS)
|
| 167 |
+
except Exception:
|
| 168 |
+
rec["links"] = []
|
| 169 |
+
if dark is not None:
|
| 170 |
+
rec["dark_darkness"] = dark["darkness"]
|
| 171 |
+
await page.screenshot(path=str(pdir / "shot_dark.jpg"), type="jpeg", quality=80, full_page=True,
|
| 172 |
+
clip={"x": 0, "y": 0, "width": 1280, "height": H})
|
| 173 |
+
with gzip.open(pdir / "dark.json.gz", "wt") as f:
|
| 174 |
+
json.dump(dark, f, separators=(",", ":"))
|
| 175 |
+
with gzip.open(pdir / "light.json.gz", "wt") as f:
|
| 176 |
+
json.dump(light, f, separators=(",", ":"))
|
| 177 |
+
rec["secs"] = round(time.time() - t0, 1)
|
| 178 |
+
return rec
|
| 179 |
+
except Exception as e:
|
| 180 |
+
rec["kind"] = "error"
|
| 181 |
+
rec["error"] = (type(e).__name__ + ": " + str(e))[:300]
|
| 182 |
+
return rec
|
| 183 |
+
finally:
|
| 184 |
+
try:
|
| 185 |
+
await page.close()
|
| 186 |
+
except Exception:
|
| 187 |
+
pass
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
async def worker(wid, browser_pool, queue, out_dir, args, index_f, counters):
|
| 191 |
+
while True:
|
| 192 |
+
try:
|
| 193 |
+
url = queue.get_nowait()
|
| 194 |
+
except asyncio.QueueEmpty:
|
| 195 |
+
return
|
| 196 |
+
browser = browser_pool[wid % len(browser_pool)]
|
| 197 |
+
ctx = None
|
| 198 |
+
try:
|
| 199 |
+
ctx = await browser.new_context(viewport={"width": 1280, "height": 800}, device_scale_factor=1,
|
| 200 |
+
color_scheme="light", locale="en-US", ignore_https_errors=True,
|
| 201 |
+
user_agent=args.ua, java_script_enabled=True, bypass_csp=True)
|
| 202 |
+
ctx.set_default_timeout(15000)
|
| 203 |
+
rec = await asyncio.wait_for(crawl_one(ctx, url, out_dir, args), timeout=args.page_timeout)
|
| 204 |
+
except asyncio.TimeoutError:
|
| 205 |
+
rec = {"id": page_id(url), "url": url, "kind": "error", "error": "page timeout"}
|
| 206 |
+
except Exception as e:
|
| 207 |
+
rec = {"id": page_id(url), "url": url, "kind": "error", "error": str(e)[:300]}
|
| 208 |
+
finally:
|
| 209 |
+
if ctx:
|
| 210 |
+
try:
|
| 211 |
+
await asyncio.wait_for(ctx.close(), 10)
|
| 212 |
+
except Exception:
|
| 213 |
+
pass
|
| 214 |
+
counters[rec.get("kind", "error")] = counters.get(rec.get("kind", "error"), 0) + 1
|
| 215 |
+
index_f.write(json.dumps(rec) + "\n")
|
| 216 |
+
index_f.flush()
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
async def main():
|
| 220 |
+
ap = argparse.ArgumentParser()
|
| 221 |
+
ap.add_argument("--urls", required=True)
|
| 222 |
+
ap.add_argument("--out", required=True)
|
| 223 |
+
ap.add_argument("--workers", type=int, default=24)
|
| 224 |
+
ap.add_argument("--browsers", type=int, default=4)
|
| 225 |
+
ap.add_argument("--nav-timeout", type=int, default=25)
|
| 226 |
+
ap.add_argument("--page-timeout", type=int, default=90)
|
| 227 |
+
ap.add_argument("--shot-h", type=int, default=2000)
|
| 228 |
+
ap.add_argument("--max-images", type=int, default=48, help="images kept per page (the model sees the first 48; round 2 keeps more for the element head)")
|
| 229 |
+
ap.add_argument("--limit", type=int, default=0)
|
| 230 |
+
ap.add_argument("--shard", type=int, default=0)
|
| 231 |
+
ap.add_argument("--nshards", type=int, default=1)
|
| 232 |
+
ap.add_argument("--ua", default="Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36")
|
| 233 |
+
args = ap.parse_args()
|
| 234 |
+
out_dir = Path(args.out)
|
| 235 |
+
(out_dir / "pages").mkdir(parents=True, exist_ok=True)
|
| 236 |
+
done = set()
|
| 237 |
+
for ip in out_dir.glob("index*.jsonl"):
|
| 238 |
+
for line in ip.open():
|
| 239 |
+
try:
|
| 240 |
+
done.add(json.loads(line)["url"])
|
| 241 |
+
except Exception:
|
| 242 |
+
pass
|
| 243 |
+
idx = out_dir / ("index.jsonl" if args.nshards == 1 else f"index.{args.shard}.jsonl")
|
| 244 |
+
urls = [u.strip() for u in open(args.urls) if u.strip() and not u.startswith("#")]
|
| 245 |
+
urls = [u for u in urls if u not in done and (args.nshards == 1 or int(hashlib.sha1(u.encode()).hexdigest(), 16) % args.nshards == args.shard)]
|
| 246 |
+
if args.limit:
|
| 247 |
+
urls = urls[: args.limit]
|
| 248 |
+
print(f"{len(urls)} urls to crawl ({len(done)} done)", flush=True)
|
| 249 |
+
q = asyncio.Queue()
|
| 250 |
+
for u in urls:
|
| 251 |
+
q.put_nowait(u)
|
| 252 |
+
counters = {}
|
| 253 |
+
async with async_playwright() as p:
|
| 254 |
+
browsers = []
|
| 255 |
+
for _ in range(args.browsers):
|
| 256 |
+
browsers.append(await p.chromium.launch(headless=True, args=[
|
| 257 |
+
"--disable-web-security", "--disable-features=IsolateOrigins,site-per-process,BlockInsecurePrivateNetworkRequests",
|
| 258 |
+
"--disable-dev-shm-usage", "--no-first-run", "--mute-audio", "--hide-scrollbars", "--font-render-hinting=none",
|
| 259 |
+
"--autoplay-policy=user-gesture-required", "--disable-notifications"]))
|
| 260 |
+
with idx.open("a") as f:
|
| 261 |
+
start = time.time()
|
| 262 |
+
async def report():
|
| 263 |
+
while True:
|
| 264 |
+
await asyncio.sleep(60)
|
| 265 |
+
n = sum(counters.values())
|
| 266 |
+
print(f"[{time.strftime('%H:%M:%S')}] {n} done, {n / max(1, time.time() - start) * 3600:.0f}/h {counters}", flush=True)
|
| 267 |
+
rep = asyncio.create_task(report())
|
| 268 |
+
await asyncio.gather(*[worker(i, browsers, q, out_dir, args, f, counters) for i in range(args.workers)])
|
| 269 |
+
rep.cancel()
|
| 270 |
+
for b in browsers:
|
| 271 |
+
await b.close()
|
| 272 |
+
print("DONE", counters, flush=True)
|
| 273 |
+
|
| 274 |
+
|
| 275 |
+
if __name__ == "__main__":
|
| 276 |
+
asyncio.run(main())
|
training/elem_eval.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Element-head evaluation on held-out sites: does each image get a rendering Haiku judged acceptable?
|
| 3 |
+
|
| 4 |
+
Compares the v2 element head with what v1 shipped for the same images (its 5-class image head for the first 32
|
| 5 |
+
images of a page, the hand-written runtime rules for everything else), per kind of image.
|
| 6 |
+
"""
|
| 7 |
+
import argparse, collections, gzip, importlib.util, json, pickle, sys
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
import numpy as np
|
| 10 |
+
import torch
|
| 11 |
+
|
| 12 |
+
from model import NadirNet, TREAT
|
| 13 |
+
from train import collate, prep, element_logits
|
| 14 |
+
import safety
|
| 15 |
+
|
| 16 |
+
V1_MAP = {"keep": 0, "dim": 0, "invert": 1, "light": 1, "plate": 1, "clear": 3}
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def v1_decision(t, im, surf_L):
|
| 20 |
+
"""v1 runtime: model treatment (or 'keep' for images it never saw) through the hand-written rules."""
|
| 21 |
+
s = safety.safe_treatment(t, im, surf_L)
|
| 22 |
+
if s.startswith("adapt:"):
|
| 23 |
+
return 2 # the logo adaptation path
|
| 24 |
+
return V1_MAP.get(s, 0)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def main():
|
| 28 |
+
ap = argparse.ArgumentParser()
|
| 29 |
+
ap.add_argument("--ckpt", required=True)
|
| 30 |
+
ap.add_argument("--data", required=True)
|
| 31 |
+
ap.add_argument("--crawl", action="append", required=True, help="TAG=CRAWL_DIR")
|
| 32 |
+
ap.add_argument("--v1-ckpt", default="")
|
| 33 |
+
ap.add_argument("--v1-src", default="", help="v1 model.py (its architecture)")
|
| 34 |
+
ap.add_argument("--out", required=True)
|
| 35 |
+
ap.add_argument("--rows", default="", help="also write every element (probabilities, labels) as jsonl")
|
| 36 |
+
args = ap.parse_args()
|
| 37 |
+
crawls = dict(c.split("=", 1) for c in args.crawl)
|
| 38 |
+
dev = "cuda" if torch.cuda.is_available() else "cpu"
|
| 39 |
+
ck = torch.load(args.ckpt, map_location="cpu")
|
| 40 |
+
T = float(ck.get("temperature", 1.0))
|
| 41 |
+
m = NadirNet(**ck["cfg"]); m.load_state_dict(ck["model"]); m.eval().to(dev)
|
| 42 |
+
v1 = None
|
| 43 |
+
if args.v1_ckpt and args.v1_src:
|
| 44 |
+
spec = importlib.util.spec_from_file_location("model_v1", args.v1_src)
|
| 45 |
+
mv1 = importlib.util.module_from_spec(spec); spec.loader.exec_module(mv1)
|
| 46 |
+
c1 = torch.load(args.v1_ckpt, map_location="cpu")
|
| 47 |
+
v1 = mv1.NadirNet(**c1["cfg"]); v1.load_state_dict(c1["model"]); v1.eval().to(dev)
|
| 48 |
+
data = pickle.load(open(args.data, "rb"))
|
| 49 |
+
val = [prep(s) for s in data["val"] if len(s.get("E_ut", []))]
|
| 50 |
+
rows = []
|
| 51 |
+
with torch.no_grad():
|
| 52 |
+
for s in val:
|
| 53 |
+
b = collate([s], dev)
|
| 54 |
+
lab, _, ctx = m(b["tok_f"], b["role"], b["lab"], b["img_f"], b["thumbs"], b["page"], b["src"], b["rel"], b["mask"])
|
| 55 |
+
tl, rl = element_logits(m, b, lab.float(), ctx)
|
| 56 |
+
k = int(b["e_mask"][0].sum())
|
| 57 |
+
p = torch.softmax(tl[0, :k] / T, -1).cpu().numpy()
|
| 58 |
+
role = rl[0, :k].argmax(-1).cpu().numpy()
|
| 59 |
+
rprob = torch.softmax(rl[0, :k].float() / T, -1).max(-1).values.cpu().numpy()
|
| 60 |
+
v1t = None
|
| 61 |
+
if v1 is not None and len(s["IF"]):
|
| 62 |
+
_, _, il = v1(b["tok_f"], b["role"], b["lab"], b["img_f"][..., :39], b["thumbs"], b["page"], b["src"], b["rel"], b["mask"])
|
| 63 |
+
v1t = il[0].float().argmax(-1).cpu().numpy()
|
| 64 |
+
L = json.load(gzip.open(Path(crawls[s["crawl"]]) / "pages" / s["id"] / "light.json.gz"))
|
| 65 |
+
n = len(s["role"])
|
| 66 |
+
outL = lab[0, :n, 0].float().cpu().numpy()
|
| 67 |
+
for q in range(k):
|
| 68 |
+
j = int(s["E_idx"][q])
|
| 69 |
+
im = L["images"][j]
|
| 70 |
+
u = im.get("underTok", -1)
|
| 71 |
+
surf_L = float(outL[u]) if 0 <= u < n else None
|
| 72 |
+
ud = [round(float(x), 4) for x in lab[0, u].float().cpu().numpy()] if 0 <= u < n else None
|
| 73 |
+
old = ["keep", "dim", "invert", "light", "plate"][int(v1t[j])] if (v1t is not None and j < 32 and j < len(v1t)) else "keep"
|
| 74 |
+
soft = s["E_soft"][q]
|
| 75 |
+
rows.append({"bucket": s["E_bucket"][q], "kind": s["E_kind"][q], "soft": soft.tolist(), "best": int(np.argmax(soft)),
|
| 76 |
+
"v2": int(np.argmax(p[q])), "conf": float(p[q].max()), "p": [round(float(x), 4) for x in p[q]], "role_pred": int(role[q]), "role": int(s["E_role"][q]),
|
| 77 |
+
"v1": v1_decision(old, im, surf_L), "v1_seen": bool(v1t is not None and j < 32), "nlab": int(s["E_nlab"][q]),
|
| 78 |
+
**({"rp": round(float(rprob[q]), 4), "f": [round(float(x), 4) for x in s["E_f"][q]], "ud": ud, "ul": [round(float(x), 4) for x in s["E_ul"][q]]} if args.rows else {})})
|
| 79 |
+
def score(rs, key):
|
| 80 |
+
if not rs:
|
| 81 |
+
return {}
|
| 82 |
+
acc = np.mean([r[key] == r["best"] for r in rs])
|
| 83 |
+
ok = np.mean([r["soft"][r[key]] > 0 for r in rs])
|
| 84 |
+
return {"n": len(rs), "acc": round(float(acc), 4), "acceptable": round(float(ok), 4)}
|
| 85 |
+
rep = {"temperature": T, "overall": {"v2": score(rows, "v2"), "v1": score(rows, "v1")}, "by_bucket": {}, "by_kind": {}}
|
| 86 |
+
for key in ("bucket", "kind"):
|
| 87 |
+
groups = collections.defaultdict(list)
|
| 88 |
+
for r in rows:
|
| 89 |
+
groups[r[key]].append(r)
|
| 90 |
+
rep["by_" + key] = {g: {"v2": score(rs, "v2"), "v1": score(rs, "v1")} for g, rs in sorted(groups.items())}
|
| 91 |
+
# calibration: confidence vs accuracy
|
| 92 |
+
bins = collections.defaultdict(list)
|
| 93 |
+
for r in rows:
|
| 94 |
+
bins[min(9, int(r["conf"] * 10))].append(r["v2"] == r["best"])
|
| 95 |
+
rep["calibration"] = {f"{b / 10:.1f}": {"n": len(v), "acc": round(float(np.mean(v)), 3)} for b, v in sorted(bins.items())}
|
| 96 |
+
rr = [r for r in rows if r["role"] >= 0]
|
| 97 |
+
rep["role_acc"] = round(float(np.mean([r["role_pred"] == r["role"] for r in rr])), 4) if rr else None
|
| 98 |
+
# Haiku's own consistency where a second, independent label exists
|
| 99 |
+
rep["n_double_labelled"] = sum(r["nlab"] > 1 for r in rows)
|
| 100 |
+
json.dump(rep, open(args.out, "w"), indent=1)
|
| 101 |
+
if args.rows:
|
| 102 |
+
with open(args.rows, "w") as f:
|
| 103 |
+
for r in rows:
|
| 104 |
+
f.write(json.dumps(r) + "\n")
|
| 105 |
+
print(json.dumps({k: rep[k] for k in ("overall", "role_acc", "temperature")}, indent=1))
|
| 106 |
+
print(json.dumps(rep["by_bucket"], indent=1))
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
if __name__ == "__main__":
|
| 110 |
+
main()
|
training/elem_label.py
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Element-head teacher data, step 2: Claude Haiku 5.5 picks how each image should render in dark mode.
|
| 3 |
+
|
| 4 |
+
Reads the contact sheets from elem_sheets.py and asks, per row, for the best rendering (A keep, B invert, C adapt,
|
| 5 |
+
D remove), every acceptable one, and the image's role. Batch API (half price) with a cost ledger and a hard budget;
|
| 6 |
+
reads ANTHROPIC_API_KEY from the environment. Output: OUT/labels.jsonl, one line per sheet.
|
| 7 |
+
"""
|
| 8 |
+
import argparse, base64, json, os, random, sys, time, urllib.request
|
| 9 |
+
from concurrent.futures import ThreadPoolExecutor
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
|
| 12 |
+
MODEL = "claude-haiku-5-5"
|
| 13 |
+
PRICE_IN, PRICE_OUT = 0.10 / 1e6, 0.50 / 1e6
|
| 14 |
+
PROXY = os.environ.get("ANTHROPIC_BASE_URL", "https://api.anthropic.com")
|
| 15 |
+
HDRS = {"x-api-key": os.environ.get("ANTHROPIC_API_KEY", ""), "anthropic-version": "2023-06-01", "content-type": "application/json"}
|
| 16 |
+
ROLES = ["photo", "illustration", "diagram", "logo", "icon", "text", "backdrop", "avatar", "ui"]
|
| 17 |
+
OPTS = ["A", "B", "C", "D"]
|
| 18 |
+
|
| 19 |
+
SYSTEM = """You are a senior product designer reviewing how a dark-mode browser extension renders the images of light websites. Each numbered row of the sheet is one image from a real web page.
|
| 20 |
+
|
| 21 |
+
Left: the image in its original light page (inside the red box), or on its light background when the page area was not captured. Then the candidate renderings on the dark surface that the dark theme puts behind that image. These are the exact pixels the browser would show:
|
| 22 |
+
- A keep: unchanged.
|
| 23 |
+
- B invert: hue-preserving inversion. Dark becomes light and light becomes dark while hues stay, so yellows, pastels and white areas turn dark.
|
| 24 |
+
- C adapt: only dark neutral ink (black or gray text, lines, glyphs) becomes light, and a white background box is removed; colours are kept and lifted just enough to read on dark.
|
| 25 |
+
- D remove: the background image is hidden so the dark surface shows through (offered only for large CSS backgrounds; the sample text shows how content on top of it will look in dark mode).
|
| 26 |
+
"n/a" means that rendering is not possible for this image.
|
| 27 |
+
|
| 28 |
+
Pick the rendering a careful designer would ship in a polished dark theme:
|
| 29 |
+
- Photos, video thumbnails, artwork, product shots, screenshots, maps, avatars, flags, emoji, ads and colourful illustrations must look exactly as their author intended: A. Never alter the colours of real content.
|
| 30 |
+
- Everything must stay clearly visible. Dark icons, arrows, glyphs, text rendered as an image, math formulas and line art that would vanish or be hard to see on the dark surface must become light: B or C, whichever looks more natural.
|
| 31 |
+
- Logos must stay recognizable and true to the brand. Keep brand colours: A when the logo already reads well on the dark surface, C when it mixes brand colours with dark text or sits in a white box, B only if the inverted logo still looks like the brand.
|
| 32 |
+
- Diagrams, charts and documents on white: A unless the inverted or adapted version stays faithful and clearly readable.
|
| 33 |
+
- Large light textures, pale washes, subtle patterns and plain light panels behind content: D. Photographic or illustrated backgrounds: A.
|
| 34 |
+
- A white box around an image is acceptable for content (photos, screenshots), not for icons and logos when a cleaner option exists.
|
| 35 |
+
|
| 36 |
+
For every row return its number k, the image's role, the best rendering, and every rendering you would consider acceptable (always including the best). Roles: photo, illustration, diagram (charts, maps, documents, screenshots of text), logo, icon (UI glyphs and symbols), text (text or math rendered as an image), backdrop (decorative background behind content), avatar (people, profile pictures), ui (other interface decoration: dividers, bullets, shapes, badges)."""
|
| 37 |
+
|
| 38 |
+
SCHEMA = {
|
| 39 |
+
"type": "object",
|
| 40 |
+
"properties": {"rows": {"type": "array", "items": {"type": "object", "properties": {
|
| 41 |
+
"k": {"type": "integer"}, "role": {"type": "string", "enum": ROLES}, "best": {"type": "string", "enum": OPTS},
|
| 42 |
+
"ok": {"type": "array", "items": {"type": "string", "enum": OPTS}}}, "required": ["k", "role", "best", "ok"], "additionalProperties": False}}},
|
| 43 |
+
"required": ["rows"], "additionalProperties": False,
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def http(method, path, body=None, timeout=300):
|
| 48 |
+
req = urllib.request.Request(PROXY + path, data=json.dumps(body).encode() if body is not None else None, headers=HDRS, method=method)
|
| 49 |
+
with urllib.request.urlopen(req, timeout=timeout) as r:
|
| 50 |
+
return r.read()
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def as_jpeg(png):
|
| 54 |
+
from PIL import Image
|
| 55 |
+
import io
|
| 56 |
+
buf = io.BytesIO()
|
| 57 |
+
Image.open(io.BytesIO(png)).convert("RGB").save(buf, "JPEG", quality=88)
|
| 58 |
+
return buf.getvalue()
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def params(sheet_png, n_rows, args):
|
| 62 |
+
content = [{"type": "image", "source": {"type": "base64", "media_type": "image/jpeg", "data": base64.b64encode(as_jpeg(sheet_png)).decode()}},
|
| 63 |
+
{"type": "text", "text": f"This sheet has {n_rows} rows (#1-#{n_rows}). Return one entry per row."}]
|
| 64 |
+
return {"model": MODEL, "max_tokens": args.max_out, "system": SYSTEM, "thinking": {"type": "adaptive"},
|
| 65 |
+
"output_config": {"effort": args.effort, "format": {"type": "json_schema", "schema": SCHEMA}},
|
| 66 |
+
"messages": [{"role": "user", "content": content}]}
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def parse(msg, rows):
|
| 70 |
+
txt = "".join(b.get("text", "") for b in msg.get("content", []) if b.get("type") == "text")
|
| 71 |
+
ans = json.loads(txt)["rows"]
|
| 72 |
+
out = {}
|
| 73 |
+
for a in ans:
|
| 74 |
+
k = int(a["k"])
|
| 75 |
+
if not 1 <= k <= len(rows) or k in out:
|
| 76 |
+
continue
|
| 77 |
+
offer = rows[k - 1]["offer"]
|
| 78 |
+
best = a["best"] if a["best"] in offer else None
|
| 79 |
+
ok = sorted(set(x for x in a["ok"] if x in offer) | ({best} if best else set()))
|
| 80 |
+
if best:
|
| 81 |
+
out[k] = {"role": a["role"], "best": best, "ok": ok}
|
| 82 |
+
return out
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def usage_cost(u, batch=True):
|
| 86 |
+
c = u.get("input_tokens", 0) * PRICE_IN + u.get("output_tokens", 0) * PRICE_OUT
|
| 87 |
+
return c * (0.5 if batch else 1.0)
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
class Ledger:
|
| 91 |
+
def __init__(self, path):
|
| 92 |
+
self.path, self.cost, self.n = path, 0.0, 0
|
| 93 |
+
if path.exists():
|
| 94 |
+
for l in path.open():
|
| 95 |
+
r = json.loads(l)
|
| 96 |
+
self.cost += r["cost"]
|
| 97 |
+
self.n += 1
|
| 98 |
+
|
| 99 |
+
def add(self, rec):
|
| 100 |
+
self.cost += rec["cost"]
|
| 101 |
+
self.n += 1
|
| 102 |
+
with self.path.open("a") as f:
|
| 103 |
+
f.write(json.dumps(rec) + "\n")
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def load_sheets(sheet_dir):
|
| 107 |
+
sheets = {}
|
| 108 |
+
for mp in sorted(Path(sheet_dir).glob("meta.*.jsonl")):
|
| 109 |
+
for l in mp.open():
|
| 110 |
+
if l.strip():
|
| 111 |
+
s = json.loads(l)
|
| 112 |
+
sheets[s["sheet"]] = s
|
| 113 |
+
return sheets
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def write_label(out_f, sid, meta, labels, rec, dup=False):
|
| 117 |
+
rows = []
|
| 118 |
+
for r in meta["rows"]:
|
| 119 |
+
lab = labels.get(r["k"])
|
| 120 |
+
if lab:
|
| 121 |
+
rows.append(dict(r, **lab))
|
| 122 |
+
out_f.write(json.dumps({"sheet": sid, "dup": dup, "type": meta["type"], "rows": rows, "usage": rec}) + "\n")
|
| 123 |
+
out_f.flush()
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
def pilot(args, sheets, ledger, out_dir):
|
| 127 |
+
ids = list(sheets)[: args.pilot]
|
| 128 |
+
with (out_dir / "pilot.jsonl").open("a") as f:
|
| 129 |
+
def one(sid):
|
| 130 |
+
meta = sheets[sid]
|
| 131 |
+
png = (Path(args.sheets) / "sheets" / f"{sid}.png").read_bytes()
|
| 132 |
+
t0 = time.time()
|
| 133 |
+
m = json.loads(http("POST", "/v1/messages", params(png, len(meta["rows"]), args), timeout=600))
|
| 134 |
+
u = m.get("usage", {})
|
| 135 |
+
rec = {"id": sid, "cost": usage_cost(u, batch=False), "in": u.get("input_tokens"), "out": u.get("output_tokens"), "secs": round(time.time() - t0, 1)}
|
| 136 |
+
ledger.add(rec)
|
| 137 |
+
write_label(f, sid, meta, parse(m, meta["rows"]), rec)
|
| 138 |
+
return rec
|
| 139 |
+
with ThreadPoolExecutor(6) as ex:
|
| 140 |
+
for rec in ex.map(one, ids):
|
| 141 |
+
print(rec, flush=True)
|
| 142 |
+
print(f"pilot spent ${ledger.cost:.4f} on {ledger.n}", flush=True)
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
def batch(args, sheets, ledger, out_dir):
|
| 146 |
+
state_p = out_dir / "batches.jsonl"
|
| 147 |
+
batches = {}
|
| 148 |
+
if state_p.exists():
|
| 149 |
+
for l in state_p.open():
|
| 150 |
+
b = json.loads(l)
|
| 151 |
+
batches.setdefault(b["id"], {}).update(b)
|
| 152 |
+
submitted = set(s for b in batches.values() for s in b.get("ids", []))
|
| 153 |
+
labels_f = (out_dir / "labels.jsonl").open("a")
|
| 154 |
+
est = args.est
|
| 155 |
+
rnd = random.Random(11)
|
| 156 |
+
while True:
|
| 157 |
+
pending = 0.0
|
| 158 |
+
for bid, b in list(batches.items()):
|
| 159 |
+
if b.get("collected"):
|
| 160 |
+
continue
|
| 161 |
+
try:
|
| 162 |
+
info = json.loads(http("GET", f"/v1/messages/batches/{bid}"))
|
| 163 |
+
except Exception as e:
|
| 164 |
+
print("poll error", str(e)[:200], flush=True)
|
| 165 |
+
pending += len(b["ids"]) * est
|
| 166 |
+
continue
|
| 167 |
+
if info["processing_status"] != "ended":
|
| 168 |
+
pending += len(b["ids"]) * est
|
| 169 |
+
continue
|
| 170 |
+
raw = http("GET", f"/v1/messages/batches/{bid}/results", timeout=900).decode()
|
| 171 |
+
ok = bad = 0
|
| 172 |
+
for line in raw.splitlines():
|
| 173 |
+
if not line.strip():
|
| 174 |
+
continue
|
| 175 |
+
r = json.loads(line)
|
| 176 |
+
cid = r["custom_id"]
|
| 177 |
+
sid, dup = cid.split("_")[0], cid.endswith("_dup")
|
| 178 |
+
res = r["result"]
|
| 179 |
+
if res["type"] != "succeeded":
|
| 180 |
+
bad += 1
|
| 181 |
+
continue
|
| 182 |
+
m = res["message"]
|
| 183 |
+
u = m.get("usage", {})
|
| 184 |
+
rec = {"id": cid, "cost": usage_cost(u), "in": u.get("input_tokens"), "out": u.get("output_tokens"), "batch": bid}
|
| 185 |
+
ledger.add(rec)
|
| 186 |
+
try:
|
| 187 |
+
write_label(labels_f, sid, sheets[sid], parse(m, sheets[sid]["rows"]), rec, dup)
|
| 188 |
+
ok += 1
|
| 189 |
+
except Exception:
|
| 190 |
+
bad += 1
|
| 191 |
+
b["collected"] = True
|
| 192 |
+
with state_p.open("a") as f:
|
| 193 |
+
f.write(json.dumps({"id": bid, "collected": True}) + "\n")
|
| 194 |
+
print(f"[{time.strftime('%H:%M:%S')}] collected {bid}: {ok} ok, {bad} failed", flush=True)
|
| 195 |
+
if ledger.n > 100:
|
| 196 |
+
est = max(0.0005, ledger.cost / ledger.n)
|
| 197 |
+
if args.follow:
|
| 198 |
+
sheets.update(load_sheets(args.sheets))
|
| 199 |
+
new = [s for s in sheets if s not in submitted]
|
| 200 |
+
room = args.budget - ledger.cost - pending
|
| 201 |
+
can = int(max(0, room) / est)
|
| 202 |
+
if new and can > 0:
|
| 203 |
+
chunk = new[: min(args.batch_size, can)]
|
| 204 |
+
reqs = []
|
| 205 |
+
for sid in chunk:
|
| 206 |
+
png = (Path(args.sheets) / "sheets" / f"{sid}.png").read_bytes()
|
| 207 |
+
p = params(png, len(sheets[sid]["rows"]), args)
|
| 208 |
+
reqs.append({"custom_id": sid, "params": p})
|
| 209 |
+
if rnd.random() < args.dup: # independent second opinion: measures label agreement
|
| 210 |
+
reqs.append({"custom_id": sid + "_dup", "params": p})
|
| 211 |
+
try:
|
| 212 |
+
info = json.loads(http("POST", "/v1/messages/batches", {"requests": reqs}, timeout=900))
|
| 213 |
+
b = {"id": info["id"], "ids": chunk, "t": time.time()}
|
| 214 |
+
batches[info["id"]] = b
|
| 215 |
+
with state_p.open("a") as f:
|
| 216 |
+
f.write(json.dumps(b) + "\n")
|
| 217 |
+
submitted.update(chunk)
|
| 218 |
+
print(f"[{time.strftime('%H:%M:%S')}] submitted {info['id']} with {len(reqs)} (spent ${ledger.cost:.2f}, pending ~${pending:.2f}, est/sheet ${est:.4f})", flush=True)
|
| 219 |
+
continue
|
| 220 |
+
except Exception as e:
|
| 221 |
+
print("submit error", str(e)[:300], flush=True)
|
| 222 |
+
time.sleep(60)
|
| 223 |
+
open_b = [b for b in batches.values() if not b.get("collected")]
|
| 224 |
+
print(f"[{time.strftime('%H:%M:%S')}] spent ${ledger.cost:.3f} on {ledger.n} | open batches {len(open_b)} | unsubmitted {len(new)}", flush=True)
|
| 225 |
+
if not open_b and (not new or can <= 0) and not args.follow:
|
| 226 |
+
break
|
| 227 |
+
if room <= 0 and not open_b:
|
| 228 |
+
print("budget reached", flush=True)
|
| 229 |
+
break
|
| 230 |
+
time.sleep(args.poll)
|
| 231 |
+
print(f"DONE ${ledger.cost:.3f} {ledger.n}", flush=True)
|
| 232 |
+
|
| 233 |
+
|
| 234 |
+
def main():
|
| 235 |
+
ap = argparse.ArgumentParser()
|
| 236 |
+
ap.add_argument("--sheets", required=True)
|
| 237 |
+
ap.add_argument("--out", required=True)
|
| 238 |
+
ap.add_argument("--budget", type=float, default=25.0)
|
| 239 |
+
ap.add_argument("--effort", default="high")
|
| 240 |
+
ap.add_argument("--max-out", type=int, default=16000)
|
| 241 |
+
ap.add_argument("--pilot", type=int, default=0, help="label N sheets with the realtime API and stop")
|
| 242 |
+
ap.add_argument("--batch-size", type=int, default=300)
|
| 243 |
+
ap.add_argument("--dup", type=float, default=0.02)
|
| 244 |
+
ap.add_argument("--est", type=float, default=0.002)
|
| 245 |
+
ap.add_argument("--poll", type=int, default=60)
|
| 246 |
+
ap.add_argument("--follow", action="store_true", help="keep picking up new sheets")
|
| 247 |
+
args = ap.parse_args()
|
| 248 |
+
out_dir = Path(args.out)
|
| 249 |
+
out_dir.mkdir(parents=True, exist_ok=True)
|
| 250 |
+
sheets = load_sheets(args.sheets)
|
| 251 |
+
ledger = Ledger(out_dir / ("ledger_pilot.jsonl" if args.pilot else "ledger.jsonl"))
|
| 252 |
+
print(len(sheets), "sheets", flush=True)
|
| 253 |
+
if args.pilot:
|
| 254 |
+
return pilot(args, sheets, ledger, out_dir)
|
| 255 |
+
batch(args, sheets, ledger, out_dir)
|
| 256 |
+
|
| 257 |
+
|
| 258 |
+
if __name__ == "__main__":
|
| 259 |
+
main()
|
training/elem_sheets.py
ADDED
|
@@ -0,0 +1,408 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Element-head teacher data, step 1: contact sheets showing each page image every way the extension can render it.
|
| 3 |
+
|
| 4 |
+
For each sampled image (logo, icon, formula, photo, backdrop...) of a crawled light page that has a Haiku dark design,
|
| 5 |
+
one row: the element in its light page (red outline), then the candidates on the dark surface designed for that spot:
|
| 6 |
+
A keep - unchanged
|
| 7 |
+
B invert - exactly Chrome's `invert(1) hue-rotate(180deg)` (dark <-> light, hues kept)
|
| 8 |
+
C adapt - the runtime's adaptPixels: neutral dark ink -> light, white box removed, brand colours kept
|
| 9 |
+
D remove - large CSS background images only: the designed dark surface shows through
|
| 10 |
+
Writes OUT/sheets/<id>.png and OUT/meta.<worker>.jsonl (one line per sheet: rows with page id, image index, offers).
|
| 11 |
+
"""
|
| 12 |
+
import argparse, base64, glob, gzip, hashlib, io, json, math, os, random, sys
|
| 13 |
+
from multiprocessing import Pool
|
| 14 |
+
from pathlib import Path
|
| 15 |
+
|
| 16 |
+
import numpy as np
|
| 17 |
+
from PIL import Image, ImageDraw, ImageFont
|
| 18 |
+
|
| 19 |
+
HUE180 = np.array([[-0.574, 1.43, 0.144], [0.426, 0.43, 0.144], [0.426, 1.43, -0.856]])
|
| 20 |
+
SMALL, LARGE = "small", "large"
|
| 21 |
+
GEOM = {SMALL: dict(cell=(124, 112), ctx=(232, 112), rows=8), LARGE: dict(cell=(250, 140), ctx=(250, 140), rows=5)}
|
| 22 |
+
KIND = {"img": "image", "bg": "CSS background", "pseudo": "CSS icon", "svg": "inline SVG", "svgimage": "SVG image"}
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
# ---------- colour maths (same as the runtime) ----------
|
| 26 |
+
def lin(u):
|
| 27 |
+
u = u / 255.0
|
| 28 |
+
return np.where(u <= 0.04045, u / 12.92, ((u + 0.055) / 1.055) ** 2.4)
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def oklab(rgb): # (..., 3) uint8 -> (..., 3)
|
| 32 |
+
r, g, b = lin(rgb[..., 0]), lin(rgb[..., 1]), lin(rgb[..., 2])
|
| 33 |
+
l = np.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b)
|
| 34 |
+
m = np.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b)
|
| 35 |
+
s = np.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b)
|
| 36 |
+
return np.stack([0.2104542553 * l + 0.7936177850 * m - 0.0040720468 * s,
|
| 37 |
+
1.9779984951 * l - 2.4285922050 * m + 0.4505937099 * s,
|
| 38 |
+
0.0259040371 * l + 0.7827717662 * m - 0.8086757660 * s], -1)
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def to_rgb(L, a, b):
|
| 42 |
+
l_ = L + 0.3963377774 * a + 0.2158037573 * b
|
| 43 |
+
m_ = L - 0.1055613458 * a - 0.0638541728 * b
|
| 44 |
+
s_ = L - 0.0894841775 * a - 1.2914855480 * b
|
| 45 |
+
l, m, s = l_ ** 3, m_ ** 3, s_ ** 3
|
| 46 |
+
def enc(x):
|
| 47 |
+
x = np.clip(x, 0, 1)
|
| 48 |
+
return np.round(255 * np.where(x <= 0.0031308, 12.92 * x, 1.055 * np.power(x, 1 / 2.4) - 0.055))
|
| 49 |
+
return np.stack([enc(4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s),
|
| 50 |
+
enc(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s),
|
| 51 |
+
enc(-0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s)], -1)
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def invert(rgba):
|
| 55 |
+
x = 1.0 - rgba[..., :3] / 255.0
|
| 56 |
+
out = rgba.copy()
|
| 57 |
+
out[..., :3] = np.round(np.clip(x @ HUE180.T, 0, 1) * 255)
|
| 58 |
+
return out
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def adapt(rgba):
|
| 62 |
+
"""Port of nadir-map.js adaptPixels (incl. transparent mode). None when there is nothing to adapt."""
|
| 63 |
+
d = rgba.astype(np.float64)
|
| 64 |
+
h, w = d.shape[:2]
|
| 65 |
+
ring = np.zeros((h, w), bool)
|
| 66 |
+
ring[0, :] = ring[-1, :] = ring[:, 0] = ring[:, -1] = True
|
| 67 |
+
rp = d[ring]
|
| 68 |
+
ringN = len(rp)
|
| 69 |
+
opq = rp[:, 3] >= 250
|
| 70 |
+
n = int(opq.sum())
|
| 71 |
+
o = oklab(rp[opq][:, :3]) if n else np.zeros((0, 3))
|
| 72 |
+
lightm = (o[:, 0] > 0.9) & (np.hypot(o[:, 1], o[:, 2]) < 0.04) if n else np.zeros(0, bool)
|
| 73 |
+
light = int(lightm.sum())
|
| 74 |
+
bg = None
|
| 75 |
+
if n and light / n >= 0.5:
|
| 76 |
+
bg = oklab(rp[opq][lightm][:, :3].mean(0))
|
| 77 |
+
elif n > 0.2 * ringN:
|
| 78 |
+
return None
|
| 79 |
+
else:
|
| 80 |
+
a = d[..., 3] >= 128
|
| 81 |
+
if not a.any():
|
| 82 |
+
return None
|
| 83 |
+
ol = oklab(d[a][:, :3])
|
| 84 |
+
ink = (ol[:, 0] < 0.5) & (np.hypot(ol[:, 1], ol[:, 2]) < 0.05)
|
| 85 |
+
if ink.mean() < 0.08:
|
| 86 |
+
return None
|
| 87 |
+
out = np.zeros_like(d)
|
| 88 |
+
vis = d[..., 3] > 0
|
| 89 |
+
px = d[vis]
|
| 90 |
+
ol = oklab(px[:, :3])
|
| 91 |
+
if bg is not None:
|
| 92 |
+
dist = np.linalg.norm(ol - bg, axis=1)
|
| 93 |
+
cov = np.clip((dist - 0.03) / 0.22, 0, 1)
|
| 94 |
+
else:
|
| 95 |
+
cov = np.ones(len(px))
|
| 96 |
+
keep = cov > 0
|
| 97 |
+
c = np.where(keep, cov, 1.0)
|
| 98 |
+
if bg is not None:
|
| 99 |
+
L = (ol[:, 0] - (1 - c) * bg[0]) / c
|
| 100 |
+
A = (ol[:, 1] - (1 - c) * bg[1]) / c
|
| 101 |
+
B = (ol[:, 2] - (1 - c) * bg[2]) / c
|
| 102 |
+
else:
|
| 103 |
+
L, A, B = ol[:, 0].copy(), ol[:, 1].copy(), ol[:, 2].copy()
|
| 104 |
+
L = np.clip(L, 0, 1)
|
| 105 |
+
C = np.hypot(A, B)
|
| 106 |
+
neutral = C < 0.05
|
| 107 |
+
lift = (~neutral) & (L < 0.62)
|
| 108 |
+
L = np.where(neutral, 0.94 - 0.75 * L, np.where(lift, 0.62 + 0.15 * L / 0.62, L))
|
| 109 |
+
A = np.where(lift, A * 0.92, A)
|
| 110 |
+
B = np.where(lift, B * 0.92, B)
|
| 111 |
+
rgb = to_rgb(L, A, B)
|
| 112 |
+
res = np.zeros((len(px), 4))
|
| 113 |
+
res[:, :3] = rgb
|
| 114 |
+
res[:, 3] = np.round(px[:, 3] * cov)
|
| 115 |
+
res[~keep] = 0
|
| 116 |
+
out[vis] = res
|
| 117 |
+
return out.astype(np.uint8)
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
# ---------- page data ----------
|
| 121 |
+
def hex_rgba(h):
|
| 122 |
+
h = h.lstrip("#")
|
| 123 |
+
r, g, b = int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)
|
| 124 |
+
a = int(h[6:8], 16) / 255 if len(h) >= 8 else 1.0
|
| 125 |
+
return r, g, b, a
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
def over(top, base):
|
| 129 |
+
r, g, b, a = top
|
| 130 |
+
return tuple(round(a * t + (1 - a) * u) for t, u in zip((r, g, b), base))
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def dark_palette(page, design):
|
| 134 |
+
"""Dark surface per token index and the main dark text colour, from the Haiku design."""
|
| 135 |
+
cols = {int(k): v for k, v in design["colors"].items()} if isinstance(design["colors"], dict) else {int(c["id"].lstrip("t")): c["dark"] for c in design["colors"]}
|
| 136 |
+
toks = page["tokens"]
|
| 137 |
+
root = next((i for i, t in enumerate(toks) if t["role"] == 0 and t["f"][24] > 0.5), 0)
|
| 138 |
+
canvas = hex_rgba(cols[root])[:3] if root in cols else (18, 18, 20)
|
| 139 |
+
best, text = -1, (230, 232, 235)
|
| 140 |
+
for i, t in enumerate(toks):
|
| 141 |
+
if t["role"] == 1 and i in cols and t["f"][19] > best:
|
| 142 |
+
best, text = t["f"][19], over(hex_rgba(cols[i]), canvas)
|
| 143 |
+
surf = {i: over(hex_rgba(cols[i]), canvas) for i, t in enumerate(toks) if i in cols and t["role"] == 0}
|
| 144 |
+
return surf, canvas, text
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
def content_area(o, big):
|
| 148 |
+
"""The big thumbnail is letterboxed into a square: cut out the image itself."""
|
| 149 |
+
S = big.shape[0]
|
| 150 |
+
if o.get("cropPx"):
|
| 151 |
+
nw, nh = o["cropPx"][2], o["cropPx"][3]
|
| 152 |
+
elif o.get("kind") == "svg":
|
| 153 |
+
nw, nh = o["w"], o["h"]
|
| 154 |
+
else:
|
| 155 |
+
nw, nh = (o.get("nw") or o["w"]), (o.get("nh") or o["h"])
|
| 156 |
+
nw, nh = max(1, nw), max(1, nh)
|
| 157 |
+
s = min(S / nw, S / nh)
|
| 158 |
+
dw, dh = max(1, round(nw * s)), max(1, round(nh * s))
|
| 159 |
+
x0, y0 = (S - dw) // 2, (S - dh) // 2
|
| 160 |
+
return big[y0:y0 + dh, x0:x0 + dw]
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
def decode_png(data_url):
|
| 164 |
+
return np.array(Image.open(io.BytesIO(base64.b64decode(data_url.split(",", 1)[1]))).convert("RGBA"))
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
def bucket(o):
|
| 168 |
+
s = o.get("stats") or {}
|
| 169 |
+
area = o["w"] * o["h"]
|
| 170 |
+
if o.get("logo") or (o.get("homeLink") and o.get("header")):
|
| 171 |
+
return "logo"
|
| 172 |
+
if o["kind"] == "bg" and area >= 20000:
|
| 173 |
+
return "backdrop"
|
| 174 |
+
if o["kind"] in ("pseudo",) or o.get("cropPx"):
|
| 175 |
+
return "icon"
|
| 176 |
+
if s.get("transp", 0) > 0.15:
|
| 177 |
+
return "transparent"
|
| 178 |
+
if s.get("transp", 1) < 0.02 and s.get("colors", 0) > 0.18 and s.get("stdL", 0) > 0.12:
|
| 179 |
+
return "photo"
|
| 180 |
+
return "flat"
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
RATE = {"logo": 1.0, "backdrop": 1.0, "icon": 1.0, "transparent": 1.0, "flat": 0.8, "photo": 0.12}
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
def candidates(task):
|
| 187 |
+
"""All sampled elements of one page with everything needed to draw their rows."""
|
| 188 |
+
crawl, haiku, pid, tag, seed = task
|
| 189 |
+
rnd = random.Random(seed)
|
| 190 |
+
try:
|
| 191 |
+
page = json.load(gzip.open(Path(crawl) / "pages" / pid / "light.json.gz"))
|
| 192 |
+
design = json.load(open(Path(haiku) / f"{pid}.json"))
|
| 193 |
+
if design.get("refusal") or not design.get("colors"):
|
| 194 |
+
return []
|
| 195 |
+
surf, canvas, text = dark_palette(page, design)
|
| 196 |
+
except Exception:
|
| 197 |
+
return []
|
| 198 |
+
out = []
|
| 199 |
+
for i, o in enumerate(page["images"]):
|
| 200 |
+
if not o.get("big") or o["w"] * o["h"] < 64 or o["w"] < 4 or o["h"] < 4:
|
| 201 |
+
continue
|
| 202 |
+
if o["kind"] == "svg" and not o.get("useExt"): # inline SVGs are recoloured by the colour model, not as images
|
| 203 |
+
continue
|
| 204 |
+
b = bucket(o)
|
| 205 |
+
if rnd.random() > RATE[b]:
|
| 206 |
+
continue
|
| 207 |
+
dsurf = surf.get(o.get("underTok", -1), canvas)
|
| 208 |
+
out.append({"page": pid, "crawl": tag, "img": i, "bucket": b, "o": {k: o.get(k) for k in ("kind", "src", "x", "y", "w", "h", "nw", "nh", "cropPx", "logo", "homeLink", "header", "inLink", "surf", "big", "stats")},
|
| 209 |
+
"dsurf": dsurf, "dtext": text, "light": tuple((o.get("surf") or [255, 255, 255])[:3])})
|
| 210 |
+
return out
|
| 211 |
+
|
| 212 |
+
|
| 213 |
+
# ---------- drawing ----------
|
| 214 |
+
def font(sz):
|
| 215 |
+
try:
|
| 216 |
+
return ImageFont.load_default(size=sz)
|
| 217 |
+
except TypeError:
|
| 218 |
+
return ImageFont.load_default()
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
F11, F13, F15 = font(11), font(13), font(15)
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
def fit(o, box, maxzoom=4.0):
|
| 225 |
+
s = min(box[0] / max(1, o["w"]), box[1] / max(1, o["h"]), maxzoom)
|
| 226 |
+
return max(1, round(o["w"] * s)), max(1, round(o["h"] * s)), s
|
| 227 |
+
|
| 228 |
+
|
| 229 |
+
def variant_cell(img, o, typ, size, surface, text, mode):
|
| 230 |
+
W, H = size
|
| 231 |
+
cell = Image.new("RGBA", (W, H), tuple(surface) + (255,))
|
| 232 |
+
if mode != "D":
|
| 233 |
+
dw, dh, _ = fit(o, (W - 12, H - 12))
|
| 234 |
+
im = Image.fromarray(img, "RGBA").resize((dw, dh), Image.LANCZOS)
|
| 235 |
+
cell.alpha_composite(im, ((W - dw) // 2, (H - dh) // 2))
|
| 236 |
+
if typ == LARGE and o["kind"] == "bg": # content on a background image: in dark mode its text is light
|
| 237 |
+
dr = ImageDraw.Draw(cell)
|
| 238 |
+
dr.text((12, H // 2 - 22), "Heading on this background", fill=tuple(text), font=F15)
|
| 239 |
+
dr.text((12, H // 2 + 2), "Body text that sits on top of it.", fill=tuple(text), font=F13)
|
| 240 |
+
return cell
|
| 241 |
+
|
| 242 |
+
|
| 243 |
+
def context_cell(shot, e, size):
|
| 244 |
+
W, H = size
|
| 245 |
+
o = e["o"]
|
| 246 |
+
if shot is not None and o["y"] + o["h"] <= shot.height and o["x"] >= -2 and o["x"] + o["w"] <= shot.width + 2:
|
| 247 |
+
m = max(30, 0.75 * max(o["w"], o["h"]))
|
| 248 |
+
x0, y0 = max(0, o["x"] - m), max(0, o["y"] - m)
|
| 249 |
+
x1, y1 = min(shot.width, o["x"] + o["w"] + m), min(shot.height, o["y"] + o["h"] + m)
|
| 250 |
+
crop = shot.crop((int(x0), int(y0), int(x1), int(y1)))
|
| 251 |
+
s = min(W / crop.width, H / crop.height, 3.0)
|
| 252 |
+
cw, ch = max(1, round(crop.width * s)), max(1, round(crop.height * s))
|
| 253 |
+
crop = crop.resize((cw, ch), Image.LANCZOS).convert("RGBA")
|
| 254 |
+
cell = Image.new("RGBA", (W, H), (236, 236, 236, 255))
|
| 255 |
+
ox, oy = (W - cw) // 2, (H - ch) // 2
|
| 256 |
+
cell.alpha_composite(crop, (ox, oy))
|
| 257 |
+
dr = ImageDraw.Draw(cell)
|
| 258 |
+
rx, ry = ox + (o["x"] - x0) * s, oy + (o["y"] - y0) * s
|
| 259 |
+
dr.rectangle([rx - 2, ry - 2, rx + o["w"] * s + 1, ry + o["h"] * s + 1], outline=(230, 20, 20, 255), width=2)
|
| 260 |
+
return cell
|
| 261 |
+
# below the captured screenshot: the element on its light surface
|
| 262 |
+
cell = Image.new("RGBA", (W, H), tuple(e["light"]) + (255,))
|
| 263 |
+
big = content_area(o, decode_png(o["big"]))
|
| 264 |
+
dw, dh, _ = fit(o, (W - 16, H - 16))
|
| 265 |
+
cell.alpha_composite(Image.fromarray(big, "RGBA").resize((dw, dh), Image.LANCZOS), ((W - dw) // 2, (H - dh) // 2))
|
| 266 |
+
ImageDraw.Draw(cell).text((4, H - 14), "(on its light background)", fill=(90, 90, 90), font=F11)
|
| 267 |
+
return cell
|
| 268 |
+
|
| 269 |
+
|
| 270 |
+
def draw_sheet(rows, typ, shots, path):
|
| 271 |
+
g = GEOM[typ]
|
| 272 |
+
cw, ch = g["cell"]
|
| 273 |
+
xw, xh = g["ctx"]
|
| 274 |
+
pad, lab, cap = 6, 30, 16
|
| 275 |
+
W = lab + xw + 4 * (cw + pad) + pad
|
| 276 |
+
rh = max(ch, xh) + cap + 10
|
| 277 |
+
H = 26 + rh * len(rows)
|
| 278 |
+
sheet = Image.new("RGB", (W, H), (255, 255, 255))
|
| 279 |
+
dr = ImageDraw.Draw(sheet)
|
| 280 |
+
heads = ["light page (red box)", "A keep", "B invert", "C adapt", "D remove"]
|
| 281 |
+
xs = [lab, lab + xw + pad] + [lab + xw + pad + k * (cw + pad) for k in range(1, 4)]
|
| 282 |
+
for x, t in zip(xs, heads):
|
| 283 |
+
dr.text((x + 4, 6), t, fill=(0, 0, 0), font=F13)
|
| 284 |
+
meta = []
|
| 285 |
+
for r, e in enumerate(rows):
|
| 286 |
+
y = 26 + r * rh
|
| 287 |
+
o = e["o"]
|
| 288 |
+
dr.text((4, y + ch // 2 - 8), f"#{r + 1}", fill=(0, 0, 0), font=F15)
|
| 289 |
+
sheet.paste(context_cell(shots.get(e["page"]), e, (xw, xh)).convert("RGB"), (xs[0], y))
|
| 290 |
+
big = content_area(o, decode_png(o["big"]))
|
| 291 |
+
offers = "AB"
|
| 292 |
+
inv = invert(big)
|
| 293 |
+
ad = adapt(big)
|
| 294 |
+
cells = [variant_cell(big, o, typ, (cw, ch), e["dsurf"], e["dtext"], "A"), variant_cell(inv, o, typ, (cw, ch), e["dsurf"], e["dtext"], "B")]
|
| 295 |
+
if ad is not None:
|
| 296 |
+
offers += "C"
|
| 297 |
+
cells.append(variant_cell(ad, o, typ, (cw, ch), e["dsurf"], e["dtext"], "C"))
|
| 298 |
+
else:
|
| 299 |
+
cells.append(None)
|
| 300 |
+
if typ == LARGE and o["kind"] == "bg":
|
| 301 |
+
offers += "D"
|
| 302 |
+
cells.append(variant_cell(big, o, typ, (cw, ch), e["dsurf"], e["dtext"], "D"))
|
| 303 |
+
else:
|
| 304 |
+
cells.append(None)
|
| 305 |
+
for k, c in enumerate(cells):
|
| 306 |
+
x = xs[1 + k]
|
| 307 |
+
if c is None:
|
| 308 |
+
dr.rectangle([x, y, x + cw - 1, y + ch - 1], fill=(225, 225, 225))
|
| 309 |
+
dr.text((x + cw // 2 - 14, y + ch // 2 - 7), "n/a", fill=(140, 140, 140), font=F13)
|
| 310 |
+
else:
|
| 311 |
+
sheet.paste(c.convert("RGB"), (x, y))
|
| 312 |
+
flags = [f for f, v in (("logo", o.get("logo")), ("links home", o.get("homeLink")), ("in a link", o.get("inLink") and not o.get("homeLink")), ("in header", o.get("header"))) if v]
|
| 313 |
+
_, _, z = fit(o, (cw - 12, ch - 12))
|
| 314 |
+
zoom = f", shown {z:.1f}x" if abs(z - 1) > 0.15 else ""
|
| 315 |
+
dr.text((xs[1], y + ch + 2), f"#{r + 1}: {KIND.get(o['kind'], o['kind'])} {o['w']}x{o['h']} px{zoom}" + (" · " + ", ".join(flags) if flags else ""), fill=(60, 60, 60), font=F11)
|
| 316 |
+
meta.append({"k": r + 1, "page": e["page"], "crawl": e["crawl"], "img": e["img"], "bucket": e["bucket"], "offer": offers, "kind": o["kind"]})
|
| 317 |
+
sheet.save(path, optimize=True)
|
| 318 |
+
return meta
|
| 319 |
+
|
| 320 |
+
|
| 321 |
+
def render_chunk(job):
|
| 322 |
+
wid, rows_by_sheet, crawls, out = job
|
| 323 |
+
shots = {}
|
| 324 |
+
outp = Path(out)
|
| 325 |
+
lines = []
|
| 326 |
+
for sid, typ, rows in rows_by_sheet:
|
| 327 |
+
for e in rows:
|
| 328 |
+
if e["page"] not in shots:
|
| 329 |
+
p = Path(crawls[e["crawl"]]) / "pages" / e["page"] / "shot.jpg"
|
| 330 |
+
try:
|
| 331 |
+
shots[e["page"]] = Image.open(p).convert("RGB")
|
| 332 |
+
except Exception:
|
| 333 |
+
shots[e["page"]] = None
|
| 334 |
+
try:
|
| 335 |
+
meta = draw_sheet(rows, typ, shots, outp / "sheets" / f"{sid}.png")
|
| 336 |
+
lines.append(json.dumps({"sheet": sid, "type": typ, "rows": meta}))
|
| 337 |
+
except Exception as ex:
|
| 338 |
+
print("sheet error", sid, repr(ex)[:200], flush=True)
|
| 339 |
+
if len(shots) > 64:
|
| 340 |
+
shots.clear()
|
| 341 |
+
with open(outp / f"meta.{wid}.jsonl", "a") as f:
|
| 342 |
+
f.write("\n".join(lines) + ("\n" if lines else ""))
|
| 343 |
+
return len(lines)
|
| 344 |
+
|
| 345 |
+
|
| 346 |
+
def main():
|
| 347 |
+
ap = argparse.ArgumentParser()
|
| 348 |
+
ap.add_argument("--crawl", action="append", required=True, help="TAG=CRAWL_DIR=HAIKU_DIR (repeatable)")
|
| 349 |
+
ap.add_argument("--out", required=True)
|
| 350 |
+
ap.add_argument("--max-elements", type=int, default=150000)
|
| 351 |
+
ap.add_argument("--max-per-src", type=int, default=2)
|
| 352 |
+
ap.add_argument("--workers", type=int, default=28)
|
| 353 |
+
ap.add_argument("--exclude-pages", default="", help="file of page ids already done")
|
| 354 |
+
ap.add_argument("--flat-rate", type=float, default=None, help="sampling rate for opaque flat/photo-like images (mostly 'keep')")
|
| 355 |
+
args = ap.parse_args()
|
| 356 |
+
if args.flat_rate is not None:
|
| 357 |
+
RATE["flat"] = args.flat_rate
|
| 358 |
+
out = Path(args.out)
|
| 359 |
+
(out / "sheets").mkdir(parents=True, exist_ok=True)
|
| 360 |
+
crawls, tasks = {}, []
|
| 361 |
+
done = set(l.strip() for l in open(args.exclude_pages)) if args.exclude_pages and os.path.exists(args.exclude_pages) else set()
|
| 362 |
+
for spec in args.crawl:
|
| 363 |
+
tag, cdir, hdir = spec.split("=")
|
| 364 |
+
crawls[tag] = cdir
|
| 365 |
+
for hp in Path(hdir).glob("*.json"):
|
| 366 |
+
pid = hp.stem
|
| 367 |
+
if len(pid) == 16 and pid not in done and (Path(cdir) / "pages" / pid / "light.json.gz").exists():
|
| 368 |
+
tasks.append((cdir, hdir, pid, tag, int(hashlib.md5(pid.encode()).hexdigest()[:8], 16)))
|
| 369 |
+
random.Random(3).shuffle(tasks)
|
| 370 |
+
print(len(tasks), "pages with designs", flush=True)
|
| 371 |
+
per_src, elems, by_bucket = {}, [], {}
|
| 372 |
+
with Pool(args.workers) as pool:
|
| 373 |
+
for res in pool.imap_unordered(candidates, tasks, chunksize=16):
|
| 374 |
+
for e in res:
|
| 375 |
+
s = e["o"]["src"]
|
| 376 |
+
if per_src.get(s, 0) >= args.max_per_src:
|
| 377 |
+
continue
|
| 378 |
+
per_src[s] = per_src.get(s, 0) + 1
|
| 379 |
+
elems.append(e)
|
| 380 |
+
by_bucket[e["bucket"]] = by_bucket.get(e["bucket"], 0) + 1
|
| 381 |
+
if len(elems) >= args.max_elements:
|
| 382 |
+
break
|
| 383 |
+
elems = elems[: args.max_elements]
|
| 384 |
+
print(len(elems), "elements", by_bucket, flush=True)
|
| 385 |
+
with open(out / "pages_used.txt", "a") as f:
|
| 386 |
+
f.write("\n".join(sorted(set(e["page"] for e in elems))) + "\n")
|
| 387 |
+
# sheets: small and large elements apart; rows mixed across pages
|
| 388 |
+
random.Random(5).shuffle(elems)
|
| 389 |
+
groups = {SMALL: [], LARGE: []}
|
| 390 |
+
for e in elems:
|
| 391 |
+
o = e["o"]
|
| 392 |
+
typ = LARGE if (o["w"] * o["h"] >= 20000 or o["w"] > 300) else SMALL
|
| 393 |
+
groups[typ].append(e)
|
| 394 |
+
sheets = []
|
| 395 |
+
for typ, es in groups.items():
|
| 396 |
+
n = GEOM[typ]["rows"]
|
| 397 |
+
for i in range(0, len(es), n):
|
| 398 |
+
rows = es[i:i + n]
|
| 399 |
+
sid = hashlib.sha1(json.dumps([(r["page"], r["img"]) for r in rows]).encode()).hexdigest()[:16]
|
| 400 |
+
sheets.append((sid, typ, rows))
|
| 401 |
+
jobs = [(w, sheets[w::args.workers], crawls, str(out)) for w in range(args.workers)]
|
| 402 |
+
with Pool(args.workers) as pool:
|
| 403 |
+
n = sum(pool.map(render_chunk, jobs))
|
| 404 |
+
print("sheets", n, flush=True)
|
| 405 |
+
|
| 406 |
+
|
| 407 |
+
if __name__ == "__main__":
|
| 408 |
+
main()
|
training/export.py
CHANGED
|
@@ -1,15 +1,18 @@
|
|
| 1 |
#!/usr/bin/env python3
|
| 2 |
-
"""Export NadirNet to ONNX
|
|
|
|
| 3 |
import argparse, json, os, pickle, time
|
| 4 |
import numpy as np
|
| 5 |
import torch
|
| 6 |
import torch.nn as nn
|
| 7 |
|
| 8 |
-
from model import NadirNet
|
| 9 |
-
from train import collate, prep,
|
| 10 |
|
| 11 |
NAMES_IN = ["tok_f", "tok_role", "tok_lab", "img_f", "img_thumb", "page_f", "src", "rel", "mask"]
|
| 12 |
-
NAMES_OUT = ["lab", "alpha_logit", "
|
|
|
|
|
|
|
| 13 |
|
| 14 |
|
| 15 |
class Wrapped(nn.Module):
|
|
@@ -45,16 +48,25 @@ def feeds_for(s, pad=None):
|
|
| 45 |
M = b["img_f"].shape[1]
|
| 46 |
if M == 0: # the runtime always sends at least one (masked) image slot
|
| 47 |
T = b["mask"].shape[1]
|
| 48 |
-
b["img_f"] = torch.zeros(1, 1,
|
| 49 |
rel = torch.zeros(1, T + 1, T + 1, b["rel"].shape[-1]); rel[:, :T, :T] = b["rel"]; b["rel"] = rel
|
| 50 |
b["mask"] = torch.cat([b["mask"], torch.zeros(1, 1, dtype=torch.bool)], 1)
|
| 51 |
return {"tok_f": b["tok_f"].numpy(), "tok_role": b["role"].numpy(), "tok_lab": b["lab"].numpy(), "img_f": b["img_f"].numpy(),
|
| 52 |
"img_thumb": b["thumbs"].numpy(), "page_f": b["page"].numpy(), "src": b["src"].numpy(), "rel": b["rel"].numpy(), "mask": b["mask"].numpy()}
|
| 53 |
|
| 54 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
def fidelity(sess, ref_sess, samples):
|
| 56 |
-
"""Mean OKLab distance between this model's colours and fp32's,
|
| 57 |
-
de,
|
| 58 |
for s in samples:
|
| 59 |
f = feeds_for(s)
|
| 60 |
a = sess.run(None, f); r = ref_sess.run(None, f)
|
|
@@ -62,16 +74,13 @@ def fidelity(sess, ref_sess, samples):
|
|
| 62 |
d = np.linalg.norm(a[0][0, :n] - r[0][0, :n], axis=-1)
|
| 63 |
w = s["w"][:n]
|
| 64 |
de.append(float((d * w).sum() / w.sum())); worst = max(worst, float(d.max()))
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
agree += int((a[2][0, :m].argmax(-1) == r[2][0, :m].argmax(-1)).sum()); n_img += m
|
| 68 |
-
return {"dE_vs_fp32": float(np.mean(de)), "dE_max": worst, "img_agree": agree / max(1, n_img)}
|
| 69 |
|
| 70 |
|
| 71 |
-
def bench(sess,
|
| 72 |
ts = []
|
| 73 |
-
for
|
| 74 |
-
f = feeds_for(s)
|
| 75 |
sess.run(None, f)
|
| 76 |
t = time.time()
|
| 77 |
for _ in range(reps):
|
|
@@ -85,8 +94,7 @@ def main():
|
|
| 85 |
ap.add_argument("--ckpt", required=True)
|
| 86 |
ap.add_argument("--data", required=True)
|
| 87 |
ap.add_argument("--out", required=True)
|
| 88 |
-
ap.add_argument("--
|
| 89 |
-
ap.add_argument("--only-float", action="store_true", help="fp32/fp16 only (calibrated 8/4-bit come from quantize.py)")
|
| 90 |
args = ap.parse_args()
|
| 91 |
os.makedirs(args.out, exist_ok=True)
|
| 92 |
import onnx, onnxruntime as ort
|
|
@@ -94,116 +102,56 @@ def main():
|
|
| 94 |
model = NadirNet(**{k: v for k, v in ck["cfg"].items()}); model.load_state_dict(ck["model"]); model.eval()
|
| 95 |
data = pickle.load(open(args.data, "rb"))
|
| 96 |
val = [prep(s) for s in data["val"]]
|
| 97 |
-
rng = np.random.default_rng(0)
|
| 98 |
-
calib = [prep(s) for s in rng.choice(np.array(data["train"], dtype=object), size=min(args.calib, len(data["train"])), replace=False)]
|
| 99 |
s0 = max(val, key=lambda s: len(s["role"]))
|
| 100 |
ex = feeds_for(s0)
|
| 101 |
par_samples = val[:8]
|
| 102 |
with torch.no_grad():
|
| 103 |
-
ref_out = [model(*[torch.from_numpy(feeds_for(s)[k]) for k in NAMES_IN])
|
| 104 |
fp32 = os.path.join(args.out, "nadir-fp32.onnx")
|
| 105 |
torch.onnx.export(Wrapped(model), tuple(torch.from_numpy(ex[k]) for k in NAMES_IN), fp32, input_names=NAMES_IN, output_names=NAMES_OUT,
|
| 106 |
dynamic_axes={"tok_f": {1: "N"}, "tok_role": {1: "N"}, "tok_lab": {1: "N"}, "img_f": {1: "M"}, "img_thumb": {1: "M"},
|
| 107 |
-
"rel": {1: "T", 2: "T"}, "mask": {1: "T"}, "lab": {1: "N"}, "alpha_logit": {1: "N"}
|
| 108 |
opset_version=17, do_constant_folding=True, dynamo=False)
|
| 109 |
onnx.checker.check_model(fp32)
|
| 110 |
so = ort.SessionOptions(); so.intra_op_num_threads = 4
|
| 111 |
ref = ort.InferenceSession(fp32, so, providers=["CPUExecutionProvider"])
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
pre = os.path.join(args.out, "_pre.onnx")
|
| 150 |
-
quant_pre_process(fp32, pre, skip_symbolic_shape=True)
|
| 151 |
-
|
| 152 |
-
class Reader(CalibrationDataReader):
|
| 153 |
-
def __init__(self, ss):
|
| 154 |
-
self.it = iter([feeds_for(s, pad=(256, 32)) for s in ss])
|
| 155 |
-
def get_next(self):
|
| 156 |
-
return next(self.it, None)
|
| 157 |
-
|
| 158 |
-
# 8-bit A: per-channel int8 weights, dynamic activations
|
| 159 |
-
q8d = os.path.join(args.out, "nadir-int8-dynamic.onnx")
|
| 160 |
-
quantize_dynamic(pre, q8d, weight_type=QuantType.QInt8, per_channel=True, op_types_to_quantize=["MatMul", "Gemm"])
|
| 161 |
-
# 8-bit B: static QDQ, activations calibrated on real pages (percentile)
|
| 162 |
-
q8s = os.path.join(args.out, "nadir-int8-static.onnx")
|
| 163 |
-
try:
|
| 164 |
-
quantize_static(pre, q8s, Reader(calib[:128]), quant_format=QuantFormat.QDQ, per_channel=True, activation_type=QuantType.QInt8,
|
| 165 |
-
weight_type=QuantType.QInt8, op_types_to_quantize=["MatMul", "Gemm"], calibrate_method=CalibrationMethod.Percentile,
|
| 166 |
-
extra_options={"CalibPercentile": 99.99, "ActivationSymmetric": True, "WeightSymmetric": True})
|
| 167 |
-
except Exception as e:
|
| 168 |
-
print("static int8 failed:", e, flush=True); q8s = None
|
| 169 |
-
# 4-bit: block-wise weights (MatMulNBits), GPTQ calibrated on real pages, RTN fallback
|
| 170 |
-
q4 = os.path.join(args.out, "nadir-int4.onnx")
|
| 171 |
-
from onnxruntime.quantization import matmul_nbits_quantizer as mnq
|
| 172 |
-
done4 = False
|
| 173 |
-
# GPTQ needs reduction dims that are multiples of the block size; leave tiny matrices (relation bias, 11 wide) in fp32
|
| 174 |
-
pm = onnx.load(pre)
|
| 175 |
-
inits = {i.name: i for i in pm.graph.initializer}
|
| 176 |
-
skip = [n.name for n in pm.graph.node if n.op_type == "MatMul" and len(n.input) > 1 and n.input[1] in inits and inits[n.input[1]].dims[0] % 32 != 0]
|
| 177 |
-
report["int4_fp32_nodes"] = len(skip)
|
| 178 |
-
for algo in ("gptq", "hqq", "rtn"):
|
| 179 |
-
try:
|
| 180 |
-
if algo == "gptq":
|
| 181 |
-
cfg = mnq.GPTQWeightOnlyQuantConfig(calibration_data_reader=Reader(calib), percdamp=0.01, block_size=32, actorder=True, mse=True)
|
| 182 |
-
elif algo == "hqq":
|
| 183 |
-
cfg = mnq.HQQWeightOnlyQuantConfig(block_size=32, bits=4)
|
| 184 |
-
else:
|
| 185 |
-
cfg = mnq.RTNWeightOnlyQuantConfig()
|
| 186 |
-
qz = mnq.MatMulNBitsQuantizer(onnx.load(pre), block_size=32, is_symmetric=False, accuracy_level=4, algo_config=cfg, nodes_to_exclude=skip)
|
| 187 |
-
qz.process()
|
| 188 |
-
qz.model.save_model_to_file(q4, use_external_data_format=False)
|
| 189 |
-
report["int4_algo"] = algo
|
| 190 |
-
done4 = True
|
| 191 |
-
break
|
| 192 |
-
except Exception as e:
|
| 193 |
-
print(f"int4 {algo} failed: {str(e)[:300]}", flush=True)
|
| 194 |
-
variants = {"fp32": fp32, "int8-dynamic": q8d}
|
| 195 |
-
if have16: variants["fp16"] = os.path.join(args.out, "nadir-fp16.onnx")
|
| 196 |
-
if q8s: variants["int8-static"] = q8s
|
| 197 |
-
if done4: variants["int4"] = q4
|
| 198 |
-
evs = val[:300]
|
| 199 |
-
for name, path in variants.items():
|
| 200 |
-
sess = ort.InferenceSession(path, so, providers=["CPUExecutionProvider"])
|
| 201 |
-
r = fidelity(sess, ref, evs)
|
| 202 |
-
r["size_mb"] = round(os.path.getsize(path) / 1e6, 2)
|
| 203 |
-
r["cpu_ms_median"] = round(bench(sess, evs), 2)
|
| 204 |
-
report["variants"][name] = r
|
| 205 |
-
print(name, r, flush=True)
|
| 206 |
-
os.remove(pre)
|
| 207 |
json.dump(report, open(os.path.join(args.out, "export_report.json"), "w"), indent=1)
|
| 208 |
|
| 209 |
|
|
|
|
| 1 |
#!/usr/bin/env python3
|
| 2 |
+
"""Export NadirNet to ONNX: the page graph (palette + page context) and the element graph (treatment + role for any
|
| 3 |
+
set of page images). Calibrated 8-bit and 4-bit variants of the page graph come from quantize.py."""
|
| 4 |
import argparse, json, os, pickle, time
|
| 5 |
import numpy as np
|
| 6 |
import torch
|
| 7 |
import torch.nn as nn
|
| 8 |
|
| 9 |
+
from model import NadirNet, ElemNet, FI, TREAT, IMG_ROLES, N_CTX
|
| 10 |
+
from train import collate, prep, element_logits
|
| 11 |
|
| 12 |
NAMES_IN = ["tok_f", "tok_role", "tok_lab", "img_f", "img_thumb", "page_f", "src", "rel", "mask"]
|
| 13 |
+
NAMES_OUT = ["lab", "alpha_logit", "ctx"]
|
| 14 |
+
ELEM_IN = ["e_f", "e_thumb", "under_light", "under_dark", "ctx"]
|
| 15 |
+
ELEM_OUT = ["treat_logits", "role_logits"]
|
| 16 |
|
| 17 |
|
| 18 |
class Wrapped(nn.Module):
|
|
|
|
| 48 |
M = b["img_f"].shape[1]
|
| 49 |
if M == 0: # the runtime always sends at least one (masked) image slot
|
| 50 |
T = b["mask"].shape[1]
|
| 51 |
+
b["img_f"] = torch.zeros(1, 1, FI); b["thumbs"] = torch.zeros(1, 1, 4, 32, 32)
|
| 52 |
rel = torch.zeros(1, T + 1, T + 1, b["rel"].shape[-1]); rel[:, :T, :T] = b["rel"]; b["rel"] = rel
|
| 53 |
b["mask"] = torch.cat([b["mask"], torch.zeros(1, 1, dtype=torch.bool)], 1)
|
| 54 |
return {"tok_f": b["tok_f"].numpy(), "tok_role": b["role"].numpy(), "tok_lab": b["lab"].numpy(), "img_f": b["img_f"].numpy(),
|
| 55 |
"img_thumb": b["thumbs"].numpy(), "page_f": b["page"].numpy(), "src": b["src"].numpy(), "rel": b["rel"].numpy(), "mask": b["mask"].numpy()}
|
| 56 |
|
| 57 |
|
| 58 |
+
def elem_feeds(s, lab, ctx):
|
| 59 |
+
"""Element-graph inputs for a page's labelled images, with the dark surface taken from the page graph's output."""
|
| 60 |
+
b = collate([s], "cpu")
|
| 61 |
+
k = int(b["e_mask"][0].sum())
|
| 62 |
+
ut = b["e_ut"][0, :k].numpy()
|
| 63 |
+
return {"e_f": b["e_f"][:, :k].numpy(), "e_thumb": b["e_thumb"][:, :k].numpy(), "under_light": b["e_ul"][:, :k].numpy(),
|
| 64 |
+
"under_dark": lab[:, ut].astype(np.float32), "ctx": ctx.astype(np.float32)}, b["e_soft"][0, :k].numpy()
|
| 65 |
+
|
| 66 |
+
|
| 67 |
def fidelity(sess, ref_sess, samples):
|
| 68 |
+
"""Mean OKLab distance between this model's colours and fp32's (weighted like training), worst token, ctx drift."""
|
| 69 |
+
de, worst, ctxd = [], 0.0, []
|
| 70 |
for s in samples:
|
| 71 |
f = feeds_for(s)
|
| 72 |
a = sess.run(None, f); r = ref_sess.run(None, f)
|
|
|
|
| 74 |
d = np.linalg.norm(a[0][0, :n] - r[0][0, :n], axis=-1)
|
| 75 |
w = s["w"][:n]
|
| 76 |
de.append(float((d * w).sum() / w.sum())); worst = max(worst, float(d.max()))
|
| 77 |
+
ctxd.append(float(np.abs(a[2] - r[2]).max()))
|
| 78 |
+
return {"dE_vs_fp32": float(np.mean(de)), "dE_max": worst, "ctx_max_abs": float(np.max(ctxd))}
|
|
|
|
|
|
|
| 79 |
|
| 80 |
|
| 81 |
+
def bench(sess, feeds, reps=3):
|
| 82 |
ts = []
|
| 83 |
+
for f in feeds[:20]:
|
|
|
|
| 84 |
sess.run(None, f)
|
| 85 |
t = time.time()
|
| 86 |
for _ in range(reps):
|
|
|
|
| 94 |
ap.add_argument("--ckpt", required=True)
|
| 95 |
ap.add_argument("--data", required=True)
|
| 96 |
ap.add_argument("--out", required=True)
|
| 97 |
+
ap.add_argument("--only-float", action="store_true", help="kept for the job scripts (quantization lives in quantize.py)")
|
|
|
|
| 98 |
args = ap.parse_args()
|
| 99 |
os.makedirs(args.out, exist_ok=True)
|
| 100 |
import onnx, onnxruntime as ort
|
|
|
|
| 102 |
model = NadirNet(**{k: v for k, v in ck["cfg"].items()}); model.load_state_dict(ck["model"]); model.eval()
|
| 103 |
data = pickle.load(open(args.data, "rb"))
|
| 104 |
val = [prep(s) for s in data["val"]]
|
|
|
|
|
|
|
| 105 |
s0 = max(val, key=lambda s: len(s["role"]))
|
| 106 |
ex = feeds_for(s0)
|
| 107 |
par_samples = val[:8]
|
| 108 |
with torch.no_grad():
|
| 109 |
+
ref_out = [model(*[torch.from_numpy(feeds_for(s)[k]) for k in NAMES_IN]) for s in par_samples]
|
| 110 |
fp32 = os.path.join(args.out, "nadir-fp32.onnx")
|
| 111 |
torch.onnx.export(Wrapped(model), tuple(torch.from_numpy(ex[k]) for k in NAMES_IN), fp32, input_names=NAMES_IN, output_names=NAMES_OUT,
|
| 112 |
dynamic_axes={"tok_f": {1: "N"}, "tok_role": {1: "N"}, "tok_lab": {1: "N"}, "img_f": {1: "M"}, "img_thumb": {1: "M"},
|
| 113 |
+
"rel": {1: "T", 2: "T"}, "mask": {1: "T"}, "lab": {1: "N"}, "alpha_logit": {1: "N"}},
|
| 114 |
opset_version=17, do_constant_folding=True, dynamo=False)
|
| 115 |
onnx.checker.check_model(fp32)
|
| 116 |
so = ort.SessionOptions(); so.intra_op_num_threads = 4
|
| 117 |
ref = ort.InferenceSession(fp32, so, providers=["CPUExecutionProvider"])
|
| 118 |
+
parity = 0.0
|
| 119 |
+
for r, s in zip(ref_out, par_samples):
|
| 120 |
+
o = ref.run(None, feeds_for(s))
|
| 121 |
+
parity = max(parity, float(np.abs(r[0].numpy() - o[0]).max()), float(np.abs(r[2].numpy() - o[2]).max()))
|
| 122 |
+
# element graph
|
| 123 |
+
es = [s for s in val if len(s["E_ut"])][:64]
|
| 124 |
+
ef = []
|
| 125 |
+
for s in es:
|
| 126 |
+
o = ref.run(None, feeds_for(s))
|
| 127 |
+
ef.append(elem_feeds(s, o[0], o[2]))
|
| 128 |
+
exe = max(ef, key=lambda x: x[0]["e_f"].shape[1])[0] if ef else {"e_f": np.zeros((1, 4, FI), np.float32), "e_thumb": np.zeros((1, 4, 4, 32, 32), np.float32),
|
| 129 |
+
"under_light": np.zeros((1, 4, 3), np.float32), "under_dark": np.zeros((1, 4, 3), np.float32), "ctx": np.zeros((1, N_CTX), np.float32)}
|
| 130 |
+
elem = os.path.join(args.out, "nadir-elem-fp32.onnx")
|
| 131 |
+
em = ElemNet(model).eval()
|
| 132 |
+
torch.onnx.export(em, tuple(torch.from_numpy(exe[k]) for k in ELEM_IN), elem, input_names=ELEM_IN, output_names=ELEM_OUT,
|
| 133 |
+
dynamic_axes={"e_f": {1: "K"}, "e_thumb": {1: "K"}, "under_light": {1: "K"}, "under_dark": {1: "K"}, "treat_logits": {1: "K"}, "role_logits": {1: "K"}},
|
| 134 |
+
opset_version=17, do_constant_folding=True, dynamo=False)
|
| 135 |
+
onnx.checker.check_model(elem)
|
| 136 |
+
esess = ort.InferenceSession(elem, so, providers=["CPUExecutionProvider"])
|
| 137 |
+
eparity, agree, ok, n = 0.0, 0, 0, 0
|
| 138 |
+
T = float(ck.get("temperature", 1.0))
|
| 139 |
+
for f, soft in ef:
|
| 140 |
+
with torch.no_grad():
|
| 141 |
+
tt, rr = em(*[torch.from_numpy(f[k]) for k in ELEM_IN])
|
| 142 |
+
o = esess.run(None, f)
|
| 143 |
+
eparity = max(eparity, float(np.abs(tt.numpy() - o[0]).max()), float(np.abs(rr.numpy() - o[1]).max()))
|
| 144 |
+
pred = o[0][0].argmax(-1)
|
| 145 |
+
agree += int((pred == soft.argmax(-1)).sum()); ok += int((soft[np.arange(len(pred)), pred] > 0).sum()); n += len(pred)
|
| 146 |
+
report = {"params": sum(p.numel() for p in model.parameters()),
|
| 147 |
+
"elem_params": sum(p.numel() for k, p in model.named_parameters() if k.startswith(("cnn.", "img_in.", "elem_head."))),
|
| 148 |
+
"parity_max_abs": parity, "elem_parity_max_abs": eparity, "temperature": T, "treat": TREAT, "img_roles": IMG_ROLES,
|
| 149 |
+
"elem_val_sample": {"n": n, "acc": agree / max(1, n), "ok": ok / max(1, n)}, "variants": {}}
|
| 150 |
+
print("parity", parity, "elem parity", eparity, flush=True)
|
| 151 |
+
r = fidelity(ref, ref, val[:50]); r["size_mb"] = round(os.path.getsize(fp32) / 1e6, 2); r["cpu_ms_median"] = round(bench(ref, [feeds_for(s) for s in val[:20]]), 2)
|
| 152 |
+
report["variants"]["fp32"] = r
|
| 153 |
+
report["elem"] = {"size_mb": round(os.path.getsize(elem) / 1e6, 2), "cpu_ms_median": round(bench(esess, [f for f, _ in ef]), 2) if ef else None}
|
| 154 |
+
print(report, flush=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 155 |
json.dump(report, open(os.path.join(args.out, "export_report.json"), "w"), indent=1)
|
| 156 |
|
| 157 |
|
training/ext_render.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""End-to-end eval renders: each page as the user sees it with a given build of the extension (or none).
|
| 3 |
+
|
| 4 |
+
usage: ext_render.py --urls URLS.jsonl --out OUT --variant TAG=EXT_DIR [--variant TAG=EXT_DIR ...] [--light]
|
| 5 |
+
URLS.jsonl lines: {"id", "url"}. Writes OUT/<tag>/<id>.jpg (viewport, after the extension settled) and
|
| 6 |
+
OUT/<tag>/<id>.json (Nadir status). With --light also OUT/light/<id>.jpg without any extension.
|
| 7 |
+
Each variant runs in its own persistent profile, so first-visit behaviour (no site cache) is what gets judged.
|
| 8 |
+
"""
|
| 9 |
+
import argparse, asyncio, json, os, shutil, time
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
from playwright.async_api import async_playwright
|
| 12 |
+
|
| 13 |
+
STATUS_JS = "async () => { const a = await chrome.storage.session.get(null); return Object.values(a).pop() || null; }"
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
async def render_variant(p, tag, ext, urls, out, args):
|
| 17 |
+
od = out / tag
|
| 18 |
+
od.mkdir(parents=True, exist_ok=True)
|
| 19 |
+
prof = out / f"_profile_{tag}"
|
| 20 |
+
shutil.rmtree(prof, ignore_errors=True)
|
| 21 |
+
# the whole browser (extension pages too) presents a normal Chrome UA: some image hosts refuse "HeadlessChrome"
|
| 22 |
+
launch_args = ["--hide-scrollbars", "--mute-audio", "--disable-features=DisableLoadExtensionCommandLineSwitch", f"--user-agent={args.ua}"]
|
| 23 |
+
if ext:
|
| 24 |
+
launch_args += [f"--disable-extensions-except={ext}", f"--load-extension={ext}"]
|
| 25 |
+
# extensions need the full browser in new-headless mode, not Playwright's headless shell
|
| 26 |
+
ctx = await p.chromium.launch_persistent_context(str(prof), executable_path=args.exe or None, channel=None if args.exe else "chromium", headless=True,
|
| 27 |
+
ignore_default_args=["--disable-extensions"] if ext else [],
|
| 28 |
+
viewport={"width": 1280, "height": 800}, args=launch_args, user_agent=args.ua)
|
| 29 |
+
sw = None
|
| 30 |
+
if ext:
|
| 31 |
+
sw = ctx.service_workers[0] if ctx.service_workers else await ctx.wait_for_event("serviceworker", timeout=20000)
|
| 32 |
+
sem = asyncio.Semaphore(args.conc)
|
| 33 |
+
|
| 34 |
+
async def one(rec):
|
| 35 |
+
dst = od / f"{rec['id']}.jpg"
|
| 36 |
+
if dst.exists():
|
| 37 |
+
return
|
| 38 |
+
async with sem:
|
| 39 |
+
pg = await ctx.new_page()
|
| 40 |
+
st = {"url": rec["url"], "t": time.time()}
|
| 41 |
+
try:
|
| 42 |
+
await pg.goto(rec["url"], wait_until="domcontentloaded", timeout=30000)
|
| 43 |
+
try:
|
| 44 |
+
await pg.wait_for_load_state("load", timeout=10000)
|
| 45 |
+
except Exception:
|
| 46 |
+
pass
|
| 47 |
+
await pg.wait_for_timeout(args.wait * 1000)
|
| 48 |
+
await pg.screenshot(path=str(dst), type="jpeg", quality=82)
|
| 49 |
+
if sw:
|
| 50 |
+
try:
|
| 51 |
+
st["nadir"] = await sw.evaluate(STATUS_JS)
|
| 52 |
+
except Exception as e:
|
| 53 |
+
st["nadir_err"] = str(e)[:120]
|
| 54 |
+
except Exception as e:
|
| 55 |
+
st["error"] = str(e)[:200]
|
| 56 |
+
finally:
|
| 57 |
+
st["ok"] = dst.exists() and "error" not in st
|
| 58 |
+
(od / f"{rec['id']}.json").write_text(json.dumps(st))
|
| 59 |
+
await pg.close()
|
| 60 |
+
await asyncio.gather(*[one(r) for r in urls])
|
| 61 |
+
await ctx.close()
|
| 62 |
+
shutil.rmtree(prof, ignore_errors=True)
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
async def main():
|
| 66 |
+
ap = argparse.ArgumentParser()
|
| 67 |
+
ap.add_argument("--urls", required=True)
|
| 68 |
+
ap.add_argument("--out", required=True)
|
| 69 |
+
ap.add_argument("--variant", action="append", default=[], help="TAG=EXT_DIR")
|
| 70 |
+
ap.add_argument("--light", action="store_true")
|
| 71 |
+
ap.add_argument("--exe", default="")
|
| 72 |
+
ap.add_argument("--wait", type=float, default=7.0)
|
| 73 |
+
ap.add_argument("--conc", type=int, default=4)
|
| 74 |
+
ap.add_argument("--ua", default="Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36")
|
| 75 |
+
args = ap.parse_args()
|
| 76 |
+
out = Path(args.out)
|
| 77 |
+
urls = [json.loads(l) for l in open(args.urls) if l.strip()]
|
| 78 |
+
async with async_playwright() as p:
|
| 79 |
+
jobs = [("light", None)] if args.light else []
|
| 80 |
+
jobs += [tuple(v.split("=", 1)) for v in args.variant]
|
| 81 |
+
# variants side by side (separate browsers): the same live page state as far as possible
|
| 82 |
+
await asyncio.gather(*[render_variant(p, tag, ext, urls, out, args) for tag, ext in jobs])
|
| 83 |
+
print("done", len(urls), [t for t, _ in jobs], flush=True)
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
if __name__ == "__main__":
|
| 87 |
+
asyncio.run(main())
|
training/haiku_gen.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
#!/usr/bin/env python3
|
| 2 |
"""Nadir teacher data: Claude Haiku 5.5 designs a dark theme for each crawled light page.
|
| 3 |
|
| 4 |
-
|
| 5 |
OUT/<id>.json and keeps a running cost ledger; stops at --budget dollars.
|
| 6 |
"""
|
| 7 |
import argparse, asyncio, base64, gzip, io, json, math, os, random, re, sys, time
|
|
|
|
| 1 |
#!/usr/bin/env python3
|
| 2 |
"""Nadir teacher data: Claude Haiku 5.5 designs a dark theme for each crawled light page.
|
| 3 |
|
| 4 |
+
Reads ANTHROPIC_API_KEY from the environment. Writes one JSON per page to
|
| 5 |
OUT/<id>.json and keeps a running cost ledger; stops at --budget dollars.
|
| 6 |
"""
|
| 7 |
import argparse, asyncio, base64, gzip, io, json, math, os, random, re, sys, time
|
training/judge.py
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
#!/usr/bin/env python3
|
| 2 |
-
"""Blind pairwise judging of dark-mode renders with Claude Haiku 5.5 (Batch API).
|
| 3 |
import argparse, base64, io, json, random, time
|
| 4 |
from pathlib import Path
|
| 5 |
from PIL import Image
|
|
@@ -32,6 +32,7 @@ def main():
|
|
| 32 |
ap.add_argument("--effort", default="medium")
|
| 33 |
ap.add_argument("--budget", type=float, default=3.0)
|
| 34 |
ap.add_argument("--chunk", type=int, default=400, help="requests per batch (3 images each, ~150 KB)")
|
|
|
|
| 35 |
args = ap.parse_args()
|
| 36 |
R, crawl, out = Path(args.renders), Path(args.crawl), Path(args.out)
|
| 37 |
out.mkdir(parents=True, exist_ok=True)
|
|
@@ -50,7 +51,10 @@ def main():
|
|
| 50 |
flip = rng.random() < 0.5
|
| 51 |
a, b = (y, x) if flip else (x, y)
|
| 52 |
cid = f"{pid}-{x}-{y}"
|
| 53 |
-
|
|
|
|
|
|
|
|
|
|
| 54 |
{"type": "text", "text": "Dark mode A:"}, img_block(R / a / f"{pid}.jpg"),
|
| 55 |
{"type": "text", "text": "Dark mode B:"}, img_block(R / b / f"{pid}.jpg"),
|
| 56 |
{"type": "text", "text": "Which dark mode is better? Scores are 1-10."}]
|
|
|
|
| 1 |
#!/usr/bin/env python3
|
| 2 |
+
"""Blind pairwise judging of dark-mode renders with Claude Haiku 5.5 (Batch API). Reads ANTHROPIC_API_KEY via haiku_gen."""
|
| 3 |
import argparse, base64, io, json, random, time
|
| 4 |
from pathlib import Path
|
| 5 |
from PIL import Image
|
|
|
|
| 32 |
ap.add_argument("--effort", default="medium")
|
| 33 |
ap.add_argument("--budget", type=float, default=3.0)
|
| 34 |
ap.add_argument("--chunk", type=int, default=400, help="requests per batch (3 images each, ~150 KB)")
|
| 35 |
+
ap.add_argument("--light-tag", default="", help="reference light renders from RENDERS/<tag>/<id>.jpg instead of the crawl screenshot")
|
| 36 |
args = ap.parse_args()
|
| 37 |
R, crawl, out = Path(args.renders), Path(args.crawl), Path(args.out)
|
| 38 |
out.mkdir(parents=True, exist_ok=True)
|
|
|
|
| 51 |
flip = rng.random() < 0.5
|
| 52 |
a, b = (y, x) if flip else (x, y)
|
| 53 |
cid = f"{pid}-{x}-{y}"
|
| 54 |
+
ref = (R / args.light_tag / f"{pid}.jpg") if args.light_tag else (crawl / "pages" / pid / "shot.jpg")
|
| 55 |
+
if not ref.exists():
|
| 56 |
+
continue
|
| 57 |
+
content = [{"type": "text", "text": "Original light page:"}, img_block(ref),
|
| 58 |
{"type": "text", "text": "Dark mode A:"}, img_block(R / a / f"{pid}.jpg"),
|
| 59 |
{"type": "text", "text": "Dark mode B:"}, img_block(R / b / f"{pid}.jpg"),
|
| 60 |
{"type": "text", "text": "Which dark mode is better? Scores are 1-10."}]
|
training/make_urls2.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Round-2 crawl list: inner pages (articles, docs, product and app pages) of the sites crawled in round 1.
|
| 3 |
+
|
| 4 |
+
Round 1 crawled home pages only; the extension mostly meets inner pages. One same-site link per light or
|
| 5 |
+
native-dark-mode site (a second one for some), preferring content-like paths, plus a curated list of web apps.
|
| 6 |
+
"""
|
| 7 |
+
import argparse, glob, json, random, re
|
| 8 |
+
from urllib.parse import urlparse
|
| 9 |
+
|
| 10 |
+
CONTENT = re.compile(r"/(blog|news|article|articles|post|posts|docs?|documentation|help|support|guide|guides|learn|wiki|product|products|"
|
| 11 |
+
r"shop|store|category|categories|topic|topics|forum|community|about|pricing|features|faq|story|stories|events?|"
|
| 12 |
+
r"courses?|recipes?|reviews?|search|tag|tags|catalog|collections?|services?)(/|$)", re.I)
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def score(u):
|
| 16 |
+
p = urlparse(u).path.rstrip("/")
|
| 17 |
+
segs = [s for s in p.split("/") if s]
|
| 18 |
+
s = 1.0 + (2.0 if CONTENT.search(p + "/") else 0) + (1.0 if len(segs) >= 2 else 0) + (0.5 if re.search(r"[-_][a-z]+[-_]", p) else 0)
|
| 19 |
+
if re.search(r"\.(php|aspx?)$", p) and len(segs) <= 1:
|
| 20 |
+
s -= 0.5
|
| 21 |
+
if re.search(r"/(privacy|terms|legal|cookie|imprint|impressum|contact|careers?|jobs|sitemap)", p, re.I):
|
| 22 |
+
s -= 1.5
|
| 23 |
+
return max(0.1, s)
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def main():
|
| 27 |
+
ap = argparse.ArgumentParser()
|
| 28 |
+
ap.add_argument("--index", required=True, help="glob of round-1 crawl index files")
|
| 29 |
+
ap.add_argument("--extra", default="", help="curated URL list to add")
|
| 30 |
+
ap.add_argument("--out", required=True)
|
| 31 |
+
ap.add_argument("--second", type=float, default=0.3)
|
| 32 |
+
args = ap.parse_args()
|
| 33 |
+
rnd = random.Random(7)
|
| 34 |
+
out, seen = [], set()
|
| 35 |
+
n = {"light": 0, "native": 0}
|
| 36 |
+
for f in sorted(glob.glob(args.index)):
|
| 37 |
+
for line in open(f):
|
| 38 |
+
try:
|
| 39 |
+
r = json.loads(line)
|
| 40 |
+
except Exception:
|
| 41 |
+
continue
|
| 42 |
+
kind = r.get("kind")
|
| 43 |
+
if kind not in ("light", "native_media", "native_class") or not r.get("links"):
|
| 44 |
+
continue
|
| 45 |
+
home = (r.get("final_url") or r["url"]).rstrip("/")
|
| 46 |
+
links = [l for l in r["links"] if l.rstrip("/") != home and l not in seen]
|
| 47 |
+
if not links:
|
| 48 |
+
continue
|
| 49 |
+
k = 2 if (kind == "light" and rnd.random() < args.second and len(links) > 1) else 1
|
| 50 |
+
for _ in range(k):
|
| 51 |
+
w = [score(l) for l in links]
|
| 52 |
+
pick = rnd.choices(links, weights=w)[0]
|
| 53 |
+
links.remove(pick)
|
| 54 |
+
seen.add(pick)
|
| 55 |
+
out.append(pick)
|
| 56 |
+
n["light" if kind == "light" else "native"] += 1
|
| 57 |
+
if not links:
|
| 58 |
+
break
|
| 59 |
+
if args.extra:
|
| 60 |
+
for u in open(args.extra):
|
| 61 |
+
u = u.strip()
|
| 62 |
+
if u and not u.startswith("#") and u not in seen:
|
| 63 |
+
seen.add(u)
|
| 64 |
+
out.append(u)
|
| 65 |
+
rnd.shuffle(out)
|
| 66 |
+
open(args.out, "w").write("\n".join(out) + "\n")
|
| 67 |
+
print(len(out), n)
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
if __name__ == "__main__":
|
| 71 |
+
main()
|
training/model.py
CHANGED
|
@@ -5,15 +5,24 @@ Attention logits get a learned per-head bias from the page's structure graph (te
|
|
| 5 |
surface-on-surface, border-on-surface, same-element, image-on-surface, same-colour), so the model
|
| 6 |
reasons about hierarchy and contrast the way a designer would. Outputs a dark colour per token
|
| 7 |
(OKLab, parameterised relative to the input so hue is kept unless the model chooses otherwise),
|
| 8 |
-
an alpha, and a
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
"""
|
| 10 |
import math
|
| 11 |
import torch
|
| 12 |
import torch.nn as nn
|
| 13 |
import torch.nn.functional as F
|
| 14 |
|
| 15 |
-
FT, FI, FP = 49,
|
| 16 |
-
N_ROLES
|
|
|
|
|
|
|
|
|
|
| 17 |
K_REL = 11
|
| 18 |
|
| 19 |
|
|
@@ -89,7 +98,19 @@ class NadirNet(nn.Module):
|
|
| 89 |
self.blocks = nn.ModuleList([Block(d, heads, ff, drop) for _ in range(layers)])
|
| 90 |
self.ln = nn.LayerNorm(d)
|
| 91 |
self.color_head = MLP(d + 3, d, 6)
|
| 92 |
-
self.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 93 |
|
| 94 |
def forward(self, tok_f, tok_role, tok_lab, img_f, img_thumb, page_f, src, rel, mask):
|
| 95 |
"""tok_f (B,N,FT) tok_role (B,N) long, tok_lab (B,N,3), img_f (B,M,FI), img_thumb (B,M,4,32,32) float 0-1,
|
|
@@ -98,7 +119,7 @@ class NadirNet(nn.Module):
|
|
| 98 |
M = img_f.shape[1]
|
| 99 |
tok = self.tok_in(torch.cat([tok_f, F.one_hot(tok_role, N_ROLES).to(tok_f.dtype), fourier_lab(tok_lab)], -1)) + self.typ.weight[1]
|
| 100 |
if M > 0:
|
| 101 |
-
img = self.
|
| 102 |
else:
|
| 103 |
img = tok.new_zeros(B, 0, tok.shape[-1])
|
| 104 |
cls = self.page_in(page_f)[:, None] + self.typ.weight[0]
|
|
@@ -117,8 +138,18 @@ class NadirNet(nn.Module):
|
|
| 117 |
b = r * a_in + s * b_in + 0.12 * torch.tanh(z[..., 4])
|
| 118 |
lab = torch.stack([L, a, b], -1)
|
| 119 |
alpha_logit = z[..., 5]
|
| 120 |
-
|
| 121 |
-
return lab, alpha_logit,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 122 |
|
| 123 |
|
| 124 |
# ---------- colour maths (differentiable) ----------
|
|
|
|
| 5 |
surface-on-surface, border-on-surface, same-element, image-on-surface, same-colour), so the model
|
| 6 |
reasons about hierarchy and contrast the way a designer would. Outputs a dark colour per token
|
| 7 |
(OKLab, parameterised relative to the input so hue is kept unless the model chooses otherwise),
|
| 8 |
+
an alpha, and a compact page context.
|
| 9 |
+
|
| 10 |
+
Element head (v2): decides how every image, CSS background, sprite or pseudo-element icon on the page is shown
|
| 11 |
+
(keep / invert / adapt / remove) and what it is (photo, logo, icon, formula...). It reuses the trunk's image
|
| 12 |
+
encoder and reads the page context plus the light surface under the element and the dark surface the palette
|
| 13 |
+
head designed for it, so it can run for any element at any time (late-loaded icons, sprites) in a tiny second
|
| 14 |
+
graph without re-running the transformer.
|
| 15 |
"""
|
| 16 |
import math
|
| 17 |
import torch
|
| 18 |
import torch.nn as nn
|
| 19 |
import torch.nn.functional as F
|
| 20 |
|
| 21 |
+
FT, FI, FP = 49, 41, 8
|
| 22 |
+
N_ROLES = 7
|
| 23 |
+
TREAT = ["keep", "invert", "adapt", "remove"]
|
| 24 |
+
IMG_ROLES = ["photo", "illustration", "diagram", "logo", "icon", "text", "backdrop", "avatar", "ui"]
|
| 25 |
+
N_TREAT, N_IROLE, N_CTX = len(TREAT), len(IMG_ROLES), 32
|
| 26 |
K_REL = 11
|
| 27 |
|
| 28 |
|
|
|
|
| 98 |
self.blocks = nn.ModuleList([Block(d, heads, ff, drop) for _ in range(layers)])
|
| 99 |
self.ln = nn.LayerNorm(d)
|
| 100 |
self.color_head = MLP(d + 3, d, 6)
|
| 101 |
+
self.ctx_head = nn.Linear(d, N_CTX)
|
| 102 |
+
self.elem_head = MLP(d + N_CTX + 72, d, N_TREAT + N_IROLE, drop=drop)
|
| 103 |
+
|
| 104 |
+
def embed_images(self, img_f, img_thumb):
|
| 105 |
+
return self.img_in(torch.cat([img_f, self.cnn(img_thumb)], -1))
|
| 106 |
+
|
| 107 |
+
def elements(self, e_f, e_thumb, under_light, under_dark, ctx):
|
| 108 |
+
"""e_f (B,K,FI), e_thumb (B,K,4,32,32), under_light/under_dark (B,K,3) OKLab, ctx (B,N_CTX)
|
| 109 |
+
-> treatment logits (B,K,N_TREAT), role logits (B,K,N_IROLE)."""
|
| 110 |
+
K = e_f.shape[1]
|
| 111 |
+
h = torch.cat([self.embed_images(e_f, e_thumb), ctx[:, None].expand(-1, K, -1), fourier_lab(under_light), fourier_lab(under_dark)], -1)
|
| 112 |
+
z = self.elem_head(h)
|
| 113 |
+
return z[..., :N_TREAT], z[..., N_TREAT:]
|
| 114 |
|
| 115 |
def forward(self, tok_f, tok_role, tok_lab, img_f, img_thumb, page_f, src, rel, mask):
|
| 116 |
"""tok_f (B,N,FT) tok_role (B,N) long, tok_lab (B,N,3), img_f (B,M,FI), img_thumb (B,M,4,32,32) float 0-1,
|
|
|
|
| 119 |
M = img_f.shape[1]
|
| 120 |
tok = self.tok_in(torch.cat([tok_f, F.one_hot(tok_role, N_ROLES).to(tok_f.dtype), fourier_lab(tok_lab)], -1)) + self.typ.weight[1]
|
| 121 |
if M > 0:
|
| 122 |
+
img = self.embed_images(img_f, img_thumb) + self.typ.weight[2]
|
| 123 |
else:
|
| 124 |
img = tok.new_zeros(B, 0, tok.shape[-1])
|
| 125 |
cls = self.page_in(page_f)[:, None] + self.typ.weight[0]
|
|
|
|
| 138 |
b = r * a_in + s * b_in + 0.12 * torch.tanh(z[..., 4])
|
| 139 |
lab = torch.stack([L, a, b], -1)
|
| 140 |
alpha_logit = z[..., 5]
|
| 141 |
+
ctx = self.ctx_head(x[:, 0])
|
| 142 |
+
return lab, alpha_logit, ctx
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
class ElemNet(nn.Module):
|
| 146 |
+
"""The element head as its own graph (shares NadirNet's weights): classify any set of page images."""
|
| 147 |
+
def __init__(self, net):
|
| 148 |
+
super().__init__()
|
| 149 |
+
self.net = net
|
| 150 |
+
|
| 151 |
+
def forward(self, e_f, e_thumb, under_light, under_dark, ctx):
|
| 152 |
+
return self.net.elements(e_f, e_thumb, under_light, under_dark, ctx)
|
| 153 |
|
| 154 |
|
| 155 |
# ---------- colour maths (differentiable) ----------
|
training/predict.py
CHANGED
|
@@ -9,10 +9,11 @@ from model import NadirNet
|
|
| 9 |
from train import collate, prep
|
| 10 |
from mkjobs import job_from
|
| 11 |
|
| 12 |
-
TREAT = ["keep", "
|
|
|
|
| 13 |
|
| 14 |
|
| 15 |
-
from safety import oklab, lab_to_rgb, contrast, safe_treatment, enforce_contrast, is_media_keep, hue_guard, keep_invisible
|
| 16 |
|
| 17 |
|
| 18 |
def decode(L, lab, alpha_logit, img_logits, rels):
|
|
@@ -29,11 +30,14 @@ def decode(L, lab, alpha_logit, img_logits, rels):
|
|
| 29 |
out[i] = {"rgb": rgb, "a": float(t["rgba"][3]), "L": oklab(*rgb)[0]}
|
| 30 |
else:
|
| 31 |
hue_guard(t, out[i])
|
|
|
|
| 32 |
for i, t in enumerate(toks):
|
| 33 |
if t["f"][24] > 0.5 and t["role"] == 0 and out[i]["L"] > 0.35:
|
| 34 |
o = oklab(*out[i]["rgb"]); out[i]["rgb"] = lab_to_rgb(0.2, o[1] * 0.3, o[2] * 0.3); out[i]["L"] = 0.2
|
|
|
|
| 35 |
keep_invisible(toks, out, rels, keep)
|
| 36 |
enforce_contrast(toks, out, rels, keep)
|
|
|
|
| 37 |
def hx(o):
|
| 38 |
h = "#%02x%02x%02x" % tuple(o["rgb"])
|
| 39 |
return h + ("%02x" % round(o["a"] * 255) if o["a"] < 0.999 else "")
|
|
@@ -41,11 +45,24 @@ def decode(L, lab, alpha_logit, img_logits, rels):
|
|
| 41 |
treat = {}
|
| 42 |
for j, im in enumerate(L["images"][:32]):
|
| 43 |
t = TREAT[int(np.argmax(img_logits[j]))] if j < len(img_logits) else "keep"
|
| 44 |
-
|
| 45 |
-
treat[str(j)] = safe_treatment(t, im, out[u]["L"] if u is not None and 0 <= u < len(out) else None)
|
| 46 |
return colors, sorted(keep), treat
|
| 47 |
|
| 48 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
def main():
|
| 50 |
ap = argparse.ArgumentParser()
|
| 51 |
ap.add_argument("--ckpt", required=True)
|
|
@@ -70,10 +87,11 @@ def main():
|
|
| 70 |
L = json.load(gzip.open(Path(args.crawl) / "pages" / s["id"] / "light.json.gz"))
|
| 71 |
b = collate([s], dev)
|
| 72 |
with torch.no_grad():
|
| 73 |
-
lab, al,
|
|
|
|
| 74 |
n = len(s["role"])
|
| 75 |
colors, keep, treat = decode(L, lab[0, :n].float().cpu().numpy().tolist(), al[0, :n].float().cpu().numpy().tolist(),
|
| 76 |
-
il
|
| 77 |
colors = colors + [None] * (len(L["tokens"]) - len(colors))
|
| 78 |
j = job_from(L, s["id"], args.tag, colors, treat)
|
| 79 |
j["keep"] = sorted(set(j["keep"]) | set(keep))
|
|
|
|
| 9 |
from train import collate, prep
|
| 10 |
from mkjobs import job_from
|
| 11 |
|
| 12 |
+
TREAT = ["keep", "invert", "adapt", "remove"]
|
| 13 |
+
RENDER_T = {"keep": "keep", "invert": "invert", "adapt": "invert", "remove": "clear"} # render.py has no adapt path
|
| 14 |
|
| 15 |
|
| 16 |
+
from safety import oklab, lab_to_rgb, contrast, safe_treatment, enforce_contrast, is_media_keep, hue_guard, keep_invisible, rescue_surfaces, glass_panels, opaque_surfaces
|
| 17 |
|
| 18 |
|
| 19 |
def decode(L, lab, alpha_logit, img_logits, rels):
|
|
|
|
| 30 |
out[i] = {"rgb": rgb, "a": float(t["rgba"][3]), "L": oklab(*rgb)[0]}
|
| 31 |
else:
|
| 32 |
hue_guard(t, out[i])
|
| 33 |
+
glass_panels(toks, out, keep)
|
| 34 |
for i, t in enumerate(toks):
|
| 35 |
if t["f"][24] > 0.5 and t["role"] == 0 and out[i]["L"] > 0.35:
|
| 36 |
o = oklab(*out[i]["rgb"]); out[i]["rgb"] = lab_to_rgb(0.2, o[1] * 0.3, o[2] * 0.3); out[i]["L"] = 0.2
|
| 37 |
+
opaque_surfaces(toks, out, keep)
|
| 38 |
keep_invisible(toks, out, rels, keep)
|
| 39 |
enforce_contrast(toks, out, rels, keep)
|
| 40 |
+
rescue_surfaces(toks, out, rels, keep)
|
| 41 |
def hx(o):
|
| 42 |
h = "#%02x%02x%02x" % tuple(o["rgb"])
|
| 43 |
return h + ("%02x" % round(o["a"] * 255) if o["a"] < 0.999 else "")
|
|
|
|
| 45 |
treat = {}
|
| 46 |
for j, im in enumerate(L["images"][:32]):
|
| 47 |
t = TREAT[int(np.argmax(img_logits[j]))] if j < len(img_logits) else "keep"
|
| 48 |
+
treat[str(j)] = RENDER_T[t]
|
|
|
|
| 49 |
return colors, sorted(keep), treat
|
| 50 |
|
| 51 |
|
| 52 |
+
def element_treatments(model, s, lab, ctx, dev):
|
| 53 |
+
"""Element head over a page's first 32 images (render jobs only know those)."""
|
| 54 |
+
m = min(32, len(s["IF"]))
|
| 55 |
+
if not m:
|
| 56 |
+
return []
|
| 57 |
+
f = torch.from_numpy(s["IF"][:m])[None].to(dev)
|
| 58 |
+
th = torch.from_numpy(s["thumbs"][:m].transpose(0, 3, 1, 2) / 255.0).float()[None].to(dev)
|
| 59 |
+
ul = torch.from_numpy(s["IF"][:m, 23:26] * np.array([1, 0.25, 0.25], np.float32))[None].to(dev)
|
| 60 |
+
u = np.where((s["img_under"][:m] >= 0) & (s["img_under"][:m] < lab.shape[1]), s["img_under"][:m], s["root"])
|
| 61 |
+
ud = lab[:, torch.from_numpy(u).to(dev)]
|
| 62 |
+
t, _ = model.elements(f, th, ul, ud, ctx)
|
| 63 |
+
return t[0].float().cpu().numpy().tolist()
|
| 64 |
+
|
| 65 |
+
|
| 66 |
def main():
|
| 67 |
ap = argparse.ArgumentParser()
|
| 68 |
ap.add_argument("--ckpt", required=True)
|
|
|
|
| 87 |
L = json.load(gzip.open(Path(args.crawl) / "pages" / s["id"] / "light.json.gz"))
|
| 88 |
b = collate([s], dev)
|
| 89 |
with torch.no_grad():
|
| 90 |
+
lab, al, ctx = model(b["tok_f"], b["role"], b["lab"], b["img_f"], b["thumbs"], b["page"], torch.zeros_like(b["src"]), b["rel"], b["mask"])
|
| 91 |
+
il = element_treatments(model, s, lab.float(), ctx, dev)
|
| 92 |
n = len(s["role"])
|
| 93 |
colors, keep, treat = decode(L, lab[0, :n].float().cpu().numpy().tolist(), al[0, :n].float().cpu().numpy().tolist(),
|
| 94 |
+
il, s["rel"])
|
| 95 |
colors = colors + [None] * (len(L["tokens"]) - len(colors))
|
| 96 |
j = job_from(L, s["id"], args.tag, colors, treat)
|
| 97 |
j["keep"] = sorted(set(j["keep"]) | set(keep))
|
training/quantize.py
CHANGED
|
@@ -13,7 +13,7 @@ import torch.nn as nn
|
|
| 13 |
|
| 14 |
from model import NadirNet
|
| 15 |
from train import prep
|
| 16 |
-
from export import feeds_for, fidelity, bench, NAMES_IN
|
| 17 |
|
| 18 |
GS = 32
|
| 19 |
|
|
@@ -245,25 +245,29 @@ def build_onnx(fp32_path, mapping, packed, out_path, accuracy_level=0):
|
|
| 245 |
onnx.save(m, out_path)
|
| 246 |
|
| 247 |
|
| 248 |
-
def quality(sess, samples):
|
| 249 |
-
"""dE vs the training targets (Haiku for src 0, native dark for src 1)
|
|
|
|
| 250 |
agg = {0: [0.0, 0.0], 1: [0.0, 0.0]}
|
| 251 |
ok = n = 0
|
| 252 |
for s in samples:
|
| 253 |
f = feeds_for(s)
|
| 254 |
-
lab, _,
|
| 255 |
k = len(s["role"])
|
| 256 |
m = s["tmask"][:k]
|
| 257 |
if m.any():
|
| 258 |
d = np.linalg.norm(lab[0, :k] - s["lab_t"][:k], axis=-1)
|
| 259 |
w = s["w"][:k] * m
|
| 260 |
agg[int(s["src"])][0] += float((d * w).sum() / max(w.sum(), 1e-9)); agg[int(s["src"])][1] += 1
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
ok += int((
|
| 266 |
-
|
|
|
|
|
|
|
|
|
|
| 267 |
|
| 268 |
|
| 269 |
def main():
|
|
@@ -275,6 +279,7 @@ def main():
|
|
| 275 |
ap.add_argument("--calib", type=int, default=256)
|
| 276 |
ap.add_argument("--eval", type=int, default=400)
|
| 277 |
ap.add_argument("--threads", type=int, default=8)
|
|
|
|
| 278 |
args = ap.parse_args()
|
| 279 |
torch.set_num_threads(args.threads)
|
| 280 |
os.makedirs(args.out, exist_ok=True)
|
|
@@ -291,7 +296,7 @@ def main():
|
|
| 291 |
mapping = onnx_matmuls(model, onnx.load(args.fp32))
|
| 292 |
quantizable = [n for n in order if n in mapping and not n.endswith(".rel")]
|
| 293 |
print(f"{len(quantizable)}/{len(order)} linears map to MatMul; fp32 kept: {[n for n in order if n not in quantizable]}", flush=True)
|
| 294 |
-
edges = [n for n in quantizable if n.startswith(("tok_in.", "img_in.", "color_head.", "
|
| 295 |
plans = {"int8": {n: 8 for n in quantizable},
|
| 296 |
"int4": {n: (8 if n in edges else 4) for n in quantizable}}
|
| 297 |
so = ort.SessionOptions(); so.intra_op_num_threads = 4
|
|
@@ -299,7 +304,9 @@ def main():
|
|
| 299 |
report = {"format": {"8-bit layers": f"uint8 blocks of {GS} with fp32 scale + uint8 zero point, dequantized in-graph (Cast/Sub/Mul, folded at load)", "4-bit layers": f"uint4 blocks of {GS}, MatMulNBits"},
|
| 300 |
"group_size": GS, "calib_pages": len(calib), "eval_pages": len(val), "fp32_layers": [n for n in order if n not in quantizable],
|
| 301 |
"int4_8bit_edge_layers": edges, "variants": {}}
|
| 302 |
-
|
|
|
|
|
|
|
| 303 |
report["variants"]["fp32"] = r
|
| 304 |
print("fp32", r, flush=True)
|
| 305 |
for vname, bits_for in plans.items():
|
|
@@ -316,10 +323,10 @@ def main():
|
|
| 316 |
tq = qm(*[torch.from_numpy(f[k]) for k in NAMES_IN])[0].numpy()
|
| 317 |
par = max(par, float(np.abs(tq - sess.run(None, f)[0]).max()))
|
| 318 |
r = fidelity(sess, ref, val)
|
| 319 |
-
r.update(quality(sess, val))
|
| 320 |
r["pack_parity_max_abs"] = par
|
| 321 |
r["size_mb"] = round(os.path.getsize(path) / 1e6, 2)
|
| 322 |
-
r["cpu_ms_median"] = round(bench(sess,
|
| 323 |
report["variants"][vname] = r
|
| 324 |
print(vname, r, flush=True)
|
| 325 |
torch.save({"cfg": ck["cfg"], "model": qm.state_dict()}, os.path.join(args.out, f"fakequant-{vname}.pt"))
|
|
|
|
| 13 |
|
| 14 |
from model import NadirNet
|
| 15 |
from train import prep
|
| 16 |
+
from export import feeds_for, elem_feeds, fidelity, bench, NAMES_IN
|
| 17 |
|
| 18 |
GS = 32
|
| 19 |
|
|
|
|
| 245 |
onnx.save(m, out_path)
|
| 246 |
|
| 247 |
|
| 248 |
+
def quality(sess, samples, esess=None, ref=None):
|
| 249 |
+
"""dE vs the training targets (Haiku for src 0, native dark for src 1); with the element graph, how often the
|
| 250 |
+
element decisions computed from this page graph's outputs match those from the fp32 page graph."""
|
| 251 |
agg = {0: [0.0, 0.0], 1: [0.0, 0.0]}
|
| 252 |
ok = n = 0
|
| 253 |
for s in samples:
|
| 254 |
f = feeds_for(s)
|
| 255 |
+
lab, _, ctx = sess.run(None, f)
|
| 256 |
k = len(s["role"])
|
| 257 |
m = s["tmask"][:k]
|
| 258 |
if m.any():
|
| 259 |
d = np.linalg.norm(lab[0, :k] - s["lab_t"][:k], axis=-1)
|
| 260 |
w = s["w"][:k] * m
|
| 261 |
agg[int(s["src"])][0] += float((d * w).sum() / max(w.sum(), 1e-9)); agg[int(s["src"])][1] += 1
|
| 262 |
+
if esess is not None and ref is not None and len(s["E_ut"]):
|
| 263 |
+
rl, _, rc = ref.run(None, f)
|
| 264 |
+
a = esess.run(None, elem_feeds(s, lab, ctx)[0])[0][0].argmax(-1)
|
| 265 |
+
b = esess.run(None, elem_feeds(s, rl, rc)[0])[0][0].argmax(-1)
|
| 266 |
+
ok += int((a == b).sum()); n += len(a)
|
| 267 |
+
out = {"haiku_dE": agg[0][0] / max(1, agg[0][1]), "native_dE": agg[1][0] / max(1, agg[1][1])}
|
| 268 |
+
if n:
|
| 269 |
+
out["elem_decisions_same_as_fp32"] = ok / n
|
| 270 |
+
return out
|
| 271 |
|
| 272 |
|
| 273 |
def main():
|
|
|
|
| 279 |
ap.add_argument("--calib", type=int, default=256)
|
| 280 |
ap.add_argument("--eval", type=int, default=400)
|
| 281 |
ap.add_argument("--threads", type=int, default=8)
|
| 282 |
+
ap.add_argument("--elem", default="", help="element graph (fp32) to measure decision agreement")
|
| 283 |
args = ap.parse_args()
|
| 284 |
torch.set_num_threads(args.threads)
|
| 285 |
os.makedirs(args.out, exist_ok=True)
|
|
|
|
| 296 |
mapping = onnx_matmuls(model, onnx.load(args.fp32))
|
| 297 |
quantizable = [n for n in order if n in mapping and not n.endswith(".rel")]
|
| 298 |
print(f"{len(quantizable)}/{len(order)} linears map to MatMul; fp32 kept: {[n for n in order if n not in quantizable]}", flush=True)
|
| 299 |
+
edges = [n for n in quantizable if n.startswith(("tok_in.", "img_in.", "color_head.", "ctx_head."))]
|
| 300 |
plans = {"int8": {n: 8 for n in quantizable},
|
| 301 |
"int4": {n: (8 if n in edges else 4) for n in quantizable}}
|
| 302 |
so = ort.SessionOptions(); so.intra_op_num_threads = 4
|
|
|
|
| 304 |
report = {"format": {"8-bit layers": f"uint8 blocks of {GS} with fp32 scale + uint8 zero point, dequantized in-graph (Cast/Sub/Mul, folded at load)", "4-bit layers": f"uint4 blocks of {GS}, MatMulNBits"},
|
| 305 |
"group_size": GS, "calib_pages": len(calib), "eval_pages": len(val), "fp32_layers": [n for n in order if n not in quantizable],
|
| 306 |
"int4_8bit_edge_layers": edges, "variants": {}}
|
| 307 |
+
esess = ort.InferenceSession(args.elem, so, providers=["CPUExecutionProvider"]) if args.elem else None
|
| 308 |
+
bfeeds = [feeds_for(s) for s in val[:20]]
|
| 309 |
+
r = quality(ref, val); r["size_mb"] = round(os.path.getsize(args.fp32) / 1e6, 2); r["cpu_ms_median"] = round(bench(ref, bfeeds), 2)
|
| 310 |
report["variants"]["fp32"] = r
|
| 311 |
print("fp32", r, flush=True)
|
| 312 |
for vname, bits_for in plans.items():
|
|
|
|
| 323 |
tq = qm(*[torch.from_numpy(f[k]) for k in NAMES_IN])[0].numpy()
|
| 324 |
par = max(par, float(np.abs(tq - sess.run(None, f)[0]).max()))
|
| 325 |
r = fidelity(sess, ref, val)
|
| 326 |
+
r.update(quality(sess, val, esess, ref))
|
| 327 |
r["pack_parity_max_abs"] = par
|
| 328 |
r["size_mb"] = round(os.path.getsize(path) / 1e6, 2)
|
| 329 |
+
r["cpu_ms_median"] = round(bench(sess, bfeeds), 2)
|
| 330 |
report["variants"][vname] = r
|
| 331 |
print(vname, r, flush=True)
|
| 332 |
torch.save({"cfg": ck["cfg"], "model": qm.state_dict()}, os.path.join(args.out, f"fakequant-{vname}.pt"))
|
training/safety.py
CHANGED
|
@@ -44,7 +44,9 @@ def adaptable(im, s, surf_L=None):
|
|
| 44 |
return False
|
| 45 |
if not s:
|
| 46 |
return im["w"] * im["h"] <= 250000
|
| 47 |
-
|
|
|
|
|
|
|
| 48 |
|
| 49 |
|
| 50 |
def is_light_backdrop(im, s):
|
|
@@ -69,7 +71,9 @@ def _base_treatment(t, im, surf_L=None):
|
|
| 69 |
if is_light_backdrop(im, s):
|
| 70 |
return "clear"
|
| 71 |
# a mostly dark graphic on transparency would vanish on a dark surface: hue-preserving inversion
|
| 72 |
-
|
|
|
|
|
|
|
| 73 |
return "invert"
|
| 74 |
if not t or t == "keep":
|
| 75 |
return "keep"
|
|
@@ -108,6 +112,34 @@ def hue_guard(t, o):
|
|
| 108 |
o["rgb"] = lab_to_rgb(q[0], q[1] * 0.06 / cout, q[2] * 0.06 / cout)
|
| 109 |
|
| 110 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 111 |
def keep_invisible(toks, out, rels, keep):
|
| 112 |
"""Things that were invisible against their surface in the light design stay invisible: a gradient stop
|
| 113 |
with exactly a surface's colour (text fades, edge fades) takes that surface's dark colour, and a border that
|
|
@@ -163,22 +195,20 @@ def is_media_keep(t):
|
|
| 163 |
def enforce_contrast(toks, out, rels, keep):
|
| 164 |
"""Keep every foreground colour readable on all the surfaces it sits on, never asking for more contrast
|
| 165 |
than the light design had. One lightness per token (tokens are shared), chosen on a grid to minimise the
|
| 166 |
-
weighted log-contrast deficit
|
|
|
|
|
|
|
| 167 |
n = len(toks)
|
| 168 |
surf = {}
|
| 169 |
for typ, a, b, w in rels:
|
| 170 |
a, b = int(a), int(b)
|
| 171 |
if int(typ) == 0 and a < n and b < n and toks[b]["role"] == 0 and out[b]["a"] >= 0.999:
|
| 172 |
-
surf.setdefault(a, []).append((b, float(w) +
|
| 173 |
for i, lst in surf.items():
|
| 174 |
t = toks[i]
|
| 175 |
if i in keep or t["role"] not in FG_ROLES or out[i]["a"] < 0.999:
|
| 176 |
continue
|
| 177 |
-
|
| 178 |
-
fs, bold = t["f"][21] * 32, t["f"][23] > 0.5
|
| 179 |
-
need0 = 3.0 if (fs >= 24 or (fs >= 18.5 and bold)) else 4.5
|
| 180 |
-
else:
|
| 181 |
-
need0 = 3.0
|
| 182 |
reqs = []
|
| 183 |
for j, w in lst:
|
| 184 |
need = min(need0, contrast(t["rgba"][:3], toks[j]["rgba"][:3]))
|
|
@@ -196,7 +226,49 @@ def enforce_contrast(toks, out, rels, keep):
|
|
| 196 |
for k in range(51):
|
| 197 |
Lx = k / 50
|
| 198 |
rgb = lab_to_rgb(Lx, o[1], o[2])
|
| 199 |
-
c = deficit(rgb) / W +
|
| 200 |
if best is None or c < best[0]:
|
| 201 |
best = (c, rgb, Lx)
|
| 202 |
out[i]["rgb"], out[i]["L"] = best[1], best[2]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
return False
|
| 45 |
if not s:
|
| 46 |
return im["w"] * im["h"] <= 250000
|
| 47 |
+
# on its own white rectangle, or on transparency with dark ink that would vanish (the pixel check confirms)
|
| 48 |
+
return (s["transp"] < 0.05 and s.get("ringA", 0) >= 0.95 and s.get("ringL", 0) >= 0.85 and s.get("white", 0) >= 0.3 and s["colors"] < 0.2) or \
|
| 49 |
+
(s["transp"] > 0.15 and s["meanL"] < 0.6)
|
| 50 |
|
| 51 |
|
| 52 |
def is_light_backdrop(im, s):
|
|
|
|
| 71 |
if is_light_backdrop(im, s):
|
| 72 |
return "clear"
|
| 73 |
# a mostly dark graphic on transparency would vanish on a dark surface: hue-preserving inversion
|
| 74 |
+
# (icon-sized ones already when mid-grey: grey UI icons are as unreadable on dark as black ones)
|
| 75 |
+
if (not t or t == "keep") and s and s["transp"] > 0.15 and im["w"] * im["h"] < 200000 and (surf_L is None or surf_L < 0.4) and \
|
| 76 |
+
(s["meanL"] < 0.35 or (s["meanL"] < 0.5 and im["w"] * im["h"] <= 4096)):
|
| 77 |
return "invert"
|
| 78 |
if not t or t == "keep":
|
| 79 |
return "keep"
|
|
|
|
| 112 |
o["rgb"] = lab_to_rgb(q[0], q[1] * 0.06 / cout, q[2] * 0.06 / cout)
|
| 113 |
|
| 114 |
|
| 115 |
+
def glass_panels(toks, out, keep):
|
| 116 |
+
"""A see-through light panel (frosted card, glass button: white at 40-99% opacity) stays see-through but turns
|
| 117 |
+
dark; left mid-grey it is a surface no text colour reads on (enforce_contrast only sees opaque surfaces)."""
|
| 118 |
+
for i, t in enumerate(toks):
|
| 119 |
+
a = t["rgba"][3]
|
| 120 |
+
if i in keep or t["role"] != 0 or a >= 0.999 or a < 0.4:
|
| 121 |
+
continue
|
| 122 |
+
if oklab(*[int(x) for x in t["rgba"][:3]])[0] < 0.85 or out[i]["L"] <= 0.45:
|
| 123 |
+
continue
|
| 124 |
+
o = oklab(*out[i]["rgb"])
|
| 125 |
+
out[i]["rgb"] = lab_to_rgb(0.24, o[1] * 0.5, o[2] * 0.5)
|
| 126 |
+
out[i]["L"] = 0.24
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def opaque_surfaces(toks, out, keep):
|
| 130 |
+
"""An opaque surface stays opaque: see-through, it would show whatever the design stacks beneath it (a hidden
|
| 131 |
+
sticky header, the next slide). The predicted colour is composited over the dark canvas instead."""
|
| 132 |
+
ci = next((i for i, t in enumerate(toks) if t["f"][24] > 0.5 and t["role"] == 0), -1)
|
| 133 |
+
under = out[ci]["rgb"] if ci >= 0 else [18, 18, 20]
|
| 134 |
+
for i, t in enumerate(toks):
|
| 135 |
+
if i in keep or t["role"] != 0 or t["rgba"][3] < 0.999 or out[i]["a"] >= 0.999:
|
| 136 |
+
continue
|
| 137 |
+
a = out[i]["a"]
|
| 138 |
+
out[i]["rgb"] = [int(round(c * a + u * (1 - a))) for c, u in zip(out[i]["rgb"], under)]
|
| 139 |
+
out[i]["a"] = 1.0
|
| 140 |
+
out[i]["L"] = oklab(*out[i]["rgb"])[0]
|
| 141 |
+
|
| 142 |
+
|
| 143 |
def keep_invisible(toks, out, rels, keep):
|
| 144 |
"""Things that were invisible against their surface in the light design stay invisible: a gradient stop
|
| 145 |
with exactly a surface's colour (text fades, edge fades) takes that surface's dark colour, and a border that
|
|
|
|
| 195 |
def enforce_contrast(toks, out, rels, keep):
|
| 196 |
"""Keep every foreground colour readable on all the surfaces it sits on, never asking for more contrast
|
| 197 |
than the light design had. One lightness per token (tokens are shared), chosen on a grid to minimise the
|
| 198 |
+
area-weighted log-contrast deficit plus the distance from the model's own lightness: the surfaces a colour
|
| 199 |
+
mostly sits on decide, and a few bright accent surfaces cannot drag it to a mid-grey that reads nowhere
|
| 200 |
+
(those surfaces are handled by rescue_surfaces)."""
|
| 201 |
n = len(toks)
|
| 202 |
surf = {}
|
| 203 |
for typ, a, b, w in rels:
|
| 204 |
a, b = int(a), int(b)
|
| 205 |
if int(typ) == 0 and a < n and b < n and toks[b]["role"] == 0 and out[b]["a"] >= 0.999:
|
| 206 |
+
surf.setdefault(a, []).append((b, math.expm1(float(w)) + 1.0)) # rels store log1p(weight)
|
| 207 |
for i, lst in surf.items():
|
| 208 |
t = toks[i]
|
| 209 |
if i in keep or t["role"] not in FG_ROLES or out[i]["a"] < 0.999:
|
| 210 |
continue
|
| 211 |
+
need0 = _need(t)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 212 |
reqs = []
|
| 213 |
for j, w in lst:
|
| 214 |
need = min(need0, contrast(t["rgba"][:3], toks[j]["rgba"][:3]))
|
|
|
|
| 226 |
for k in range(51):
|
| 227 |
Lx = k / 50
|
| 228 |
rgb = lab_to_rgb(Lx, o[1], o[2])
|
| 229 |
+
c = deficit(rgb) / W + abs(Lx - o[0])
|
| 230 |
if best is None or c < best[0]:
|
| 231 |
best = (c, rgb, Lx)
|
| 232 |
out[i]["rgb"], out[i]["L"] = best[1], best[2]
|
| 233 |
+
|
| 234 |
+
|
| 235 |
+
def _need(t):
|
| 236 |
+
"""WCAG target for a foreground token: 3:1 for large/bold text and graphics, 4.5:1 for body text."""
|
| 237 |
+
if t["role"] == 1:
|
| 238 |
+
fs, bold = t["f"][21] * 32, t["f"][23] > 0.5
|
| 239 |
+
return 3.0 if (fs >= 24 or (fs >= 18.5 and bold)) else 4.5
|
| 240 |
+
return 3.0
|
| 241 |
+
|
| 242 |
+
|
| 243 |
+
def rescue_surfaces(toks, out, rels, keep):
|
| 244 |
+
"""A light surface whose text no longer reads (its text colour is shared with the dark page and follows it)
|
| 245 |
+
is darkened within its own hue until that text reads again: a yellow note becomes dark gold, not a glare."""
|
| 246 |
+
n = len(toks)
|
| 247 |
+
on = {}
|
| 248 |
+
for typ, a, b, w in rels:
|
| 249 |
+
a, b = int(a), int(b)
|
| 250 |
+
if int(typ) == 0 and a < n and b < n and toks[b]["role"] == 0 and toks[a]["role"] in FG_ROLES:
|
| 251 |
+
on.setdefault(b, []).append((a, math.expm1(float(w)) + 1.0))
|
| 252 |
+
for j, lst in on.items():
|
| 253 |
+
s = toks[j]
|
| 254 |
+
if j in keep or s["f"][24] > 0.5 or out[j]["a"] < 0.999 or out[j]["L"] < 0.6:
|
| 255 |
+
continue
|
| 256 |
+
reqs = []
|
| 257 |
+
for i, w in lst:
|
| 258 |
+
if i in keep or out[i]["a"] < 0.999:
|
| 259 |
+
continue
|
| 260 |
+
need = min(_need(toks[i]), contrast(toks[i]["rgba"][:3], s["rgba"][:3]))
|
| 261 |
+
if need >= 1.5:
|
| 262 |
+
reqs.append((out[i]["rgb"], need, w))
|
| 263 |
+
if not reqs:
|
| 264 |
+
continue
|
| 265 |
+
W = sum(r[2] for r in reqs)
|
| 266 |
+
fail = lambda rgb: sum(w for fg, need, w in reqs if contrast(fg, rgb) < need) / W
|
| 267 |
+
if fail(out[j]["rgb"]) <= 0.5:
|
| 268 |
+
continue
|
| 269 |
+
o = oklab(*out[j]["rgb"])
|
| 270 |
+
for k in range(int(o[0] * 50), 9, -1):
|
| 271 |
+
rgb = lab_to_rgb(k / 50, o[1], o[2])
|
| 272 |
+
if fail(rgb) <= 0.2:
|
| 273 |
+
out[j]["rgb"], out[j]["L"] = rgb, k / 50
|
| 274 |
+
break
|
training/train.py
CHANGED
|
@@ -1,12 +1,12 @@
|
|
| 1 |
#!/usr/bin/env python3
|
| 2 |
-
"""Train NadirNet on Haiku-designed and native dark-mode targets."""
|
| 3 |
import argparse, json, math, os, pickle, random, time
|
| 4 |
from collections import defaultdict
|
| 5 |
import numpy as np
|
| 6 |
import torch
|
| 7 |
import torch.nn.functional as F
|
| 8 |
|
| 9 |
-
from model import NadirNet, K_REL, contrast_ratio
|
| 10 |
from build_ds import oklab
|
| 11 |
|
| 12 |
COLOR_IDX = dict(lab=(0, 1, 2), C=3, alpha=4, cs=(5, 6), rgb=(7, 8, 9), under=(34, 35, 36), over=(39, 40, 41))
|
|
@@ -35,6 +35,10 @@ def prep(s):
|
|
| 35 |
if len(s["keep"]):
|
| 36 |
km[s["keep"]] = True
|
| 37 |
s["tmask"] = s["tmask"] & ~km
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
return s
|
| 39 |
|
| 40 |
|
|
@@ -57,6 +61,8 @@ def augment(s, rng):
|
|
| 57 |
rel = rel[good].copy(); rel[:, 1] = remap[a[good]]; rel[:, 2] = remap[b[good]]
|
| 58 |
s["rel"] = rel
|
| 59 |
iu = s["img_under"].copy(); m = iu >= 0; iu[m] = remap[iu[m]]; s["img_under"] = iu
|
|
|
|
|
|
|
| 60 |
n = len(idx)
|
| 61 |
# hue rotation of the whole design (inputs and targets together)
|
| 62 |
if rng.random() < 0.3:
|
|
@@ -70,6 +76,12 @@ def augment(s, rng):
|
|
| 70 |
F_[:, 7:10] = lab_to_rgb_np(lab.astype(np.float64)).astype(np.float32)
|
| 71 |
s["F"], s["lab_in"] = F_, lab.astype(np.float32)
|
| 72 |
lt = s["lab_t"].copy(); lt[:, 1], lt[:, 2] = rot(lt[:, 1], lt[:, 2]); s["lab_t"] = lt.astype(np.float32)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
return s
|
| 74 |
|
| 75 |
|
|
@@ -81,7 +93,10 @@ def collate(batch, device):
|
|
| 81 |
tok_f = np.zeros((B, N, 49), np.float32); role = np.zeros((B, N), np.int64); lab = np.zeros((B, N, 3), np.float32)
|
| 82 |
lab_t = np.zeros((B, N, 3), np.float32); tmask = np.zeros((B, N), bool); w = np.zeros((B, N), np.float32)
|
| 83 |
alpha_in = np.ones((B, N), np.float32); alpha_t = np.ones((B, N), np.float32)
|
| 84 |
-
img_f = np.zeros((B, M,
|
|
|
|
|
|
|
|
|
|
| 85 |
page = np.zeros((B, 8), np.float32); src = np.zeros(B, np.int64)
|
| 86 |
rel = np.zeros((B, T, T, K_REL), np.float32); mask = np.zeros((B, T), bool)
|
| 87 |
tb_pair = -np.ones((B, N), np.int64)
|
|
@@ -93,6 +108,13 @@ def collate(batch, device):
|
|
| 93 |
img_f[i, :m] = s["IF"]; thumbs[i, :m] = s["thumbs"].transpose(0, 3, 1, 2) / 255.0; imgt[i, :m] = s["imgt"]
|
| 94 |
page[i] = s["page"]; src[i] = s["src"]
|
| 95 |
mask[i, :1 + n] = True; mask[i, 1 + N:1 + N + m] = True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
# relations
|
| 97 |
r = s["rel"]
|
| 98 |
if len(r):
|
|
@@ -119,12 +141,12 @@ def collate(batch, device):
|
|
| 119 |
key = s["rgba"][:, :3] @ np.array([65536, 256, 1], np.float32)
|
| 120 |
same = (key[:, None] == key[None, :]) & ~np.eye(n, dtype=bool)
|
| 121 |
rel[i, 1:1 + n, 1:1 + n, 10] = same
|
|
|
|
|
|
|
|
|
|
| 122 |
if device is None:
|
| 123 |
-
return
|
| 124 |
-
|
| 125 |
-
t = lambda x: torch.from_numpy(x).to(device, non_blocking=True)
|
| 126 |
-
return dict(tok_f=t(tok_f), role=t(role), lab=t(lab), lab_t=t(lab_t), tmask=t(tmask), w=t(w), alpha_in=t(alpha_in), alpha_t=t(alpha_t),
|
| 127 |
-
img_f=t(img_f), thumbs=t(thumbs), imgt=t(imgt), page=t(page), src=t(src), rel=t(rel), mask=t(mask), tb_pair=t(tb_pair))
|
| 128 |
|
| 129 |
|
| 130 |
class Batches(torch.utils.data.Dataset):
|
|
@@ -152,9 +174,16 @@ def huber(x, d):
|
|
| 152 |
return torch.where(ax < d, 0.5 * x * x / d, ax - 0.5 * d)
|
| 153 |
|
| 154 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 155 |
def losses(model, b, img_w=None):
|
| 156 |
-
lab, alpha_logit,
|
| 157 |
-
lab = lab.float(); alpha_logit = alpha_logit.float()
|
| 158 |
m = b["tmask"].float() * b["w"]
|
| 159 |
d = lab - b["lab_t"]
|
| 160 |
per = 2.0 * huber(d[..., 0], 0.02) + huber(d[..., 1], 0.01) + huber(d[..., 2], 0.01)
|
|
@@ -178,17 +207,27 @@ def losses(model, b, img_w=None):
|
|
| 178 |
lcon = (F.relu(c_t - c_pred) * pm.float()).sum() / pm.float().sum().clamp_min(1)
|
| 179 |
else:
|
| 180 |
lcon = lab.new_zeros(())
|
| 181 |
-
#
|
| 182 |
-
|
| 183 |
-
if
|
| 184 |
-
|
| 185 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 186 |
else:
|
| 187 |
-
|
| 188 |
with torch.no_grad():
|
| 189 |
de = (d.norm(dim=-1) * m).sum() / m.sum().clamp_min(1)
|
| 190 |
dl = (d[..., 0].abs() * m).sum() / m.sum().clamp_min(1)
|
| 191 |
-
return lc, la, lcon,
|
| 192 |
|
| 193 |
|
| 194 |
def evaluate(model, val, device, bs, img_w):
|
|
@@ -199,16 +238,22 @@ def evaluate(model, val, device, bs, img_w):
|
|
| 199 |
vs = [s for s in val if s["src"] == src]
|
| 200 |
for k in range(0, len(vs), bs):
|
| 201 |
b = collate(vs[k:k + bs], device)
|
| 202 |
-
lc, la, lcon,
|
| 203 |
n = len(vs[k:k + bs])
|
| 204 |
agg[f"s{src}_dE"] += st["dE"] * n; agg[f"s{src}_dL"] += st["dL"] * n; agg[f"s{src}_con"] += lcon.item() * n
|
| 205 |
cnt[f"s{src}"] += n
|
| 206 |
-
if st["
|
| 207 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 208 |
out = {}
|
| 209 |
for k, v in agg.items():
|
| 210 |
-
if k
|
| 211 |
-
out[k] = v / max(1, cnt["
|
|
|
|
|
|
|
| 212 |
else:
|
| 213 |
out[k] = v / max(1, cnt[k.split("_")[0]])
|
| 214 |
model.train()
|
|
@@ -230,25 +275,42 @@ def main():
|
|
| 230 |
ap.add_argument("--native-weight", type=float, default=0.5)
|
| 231 |
ap.add_argument("--seed", type=int, default=0)
|
| 232 |
ap.add_argument("--workers", type=int, default=8)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 233 |
args = ap.parse_args()
|
| 234 |
torch.manual_seed(args.seed); rng = np.random.default_rng(args.seed)
|
| 235 |
os.makedirs(args.out, exist_ok=True)
|
| 236 |
data = pickle.load(open(args.data, "rb"))
|
| 237 |
train, val = [prep(s) for s in data["train"]], [prep(s) for s in data["val"]]
|
| 238 |
print(f"train {len(train)} val {len(val)} (haiku {sum(s['src'] == 0 for s in train)}, native {sum(s['src'] == 1 for s in train)})", flush=True)
|
| 239 |
-
device = "cuda"
|
| 240 |
model = NadirNet(d=args.d, layers=args.layers, heads=args.heads, drop=args.drop).to(device)
|
| 241 |
nparam = sum(p.numel() for p in model.parameters())
|
| 242 |
print(f"params {nparam / 1e6:.2f}M", flush=True)
|
| 243 |
-
|
| 244 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 245 |
for s in train:
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
opt = torch.optim.AdamW(model.parameters(), lr=args.lr, weight_decay=args.wd, betas=(0.9, 0.98))
|
| 253 |
steps_per = math.ceil(len(train) / args.bs)
|
| 254 |
total = steps_per * args.epochs
|
|
@@ -266,8 +328,8 @@ def main():
|
|
| 266 |
for bnp in loader:
|
| 267 |
b = to_dev(bnp, device)
|
| 268 |
with torch.autocast("cuda", dtype=torch.bfloat16):
|
| 269 |
-
lc, la, lcon,
|
| 270 |
-
loss = lc + 0.5 * la + 0.02 * lcon +
|
| 271 |
opt.zero_grad(set_to_none=True)
|
| 272 |
if not torch.isfinite(loss): # one bad page must never poison the weights
|
| 273 |
run["skipped"] += 1
|
|
@@ -275,16 +337,42 @@ def main():
|
|
| 275 |
loss.backward()
|
| 276 |
torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
|
| 277 |
opt.step(); sched.step(); step += 1
|
| 278 |
-
run["loss"] += loss.item(); run["dE"] += st["dE"]; run["con"] += lcon.item(); run["
|
| 279 |
ev = evaluate(model, val, device, 64, img_w)
|
| 280 |
rec = {"epoch": ep, "step": step, "secs": round(time.time() - t0), **{k: round(v / nb, 5) for k, v in run.items()}, **{k: round(v, 5) for k, v in ev.items()}}
|
| 281 |
log.append(rec)
|
| 282 |
print(json.dumps(rec), flush=True)
|
| 283 |
-
score = ev.get("s0_dE", 9) + 0.3 * ev.get("s1_dE", 0) + 0.02 * ev.get("s0_con", 0) + 0.
|
| 284 |
if score < best:
|
| 285 |
best = score
|
| 286 |
torch.save({"model": model.state_dict(), "cfg": model.cfg, "epoch": ep, "eval": ev}, os.path.join(args.out, "best.pt"))
|
| 287 |
torch.save({"model": model.state_dict(), "cfg": model.cfg, "epoch": args.epochs - 1}, os.path.join(args.out, "last.pt"))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 288 |
json.dump({"args": vars(args), "params": nparam, "log": log, "best_score": best}, open(os.path.join(args.out, "train_log.json"), "w"), indent=1)
|
| 289 |
|
| 290 |
|
|
|
|
| 1 |
#!/usr/bin/env python3
|
| 2 |
+
"""Train NadirNet on Haiku-designed and native dark-mode targets, and its element head on Haiku's image labels."""
|
| 3 |
import argparse, json, math, os, pickle, random, time
|
| 4 |
from collections import defaultdict
|
| 5 |
import numpy as np
|
| 6 |
import torch
|
| 7 |
import torch.nn.functional as F
|
| 8 |
|
| 9 |
+
from model import NadirNet, K_REL, FI, N_TREAT, N_IROLE, contrast_ratio
|
| 10 |
from build_ds import oklab
|
| 11 |
|
| 12 |
COLOR_IDX = dict(lab=(0, 1, 2), C=3, alpha=4, cs=(5, 6), rgb=(7, 8, 9), under=(34, 35, 36), over=(39, 40, 41))
|
|
|
|
| 35 |
if len(s["keep"]):
|
| 36 |
km[s["keep"]] = True
|
| 37 |
s["tmask"] = s["tmask"] & ~km
|
| 38 |
+
if "E_ut" not in s:
|
| 39 |
+
s.update(E_f=np.zeros((0, FI), np.float32), E_thumb=np.zeros((0, 32, 32, 4), np.uint8), E_ul=np.zeros((0, 3), np.float32),
|
| 40 |
+
E_ut=np.zeros(0, np.int64), E_soft=np.zeros((0, N_TREAT), np.float32), E_role=np.zeros(0, np.int64))
|
| 41 |
+
s["root"] = int(np.argmax(s["F"][:, 24])) if len(s["F"]) else 0
|
| 42 |
return s
|
| 43 |
|
| 44 |
|
|
|
|
| 61 |
rel = rel[good].copy(); rel[:, 1] = remap[a[good]]; rel[:, 2] = remap[b[good]]
|
| 62 |
s["rel"] = rel
|
| 63 |
iu = s["img_under"].copy(); m = iu >= 0; iu[m] = remap[iu[m]]; s["img_under"] = iu
|
| 64 |
+
eu = s["E_ut"].copy(); eu[eu >= len(remap)] = -1; m = eu >= 0; eu[m] = remap[eu[m]]; s["E_ut"] = eu
|
| 65 |
+
s["root"] = int(remap[s["root"]]) if remap[s["root"]] >= 0 else 0
|
| 66 |
n = len(idx)
|
| 67 |
# hue rotation of the whole design (inputs and targets together)
|
| 68 |
if rng.random() < 0.3:
|
|
|
|
| 76 |
F_[:, 7:10] = lab_to_rgb_np(lab.astype(np.float64)).astype(np.float32)
|
| 77 |
s["F"], s["lab_in"] = F_, lab.astype(np.float32)
|
| 78 |
lt = s["lab_t"].copy(); lt[:, 1], lt[:, 2] = rot(lt[:, 1], lt[:, 2]); s["lab_t"] = lt.astype(np.float32)
|
| 79 |
+
# the surfaces under images turn with the design (the images themselves stay as they are)
|
| 80 |
+
for k in ("IF", "E_f"):
|
| 81 |
+
if len(s[k]):
|
| 82 |
+
x = s[k].copy(); x[:, 24], x[:, 25] = rot(x[:, 24], x[:, 25]); s[k] = x
|
| 83 |
+
if len(s["E_ul"]):
|
| 84 |
+
u = s["E_ul"].copy(); u[:, 1], u[:, 2] = rot(u[:, 1], u[:, 2]); s["E_ul"] = u.astype(np.float32)
|
| 85 |
return s
|
| 86 |
|
| 87 |
|
|
|
|
| 93 |
tok_f = np.zeros((B, N, 49), np.float32); role = np.zeros((B, N), np.int64); lab = np.zeros((B, N, 3), np.float32)
|
| 94 |
lab_t = np.zeros((B, N, 3), np.float32); tmask = np.zeros((B, N), bool); w = np.zeros((B, N), np.float32)
|
| 95 |
alpha_in = np.ones((B, N), np.float32); alpha_t = np.ones((B, N), np.float32)
|
| 96 |
+
img_f = np.zeros((B, M, FI), np.float32); thumbs = np.zeros((B, M, 4, 32, 32), np.float32); imgt = -np.ones((B, M), np.int64)
|
| 97 |
+
K = max(1, max(min(64, len(s["E_ut"])) for s in batch))
|
| 98 |
+
e_f = np.zeros((B, K, FI), np.float32); e_thumb = np.zeros((B, K, 4, 32, 32), np.float32); e_ul = np.zeros((B, K, 3), np.float32)
|
| 99 |
+
e_ut = np.zeros((B, K), np.int64); e_soft = np.zeros((B, K, N_TREAT), np.float32); e_role = -np.ones((B, K), np.int64); e_mask = np.zeros((B, K), bool)
|
| 100 |
page = np.zeros((B, 8), np.float32); src = np.zeros(B, np.int64)
|
| 101 |
rel = np.zeros((B, T, T, K_REL), np.float32); mask = np.zeros((B, T), bool)
|
| 102 |
tb_pair = -np.ones((B, N), np.int64)
|
|
|
|
| 108 |
img_f[i, :m] = s["IF"]; thumbs[i, :m] = s["thumbs"].transpose(0, 3, 1, 2) / 255.0; imgt[i, :m] = s["imgt"]
|
| 109 |
page[i] = s["page"]; src[i] = s["src"]
|
| 110 |
mask[i, :1 + n] = True; mask[i, 1 + N:1 + N + m] = True
|
| 111 |
+
k = len(s["E_ut"])
|
| 112 |
+
if k:
|
| 113 |
+
sel = np.arange(k) if k <= K else np.sort(np.random.default_rng(len(s["role"]) * 7 + k).choice(k, K, replace=False))
|
| 114 |
+
k = len(sel)
|
| 115 |
+
e_f[i, :k] = s["E_f"][sel]; e_thumb[i, :k] = s["E_thumb"][sel].transpose(0, 3, 1, 2) / 255.0; e_ul[i, :k] = s["E_ul"][sel]
|
| 116 |
+
ut = s["E_ut"][sel]
|
| 117 |
+
e_ut[i, :k] = np.where((ut >= 0) & (ut < n), ut, s["root"]); e_soft[i, :k] = s["E_soft"][sel]; e_role[i, :k] = s["E_role"][sel]; e_mask[i, :k] = True
|
| 118 |
# relations
|
| 119 |
r = s["rel"]
|
| 120 |
if len(r):
|
|
|
|
| 141 |
key = s["rgba"][:, :3] @ np.array([65536, 256, 1], np.float32)
|
| 142 |
same = (key[:, None] == key[None, :]) & ~np.eye(n, dtype=bool)
|
| 143 |
rel[i, 1:1 + n, 1:1 + n, 10] = same
|
| 144 |
+
out = dict(tok_f=tok_f, role=role, lab=lab, lab_t=lab_t, tmask=tmask, w=w, alpha_in=alpha_in, alpha_t=alpha_t,
|
| 145 |
+
img_f=img_f, thumbs=thumbs, imgt=imgt, page=page, src=src, rel=rel, mask=mask, tb_pair=tb_pair,
|
| 146 |
+
e_f=e_f, e_thumb=e_thumb, e_ul=e_ul, e_ut=e_ut, e_soft=e_soft, e_role=e_role, e_mask=e_mask)
|
| 147 |
if device is None:
|
| 148 |
+
return out
|
| 149 |
+
return {k: torch.from_numpy(v).to(device, non_blocking=True) for k, v in out.items()}
|
|
|
|
|
|
|
|
|
|
| 150 |
|
| 151 |
|
| 152 |
class Batches(torch.utils.data.Dataset):
|
|
|
|
| 174 |
return torch.where(ax < d, 0.5 * x * x / d, ax - 0.5 * d)
|
| 175 |
|
| 176 |
|
| 177 |
+
def element_logits(model, b, lab, ctx):
|
| 178 |
+
"""The element head reads the dark surface the palette head designed under each element (as the runtime does)."""
|
| 179 |
+
under_dark = torch.gather(lab.detach(), 1, b["e_ut"][..., None].expand(-1, -1, 3))
|
| 180 |
+
t, r = model.elements(b["e_f"], b["e_thumb"], b["e_ul"], under_dark, ctx)
|
| 181 |
+
return t.float(), r.float()
|
| 182 |
+
|
| 183 |
+
|
| 184 |
def losses(model, b, img_w=None):
|
| 185 |
+
lab, alpha_logit, ctx = model(b["tok_f"], b["role"], b["lab"], b["img_f"], b["thumbs"], b["page"], b["src"], b["rel"], b["mask"])
|
| 186 |
+
lab = lab.float(); alpha_logit = alpha_logit.float()
|
| 187 |
m = b["tmask"].float() * b["w"]
|
| 188 |
d = lab - b["lab_t"]
|
| 189 |
per = 2.0 * huber(d[..., 0], 0.02) + huber(d[..., 1], 0.01) + huber(d[..., 2], 0.01)
|
|
|
|
| 207 |
lcon = (F.relu(c_t - c_pred) * pm.float()).sum() / pm.float().sum().clamp_min(1)
|
| 208 |
else:
|
| 209 |
lcon = lab.new_zeros(())
|
| 210 |
+
# elements: soft targets (best rendering 0.75, other acceptable ones share 0.25) and the image's role
|
| 211 |
+
em = b["e_mask"]
|
| 212 |
+
if em.any():
|
| 213 |
+
tl, rl = element_logits(model, b, lab, ctx)
|
| 214 |
+
soft = b["e_soft"][em]
|
| 215 |
+
lp = F.log_softmax(tl[em], -1)
|
| 216 |
+
le = -(soft * lp * (img_w if img_w is not None else 1.0)).sum(-1).mean()
|
| 217 |
+
rm = em & (b["e_role"] >= 0)
|
| 218 |
+
lr = F.cross_entropy(rl[rm], b["e_role"][rm], label_smoothing=0.05) if rm.any() else lab.new_zeros(())
|
| 219 |
+
with torch.no_grad():
|
| 220 |
+
pred = tl[em].argmax(-1)
|
| 221 |
+
e_acc = (pred == soft.argmax(-1)).float().mean().item()
|
| 222 |
+
e_ok = (soft.gather(1, pred[:, None])[:, 0] > 0).float().mean().item()
|
| 223 |
+
r_acc = (rl[rm].argmax(-1) == b["e_role"][rm]).float().mean().item() if rm.any() else 0.0
|
| 224 |
+
n_el, n_role = int(em.sum()), int(rm.sum())
|
| 225 |
else:
|
| 226 |
+
le = lr = lab.new_zeros(()); e_acc = e_ok = r_acc = 0.0; n_el = n_role = 0
|
| 227 |
with torch.no_grad():
|
| 228 |
de = (d.norm(dim=-1) * m).sum() / m.sum().clamp_min(1)
|
| 229 |
dl = (d[..., 0].abs() * m).sum() / m.sum().clamp_min(1)
|
| 230 |
+
return lc, la, lcon, le, lr, dict(dE=de.item(), dL=dl.item(), e_acc=e_acc, e_ok=e_ok, r_acc=r_acc, n_el=n_el, n_role=n_role)
|
| 231 |
|
| 232 |
|
| 233 |
def evaluate(model, val, device, bs, img_w):
|
|
|
|
| 238 |
vs = [s for s in val if s["src"] == src]
|
| 239 |
for k in range(0, len(vs), bs):
|
| 240 |
b = collate(vs[k:k + bs], device)
|
| 241 |
+
lc, la, lcon, le, lr, st = losses(model, b, img_w)
|
| 242 |
n = len(vs[k:k + bs])
|
| 243 |
agg[f"s{src}_dE"] += st["dE"] * n; agg[f"s{src}_dL"] += st["dL"] * n; agg[f"s{src}_con"] += lcon.item() * n
|
| 244 |
cnt[f"s{src}"] += n
|
| 245 |
+
if st["n_el"]:
|
| 246 |
+
for key in ("e_acc", "e_ok"):
|
| 247 |
+
agg[key] += st[key] * st["n_el"]
|
| 248 |
+
agg["e_nll"] += le.item() * st["n_el"]; cnt["e"] += st["n_el"]
|
| 249 |
+
if st["n_role"]:
|
| 250 |
+
agg["r_acc"] += st["r_acc"] * st["n_role"]; cnt["r"] += st["n_role"]
|
| 251 |
out = {}
|
| 252 |
for k, v in agg.items():
|
| 253 |
+
if k.startswith("e_"):
|
| 254 |
+
out[k] = v / max(1, cnt["e"])
|
| 255 |
+
elif k.startswith("r_"):
|
| 256 |
+
out[k] = v / max(1, cnt["r"])
|
| 257 |
else:
|
| 258 |
out[k] = v / max(1, cnt[k.split("_")[0]])
|
| 259 |
model.train()
|
|
|
|
| 275 |
ap.add_argument("--native-weight", type=float, default=0.5)
|
| 276 |
ap.add_argument("--seed", type=int, default=0)
|
| 277 |
ap.add_argument("--workers", type=int, default=8)
|
| 278 |
+
ap.add_argument("--elem-weight", type=float, default=0.1)
|
| 279 |
+
ap.add_argument("--role-weight", type=float, default=0.03)
|
| 280 |
+
ap.add_argument("--elem-balance", type=float, default=0.3, help="element class weights = (mass share)^-balance")
|
| 281 |
+
ap.add_argument("--init", default="", help="warm start from a checkpoint (matching tensors; widened inputs are zero-padded)")
|
| 282 |
args = ap.parse_args()
|
| 283 |
torch.manual_seed(args.seed); rng = np.random.default_rng(args.seed)
|
| 284 |
os.makedirs(args.out, exist_ok=True)
|
| 285 |
data = pickle.load(open(args.data, "rb"))
|
| 286 |
train, val = [prep(s) for s in data["train"]], [prep(s) for s in data["val"]]
|
| 287 |
print(f"train {len(train)} val {len(val)} (haiku {sum(s['src'] == 0 for s in train)}, native {sum(s['src'] == 1 for s in train)})", flush=True)
|
| 288 |
+
device = "cuda" if torch.cuda.is_available() else "cpu"
|
| 289 |
model = NadirNet(d=args.d, layers=args.layers, heads=args.heads, drop=args.drop).to(device)
|
| 290 |
nparam = sum(p.numel() for p in model.parameters())
|
| 291 |
print(f"params {nparam / 1e6:.2f}M", flush=True)
|
| 292 |
+
if args.init:
|
| 293 |
+
src_sd = torch.load(args.init, map_location="cpu")["model"]
|
| 294 |
+
sd = model.state_dict()
|
| 295 |
+
loaded = 0
|
| 296 |
+
for k, v in src_sd.items():
|
| 297 |
+
if k not in sd:
|
| 298 |
+
continue
|
| 299 |
+
if sd[k].shape == v.shape:
|
| 300 |
+
sd[k] = v; loaded += 1
|
| 301 |
+
elif sd[k].dim() == 2 and sd[k].shape[0] == v.shape[0] and sd[k].shape[1] > v.shape[1]:
|
| 302 |
+
w = torch.zeros_like(sd[k]); w[:, :v.shape[1]] = v; sd[k] = w; loaded += 1
|
| 303 |
+
model.load_state_dict(sd)
|
| 304 |
+
print(f"warm start from {args.init}: {loaded} tensors", flush=True)
|
| 305 |
+
img_w = None
|
| 306 |
+
el = np.zeros(N_TREAT)
|
| 307 |
for s in train:
|
| 308 |
+
el += s["E_soft"].sum(0) if len(s["E_soft"]) else 0
|
| 309 |
+
print("element label mass", el.round().astype(int).tolist(), "pages with elements", sum(len(s["E_ut"]) > 0 for s in train), flush=True)
|
| 310 |
+
if el.sum() > 0:
|
| 311 |
+
cw = (np.maximum(el, 1) / el.sum()) ** (-args.elem_balance)
|
| 312 |
+
img_w = torch.tensor(cw / cw.mean(), dtype=torch.float32, device=device)
|
| 313 |
+
print("element class weights", [round(float(x), 3) for x in img_w], flush=True)
|
| 314 |
opt = torch.optim.AdamW(model.parameters(), lr=args.lr, weight_decay=args.wd, betas=(0.9, 0.98))
|
| 315 |
steps_per = math.ceil(len(train) / args.bs)
|
| 316 |
total = steps_per * args.epochs
|
|
|
|
| 328 |
for bnp in loader:
|
| 329 |
b = to_dev(bnp, device)
|
| 330 |
with torch.autocast("cuda", dtype=torch.bfloat16):
|
| 331 |
+
lc, la, lcon, le, lr, st = losses(model, b, img_w)
|
| 332 |
+
loss = lc + 0.5 * la + 0.02 * lcon + args.elem_weight * le + args.role_weight * lr
|
| 333 |
opt.zero_grad(set_to_none=True)
|
| 334 |
if not torch.isfinite(loss): # one bad page must never poison the weights
|
| 335 |
run["skipped"] += 1
|
|
|
|
| 337 |
loss.backward()
|
| 338 |
torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
|
| 339 |
opt.step(); sched.step(); step += 1
|
| 340 |
+
run["loss"] += loss.item(); run["dE"] += st["dE"]; run["con"] += lcon.item(); run["elem"] += le.item(); run["e_ok"] += st["e_ok"]; nb += 1
|
| 341 |
ev = evaluate(model, val, device, 64, img_w)
|
| 342 |
rec = {"epoch": ep, "step": step, "secs": round(time.time() - t0), **{k: round(v / nb, 5) for k, v in run.items()}, **{k: round(v, 5) for k, v in ev.items()}}
|
| 343 |
log.append(rec)
|
| 344 |
print(json.dumps(rec), flush=True)
|
| 345 |
+
score = ev.get("s0_dE", 9) + 0.3 * ev.get("s1_dE", 0) + 0.02 * ev.get("s0_con", 0) + 0.05 * (1 - ev.get("e_ok", 0))
|
| 346 |
if score < best:
|
| 347 |
best = score
|
| 348 |
torch.save({"model": model.state_dict(), "cfg": model.cfg, "epoch": ep, "eval": ev}, os.path.join(args.out, "best.pt"))
|
| 349 |
torch.save({"model": model.state_dict(), "cfg": model.cfg, "epoch": args.epochs - 1}, os.path.join(args.out, "last.pt"))
|
| 350 |
+
# confidence calibration of the element head: one temperature fitted on held-out sites
|
| 351 |
+
ck = torch.load(os.path.join(args.out, "best.pt"), map_location=device)
|
| 352 |
+
model.load_state_dict(ck["model"]); model.eval()
|
| 353 |
+
logits, softs = [], []
|
| 354 |
+
with torch.no_grad(), torch.autocast("cuda", dtype=torch.bfloat16):
|
| 355 |
+
vs = [s for s in val if len(s["E_ut"])]
|
| 356 |
+
for k in range(0, len(vs), 64):
|
| 357 |
+
b = collate(vs[k:k + 64], device)
|
| 358 |
+
lab, _, ctx = model(b["tok_f"], b["role"], b["lab"], b["img_f"], b["thumbs"], b["page"], b["src"], b["rel"], b["mask"])
|
| 359 |
+
tl, _ = element_logits(model, b, lab.float(), ctx)
|
| 360 |
+
logits.append(tl[b["e_mask"]].float()); softs.append(b["e_soft"][b["e_mask"]])
|
| 361 |
+
if logits:
|
| 362 |
+
lg, sf = torch.cat(logits), torch.cat(softs)
|
| 363 |
+
hard = sf.argmax(-1)
|
| 364 |
+
best_t, best_nll = 1.0, 1e9
|
| 365 |
+
for tt in np.arange(0.5, 3.01, 0.05):
|
| 366 |
+
nll = F.cross_entropy(lg / tt, hard).item()
|
| 367 |
+
if nll < best_nll:
|
| 368 |
+
best_t, best_nll = float(tt), nll
|
| 369 |
+
p = F.softmax(lg / best_t, -1); conf, pred = p.max(-1); corr = (pred == hard).float()
|
| 370 |
+
bins = torch.clamp((conf * 10).long(), max=9)
|
| 371 |
+
ece = sum((bins == i).float().mean() * (conf[bins == i].mean() - corr[bins == i].mean()).abs() for i in range(10) if (bins == i).any()).item()
|
| 372 |
+
ck["temperature"] = best_t
|
| 373 |
+
ck["calibration"] = {"temperature": best_t, "nll": best_nll, "ece": ece, "n": int(len(hard))}
|
| 374 |
+
torch.save(ck, os.path.join(args.out, "best.pt"))
|
| 375 |
+
print("element head calibration", ck["calibration"], flush=True)
|
| 376 |
json.dump({"args": vars(args), "params": nparam, "log": log, "best_score": best}, open(os.path.join(args.out, "train_log.json"), "w"), indent=1)
|
| 377 |
|
| 378 |
|