animedetect
Version:
Detects anime/manga characters in Node.js
253 lines (229 loc) • 10.7 kB
text/typescript
import Color from "color"
import jimp from "jimp"
import cv from "./opencv.js"
import {RectVector} from "./cvtypes.js"
import ffmpeg from "fluent-ffmpeg"
import fs from "fs"
import gifFrames from "gif-frames"
import path from "path"
import util from "util"
const exec = util.promisify(require("child_process").exec)
export interface AnimeDetectOptions {
cascade?: string
scaleFactor?: number
minNeighbors?: number
minSize?: number[]
maxSize?: number[]
skipFactor?: number
framerate?: number
ffmpegPath?: string
downloadDir?: string
thickness?: number
color?: string
writeDir?: string
}
export interface AnimeDetectResult {
frame?: number
dest?: string
objects: RectVector
}
const videoExtensions = [".mp4", ".mov", ".avi", ".flv", ".mkv", ".webm"]
const parseFramerate = async (file: string, ffmpegPath?: string) => {
const command = `${ffmpegPath ? ffmpegPath : "ffmpeg"} -i ${file}`
const str = await exec(command).then((s: any) => s.stdout).catch((e: any) => e.stderr)
return Number(str.match(/[0-9]* (?=fps,)/)[0])
}
const removeDirectory = (dir: string) => {
if (dir === "/" || dir === "./") return
if (fs.existsSync(dir)) {
fs.readdirSync(dir).forEach(function(entry) {
const entryPath = path.join(dir, entry)
if (fs.lstatSync(entryPath).isDirectory()) {
removeDirectory(entryPath)
} else {
fs.unlinkSync(entryPath)
}
})
try {
fs.rmdirSync(dir)
} catch (e) {
console.log(e)
}
}
}
const download = async (link: string, dest: string) => {
const headers = {"user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/80.0.3987.122 Safari/537.36", "referer": "https://www.pixiv.net/"}
const bin = await fetch(link, {headers}).then((r) => r.arrayBuffer()) as any
fs.writeFileSync(dest, Buffer.from(bin, "binary"))
}
const matToJimp = (mat: cv.Mat) => {
const channels = mat.channels()
const {cols: width, rows: height} = mat
const jimpImage = new jimp(width, height)
// Handling Grayscale Images
if (channels === 1) {
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const pixelValue = mat.ucharAt(y, x)
const color = jimp.rgbaToInt(pixelValue, pixelValue, pixelValue, 255)
jimpImage.setPixelColor(color, x, y)
}
}
}
// Handling RGB Images
else if (channels === 3) {
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const r = mat.ucharPtr(y, x)[0]
const g = mat.ucharPtr(y, x)[1]
const b = mat.ucharPtr(y, x)[2]
const color = jimp.rgbaToInt(r, g, b, 255)
jimpImage.setPixelColor(color, x, y)
}
}
}
// Handling RGBA Images
else if (channels === 4) {
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const r = mat.ucharPtr(y, x)[0]
const g = mat.ucharPtr(y, x)[1]
const b = mat.ucharPtr(y, x)[2]
const a = mat.ucharPtr(y, x)[3]
const color = jimp.rgbaToInt(r, g, b, a)
jimpImage.setPixelColor(color, x, y)
}
}
}
return jimpImage as jimp
}
const sharpen = async (link: string, options?: {ksize?: number, sigma?: number, amount?: number}) => {
if (!options) options = {}
let imgData = await jimp.read(link)
const img = cv.matFromImageData(imgData.bitmap)
const ksize = options.ksize ? Number(options.ksize) : 5.0
const sigma = options.sigma ? Number(options.sigma) : 1.0
const amount = options.amount ? Number(options.amount) : 1.0
const blurry = new cv.Mat()
cv.GaussianBlur(img, blurry, new cv.Size(ksize, ksize), sigma, sigma, cv.BORDER_DEFAULT)
const sharp = new cv.Mat()
cv.addWeighted(img, 1 + amount, blurry, -amount, 0, sharp)
return matToJimp(sharp)
}
const detectImage = async (link: string, options?: AnimeDetectOptions) => {
if (!options) options = {}
let imgData = await jimp.read(link)
const img = cv.matFromImageData(imgData.bitmap)
const newImg = img.clone()
const grayImg = new cv.Mat()
cv.cvtColor(img, grayImg, cv.COLOR_RGBA2GRAY, 0)
try {
if (!options.cascade) options.cascade = path.join(__dirname, `../cascade/lbpcascade_animeface.xml`)
const data = new Uint8Array(fs.readFileSync(options.cascade))
cv.FS_createDataFile("/", "lbpcascade_animeface.xml", data, true, false, false)
} catch {
// ignore
}
const classifier = new cv.CascadeClassifier("lbpcascade_animeface.xml")
if (!options.scaleFactor) options.scaleFactor = 1.1
if (!options.minNeighbors) options.minNeighbors = 5
const minSize = options.minSize?.[0] && options.minSize[1] ? new cv.Size(options.minSize[0], options.minSize[1]) : new cv.Size(0, 0)
const maxSize = options.maxSize?.[0] && options.maxSize[1] ? new cv.Size(options.maxSize[0], options.maxSize[1]) : new cv.Size(0, 0)
const objects = new cv.RectVector()
classifier.detectMultiScale(grayImg, objects, options.scaleFactor, options.minNeighbors, 0, minSize, maxSize)
let result = {objects} as any
if (objects.size() && options.writeDir) {
if (!fs.existsSync(options.writeDir)) fs.mkdirSync(options.writeDir, {recursive: true})
if (!options.thickness) options.thickness = 1
let color = [255, 44, 41, 255]
if (options.color) {
const c = new Color(options.color)
color = [255, c.blue(), c.green(), c.red()]
}
for (let i = 0; i < objects.size(); i++) {
const point1 = new cv.Point(objects.get(i).x, objects.get(i).y)
const point2 = new cv.Point(objects.get(i).x + objects.get(i).width, objects.get(i).y + objects.get(i).height)
cv.rectangle(newImg, point1, point2, color, options.thickness)
}
const dest = `${options.writeDir}/${path.basename(link, path.extname(link))}-result${path.extname(link)}`
const jimpImage = matToJimp(newImg)
jimpImage.write(dest)
result = {...result, dest}
}
return objects.size() ? result as unknown as AnimeDetectResult : null
}
const detectGIF = async (link: string, options?: AnimeDetectOptions) => {
if (!options) options = {}
if (link.startsWith("http") && options.downloadDir) {
if (!fs.existsSync(options.downloadDir)) fs.mkdirSync(options.downloadDir, {recursive: true})
await download(link, `${options.downloadDir}/${path.basename(link)}`)
link = `${options.downloadDir}/${path.basename(link)}`
}
const baseDir = options.downloadDir ? options.downloadDir : (options.writeDir ? options.writeDir : ".")
const frameDest = `${baseDir}/${path.basename(link, path.extname(link))}Frames`
if (fs.existsSync(frameDest)) removeDirectory(frameDest)
fs.mkdirSync(frameDest, {recursive: true})
const frames = await gifFrames({url: link, frames: "all", cumulative: true})
if (Number(options.skipFactor) < 1) options.skipFactor = 1
const constraint = options.skipFactor ? frames.length / options.skipFactor : frames.length
const step = Math.ceil(frames.length / constraint)
const frameArray: string[] = []
const promiseArray: Array<Promise<void>> = []
for (let i = 0; i < frames.length; i += step) {
const writeStream = fs.createWriteStream(`${frameDest}/${path.basename(link, path.extname(link))}frame${i + 1}.jpg`)
frames[i].getImage().pipe(writeStream)
frameArray.push(`${frameDest}/${path.basename(link, path.extname(link))}frame${i + 1}.jpg`)
promiseArray.push(new Promise((resolve) => writeStream.on("finish", resolve)))
}
await Promise.all(promiseArray)
let result = null as AnimeDetectResult | null
for (let i = 0; i < frameArray.length; i++) {
result = await detectImage(frameArray[i], options)
if (result) {
result = {...result, frame: i + 1}
break
}
}
removeDirectory(frameDest)
return result
}
const detectVideo = async (link: string, options?: AnimeDetectOptions) => {
if (!options) options = {}
if (link.startsWith("http") && options.downloadDir) {
if (!fs.existsSync(options.downloadDir)) fs.mkdirSync(options.downloadDir, {recursive: true})
await download(link, `${options.downloadDir}/${path.basename(link)}`)
link = `${options.downloadDir}/${path.basename(link)}`
}
if (options.ffmpegPath) ffmpeg.setFfmpegPath(options.ffmpegPath)
if (!options.framerate) options.framerate = await parseFramerate(link, options.ffmpegPath)
const baseDir = options.downloadDir ? options.downloadDir : (options.writeDir ? options.writeDir : ".")
const frameDest = `${baseDir}/${path.basename(link, path.extname(link))}Frames`
if (fs.existsSync(frameDest)) removeDirectory(frameDest)
fs.mkdirSync(frameDest, {recursive: true})
const framerate = ["-r", `${options.framerate}`]
await new Promise<void>((resolve) => {
ffmpeg(link).outputOptions([...framerate])
.save(`${frameDest}/${path.basename(link, path.extname(link))}frame%d.png`)
.on("end", () => resolve())
})
const frameArray = fs.readdirSync(frameDest).map((f) => `${frameDest}/${f}`).sort(new Intl.Collator(undefined, {numeric: true, sensitivity: "base"}).compare)
let result = null as AnimeDetectResult | null
for (let i = 0; i < frameArray.length; i++) {
result = await detectImage(frameArray[i], options)
if (result) {
result = {...result, frame: i + 1}
break
}
}
removeDirectory(frameDest)
return result
}
const animedetect = async (link: string, options?: AnimeDetectOptions) => {
if (!path.extname(link)) return Promise.reject("link or file path is invalid")
if (path.extname(link) === ".gif") return detectGIF(link, options)
if (videoExtensions.includes(path.extname(link))) return detectVideo(link, options)
return detectImage(link, options)
}
module.exports.default = {cv, animedetect, sharpen}
module.exports = {cv, animedetect, sharpen}
export {animedetect, sharpen, cv}