optiq-image 0.2.0-beta.0 → 0.3.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,6 +1,13 @@
1
1
  # Changelog
2
2
 
3
- ## 0.2.0-beta.0 (unreleased)
3
+ ## 0.3.0-beta.0 (2026-10-28)
4
+
5
+ - `fast` mode starts each codec's quality search from a prediction (ridge regression on cheap image features,
6
+ fitted on a 317-image corpus in `bench/predictor/`). Benchmark: −8.0% bytes and −18% time vs the fixed start;
7
+ quality still verified for every output. Not enabled for `balanced`, where it traded size for speed.
8
+ - Demo: settings apply to all loaded images immediately; compare and candidates sections expanded by default.
9
+
10
+ ## 0.2.0-beta.0 (2026-10-27)
4
11
 
5
12
  - `mode: "fast"`: SSIM ≥ 0.975 like `balanced`, no AVIF, no oxipng, ≤ 5 encodes. Benchmark: −54.9% total
6
13
  (balanced −61.2%), median 405 ms in Chromium (balanced 1.3 s), median peak RSS 184 MB (balanced 304 MB).
@@ -9,7 +16,7 @@
9
16
  - `optiq-image/react`: `useOptiq(file, options)` hook.
10
17
  - `optimizeFiles()` batch API for Node (worker_threads pool).
11
18
 
12
- ## 0.1.0-beta.0 (unreleased)
19
+ ## 0.1.0-beta.0 (2026-10-27)
13
20
 
14
21
  Audit fixes (see docs/audit.md):
15
22
 
