@logomarks/core 0.1.7 → 0.1.8

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
@@ -5,6 +5,23 @@ All notable changes to `@logomarks/core` are documented here.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.1.8] — 2026-11-07
9
+
10
+ ### Fixed
11
+
12
+ - `shapeTags()` measures each tag from the mark's geometry instead of a few corner and midpoint samples, so the tags agree with what the marks look like. Re-tagging the 4,040 Logomarks marks: `round` 248 → 267 (its old test passed iso cubes and hexagons and missed many discs with a cut), `square` 144 → 283, `symmetric` 1,202 → 968, `radial` 460 → 235, `solid` 984 → 3,395 and `light` 446 → 645 (every mark now gets exactly one of the two). The rules, the measures and their known limits are in [docs/tags.md](docs/tags.md).
13
+ - **round:** about square, the ink reaching its convex hull all round, and the hull running on a circle (found by a robust circle fit) with a compact hull.
14
+ - **square:** about square, not round, and its silhouette (gaps up to 60 closed, holes filled) filling its box to every corner.
15
+ - **symmetric:** the left-right mirror only, by coverage and by outline (so a small asymmetric glyph inside a badge breaks it). A top-bottom mirror alone no longer counts.
16
+ - **radial:** rotational symmetry of order 3, 4 or 5 about the centre of mass. A half turn alone (an S, a bar) no longer counts.
17
+ - **solid / light:** light when the pieces are strokes with little mass (under 30% of the ink in parts thicker than 48 units or 12% of the short side); solid otherwise. Density no longer decides it.
18
+ - The raster's default `size` is now 256 (was 128); the thresholds are tuned there.
19
+
20
+ ### Added
21
+
22
+ - `shapeMeasures(coverage, { w, h, size }, settings?)` and `MEASURE` in `/analyse`: the numbers the tags are decided on (reach, arc, rim, circularity, closed fill and corners, mirror and turn matches, thickness, elongation).
23
+ - `tagsOf(measures, thresholds?)` and `SHAPE`: the tags from the measures, with the thresholds open to change.
24
+
8
25
  ## [0.1.7] — 2026-11-07
9
26
 
10
27
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@logomarks/core",
3
- "version": "0.1.7",
3
+ "version": "0.1.8",
4
4
  "description": "Geometry, SVG reading, fitting, analysis and similarity for logomarks on a 512 grid: the kit the Logomarks set is drawn with (shapes, real booleans, strokes, tracks, iso solids, badges, letter skeletons), one SVG path parser, the 448 fit, speck and shape-tag analysis, outline signatures, and the industry-symbol parts.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -1,6 +1,8 @@
1
1
  export type { Part, PartsRead, SmallOptions, SkipReason } from './specks.js';
2
2
  export type { ShapeTag } from './tags.js';
3
+ export type { ShapeMeasures, SelfMatch } from './shape/measures.js';
3
4
  export type { RunGroup, WidthSettings } from './widths.js';
4
5
  export { SPECKS, vectorParts, rasterParts, markParts, smallParts } from './specks.js';
5
- export { shapeTags } from './tags.js';
6
+ export { SHAPE, shapeTags, tagsOf } from './tags.js';
7
+ export { MEASURE, shapeMeasures } from './shape/measures.js';
6
8
  export { WIDTHS, groupRuns, widths } from './widths.js';
@@ -1,6 +1,8 @@
1
1
  /**
2
- * Analysis of a mark: its pieces and holes (specks, slivers, pin-holes), its stroke and gap widths, and its shape tags.
2
+ * Analysis of a mark: its pieces and holes (specks, slivers, pin-holes), its stroke and gap widths, and its shape tags
3
+ * (with the measures they are decided on).
3
4
  */
4
5
  export { SPECKS, vectorParts, rasterParts, markParts, smallParts } from './specks.js';
5
- export { shapeTags } from './tags.js';
6
+ export { SHAPE, shapeTags, tagsOf } from './tags.js';
7
+ export { MEASURE, shapeMeasures } from './shape/measures.js';
6
8
  export { WIDTHS, groupRuns, widths } from './widths.js';
