feat: similarity search

This commit is contained in:
Damillora 2021-05-12 01:22:46 +07:00
parent 03f7de692a
commit 064f61cc89
12 changed files with 264 additions and 71 deletions

4
go.mod
View File

@ -3,14 +3,16 @@ module github.com/Damillora/Shioriko
go 1.16
require (
github.com/corona10/goimagehash v1.0.3
github.com/dgrijalva/jwt-go v3.2.0+incompatible
github.com/disintegration/imaging v1.6.2
github.com/gin-contrib/cors v1.3.1
github.com/gin-contrib/static v0.0.1
github.com/gin-gonic/gin v1.7.1
github.com/go-playground/validator/v10 v10.6.0
github.com/google/uuid v1.2.0
github.com/h2non/bimg v1.1.5
golang.org/x/crypto v0.0.0-20210506145944-38f3c27a63bf
golang.org/x/image v0.0.0-20210504121937-7319ad40d33e
gorm.io/driver/postgres v1.1.0
gorm.io/gorm v1.21.9
)

9
go.sum
View File

@ -40,6 +40,8 @@ github.com/coreos/go-systemd v0.0.0-20180511133405-39ca1b05acc7/go.mod h1:F5haX7
github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
github.com/coreos/pkg v0.0.0-20160727233714-3ac0863d7acf/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA=
github.com/corona10/goimagehash v1.0.3 h1:NZM518aKLmoNluluhfHGxT3LGOnrojrxhGn63DR/CZA=
github.com/corona10/goimagehash v1.0.3/go.mod h1:VkvE0mLn84L4aF8vCb6mafVajEb6QYMHl2ZJLn0mOGI=
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@ -47,6 +49,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM=
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
github.com/dustin/go-humanize v0.0.0-20171111073723-bb3d318650d4/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
github.com/eapache/go-resiliency v1.1.0/go.mod h1:kFI+JgMyC7bLPUVY133qvEBtVayf5mFgVsvEsIPBvNs=
github.com/eapache/go-xerial-snappy v0.0.0-20180814174437-776d5712da21/go.mod h1:+020luEh2TKB4/GOp8oxxtq0Daoen/Cii55CzbTV6DU=
@ -273,6 +277,8 @@ github.com/nats-io/nats.go v1.9.1/go.mod h1:ZjDU1L/7fJ09jvUSRVBR2e7+RnLiiIQyqyzE
github.com/nats-io/nkeys v0.1.0/go.mod h1:xpnFELMwJABBLVhffcfd1MZx6VsNRFpEugbxziKVo7w=
github.com/nats-io/nkeys v0.1.3/go.mod h1:xpnFELMwJABBLVhffcfd1MZx6VsNRFpEugbxziKVo7w=
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
github.com/oklog/oklog v0.3.2/go.mod h1:FCV+B7mhrz4o+ueLpx+KqkyXRGMWOYEvfiXtdGtbWGs=
github.com/oklog/run v1.0.0/go.mod h1:dlhp/R75TPv97u0XWUtDeV/lRKWPKSdTuV0TZvrmrQA=
github.com/olekukonko/tablewriter v0.0.0-20170122224234-a0225b3f23b5/go.mod h1:vsDQFd/mU46D+Z4whnwzcISnGGzXWMclvtLoiIKAKIo=
@ -392,6 +398,9 @@ golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm
golang.org/x/crypto v0.0.0-20210506145944-38f3c27a63bf h1:B2n+Zi5QeYRDAEodEu72OS36gmTWjgpXr2+cWcBW90o=
golang.org/x/crypto v0.0.0-20210506145944-38f3c27a63bf/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.0.0-20210504121937-7319ad40d33e h1:PzJMNfFQx+QO9hrC1GwZ4BoPGeNGhfeQEgcQFArEjPk=
golang.org/x/image v0.0.0-20210504121937-7319ad40d33e/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=

View File