package/README.md CHANGED
@@ -197,7 +197,7 @@ input. Full tables, per-image rows and the optimisation log: [`bench/results/RES
197
197
  | Tool | Total output | Reduction | Meets SSIM ≥ 0.975 | Median time | Median peak RSS |
198
198
  |---|---|---|---|---|---|
199
199
  | Optiq `balanced` | 13.25 → 5.15 MB | 61.1% | 24/24 | 1.8 s | 301 MB |
200
- | Optiq `fast` (no AVIF, ≤ 5 encodes) | 13.25 → 5.97 MB | 54.9% | 24/24 | 521 ms | 184 MB |
200
+ | Optiq `fast` (no AVIF, ≤ 5 encodes) | 13.25 → 5.49 MB | 58.5% | 24/24 | 434 ms | 173 MB |
201
201
  | Optiq `balanced`, `preserveMetadata: false` | 13.25 → 4.73 MB | 64.3% | 24/24 | 1.5 s | 297 MB |
202
202
  | Optiq `visually-lossless` | 13.25 → 6.38 MB | 51.8% | 24/24 (≥ 0.99: 24/24) | 2.3 s | 325 MB |
203
203
  | libvips WebP q80 | 13.25 → 6.50 MB | 51.0% | 17/24 | 59 ms | 105 MB |
@@ -209,7 +209,7 @@ input. Full tables, per-image rows and the optimisation log: [`bench/results/RES
209
209
  | Tool | Total output | Reduction | Meets SSIM ≥ 0.975 | Median time |
210
210
  |---|---|---|---|---|
211
211
  | Optiq `balanced` | 13.25 → 5.15 MB | 61.1% | 24/24 | 1.4 s |
212
- | Optiq `fast` | 13.25 → 5.97 MB | 54.9% | 24/24 | 405 ms |
212
+ | Optiq `fast` | 13.25 → 5.49 MB | 58.5% | 24/24 | 349 ms |
213
213
  | browser-image-compression `initialQuality: 0.8` | 13.25 → 10.21 MB | 22.9% | 19/24 | 78 ms |
214
214
  | browser-image-compression `fileType: webp, 0.8` | 13.25 → 6.52 MB | 50.8% | 18/24 | 37 ms |
215
215
  | browser-image-compression `maxSizeMB: 1` | 13.25 → 7.14 MB | 46.1% | 21/24 (3 large photos were downscaled, not scored) | 128 ms |
@@ -14,6 +14,12 @@ export interface ImageFeatures {
14
14
  smoothStepShare: number;
15
15
  /** Pixels with alpha < 255. */
16
16
  transparentShare: number;
17
+ /** Texture features used by the quality predictor (see src/predictor.ts). */
18
+ lumaStd: number;
19
+ chromaRms: number;
20
+ meanGrad: number;
21
+ edgeShare16: number;
22
+ hfEnergy: number;
17
23
  }
18
24
  /**
19
25
  * Deterministic feature extraction on a grid of up to 24x24 patches of 16x16 contiguous pixels.
package/dist/classify.js CHANGED
@@ -13,6 +13,9 @@ export function extractFeatures(img) {
13
13
  const ny = Math.min(MAX_PATCHES_PER_AXIS, Math.max(1, Math.floor(H / ph)));
14
14
  const counts = new Map();
15
15
  let sampled = 0, pairs = 0, flat = 0, hard = 0, smooth = 0, transparent = 0;
16
+ // Texture statistics (float BT.601 luma/chroma; must match bench/predictor/features.mjs used for fitting).
17
+ let sumY = 0, sumY2 = 0, sumC2 = 0, grad = 0, gradN = 0, edges16 = 0, lap = 0, lapN = 0;
18
+ const Yf = (o) => 0.299 * data[o] + 0.587 * data[o + 1] + 0.114 * data[o + 2];
16
19
  const luma = (o) => (data[o] * 77 + data[o + 1] * 150 + data[o + 2] * 29) >> 8;
17
20
  const word = (o) => ((data[o] << 24) | (data[o + 1] << 16) | (data[o + 2] << 8) | data[o + 3]) >>> 0;
18
21
  const pair = (a, b) => {
@@ -44,6 +47,24 @@ export function extractFeatures(img) {
44
47
  pair(o, o + 4);
45
48
  if (y + 1 < y0 + ph)
46
49
  pair(o, o + W * 4);
50
+ const yy = Yf(o);
51
+ const cb = -0.168736 * data[o] - 0.331264 * data[o + 1] + 0.5 * data[o + 2];
52
+ const cr = 0.5 * data[o] - 0.418688 * data[o + 1] - 0.081312 * data[o + 2];
53
+ sumY += yy;
54
+ sumY2 += yy * yy;
55
+ sumC2 += cb * cb + cr * cr;
56
+ if (x + 1 < x0 + pw) {
57
+ const d = Math.abs(yy - Yf(o + 4));
58
+ grad += d;
59
+ gradN++;
60
+ if (d > 16)
61
+ edges16++;
62
+ }
63
+ if (x > x0 && x + 1 < x0 + pw && y > y0 && y + 1 < y0 + ph) {
64
+ const l = 4 * yy - Yf(o - 4) - Yf(o + 4) - Yf(o - W * 4) - Yf(o + W * 4);
65
+ lap += l * l;
66
+ lapN++;
67
+ }
47
68
  }
48
69
  }
49
70
  }
@@ -60,6 +81,11 @@ export function extractFeatures(img) {
60
81
  hardEdgeShare: pairs ? hard / pairs : 0,
61
82
  smoothStepShare: pairs ? smooth / pairs : 0,
62
83
  transparentShare: transparent / sampled,
84
+ lumaStd: Math.sqrt(Math.max(0, sumY2 / sampled - (sumY / sampled) ** 2)) / 255,
85
+ chromaRms: Math.sqrt(sumC2 / (2 * sampled)) / 128,
86
+ meanGrad: gradN ? grad / gradN / 255 : 0,
87
+ edgeShare16: gradN ? edges16 / gradN : 0,
88
+ hfEnergy: Math.sqrt(lap / Math.max(1, lapN)) / 255,
63
89
  };
64
90
  }
65
91
  /**
package/dist/engine.js CHANGED
@@ -9,6 +9,7 @@ import { OptiqError, throwIfAborted } from "./errors.js";
9
9
  import { OUTPUT_FORMATS, resolveOptions } from "./options.js";
10
10
  import { applyOrientation, hasTransparentPixels, pixelsEqual, targetSize, tileMosaic } from "./pixels.js";
11
11
  import { effectiveMinQuality, losslessPlans, planCandidates } from "./plan.js";
12
+ import { predictStart } from "./predictor.js";
12
13
  import { displayDimensions, probe } from "./probe.js";
13
14
  import { ssim } from "./quality.js";
14
15
  const MIME = {
@@ -307,7 +308,7 @@ export async function optimize(input, options, ctx) {
307
308
  check();
308
309
  }
309
310
  emit("analyzing", 0.12);
310
- const { category } = await timed("classify", () => classify(reference));
311
+ const { category, features } = await timed("classify", () => classify(reference));
311
312
  minQuality = effectiveMinQuality(category, opts);
312
313
  const planInput = {
313
314
  category,
@@ -423,6 +424,13 @@ export async function optimize(input, options, ctx) {
423
424
  let hi = hi0; // search upper bound
424
425
  let passQ;
425
426
  let q = plan.start;
427
+ // The predictor was fitted for the balanced thresholds and settings (shared by `fast`).
428
+ const predicted = opts.searchStart !== "fixed" && (opts.mode === "balanced" || opts.mode === "fast") && plan.format !== "png"
429
+ ? predictStart(plan.format, category, features, opts.searchStart)
430
+ : undefined;
431
+ if (predicted)
432
+ q = Math.max(lo0, Math.min(hi0, predicted.start));
433
+ let steppedDown = false;
426
434
  while (share > 0 && lo <= hi) {
427
435
  const c = await tryEvaluate(plan, { ...plan.settings, quality: q }, useProxy);
428
436
  if (!c)
@@ -443,8 +451,19 @@ export async function optimize(input, options, ctx) {
443
451
  break;
444
452
  if (passQ === undefined && q >= hi0)
445
453
  break;
446
- // Next probe: bisect the open interval; if nothing passed yet, move up towards the top of the range.
447
- q = passQ === undefined ? Math.min(hi, Math.ceil((lo + hi0) / 2)) : Math.floor((lo + hi) / 2);
454
+ if (predicted && passQ !== undefined && lo === lo0 && !steppedDown) {
455
+ // The predicted start passed and nothing has failed yet: probe one typical-error step lower before bisecting.
456
+ // (Upward moves keep the original bisection towards the top of the range: growing upward jumps overshot
457
+ // and exhausted the budget in the first comparison run — kodim19 +81% bytes.)
458
+ {
459
+ steppedDown = true;
460
+ q = Math.max(lo0, passQ - predicted.step);
461
+ }
462
+ }
463
+ else {
464
+ // Next probe: bisect the open interval; if nothing passed yet, move up towards the top of the range.
465
+ q = passQ === undefined ? Math.min(hi, Math.ceil((lo + hi0) / 2)) : Math.floor((lo + hi) / 2);
466
+ }
448
467
  if (passQ === undefined && share === 0)
449
468
  break;
450
469
  }
package/dist/options.d.ts CHANGED
@@ -19,6 +19,7 @@ export interface ResolvedOptions {
19
19
  /** Quality search tolerance (codec quality units). */
20
20
  qualityTolerance: number;
21
21
  avifSpeed: number;
22
+ searchStart: "fixed" | "category" | "model";
22
23
  }