@@ -0,0 +1,15 @@
1
+ import type { Pt } from './hull.js';
2
+
3
+ /** A circle: its centre and radius. */
4
+ export interface Circle {
5
+ cx: number;
6
+ cy: number;
7
+ r: number;
8
+ }
9
+
10
+ /** The least-squares circle through some points (NaN radius when they are in a line). */
11
+ export function fitCircle(pts: readonly Pt[]): Circle;
12
+ /** The share of points within `tol` × r of a circle's line. */
13
+ export function onCircle(pts: readonly Pt[], c: Circle, tol: number): number;
14
+ /** The circle most of some points lie on, found the same way every time, then refit on the points near it. */
15
+ export function findCircle(pts: readonly Pt[], o?: { tol?: number; refit?: number; tries?: number; largest?: number }): Circle;
@@ -0,0 +1,77 @@
1
+ /**
2
+ * @module @logomarks/core/analyse/shape/circle
3
+ *
4
+ * The circle that best runs along some points: a least-squares fit, a deterministic search over point triples for the
5
+ * circle most points lie on (so a cut-off cap or a tail doesn't pull it), then a refit on the points near it.
6
+ */
7
+
8
+ /** @typedef {import('./hull.js').Pt} Pt */
9
+ /** @typedef {import('./circle.js').Circle} Circle */
10
+
11
+ /**
12
+ * The least-squares circle through some points (Kåsa's algebraic fit).
13
+ *
14
+ * @param {readonly Pt[]} pts At least three points.
15
+ * @returns {Circle} Its centre and radius (NaN when the points are in a line).
16
+ */
17
+ export function fitCircle(pts) {
18
+ let sx = 0, sy = 0, sxx = 0, syy = 0, sxy = 0, sxz = 0, syz = 0, sz = 0;
19
+ for (const [x, y] of pts) {
20
+ const z = x * x + y * y;
21
+ sx += x; sy += y; sxx += x * x; syy += y * y; sxy += x * y; sxz += x * z; syz += y * z; sz += z;
22
+ }
23
+ // solve [sxx sxy sx; sxy syy sy; sx sy n] · [A B C] = [sxz syz sz]; the circle is (A/2, B/2), r² = C + cx² + cy²
24
+ const m = [[sxx, sxy, sx, sxz], [sxy, syy, sy, syz], [sx, sy, pts.length, sz]];
25
+ for (let i = 0; i < 3; i++) {
26
+ let pivot = i;
27
+ for (let j = i + 1; j < 3; j++) if (Math.abs(m[j][i]) > Math.abs(m[pivot][i])) pivot = j;
28
+ [m[i], m[pivot]] = [m[pivot], m[i]];
29
+ for (let j = 0; j < 3; j++) {
30
+ if (j === i) continue;
31
+ const f = m[j][i] / m[i][i];
32
+ for (let k = i; k < 4; k++) m[j][k] -= f * m[i][k];
33
+ }
34
+ }
35
+ const cx = m[0][3] / m[0][0] / 2, cy = m[1][3] / m[1][1] / 2;
36
+ return { cx, cy, r: Math.sqrt(m[2][3] / m[2][2] + cx * cx + cy * cy) };
37
+ }
38
+
39
+ /**
40
+ * The share of points within `tol` × r of a circle's line.
41
+ *
42
+ * @param {readonly Pt[]} pts The points.
43
+ * @param {Circle} c The circle.
44
+ * @param {number} tol The tolerance, a fraction of the radius.
45
+ * @returns {number} 0–1.
46
+ */
47
+ export function onCircle(pts, { cx, cy, r }, tol) {
48
+ let n = 0;
49
+ for (const [x, y] of pts) if (Math.abs(Math.hypot(x - cx, y - cy) - r) < tol * r) n++;
50
+ return pts.length ? n / pts.length : 0;
51
+ }
52
+
53
+ /**
54
+ * The circle most of some points lie on: the least-squares fit, or a better one through three of them (`tries`
55
+ * triples, picked the same way every time), refit three times on the points within `refit` × r of it.
56
+ *
57
+ * @param {readonly Pt[]} pts The points, e.g. along a hull's edge.
58
+ * @param {{ tol?: number, refit?: number, tries?: number, largest?: number }} [o] Inlier tolerance (0.025 of r),
59
+ * refit tolerance (0.04), triples tried (300), and the largest radius worth trying.
60
+ * @returns {Circle} The circle.
61
+ */
62
+ export function findCircle(pts, { tol = 0.025, refit = 0.04, tries = 300, largest = Infinity } = {}) {
63
+ let best = fitCircle(pts), most = onCircle(pts, best, tol);
64
+ const n = pts.length;
65
+ for (let k = 0; k < tries && n >= 3; k++) {
66
+ const c = fitCircle([pts[(k * 7919) % n], pts[(k * 104729 + Math.floor(n / 3)) % n], pts[(k * 1299709 + Math.floor(2 * n / 3)) % n]]);
67
+ if (!(c.r >= 10 && c.r <= largest)) continue;
68
+ const on = onCircle(pts, c, tol);
69
+ if (on > most) { most = on; best = c; }
70
+ }
71
+ for (let i = 0; i < 3; i++) {
72
+ const near = pts.filter(([x, y]) => Math.abs(Math.hypot(x - best.cx, y - best.cy) - best.r) < refit * best.r);
73
+ if (near.length < 20) break;
74
+ best = fitCircle(near);
75
+ }
76
+ return best;
77
+ }
@@ -0,0 +1,15 @@
1
+ import type { Ink } from './ink.js';
2
+
3
+ /** A point, `[x, y]`. */
4
+ export type Pt = [number, number];
5
+
6
+ /** The convex hull of some points (sorts them in place). */
7
+ export function convexHull(pts: Pt[]): Pt[];
8
+ /** The hull of a raster's ink, in pixels. */
9
+ export function inkHull(ink: Pick<Ink, 'bin' | 'n' | 'box'>): Pt[];
10
+ /** A polygon's area (unsigned), perimeter and centre of area. */
11
+ export function polygonMeasures(poly: readonly Pt[]): { area: number; perimeter: number; centre: Pt };
12
+ /** How far a ray from `c` at angle `t` (radians) runs before it leaves a convex polygon round `c`. */
13
+ export function rayExit(poly: readonly Pt[], c: Pt, t: number): number;
14
+ /** Points along a polygon's edges, `step` (1) apart. */
15
+ export function edgePoints(poly: readonly Pt[], step?: number): Pt[];
@@ -0,0 +1,113 @@
1
+ /**
2
+ * @module @logomarks/core/analyse/shape/hull
3
+ *
4
+ * The convex hull of a mark's ink, and what is measured on it: area, perimeter, centre, how far it reaches along a
5
+ * ray, and points along its edge.
6
+ */
7
+
8
+ /** @typedef {[number, number]} Pt */
9
+
10
+ /**
11
+ * The cross product of `oa` and `ob`.
12
+ *
13
+ * @param {Pt} o The origin.
14
+ * @param {Pt} a One point.
15
+ * @param {Pt} b The other.
16
+ * @returns {number} Positive for a left turn.
17
+ */
18
+ const cross = (o, a, b) => (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]);
19
+
20
+ /**
21
+ * The convex hull of some points (Andrew's monotone chain), counter-clockwise on screen axes.
22
+ *
23
+ * @param {Pt[]} pts The points (sorted in place).
24
+ * @returns {Pt[]} The hull's corners.
25
+ */
26
+ export function convexHull(pts) {
27
+ pts.sort((p, q) => p[0] - q[0] || p[1] - q[1]);
28
+ /** @type {Pt[]} */
29
+ const lo = [], up = [];
30
+ for (const p of pts) {
31
+ while (lo.length >= 2 && cross(lo[lo.length - 2], lo[lo.length - 1], p) <= 0) lo.pop();
32
+ lo.push(p);
33
+ }
34
+ for (let i = pts.length - 1; i >= 0; i--) {
35
+ const p = pts[i];
36
+ while (up.length >= 2 && cross(up[up.length - 2], up[up.length - 1], p) <= 0) up.pop();
37
+ up.push(p);
38
+ }
39
+ return lo.slice(0, -1).concat(up.slice(0, -1));
40
+ }
41
+
42
+ /**
43
+ * The hull of a raster's ink: the corners of each row's outermost ink pixels.
44
+ *
45
+ * @param {import('./ink.js').Ink} ink The ink.
46
+ * @returns {Pt[]} The hull's corners, in pixels.
47
+ */
48
+ export function inkHull({ bin, n, box }) {
49
+ /** @type {Pt[]} */
50
+ const pts = [];
51
+ for (let y = box[1]; y < box[3]; y++) {
52
+ let l = -1, r = -1;
53
+ for (let x = box[0]; x < box[2]; x++) if (bin[y * n + x]) { if (l < 0) l = x; r = x; }
54
+ if (l >= 0) pts.push([l, y], [l, y + 1], [r + 1, y], [r + 1, y + 1]);
55
+ }
56
+ return convexHull(pts);
57
+ }
58
+
59
+ /**
60
+ * A polygon's area, perimeter and centre of area.
61
+ *
62
+ * @param {Pt[]} poly The corners.
63
+ * @returns {{ area: number, perimeter: number, centre: Pt }} Its measures (area unsigned).
64
+ */
65
+ export function polygonMeasures(poly) {
66
+ let a = 0, p = 0, cx = 0, cy = 0;
67
+ for (let i = 0; i < poly.length; i++) {
68
+ const [x0, y0] = poly[i], [x1, y1] = poly[(i + 1) % poly.length], c = x0 * y1 - x1 * y0;
69
+ a += c; cx += (x0 + x1) * c; cy += (y0 + y1) * c;
70
+ p += Math.hypot(x1 - x0, y1 - y0);
71
+ }
72
+ a /= 2;
73
+ return { area: Math.abs(a), perimeter: p, centre: a ? [cx / (6 * a), cy / (6 * a)] : poly[0] ?? [0, 0] };
74
+ }
75
+
76
+ /**
77
+ * How far a ray from `c` at angle `t` runs before it leaves a convex polygon round `c`.
78
+ *
79
+ * @param {Pt[]} poly The polygon.
80
+ * @param {Pt} c The ray's start, inside.
81
+ * @param {number} t Its angle in radians.
82
+ * @returns {number} The distance to the polygon's edge.
83
+ */
84
+ export function rayExit(poly, c, t) {
85
+ const dx = Math.cos(t), dy = Math.sin(t);
86
+ let far = 0;
87
+ for (let i = 0; i < poly.length; i++) {
88
+ const p = poly[i], q = poly[(i + 1) % poly.length], ex = q[0] - p[0], ey = q[1] - p[1];
89
+ const den = dx * ey - dy * ex;
90
+ if (Math.abs(den) < 1e-9) continue;
91
+ const px = p[0] - c[0], py = p[1] - c[1];
92
+ const s = (px * ey - py * ex) / den, u = (px * dy - py * dx) / den;
93
+ if (s > 0 && u >= 0 && u <= 1) far = Math.max(far, s);
94
+ }
95
+ return far;
96
+ }
97
+
98
+ /**
99
+ * Points along a polygon's edges, `step` apart.
100
+ *
101
+ * @param {Pt[]} poly The polygon.
102
+ * @param {number} [step] The spacing (1).
103
+ * @returns {Pt[]} The points.
104
+ */
105
+ export function edgePoints(poly, step = 1) {
106
+ /** @type {Pt[]} */
107
+ const out = [];
108
+ for (let i = 0; i < poly.length; i++) {
109
+ const p = poly[i], q = poly[(i + 1) % poly.length], len = Math.hypot(q[0] - p[0], q[1] - p[1]);
110
+ for (let t = 0; t < len; t += step) out.push([p[0] + (q[0] - p[0]) * t / len, p[1] + (q[1] - p[1]) * t / len]);
111
+ }
112
+ return out;
113
+ }
@@ -0,0 +1,28 @@
1
+ import type { Bitmap } from '../../raster/scanline.js';
2
+
3
+ /** A mark's ink on a square coverage raster. */
4
+ export interface Ink {
5
+ /** The coverage, 0–1, row by row. */
6
+ a: Float32Array;
7
+ /** The raster's side in pixels. */
8
+ n: number;
9
+ /** 1 where a pixel is over half covered. */
10
+ bin: Uint8Array;
11
+ /** How many ink pixels. */
12
+ count: number;
13
+ /** The ink's box in pixels, `[x0, y0, x1, y1]`, end-exclusive. */
14
+ box: [number, number, number, number];
15
+ /** The coverage's centre of mass, in pixels. */
16
+ centre: [number, number];
17
+ }
18
+
19
+ /** A square binary raster as the raster module reads one. */
20
+ export function bitmap(data: Uint8Array, n: number): Bitmap;
21
+ /** The ink of a coverage raster: pixels over half covered, their box and the coverage's centre. */
22
+ export function inkOf(a: Float32Array, n: number): Ink;
23
+ /** The outline: ink pixels on the border or beside an empty pixel. */
24
+ export function outlineOf(ink: Pick<Ink, 'bin' | 'n'>): number[];
25
+ /** A binary raster with its holes filled. */
26
+ export function filled(bin: Uint8Array, n: number): Uint8Array;
27
+ /** The ink closed by a disc of radius `r` pixels: narrower gaps join. */
28
+ export function closed(bin: Uint8Array, n: number, r: number): Uint8Array;
@@ -0,0 +1,95 @@
1
+ /**
2
+ * @module @logomarks/core/analyse/shape/ink
3
+ *
4
+ * A mark's ink on a square coverage raster: the binary ink, its box and centre, its outline pixels, and its
5
+ * silhouette with the holes filled (optionally closed first, so narrow gaps join the pieces).
6
+ */
7
+
8
+ import { distance } from '../../raster/distance.js';
9
+
10
+ /**
11
+ * A square binary raster as the raster module reads one.
12
+ *
13
+ * @param {Uint8Array} data 1 for ink, 0 for empty, row by row.
14
+ * @param {number} n Its side in pixels.
15
+ * @returns {import('../../raster/scanline.js').Bitmap} The bitmap.
16
+ */
17
+ export const bitmap = (data, n) => ({ data, width: n, height: n, x0: 0, y0: 0, scale: 1 });
18
+
19
+ /**
20
+ * The ink of a coverage raster: pixels over half covered.
21
+ *
22
+ * @param {Float32Array} a Coverage 0–1, `n` × `n`, row by row.
23
+ * @param {number} n The raster's side.
24
+ * @returns {import('./ink.js').Ink} The ink, its box and its centre.
25
+ */
26
+ export function inkOf(a, n) {
27
+ const bin = new Uint8Array(n * n);
28
+ let count = 0, mass = 0, mx = 0, my = 0, x0 = n, y0 = n, x1 = -1, y1 = -1;
29
+ for (let y = 0; y < n; y++) for (let x = 0; x < n; x++) {
30
+ const v = a[y * n + x];
31
+ mass += v; mx += v * (x + 0.5); my += v * (y + 0.5);
32
+ if (v <= 0.5) continue;
33
+ bin[y * n + x] = 1;
34
+ count++;
35
+ if (x < x0) x0 = x;
36
+ if (x > x1) x1 = x;
37
+ if (y < y0) y0 = y;
38
+ if (y > y1) y1 = y;
39
+ }
40
+ return { a, n, bin, count, box: [x0, y0, x1 + 1, y1 + 1], centre: mass ? [mx / mass, my / mass] : [n / 2, n / 2] };
41
+ }
42
+
43
+ /**
44
+ * The outline: ink pixels on the raster's border or beside an empty pixel (4-connected).
45
+ *
46
+ * @param {import('./ink.js').Ink} ink The ink.
47
+ * @returns {number[]} The outline pixels' indices.
48
+ */
49
+ export function outlineOf({ bin, n }) {
50
+ const out = [];
51
+ for (let y = 0; y < n; y++) for (let x = 0; x < n; x++) {
52
+ const k = y * n + x;
53
+ if (bin[k] && (x === 0 || y === 0 || x === n - 1 || y === n - 1 || !bin[k - 1] || !bin[k + 1] || !bin[k - n] || !bin[k + n])) out.push(k);
54
+ }
55
+ return out;
56
+ }
57
+
58
+ /**
59
+ * Pixels of a binary raster with their holes filled: everything the empty space round the border can't reach.
60
+ *
61
+ * @param {Uint8Array} bin 1 for ink.
62
+ * @param {number} n The raster's side.
63
+ * @returns {Uint8Array} 1 inside the silhouette.
64
+ */
65
+ export function filled(bin, n) {
66
+ const out = new Uint8Array(n * n).fill(1), stack = [];
67
+ const seed = (/** @type {number} */ k) => { if (!bin[k] && out[k]) { out[k] = 0; stack.push(k); } };
68
+ for (let i = 0; i < n; i++) { seed(i); seed((n - 1) * n + i); seed(i * n); seed(i * n + n - 1); }
69
+ while (stack.length) {
70
+ const k = /** @type {number} */ (stack.pop()), x = k % n, y = (k - x) / n;
71
+ if (x > 0) seed(k - 1);
72
+ if (x < n - 1) seed(k + 1);
73
+ if (y > 0) seed(k - n);
74
+ if (y < n - 1) seed(k + n);
75
+ }
76
+ return out;
77
+ }
78
+
79
+ /**
80
+ * The ink closed by a disc (grown, then shrunk, by `r` pixels): gaps narrower than 2r join, outer corners stay.
81
+ *
82
+ * @param {Uint8Array} bin 1 for ink.
83
+ * @param {number} n The raster's side.
84
+ * @param {number} r The disc's radius in pixels.
85
+ * @returns {Uint8Array} 1 for the closed ink.
86
+ */
87
+ export function closed(bin, n, r) {
88
+ const near = distance(bitmap(bin, n), 0);
89
+ const grown = new Uint8Array(n * n);
90
+ for (let k = 0; k < n * n; k++) grown[k] = near[k] <= r ? 1 : 0;
91
+ const depth = distance(bitmap(grown, n), 1);
92
+ const out = new Uint8Array(n * n);
93
+ for (let k = 0; k < n * n; k++) out[k] = depth[k] > r ? 1 : 0;
94
+ return out;
95
+ }
@@ -0,0 +1,52 @@
1
+ /** How well a mark maps onto itself, two ways. */
2
+ export interface SelfMatch {
3
+ /** Coverage overlap over union, 0–1 (a big solid mass dominates it). */
4
+ coverage: number;
5
+ /** Share of outline pixels that land on the outline, 0–1 (a small asymmetric detail still breaks it). */
6
+ outline: number;
7
+ }
8
+
9
+ /** The measures behind the shape tags. */
10
+ export interface ShapeMeasures {
11
+ /** The fitted outline's width over its height. */
12
+ ratio: number;
13
+ /** Share of rays from the hull's centre on which the ink reaches the hull. */
14
+ reach: number;
15
+ /** Share of the hull's edge on its best circle. */
16
+ arc: number;
17
+ /** Share of rays from that circle's centre whose outermost ink lies on it. */
18
+ rim: number;
19
+ /** The hull's circularity, 4πA / P² (a disc 1, a hexagon about 0.95 on the raster, a square 0.79). */
20
+ circularity: number;
21
+ /** The closed silhouette's share of the ink's box. */
22
+ closedFill: number;
23
+ /** Its emptiest corner patch's filled share. */
24
+ closedCorners: number;
25
+ /** The mirror across the vertical middle. */
26
+ mirror: SelfMatch;
27
+ /** Each turn tried (orders 3, 4, 5). */
28
+ turns: (SelfMatch & { order: number })[];
29
+ /** Share of the ink in parts thicker than 48 units. */
30
+ thick: number;
31
+ /** Share of the ink in parts thicker than 12% of the short side. */
32
+ thickRelative: number;
33
+ /** How drawn-out the pieces are (a stroke ≈ length / width, a dot 1.8). */
34
+ elongation: number;
35
+ }
36
+
37
+ /** How the measures are taken (units of the 512 canvas, unless a fraction). */
38
+ export const MEASURE: {
39
+ rays: { reach: number; rim: number };
40
+ reach: number;
41
+ arc: number;
42
+ rim: number;
43
+ closing: number;
44
+ corner: number;
45
+ tolerance: number;
46
+ orders: number[];
47
+ thick: number;
48
+ thickRelative: number;
49
+ };
50
+
51
+ /** The measures behind the shape tags, from a fitted mark's coverage at `size` px (256). */
52
+ export function shapeMeasures(a: Float32Array, m: { w: number; h: number; size?: number }, o?: Partial<typeof MEASURE>): ShapeMeasures;
@@ -0,0 +1,131 @@
1
+ /**
2
+ * @module @logomarks/core/analyse/shape/measures
3
+ *
4
+ * Everything the shape tags are decided on, measured from a fitted mark's coverage raster: how round its outline is,
5
+ * how square its closed silhouette, how it mirrors and turns onto itself, and how thick its ink is.
6
+ */
7
+
8
+ import { distance } from '../../raster/distance.js';
9
+ import { inkOf, outlineOf, filled, closed, bitmap } from './ink.js';
10
+ import { inkHull, polygonMeasures, rayExit, edgePoints } from './hull.js';
11
+ import { findCircle, onCircle } from './circle.js';
12
+ import { coverageMatch, outlineMatcher, mirrorX, turn } from './symmetry.js';
13
+ import { thickShare, elongation } from './thickness.js';
14
+
15
+ /** How the measures are taken (units of the 512 canvas, unless a fraction): the Logomarks settings. */
16
+ export const MEASURE = {
17
+ /** Rays cast for `reach` and `rim`. */
18
+ rays: { reach: 180, rim: 360 },
19
+ /** The ink reaches the hull on a ray when it gets within this fraction of the hull's edge (less a pixel). */
20
+ reach: 0.08,
21
+ /** A hull point is on the circle within this fraction of the radius. */
22
+ arc: 0.025,
23
+ /** A ray's outermost ink is on the rim within this fraction of the radius (plus half a pixel). */
24
+ rim: 0.07,
25
+ /** The closing that joins a modular mark's pieces before its silhouette is read (bridges gaps up to 60). */
26
+ closing: 30,
27
+ /** Each corner patch of the closed silhouette, a fraction of the box's short side. */
28
+ corner: 0.3,
29
+ /** How far a mirrored or turned outline pixel may land from the outline. */
30
+ tolerance: 4,
31
+ /** The turns tried for rotational symmetry. */
32
+ orders: [3, 4, 5],
33
+ /** Ink in parts thicker than this counts as mass… */
34
+ thick: 48,
35
+ /** …or thicker than this fraction of the box's short side. */
36
+ thickRelative: 0.12,
37
+ };
38
+
39
+ /**
40
+ * The share of rays from `c` on which the ink's outermost pixel passes `test`.
41
+ *
42
+ * @param {import('./ink.js').Ink} ink The ink.
43
+ * @param {[number, number]} c The rays' start.
44
+ * @param {number} count How many rays, evenly turned.
45
+ * @param {(k: number) => number} limit How far to look along ray `k`.
46
+ * @param {(far: number, k: number) => boolean} test Whether ray `k`'s outermost ink, `far` from `c`, counts.
47
+ * @returns {number} 0–1.
48
+ */
49
+ function rayShare({ bin, n }, [cx, cy], count, limit, test) {
50
+ let hits = 0;
51
+ for (let k = 0; k < count; k++) {
52
+ const t = k * 2 * Math.PI / count, dx = Math.cos(t), dy = Math.sin(t), end = limit(k);
53
+ let far = 0;
54
+ for (let s = 0; s < end; s += 0.5) {
55
+ const X = Math.floor(cx + dx * s), Y = Math.floor(cy + dy * s);
56
+ if (X >= 0 && Y >= 0 && X < n && Y < n && bin[Y * n + X]) far = s;
57
+ }
58
+ if (test(far, k)) hits++;
59
+ }
60
+ return hits / count;
61
+ }
62
+
63
+ /**
64
+ * The share of a box filled by a mask, and its emptiest corner patch's filled share.
65
+ *
66
+ * @param {Uint8Array} mask 1 inside.
67
+ * @param {number} n The raster's side.
68
+ * @param {[number, number, number, number]} box The box, end-exclusive.
69
+ * @param {number} corner The corner patch's side, a fraction of the box's short side.
70
+ * @returns {{ fill: number, corners: number }} Both 0–1.
71
+ */
72
+ function boxFill(mask, n, [x0, y0, x1, y1], corner) {
73
+ const count = (/** @type {number} */ ax, /** @type {number} */ ay, /** @type {number} */ bx, /** @type {number} */ by) => {
74
+ let c = 0;
75
+ for (let y = ay; y < by; y++) for (let x = ax; x < bx; x++) c += mask[y * n + x];
76
+ return c;
77
+ };
78
+ const w = x1 - x0, h = y1 - y0, c = Math.max(2, Math.round(Math.min(w, h) * corner));
79
+ const patch = (/** @type {number} */ x, /** @type {number} */ y) => count(x, y, x + c, y + c) / (c * c);
80
+ return { fill: count(x0, y0, x1, y1) / (w * h), corners: Math.min(patch(x0, y0), patch(x1 - c, y0), patch(x0, y1 - c), patch(x1 - c, y1 - c)) };
81
+ }
82
+
83
+ /**
84
+ * The measures behind the shape tags, from a fitted mark's coverage.
85
+ *
86
+ * @param {Float32Array} a Coverage 0–1, `size` × `size`, row by row (the 512 canvas, mark centred).
87
+ * @param {{ w: number, h: number, size?: number }} m The fitted outline's width and height, and the raster's side
88
+ * (256: the thresholds are tuned there).
89
+ * @param {Partial<typeof MEASURE>} [o] The settings (MEASURE by default).
90
+ * @returns {import('./measures.js').ShapeMeasures} The measures.
91
+ */
92
+ export function shapeMeasures(a, { w, h, size: n = 256 }, o = {}) {
93
+ const s = { ...MEASURE, ...o }, unit = n / 512;
94
+ const ink = inkOf(a, n);
95
+ const ratio = w / h;
96
+ if (!ink.count) return { ratio, reach: 0, arc: 0, rim: 0, circularity: 0, closedFill: 0, closedCorners: 0, mirror: { coverage: 0, outline: 0 }, turns: [], thick: 0, thickRelative: 0, elongation: 0 };
97
+
98
+ // the outline's hull: how round it is, and whether the ink fills out to it
99
+ const hull = inkHull(ink), { area, perimeter, centre } = polygonMeasures(hull);
100
+ const edge = edgePoints(hull);
101
+ const circle = findCircle(edge, { tol: s.arc, largest: n });
102
+ const reachTo = Array.from({ length: s.rays.reach }, (_, k) => rayExit(hull, centre, k * 2 * Math.PI / s.rays.reach));
103
+ const reach = rayShare(ink, centre, s.rays.reach, (k) => reachTo[k] + 2, (far, k) => far >= reachTo[k] * (1 - s.reach) - 1);
104
+ const rim = rayShare(ink, [circle.cx, circle.cy], s.rays.rim, () => circle.r * 1.3, (far) => Math.abs(far - circle.r) <= s.rim * circle.r + 0.5);
105
+
106
+ // the closed silhouette in its box: a square, or a modular tile, fills it to the corners
107
+ const { fill: closedFill, corners: closedCorners } = boxFill(filled(closed(ink.bin, n, s.closing * unit), n), n, ink.box, s.corner);
108
+
109
+ // symmetry: the mirror across the box's middle, and turns about the centre of mass
110
+ const match = outlineMatcher(ink, outlineOf(ink)), tol = s.tolerance * unit;
111
+ const axis = mirrorX((ink.box[0] + ink.box[2]) / 2);
112
+ const mirror = { coverage: coverageMatch(ink, axis), outline: match(axis, tol) };
113
+ const turns = s.orders.map((order) => { const f = turn(order, ink.centre); return { order, coverage: coverageMatch(ink, f), outline: match(f, tol) }; });
114
+
115
+ // weight: mass versus strokes
116
+ const depth = distance(bitmap(ink.bin, n), 1);
117
+ return {
118
+ ratio,
119
+ reach,
120
+ arc: onCircle(edge, circle, s.arc),
121
+ rim,
122
+ circularity: perimeter ? 4 * Math.PI * area / perimeter ** 2 : 0,
123
+ closedFill,
124
+ closedCorners,
125
+ mirror,
126
+ turns,
127
+ thick: thickShare(ink, depth, s.thick / 2 * unit),
128
+ thickRelative: thickShare(ink, depth, s.thickRelative / 2 * Math.min(w, h) * unit),
129
+ elongation: elongation(ink, depth),
130
+ };
131
+ }
@@ -0,0 +1,13 @@
1
+ import type { Ink } from './ink.js';
2
+
3
+ /** Where a pixel centre's value comes from, in pixel coordinates. */
4
+ export type PixelMap = (x: number, y: number) => [number, number];
5
+
6
+ /** The coverage's overlap with itself mapped by `f` (intersection over union), 0–1. */
7
+ export function coverageMatch(ink: Pick<Ink, 'a' | 'n'>, f: PixelMap): number;
8
+ /** A matcher for the outline: the share of outline pixels a map lands within `tol` pixels of the outline. */
9
+ export function outlineMatcher(ink: Pick<Ink, 'n'>, outline: readonly number[]): (f: PixelMap, tol: number) => number;
10
+ /** The mirror across the vertical line x = `cx`. */
11
+ export function mirrorX(cx: number): PixelMap;
12
+ /** A turn by a whole circle over `order` about `c`. */
13
+ export function turn(order: number, c: [number, number]): PixelMap;
@@ -0,0 +1,84 @@
1
+ /**
2
+ * @module @logomarks/core/analyse/shape/symmetry
3
+ *
4
+ * How well a mark maps onto itself under a mirror or a turn, measured two ways: its coverage (overlap over union,
5
+ * which a big solid mass dominates) and its outline (the share of outline pixels that land on the outline, which a
6
+ * small asymmetric detail inside a badge still breaks).
7
+ */
8
+
9
+ import { distance } from '../../raster/distance.js';
10
+ import { bitmap } from './ink.js';
11
+
12
+ /** @typedef {(x: number, y: number) => [number, number]} PixelMap */
13
+
14
+ /**
15
+ * Coverage at a point between pixel centres (bilinear; 0 outside).
16
+ *
17
+ * @param {Float32Array} a Coverage, `n` × `n`.
18
+ * @param {number} n The side.
19
+ * @param {number} x Pixel coordinates (centres at whole numbers).
20
+ * @param {number} y
21
+ * @returns {number} The coverage there.
22
+ */
23
+ function sample(a, n, x, y) {
24
+ const x0 = Math.floor(x), y0 = Math.floor(y), fx = x - x0, fy = y - y0;
25
+ const at = (/** @type {number} */ i, /** @type {number} */ j) => (i < 0 || j < 0 || i >= n || j >= n ? 0 : a[j * n + i]);
26
+ return at(x0, y0) * (1 - fx) * (1 - fy) + at(x0 + 1, y0) * fx * (1 - fy) + at(x0, y0 + 1) * (1 - fx) * fy + at(x0 + 1, y0 + 1) * fx * fy;
27
+ }
28
+
29
+ /**
30
+ * The coverage's overlap with itself mapped by `f` (intersection over union).
31
+ *
32
+ * @param {import('./ink.js').Ink} ink The ink.
33
+ * @param {PixelMap} f Where each pixel centre (x + 0.5, y + 0.5) comes from.
34
+ * @returns {number} 0–1.
35
+ */
36
+ export function coverageMatch({ a, n }, f) {
37
+ let i = 0, u = 0;
38
+ for (let y = 0; y < n; y++) for (let x = 0; x < n; x++) {
39
+ const [sx, sy] = f(x + 0.5, y + 0.5), p = a[y * n + x], q = sample(a, n, sx - 0.5, sy - 0.5);
40
+ i += Math.min(p, q); u += Math.max(p, q);
41
+ }
42
+ return u ? i / u : 0;
43
+ }
44
+
45
+ /**
46
+ * A matcher for the outline: maps each outline pixel and counts those landing within `tol` pixels of the outline.
47
+ *
48
+ * @param {import('./ink.js').Ink} ink The ink.
49
+ * @param {number[]} outline Its outline pixels (`outlineOf`).
50
+ * @returns {(f: PixelMap, tol: number) => number} The share matched, 0–1, for a map and a tolerance in pixels.
51
+ */
52
+ export function outlineMatcher({ n }, outline) {
53
+ const edge = new Uint8Array(n * n);
54
+ for (const k of outline) edge[k] = 1;
55
+ const near = distance(bitmap(edge, n), 0);
56
+ return (f, tol) => {
57
+ let ok = 0;
58
+ for (const k of outline) {
59
+ const x = k % n, [X, Y] = f(x + 0.5, (k - x) / n + 0.5), i = Math.floor(X), j = Math.floor(Y);
60
+ if (i >= 0 && j >= 0 && i < n && j < n && near[j * n + i] <= tol) ok++;
61
+ }
62
+ return outline.length ? ok / outline.length : 0;
63
+ };
64
+ }
65
+
66
+ /**
67
+ * The mirror across the vertical line x = `cx`.
68
+ *
69
+ * @param {number} cx The axis, in pixels.
70
+ * @returns {PixelMap} The map.
71
+ */
72
+ export const mirrorX = (cx) => (x, y) => [2 * cx - x, y];
73
+
74
+ /**
75
+ * A turn by a whole circle over `order` about `c`.
76
+ *
77
+ * @param {number} order How many turns make the circle.
78
+ * @param {[number, number]} c The centre, in pixels.
79
+ * @returns {PixelMap} The map.
80
+ */
81
+ export function turn(order, [cx, cy]) {
82
+ const t = 2 * Math.PI / order, co = Math.cos(t), si = Math.sin(t);
83
+ return (x, y) => [cx + (x - cx) * co - (y - cy) * si, cy + (x - cx) * si + (y - cy) * co];
84
+ }
@@ -0,0 +1,6 @@
1
+ import type { Ink } from './ink.js';
2
+
3
+ /** The share of the ink in parts thicker than 2r (covered by discs of radius `r` pixels inside it), 0–1. */
4
+ export function thickShare(ink: Pick<Ink, 'bin' | 'n' | 'count'>, depth: Float32Array, r: number): number;
5
+ /** How drawn-out the pieces are: area over width squared per piece, averaged by area (a stroke ≈ length / width). */
6
+ export function elongation(ink: Pick<Ink, 'bin' | 'n'>, depth: Float32Array): number;
@@ -0,0 +1,62 @@
1
+ /**
2
+ * @module @logomarks/core/analyse/shape/thickness
3
+ *
4
+ * How thick a mark's ink is, from its distance transform: the share of the ink in parts thicker than a width (what
5
+ * an opening by a disc keeps), and how drawn-out its pieces are (a stroke is long for its width; a dot or a block
6
+ * isn't).
7
+ */
8
+
9
+ import { distance } from '../../raster/distance.js';
10
+ import { bitmap } from './ink.js';
11
+
12
+ /**
13
+ * The share of the ink covered by discs of radius `r` that fit inside it: the ink in parts thicker than 2r.
14
+ *
15
+ * @param {import('./ink.js').Ink} ink The ink.
16
+ * @param {Float32Array} depth Each ink pixel's distance to the empty space (`distance(ink, 1)`).
17
+ * @param {number} r The radius in pixels.
18
+ * @returns {number} 0–1.
19
+ */
20
+ export function thickShare({ bin, n, count }, depth, r) {
21
+ const core = new Uint8Array(n * n);
22
+ let any = false;
23
+ for (let k = 0; k < n * n; k++) if (bin[k] && depth[k] >= r) { core[k] = 1; any = true; }
24
+ if (!any || !count) return 0;
25
+ const near = distance(bitmap(core, n), 0);
26
+ let kept = 0;
27
+ for (let k = 0; k < n * n; k++) if (bin[k] && near[k] <= r) kept++;
28
+ return kept / count;
29
+ }
30
+
31
+ /**
32
+ * How drawn-out the pieces are: each 8-connected piece's area over its width squared (its width four times its mean
33
+ * depth, exact for a long even stroke), averaged by area. A stroke L long and w wide scores about L / w; a dot about
34
+ * 1.8, a square block 2.3.
35
+ *
36
+ * @param {import('./ink.js').Ink} ink The ink.
37
+ * @param {Float32Array} depth Each ink pixel's distance to the empty space.
38
+ * @returns {number} The area-weighted mean.
39
+ */
40
+ export function elongation({ bin, n }, depth) {
41
+ const seen = new Uint8Array(n * n), stack = [];
42
+ let sum = 0, total = 0;
43
+ for (let s = 0; s < n * n; s++) {
44
+ if (!bin[s] || seen[s]) continue;
45
+ let area = 0, deep = 0;
46
+ seen[s] = 1;
47
+ stack.push(s);
48
+ while (stack.length) {
49
+ const k = /** @type {number} */ (stack.pop()), x = k % n, y = (k - x) / n;
50
+ area++;
51
+ deep += depth[k];
52
+ for (let dy = -1; dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++) {
53
+ const X = x + dx, Y = y + dy, j = Y * n + X;
54
+ if (X >= 0 && Y >= 0 && X < n && Y < n && bin[j] && !seen[j]) { seen[j] = 1; stack.push(j); }
55
+ }
56
+ }
57
+ const width = 4 * deep / area;
58
+ sum += area * area / (width * width);
59
+ total += area;
60
+ }
61
+ return total ? sum / total : 0;
62
+ }
@@ -1,5 +1,21 @@
1
+ import type { ShapeMeasures } from './shape/measures.js';
2
+
1
3
  /** A shape tag measured from a mark's raster. */
