├── .gitignore ├── requirements.txt ├── config.yaml ├── README.md ├── heatmap.py ├── img_processing.py ├── scraper.py └── heatmap.ipynb /.gitignore: -------------------------------------------------------------------------------- 1 | */* -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | numpy 2 | opencv-python 3 | tqdm 4 | pillow 5 | beautifulsoup4 -------------------------------------------------------------------------------- /config.yaml: -------------------------------------------------------------------------------- 1 | timelapse: 2 | image-range: 3 | start: 1648995088 4 | end: 1649113741 5 | frame-granularity: 1 # take every nth image 6 | download-threads: 8 7 | 8 | # Canada is given as an example 9 | place-canvas: 10 | top-left-coordinates: 11 | x: 176 12 | y: 485 13 | dimensions: 14 | width: 68 15 | height: 44 16 | 17 | heatmap: 18 | intensity: 140 19 | decay: 10 20 | 21 | output: 22 | name: "canada" 23 | dimensions: 24 | width: 680 25 | height: 440 26 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # r/Place 2022 heatmap 2 | 3 | The code is open source and free to use, all I ask is you give credit (to either u/DeathByElectives or the repo) if you do post any visualisations. 4 | 5 | If you don't have experience in coding but still want to explore the data (blender, etc) it is available here: https://drive.google.com/file/d/1BSaWgy8okppoEIYT5xodkrJb7-fL0RBm/view?usp=sharing 6 | 7 | ## Running heatmap script 8 | 9 | - Install python 10 | - Install dependences (`pip install -r requirements.txt`) 11 | - Configure `Config.yaml` to specify region of canvas. 12 | - Run `python heatmap.py` 13 | 14 | Scraper code adapted from [Robin Gisler](https://github.com/gislerro/) 15 | 16 | ## Running heatmap notebook 17 | 18 | - Download the image data from [here](https://place.thatguyalex.com/) 19 | - Extract image data from the zip into the top-level directory, so that the `images_` folders are in `./final_v1/` 20 | 21 | - Install Python and the dependences listed in `requirements.txt` (by using `pip install -r requirements.txt`) 22 | 23 | - Open the notebook in your IDE of choice and run 24 | 25 | ## Any pull requests and suggestions are welcome 26 | 27 | - There is plenty to be improved on here, would be a good first project for someone looking to get into image processing. 28 | - Some ideas have been suggested in the issues tab to get someone started 29 | -------------------------------------------------------------------------------- /heatmap.py: -------------------------------------------------------------------------------- 1 | import yaml 2 | import cv2 3 | import numpy as np 4 | 5 | from PIL import Image 6 | from tqdm import tqdm 7 | 8 | import scraper 9 | from img_processing import HeatmapGenerator 10 | 11 | def get_img_pair(img_ids, idx): 12 | return scraper.get_image(img_ids[idx-1]), scraper.get_image(img_ids[idx]) 13 | 14 | with open('config.yaml', 'r') as f: 15 | config = yaml.load(f, Loader=yaml.FullLoader) 16 | 17 | s = config['timelapse']['image-range']['start'] 18 | e = config['timelapse']['image-range']['end'] 19 | g = config['timelapse']['frame-granularity'] 20 | t = config['timelapse']['download-threads'] 21 | 22 | x = config['place-canvas']['top-left-coordinates']['x'] 23 | y = config['place-canvas']['top-left-coordinates']['y'] 24 | dx = config['place-canvas']['dimensions']['width'] 25 | dy = config['place-canvas']['dimensions']['height'] 26 | 27 | intensity = config['heatmap']['intensity'] 28 | decay = config['heatmap']['decay'] 29 | 30 | n = config['output']['name'] 31 | w = config['output']['dimensions']['width'] 32 | h = config['output']['dimensions']['height'] 33 | 34 | ids = scraper.get_image_ids(s, e, g) 35 | scraper.init_fetch_images(ids, t) 36 | 37 | heat_gen = HeatmapGenerator(intensity, decay, [[x, y], [x+dx, y+dy]]) 38 | 39 | fourcc = cv2.VideoWriter_fourcc(*'mp4v') 40 | vid = cv2.VideoWriter(f'{n}.mp4', fourcc, 30.0, (w, h)) 41 | 42 | for idx in tqdm(range(1, len(ids)), desc='Timelapse creation', ascii=True, leave=False): 43 | img1, img2 = get_img_pair(ids, idx) 44 | img1 = img1.crop((x, y, x + dx, y + dy)) 45 | img2 = img2.crop((x, y, x + dx, y + dy)) 46 | 47 | heatmap = heat_gen.generate_heat_map(cv2.cvtColor(np.array(img1), cv2.COLOR_RGB2BGR), cv2.cvtColor(np.array(img2), cv2.COLOR_RGB2BGR)) 48 | cv2.imshow("image", heatmap) 49 | cv2.waitKey(1) 50 | heatmap = Image.fromarray(heatmap).resize((w, h), Image.Resampling.NEAREST) 51 | vid.write(np.array(heatmap)) 52 | 53 | vid.release() 54 | print(f'\nDone, check {n}.mp4') -------------------------------------------------------------------------------- /img_processing.py: -------------------------------------------------------------------------------- 1 | from typing_extensions import Self 2 | import numpy as np 3 | import os 4 | import cv2 5 | 6 | class HeatmapGenerator(): 7 | def __init__(self, intensity, decay, area): 8 | self.intensity = intensity 9 | self.decay = decay 10 | self.heatmap = np.zeros((area[1][1]-area[0][1], area[1][0]-area[0][0], 3)) 11 | 12 | def generate_heat_map(self, img, img2): 13 | 14 | b, g, r = self.heatmap[..., 0], self.heatmap[..., 1], self.heatmap[..., 2] 15 | r_mask = (r > 0) & (g == 0.0) & (b == 0.0) 16 | g_mask = (g > 0.0) & (b == 0.0) 17 | b_mask = (b > 0.0) 18 | 19 | # Decay hotspots quicker 20 | temp_image = self.heatmap[b_mask] 21 | temp_image[:, 0] -= self.decay / 255 22 | self.heatmap[b_mask] = temp_image 23 | temp_image = self.heatmap[g_mask] 24 | temp_image[:, 1] -= self.decay / 255 25 | self.heatmap[g_mask] = temp_image 26 | temp_image = self.heatmap[r_mask] 27 | temp_image[:, 2] -= self.decay / 255 28 | self.heatmap[r_mask] = temp_image 29 | 30 | # Sequential frame colour masks 31 | # (Detects changes for each pixel between sequential images) 32 | b_1, g_1, r_1 = img[..., 0], img[..., 1], img[..., 2] 33 | b_2, g_2, r_2 = img2[..., 0], img2[..., 1], img2[..., 2] 34 | mask = (b_1 == b_2) & (g_1 == g_2) & (r_1 == r_2) 35 | mask = ~mask 36 | 37 | b, g, r = self.heatmap[..., 0], self.heatmap[..., 1], self.heatmap[..., 2] 38 | r_mask = (r < 1.0) 39 | g_mask = (~r_mask) & (g < 1.0) 40 | b_mask = (~r_mask) & (~g_mask) & (b < 1.0) 41 | 42 | # If changes detected increase the brightness 43 | # (Using "Hot" colour map) 44 | # Work around for numpy masking issues (probably a faster way of doing this) 45 | temp_image = self.heatmap[mask & r_mask] 46 | temp_image[:, 2] += self.intensity / 255 47 | temp_image[:, 1] += (temp_image[:, 2] - 1).clip(0, 1) 48 | self.heatmap[mask & r_mask] = temp_image 49 | 50 | 51 | temp_image = self.heatmap[mask & g_mask] 52 | temp_image[:, 1] += self.intensity / 255 53 | temp_image[:, 0] += (temp_image[:, 1] - 1).clip(0, 1) 54 | self.heatmap[mask & g_mask] = temp_image 55 | 56 | 57 | temp_image = self.heatmap[mask & b_mask] 58 | temp_image[:, 0] += self.intensity / 255 59 | self.heatmap[mask & b_mask] = temp_image 60 | 61 | self.heatmap = self.heatmap.clip(0.0, 1.0) 62 | 63 | return (self.heatmap * 255).astype("uint8") -------------------------------------------------------------------------------- /scraper.py: -------------------------------------------------------------------------------- 1 | # Robin Gisler 2 | # https://github.com/gislerro/rplace-cropped-timelapse-creator/blob/main/scraper.py 3 | 4 | import os 5 | import io 6 | import sys 7 | import requests 8 | import queue 9 | import time 10 | 11 | from PIL import Image 12 | from bs4 import BeautifulSoup 13 | from threading import Thread 14 | from tqdm import tqdm 15 | 16 | url = 'https://rplace.space/combined/' 17 | img_url = lambda id: f'{url}/{id}.png' 18 | 19 | cache = 'image_cache' 20 | img_cache = lambda id: f'{cache}/{id}.png' 21 | 22 | def get_image_ids(start_id, end_id, granularity): 23 | res = requests.get(url) 24 | 25 | ids = [] 26 | if res.status_code == 200: 27 | soup = BeautifulSoup(res.text, 'html.parser') 28 | for a in soup.find_all('a'): 29 | id = os.path.splitext(a.get('href'))[0] 30 | try: 31 | id = int(id) 32 | if id > end_id: 33 | break 34 | if id >= start_id: 35 | ids.append(id) 36 | except ValueError: # skip tags that aren't image links 37 | continue 38 | else: 39 | print(f'Could not get image ids from {url}') 40 | sys.exit() 41 | 42 | return ids[::granularity] 43 | 44 | def init_fetch_images(ids, t): 45 | os.makedirs(cache, exist_ok=True) 46 | q = queue.Queue() 47 | for id in ids: 48 | q.put(id) 49 | 50 | for i in range(t): 51 | w = Worker(q, i) 52 | w.setDaemon(True) 53 | w.start() 54 | 55 | class Worker(Thread): 56 | def __init__(self, request_queue, wid): 57 | Thread.__init__(self) 58 | self.queue = request_queue 59 | self.wid = wid 60 | 61 | def run(self): 62 | n = self.queue.qsize() 63 | pbar = tqdm(desc='Image download', 64 | ascii=True, 65 | total=n, 66 | disable= self.wid != 0, 67 | leave=False) 68 | while True: 69 | if self.queue.empty(): 70 | break 71 | id = self.queue.get() 72 | try: 73 | img = Image.open(img_cache(id)) 74 | img.load() 75 | except: 76 | res = requests.get(img_url(id), stream=True) 77 | if res.status_code == 200: 78 | img = Image.open(io.BytesIO(res.content)) 79 | img.save(img_cache(id), 'PNG') 80 | else: 81 | print(f'Could not download image {id}') 82 | sys.exit() 83 | if self.wid == 0: 84 | pbar.n = n - self.queue.qsize() 85 | pbar.refresh() 86 | self.queue.task_done() 87 | 88 | 89 | def get_image(id): 90 | while True: 91 | try: 92 | img = Image.open(img_cache(id)) 93 | img.load() 94 | break 95 | except: 96 | time.sleep(1) 97 | return img -------------------------------------------------------------------------------- /heatmap.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "code", 5 | "execution_count": null, 6 | "metadata": {}, 7 | "outputs": [], 8 | "source": [ 9 | "# MAKE SURE YOU HAVE THE DATA SET DOWNLOADED\n", 10 | "import os\n", 11 | "os.environ[\"OPENCV_IO_ENABLE_OPENEXR\"]=\"1\"\n", 12 | "import cv2\n", 13 | "import numpy as np" 14 | ] 15 | }, 16 | { 17 | "cell_type": "code", 18 | "execution_count": null, 19 | "metadata": {}, 20 | "outputs": [], 21 | "source": [ 22 | "# location of extracted images\n", 23 | "image_dir = \"./final_v1\" \n", 24 | "save_pth = \"./heatmap\"\n", 25 | "\n", 26 | "show=True\n", 27 | "save=True\n", 28 | "\n", 29 | "# The area to process \n", 30 | "# [[Top left xy][Bottom right xy]],\n", 31 | "# (default [[0, 0][999, 999]])\n", 32 | "area = [\n", 33 | " [0, 0], \n", 34 | " [999, 999]\n", 35 | "]\n", 36 | "\n", 37 | "# Intensity: how intense each pixel change is (scale goes from 0-255)\n", 38 | "# Decay: how much decay between each frame \n", 39 | "intensity = 140\n", 40 | "decay = 10" 41 | ] 42 | }, 43 | { 44 | "cell_type": "code", 45 | "execution_count": null, 46 | "metadata": {}, 47 | "outputs": [], 48 | "source": [ 49 | "# Helper function to resize small images\n", 50 | "\n", 51 | "def ResizeWithAspectRatio(image, width=None, height=None, inter=cv2.INTER_AREA):\n", 52 | " dim = None\n", 53 | " (h, w) = image.shape[:2]\n", 54 | "\n", 55 | " if width is None and height is None:\n", 56 | " return image\n", 57 | " if width is None:\n", 58 | " r = height / float(h)\n", 59 | " dim = (int(w * r), height)\n", 60 | " else:\n", 61 | " r = width / float(w)\n", 62 | " dim = (width, int(h * r))\n", 63 | "\n", 64 | " return cv2.resize(image, dim, interpolation=inter)" 65 | ] 66 | }, 67 | { 68 | "cell_type": "code", 69 | "execution_count": null, 70 | "metadata": {}, 71 | "outputs": [], 72 | "source": [ 73 | "dirs = [x[0] for x in os.walk(image_dir)]\n", 74 | "# print(dirs)\n", 75 | "img_paths = []\n", 76 | "for folder in dirs: \n", 77 | " for file in os.listdir(folder):\n", 78 | " filename = os.fsdecode(file)\n", 79 | " if filename.endswith(\"png\"): \n", 80 | " img_paths.append(os.path.join(f\"{folder}/\", filename))\n", 81 | "\n", 82 | "img_paths.sort(key = lambda x: x.split(\"/\")[-1][:-4])" 83 | ] 84 | }, 85 | { 86 | "cell_type": "code", 87 | "execution_count": null, 88 | "metadata": {}, 89 | "outputs": [], 90 | "source": [ 91 | "# Cell to get location of specific points on the image (useful for the area config param)\n", 92 | "\n", 93 | "def onMouse(event, x, y, flags, param):\n", 94 | " if event == cv2.EVENT_LBUTTONDOWN:\n", 95 | " print('x = %d, y = %d'%(x, y))\n", 96 | "\n", 97 | "cv2.imshow(\"image\", cv2.imread(img_paths[1000]))\n", 98 | "cv2.namedWindow('image')\n", 99 | "cv2.setMouseCallback('image', onMouse)\n", 100 | "cv2.waitKey(0)\n" 101 | ] 102 | }, 103 | { 104 | "cell_type": "code", 105 | "execution_count": null, 106 | "metadata": {}, 107 | "outputs": [], 108 | "source": [ 109 | "img3 = np.zeros((area[1][1]-area[0][1], area[1][0]-area[0][0], 3))\n", 110 | "\n", 111 | "if not os.path.isdir(save_pth):\n", 112 | " os.mkdir(save_pth)\n", 113 | " os.mkdir(save_pth + \"/0\")\n", 114 | " os.mkdir(save_pth + \"/1\")\n", 115 | " os.mkdir(save_pth + \"/2\")\n", 116 | " os.mkdir(save_pth + \"/3\")\n", 117 | "\n", 118 | "for i, path in enumerate(img_paths[1:]):\n", 119 | " print(f\"{i} / {len(img_paths)}\", end=\"\\r\")\n", 120 | " img = cv2.imread(img_paths[i])\n", 121 | " img2 = cv2.imread(path)\n", 122 | " \n", 123 | " if img_paths[i+1].split(\"/\")[-1][0] != img_paths[i].split(\"/\")[-1][0]: \n", 124 | " continue\n", 125 | "\n", 126 | " img = img[area[0][1]:area[1][1], area[0][0]:area[1][0]]\n", 127 | " img2 = img2[area[0][1]:area[1][1], area[0][0]:area[1][0]]\n", 128 | " \n", 129 | " # Sequential frame colour masks\n", 130 | " # (Detects changes for each pixel between sequential images)\n", 131 | " b_1, g_1, r_1 = img[..., 0], img[..., 1], img[..., 2]\n", 132 | " b_2, g_2, r_2 = img2[..., 0], img2[..., 1], img2[..., 2]\n", 133 | " mask = (b_1 == b_2) & (g_1 == g_2) & (r_1 == r_2)\n", 134 | " mask = ~mask\n", 135 | "\n", 136 | " b, g, r = img3[..., 0], img3[..., 1], img3[..., 2]\n", 137 | " r_mask = (r < 1.0)\n", 138 | " g_mask = (~r_mask) & (g < 1.0)\n", 139 | " b_mask = (~r_mask) & (~g_mask) & (b < 1.0)\n", 140 | "\n", 141 | " # If changes detected increase the brightness \n", 142 | " # (Using \"Hot\" colour map)\n", 143 | " # Work around for numpy masking issues (probably a faster way of doing this)\n", 144 | " temp_image = img3[mask & r_mask]\n", 145 | " temp_image[:, 2] += intensity / 255\n", 146 | " temp_image[:, 1] += (temp_image[:, 2] - 1).clip(0, 1)\n", 147 | " img3[mask & r_mask] = temp_image\n", 148 | "\n", 149 | "\n", 150 | " temp_image = img3[mask & g_mask]\n", 151 | " temp_image[:, 1] += intensity / 255\n", 152 | " temp_image[:, 0] += (temp_image[:, 1] - 1).clip(0, 1)\n", 153 | " img3[mask & g_mask] = temp_image\n", 154 | "\n", 155 | "\n", 156 | " temp_image = img3[mask & b_mask]\n", 157 | " temp_image[:, 0] += intensity / 255\n", 158 | " img3[mask & b_mask] = temp_image\n", 159 | "\n", 160 | " img3 = img3.clip(0.0, 1.0)\n", 161 | " \n", 162 | "\n", 163 | "\n", 164 | " if img3.shape[1] < 300:\n", 165 | " if save: \n", 166 | " img3 = ResizeWithAspectRatio(img3)\n", 167 | " alpha = np.sum(img3, axis=-1) > 0\n", 168 | " cv2.imwrite(f\"{save_pth}/{tile}-{i}.exr\", np.dstack((img3, alpha)).astype(\"float32\"))\n", 169 | " if show: \n", 170 | " cv2.imshow(\"Heatmap\", ResizeWithAspectRatio(img3.astype(\"float32\")))\n", 171 | " cv2.waitKey(1)\n", 172 | " else:\n", 173 | " if show: \n", 174 | " \n", 175 | "\n", 176 | " cv2.imshow(f\"Heatmap\", img3)\n", 177 | " cv2.waitKey(1)\n", 178 | " if save: \n", 179 | " tile = img_paths[i].split(\"/\")[-1][0]\n", 180 | " file_name = img_paths[i].split(\"/\")[-1][:-4]\n", 181 | " cv2.imwrite(f\"{save_pth}/{tile}/{file_name}.exr\", img3.astype(\"float32\"))\n", 182 | " \n", 183 | "\n", 184 | " \n", 185 | " b, g, r = img3[..., 0], img3[..., 1], img3[..., 2]\n", 186 | " r_mask = (r > 0) & (g == 0.0) & (b == 0.0)\n", 187 | " g_mask = (g > 0.0) & (b == 0.0)\n", 188 | " b_mask = (b > 0.0)\n", 189 | "\n", 190 | " # Decay hotspots quicker \n", 191 | " temp_image = img3[b_mask]\n", 192 | " temp_image[:, 0] -= decay / 255\n", 193 | " img3[b_mask] = temp_image\n", 194 | " temp_image = img3[g_mask]\n", 195 | " temp_image[:, 1] -= decay / 255\n", 196 | " img3[g_mask] = temp_image\n", 197 | " temp_image = img3[r_mask]\n", 198 | " temp_image[:, 2] -= decay / 255\n", 199 | " img3[r_mask] = temp_image" 200 | ] 201 | } 202 | ], 203 | "metadata": { 204 | "interpreter": { 205 | "hash": "f3cd2f557b83b6cde2c3b8adaea9ca90aaf05685b7ca3f8036a7de4ebfa5124c" 206 | }, 207 | "kernelspec": { 208 | "display_name": "Python 3.9.7 64-bit", 209 | "language": "python", 210 | "name": "python3" 211 | }, 212 | "language_info": { 213 | "codemirror_mode": { 214 | "name": "ipython", 215 | "version": 3 216 | }, 217 | "file_extension": ".py", 218 | "mimetype": "text/x-python", 219 | "name": "python", 220 | "nbconvert_exporter": "python", 221 | "pygments_lexer": "ipython3", 222 | "version": "3.10.4" 223 | }, 224 | "orig_nbformat": 4 225 | }, 226 | "nbformat": 4, 227 | "nbformat_minor": 2 228 | } 229 | --------------------------------------------------------------------------------