Расчёт правок считал math.Pow для каждого канала каждого пикселя, из-за чего большое фото обрабатывалось больше секунды. Факторы правок теперь считаются один раз на рендер, квадраты считаются умножением, а строки обрабатываются параллельно. Обработка кадра 4000×3000 сократилась с 1.23 с до 0.14 с. Задержка от сохранения правок до появления отредактированной версии в сетке и просмотре: около 0.15 с для небольшого фото и 0.6 с для большого (полноразмерная версия догружается следом, через 1.4 с).
270 lines
7.5 KiB
Go
270 lines
7.5 KiB
Go
package photos
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"math"
|
|
"sort"
|
|
)
|
|
|
|
// Edit parameters are stored in the photo metadata and describe a
|
|
// non-destructive render: the original object is never touched, the edited
|
|
// image is produced as a separate object.
|
|
|
|
// LightParams holds the parameters of the "Свет" (light) tool. The slider
|
|
// ranges mirror the ones Lightroom exposes: exposure in stops, everything else
|
|
// in percent.
|
|
type LightParams struct {
|
|
Exposure float64 `json:"exposure"`
|
|
Contrast float64 `json:"contrast"`
|
|
Highlights float64 `json:"highlights"`
|
|
Shadows float64 `json:"shadows"`
|
|
Whites float64 `json:"whites"`
|
|
Blacks float64 `json:"blacks"`
|
|
Curve CurveParams `json:"curve"`
|
|
}
|
|
|
|
// CurveParams holds one tone curve per channel. An empty channel means the
|
|
// identity (linear) curve.
|
|
type CurveParams struct {
|
|
RGB []CurvePoint `json:"rgb,omitempty"`
|
|
Red []CurvePoint `json:"red,omitempty"`
|
|
Green []CurvePoint `json:"green,omitempty"`
|
|
Blue []CurvePoint `json:"blue,omitempty"`
|
|
}
|
|
|
|
// CurvePoint is a control point of a tone curve, in the 0..1 range.
|
|
type CurvePoint struct {
|
|
X float64 `json:"x"`
|
|
Y float64 `json:"y"`
|
|
}
|
|
|
|
// EditParams is the full set of non-destructive editing parameters of a photo.
|
|
type EditParams struct {
|
|
Light LightParams `json:"light"`
|
|
}
|
|
|
|
// Limits of the editing parameters. They are enforced on write so a hand-made
|
|
// request can never push the renderer into an extreme state.
|
|
const (
|
|
MaxExposureStops = 5.0
|
|
MaxPercent = 100.0
|
|
MaxCurvePoints = 16
|
|
)
|
|
|
|
// ErrInvalidEditParams is returned when the client sends unusable parameters.
|
|
var ErrInvalidEditParams = errors.New("invalid edit params")
|
|
|
|
// ParseEditParams decodes and normalizes the editing parameters sent by a
|
|
// client. Values outside the allowed range are clamped, and the curve points
|
|
// are sorted and de-duplicated.
|
|
func ParseEditParams(raw []byte) (EditParams, error) {
|
|
var p EditParams
|
|
if len(raw) == 0 {
|
|
return p, nil
|
|
}
|
|
if err := json.Unmarshal(raw, &p); err != nil {
|
|
return EditParams{}, ErrInvalidEditParams
|
|
}
|
|
p = p.Normalized()
|
|
return p, nil
|
|
}
|
|
|
|
// Normalized returns a copy with every value clamped to the allowed range.
|
|
func (p EditParams) Normalized() EditParams {
|
|
p.Light.Exposure = clampFloat(p.Light.Exposure, -MaxExposureStops, MaxExposureStops)
|
|
p.Light.Contrast = clampFloat(p.Light.Contrast, -MaxPercent, MaxPercent)
|
|
p.Light.Highlights = clampFloat(p.Light.Highlights, -MaxPercent, MaxPercent)
|
|
p.Light.Shadows = clampFloat(p.Light.Shadows, -MaxPercent, MaxPercent)
|
|
p.Light.Whites = clampFloat(p.Light.Whites, -MaxPercent, MaxPercent)
|
|
p.Light.Blacks = clampFloat(p.Light.Blacks, -MaxPercent, MaxPercent)
|
|
p.Light.Curve.RGB = normalizeCurve(p.Light.Curve.RGB)
|
|
p.Light.Curve.Red = normalizeCurve(p.Light.Curve.Red)
|
|
p.Light.Curve.Green = normalizeCurve(p.Light.Curve.Green)
|
|
p.Light.Curve.Blue = normalizeCurve(p.Light.Curve.Blue)
|
|
return p
|
|
}
|
|
|
|
// IsEmpty reports whether the parameters describe an untouched photo.
|
|
func (p EditParams) IsEmpty() bool {
|
|
l := p.Light
|
|
if l.Exposure != 0 || l.Contrast != 0 || l.Highlights != 0 || l.Shadows != 0 ||
|
|
l.Whites != 0 || l.Blacks != 0 {
|
|
return false
|
|
}
|
|
for _, pts := range [][]CurvePoint{l.Curve.RGB, l.Curve.Red, l.Curve.Green, l.Curve.Blue} {
|
|
for _, pt := range pts {
|
|
if math.Abs(pt.X-pt.Y) > 1e-6 {
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// JSON encodes the parameters for storage in the photo metadata.
|
|
func (p EditParams) JSON() []byte {
|
|
if p.IsEmpty() {
|
|
return []byte("{}")
|
|
}
|
|
data, err := json.Marshal(p)
|
|
if err != nil {
|
|
return []byte("{}")
|
|
}
|
|
return data
|
|
}
|
|
|
|
// normalizeCurve clamps the points to the unit square, keeps them sorted by X
|
|
// and drops duplicates.
|
|
func normalizeCurve(points []CurvePoint) []CurvePoint {
|
|
if len(points) == 0 {
|
|
return nil
|
|
}
|
|
out := make([]CurvePoint, 0, len(points))
|
|
for _, pt := range points {
|
|
out = append(out, CurvePoint{
|
|
X: clampFloat(pt.X, 0, 1),
|
|
Y: clampFloat(pt.Y, 0, 1),
|
|
})
|
|
}
|
|
sort.SliceStable(out, func(i, j int) bool { return out[i].X < out[j].X })
|
|
deduped := out[:0]
|
|
for i, pt := range out {
|
|
if i > 0 && math.Abs(pt.X-deduped[len(deduped)-1].X) < 1e-6 {
|
|
continue
|
|
}
|
|
deduped = append(deduped, pt)
|
|
}
|
|
if len(deduped) > MaxCurvePoints {
|
|
deduped = deduped[:MaxCurvePoints]
|
|
}
|
|
return deduped
|
|
}
|
|
|
|
func clampFloat(v, min, max float64) float64 {
|
|
if math.IsNaN(v) {
|
|
return 0
|
|
}
|
|
if v < min {
|
|
return min
|
|
}
|
|
if v > max {
|
|
return max
|
|
}
|
|
return v
|
|
}
|
|
|
|
func clamp01(v float64) float64 {
|
|
return clampFloat(v, 0, 1)
|
|
}
|
|
|
|
func clampInt(v, min, max int) int {
|
|
if v < min {
|
|
return min
|
|
}
|
|
if v > max {
|
|
return max
|
|
}
|
|
return v
|
|
}
|
|
|
|
// curveLUT builds a 256-entry lookup table from the control points of a
|
|
// curve. The first and last points extend to the edges of the range, and the
|
|
// values in between are interpolated linearly.
|
|
func curveLUT(points []CurvePoint) [256]uint8 {
|
|
var lut [256]uint8
|
|
pts := normalizeCurve(points)
|
|
if len(pts) < 2 {
|
|
for i := range lut {
|
|
lut[i] = uint8(i)
|
|
}
|
|
return lut
|
|
}
|
|
for i := 0; i < 256; i++ {
|
|
x := float64(i) / 255
|
|
lut[i] = uint8(math.Round(clamp01(curveEval(pts, x)) * 255))
|
|
}
|
|
return lut
|
|
}
|
|
|
|
// curveEval evaluates the curve at x using linear interpolation between the
|
|
// surrounding control points.
|
|
func curveEval(points []CurvePoint, x float64) float64 {
|
|
if x <= points[0].X {
|
|
return points[0].Y
|
|
}
|
|
last := points[len(points)-1]
|
|
if x >= last.X {
|
|
return last.Y
|
|
}
|
|
for i := 1; i < len(points); i++ {
|
|
if x > points[i].X {
|
|
continue
|
|
}
|
|
x0, y0 := points[i-1].X, points[i-1].Y
|
|
x1, y1 := points[i].X, points[i].Y
|
|
if x1-x0 < 1e-9 {
|
|
return y1
|
|
}
|
|
t := (x - x0) / (x1 - x0)
|
|
return y0 + t*(y1-y0)
|
|
}
|
|
return last.Y
|
|
}
|
|
|
|
// lightAdjuster holds the precomputed factors of the light adjustments. The
|
|
// factors are calculated once per render, so the per-pixel loop stays cheap.
|
|
type lightAdjuster struct {
|
|
exposure float64
|
|
contrast float64
|
|
highlights float64
|
|
shadows float64
|
|
whites float64
|
|
blacks float64
|
|
}
|
|
|
|
func newLightAdjuster(p LightParams) lightAdjuster {
|
|
return lightAdjuster{
|
|
exposure: math.Pow(2, p.Exposure),
|
|
contrast: 1 + p.Contrast/100,
|
|
highlights: p.Highlights / 100 * 80,
|
|
shadows: p.Shadows / 100 * 80,
|
|
whites: p.Whites / 100 * 80,
|
|
blacks: p.Blacks / 100 * 80,
|
|
}
|
|
}
|
|
|
|
// apply maps a single channel value through the adjustments and the tone
|
|
// curves: the master curve first, the channel curve second. The order of the
|
|
// operations is fixed and mirrored by the client-side preview, so the live
|
|
// preview and the rendered result match.
|
|
func (a lightAdjuster) apply(v float64, master, channel *[256]uint8) float64 {
|
|
// Экспозиция (стопы).
|
|
v *= a.exposure
|
|
// Контраст вокруг средней точки.
|
|
v = 128 + (v-128)*a.contrast
|
|
// Света: верхняя часть диапазона.
|
|
w := clamp01((v - 128) / 127)
|
|
v += a.highlights * w * w
|
|
// Тени: нижняя часть диапазона.
|
|
w = clamp01((128 - v) / 128)
|
|
v += a.shadows * w * w
|
|
// Белые: самый верх диапазона.
|
|
w = clamp01((v - 192) / 63)
|
|
v += a.whites * w
|
|
// Чёрные: самый низ диапазона.
|
|
w = clamp01((64 - v) / 64)
|
|
v += a.blacks * w
|
|
// Тоновая кривая: общая, затем канальная.
|
|
idx := clampInt(int(v+0.5), 0, 255)
|
|
v = float64(master[idx])
|
|
idx = clampInt(int(v+0.5), 0, 255)
|
|
return float64(channel[idx])
|
|
}
|
|
|
|
// adjustChannel applies the light adjustments to a single channel value. It
|
|
// is the per-pixel helper of the renderer.
|
|
func adjustChannel(v float64, p LightParams, master, channel *[256]uint8) float64 {
|
|
return newLightAdjuster(p).apply(v, master, channel)
|
|
}
|