2
4
  export type ShapeTag = 'wide' | 'tall' | 'round' | 'square' | 'solid' | 'light' | 'symmetric' | 'radial';
3
5
 
4
- /** A fitted mark's shape tags, from its coverage at `size` px (128) and its outline's width and height. */
6
+ /** The thresholds the tags are decided by. */
7
+ export const SHAPE: {
8
+ wide: number;
9
+ tall: number;
10
+ squarish: number;
11
+ round: { reach: number; ways: Partial<Record<'arc' | 'rim' | 'circularity', number>>[] };
12
+ square: { fill: number; corners: number };
13
+ symmetric: { coverage: number; outline: number };
14
+ radial: { coverage: number; outline: number };
15
+ light: { elongation: number; thick: number };
16
+ };
17
+
18
+ /** The shape tags a mark's measures earn, in a fixed order; exactly one of solid and light. */
19
+ export function tagsOf(ms: ShapeMeasures, t?: typeof SHAPE): ShapeTag[];
20
+ /** A fitted mark's shape tags, from its coverage at `size` px (256) and its outline's width and height. */
5
21
  export function shapeTags(a: Float32Array, m: { w: number; h: number; size?: number }): ShapeTag[];
@@ -2,47 +2,82 @@
2
2
  * @module @logomarks/core/analyse/tags
