termpic 0.1.0 → 0.2.1

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/README.md CHANGED
@@ -30,6 +30,40 @@ toAnsi(grid); // そのままターミナルに貼れる
30
30
  | `cellAspect` | `2` | 等幅フォント1文字の縦横比(高さ ÷ 幅) |
31
31
  | `background` | `"dark"` | 置く背景。`light` では濃淡が反転する |
32
32
 
33
+ ## 描き方
34
+
35
+ | モード | 1マスの分割 | 色 | 向いているもの |
36
+ | ----------- | ----------- | ----------- | ------------------------------ |
37
+ | `ascii` | 1x1 | 文字色のみ | 文字として貼りたいとき |
38
+ | `halfblock` | 1x2 | 上下2色 | 汎用。既定 |
39
+ | `quadrant` | 2x2 | 明暗2色 | 横の解像度がほしいとき |
40
+ | `braille` | 2x4 | 点と地の2色 | 最も細かい。輪郭がはっきり出る |
41
+
42
+ `quadrant` は、4つの小マスの明度差が小さいマスを単色で塗ります。差が無いのに明暗2色に分けると、平坦な面に無意味な模様が出るためです(実写真で測ると閾値 0.03 でちょうど無くなります)。
43
+
44
+ `braille` は 1 マスに 2x4 の点を持てるので `halfblock` の4倍の解像度になります。点の有無は**画像全体の平均の明度**で決めます。マスごとの中間値で切ると、一様なマスが必ず全点灯になって図地が反転するためです。点の色に加えて点が付かない側の平均も背景色として持たせているので、写真でも図地が反転しません。
45
+
46
+ ## 画像から色を抽出する
47
+
48
+ 決め打ちのパレットに落とすと、そこに無い色(茶色など)が破綻します。画像自身から代表色を求められます。
49
+
50
+ ```ts
51
+ import { convert, extractPalette } from "termpic";
52
+
53
+ const palette = extractPalette(bitmap, 16);
54
+ convert(bitmap, { palette });
55
+ ```
56
+
57
+ 中央値分割法です。色空間の直方体を最も幅の広い軸の中央値で切る操作を、箱が指定数になるまで繰り返します。画像の色数が要求より少ない場合は、重複を除いた色数だけを返します。
58
+
59
+ ## 輪郭を拾う
60
+
61
+ ```ts
62
+ convert(bitmap, { mode: "ascii", edges: true });
63
+ ```
64
+
65
+ Sobel フィルタでマス目の明度の傾きを取り、輪郭が強いマスに向きの文字を割り当てます。マスは縦長なので、画面上の角度に直すために縦方向の傾きへ `cellAspect` を掛けています。これを忘れると斜めの輪郭がすべて縦寄りに倒れます。
66
+
33
67
  ## 設計のポイント
34
68
 
35
69
  **マスの縦横比は出力先で変わる。** 1マスが受け持つのは横 `blockW`・縦 `blockW * cellAspect` ピクセルです。既定の `2` は**ターミナルの比率**(行間込みで文字セルが縦2倍)で、`toAnsi` の出力にはこれが正しい値です。
package/dist/index.d.mts CHANGED
@@ -19,7 +19,7 @@ interface Cell {
19
19
  /** 背景色。ascii モードでは付かない */
20
20
  bg?: string;
21
21
  }
