UNPKG

animedetect

Version:

Detects anime/manga characters in Node.js

253 lines (229 loc) 10.7 kB
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}