3
3
  *
4
4
  * Shape tags measured from a mark's coverage: wide or tall, round or square, solid or light, symmetric, radial.
5
+ * Tuned on the 4,040 Logomarks marks against contact sheets (about 95% read right by eye; see docs/tags.md).
5
6
  */
6
7
 
7
- import { remap, iou } from '../similar/signature.js';
8
+ import { shapeMeasures } from './shape/measures.js';
9
+
10
+ /** The thresholds the tags are decided by: the Logomarks settings. */
11
+ export const SHAPE = {
12
+ /** Width over height beyond which a mark is wide, or below which it is tall. */
13
+ wide: 1.25,
14
+ tall: 0.8,
15
+ /** Round and square marks are about as wide as tall: between this and its inverse. */
16
+ squarish: 0.9,
17
+ /** Round: the ink reaches the hull all round, and the hull runs on a circle (any one way). */
18
+ round: {
19
+ reach: 0.6,
20
+ ways: [
21
+ { arc: 0.74, circularity: 0.89 },
22
+ { arc: 0.65, circularity: 0.96 },
23
+ { rim: 0.8, circularity: 0.97 },
24
+ ],
25
+ },
26
+ /** Square: the closed silhouette fills its box, every corner too (and it isn't round). */
27
+ square: { fill: 0.88, corners: 0.6 },
28
+ /** Symmetric: the left-right mirror matches, coverage and outline both. */
29
+ symmetric: { coverage: 0.95, outline: 0.95 },
30
+ /** Radial: a turn of order 3, 4 or 5 matches, coverage and outline both. */
31
+ radial: { coverage: 0.9, outline: 0.9 },
32
+ /** Light: strokes (drawn-out pieces) with little mass; every other mark is solid. */
33
+ light: { elongation: 3, thick: 0.3 },
34
+ };
35
+
36
+ /**
37
+ * Whether the measures clear every threshold in `need`.
38
+ *
39
+ * @param {import('./shape/measures.js').ShapeMeasures} ms The measures.
40
+ * @param {Partial<Record<'arc' | 'rim' | 'circularity', number>>} need The thresholds, by measure.
41
+ * @returns {boolean} All met.
42
+ */
43
+ const meets = (ms, need) => Object.entries(need).every(([k, v]) => ms[/** @type {'arc' | 'rim' | 'circularity'} */ (k)] >= /** @type {number} */ (v));
44
+
45
+ /**
46
+ * The shape tags a mark's measures earn.
47
+ *
48
+ * @param {import('./shape/measures.js').ShapeMeasures} ms The measures (`shapeMeasures`).
49
+ * @param {typeof SHAPE} [t] The thresholds (SHAPE by default).
50
+ * @returns {import('./tags.js').ShapeTag[]} The tags, in a fixed order; exactly one of solid and light.
51
+ */
52
+ export function tagsOf(ms, t = SHAPE) {
53
+ /** @type {import('./tags.js').ShapeTag[]} */
54
+ const tags = [];
55
+ if (ms.ratio > t.wide) tags.push('wide'); else if (ms.ratio < t.tall) tags.push('tall');
56
+ const squarish = ms.ratio > t.squarish && ms.ratio < 1 / t.squarish;
57
+ if (squarish && ms.reach >= t.round.reach && t.round.ways.some((way) => meets(ms, way))) tags.push('round');
58
+ else if (squarish && ms.closedFill >= t.square.fill && ms.closedCorners >= t.square.corners) tags.push('square');
59
+ const light = ms.elongation >= t.light.elongation && Math.min(ms.thick, ms.thickRelative) < t.light.thick;
60
+ tags.push(light ? 'light' : 'solid');
61
+ if (ms.mirror.coverage >= t.symmetric.coverage && ms.mirror.outline >= t.symmetric.outline) tags.push('symmetric');
62
+ if (ms.turns.some((r) => r.coverage >= t.radial.coverage && r.outline >= t.radial.outline)) tags.push('radial');
63
+ return tags;
64
+ }
8
65
 