@ -1,17 +1,27 @@
package app
import (
"encoding/binary"
"image"
_ "image/gif"
"image/jpeg"
_ "image/jpeg"
_ "image/png"
"net/http"
"os"
"path/filepath"
_ "golang.org/x/image/webp"
"github.com/Damillora/Shioriko/pkg/config"
"github.com/Damillora/Shioriko/pkg/database"
"github.com/Damillora/Shioriko/pkg/middleware"
"github.com/Damillora/Shioriko/pkg/models"
"github.com/Damillora/Shioriko/pkg/services"
"github.com/corona10/goimagehash"
"github.com/disintegration/imaging"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/h2non/bimg"
)
func InitializeBlobRoutes(g *gin.Engine) {
@ -67,6 +77,87 @@ func uploadBlob(c *gin.Context) {
os.Mkdir(filepath.Join(dataDir, "thumbnail", folder1, folder2), 0755)
}
previewFilename := id + ".jpg"
previewFilePath := filepath.Join(dataDir, "preview", folder1, folder2, previewFilename)
thumbnailFilePath := filepath.Join(dataDir, "thumbnail", folder1, folder2, previewFilename)
fileObj, _ := file.Open()
originalImage, _, err := image.Decode(fileObj)
if err != nil {
c.JSON(http.StatusBadRequest, models.ErrorResponse{
Code: http.StatusBadRequest,
Message: err.Error(),
})
}
width := originalImage.Bounds().Dx()
height := originalImage.Bounds().Dy()
hash, err := goimagehash.PerceptionHash(originalImage)
if err != nil {
c.JSON(http.StatusBadRequest, models.ErrorResponse{
Code: http.StatusBadRequest,
Message: err.Error(),
})
return
}
hashInt := hash.GetHash()
similarPosts, err := services.SimilaritySearch(hashInt)
hashSlice := make([]byte, 8)
binary.LittleEndian.PutUint64(hashSlice, hashInt)
previewImage := imaging.Resize(originalImage, 1000, 0, imaging.Lanczos)
if err != nil {
c.JSON(http.StatusBadRequest, models.ErrorResponse{
Code: http.StatusBadRequest,
Message: err.Error(),
})
return
}
thumbnailImage := imaging.Resize(originalImage, 300, 0, imaging.Lanczos)
if err != nil {
c.JSON(http.StatusBadRequest, models.ErrorResponse{
Code: http.StatusBadRequest,
Message: err.Error(),
})
return
}
previewFile, err := os.Create(previewFilePath)
if err != nil {
c.JSON(http.StatusBadRequest, models.ErrorResponse{
Code: http.StatusBadRequest,
Message: err.Error(),
})
return
}
thumbnailFile, err := os.Create(thumbnailFilePath)
if err != nil {
c.JSON(http.StatusBadRequest, models.ErrorResponse{
Code: http.StatusBadRequest,
Message: err.Error(),
})
return
}
err = jpeg.Encode(previewFile, previewImage, nil)
if err != nil {
c.JSON(http.StatusBadRequest, models.ErrorResponse{
Code: http.StatusBadRequest,
Message: err.Error(),
})
}
err = jpeg.Encode(thumbnailFile, thumbnailImage, nil)
if err != nil {
c.JSON(http.StatusBadRequest, models.ErrorResponse{
Code: http.StatusBadRequest,
Message: err.Error(),
})
return
}
filename := id + filepath.Ext(file.Filename)
filePath := filepath.Join(dataDir, folder1, folder2, filename)
err = c.SaveUploadedFile(file, filePath)
@ -75,60 +166,7 @@ func uploadBlob(c *gin.Context) {
Code: http.StatusBadRequest,
Message: err.Error(),
})
}
previewFilename := id + ".webp"
previewFilePath := filepath.Join(dataDir, "preview", folder1, folder2, previewFilename)
thumbnailFilePath := filepath.Join(dataDir, "thumbnail", folder1, folder2, previewFilename)
buffer, err := bimg.Read(filePath)
if err != nil {
c.JSON(http.StatusBadRequest, models.ErrorResponse{
Code: http.StatusBadRequest,
Message: err.Error(),
})
}
image := bimg.NewImage(buffer)
metadata, err := image.Metadata()
if err != nil {
c.JSON(http.StatusBadRequest, models.ErrorResponse{
Code: http.StatusBadRequest,
Message: err.Error(),
})
}
width := metadata.Size.Width
height := metadata.Size.Height
previewImage, err := image.Resize(1000, 0)
if err != nil {
c.JSON(http.StatusBadRequest, models.ErrorResponse{
Code: http.StatusBadRequest,
Message: err.Error(),
})
}
thumbnailImage, err := image.Resize(300, 0)
if err != nil {
c.JSON(http.StatusBadRequest, models.ErrorResponse{
Code: http.StatusBadRequest,
Message: err.Error(),
})
}
err = bimg.Write(previewFilePath, previewImage)
if err != nil {
c.JSON(http.StatusBadRequest, models.ErrorResponse{
Code: http.StatusBadRequest,
Message: err.Error(),
})
}
err = bimg.Write(thumbnailFilePath, thumbnailImage)
if err != nil {
c.JSON(http.StatusBadRequest, models.ErrorResponse{
Code: http.StatusBadRequest,
Message: err.Error(),
})
return
}
blob := database.Blob{
@ -138,13 +176,29 @@ func uploadBlob(c *gin.Context) {
ThumbnailFilePath: filepath.Join("thumbnail", folder1, folder2, previewFilename),
Width: width,
Height: height,
Hash1: hashSlice[0:2],
Hash2: hashSlice[2:4],
Hash3: hashSlice[4:6],
Hash4: hashSlice[6:8],
}
database.DB.Create(&blob)
c.JSON(http.StatusOK, models.BlobResponse{
ID: id,
Width: width,
Height: height,
})
if len(similarPosts) > 0 {
c.JSON(http.StatusOK,
models.BlobSimilarResponse{
ID: id,
Width: width,
Height: height,
Similar: similarPosts,
})
return
} else {
c.JSON(http.StatusOK, models.BlobResponse{
ID: id,
Width: width,
Height: height,
})
return
}
}

