asciigen/image.go
2026-08-26 11:56:56 +03:00

152 lines
5.2 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package asciigen
import (
"fmt"
"image"
"math"
"strings"
"golang.org/x/image/draw"
)
const previewWidth = 60 // ширина превью в символах, независимо от финального размера
// ImageRenderer реализует Renderer для картинок.
type ImageRenderer struct {
src image.Image
}
func NewImageRenderer(src image.Image) *ImageRenderer {
return &ImageRenderer{src: src}
}
func (r *ImageRenderer) Previews() ([]Variant, error) {
presets := defaultImagePresets()
variants := make([]Variant, 0, len(presets))
for id, opts := range presets {
previewOpts := opts
previewOpts.Width = previewWidth
ascii, err := r.render(previewOpts)
if err != nil {
return nil, fmt.Errorf("превью %q: %w", id, err)
}
variants = append(variants, Variant{
ID: id,
Label: labelForImagePreset(id),
Preview: ascii,
})
}
return variants, nil
}
func (r *ImageRenderer) Full(variantID string) (string, error) {
presets := defaultImagePresets()
opts, ok := presets[variantID]
if !ok {
return "", fmt.Errorf("неизвестный вариант: %q", variantID)
}
// Полный размер — либо оригинальное разрешение картинки в символах,
// либо разумный кап (напр. 200 символов), чтобы не рендерить гигантские строки.
opts.Width = fullWidthFor(r.src)
return r.render(opts)
}
func fullWidthFor(img image.Image) int {
w := img.Bounds().Dx()
const cap = 200
if w > cap {
return cap
}
return w
}
// render — основной конвейер: ресайз -> яркость -> символ палитры -> (опц.) ANSI-цвет.
func (r *ImageRenderer) render(opts ImageOptions) (string, error) {
if opts.Width <= 0 {
return "", fmt.Errorf("render: width должен быть > 0, получено %d", opts.Width)
}
if len(opts.Palette) == 0 {
return "", fmt.Errorf("render: пустая палитра")
}
gamma := opts.Gamma
if gamma <= 0 {
gamma = 1.0 // 0 трактуем как "не задано", чтобы не делить на ноль
}
resized := resize(r.src, opts.Width, opts.Height)
bounds := resized.Bounds()
var out strings.Builder
// Небольшой запас под ANSI-коды, чтобы не было лишних реаллокаций.
out.Grow(bounds.Dy() * bounds.Dx() * 8)
for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
for x := bounds.Min.X; x < bounds.Max.X; x++ {
rr, gg, bb, _ := resized.At(x, y).RGBA()
// RGBA() отдаёт значения в диапазоне [0, 65535] — приводим к [0, 255].
r8, g8, b8 := rr>>8, gg>>8, bb>>8
ch := brightnessToChar(r8, g8, b8, opts.Palette, gamma, opts.Invert)
if opts.Color {
out.WriteString(fmt.Sprintf("\x1b[38;2;%d;%d;%dm%c\x1b[0m", r8, g8, b8, ch))
} else {
out.WriteRune(ch)
}
}
out.WriteByte('\n')
}
return out.String(), nil
}
// brightnessToChar переводит яркость пикселя в символ палитры с учётом гаммы и инверсии.
func brightnessToChar(r8, g8, b8 uint32, palette Palette, gamma float64, invert bool) rune {
// Стандартная формула яркости (luma), учитывает разную чувствительность глаза к цветам.
luma := 0.299*float64(r8) + 0.587*float64(g8) + 0.114*float64(b8)
normalized := luma / 255.0 // теперь в диапазоне [0, 1]
// Гамма-коррекция: gamma > 1 делает тени темнее (больше контраста),
// gamma < 1 — светлее (больше деталей в тенях).
corrected := math.Pow(normalized, gamma)
if invert {
corrected = 1.0 - corrected
}
chars := []rune(palette)
// corrected=0 (тёмный пиксель) -> первый символ палитры (обычно самый "плотный", @/#).
// corrected=1 (светлый пиксель) -> последний символ (обычно пробел).
idx := int(corrected * float64(len(chars)-1))
if idx < 0 {
idx = 0
}
if idx >= len(chars) {
idx = len(chars) - 1
}
return chars[idx]
}
// resize — уменьшает картинку с учётом того, что символ терминала выше, чем шире (~2:1).
func resize(src image.Image, width, height int) image.Image {
if height == 0 {
b := src.Bounds()
aspect := float64(b.Dy()) / float64(b.Dx())
height = int(float64(width) * aspect * 0.5) // компенсация формы символа
}
dst := image.NewRGBA(image.Rect(0, 0, width, height))
draw.CatmullRom.Scale(dst, dst.Bounds(), src, src.Bounds(), draw.Over, nil)
return dst
}
func labelForImagePreset(id string) string {
labels := map[string]string{
"blocks-standard": "Блочная палитра, стандартный контраст",
"blocks-inverted": "Блочная палитра, инверсия",
"detailed": "Расширенная градация полутонов",
"high-contrast": "Высокий контраст",
"minimal": "Минимальная палитра (2 символа)",
}
return labels[id]
}