9
66
  /**
10
- * A fitted mark's shape tags, from its coverage at `size` px (128) and its outline's size on the 512 canvas.
67
+ * A fitted mark's shape tags, from its coverage at `size` px (256) and its outline's size on the 512 canvas.
11
68
  * - wide / tall: wider than 1.25 or narrower than 0.8;
12
- * - round: about square, every corner of its box empty and the silhouette reaching the edge midpoints and diagonals;
13
- * - square: about square with three corners or more filled;
14
- * - solid / light: over 62% or under 30% of its box inked;
15
- * - symmetric: mirrors onto itself (IoU over 0.94); radial: a quarter turn (0.92) or a half turn (0.94) does.
69
+ * - round: about square (0.9–1.11), the ink reaching its convex hull all round, and the hull running on a circle;
70
+ * - square: about square, not round, and its silhouette (gaps up to 60 closed, holes filled) filling its box to the
71
+ * corners;
72
+ * - solid / light: light when it's drawn in strokes with little mass (under 30% of the ink in parts thicker than 48
73
+ * units, or than 12% of its short side); every other mark is solid;
74
+ * - symmetric: its left-right mirror matches (coverage and outline 0.95); a top-bottom mirror alone doesn't count;
75
+ * - radial: a turn by a third, a quarter or a fifth matches (0.9): rotational symmetry of order 3 or more.
16
76
  *
17
77
  * @param {Float32Array} a Coverage 0–1, `size` × `size`, row by row.
18
78
  * @param {{ w: number, h: number, size?: number }} m The outline's width and height (fitted), and the raster's side.
19
79
  * @returns {import('./tags.js').ShapeTag[]} The tags, in a fixed order.
20
80
  */
