Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions pkg/analyzer/LICENSE.phash
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2020 Adrien Delorme

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
2 changes: 1 addition & 1 deletion pkg/analyzer/image.go
Original file line number Diff line number Diff line change
Expand Up @@ -268,7 +268,7 @@ func InspectImage(system fs.FS, link manifest.Link, algorithms []manifest.HashAl
if slices.Contains(neededAlgorithms, manifest.HashAlgorithmPhashDCT) {
// Create phash and put it in a byte array
p.Hashes.PhashDCT = make([]byte, 8)
binary.BigEndian.PutUint64(p.Hashes.PhashDCT, phash.DTC(img))
binary.BigEndian.PutUint64(p.Hashes.PhashDCT, perceptualHash(img))
}
if slices.Contains(neededAlgorithms, blurHashAlgorithm) {
// Create the blurhash
Expand Down
155 changes: 155 additions & 0 deletions pkg/analyzer/image_bench_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
package analyzer

import (
"bytes"
"image"
"os"
"testing"
"testing/fstest"

"github.com/azr/phash"
"github.com/bbrks/go-blurhash"
"github.com/disintegration/imaging"
"github.com/readium/go-toolkit/pkg/manifest"
"github.com/readium/go-toolkit/pkg/mediatype"
)

var benchmarkHashImplementations = []struct {
name string
hash func(image.Image) uint64
}{
{"Reference", phash.DTC},
{"Optimized", perceptualHash},
}

func benchmarkImageFixture(b *testing.B) ([]byte, image.Image) {
b.Helper()
data, err := os.ReadFile("testdata/catsink.jpg")
if err != nil {
b.Fatal(err)
}
img, _, err := image.Decode(bytes.NewReader(data))
if err != nil {
b.Fatal(err)
}
return data, img
}

// BenchmarkImageAnalysis isolates each stage. The hash comparison includes its
// internal 32x32 resize; fixture decoding and the initial 128-pixel resize happen
// before timing. Reference uses the pinned phash dependency's original full DCT.
func BenchmarkImageAnalysis(b *testing.B) {
data, img := benchmarkImageFixture(b)
thumbnail := imaging.Resize(img, 128, 0, imaging.Lanczos)
b.Run("DecodeJPEG", func(b *testing.B) {
b.ReportAllocs()
for b.Loop() {
if _, _, err := image.Decode(bytes.NewReader(data)); err != nil {
b.Fatal(err)
}
}
})
b.Run("Resize128", func(b *testing.B) {
b.ReportAllocs()
for b.Loop() {
imaging.Resize(img, 128, 0, imaging.Lanczos)
}
})
b.Run("PhashDCT", func(b *testing.B) {
want := phash.DTC(thumbnail)
for _, impl := range benchmarkHashImplementations {
b.Run(impl.name, func(b *testing.B) {
b.ReportAllocs()
var hash uint64
for b.Loop() {
hash = impl.hash(thumbnail)
}
if hash != want {
b.Fatalf("hash = %016x, reference = %016x", hash, want)
}
})
}
})
b.Run("BlurHash5x5", func(b *testing.B) {
b.ReportAllocs()
for b.Loop() {
if _, err := blurhash.Encode(5, 5, thumbnail); err != nil {
b.Fatal(err)
}
}
})
}

// BenchmarkVisualHashPipeline compares the original and optimized hash within
// the same decode/resize/hash pipeline. Every iteration decodes the JPEG, resizes
// it to 128 pixels, and computes the selected hashes. File I/O and manifest work
// are excluded; BenchmarkInspectImage below measures the full current API.
func BenchmarkVisualHashPipeline(b *testing.B) {
data, img := benchmarkImageFixture(b)
thumbnail := imaging.Resize(img, 128, 0, imaging.Lanczos)
wantHash := phash.DTC(thumbnail)
wantBlur, err := blurhash.Encode(5, 5, thumbnail)
if err != nil {
b.Fatal(err)
}
for _, mode := range []string{"Phash", "Both"} {
b.Run(mode, func(b *testing.B) {
for _, impl := range benchmarkHashImplementations {
b.Run(impl.name, func(b *testing.B) {
b.ReportAllocs()
var hash uint64
var blur string
for b.Loop() {
decoded, _, err := image.Decode(bytes.NewReader(data))
if err != nil {
b.Fatal(err)
}
resized := imaging.Resize(decoded, 128, 0, imaging.Lanczos)
hash = impl.hash(resized)
if mode == "Both" {
blur, err = blurhash.Encode(5, 5, resized)
if err != nil {
b.Fatal(err)
}
}
}
if hash != wantHash || (mode == "Both" && blur != wantBlur) {
b.Fatal("hashes differ from the reference pipeline")
}
})
}
})
}
}

// BenchmarkInspectImage measures the current public API using an in-memory file
// system to keep disk latency out of the result. It includes image decoding,
// resizing, visual hashing, and manifest property construction.
func BenchmarkInspectImage(b *testing.B) {
data, err := os.ReadFile("testdata/catsink.jpg")
if err != nil {
b.Fatal(err)
}
fs := fstest.MapFS{"catsink.jpg": &fstest.MapFile{Data: data}}
link := manifest.Link{
Href: manifest.MustNewHREFFromString("catsink.jpg", false),
MediaType: &mediatype.JPEG,
}
for _, tc := range []struct {
name string
algorithms []manifest.HashAlgorithm
}{
{"Phash", []manifest.HashAlgorithm{manifest.HashAlgorithmPhashDCT}},
{"BlurHash", []manifest.HashAlgorithm{blurHashAlgorithm}},
{"Both", []manifest.HashAlgorithm{manifest.HashAlgorithmPhashDCT, blurHashAlgorithm}},
} {
b.Run(tc.name, func(b *testing.B) {
b.ReportAllocs()
for b.Loop() {
if _, err := InspectImage(fs, link, tc.algorithms); err != nil {
b.Fatal(err)
}
}
})
}
}
72 changes: 72 additions & 0 deletions pkg/analyzer/phash.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package analyzer

// Adapted from github.com/azr/phash v0.2.0, copyright (c) 2020 Adrien Delorme.
// See LICENSE.phash for the MIT license.

import (
"image"
"math"
"slices"

"github.com/disintegration/imaging"
)

const phashSize = 32
const phashFrequencies = 8

// The reference hash uses frequencies 1 through 8 on both axes. Cache their
// cosine values, preserving the reference expression's floating-point order.
var phashCosines = func() [phashFrequencies][phashSize]float64 {
var cosines [phashFrequencies][phashSize]float64
for frequency := range cosines {
for sample := range cosines[frequency] {
index := (2*sample + 1) * (frequency + 1)
cosines[frequency][sample] = math.Cos(float64(index) / float64(2*phashSize) * math.Pi)
}
}
return cosines
}()

// perceptualHash produces the same hash as phash.DTC, computing only the 64
// coefficients it retains instead of all 1024. Keep the pixel traversal and
// summation order: reassociating the DCT can change bits near the median.
func perceptualHash(img image.Image) uint64 {
if img == nil {
return 0
}
thumbnail := imaging.Resize(img, phashSize, phashSize, imaging.Lanczos)
var pixels [phashSize * phashSize]float64
for x := range phashSize {
for y := range phashSize {
// NRGBAAt avoids boxing each pixel as color.Color. RGBA preserves
// the reference's 16-bit, alpha-premultiplied color conversion.
r, g, b, _ := thumbnail.NRGBAAt(x, y).RGBA()
pixels[x*phashSize+y] = 0.299*float64(r) + 0.587*float64(g) + 0.114*float64(b)
}
}

var coefficients [phashFrequencies * phashFrequencies]float64
for u := range phashFrequencies {
for v := range phashFrequencies {
var sum float64
for x := range phashSize {
for y := range phashSize {
sum += phashCosines[u][x] * phashCosines[v][y] * pixels[x*phashSize+y]
}
}
// The normalization coefficients are both 1 for nonzero frequencies.
coefficients[u*phashFrequencies+v] = sum * 0.25
}
}

sorted := coefficients
slices.Sort(sorted[:])
median := sorted[len(sorted)/2]
var hash uint64
for bit, coefficient := range coefficients {
if coefficient > median {
hash |= uint64(1) << bit
}
}
return hash
}
91 changes: 91 additions & 0 deletions pkg/analyzer/phash_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
package analyzer

import (
"fmt"
"image"
"image/color"
"math/rand"
"os"
"testing"

"github.com/azr/phash"
"github.com/disintegration/imaging"
)

func TestPerceptualHashCompatibility(t *testing.T) {
check := func(t *testing.T, img image.Image) {
t.Helper()
want := phash.DTC(img)
if got := perceptualHash(img); got != want {
t.Fatalf("hash = %016x, reference = %016x", got, want)
}
}
t.Run("nil", func(t *testing.T) { check(t, nil) })
for _, name := range []string{"catsink.jpg", "frame1.png", "frame2.png", "animated.png"} {
t.Run(name, func(t *testing.T) {
f, err := os.Open("testdata/" + name)
if err != nil {
t.Fatal(err)
}
defer f.Close()
img, _, err := image.Decode(f)
if err != nil {
t.Fatal(err)
}
check(t, img)
// InspectImage first resizes wide images to 128 pixels.
check(t, imaging.Resize(img, 128, 0, imaging.Lanczos))
})
}

// Constant and nearly constant images exercise coefficients near the median,
// where even small floating-point changes could flip a hash bit.
for _, c := range []color.NRGBA{
{0, 0, 0, 255}, {255, 255, 255, 255}, {127, 127, 127, 255},
{255, 0, 0, 255}, {0, 255, 0, 255}, {0, 0, 255, 255},
{200, 80, 35, 0}, {200, 80, 35, 1}, {200, 80, 35, 128},
} {
t.Run(fmt.Sprintf("solid-%v", c), func(t *testing.T) {
img := image.NewNRGBA(image.Rect(0, 0, 32, 32))
for y := range 32 {
for x := range 32 {
img.SetNRGBA(x, y, c)
}
}
check(t, img)
img.SetNRGBA(13, 19, color.NRGBA{c.R ^ 1, c.G, c.B, c.A})
check(t, img)
})
}

for seed := range int64(32) {
t.Run(fmt.Sprintf("random-%d", seed), func(t *testing.T) {
rng := rand.New(rand.NewSource(seed))
width, height := 1+rng.Intn(160), 1+rng.Intn(160)
bounds := image.Rect(-3, 5, width-3, height+5)
nrgba := image.NewNRGBA(bounds)
rgba := image.NewRGBA(bounds)
gray := image.NewGray(bounds)
nrgba64 := image.NewNRGBA64(bounds)
paletted := image.NewPaletted(bounds, color.Palette{color.Transparent, color.Black, color.White, color.NRGBA{255, 40, 100, 128}})
for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
for x := bounds.Min.X; x < bounds.Max.X; x++ {
c := color.NRGBA{uint8(rng.Intn(256)), uint8(rng.Intn(256)), uint8(rng.Intn(256)), uint8(rng.Intn(256))}
nrgba.SetNRGBA(x, y, c)
rgba.Set(x, y, c)
gray.Set(x, y, c)
nrgba64.Set(x, y, c)
paletted.SetColorIndex(x, y, uint8(rng.Intn(4)))
}
}
for _, img := range []image.Image{nrgba, rgba, gray, nrgba64, paletted} {
check(t, img)
}
if width > 2 && height > 2 {
// Subimages keep the parent's stride and have nonzero origins.
check(t, nrgba.SubImage(bounds.Inset(1)))
check(t, rgba.SubImage(bounds.Inset(1)))
}
})
}
}
5 changes: 1 addition & 4 deletions pkg/archive/archive_zip.go
Original file line number Diff line number Diff line change
Expand Up @@ -241,10 +241,7 @@ func (e *gozipArchiveEntry) Stream(w io.Writer, start int64, end int64) (int64,
if start >= size {
return 0, nil
}
length = end - start + 1
if length > size-start {
length = size - start
}
length = min(end-start+1, size-start)
}
if n, err, handled := e.streamRawFromFile(w, start, length); handled {
return n, err
Expand Down
8 changes: 4 additions & 4 deletions pkg/content/element/element.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@ type Element interface {
Locator() manifest.Locator // Locator targeting this element in the Publication.
}

func ElementToMap(e Element) map[string]interface{} {
res := make(map[string]interface{})
func ElementToMap(e Element) map[string]any {
res := make(map[string]any)
res["locator"] = e.Locator()
if l := e.Language(); l != "" {
res["language"] = l
Expand Down Expand Up @@ -223,9 +223,9 @@ func (e TextElement) Role() TextRole {
func (e TextElement) MarshalJSON() ([]byte, error) {
res := ElementToMap(e)
res["role"] = e.role.Role()
textElements := make([]interface{}, len(e.segments))
textElements := make([]any, len(e.segments))
for i, s := range e.segments {
te := map[string]interface{}{
te := map[string]any{
"locator": s.Locator,
"text": s.Text,
}
Expand Down
Loading
Loading