View File

@ -11,6 +11,10 @@ type Blob struct {
ThumbnailFilePath string
Width int
Height int
Hash1 []byte
Hash2 []byte
Hash3 []byte
Hash4 []byte
CreatedAt time.Time
UpdatedAt time.Time
}

View File

@ -17,3 +17,8 @@ type PostListItem struct {
ImageThumbnailPath string `json:"thumbnail_path"`
Tags []string `json:"tags"`
}
type PostSimilarityListItem struct {
ID string `json:"id"`
Distance int `json:"distance"`
}

View File

@ -20,6 +20,12 @@ type BlobResponse struct {
Height int `json:"height"`
}
type BlobSimilarResponse struct {
ID string `json:"id"`
Width int `json:"width"`
Height int `json:"height"`
Similar []PostSimilarityListItem `json:"similar"`
}
type PostPaginationResponse struct {
CurrentPage int `json:"currentPage"`
PostCount int `json:"postCount"`

View File

@ -2,7 +2,6 @@ package services
import (
"errors"
"log"
"time"
"github.com/Damillora/Shioriko/pkg/config"
@ -13,7 +12,7 @@ import (
func Login(username string, password string) *database.User {
user := GetUserFromUsername(username)
log.Println(user.Username)
err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password))
if err != nil {
return nil

48
pkg/services/blob.go Normal file
View File

@ -0,0 +1,48 @@
package services
import (
"encoding/binary"
"github.com/Damillora/Shioriko/pkg/database"
"github.com/Damillora/Shioriko/pkg/models"
"github.com/corona10/goimagehash"
)
func SimilaritySearch(originalHashInt uint64) ([]models.PostSimilarityListItem, error) {
originalHash := goimagehash.NewImageHash(originalHashInt, goimagehash.PHash)
hashSlice := make([]byte, 8)
binary.LittleEndian.PutUint64(hashSlice, originalHashInt)
var blobs []database.Blob
database.DB.
Joins("inner join posts on posts.blob_id = blobs.id").
Where("hash1 = ?", hashSlice[0:2]).
Or("hash2 = ?", hashSlice[2:4]).
Or("hash3 = ?", hashSlice[4:6]).
Or("hash4 = ?", hashSlice[6:8]).
Find(&blobs)
posts := make([]models.PostSimilarityListItem, 0)
for _, blob := range blobs {
hash2 := append(blob.Hash1, blob.Hash2...)
hash3 := append(hash2, blob.Hash3...)
hash4 := append(hash3, blob.Hash4...)
hashInt := binary.LittleEndian.Uint64(hash4)
compareHash := goimagehash.NewImageHash(hashInt, goimagehash.PHash)
distance, _ := compareHash.Distance(originalHash)
if distance < 1 {
var post database.Post
database.DB.Where("blob_id = ?", blob.ID).Find(&post)
posts = append(posts, models.PostSimilarityListItem{
ID: post.ID,
Distance: distance,
})
}
}
return posts, nil
}

View File

@ -1,8 +1,6 @@
package services
import (
"log"
"github.com/Damillora/Shioriko/pkg/database"
"github.com/Damillora/Shioriko/pkg/models"
"github.com/google/uuid"
@ -18,7 +16,7 @@ func GetPostAll(page int) []database.Post {
func GetPostTags(page int, tagSyntax []string) []database.Post {
tags, err := ParseReadTags(tagSyntax)
log.Println(tags)
if err != nil {
return []database.Post{}
}

View File

@ -102,4 +102,16 @@ export async function postUpdate(id, { source_url, tags }) {
}
})
return response.data;
}
export async function postDelete({id}) {
const endpoint = url + "/api/post/"+id;
const response = await axios({
url: endpoint,
method: "DELETE",
headers: {
'Authorization': 'Bearer ' + current_token,
},
withCredentials: true,
})
return response.status == 200;
}

View File

@ -1,8 +1,8 @@
<script>
import { onMount } from "svelte";
import TagLink from "../TagLink.svelte";
import { getPost, postCreate } from "../api.js";
import { Link } from "svelte-routing";
import { getPost, postCreate, postDelete } from "../api.js";
import { Link, navigate } from "svelte-routing";
export let id;
let post;
const getData = async () => {
@ -20,6 +20,19 @@
onMount(() => {
getData();
});
let modal_shown = false;
const deletePost = async () => {
toggleModal();
const success = await postDelete({ id });
if (success) {
navigate("/posts");
}
};
const toggleModal = () => {
modal_shown = !modal_shown;
};
</script>
<section class="hero is-primary">
@ -42,6 +55,10 @@
class="button is-primary"
to="/post/edit/{post.id}">Edit</Link
>
<button
on:click|preventDefault={toggleModal}
class="button is-danger">Delete</button
>
</p>
<p>
Uploader: {post.uploader}
@ -87,4 +104,27 @@
</div>
</section>
</div>
<div class="modal" class:is-active={modal_shown}>
<div class="modal-background" />
<div class="modal-content">
Are you sure to delete post {post.id}?
</div>
<div class="modal-card">
<header class="modal-card-head">
<p class="modal-card-title">Delete post?</p>
<button class="delete" aria-label="close" />
</header>
<section class="modal-card-body" />
<footer class="modal-card-foot">
<button
on:click|preventDefault={deletePost}
class="button is-danger">Delete</button
>
<button class="button" on:click|preventDefault={toggleModal}
>Cancel</button
>
</footer>
</div>
</div>
{/if}

View File

@ -1,12 +1,13 @@
<script>
import { uploadBlob, postCreate } from "../api.js";
import { navigate } from "svelte-routing";
import { navigate, Link } from "svelte-routing";
import Tags from "svelte-tags-input";
import AuthRequired from "../AuthRequired.svelte";
let currentProgress = 0;
let fileName = "";
let similar = [];
let form = {
blob_id: "",
@ -21,9 +22,13 @@
const onFileChange = async (e) => {
fileName = "";
similar = [];
var file = e.target.files[0];
if (file) {
var response = await uploadBlob({ file, onProgress });
if (response.similar) {
similar = response.similar;
}
form.blob_id = response.id;
fileName = file.name;
}
@ -31,7 +36,7 @@
const onTagChange = (value) => {
form.tags = value.detail.tags;
}
};
const onSubmit = async () => {
const response = await postCreate(form);
@ -75,6 +80,17 @@
{#if fileName !== ""}
<p class="help">{fileName} uploaded</p>
{/if}
{#if similar.length > 0}
<p class="help">
Similar posts:
{#each similar as post, i}
<Link to="/post/{post.id}">{post.id}</Link>
{#if i < similar.length - 1}
,
{/if}
{/each}
</p>
{/if}
</div>
<div class="field">
<label for="source" class="label">Source URL</label>
@ -91,7 +107,7 @@
<div class="field">
<label for="tags" class="label">Tags</label>
<div class="control" id="tags">
<Tags addKeys={[9,32]} on:tags={onTagChange} />
<Tags addKeys={[9, 32]} on:tags={onTagChange} />
</div>
</div>
<div class="control">