21
- export function shapeTags(a, { w: mw, h: mh, size: N = 128 }) {
22
- const tags = new Set();
23
- const tf = (/** @type {(x: number, y: number) => number[]} */ f) => remap(a, N, f);
24
- // bbox in raster px
25
- const s = N / 512, x0 = Math.round((256 - mw / 2) * s), y0 = Math.round((256 - mh / 2) * s), w = Math.round(mw * s), h = Math.round(mh * s);
26
- let fill = 0; for (let y = y0; y < y0 + h; y++) for (let x = x0; x < x0 + w; x++) fill += a[y * N + x] || 0;
27
- const density = fill / (w * h);
28
- const ratio = mw / mh;
29
- if (ratio > 1.25) tags.add('wide'); else if (ratio < 0.8) tags.add('tall');
30
- // corners of the bbox: empty everywhere → round silhouette; mostly filled → square
31
- const c = Math.max(2, Math.round(Math.min(w, h) * 0.1));
32
- const corner = (/** @type {number} */ cx, /** @type {number} */ cy) => { let f = 0; for (let y = cy; y < cy + c; y++) for (let x = cx; x < cx + c; x++) f += a[y * N + x] || 0; return f / (c * c); };
33
- const cs = [corner(x0, y0), corner(x0 + w - c, y0), corner(x0, y0 + h - c), corner(x0 + w - c, y0 + h - c)];
34
- const sq = ratio > 0.92 && ratio < 1.08;
35
- if (sq && cs.every((v) => v < 0.04)) {
36
- // also require the silhouette to reach the edge midpoints (a disc, not a diamond or a small blob)
37
- const mid = (/** @type {number} */ x, /** @type {number} */ y) => a[y * N + x] > 0.5;
38
- const r = Math.round(w / 2), cxp = x0 + r, cyp = y0 + r, d = Math.round(w * 0.06);
39
- const diag = Math.round(r * 0.70);
40
- if ([mid(cxp, y0 + d), mid(cxp, y0 + h - 1 - d), mid(x0 + d, cyp), mid(x0 + w - 1 - d, cyp)].filter(Boolean).length >= 3 &&
41
- [mid(cxp - diag + d, cyp - diag + d), mid(cxp + diag - d, cyp - diag + d), mid(cxp - diag + d, cyp + diag - d), mid(cxp + diag - d, cyp + diag - d)].filter(Boolean).length >= 3) tags.add('round');
42
- }
43
- if (sq && cs.filter((v) => v > 0.6).length >= 3) tags.add('square');
44
- if (density > 0.62) tags.add('solid'); else if (density < 0.3) tags.add('light');
45
- if (iou(a, tf((x, y) => [N - 1 - x, y])) > 0.94 || iou(a, tf((x, y) => [x, N - 1 - y])) > 0.94) tags.add('symmetric');
46
- if (iou(a, tf((x, y) => [y, N - 1 - x])) > 0.92 || iou(a, tf((x, y) => [N - 1 - x, N - 1 - y])) > 0.94) tags.add('radial');
47
- return /** @type {import('./tags.js').ShapeTag[]} */ ([...tags]);
81
+ export function shapeTags(a, m) {
82
+ return tagsOf(shapeMeasures(a, m));
48
83
  }