ProCreations commited on
Commit
864f427
·
verified ·
1 Parent(s): 61b9049

Nadir v2: element head, inner-page data, runtime fixes

Browse files

Weights, quants, runtime, extension, reports and README swapped to v2.

.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
- care of a real dark theme rather than a filter. It runs entirely in your browser (onnxruntime-web, WASM),
19
- in about 35 ms of model time per page, and ships with a ready-to-load Chromium extension.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 (`prefers-color-scheme`
25
- media rules, or `dark`/`theme-dark` class and attribute toggles it detects in the page's CSS).
26
- 3. **No dark mode? The model designs one.** Nadir reads the page's colors and images and predicts a dark
27
- palette for that specific site: brand colors stay recognizable, surfaces keep their hierarchy, text stays
28
- readable, and logos stay visible: a logo on its own white rectangle is adapted to sit cleanly on the dark
29
- surface. Photos, videos and ads are never color-shifted.
30
-
31
- In a blind pairwise test on 146 sites it never trained on, judged by Claude Haiku 5.5, the preference
32
- went to Nadir's dark mode over Dark Reader's on **93 sites vs 50** (3 ties): 65% of the decided pages.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- *in its role*: background, text, border, shadow, gradient, SVG fill, SVG stroke. Each token gets 49
40
- features: lightness, chroma, hue, area, text size and weight, where it appears (header, nav, link, form,
41
- code, footer), what it sits on and the original contrast. It also records four relations between tokens
42
- (text-on-surface, surface-on-surface, border-on-surface, same-element) and every visible image or
43
- inline-SVG logo with 39 features plus a 32×32 thumbnail. Ad slots are excluded.
44
- 2. **Model (`NadirNet`).** A relation-biased transformer over `[page] + tokens + images`. Each attention head
45
- gets a learned bias from the 11 relation channels, so the model knows which text sits on which surface.
46
- It predicts for every token an OKLab color as a parameterized edit of the input (lightness, chroma scale,
47
- hue rotation, tint, alpha). This keeps brand hues stable and makes impossible colors rare. For every
48
- image it predicts a treatment: `keep`, `dim`, `invert`, `light` (monochrome dark glyph → near-white) or
49
- `plate` (dark multicolor logo that needs help; shown with a hue-preserving inversion at runtime). A small
50
- CNN reads the thumbnails.
51
- 3. **Design rules (`runtime/nadir-infer.js`, `runtime/nadir-map.js`).** Deterministic guards on top of the
52
- model:
 
 
 
 
 
 
 
 
 
 
 
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
- more contrast than the original design had.
 
56
  - Brand colors keep their hue (±45°).
57
- - Things that were invisible in the light design stay invisible: text-fade gradients and borders that
58
- matched their surface follow the surface.
59
- - Colors drawn over video and photos are left alone when they already read as dark mode (player
60
- controls, scrims, captions).
61
- - Photos and video are never recolored.
62
- - Light, flat background images behind content (paper textures, pale washes, SVG gradients) are cleared so the
63
- designed dark surface shows through. Images with real detail are left alone.
64
- - Inline SVG logos stay readable: their shapes are drawn as ink, and SVG mask and clip-path shapes (knockout
65
- text, cut-outs) are never recolored.
66
- - SVG shapes painted in a surface color and invisible on what they sit on (inverted rounded corners, section
67
- caps) are part of that surface and take its dark color.
68
- - **Logos on their own white rectangle are adapted.** The white background is removed so Nadir's surface
69
- shows through, black or gray ink becomes light, and brand colors keep their hue (lifted only as far as
70
- needed to read on dark). This applies only to images the page itself marks as its logo.
71
- - Dark logos on transparency get a hue-preserving inversion, and already-light ones are kept.
72
- 4. **Engine.** The predicted palette is applied through a patched
73
- [Dark Reader](https://github.com/darkreader/darkreader) 4.9.133 dynamic engine (MIT), which handles the
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 | 15.9M (15,895,995) |
83
  | Width / depth / heads | 384 / 8 layers / 6 heads |
84
- | Inputs | ≤256 color tokens, ≤32 images, 11 relation channels |
85
- | Outputs | OKLab color + alpha per token, 5-way image treatment |
86
  | Runtime | onnxruntime-web 1.29 (WASM SIMD, multi-threaded) |
 
87
 
88
  ### Files
89
 
90
- | File | Precision | Size | Δ color vs fp32 (mean / max, OKLab) | Image agreement |
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
- | fp32 | ~7 s | 34 ms |
103
- | int8 | ~1.5 s | 33–35 ms |
104
- | int4 | ~1.0 s | 38 ms |
105
-
106
- The extension's own end-to-end time for a first visit is 0.7–3 s, mostly reading the page and its images. Revisits apply
107
- from cache.
108
-
109
- - `onnx/nadir-int8.onnx` is the extension's default.
110
- - The 8-bit and 4-bit models are **calibrated weight-only quants**:
111
- - Layer-sequential GPTQ, with Hessians collected from 256 real crawled pages flowing through the
112
- already-quantized earlier layers.
113
- - Asymmetric codes in groups of 32, with each group's clipping range chosen by an MSE search.
114
- - The 4-bit model keeps its input and output layers at 8 bits.
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
- - **Pages.** A crawl of 47,569 sites from the [Tranco](https://tranco-list.eu/) top list at 1280×800.
131
- Each page was captured in light mode, in emulated dark mode, and with dark-class toggles applied.
132
- - **Teacher.** 19,563 light pages were redesigned by **Claude Haiku 5.5** (Anthropic Batch API,
133
- high effort) acting as the site's own product designer. It was given the screenshot, a contact sheet of
134
- the images, and the full color inventory with roles and locations, and returned a dark palette plus image
135
- treatments in a strict JSON schema. The design brief is in `training/haiku_gen.py`.
136
- - **Native pairs.** 4,142 pages with a real built-in dark theme give human-designed light→dark
137
- pairs, aligned token by token.
 
 
 
 
 
 
 
 
 
 
138
  - **Quality control.**
139
- - Designs that missed ids, kept a light canvas, or left >15% of text below 3:1 contrast were dropped.
140
- - The rest were passed through the same contrast rule the runtime uses (14,338 of 19,464 designs adjusted).
141
- - The train/validation split is by site (hash of the hostname).
142
- - **Augmentation.** Random token dropout (it also simulates ad blockers and partial loads) and whole-design
143
- hue rotation.
 
144
 
145
  ## Evaluation
146
 
147
- The judge was a blind pairwise one: Claude Haiku 5.5 saw the original light page plus two dark versions in
148
- random order, A and B. It judged readability first, then breakage (glaring light patches, lost logos,
149
- distorted photos), then design quality. All pages are held-out sites the model never trained on.
150
 
151
- | Nadir vs … (146 held-out sites) | Nadir preferred | Other preferred | Ties |
 
 
 
 
 
152
  |---|---|---|---|
153
- | Dark Reader 4.9 (dynamic mode) | **93** | 50 | 3 |
154
- | Claude Haiku 5.5's own designs (the teacher)¹ | 76 | 61 | 9 |
155
- | Nadir before its final design rules² | 45 | 29 | 72 |
 
156
 
157
- ¹ The teacher's designs were rendered with the earlier runtime, so part of this margin comes from the runtime's newer
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
- **Model size.** A 34.0M-parameter version (512 / 10 layers / 8 heads) trained on the same data was no better: 49 vs 53
162
- with 45 ties against this one. It reached the same color error and runs about 2× slower in the browser, so the 15.9M
163
- model is the release.
 
 
 
 
 
 
 
 
 
 
 
 
164
 
165
- Held-out color error vs the teacher designs (OKLab ΔE): 0.040 (Haiku pages), 0.103
166
- (native dark themes). Image-treatment accuracy: 79.4% over 5 classes.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
167
 
168
  ## Use it
169
 
170
  ### Browser extension (Chrome, Edge, Brave, Arc, Aside and other Chromium browsers)
171
 
172
- 1. Download `nadir-extension.zip` and unzip it.
 
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}); // in the page: colors, roles, relations, images
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 session = await ort.InferenceSession.create('nadir-int8.onnx', {executionProviders: ['wasm']});
195
- const feeds = Object.fromEntries(Object.entries(built.feeds).map(([k, [data, dims]]) =>
196
- [k, new ort.Tensor(k === 'tok_role' || k === 'src' ? 'int64' : k === 'mask' ? 'bool' : 'float32', data, dims)]));
197
- const out = await session.run(feeds);
198
- const {colors, keep, treatments} = NadirInfer.decode(page, built,
199
- {lab: out.lab.data, alpha_logit: out.alpha_logit.data, img_logits: out.img_logits.data});
200
- // colors[i] is the dark replacement for page.tokens[i] (null = keep); treatments[j] applies to page.images[j]
 
 
 
 
 
201
  ```
202
 
203
  ## Limitations
204
 
205
- - Nadir recolors CSS. Text and UI baked into images, canvas drawings and cross-origin iframes keep their
206
- original colors, though the iframes get their own Nadir pass in the extension.
207
- - Colors are mapped per (color, role). If one exact color is used both as a light card and as a light
208
- button, both get the same dark color.
209
- - Background images that mix light areas with strong color (for example a colored hero shape with a white base) are
210
- kept as they are, so their light parts stay light.
211
- - The model reads at most 256 colors per page. On very colorful pages the rarest colors are mapped by nearest
212
- neighbor in the predicted palette.
213
- - The teacher is an LLM. Its taste is good but not perfect, and the model inherits its preferences.
214
- - The judge is also an LLM, so the evaluation is automated, not a human study.
 
 
 
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
- included in the extension (`lib/DARKREADER-LICENSE`).
 
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
+ ![geogebra: light page and Nadir dark mode side by side](https://huggingface.co/ProCreations/nadir/resolve/main/demos/geogebra.jpg)
25
+
26
+ **GeoGebra.** The purple brand color and the illustration keep their colors; surfaces and text go dark.
27
+
28
+ ![python: light page and Nadir dark mode side by side](https://huggingface.co/ProCreations/nadir/resolve/main/demos/python.jpg)
29
+
30
+ **python.org.** The brand's blue and yellow stay, and the code sample keeps its syntax colors.
31
+
32
+ ![mathworld: light page and Nadir dark mode side by side](https://huggingface.co/ProCreations/nadir/resolve/main/demos/mathworld.jpg)
33
+
34
+ **Wolfram MathWorld.** Formula images are recolored so they read on the dark page; the rendered sphere keeps its colors.
35
+
36
+ ![hackernews: light page and Nadir dark mode side by side](https://huggingface.co/ProCreations/nadir/resolve/main/demos/hackernews.jpg)
37
+
38
+ **Hacker News.** The orange header stays; the beige page becomes a warm dark grey.
39
+
40
+ ![addtoany: light page and Nadir dark mode side by side](https://huggingface.co/ProCreations/nadir/resolve/main/demos/addtoany.jpg)
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
+ ![pypi: light page and Nadir dark mode side by side](https://huggingface.co/ProCreations/nadir/resolve/main/demos/pypi.jpg)
45
+
46
+ **PyPI.** Badges, code blocks and the blue header keep their colors and hierarchy.
47
+
48
+ ![wikipedia: light page and Nadir dark mode side by side](https://huggingface.co/ProCreations/nadir/resolve/main/demos/wikipedia.jpg)
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": 39,
10
  "relation_channels": 11,
11
  "max_tokens": 256,
12
  "max_images": 32,
13
- "image_treatments": [
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
  "keep",
15
- "dim",
16
  "invert",
17
- "light",
18
- "plate"
19
  ],
20
- "inputs": [
21
- "tok_f",
22
- "tok_role",
23
- "tok_lab",
24
- "img_f",
25
- "img_thumb",
26
- "page_f",
27
- "src",
28
- "rel",
29
- "mask"
30
  ],
31
- "outputs": [
32
- "lab",
33
- "alpha_logit",
34
- "img_logits"
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

  • SHA256: 26dfb8d06780ca4f833a9353c8c2d58dd401645256aa3b7b35a16e0b828f541d
  • Pointer size: 131 Bytes
  • Size of remote file: 157 kB
demos/hackernews.jpg ADDED

Git LFS Details

  • SHA256: 4bf4725d0d83ff3e0a7809686d328157a0bbbf089e9b03c8bbbdd5183f29612d
  • Pointer size: 131 Bytes
  • Size of remote file: 171 kB
demos/mathworld.jpg ADDED

Git LFS Details

  • SHA256: e79fc38d54c9edb99a8ad56630410360bca2a35703d50515b8207c990591370c
  • Pointer size: 131 Bytes
  • Size of remote file: 140 kB
demos/pypi.jpg ADDED

Git LFS Details

  • SHA256: f980a0a38b2d1e2ec480bc35ac1d083b01438e387c8b8ef4b3c0777f1518bf2b
  • Pointer size: 131 Bytes
  • Size of remote file: 116 kB
demos/python.jpg ADDED

Git LFS Details

  • SHA256: c77be32cb82a851317f98ce7fea931c7a50fa19959632f265ffed0f5128e1d79
  • Pointer size: 131 Bytes
  • Size of remote file: 142 kB
demos/wikipedia.jpg ADDED

Git LFS Details

  • SHA256: e824cd26a3e942c090e7bc1fb187a96c1133289cb04e0377c77aadec5df03566
  • Pointer size: 131 Bytes
  • Size of remote file: 161 kB
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 treatment class per image.
 
 
 
 
 
 
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, 39, 8
16
- N_ROLES, N_TREAT = 7, 5
 
 
 
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.img_head = MLP(d, d // 2, N_TREAT)
 
 
 
 
 
 
 
 
 
 
 
 
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.img_in(torch.cat([img_f, self.cnn(img_thumb)], -1)) + self.typ.weight[2]
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
- img_logits = self.img_head(x[:, 1 + N:]) if M > 0 else x.new_zeros(B, 0, N_TREAT)
121
- return lab, alpha_logit, img_logits
 
 
 
 
 
 
 
 
 
 
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:e7acc89ba8a039a4889d89ec9f01afce49b2f765d1ac57a489eb4c604aef2ceb
3
- size 63596788
 
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:71e3cb4fe7f3925621abf48d2230d97d18622da6cf08dcea42a8b5959acc0219
3
- size 23422165
 
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:83c54558313415596d4d3b3e753ff30e3a72cdc65e27a5c8eefcb132b9d5c934
3
- size 63699911
 
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:5e11864ad3d7f1edb7bc8b7006e5d7a1c12401bbc17578f590182c0ca764802a
3
- size 13313059
 
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:bbdb0a2b13bfca0c05f8d19d54d3b721fb4fcd459d40e8519a7a5f77af15d634
3
- size 20626640
 
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": 15895995,
3
- "parity_max_abs": 1.2516975402832031e-06,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
  "variants": {
5
  "fp32": {
6
  "dE_vs_fp32": 0.0,
7
  "dE_max": 0.0,
8
- "img_agree": 1.0,
9
- "size_mb": 63.7,
10
- "cpu_ms_median": 7.48
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": 7246.9,
7
- "ms_median": 117.08,
8
- "max_abs_lab_vs_ort_cpu": 6.16908073425293e-06,
9
- "max_abs_img_vs_ort_cpu": 4.172325134277344e-06
 
10
  },
11
  "t4": {
12
  "isolated": false,
13
- "load_ms": 7363.2,
14
- "ms_median": 33.93,
15
- "max_abs_lab_vs_ort_cpu": 6.16908073425293e-06,
16
- "max_abs_img_vs_ort_cpu": 4.172325134277344e-06
 
17
  }
18
  },
19
  "int8": {
20
  "t1": {
21
  "isolated": false,
22
- "load_ms": 1356.6,
23
- "ms_median": 116.78,
24
- "max_abs_lab_vs_ort_cpu": 1.9669532775878906e-06,
25
- "max_abs_img_vs_ort_cpu": 2.9802322387695312e-06
 
26
  },
27
  "t4": {
28
  "isolated": false,
29
- "load_ms": 1628.3,
30
- "ms_median": 34.78,
31
- "max_abs_lab_vs_ort_cpu": 1.9669532775878906e-06,
32
- "max_abs_img_vs_ort_cpu": 2.9802322387695312e-06
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": 1074.8,
55
- "ms_median": 137.33,
56
- "max_abs_lab_vs_ort_cpu": 1.3709068298339844e-06,
57
- "max_abs_img_vs_ort_cpu": 7.659196853637695e-06
 
58
  },
59
  "t4": {
60
  "isolated": false,
61
- "load_ms": 1047,
62
- "ms_median": 38.47,
63
- "max_abs_lab_vs_ort_cpu": 1.3709068298339844e-06,
64
- "max_abs_img_vs_ort_cpu": 7.659196853637695e-06
 
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.039349029815445344,
35
- "native_dE": 0.08639045081450604,
36
- "img_acc": 0.7966292134831461,
37
- "size_mb": 63.7,
38
- "cpu_ms_median": 11.42
39
  },
40
  "int8": {
41
- "dE_vs_fp32": 0.00015194275738394936,
42
- "dE_max": 0.02521813102066517,
43
- "img_agree": 0.9998743718592965,
44
- "haiku_dE": 0.03935358844697476,
45
- "native_dE": 0.0863548206735868,
46
- "img_acc": 0.7968164794007491,
47
- "pack_parity_max_abs": 2.5033950805664062e-06,
48
- "size_mb": 20.63,
49
- "cpu_ms_median": 9.19
50
  },
51
  "int4": {
52
- "dE_vs_fp32": 0.00036296586506068707,
53
- "dE_max": 0.0709674283862114,
54
- "img_agree": 0.9976130653266332,
55
- "haiku_dE": 0.039340872559696434,
56
- "native_dE": 0.08632776886341162,
57
- "img_acc": 0.797003745318352,
58
- "pack_parity_max_abs": 1.5497207641601562e-06,
59
- "size_mb": 13.31,
60
- "cpu_ms_median": 9.76
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": "/home/user/.local/share/rtx-temporary-jobs/tj-nadir-0fe76575c0f3/payload/work/data/ds_final.pkl",
4
- "out": "/job/export/run_m",
5
  "d": 384,
6
  "layers": 8,
7
  "heads": 6,
8
- "epochs": 30,
9
  "bs": 48,
10
- "lr": 0.0004,
11
  "wd": 0.05,
12
  "drop": 0.1,
13
  "native_weight": 0.5,
14
  "seed": 0,
15
- "workers": 4
 
 
 
 
16
  },
17
- "params": 15895995,
18
  "log": [
19
  {
20
  "epoch": 0,
21
- "step": 468,
22
- "secs": 81,
23
- "loss": 0.3405,
24
- "dE": 0.12132,
25
- "con": 0.50822,
26
- "img": 1.29224,
27
- "s0_dE": 0.06306,
28
- "s0_dL": 0.06024,
29
- "s0_con": 0.04883,
30
- "img_acc": 0.73416,
31
- "s1_dE": 0.16366,
32
- "s1_dL": 0.15909,
33
- "s1_con": 0.11938
 
 
 
34
  },
35
  {
36
  "epoch": 1,
37
- "step": 936,
38
- "secs": 148,
39
- "loss": 0.18873,
40
- "dE": 0.06161,
41
- "con": 0.03771,
42
- "img": 0.99138,
43
- "s0_dE": 0.05432,
44
- "s0_dL": 0.05155,
45
- "s0_con": 0.03668,
46
- "img_acc": 0.78603,
47
- "s1_dE": 0.1523,
48
- "s1_dL": 0.14838,
49
- "s1_con": 0.08049
 
 
 
50
  },
51
  {
52
  "epoch": 2,
53
- "step": 1404,
54
- "secs": 215,
55
- "loss": 0.17375,
56
- "dE": 0.05668,
57
- "con": 0.03498,
58
- "img": 0.90371,
59
- "s0_dE": 0.04977,
60
- "s0_dL": 0.04725,
61
- "s0_con": 0.02243,
62
- "img_acc": 0.78005,
63
- "s1_dE": 0.14476,
64
- "s1_dL": 0.14153,
65
- "s1_con": 0.26806
 
 
 
66
  },
67
  {
68
  "epoch": 3,
69
- "step": 1872,
70
- "secs": 281,
71
- "loss": 0.16779,
72
- "dE": 0.05488,
73
- "con": 0.04139,
74
- "img": 0.86775,
75
- "s0_dE": 0.0506,
76
- "s0_dL": 0.04808,
77
- "s0_con": 0.0331,
78
- "img_acc": 0.79611,
79
- "s1_dE": 0.13697,
80
- "s1_dL": 0.13372,
81
- "s1_con": 0.1237
 
 
 
82
  },
83
  {
84
  "epoch": 4,
85
- "step": 2340,
86
- "secs": 349,
87
- "loss": 0.16278,
88
- "dE": 0.05274,
89
- "con": 0.0414,
90
- "img": 0.85412,
91
- "s0_dE": 0.04849,
92
- "s0_dL": 0.04604,
93
- "s0_con": 0.02439,
94
- "img_acc": 0.78982,
95
- "s1_dE": 0.13195,
96
- "s1_dL": 0.12841,
97
- "s1_con": 0.16648
 
 
 
98
  },
99
  {
100
  "epoch": 5,
101
- "step": 2808,
102
- "secs": 416,
103
- "loss": 0.15903,
104
- "dE": 0.05166,
105
- "con": 0.03833,
106
- "img": 0.84086,
107
- "s0_dE": 0.0472,
108
- "s0_dL": 0.04477,
109
- "s0_con": 0.03465,
110
- "img_acc": 0.80467,
111
- "s1_dE": 0.12882,
112
- "s1_dL": 0.12538,
113
- "s1_con": 0.12963
 
 
 
114
  },
115
  {
116
  "epoch": 6,
117
- "step": 3276,
118
- "secs": 484,
119
- "loss": 0.15729,
120
- "dE": 0.05076,
121
- "con": 0.04035,
122
- "img": 0.83185,
123
- "s0_dE": 0.0457,
124
- "s0_dL": 0.04338,
125
- "s0_con": 0.02999,
126
- "img_acc": 0.80345,
127
- "s1_dE": 0.15936,
128
- "s1_dL": 0.15619,
129
- "s1_con": 0.51491
 
 
 
130
  },
131
  {
132
  "epoch": 7,
133
- "step": 3744,
134
- "secs": 552,
135
- "loss": 0.15392,
136
- "dE": 0.04978,
137
- "con": 0.0403,
138
- "img": 0.81841,
139
- "s0_dE": 0.04612,
140
- "s0_dL": 0.04395,
141
- "s0_con": 0.01962,
142
- "img_acc": 0.79899,
143
- "s1_dE": 0.14854,
144
- "s1_dL": 0.14578,
145
- "s1_con": 0.42056
 
 
 
146
  },
147
  {
148
  "epoch": 8,
149
- "step": 4212,
150
- "secs": 619,
151
- "loss": 0.15244,
152
- "dE": 0.04894,
153
- "con": 0.04062,
154
- "img": 0.81195,
155
- "s0_dE": 0.04632,
156
- "s0_dL": 0.04425,
157
- "s0_con": 0.02413,
158
- "img_acc": 0.79917,
159
- "s1_dE": 0.12446,
160
- "s1_dL": 0.12131,
161
- "s1_con": 0.23072
 
 
 
162
  },
163
  {
164
  "epoch": 9,
165
- "step": 4680,
166
- "secs": 687,
167
- "loss": 0.14931,
168
- "dE": 0.0481,
169
- "con": 0.03785,
170
- "img": 0.79837,
171
- "s0_dE": 0.04452,
172
- "s0_dL": 0.04238,
173
- "s0_con": 0.02434,
174
- "img_acc": 0.80094,
175
- "s1_dE": 0.12096,
176
- "s1_dL": 0.11779,
177
- "s1_con": 0.21844
 
 
 
178
  },
179
  {
180
  "epoch": 10,
181
- "step": 5148,
182
- "secs": 755,
183
- "loss": 0.1484,
184
- "dE": 0.04786,
185
- "con": 0.03854,
186
- "img": 0.79393,
187
- "s0_dE": 0.04549,
188
- "s0_dL": 0.04344,
189
- "s0_con": 0.02666,
190
- "img_acc": 0.79123,
191
- "s1_dE": 0.11822,
192
- "s1_dL": 0.11502,
193
- "s1_con": 0.25328
 
 
 
194
  },
195
  {
196
  "epoch": 11,
197
- "step": 5616,
198
- "secs": 822,
199
- "loss": 0.14645,
200
- "dE": 0.04703,
201
- "con": 0.03983,
202
- "img": 0.78304,
203
- "s0_dE": 0.04345,
204
- "s0_dL": 0.04139,
205
- "s0_con": 0.01948,
206
- "img_acc": 0.81053,
207
- "s1_dE": 0.11486,
208
- "s1_dL": 0.11153,
209
- "s1_con": 0.20122
 
 
 
210
  },
211
  {
212
  "epoch": 12,
213
- "step": 6084,
214
- "secs": 891,
215
- "loss": 0.13803,
216
- "dE": 0.04635,
217
- "con": 0.03723,
218
- "img": 0.76961,
219
- "s0_dE": 0.04328,
220
- "s0_dL": 0.04125,
221
- "s0_con": 0.01831,
222
- "img_acc": 0.8051,
223
- "s1_dE": 0.11435,
224
- "s1_dL": 0.11091,
225
- "s1_con": 0.16656
 
 
 
226
  },
227
  {
228
  "epoch": 13,
229
- "step": 6552,
230
- "secs": 958,
231
- "loss": 0.13627,
232
- "dE": 0.04589,
233
- "con": 0.03889,
234
- "img": 0.76454,
235
- "s0_dE": 0.04247,
236
- "s0_dL": 0.04047,
237
- "s0_con": 0.01875,
238
- "img_acc": 0.80161,
239
- "s1_dE": 0.11794,
240
- "s1_dL": 0.11469,
241
- "s1_con": 0.23754
 
 
 
242
  },
243
  {
244
  "epoch": 14,
245
- "step": 7020,
246
- "secs": 1025,
247
- "loss": 0.13324,
248
- "dE": 0.045,
249
- "con": 0.0383,
250
- "img": 0.75126,
251
- "s0_dE": 0.04194,
252
- "s0_dL": 0.03998,
253
- "s0_con": 0.02424,
254
- "img_acc": 0.80332,
255
- "s1_dE": 0.11805,
256
- "s1_dL": 0.11474,
257
- "s1_con": 0.22873
 
 
 
258
  },
259
  {
260
  "epoch": 15,
261
- "step": 7488,
262
- "secs": 1093,
263
- "loss": 0.13267,
264
- "dE": 0.04502,
265
- "con": 0.0394,
266
- "img": 0.74724,
267
- "s0_dE": 0.04183,
268
- "s0_dL": 0.03983,
269
- "s0_con": 0.01954,
270
- "img_acc": 0.81353,
271
- "s1_dE": 0.11306,
272
- "s1_dL": 0.10954,
273
- "s1_con": 0.20409
 
 
 
274
  },
275
  {
276
  "epoch": 16,
277
- "step": 7956,
278
- "secs": 1161,
279
- "loss": 0.1305,
280
- "dE": 0.04465,
281
- "con": 0.03933,
282
- "img": 0.72731,
283
- "s0_dE": 0.04195,
284
- "s0_dL": 0.04006,
285
- "s0_con": 0.02284,
286
- "img_acc": 0.80485,
287
- "s1_dE": 0.11437,
288
- "s1_dL": 0.11122,
289
- "s1_con": 0.27215
 
 
 
290
  },
291
  {
292
  "epoch": 17,
293
- "step": 8424,
294
- "secs": 1229,
295
- "loss": 0.12864,
296
- "dE": 0.04415,
297
- "con": 0.03796,
298
- "img": 0.71419,
299
- "s0_dE": 0.04177,
300
- "s0_dL": 0.0399,
301
- "s0_con": 0.0207,
302
- "img_acc": 0.79611,
303
- "s1_dE": 0.10924,
304
- "s1_dL": 0.10592,
305
- "s1_con": 0.22063
306
- },
307
- {
308
- "epoch": 18,
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.07564117624840601
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
- decision = nadirImageHook(imageDetails.src, {
 
 
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
- (details.isDark && details.isTransparent) ||
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}, opts || {});
 
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, 600), 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,
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: 5, url: location.href, title: document.title.slice(0, 200), vw, vh, docH, docW,
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
- const IW = img.naturalWidth || img.width, IH = img.naturalHeight || img.height;
718
  o.nw = o.nw || IW; o.nh = o.nh || IH;
719
- statsFromSource(o, img, IW, IH, opts);
 
 
 
 
 
 
 
 
 
 
 
 
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 = 39, K = 11;
6
- const TREAT = ['keep', 'dim', 'invert', 'light', 'plate'];
 
 
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.slice(0, FI).map((v) => (Number.isFinite(v) ? v : 0)), j * FI);
40
- if (im.thumb && im.thumb.length === 4096) {
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), img_logits Float32Array(M*5)
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 + 0.1]);
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 = 3;
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
- const c = deficit(rgb) / W + 0.05 * Math.abs(Lx - o[0]);
 
 
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
- const imgs = page.images.slice(0, MAX_IMG);
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
- if (!n || light / n < 0.5) { info.why = 'ring'; return false; } // no light background to remove (tight crops touch the edge)
177
- const bg = oklab(sr / light, sg / light, sb / light);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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", "dim", "invert", "light", "plate"]
11
- MAX_TOK, MAX_IMG = 256, 32
 
 
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), 39)
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("--crawl", required=True)
209
- ap.add_argument("--haiku", nargs="*", default=[])
210
  ap.add_argument("--out", required=True)
211
  ap.add_argument("--val-frac", type=float, default=0.05)
 
212
  args = ap.parse_args()
213
- crawl = Path(args.crawl)
214
- index = {}
215
- for line in (l for ip in sorted(crawl.glob("index*.jsonl")) for l in open(ip)):
216
- try:
217
- r = json.loads(line)
218
- index[r["id"]] = r
219
- except Exception:
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
- L = json.load(gzip.open(crawl / "pages" / pid / "light.json.gz"))
 
237
  except Exception:
238
- continue
239
- arrs = page_arrays(L)
240
- r = haiku_targets(L, h, arrs, qc_h)
241
- if r is None:
242
- continue
243
- arrs["tgt"], arrs["imgt"] = r
244
- if repair_contrast(arrs, arrs["tgt"]):
245
- qc_h["repaired"] += 1
246
- arrs["src"] = 0
247
- arrs["id"] = pid
248
- arrs["host"] = index.get(pid, {}).get("url", L["url"]).split("/")[2]
249
- samples.append(arrs)
250
- # native dark-mode pairs (source 1)
251
- for pid, r in index.items():
252
- if r.get("kind") not in ("native_media", "native_class"):
253
- continue
254
- try:
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 (fp32/fp16) and build calibrated 8-bit and 4-bit variants; measure each against fp32."""
 
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, evaluate
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", "img_logits"]
 
 
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, 39); b["thumbs"] = torch.zeros(1, 1, 4, 32, 32)
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, plus image-decision agreement."""
57
- de, agree, n_img, worst = [], 0, 0, 0.0
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
- m = len(s["IF"])
66
- if m:
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, samples, reps=3):
72
  ts = []
73
- for s in samples[:20]:
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("--calib", type=int, default=256)
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])[0].numpy() for s in par_samples]
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"}, "img_logits": {1: "M"}},
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
- # parity torch vs onnx (reference computed before export)
113
- parity = max(float(np.abs(r - ref.run(None, feeds_for(s))[0]).max()) for r, s in zip(ref_out, par_samples))
114
- report = {"params": sum(p.numel() for p in model.parameters()), "parity_max_abs": parity, "variants": {}}
115
- print("parity", parity, flush=True)
116
-
117
- # fp16 (weights + compute in fp16, IO kept fp32)
118
- # fp16: export a half-precision copy with fp32 inputs/outputs (casts inside the graph)
119
- have16 = True
120
- try:
121
- class Half(nn.Module):
122
- def __init__(self, m):
123
- super().__init__(); self.m = m.half()
124
- def forward(self, tok_f, tok_role, tok_lab, img_f, img_thumb, page_f, src, rel, mask):
125
- o = self.m(tok_f.half(), tok_role, tok_lab.half(), img_f.half(), img_thumb.half(), page_f.half(), src, rel.half(), mask)
126
- return tuple(x.float() for x in o)
127
- import copy
128
- torch.onnx.export(Half(copy.deepcopy(model)), tuple(torch.from_numpy(ex[k]) for k in NAMES_IN), os.path.join(args.out, "nadir-fp16.onnx"),
129
- input_names=NAMES_IN, output_names=NAMES_OUT,
130
- dynamic_axes={"tok_f": {1: "N"}, "tok_role": {1: "N"}, "tok_lab": {1: "N"}, "img_f": {1: "M"}, "img_thumb": {1: "M"},
131
- "rel": {1: "T", 2: "T"}, "mask": {1: "T"}, "lab": {1: "N"}, "alpha_logit": {1: "N"}, "img_logits": {1: "M"}},
132
- opset_version=17, do_constant_folding=True, dynamo=False)
133
- ort.InferenceSession(os.path.join(args.out, "nadir-fp16.onnx"), providers=["CPUExecutionProvider"])
134
- except Exception as e:
135
- print("fp16 failed:", str(e)[:300], flush=True); have16 = False
136
-
137
- if args.only_float:
138
- variants = {"fp32": fp32}
139
- if have16: variants["fp16"] = os.path.join(args.out, "nadir-fp16.onnx")
140
- for name, path in variants.items():
141
- sess = ort.InferenceSession(path, so, providers=["CPUExecutionProvider"])
142
- r = fidelity(sess, ref, val[:300]); r["size_mb"] = round(os.path.getsize(path) / 1e6, 2); r["cpu_ms_median"] = round(bench(sess, val[:300]), 2)
143
- report["variants"][name] = r
144
- print(name, r, flush=True)
145
- json.dump(report, open(os.path.join(args.out, "export_report.json"), "w"), indent=1)
146
- return
147
- from onnxruntime.quantization import quantize_dynamic, quantize_static, QuantType, QuantFormat, CalibrationDataReader, CalibrationMethod
148
- from onnxruntime.quantization.shape_inference import quant_pre_process
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
- Runs on Vega's host (the API key vault proxy is only reachable there). 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
 
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). Runs on Vega's host."""
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
- content = [{"type": "text", "text": "Original light page:"}, img_block(crawl / "pages" / pid / "shot.jpg"),
 
 
 
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 treatment class per image.
 
 
 
 
 
 
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, 39, 8
16
- N_ROLES, N_TREAT = 7, 5
 
 
 
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.img_head = MLP(d, d // 2, N_TREAT)
 
 
 
 
 
 
 
 
 
 
 
 
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.img_in(torch.cat([img_f, self.cnn(img_thumb)], -1)) + self.typ.weight[2]
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
- img_logits = self.img_head(x[:, 1 + N:]) if M > 0 else x.new_zeros(B, 0, N_TREAT)
121
- return lab, alpha_logit, img_logits
 
 
 
 
 
 
 
 
 
 
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", "dim", "invert", "light", "plate"]
 
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
- u = im.get("underTok", -1)
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, il = model(b["tok_f"], b["role"], b["lab"], b["img_f"], b["thumbs"], b["page"], torch.zeros_like(b["src"]), b["rel"], b["mask"])
 
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[0].float().cpu().numpy().tolist(), s["rel"])
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) and image-treatment accuracy."""
 
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, _, imgl = sess.run(None, f)
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
- mi = len(s["IF"])
262
- if mi:
263
- t = np.asarray(s["imgt"][:mi])
264
- v = t >= 0
265
- ok += int((imgl[0, :mi].argmax(-1)[v] == t[v]).sum()); n += int(v.sum())
266
- return {"haiku_dE": agg[0][0] / max(1, agg[0][1]), "native_dE": agg[1][0] / max(1, agg[1][1]), "img_acc": ok / max(1, n)}
 
 
 
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.", "img_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
- r = quality(ref, val); r["size_mb"] = round(os.path.getsize(args.fp32) / 1e6, 2); r["cpu_ms_median"] = round(bench(ref, val), 2)
 
 
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, val), 2)
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
- 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
 
 
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
- if (not t or t == "keep") and s and s["transp"] > 0.15 and s["meanL"] < 0.35 and im["w"] * im["h"] < 200000 and (surf_L is None or surf_L < 0.4):
 
 
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, with a small pull towards the model's own lightness."""
 
 
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) + 0.1)) # log-area weights: small buttons still count
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
- if t["role"] == 1:
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 + 0.05 * abs(Lx - o[0])
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, 39), np.float32); thumbs = np.zeros((B, M, 4, 32, 32), np.float32); imgt = -np.ones((B, M), np.int64)
 
 
 
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 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,
124
- img_f=img_f, thumbs=thumbs, imgt=imgt, page=page, src=src, rel=rel, mask=mask, tb_pair=tb_pair)
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, img_logits = model(b["tok_f"], b["role"], b["lab"], b["img_f"], b["thumbs"], b["page"], b["src"], b["rel"], b["mask"])
157
- lab = lab.float(); alpha_logit = alpha_logit.float(); img_logits = img_logits.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
- # images
182
- im = b["imgt"] >= 0
183
- if im.any():
184
- li = F.cross_entropy(img_logits[im], b["imgt"][im], weight=img_w, label_smoothing=0.05)
185
- acc = (img_logits[im].argmax(-1) == b["imgt"][im]).float().mean()
 
 
 
 
 
 
 
 
 
 
186
  else:
187
- li = lab.new_zeros(()); acc = lab.new_zeros(())
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, li, dict(dE=de.item(), dL=dl.item(), img_acc=acc.item(), n_img=int(im.sum()))
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, li, st = losses(model, b, img_w)
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["n_img"]:
207
- agg["img_acc"] += st["img_acc"] * st["n_img"]; cnt["img"] += st["n_img"]
 
 
 
 
208
  out = {}
209
  for k, v in agg.items():
210
- if k == "img_acc":
211
- out[k] = v / max(1, cnt["img"])
 
 
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
- # image class weights (inverse sqrt frequency)
244
- cnt = np.ones(5)
 
 
 
 
 
 
 
 
 
 
 
 
 
245
  for s in train:
246
- for v in s["imgt"]:
247
- if v >= 0:
248
- cnt[v] += 1
249
- img_w = torch.tensor((cnt.sum() / cnt) ** 0.5, dtype=torch.float32, device=device)
250
- img_w = img_w / img_w.mean()
251
- print("image label counts", cnt.astype(int).tolist(), flush=True)
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, li, st = losses(model, b, img_w)
270
- loss = lc + 0.5 * la + 0.02 * lcon + 0.05 * li
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["img"] += li.item(); nb += 1
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.02 * (1 - ev.get("img_acc", 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