22
- type Mode = "ascii" | "halfblock";
22
+ type Mode = "ascii" | "halfblock" | "quadrant" | "braille";
23
23
  interface Grid {
24
24
  cols: number;
25
25
  rows: number;
@@ -31,7 +31,12 @@ interface Grid {
31
31
  /** 薄い → 濃い の順に並べた既定の濃淡ランプ */
32
32
  declare const DEFAULT_RAMP = " .:-=+*#%@";
33
33
  interface ConvertOptions {
34
- /** ascii = 濃淡の文字だけ / halfblock = 上下2色のマス(既定) */
34
+ /**
35
+ * - `ascii` 濃淡の文字だけ
36
+ * - `halfblock` 上下2色のマス(既定)
37
+ * - `quadrant` 四分割。横の解像度が倍になる
38
+ * - `braille` 点字。1マスに 2x4 の点を持てるので最も細かいが、色は1マスにつき1色
39
+ */
35
40
  mode?: Mode;
36
41
  /** 横方向のマス数。既定 60 */
37
42
  cols?: number;
@@ -41,6 +46,11 @@ interface ConvertOptions {
41
46
  palette?: readonly string[];
42
47
  /** パレット量子化のときに誤差拡散(Floyd–Steinberg)を行う */
43
48
  dither?: boolean;
49
+ /**
50
+ * ascii モードで輪郭を拾い、傾きに応じて `-` `/` `|` `\\` を割り当てる。
51
+ * 数値を渡すとその値を閾値にする(既定 0.12)。
52
+ */
53
+ edges?: boolean | number;
44
54
  /**
45
55
  * 等幅フォント1文字の縦横比(高さ ÷ 幅)。既定 2。
46
56
  * 1 にすると出力が縦に間延びするので、通常は変えない。
@@ -80,6 +90,16 @@ declare function cssVariablePalette(variables: Readonly<Record<string, string>>)
80
90
  palette: string[];
81
91
  colors: Record<string, string>;
82
92
  };
93
+ /**
94
+ * 画像自身から代表色を求める(中央値分割法)。
95
+ *
96
+ * 決め打ちのパレットに落とすと、そこに無い色(茶色など)が破綻する。
97
+ * 画像を色空間の直方体として持ち、最も幅の広い軸で中央値に切る操作を
98
+ * 箱が `count` 個になるまで繰り返し、各箱の平均色を代表色にする。
99
+ *
100
+ * `sampleLimit` を超える画素数の画像は間引いて読む(既定 20000)。
101
+ */
102
+ declare function extractPalette(bitmap: Bitmap, count?: number, sampleLimit?: number): string[];
83
103
  //#endregion
84
104
  //#region src/render.d.ts
85
105
  /** ascii モードの出力を素のテキストにする。行末の空白は落とす */
@@ -115,4 +135,4 @@ declare function toSvg(grid: Grid, options?: SvgOptions): string;
115
135
  /** そのままターミナルに貼れる ANSI エスケープ付きの文字列 */
116
136
  declare function toAnsi(grid: Grid): string;
117
137
  //#endregion
118
- export { type Bitmap, type Cell, type ColorMapping, type ConvertOptions, DEFAULT_RAMP, type Grid, type HtmlOptions, type Mode, type Rgb, type SvgOptions, convert, cssVariablePalette, toAnsi, toHtml, toSvg, toText };
138
+ export { type Bitmap, type Cell, type ColorMapping, type ConvertOptions, DEFAULT_RAMP, type Grid, type HtmlOptions, type Mode, type Rgb, type SvgOptions, convert, cssVariablePalette, extractPalette, toAnsi, toHtml, toSvg, toText };
package/dist/index.mjs CHANGED
@@ -1,9 +1,29 @@
1
- import { formatHex, nearestColor, toOklab } from "contrast-kit";
1
+ import { formatHex, nearestColor, parseHex, toOklab } from "contrast-kit";
2
2
  //#region src/convert.ts
3
+ /** 1マスが受け持つ小マスの数(横 × 縦) */
4
+ const SUBCELLS = {
5
+ ascii: [1, 1],
6
+ halfblock: [1, 2],
7
+ quadrant: [2, 2],
8
+ braille: [2, 4]
9
+ };
10
+ /**
11
+ * 四分割ブロックの文字。添字のビットは
12
+ * 1=左上 / 2=右上 / 4=左下 / 8=右下 に対応する。
13
+ */
14
+ const QUADRANTS = " ▘▝▀▖▌▞▛▗▚▐▜▄▙▟█";
15
+ /**
16
+ * 点字の 2x4 の点と Unicode のビットの対応。
17
+ * 上3段は 0,1,2(左)/ 3,4,5(右)、最下段だけ 6(左)/ 7(右)と離れている。
18
+ */
19
+ const BRAILLE_BITS = [
20
+ [0, 3],
21
+ [1, 4],
22
+ [2, 5],
23
+ [6, 7]
24
+ ];
3
25
  /** 薄い → 濃い の順に並べた既定の濃淡ランプ */
4
26
  const DEFAULT_RAMP = " .:-=+*#%@";
5
- /** 上半分を文字色、下半分を背景色で塗る文字 */
6
- const HALF_BLOCK = "▀";
7
27
  function clamp(value, min, max) {
8
28
  return value < min ? min : value > max ? max : value;
9
29
  }
@@ -97,29 +117,35 @@ function convert(bitmap, options = {}) {
97
117
  const cellAspect = options.cellAspect ?? 2;
98
118
  const ramp = options.ramp ?? " .:-=+*#%@";
99
119
  const background = options.background ?? "dark";
120
+ const [subCols, subRows] = SUBCELLS[mode];
100
121
  const blockW = bitmap.width / cols;
101
122
  const blockH = blockW * cellAspect;
102
123
  const rows = Math.max(1, Math.round(bitmap.height / blockH));
103
- const perCell = mode === "halfblock" ? 2 : 1;
104
- const sampleRows = rows * perCell;
105
- const sampleH = blockH / perCell;
124
+ const sampleCols = cols * subCols;
125
+ const sampleRows = rows * subRows;
126
+ const sampleW = blockW / subCols;
127
+ const sampleH = blockH / subRows;
106
128
  const samples = [];
107
- for (let y = 0; y < sampleRows; y++) for (let x = 0; x < cols; x++) samples.push(averageRect(bitmap, x * blockW, y * sampleH, (x + 1) * blockW, (y + 1) * sampleH));
108
- const colors = quantize(samples, cols, sampleRows, options.palette, options.dither ?? false);
129
+ for (let y = 0; y < sampleRows; y++) for (let x = 0; x < sampleCols; x++) samples.push(averageRect(bitmap, x * sampleW, y * sampleH, (x + 1) * sampleW, (y + 1) * sampleH));
130
+ const colors = quantize(samples, sampleCols, sampleRows, options.palette, options.dither ?? false);
131
+ const at = (x, y) => colors[y * sampleCols + x];
132
+ const threshold = colors.reduce((total, color) => total + toOklab(color).L, 0) / Math.max(1, colors.length);
133
+ const edgeChars = mode === "ascii" && options.edges ? detectEdges(colors, cols, rows, cellAspect, typeof options.edges === "number" ? options.edges : .12) : void 0;
109
134
  const cells = [];
110
- for (let row = 0; row < rows; row++) for (let col = 0; col < cols; col++) if (mode === "halfblock") cells.push({
111
- char: HALF_BLOCK,
112
- fg: colors[row * 2 * cols + col],
113
- bg: colors[(row * 2 + 1) * cols + col]
114
- });
115
- else {
116
- const fg = colors[row * cols + col];
117
- const lightness = toOklab(fg).L;
118
- const level = background === "dark" ? lightness : 1 - lightness;
119
- const index = clamp(Math.round(level * (ramp.length - 1)), 0, ramp.length - 1);
120
- cells.push({
121
- char: ramp[index],
122
- fg
135
+ for (let row = 0; row < rows; row++) for (let col = 0; col < cols; col++) {
136
+ const cell = buildCell({
137
+ mode,
138
+ ramp,
139
+ background,
140
+ at,
141
+ col,
142
+ row,
143
+ threshold
144
+ });
145
+ const edge = edgeChars?.[row * cols + col];
146
+ cells.push(edge === void 0 ? cell : {
147
+ ...cell,
148
+ char: edge
123
149
  });
124
150
  }
125
151
  return {
@@ -131,6 +157,109 @@ function convert(bitmap, options = {}) {
131
157
  };
132
158
  }
133
159
  /**
160
+ * ほぼ一様なマスとみなす明度の幅。
161
+ *
162
+ * これ未満のマスを明暗に二分しても、前景色と背景色がほぼ同じなので
163
+ * 描き分けの意味がなく、平坦な面に無意味な模様が出るだけになる。
164
+ * 実写真で測ると 0.03 で無意味な分割がちょうど無くなり、
165
+ * それ以上に上げると実際の描き分けまで潰れる。
166
+ */
167
+ const FLAT_CELL_RANGE = .03;
168
+ /** 明るい側と暗い側に二分し、どの小マスが明るい側かをビットで返す */
169
+ function splitByLightness(colors) {
170
+ const levels = colors.map((color) => toOklab(color).L);
171
+ const lowest = Math.min(...levels);
172
+ const highest = Math.max(...levels);
173
+ if (highest - lowest < FLAT_CELL_RANGE) {
174
+ const flat = averageHex(colors);
175
+ return {
176
+ bits: (1 << colors.length) - 1,
177
+ light: flat,
178
+ dark: flat
179
+ };
180
+ }
181
+ const middle = (lowest + highest) / 2;
182
+ let bits = 0;
183
+ const lightGroup = [];
184
+ const darkGroup = [];
185
+ for (const [index, level] of levels.entries()) if (level >= middle) {
186
+ bits |= 1 << index;
187
+ lightGroup.push(colors[index]);
188
+ } else darkGroup.push(colors[index]);
189
+ return {
190
+ bits,
191
+ light: averageHex(lightGroup.length > 0 ? lightGroup : colors),
192
+ dark: averageHex(darkGroup.length > 0 ? darkGroup : colors)
193
+ };
194
+ }
195
+ function averageHex(colors) {
196
+ let r = 0;
197
+ let g = 0;
198
+ let b = 0;
199
+ for (const color of colors) {
200
+ const rgb = parseHex(color);
201
+ r += rgb.r;
202
+ g += rgb.g;
203
+ b += rgb.b;
204
+ }
205
+ const count = colors.length;
206
+ return formatHex({
207
+ r: Math.round(r / count),
208
+ g: Math.round(g / count),
209
+ b: Math.round(b / count)
210
+ });
211
+ }
212
+ function buildCell(context) {
213
+ const { mode, at, col, row } = context;
214
+ if (mode === "halfblock") return {
215
+ char: "▀",
216
+ fg: at(col, row * 2),
217
+ bg: at(col, row * 2 + 1)
218
+ };
219
+ if (mode === "quadrant") {
220
+ const { bits, light, dark } = splitByLightness([
221
+ at(col * 2, row * 2),
222
+ at(col * 2 + 1, row * 2),
223
+ at(col * 2, row * 2 + 1),
224
+ at(col * 2 + 1, row * 2 + 1)
225
+ ]);
226
+ return {
227
+ char: QUADRANTS[bits],
228
+ fg: light,
229
+ bg: dark
230
+ };
231
+ }
232
+ if (mode === "braille") {
233
+ const dots = [];
234
+ for (let y = 0; y < 4; y++) dots.push(at(col * 2, row * 4 + y), at(col * 2 + 1, row * 4 + y));
235
+ const levels = dots.map((color) => toOklab(color).L);
236
+ const isLit = (level) => context.background === "dark" ? level >= context.threshold : level < context.threshold;
237
+ let pattern = 0;
238
+ const lit = [];
239
+ const unlit = [];
240
+ for (let y = 0; y < 4; y++) for (let x = 0; x < 2; x++) {
241
+ const index = y * 2 + x;
242
+ if (isLit(levels[index])) {
243
+ pattern |= 1 << BRAILLE_BITS[y][x];
244
+ lit.push(dots[index]);
245
+ } else unlit.push(dots[index]);
246
+ }
247
+ return {
248
+ char: String.fromCodePoint(10240 + pattern),
249
+ fg: averageHex(lit.length > 0 ? lit : dots),
250
+ bg: averageHex(unlit.length > 0 ? unlit : dots)
251
+ };
252
+ }
253
+ const fg = at(col, row);
254
+ const lightness = toOklab(fg).L;
255
+ const level = context.background === "dark" ? lightness : 1 - lightness;
256
+ const index = clamp(Math.round(level * (context.ramp.length - 1)), 0, context.ramp.length - 1);
257
+ return {
258
+ char: context.ramp[index],
259
+ fg
260
+ };
261
+ }
262
+ /**
134
263
  * CSS カスタムプロパティの組から、量子化用のパレットと
135
264
  * 出力用の色の対応表を作る。
136
265
  *
@@ -161,6 +290,107 @@ function cssVariablePalette(variables) {
161
290
  colors
162
291
  };
163
292
  }
293
+ /**
294
+ * 画像自身から代表色を求める(中央値分割法)。
295
+ *
296
+ * 決め打ちのパレットに落とすと、そこに無い色(茶色など)が破綻する。
297
+ * 画像を色空間の直方体として持ち、最も幅の広い軸で中央値に切る操作を
298
+ * 箱が `count` 個になるまで繰り返し、各箱の平均色を代表色にする。
299
+ *
300
+ * `sampleLimit` を超える画素数の画像は間引いて読む(既定 20000)。
301
+ */
302
+ function extractPalette(bitmap, count = 16, sampleLimit = 2e4) {
303
+ if (count < 1) throw new TypeError("色数は 1 以上にしてください");
304
+ const total = bitmap.width * bitmap.height;
305
+ const step = Math.max(1, Math.ceil(total / sampleLimit));
306
+ const pixels = [];
307
+ for (let index = 0; index < total; index += step) {
308
+ const offset = index * 4;
309
+ if ((bitmap.data[offset + 3] ?? 255) === 0) continue;
310
+ pixels.push({
311
+ r: bitmap.data[offset] ?? 0,
312
+ g: bitmap.data[offset + 1] ?? 0,
313
+ b: bitmap.data[offset + 2] ?? 0
314
+ });
315
+ }
316
+ if (pixels.length === 0) return ["#000000"];
317
+ let boxes = [pixels];
318
+ while (boxes.length < count) {
319
+ let target = -1;
320
+ let widestSpread = 0;
321
+ let widestAxis = "r";
322
+ for (const [index, box] of boxes.entries()) {
323
+ if (box.length < 2) continue;
324
+ for (const axis of [
325
+ "r",
326
+ "g",
327
+ "b"
328
+ ]) {
329
+ const values = box.map((pixel) => pixel[axis]);
330
+ const spread = Math.max(...values) - Math.min(...values);
331
+ if (spread > widestSpread) {
332
+ widestSpread = spread;
333
+ widestAxis = axis;
334
+ target = index;
335
+ }
336
+ }
337
+ }
338
+ if (target === -1) break;
339
+ const box = [...boxes[target]].sort((a, b) => a[widestAxis] - b[widestAxis]);
340
+ const middle = Math.floor(box.length / 2);
341
+ boxes = [
342
+ ...boxes.slice(0, target),
343
+ box.slice(0, middle),
344
+ box.slice(middle),
345
+ ...boxes.slice(target + 1)
346
+ ];
347
+ }
348
+ const colors = boxes.filter((box) => box.length > 0).map((box) => {
349
+ const sum = box.reduce((total2, pixel) => ({
350
+ r: total2.r + pixel.r,
351
+ g: total2.g + pixel.g,
352
+ b: total2.b + pixel.b
353
+ }), {
354
+ r: 0,
355
+ g: 0,
356
+ b: 0
357
+ });
358
+ return formatHex({
359
+ r: Math.round(sum.r / box.length),
360
+ g: Math.round(sum.g / box.length),
361
+ b: Math.round(sum.b / box.length)
362
+ });
363
+ });
364
+ return [...new Set(colors)];
365
+ }
366
+ /** 輪郭の向きに割り当てる文字。添字は 0=横 / 1=右下がり / 2=縦 / 3=右上がり */
367
+ const EDGE_CHARS = [
368
+ "-",
369
+ "\\",
370
+ "|",
371
+ "/"
372
+ ];
373
+ /**
374
+ * Sobel フィルタでマス目の明度の傾きを取り、輪郭が強いマスに向きの文字を割り当てる。
375
+ *
376
+ * マスは縦長なので、画面上の角度に直すために縦方向の傾きへ `cellAspect` を掛ける。
377
+ * これを忘れると斜めの輪郭がすべて縦寄りに倒れる。
378
+ */
379
+ function detectEdges(colors, cols, rows, cellAspect, threshold) {
380
+ const lightness = colors.map((color) => toOklab(color).L);
381
+ const at = (x, y) => lightness[clamp(y, 0, rows - 1) * cols + clamp(x, 0, cols - 1)];
382
+ return Array.from({ length: cols * rows }, (_, index) => {
383
+ const col = index % cols;
384
+ const row = Math.floor(index / cols);
385
+ const gx = -at(col - 1, row - 1) + at(col + 1, row - 1) + -2 * at(col - 1, row) + 2 * at(col + 1, row) + -at(col - 1, row + 1) + at(col + 1, row + 1);
386
+ const gy = -at(col - 1, row - 1) - 2 * at(col, row - 1) - at(col + 1, row - 1) + at(col - 1, row + 1) + 2 * at(col, row + 1) + at(col + 1, row + 1);
387
+ if (Math.hypot(gx, gy) / 4 < threshold) return void 0;
388
+ let angle = Math.atan2(gy * cellAspect, gx) + Math.PI / 2;
389
+ angle = (angle % Math.PI + Math.PI) % Math.PI;
390
+ const bucket = Math.round(angle / (Math.PI / 4)) % 4;
391
+ return EDGE_CHARS[bucket];
392
+ });
393
+ }
164
394
  //#endregion
165
395
  //#region src/render.ts
166
396
  const HTML_ESCAPES = {
@@ -241,6 +471,30 @@ function toSvg(grid, options = {}) {
241
471
  emitRow((col) => cellAt(col).fg, row * cellH);
242
472
  emitRow((col) => cellAt(col).bg ?? cellAt(col).fg, row * cellH + halfH);
243
473
  }
474
+ } else if (grid.mode === "quadrant") {
475
+ const halfW = cellW / 2;
476
+ const halfH = cellH / 2;
477
+ for (let row = 0; row < grid.rows; row++) for (let col = 0; col < grid.cols; col++) {
478
+ const cell = grid.cells[row * grid.cols + col];
479
+ const bits = Math.max(0, QUADRANTS.indexOf(cell.char));
480
+ for (let corner = 0; corner < 4; corner++) {
481
+ const color = (bits & 1 << corner) === 0 ? cell.bg ?? cell.fg : cell.fg;
482
+ parts.push(`<rect x="${col * cellW + corner % 2 * halfW}" y="${row * cellH + Math.floor(corner / 2) * halfH}" width="${halfW}" height="${halfH}" fill="${mapColor(color, options.colors)}"/>`);
483
+ }
484
+ }
485
+ } else if (grid.mode === "braille") {
486
+ const dotW = cellW / 2;
487
+ const dotH = cellH / 4;
488
+ const radius = Math.min(dotW, dotH) * .38;
489
+ for (let row = 0; row < grid.rows; row++) for (let col = 0; col < grid.cols; col++) {
490
+ const cell = grid.cells[row * grid.cols + col];
491
+ const pattern = (cell.char.codePointAt(0) ?? 10240) - 10240;
492
+ parts.push(`<rect x="${col * cellW}" y="${row * cellH}" width="${cellW}" height="${cellH}" fill="${mapColor(cell.bg ?? cell.fg, options.colors)}"/>`);
493
+ for (let y = 0; y < 4; y++) for (let x = 0; x < 2; x++) {
494
+ if ((pattern & 1 << BRAILLE_BITS[y][x]) === 0) continue;
495
+ parts.push(`<circle cx="${col * cellW + (x + .5) * dotW}" cy="${row * cellH + (y + .5) * dotH}" r="${radius}" fill="${mapColor(cell.fg, options.colors)}"/>`);
496
+ }
497
+ }
244
498
  } else for (let row = 0; row < grid.rows; row++) {
245
499
  const spans = runsOfRow(grid, row).map((run) => `<tspan fill="${mapColor(run.fg, options.colors)}">${escapeHtml(run.text)}</tspan>`).join("");
246
500
  const baseline = row * cellH + cellH * .78;
@@ -267,4 +521,4 @@ function toAnsi(grid) {
267
521
  return lines.join("\n");
268
522
  }
269
523
  //#endregion
270
- export { DEFAULT_RAMP, convert, cssVariablePalette, toAnsi, toHtml, toSvg, toText };
524
+ export { DEFAULT_RAMP, convert, cssVariablePalette, extractPalette, toAnsi, toHtml, toSvg, toText };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "termpic",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "Convert images into terminal art: ASCII ramps, half-block color cells, and palette quantization.",
5
5
  "keywords": [
6
6
  "ansi",