Files
petrbalvin af4ee19703
Release / gates (push) Successful in 4m38s
Test / test (push) Successful in 5m16s
Release / release (push) Successful in 35s
feat: initial release
Assisted-by: GLM 5.3 Flash
2026-09-03 10:00:00 +02:00

324 lines
11 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.
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
package signal
import (
"slices"
"sourcedock.dev/petrbalvin/tensor/internal/base"
"sourcedock.dev/petrbalvin/tensor/internal/core"
)
// Daubechies discrete wavelets: db2 through db8 beside the
// Haar transform the package already carries in DWT and IDWT. Each
// family is an orthonormal two-channel filter bank driven over levels
// of halving blocks, packed in the same [A_L, D_L, …, D_1] layout the
// Haar transform uses; each dbN pair has N vanishing moments and 2N
// taps, so higher families resolve smoother signals into sparser
// details but tie the boundary condition to longer blocks.
//
// The tables below are not trusted on authority: they were produced
// by the spectral factorisation of Daubechies' polynomial
// P(y) = Σ_k C(N−1+k, k)·y^k, and the tests certify them against the
// defining conditions, the unit norm, the shift-2 orthogonality, the
// N vanishing moments and the spectral identity
// |H(ω)|² = 2·cos^{2N}(ω/2)·P(sin²(ω/2)), so a mistyped digit cannot
// survive.
// Daubechies names a member of the Daubechies wavelet family: dbN
// carries N vanishing moments and a 2N-tap filter pair. Haar remains
// the db1 case and keeps its own exact transform in DWT and IDWT.
type Daubechies string
const (
// DB2 is the 4-tap Daubechies wavelet with 2 vanishing moments.
DB2 Daubechies = "db2"
// DB3 is the 6-tap Daubechies wavelet with 3 vanishing moments.
DB3 Daubechies = "db3"
// DB4 is the 8-tap Daubechies wavelet with 4 vanishing moments.
DB4 Daubechies = "db4"
// DB5 is the 10-tap Daubechies wavelet with 5 vanishing moments.
DB5 Daubechies = "db5"
// DB6 is the 12-tap Daubechies wavelet with 6 vanishing moments.
DB6 Daubechies = "db6"
// DB7 is the 14-tap Daubechies wavelet with 7 vanishing moments.
DB7 Daubechies = "db7"
// DB8 is the 16-tap Daubechies wavelet with 8 vanishing moments.
DB8 Daubechies = "db8"
)
// The scaling filters h, the low-pass analysis side, normalised to
// Σ h = √2. Treat them as read-only.
var (
db2Coeffs = []float64{
0.48296291314453421, 0.83651630373780794, 0.22414386804201339, -0.1294095225512604,
}
db3Coeffs = []float64{
0.33267055295008258, 0.80689150931109266, 0.45987750211849149, -0.13501102001025453,
-0.085441273882026644, 0.035226291885709533,
}
db4Coeffs = []float64{
0.23037781330889651, 0.71484657055291556, 0.63088076792985903, -0.027983769416859948,
-0.18703481171909306, 0.030841381835560764, 0.032883011666885203, -0.010597401785069032,
}
db5Coeffs = []float64{
0.1601023979741929, 0.60382926979718965, 0.72430852843777283, 0.13842814590132091,
-0.24229488706638203, -0.032244869584638361, 0.077571493840045691, -0.0062414902127982726,
-0.012580751999081997, 0.0033357252854737704,
}
db6Coeffs = []float64{
0.11154074335010949, 0.49462389039845328, 0.75113390802109559, 0.31525035170919741,
-0.22626469396543983, -0.12976686756726177, 0.097501605587322904, 0.027522865530305803,
-0.031582039317486044, 0.00055384220116149461, 0.0047772575109455116, -0.0010773010853084798,
}
db7Coeffs = []float64{
0.07785205408500917, 0.39653931948191723, 0.72913209084623509, 0.46978228740519357,
-0.14390600392856487, -0.22403618499387515, 0.071309219266830329, 0.080612609151083051,
-0.0380299369350144, -0.016574541630666881, 0.012550998556099839, 0.00042957797292136738,
-0.0018016407040474906, 0.00035371379997452024,
}
db8Coeffs = []float64{
0.054415842243104001, 0.3128715909143, 0.67563073629728954, 0.58535468365420718,
-0.015829105256349327, -0.28401554296154741, 0.00047248457391386436, 0.12874742662047808,
-0.017369301001807447, -0.044088253930794685, 0.013981027917398216, 0.0087460940474057974,
-0.0048703529934515776, -0.00039174037337694672, 0.00067544940645056933, -0.00011747678412476953,
}
)
// daubechiesTaps resolves a family name to its scaling filter.
func daubechiesTaps(name string, family Daubechies) ([]float64, error) {
switch family {
case DB2:
return db2Coeffs, nil
case DB3:
return db3Coeffs, nil
case DB4:
return db4Coeffs, nil
case DB5:
return db5Coeffs, nil
case DB6:
return db6Coeffs, nil
case DB7:
return db7Coeffs, nil
case DB8:
return db8Coeffs, nil
default:
return nil, base.Errf("%s: unknown Daubechies family %q (want db2 through db8)", name, string(family))
}
}
// DWTMode picks the boundary treatment of the Daubechies transform.
type DWTMode int
const (
// DWTPeriodic treats the signal as one period of a periodic
// sequence: the default. Every block keeps its exact energy and
// the length must offer every level a multiple of the filter
// length to work on.
DWTPeriodic DWTMode = iota
// DWTZeroPad extends the signal with zeros at the tail to the
// next length the level tree needs, so any length transforms;
// the coefficients past the signal's own span carry the
// response of the step to zero at the seam.
DWTZeroPad
)
// DaubechiesDWT returns the discrete Daubechies wavelet transform of
// the rank-1 real signal x over levels scales, packed as
// [A_levels, D_levels, …, D_1]: the deepest approximation first, each
// detail band after it, the finest last. mode picks the boundary
// treatment, DWTPeriodic by default: it refuses a length that does
// not leave every live block a multiple of the 2N-tap filter (the
// deepest block must hold at least one filter length), while
// DWTZeroPad zero-pads the tail to the next such length first. The
// periodic transform conserves energy exactly; a zero-padded one
// conserves the energy of the padded signal. levels must be at
// least 1.
func DaubechiesDWT(x *core.Array, family Daubechies, levels int, mode DWTMode) (*core.Array, error) {
const name = "DaubechiesDWT"
src, h, levels, mode, err := dwtPrepare(name, x, family, levels, mode)
if err != nil {
return nil, err
}
L := len(h)
n := x.Len()
// The periodic contract gates the length; the zero-padded one
// extends the tail to the smallest length that offers every
// level a multiple of the filter length instead.
length := n
if mode == DWTPeriodic {
if err := dwtLengthGate(name, n, L, levels); err != nil {
return nil, err
}
} else {
// The padded block must stay inside the allocator's reach: the
// shift wraps past the addressable range first, and a span that
// survives the wrap but tops the makeslice ceiling of 2^45
// float64 elements panics in the make below instead of refusing
// here.
span := L << (levels - 1)
if span <= 0 || span >= 1<<45 {
return nil, base.Errf("%s: %d levels need a padded length no machine could hold", name, levels)
}
length = ((n-1)/span + 1) * span
}
work := make([]float64, length)
copy(work, src)
g := daubechiesHighPass(h)
// Each pass halves the live block: the approximation and the
// detail land in scratch, then the block rearranges into
// [A, D] exactly as the packed layout wants.
scratch := make([]float64, length)
m := length
for range levels {
half := m / 2
a, d := scratch[:half], scratch[half:m]
dwtForwardLevel(work[:m], a, d, h, g)
copy(work[:half], a)
copy(work[half:m], d)
m = half
}
return core.FromFloats(work, length)
}
// DaubechiesIDWT inverts DaubechiesDWT over the same family, level
// count and mode, undoing the packed layout deepest band first. The
// coefficient vector must satisfy the length contract every forward
// transform produced: a multiple of the filter length at every level
// of the tree, whichever mode the forward ran. A zero-padded forward
// transform therefore inverts to the padded length, whose head is
// the original signal; the mode argument gates exactly this
// contract, the inverse bank itself is the periodic one.
func DaubechiesIDWT(coef *core.Array, family Daubechies, levels int, mode DWTMode) (*core.Array, error) {
const name = "DaubechiesIDWT"
src, h, levels, _, err := dwtPrepare(name, coef, family, levels, mode)
if err != nil {
return nil, err
}
if err := dwtLengthGate(name, coef.Len(), len(h), levels); err != nil {
return nil, err
}
g := daubechiesHighPass(h)
work := make([]float64, len(src))
copy(work, src)
m := len(work) >> levels
for range levels {
half := m
m = 2 * m
a := slices.Clone(work[:half])
d := slices.Clone(work[half:m])
for j := range m {
var av, dv float64
for k := range h {
// The synthesis taps land at even steps only:
// e/2 is the band index the tap reads.
e := j - k
if e < 0 {
e += m
}
if e&1 == 1 {
continue
}
av += h[k] * a[e>>1]
dv += g[k] * d[e>>1]
}
work[j] = av + dv
}
}
return core.FromFloats(work, len(work))
}
// dwtPrepare runs the shared contract of the Daubechies transforms
// ahead of the length question: rank-1 real non-empty input, a known
// family, at least one level, a known mode. It returns the widened
// input, the scaling filter and the validated level count and mode.
func dwtPrepare(name string, x *core.Array, family Daubechies, levels int, mode DWTMode) (src []float64, h []float64, levelsOut int, modeOut DWTMode, err error) {
if x.NDim() != 1 {
return nil, nil, 0, 0, base.Errf("%s: needs a rank-1 signal, got shape %s", name, base.ShapeText(x.Shape()))
}
if x.Dtype() == core.Complex {
return nil, nil, 0, 0, base.Errf("%s: complex signals are not supported", name)
}
if x.Len() == 0 {
return nil, nil, 0, 0, base.Errf("%s: an empty signal has no transform", name)
}
h, err = daubechiesTaps(name, family)
if err != nil {
return nil, nil, 0, 0, err
}
if levels < 1 {
return nil, nil, 0, 0, base.Errf("%s: levels must be at least 1, got %d", name, levels)
}
switch mode {
case DWTPeriodic, DWTZeroPad:
default:
return nil, nil, 0, 0, base.Errf("%s: unknown boundary mode %d", name, int(mode))
}
return widenFloats(x), h, levels, mode, nil
}
// dwtLengthGate enforces the tree contract on a length: the deepest
// block, n>>levels−1 samples, must be a whole multiple of the filter
// length and at least one filter long, which the halvings before it
// then inherit. Shifts past the word width answer 0 and fail the
// gate, which is exactly the refusal a too deep tree wants.
func dwtLengthGate(name string, n, L, levels int) error {
blk := n >> (levels - 1)
if levels-1 >= 64 || (blk<<(levels-1)) != n || blk < L || blk%L != 0 {
return base.Errf("%s: %d levels need every live block to hold a multiple of the %d-tap filter, which %d samples do not offer",
name, levels, L, n)
}
return nil
}
// daubechiesHighPass derives the wavelet (high-pass) half of the bank
// from the scaling filter: g[k] = (−1)^k·h[L−1−k].
func daubechiesHighPass(h []float64) []float64 {
L := len(h)
g := make([]float64, L)
for k := range L {
sign := 1.0
if k%2 == 1 {
sign = -1
}
g[k] = sign * h[L-1-k]
}
return g
}
// dwtForwardLevel runs one periodic analysis level over the block
// x of even length m: a[i] = Σ h[k]·x[(2i+k) mod m] and the same with
// g for d. Outputs whose taps never wrap walk the block directly;
// the ones near the block's end correct the few negative indices.
func dwtForwardLevel(x, a, d, h, g []float64) {
m := len(x)
L := len(h)
half := m / 2
limit := (m - L) / 2
for i := 0; i <= limit; i++ {
base := 2 * i
var av, dv float64
for k := range L {
xv := x[base+k]
av += h[k] * xv
dv += g[k] * xv
}
a[i], d[i] = av, dv
}
for i := limit + 1; i < half; i++ {
base := 2*i - m
var av, dv float64
for k := range L {
idx := base + k
if idx < 0 {
idx += m
}
xv := x[idx]
av += h[k] * xv
dv += g[k] * xv
}
a[i], d[i] = av, dv
}
}