23
24
  export declare const OUTPUT_FORMATS: readonly OutputFormat[];
24
25
  export declare const MODE_DEFAULTS: Record<CompressionMode, {
package/dist/options.js CHANGED
@@ -67,5 +67,8 @@ export function resolveOptions(o = {}) {
67
67
  onUnmetConstraints: o.onUnmetConstraints ?? "best-effort",
68
68
  qualityTolerance: d.qualityTolerance,
69
69
  avifSpeed: d.avifSpeed,
70
+ // Measured (bench/predictor/compare.mjs): the feature-based start is a clean win only in `fast` (fewer encodes,
71
+ // smaller files on both the held-out split and the independent corpus); in `balanced` it traded size for speed.
72
+ searchStart: o.searchStart ?? (mode === "fast" ? "model" : "fixed"),
70
73
  };
71
74
  }
@@ -0,0 +1,7 @@
1
+ export declare const PREDICTOR_MODEL: Record<string, {
2
+ c: number;
3
+ w: number[];
4
+ sigma: number;
5
+ median: number;
6
+ n: number;
7
+ }>;
@@ -0,0 +1,112 @@
1
+ // Generated by bench/predictor/fit.mjs from bench/predictor/labels.jsonl (train split). Do not edit by hand.
2
+ // Ridge regression: quality ≈ c + Σ w_i · feature_i, features in this order: logHf, logGrad, lumaStd, chromaRms, flat, smooth, hardEdge, edge16, logColors, transparent.
3
+ export const PREDICTOR_MODEL = {
4
+ "avif:photo": {
5
+ "c": 61.909,
6
+ "w": [
7
+ 4.8476,
8
+ 4.4747,
9
+ 6.8754,
10
+ -32.094,
11
+ 2.6394,
12
+ -0.4836,
13
+ -131.98,
14
+ -50.1434,
15
+ 3.8811,
16
+ 0
17
+ ],
18
+ "sigma": 6.13,
19
+ "median": 55,
20
+ "n": 139
21
+ },
22
+ "avif:graphic": {
23
+ "c": 72.4676,
24
+ "w": [
25
+ -0.2699,
26
+ 0.4591,
27
+ -15.8289,
28
+ -9.0301,
29
+ -42.8535,
30
+ -16.7837,
31
+ -129.1587,
32
+ 16.6855,
33
+ 1.0012,
34
+ -1.8608
35
+ ],
36
+ "sigma": 7.5,
37
+ "median": 29,
38
+ "n": 87
39
+ },
40
+ "webp:photo": {
41
+ "c": 99.1048,
42
+ "w": [
43
+ 6.3456,
44
+ 4.9984,
45
+ 13.8173,
46
+ -28.2776,
47
+ -20.9312,
48
+ -7.2995,
49
+ -252.7529,
50
+ -59.7518,
51
+ 3.1287,
52
+ 0
53
+ ],
54
+ "sigma": 8.38,
55
+ "median": 76,
56
+ "n": 138
57
+ },
58
+ "webp:graphic": {
59
+ "c": 105.9106,
60
+ "w": [
61
+ 0.2885,
62
+ 0.5672,
63
+ -26.2165,
64
+ 15.9697,
65
+ -50.1994,
66
+ -10.1447,
67
+ 9.9325,
68
+ -60.0364,
69
+ -1.6271,
70
+ 0.1538
71
+ ],
72
+ "sigma": 7.47,
73
+ "median": 40,
74
+ "n": 87
75
+ },
76
+ "jpeg:photo": {
77
+ "c": 88.448,
78
+ "w": [
79
+ 7.4945,
80
+ 2.0242,
81
+ -2.5954,
82
+ -23.0289,
83
+ -6.7996,
84
+ -3.7135,
85
+ -62.2263,
86
+ -19.1349,
87
+ 1.589,
88
+ 0
89
+ ],
90
+ "sigma": 5.48,
91
+ "median": 69,
92
+ "n": 139
93
+ },
94
+ "jpeg:graphic": {
95
+ "c": 118.758,
96
+ "w": [
97
+ -0.1487,
98
+ 2.945,
99
+ -26.4641,
100
+ -11.9989,
101
+ -45.233,
102
+ -7.9,
103
+ 49.1273,
104
+ 27.3451,
105
+ -2.4267,
106
+ 0
107
+ ],
108
+ "sigma": 9.77,
109
+ "median": 52,
110
+ "n": 68
111
+ }
112
+ };
@@ -0,0 +1,16 @@
1
+ import type { ImageFeatures } from "./classify.js";
2
+ import type { ImageCategory, OutputFormat } from "./types.js";
3
+ /**
4
+ * Where the per-codec quality search starts.
5
+ * - `fixed`: one start per mode (original behaviour);
6
+ * - `category`: median quality needed per codec, separately for photos and graphics (fitted on bench/predictor);
7
+ * - `model`: ridge regression on cheap image features (same fit).
8
+ * The search always verifies its result, so a bad start costs encodes, never quality.
9
+ */
10
+ export type SearchStart = "fixed" | "category" | "model";
11
+ export interface PredictedStart {
12
+ start: number;
13
+ /** Step for the first probe after the start (≈ typical prediction error). */
14
+ step: number;
15
+ }
16
+ export declare function predictStart(format: Exclude<OutputFormat, "png">, category: ImageCategory, features: ImageFeatures, how: Exclude<SearchStart, "fixed">): PredictedStart | undefined;
@@ -0,0 +1,24 @@
1
+ import { PREDICTOR_MODEL } from "./predictor-model.js";
2
+ const GRAPHIC = new Set(["screenshot", "text-graphic", "illustration", "transparent-illustration"]);
3
+ const FEATURE_FNS = [
4
+ (f) => Math.log(f.hfEnergy + 1e-4),
5
+ (f) => Math.log(f.meanGrad + 1e-4),
6
+ (f) => f.lumaStd,
7
+ (f) => f.chromaRms,
8
+ (f) => f.flatShare,
9
+ (f) => f.smoothStepShare,
10
+ (f) => f.hardEdgeShare,
11
+ (f) => f.edgeShare16,
12
+ (f) => Math.log(f.uniqueColors),
13
+ (f) => f.transparentShare,
14
+ ];
15
+ export function predictStart(format, category, features, how) {
16
+ const m = PREDICTOR_MODEL[`${format}:${GRAPHIC.has(category) ? "graphic" : "photo"}`];
17
+ if (!m)
18
+ return undefined;
19
+ if (how === "category")
20
+ return { start: m.median, step: Math.max(3, Math.round(m.sigma)) };
21
+ const q = m.c + m.w.reduce((s, w, i) => s + w * FEATURE_FNS[i](features), 0);
22
+ // Start half a typical error above the prediction: a first encode that passes is cheaper to refine downwards.
23
+ return { start: Math.round(q + m.sigma / 2), step: Math.max(3, Math.round(m.sigma)) };
24
+ }
package/dist/types.d.ts CHANGED
@@ -48,6 +48,11 @@ export interface OptiqOptions {
48
48
  maxInputBytes?: number;
49
49
  /** Reject inputs whose header declares more pixels than this (decompression-bomb guard). Default 50 MP. */
50
50
  maxInputPixels?: number;
51
+ /**
52
+ * Where each codec's quality search starts. Only affects speed, never the quality guarantee.
53
+ * @internal Benchmarking switch; the default is chosen from measurements (see docs/audit.md).
54
+ */
55
+ searchStart?: "fixed" | "category" | "model";
51
56
  /** What to do when no candidate meets all constraints. Default `best-effort` (result lists `unmetConstraints`). */
52
57
  onUnmetConstraints?: "best-effort" | "throw";
53
58
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "optiq-image",
3
- "version": "0.2.0-beta.0",
3
+ "version": "0.3.0-beta.0",
4
4
  "description": "Browser-side image optimizer: picks the smallest encoding that satisfies lossless or perceptual (SSIM) quality constraints, in a Web Worker.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Bauyrzhan (https://github.com/Baukaalm)",
@@ -35,7 +35,7 @@
35
35
  "main": "./dist/index.js",
36
36
  "types": "./dist/index.d.ts",
37
37
  "bin": {
38
- "optiq-image": "./dist/cli.js"
38
+ "optiq-image": "dist/cli.js"
39
39
  },
40
40
  "exports": {
41
41
  ".": {