הח״כ שלא צחק – מזהים רגשות בתמונות של חברי הכנסת היוצאת

הפעם החלטתי לפנות למכנה המשותף הנמוך ביותר בין הקוראים הפוטנציאלים של הבלוג: פוליטיקה. החלטתי להריץ חיפוש תמונות בגוגל על כל הפוליטיקאים שכיהנו בכנסת העשרים וארבע, להוריד את תוצאות החיפוש הראשונות ולהריץ עליהם אלגוריתם זיהוי רגשות. לאחר מכן בניתי אתר קטן המציג את התוצאות ועונה על שאלות מרתקות כמו:
- מי הח״כ הכי זועף?
- הכי שמח? והכי פחות שמח?
- איזה רגשות מראה נפתלי בנט בתמונות שלו?
- האם זה נכון שיעקב ליצמן הוא חבר הכנסת הכי קול?
* אזהרה: אין הכוונה שהח״כ הוא באמת הכי זועף/שמח/רגוע וכו׳ אלא שעל פי התמונות שמגיעות למקום הראשון בגוגל + אלגוריתם זיהוי הרגשות של אמזון הח״כ קיבל את הציון הנ״ל.
חלק ראשון – מורידים את התמונות
קודם כל אנחנו צריכים רשימה של חברי הכנסת המכהנים (במידה ואתם לא כתבים פוליטיים תופתעו לגלות שהיו 155 ח״כים בכנסת היוצאת – נכון לעכשיו…). אני העתקתי את הרשימה מהערך בויקיפדיה ובעזרת סקריפט קצר הפכתי את זה לקובץ JSON של שם חבר כנסת למפלגה.
בשלב הבא אנחנו מבצעים אוטומציה פשוטה של תהליך פתיחת הדפדפן, חיפוש תמונות בגוגל עבור כל חבר כנסת ושמירת התמונה בקובץ מקומי. את זה ביצעתי בעזרת הקוד הבא:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 |
let activeDownload = 0 const MAX_ACTIVE_DOWNLOAD = 30 const sleep = async (ms) => new Promise(resolve => setTimeout(resolve, ms)); const donwloadImages = async (name) => { while (activeDownload >= MAX_ACTIVE_DOWNLOAD) { await sleep(1000) } activeDownload++ const search_url = 'https://www.google.com/search?site=&tbm=isch&source=hp&biw=1873&bih=990&' + 'q=' + name; console.log(`Running google image search on: ${search_url}`); const browser = await puppeteer.launch({ headless: false, args: [`--window-size=1920,1080`], defaultViewport: { width: 1920, height: 1080 } }); const page = await browser.newPage(); await page.goto(search_url); const folder = "images/" + name if (!fs.existsSync(folder)) { fs.mkdirSync(folder); } const previewimagexpath = '/html/body/div[2]/c-wiz/div[3]/div[2]/div[3]/div/div/div[3]/div[2]/c-wiz/div/div[1]/div[1]/div[3]/div/a/img' let image_index = 0; for (let i = 1; i < 100; i++) { let imagexpath = '/html/body/div[2]/c-wiz/div[3]/div[1]/div/div/div/div[1]/div[1]/span/div[1]/div[1]/div[' + i + ']/a[1]/div[1]/img' const elements = await page.$x(imagexpath) if (elements.length === 0) continue await elements[0].click(); await page.waitForTimeout(1500); const image = await page.$x(previewimagexpath); const image_source_path = '/html/body/div[2]/c-wiz/div[3]/div[2]/div[3]/div/div/div[3]/div[2]/c-wiz/div/div[1]/div[3]/div[1]/a[1]/div/div' const spanElement = await page.$x(image_source_path); const image_source_path_text = await (await spanElement[0].getProperty('textContent')).jsonValue(); let d = await image[0].getProperty('src') const url = d._remoteObject.value try { const path = `${folder}/${image_index}_${image_source_path_text}.png`; if (url.indexOf('http') === 0) { await downloadFile(url, path); image_index++; } else if (url.indexOf('data') === 0) { const image_data = url.replace(/^data:image\/png;base64,/, '').replace.replace(/^data:image\/jpeg;base64,/, '') fs.writeFileSync(path, Buffer.from(image_data), 'base64'); image_index++; } else { console.log('Invalid URL', url); } if (image_index >= MAX_IMAGE_TO_DOWNLOAD) { break; } } catch (err) {} } await browser.close(); activeDownload-- }; let main = async () => { for (const name of kenest_2022) { donwloadImages(name) } } main() |
בתחילת הקוד אני דואג למקבל את התהליך כך שכל פעם ירוצו 20 חברי כנסת במקביל (ולא כולם בבת אחת) ע״י מנגנון הגנה פשוט של משתנה activeDownload, זה עובד בjavascript בגלל שהמערכת עובדת עם events ולא עם thread ולכן לא צריך מנעול על משתנה פרימיטיבי. לאחר מכן אני בונה את הURL של חיפוש תמונה בגוגל, פותח דפדפן בעזרת puppeteer וניגש לURL ואז עובר על התמונות בעזרת הxpath לוחץ על כל תמונה ומחכה כמה שניות עד שהיא תפתח בגדול לפניי שאני מוריד את התמונה הגדולה ושומר בשם של הקובץ את המקור של התמונה.
התמונות עצמם מתחלקות לתמונות של base64 עבורם יש לי כבר את המידע ואני יכול לשחק מעט עם הסטרינג ולשמור את המידע ולתמונות עם מקור URL אותם אני מוריד ע״י פונקציית עזר שמורידה מחדש את התמונה.
הבעיה העיקרית שנתקלתי בהורדה זה שעבור חבר הכנסת אלי כהן חיפוש רגיל הביא תוצאות של אלי כהן המרגל, פתרתי זאת ע״י שינוי מילות החיפוש ל: ״חבר הכנסת אלי כהן״.
חלק שני – מזהים את הרגשות
בשלב זה אני נותן לענן של אמזון לעשות את העבודה ואני רק משלם לו על שימוש פר תמונה, כך שעבור 145 חברי כנסת שלכל אחד הורדתי 30 תמונות כך שבסה״כ ניתחתי 4,350 תמונות והמחיר ששלימתי הוא באיזור $4.35 (או בקצרה דולר על כל אלף תמונות). כדי לעשות את זה בשלב הראשון אני מעלה את כל התמונות לbucket בs3 ולאחר מכן אני מריץ סקריפט קצר של זיהוי על התמונות ושומר את התוצאות בקובץ JSON.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 |
import { RekognitionClient, DetectFacesCommand } from "@aws-sdk/client-rekognition"; import fs from 'fs' const REGION = "eu-west-1"; const rekogClient = new RekognitionClient({ region: REGION, 'profile': 'personal' }); const BUCKET = 'election-2022' let activeDownload = 0 const MAX_ACTIVE_DOWNLOAD = 10 const sleep = async (ms) => new Promise(resolve => setTimeout(resolve, ms)); const detectFace = async (photoName) => { while (activeDownload >= MAX_ACTIVE_DOWNLOAD) { await sleep(1000) } activeDownload++ console.log(`Detecting faces in ${photoName}`) try { // Set params const params = { Image: { S3Object: { Bucket: BUCKET, Name: photoName }, }, Attributes: ['ALL'] } const response = await rekogClient.send(new DetectFacesCommand(params)); fs.writeFileSync('images/' + photoName + '.json', JSON.stringify(response, null, 2)); } catch (err) { console.log("Error", err); } activeDownload-- }; const folders = fs.readdirSync('images'); for (const folder of folders) { if (!fs.lstatSync(`images/${folder}`).isDirectory()) continue const files = fs.readdirSync(`images/${folder}`); for (const file of files) { if (!file.endsWith('.png')) continue detectFace(`${folder}/${file}`); } } |
סך הכל מדובר בביצוע של קריאות פשוטות לRekognitionClient מהaws-sdk. כאשר תמונה כמו:

אנחנו מקבלים את הניתוח הבא (השורות המודגשות הם הניתוח של הרגשות):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 |
{ "FaceDetails": [ { "Emotions": [ { "Confidence": 95.79679107666016, "Type": "HAPPY" }, { "Confidence": 6.361562728881836, "Type": "SURPRISED" }, { "Confidence": 5.897488594055176, "Type": "FEAR" }, { "Confidence": 2.1936233043670654, "Type": "SAD" }, { "Confidence": 1.7069482803344727, "Type": "CONFUSED" }, { "Confidence": 1.5583518743515015, "Type": "CALM" }, { "Confidence": 0.2663203775882721, "Type": "DISGUSTED" }, { "Confidence": 0.24035526812076569, "Type": "ANGRY" } ], "AgeRange": { "High": 40, "Low": 30 }, "Beard": { "Confidence": 99.90653228759766, "Value": true }, "BoundingBox": { "Height": 0.38361939787864685, "Left": 0.37878671288490295, "Top": 0.21111132204532623, "Width": 0.18365316092967987 }, "Confidence": 99.99927520751953, "Eyeglasses": { "Confidence": 97.49884033203125, "Value": false }, "EyesOpen": { "Confidence": 76.9692611694336, "Value": true }, "Gender": { "Confidence": 99.82471466064453, "Value": "Male" }, "Landmarks": [ { "Type": "eyeLeft", "X": 0.4154231548309326, "Y": 0.3622923791408539 }, { "Type": "eyeRight", "X": 0.4971168637275696, "Y": 0.3642192780971527 }, { "Type": "mouthLeft", "X": 0.4218120872974396, "Y": 0.48780590295791626 }, { "Type": "mouthRight", "X": 0.49001431465148926, "Y": 0.48981720209121704 }, { "Type": "nose", "X": 0.44376489520072937, "Y": 0.4322037696838379 }, { "Type": "leftEyeBrowLeft", "X": 0.38943642377853394, "Y": 0.3313894271850586 }, { "Type": "leftEyeBrowRight", "X": 0.4282922148704529, "Y": 0.3256607949733734 }, { "Type": "leftEyeBrowUp", "X": 0.40694165229797363, "Y": 0.31821075081825256 }, { "Type": "rightEyeBrowLeft", "X": 0.47488853335380554, "Y": 0.3263363242149353 }, { "Type": "rightEyeBrowRight", "X": 0.5311156511306763, "Y": 0.33418411016464233 }, { "Type": "rightEyeBrowUp", "X": 0.5008023977279663, "Y": 0.3197394907474518 }, { "Type": "leftEyeLeft", "X": 0.4024614989757538, "Y": 0.36100369691848755 }, { "Type": "leftEyeRight", "X": 0.43159836530685425, "Y": 0.36370202898979187 }, { "Type": "leftEyeUp", "X": 0.41454997658729553, "Y": 0.3559785783290863 }, { "Type": "leftEyeDown", "X": 0.4157050549983978, "Y": 0.3677128255367279 }, { "Type": "rightEyeLeft", "X": 0.48088255524635315, "Y": 0.3647749722003937 }, { "Type": "rightEyeRight", "X": 0.5130384564399719, "Y": 0.36356908082962036 }, { "Type": "rightEyeUp", "X": 0.49649572372436523, "Y": 0.3577931523323059 }, { "Type": "rightEyeDown", "X": 0.4965515732765198, "Y": 0.36963194608688354 }, { "Type": "noseLeft", "X": 0.4359099864959717, "Y": 0.4442448318004608 }, { "Type": "noseRight", "X": 0.4660222828388214, "Y": 0.4449338912963867 }, { "Type": "mouthUp", "X": 0.4498591125011444, "Y": 0.47461503744125366 }, { "Type": "mouthDown", "X": 0.4515245258808136, "Y": 0.5121335387229919 }, { "Type": "leftPupil", "X": 0.4154231548309326, "Y": 0.3622923791408539 }, { "Type": "rightPupil", "X": 0.4971168637275696, "Y": 0.3642192780971527 }, { "Type": "upperJawlineLeft", "X": 0.38620465993881226, "Y": 0.3596023619174957 }, { "Type": "midJawlineLeft", "X": 0.40054723620414734, "Y": 0.49642807245254517 }, { "Type": "chinBottom", "X": 0.4569242596626282, "Y": 0.5764694213867188 }, { "Type": "midJawlineRight", "X": 0.5444665551185608, "Y": 0.4993422031402588 }, { "Type": "upperJawlineRight", "X": 0.5637227296829224, "Y": 0.36274126172065735 } ], "MouthOpen": { "Confidence": 89.56613159179688, "Value": false }, "Mustache": { "Confidence": 87.45152282714844, "Value": false }, "Pose": { "Pitch": 4.275090217590332, "Roll": 0.21992246806621552, "Yaw": -10.848104476928711 }, "Quality": { "Brightness": 94.12799835205078, "Sharpness": 94.08262634277344 }, "Smile": { "Confidence": 90.20765686035156, "Value": true }, "Sunglasses": { "Confidence": 99.99665832519531, "Value": false } } ], "$metadata": { "httpStatusCode": 200, "requestId": "f2438f25-c5e2-4247-9490-72c57227e380", "attempts": 1, "totalRetryDelay": 0 } } |
כאשר עבור כל רגש אנחנו מקבלים מספר בין 0 ל100 המייצג עד כמה הרגש זוהה בתמונה, לדוגמא בתמונה הזאת הרגש הכי חזק הוא שמחה שזוהה בוודאות של 95.79%. חשוב לציין שחיבור מידת הוודאות של כל הרגשות לא מגיע ל100 שכן אין תלות בין הזיוהיים השונים (בדרך כלל זה מגיע למספר באזור 112-114). שימו לב שמלבד הרגשות אנחנו מקבלים גם הרבה מידע על הגיל, האם יש זקן, משקפי שמש, מיקום הפרצוף והחלקים של הפרצוף בתמונה וכו׳.
חלק שלישי – תצוגה
אחריי שהורדתי את המידע כתבתי סקריפט קצר שהכין טבלת אקסל קטנה המראה את ממוצע הרגשות של כל חבר כנסת. ולאחר ששיחקתי קצת עם הטבלה והסתכלתי על התמונות הבנתי שיש כאן פוטנציאל לפלטפורמה אינטראקטיבית קטנה המציגה את הטבלה עם אפשרויות למיון, לצורך כך החלטתי לכתוב אתר React קטן.
התחלתי בלהוציא את כל המידע שאני צריך לקובץ JSON בו יהיה המידע של הטבלה (שורות ועמודות) והתמונות עבור כל חבר כנסת כולל ערכי הרגשות שהאלגוריתם זיהה. כתבתי quick & dirty סקריפט שמוציא את זה:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 |
// run faces analyter on AWS rekognition import fs from 'fs' const nameToParty = JSON.parse(fs.readFileSync('nameToParty.json', 'utf8')) const ROWS = [] const folders = fs.readdirSync('images'); const EMOTION = new Set() const imageData = {} for (const folder of folders) { // check if it is a folder if (!fs.lstatSync(`images/${folder}`).isDirectory()) continue const files = fs.readdirSync(`images/${folder}`); const data = {} let numberOfSingleFaceImage = 0 const imgData = [] for (const file of files) { if (!file.endsWith('.json')) continue const imageJsonData = JSON.parse(fs.readFileSync(`images/${folder}/${file}`, 'utf8')); const numberOfFaces = imageJsonData.FaceDetails.length; if(numberOfFaces !== 1) continue const imageEmotion = {} for (let i in imageJsonData.FaceDetails[0].Emotions) { let { Type, Confidence } = imageJsonData.FaceDetails[0].Emotions[i]; EMOTION.add(Type) if(!data[Type]) data[Type] = 0 Confidence = Math.round(Confidence * 100) / 100 data[Type] += Confidence imageEmotion[Type] = Confidence } const url = `https://election-2022.s3.eu-west-1.amazonaws.com/${folder}/${file.replace('.json', '')}` imgData.push({ url, ...imageEmotion }) numberOfSingleFaceImage++ } imageData[folder] = imgData // console.log(data) for (let i in data) { data[i] = data[i] / numberOfSingleFaceImage // round number to 2 decimal places data[i] = Math.round(data[i] * 100) / 100 } data.name = folder data.party = nameToParty[folder] || "Unknown" ROWS.push(data) } const EMOTION_LIST = [...EMOTION] const COLS = [{ label: 'name', field: 'name', },{ label: 'party', field: 'party', }] for(const emotion of EMOTION_LIST) { COLS.push({ label: emotion, field: emotion, }) } fs.writeFileSync('basic-stat/src/basicStatTableData_.json', JSON.stringify({ rows: ROWS, columns: COLS, imageData }, null, 2)); |
בסקריפט עבור כל חבר כנסת אני עובר על כל קבצי ניתוח התמונה שלו, מדלג על כל תמונה שזוהה בא יותר מפרצוף אחד, מעגל את תוצאות הרגש, שומר את המידע עבור כל תמונה, מחשב ממוצע רגשות ומוסיף פרמטר של מפלגה (כדאי שיהיה ניתן לסנן בחיפוש רק את חבריי מר״צ). לאחר מכן אני בונה את העמודות ושומר את הכל בקובץ JSON.
את דף האינטרנט עצמו פיתחתי בעזרת mdbreact שהוא מעטפת נחמדה של bootstrap על React
(יש להם אחלה כלים ליצור טבלה הניתנת למיון במינימום קוד). והשתמשתי בreact-chart-js בשביל לשרטט תרשים PIEשל רגשות. מכיוון שהמטרה של הפוסט הנוכחי היא זיהוי רגשות ומשחק עם תמונות ולא פיתוח ווב בחרתי לא לעבור על הקוד, למעוניינים ניתן לראות את קוד המקור כאן.
מוזמנים להיכנס ולשחק באתר הסופי בכתובת kneset24.route42.co.il
רעיונות לעתיד
- להוריד נתונים על כנסות עבר ולראות כיצד הרגשות שהפוליטיקאים מראים בתמונות משתנות (אולי בליכוד של שנות ה70 היו יותר עצובים?)
- הורדתי הרבה תמונות של פוליטיקאים בהם הם מצולמים (או שהוספו בעריכה) עם פוליטקאים אחרים – בניתוח הזה פשוט התעלמתי מהם, אבל זה יכול להיות מעניין ליצור גרף קטן שמראה איזה פוליטיקאי מצטלם עם איזה פוליטיקאי אחר.
- האם כלי תקשורת שונים בוחרים תמונות שונות של פוליטיקאים?
- ניתוח רגשות פר מפלגה.
תודה מיוחדת לפרופסור לב מוצ'ניק מבית הספר למנהל עסקים באונברסיטה העברית ששיחות איתו נתנו לי השראה לפרויקט.
כתיבת תגובה