facemorph.ts 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293
  1. import { isValidImage } from "../util";
  2. import Jimp from "jimp";
  3. import { client } from "../client";
  4. import * as cv from "opencv4nodejs";
  5. import * as path from "path";
  6. import request from "request-promise-native";
  7. import { ICommand } from "./command";
  8. import { Message } from "discord.js";
  9. import { getRepository } from "typeorm";
  10. import { FaceCaptionMessage, FaceCaptionType } from "../entity/FaceCaptionMessage";
  11. import { KnownChannel } from "../entity/KnownChannel";
  12. const EMOTE_GUILD = "505333548694241281";
  13. const animeCascade = new cv.CascadeClassifier(path.resolve(process.cwd(), "animu.xml"));
  14. const faceCascade = new cv.CascadeClassifier(cv.HAAR_FRONTALFACE_ALT2);
  15. const CAPTION_IMG_SIZE = 300;
  16. const CAPTION_PROBABILITY = 0.33;
  17. type ImageProcessor = (faces: cv.Rect[], data: Buffer) => Promise<Jimp>;
  18. function intersects(r1: cv.Rect, r2: cv.Rect) {
  19. return (
  20. r1.x <= r2.x + r2.width &&
  21. r1.x + r1.width >= r2.x &&
  22. (r1.y <= r2.y + r2.height && r1.y + r1.height >= r2.y)
  23. );
  24. }
  25. async function morphFaces(faces: cv.Rect[], data: Buffer) {
  26. let padoru = Math.random() <= getPadoruChance();
  27. let jimpImage = await Jimp.read(data);
  28. let emojiKeys = [
  29. ...client.guilds
  30. .get(EMOTE_GUILD)
  31. .emojis.filter(e => !e.animated && e.name.startsWith("PADORU") == padoru)
  32. .keys()
  33. ];
  34. for (const rect of faces) {
  35. let dx = rect.x + rect.width / 2;
  36. let dy = rect.y + rect.height / 2;
  37. let emojiKey = emojiKeys[Math.floor(Math.random() * emojiKeys.length)];
  38. let emoji = client.emojis.get(emojiKey);
  39. let emojiImage = await Jimp.read(emoji.url);
  40. let ew = emojiImage.getWidth();
  41. let eh = emojiImage.getHeight();
  42. const CONSTANT_SCALE = 1.1;
  43. let scaleFactor = (Math.max(rect.width, rect.height) / Math.min(ew, eh)) * CONSTANT_SCALE;
  44. ew *= scaleFactor;
  45. eh *= scaleFactor;
  46. emojiImage = emojiImage.scale(scaleFactor);
  47. jimpImage = jimpImage.composite(emojiImage, dx - ew / 2, dy - eh / 2);
  48. }
  49. return jimpImage;
  50. }
  51. const CAPTION_OFFSET = 5;
  52. async function getRandomCaption(type: FaceCaptionType) {
  53. let repo = getRepository(FaceCaptionMessage);
  54. let caption = await repo.query(`select message
  55. from face_caption_message
  56. where type = ?
  57. order by random()
  58. limit 1`, [ type ]) as FaceCaptionMessage[];
  59. if(caption.length == 0)
  60. return null;
  61. return caption[0];
  62. }
  63. async function captionFace(faces: cv.Rect[], data: Buffer) {
  64. let padoru = Math.random() <= getPadoruChance();
  65. let face = faces[Math.floor(Math.random() * faces.length)];
  66. let squaredFace = await face.toSquareAsync();
  67. let targetSize = CAPTION_IMG_SIZE;
  68. let img = await Jimp.read(data);
  69. let tempImg = await Jimp.create(squaredFace.width, squaredFace.height);
  70. tempImg = await tempImg.blit(
  71. img,
  72. 0,
  73. 0,
  74. squaredFace.x,
  75. squaredFace.y,
  76. squaredFace.width,
  77. squaredFace.height
  78. );
  79. tempImg = await tempImg.scale(targetSize / squaredFace.width);
  80. let font = await Jimp.loadFont(padoru ? Jimp.FONT_SANS_16_WHITE : Jimp.FONT_SANS_16_BLACK);
  81. let text = padoru ? "PADORU PADORU" : `${(await getRandomCaption(FaceCaptionType.PREFIX)).message} ${(await getRandomCaption(FaceCaptionType.POSTFIX)).message}`;
  82. let h = Jimp.measureTextHeight(font, text, targetSize - CAPTION_OFFSET * 2);
  83. let finalImage = await Jimp.create(targetSize, targetSize + h + CAPTION_OFFSET * 2, padoru ? "#FD2027" : "#FFFFFF");
  84. finalImage = await finalImage.print(
  85. font,
  86. CAPTION_OFFSET,
  87. CAPTION_OFFSET,
  88. text,
  89. finalImage.getWidth() - CAPTION_OFFSET
  90. );
  91. finalImage = await finalImage.composite(tempImg, 0, CAPTION_OFFSET * 2 + h);
  92. return finalImage;
  93. }
  94. /**
  95. * PADORU PADORU
  96. */
  97. function getPadoruChance() {
  98. let now = new Date();
  99. if (now.getUTCMonth() != 11 || now.getUTCDate() > 25)
  100. return 0;
  101. return 1 / (27.0 - now.getUTCDate());
  102. }
  103. async function processFaceSwap(message: Message, attachmentUrl: string, processor?: ImageProcessor, failMessage?: string, successMessage?: string) {
  104. let data = await request(attachmentUrl, { encoding: null }) as Buffer;
  105. let im = await cv.imdecodeAsync(data, cv.IMREAD_COLOR);
  106. let gray = await im.cvtColorAsync(cv.COLOR_BGR2GRAY);
  107. let normGray = await gray.equalizeHistAsync();
  108. let animeFaces = await animeCascade.detectMultiScaleAsync(
  109. normGray,
  110. 1.1,
  111. 5,
  112. 0,
  113. new cv.Size(24, 24)
  114. );
  115. let normalFaces = await faceCascade.detectMultiScaleAsync(gray);
  116. if (animeFaces.objects.length == 0 && normalFaces.objects.length == 0) {
  117. if (failMessage) message.channel.send(failMessage);
  118. return;
  119. }
  120. let faces = [...normalFaces.objects, ...animeFaces.objects];
  121. let normalCount = normalFaces.objects.length;
  122. let animeCount = animeFaces.objects.length;
  123. for (let i = 0; i < normalCount; i++) {
  124. const rNormal = faces[i];
  125. if (animeCount == 0) break;
  126. for (let j = normalCount; j < faces.length; j++) {
  127. const rAnime = faces[j];
  128. if (intersects(rAnime, rNormal)) {
  129. let animeA = rAnime.width * rAnime.height;
  130. let faceA = rNormal.width * rNormal.height;
  131. if (animeA > faceA) {
  132. faces.splice(i, 1);
  133. normalCount--;
  134. i--;
  135. break;
  136. } else {
  137. faces.splice(j, 1);
  138. animeCount--;
  139. j--;
  140. }
  141. }
  142. }
  143. }
  144. let jimpImage;
  145. if (processor)
  146. jimpImage = await processor(faces, data);
  147. else {
  148. if (Math.random() <= CAPTION_PROBABILITY)
  149. jimpImage = await captionFace(faces, data);
  150. else
  151. jimpImage = await morphFaces(faces, data);
  152. }
  153. jimpImage.quality(90);
  154. let buffer = await jimpImage.getBufferAsync(Jimp.MIME_JPEG);
  155. let messageContents =
  156. successMessage ||
  157. `I noticed a face in the image. I think this looks better ${client.emojis.get("505076258753740810").toString()}`;
  158. message.channel.send(messageContents, {
  159. files: [buffer]
  160. });
  161. }
  162. function processLastImage(msg: Message, processor: ImageProcessor) {
  163. let lastImagedMessage = msg.channel.messages.filter(m => !m.author.bot && m.attachments.find(v => isValidImage(v.filename) !== undefined) != undefined).last();
  164. if (!lastImagedMessage) {
  165. msg.channel.send(`${msg.author.toString()} Sorry, I couldn't find any recent messages with images.`);
  166. return;
  167. }
  168. let image = lastImagedMessage.attachments.find(v => isValidImage(v.filename));
  169. processFaceSwap(
  170. msg,
  171. image.url,
  172. processor,
  173. `${msg.author.toString()} Nice image! I don't see anything interesting, though.`,
  174. `${msg.author.toString()} ${client.emojis.get("505076258753740810").toString()}`
  175. ).catch(err => console.log(`Failed to run faceapp because ${err}`));
  176. }
  177. export default {
  178. onMessage: async (actionsDone, msg, contents) => {
  179. if (actionsDone) return false;
  180. if (msg.mentions.users.size > 0 && msg.mentions.users.first().id == client.user.id)
  181. return false;
  182. let imageAttachment = msg.attachments.find(v => isValidImage(v.filename));
  183. if (imageAttachment) {
  184. let repo = getRepository(KnownChannel);
  185. let knownChannel = await repo.findOne({
  186. where: { channelId: msg.channel.id },
  187. select: [ "faceMorphProbability" ]
  188. });
  189. if(!knownChannel || Math.random() > knownChannel.faceMorphProbability)
  190. return false;
  191. processFaceSwap(msg, imageAttachment.url).catch(err =>
  192. console.log(`Failed to run faceapp because ${err}`)
  193. );
  194. return true;
  195. }
  196. return false;
  197. },
  198. onDirectMention: (actionsDone, msg, content) => {
  199. if (actionsDone) return false;
  200. let image = msg.attachments.find(v => isValidImage(v.filename));
  201. if (!image) {
  202. if (msg.attachments.size > 0) {
  203. msg.channel.send(
  204. `${msg.author.toString()} Nice, but I can't do anything to it! (Invalid file type)`
  205. );
  206. return true;
  207. }
  208. return false;
  209. }
  210. let processor;
  211. if (content.startsWith("caption this"))
  212. processor = captionFace;
  213. else
  214. processor = morphFaces;
  215. processFaceSwap(
  216. msg,
  217. image.url,
  218. processor,
  219. `${msg.author.toString()} Nice image! I don't see anything interesting, though.`,
  220. `${msg.author.toString()} ${client.emojis.get("505076258753740810").toString()}`
  221. ).catch(err => console.log(`Failed to run faceapp because ${err}`));
  222. return true;
  223. },
  224. commands: [
  225. {
  226. pattern: "caption last image",
  227. action: msg => processLastImage(msg, captionFace)
  228. },
  229. {
  230. pattern: "look at last image",
  231. action: msg => processLastImage(msg, morphFaces)
  232. }]
  233. } as ICommand;