Specimen5423 commited on
Commit
8ed5a9b
·
1 Parent(s): 422c9a7

Add initial data and notebooks

Browse files
.gitattributes CHANGED
@@ -33,3 +33,6 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ posts_by_tag.feather filter=lfs diff=lfs merge=lfs -text
37
+ tags_by_post.feather filter=lfs diff=lfs merge=lfs -text
38
+ tags.feather filter=lfs diff=lfs merge=lfs -text
Building Data e6.ipynb ADDED
@@ -0,0 +1,276 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "cells": [
3
+ {
4
+ "cell_type": "markdown",
5
+ "metadata": {},
6
+ "source": [
7
+ "Preprocesses e621's [data exports](https://e621.net/db_export/) and stores them in feather files. The feather format was chosen because it loads quickly!\n",
8
+ "\n",
9
+ "Usage notes:\n",
10
+ "* Feel free to change `INPUT_FOLDER` and `OUTPUT_FOLDER` to anywhere you want to store your data.\n",
11
+ "* `DATE` is whatever date is on your input files.\n",
12
+ "* Files will only be generated if they don't already exist. Delete them if you want to regenerate."
13
+ ]
14
+ },
15
+ {
16
+ "cell_type": "code",
17
+ "execution_count": null,
18
+ "metadata": {},
19
+ "outputs": [],
20
+ "source": [
21
+ "import pandas\n",
22
+ "import os\n",
23
+ "from tqdm.notebook import tqdm\n",
24
+ "tqdm.pandas()\n",
25
+ "\n",
26
+ "INPUT_FOLDER = \"H:/Data/TagSuggest/e621_metadata\"\n",
27
+ "OUTPUT_FOLDER = \"H:/Data/TagSuggest/e621_dataframes\"\n",
28
+ "DATE = \"2023-08-23\"\n",
29
+ "\n",
30
+ "os.makedirs(OUTPUT_FOLDER, exist_ok=True)"
31
+ ]
32
+ },
33
+ {
34
+ "cell_type": "markdown",
35
+ "metadata": {},
36
+ "source": [
37
+ "The first thing to process is the tags themselves, since we'll be using their IDs\n",
38
+ "* `tag_id` - An arbitrary number from e621's database. Very useful.\n",
39
+ "* `name` - The tag!\n",
40
+ "* `category` - A number to say whether it's an artist, species, and so on. Constants for these are defined elsewhere, this notebook doesn't need to know them.\n",
41
+ "* `post_count` - The approximate number of posts the tag has. It's not perfectly aligned with the actual post data, but it's close enough for most purposes."
42
+ ]
43
+ },
44
+ {
45
+ "cell_type": "code",
46
+ "execution_count": null,
47
+ "metadata": {},
48
+ "outputs": [],
49
+ "source": [
50
+ "tags_file = f\"{OUTPUT_FOLDER}/tags.feather\"\n",
51
+ "if os.path.exists(tags_file):\n",
52
+ " tags = pandas.read_feather(tags_file)\n",
53
+ "else:\n",
54
+ " tags = pandas.read_csv(f\"{INPUT_FOLDER}/tags-{DATE}.csv.gz\", na_values=[], keep_default_na=False).astype({\"name\":\"string\"}).rename(columns={\"id\": \"tag_id\"}).reset_index(drop=True)\n",
55
+ " tags.to_feather(tags_file)\n",
56
+ "tags.info()"
57
+ ]
58
+ },
59
+ {
60
+ "cell_type": "code",
61
+ "execution_count": null,
62
+ "metadata": {},
63
+ "outputs": [],
64
+ "source": [
65
+ "tags_by_name = tags.copy(deep=True)\n",
66
+ "tags_by_name.set_index(\"name\", inplace=True)\n",
67
+ "tags_by_name.info()"
68
+ ]
69
+ },
70
+ {
71
+ "cell_type": "markdown",
72
+ "metadata": {},
73
+ "source": [
74
+ "This part takes a couple minutes! There are about 4 million posts to go through, and each one has the tags listed in string format, so they have to be parsed and translated to IDs for more compact storage. The progress bar is based on exactly four million posts, which is low now, but it's not worth actually counting the lines. Two dataframes are generated:\n",
75
+ "\n",
76
+ "* The posts file contains most of the post data.\n",
77
+ " * `post_id` - From e621. Used for linking to the other dataframe.\n",
78
+ " * `rating` - Whether the post is safe, questionable, or explicit. Handy if you want to generate SFW wildcards.\n",
79
+ " * `score` - The overall user score of the post, if you're curious. Score doesn't necessarily correlate to aesthetic quality; posts can be highly upvoted because of their content or themes irrespective of their art style.\n",
80
+ " * `up_score` - The upvote component of the score. Just guessing, but people probably upvote and downvote for totally different reasons, so it could be useful.\n",
81
+ " * `down_score` - The downvote component of the score as a negative. If it's big, it's probably an unpopular niche kink or a political meme or something.\n",
82
+ "* The post tags file stores the links between posts and tags as numbers. It's surprisingly large."
83
+ ]
84
+ },
85
+ {
86
+ "cell_type": "code",
87
+ "execution_count": null,
88
+ "metadata": {},
89
+ "outputs": [],
90
+ "source": [
91
+ "post_tags_file = f\"{OUTPUT_FOLDER}/post_tags.feather\"\n",
92
+ "posts_file = f\"{OUTPUT_FOLDER}/posts.feather\"\n",
93
+ "if os.path.exists(post_tags_file) and os.path.exists(posts_file):\n",
94
+ " post_tags = pandas.read_feather(post_tags_file)\n",
95
+ " posts = pandas.read_feather(posts_file)\n",
96
+ "else:\n",
97
+ " post_tags_parts = []\n",
98
+ " posts_parts = []\n",
99
+ " with pandas.read_csv(f\"{INPUT_FOLDER}/posts-{DATE}.csv.gz\", usecols=[\"id\", \"tag_string\", \"is_deleted\", \"is_pending\", \"rating\", \"score\", \"up_score\", \"down_score\"], chunksize=100_000) as reader:\n",
100
+ " progress = tqdm(total=4_000_000)\n",
101
+ " for posts in reader:\n",
102
+ " post_count = len(posts)\n",
103
+ " posts: pandas.DataFrame\n",
104
+ " posts = posts[posts[\"is_deleted\"] == \"f\"]\n",
105
+ " posts = posts[posts[\"is_pending\"] == \"f\"]\n",
106
+ " posts = posts.rename(columns={\"id\": \"post_id\"})\n",
107
+ " posts_parts.append(posts[[\"post_id\", \"rating\", \"score\", \"up_score\", \"down_score\"]].astype({\"rating\":\"string\"}))\n",
108
+ " posts = posts[[\"post_id\", \"tag_string\"]].set_index(\"post_id\")\n",
109
+ " posts = posts.apply(lambda x: x.str.split(' ')).explode(\"tag_string\")\n",
110
+ " posts = posts.join(tags_by_name, on=\"tag_string\")[[\"tag_id\"]].reset_index()\n",
111
+ " post_tags_parts.append(posts[[\"post_id\", \"tag_id\"]])\n",
112
+ " progress.update(post_count)\n",
113
+ " post_tags = pandas.concat(post_tags_parts)\n",
114
+ " post_tags.reset_index(drop=True, inplace=True)\n",
115
+ " post_tags.to_feather(post_tags_file)\n",
116
+ " posts = pandas.concat(posts_parts)\n",
117
+ " posts.reset_index(drop=True, inplace=True)\n",
118
+ " posts.to_feather(posts_file)\n",
119
+ "print(\"\\npost_tags\")\n",
120
+ "post_tags.info()\n",
121
+ "print(\"\\nposts\")\n",
122
+ "posts.info()"
123
+ ]
124
+ },
125
+ {
126
+ "cell_type": "markdown",
127
+ "metadata": {},
128
+ "source": [
129
+ "We also generate and store two different ways of looking at the `post_tags` frame, because it's a lot faster to cache this once than to join a many-to-many frame that size for every single query. This can also take a few minutes."
130
+ ]
131
+ },
132
+ {
133
+ "cell_type": "code",
134
+ "execution_count": null,
135
+ "metadata": {},
136
+ "outputs": [],
137
+ "source": [
138
+ "\n",
139
+ "posts_by_tag_file = f\"{OUTPUT_FOLDER}/posts_by_tag.feather\"\n",
140
+ "if os.path.exists(posts_by_tag_file):\n",
141
+ " posts_by_tag = pandas.read_feather(posts_by_tag_file)\n",
142
+ "else:\n",
143
+ " posts_by_tag = post_tags.groupby(\"tag_id\").progress_aggregate(list)\n",
144
+ " posts_by_tag.reset_index(inplace=True)\n",
145
+ " posts_by_tag.to_feather(posts_by_tag_file)\n",
146
+ "posts_by_tag.info()"
147
+ ]
148
+ },
149
+ {
150
+ "cell_type": "code",
151
+ "execution_count": null,
152
+ "metadata": {},
153
+ "outputs": [],
154
+ "source": [
155
+ "tags_by_post_file = f\"{OUTPUT_FOLDER}/tags_by_post.feather\"\n",
156
+ "if os.path.exists(tags_by_post_file):\n",
157
+ " tags_by_post = pandas.read_feather(tags_by_post_file)\n",
158
+ "else:\n",
159
+ " tags_by_post = post_tags.groupby(\"post_id\").progress_aggregate(list)\n",
160
+ " tags_by_post.reset_index(inplace=True)\n",
161
+ " tags_by_post.to_feather(tags_by_post_file)\n",
162
+ "tags_by_post.info()"
163
+ ]
164
+ },
165
+ {
166
+ "cell_type": "markdown",
167
+ "metadata": {},
168
+ "source": [
169
+ "Also make a SFW post tags list, then use it to build a list of tags that only appear in SFW posts. Optional."
170
+ ]
171
+ },
172
+ {
173
+ "cell_type": "code",
174
+ "execution_count": null,
175
+ "metadata": {},
176
+ "outputs": [],
177
+ "source": [
178
+ "safe_posts_by_tag_file = f\"{OUTPUT_FOLDER}/safe_posts_by_tag.feather\"\n",
179
+ "if os.path.exists(safe_posts_by_tag_file):\n",
180
+ " safe_posts_by_tag = pandas.read_feather(safe_posts_by_tag_file)\n",
181
+ "else:\n",
182
+ " safe_posts_by_tag = post_tags.set_index(\"post_id\").join(posts.set_index(\"post_id\"))\n",
183
+ " safe_posts_by_tag = safe_posts_by_tag[safe_posts_by_tag[\"rating\"].isin([\"s\"])].reset_index()\n",
184
+ " safe_posts_by_tag = safe_posts_by_tag[[\"tag_id\", \"post_id\"]].groupby(\"tag_id\").progress_aggregate(list)\n",
185
+ " safe_posts_by_tag.reset_index(inplace=True)\n",
186
+ " safe_posts_by_tag.to_feather(safe_posts_by_tag_file)\n",
187
+ "safe_posts_by_tag.info()"
188
+ ]
189
+ },
190
+ {
191
+ "cell_type": "code",
192
+ "execution_count": null,
193
+ "metadata": {},
194
+ "outputs": [],
195
+ "source": [
196
+ "safe_tags_by_post_file = f\"{OUTPUT_FOLDER}/safe_tags_by_post.feather\"\n",
197
+ "if os.path.exists(safe_tags_by_post_file):\n",
198
+ " safe_tags_by_post = pandas.read_feather(safe_tags_by_post_file)\n",
199
+ "else:\n",
200
+ " safe_tags_by_post = post_tags.set_index(\"post_id\").join(posts.set_index(\"post_id\"))\n",
201
+ " safe_tags_by_post = safe_tags_by_post[safe_tags_by_post[\"rating\"].isin([\"s\"])].reset_index()\n",
202
+ " safe_tags_by_post = safe_tags_by_post[[\"tag_id\", \"post_id\"]].groupby(\"post_id\").progress_aggregate(list)\n",
203
+ " safe_tags_by_post.reset_index(inplace=True)\n",
204
+ " safe_tags_by_post.to_feather(safe_tags_by_post_file)\n",
205
+ "safe_tags_by_post.info()"
206
+ ]
207
+ },
208
+ {
209
+ "cell_type": "code",
210
+ "execution_count": null,
211
+ "metadata": {},
212
+ "outputs": [],
213
+ "source": [
214
+ "safe_tags_file = f\"{OUTPUT_FOLDER}/safe_tags.feather\"\n",
215
+ "if os.path.exists(safe_tags_file):\n",
216
+ " safe_tags = pandas.read_feather(safe_tags_file)\n",
217
+ "else:\n",
218
+ " safe_tags = safe_posts_by_tag.set_index(\"tag_id\").join(tags.set_index(\"tag_id\"), how=\"inner\")\n",
219
+ " safe_tags[\"post_count\"] = safe_tags[\"post_id\"].apply(len)\n",
220
+ " safe_tags = safe_tags[[\"name\", \"category\", \"post_count\"]]\n",
221
+ " safe_tags.reset_index(inplace=True)\n",
222
+ " safe_tags.to_feather(safe_tags_file)\n",
223
+ "safe_tags.info()"
224
+ ]
225
+ },
226
+ {
227
+ "cell_type": "markdown",
228
+ "metadata": {},
229
+ "source": [
230
+ "And lastly, parse and store the implications file. Useful for filtering out tag suggestions that are implied by higher scoring ones, and for building the species hierarchy."
231
+ ]
232
+ },
233
+ {
234
+ "cell_type": "code",
235
+ "execution_count": null,
236
+ "metadata": {},
237
+ "outputs": [],
238
+ "source": [
239
+ "implications_file = f\"{OUTPUT_FOLDER}/implications.feather\"\n",
240
+ "if os.path.exists(implications_file):\n",
241
+ " implications = pandas.read_feather(implications_file)\n",
242
+ "else:\n",
243
+ " implications = pandas.read_csv(f\"{INPUT_FOLDER}/tag_implications-{DATE}.csv.gz\")\\\n",
244
+ " .join(tags_by_name, on=\"antecedent_name\", how=\"inner\")\\\n",
245
+ " .join(tags_by_name, on=\"consequent_name\", rsuffix=\"_con\")\\\n",
246
+ " [[\"tag_id\", \"tag_id_con\"]]\\\n",
247
+ " .rename(columns={\"tag_id\": \"antecedent_id\", \"tag_id_con\": \"consequent_id\"})\n",
248
+ " implications.reset_index(inplace=True,drop=True)\n",
249
+ " implications.to_feather(implications_file)\n",
250
+ "implications.info()"
251
+ ]
252
+ }
253
+ ],
254
+ "metadata": {
255
+ "kernelspec": {
256
+ "display_name": ".venv",
257
+ "language": "python",
258
+ "name": "python3"
259
+ },
260
+ "language_info": {
261
+ "codemirror_mode": {
262
+ "name": "ipython",
263
+ "version": 3
264
+ },
265
+ "file_extension": ".py",
266
+ "mimetype": "text/x-python",
267
+ "name": "python",
268
+ "nbconvert_exporter": "python",
269
+ "pygments_lexer": "ipython3",
270
+ "version": "3.10.11"
271
+ },
272
+ "orig_nbformat": 4
273
+ },
274
+ "nbformat": 4,
275
+ "nbformat_minor": 2
276
+ }
Querying Data.ipynb ADDED
@@ -0,0 +1,464 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "cells": [
3
+ {
4
+ "cell_type": "markdown",
5
+ "metadata": {},
6
+ "source": [
7
+ "This is the fun part. After all the definitions are done, a few methods are ready to play with.\n",
8
+ "* `related_tags` takes the set of posts that have ALL the provided tags and figures out which tags are most strongly correlated with precence of all the tags together.\n",
9
+ "* `big_list_suggestions` runs the same query for each tag individually and combines the results in an interesting (if probably statistically unsound) way. Since it's running each tag as a separate query, `big_list_suggestions` will work for tag combinations that don't exist in the dataset.\n",
10
+ "* `test_artist_prompt` is a neat experiment that's described later.\n",
11
+ "\n",
12
+ "Most arguments are the same for both functions. Everything except the targets is optional.\n",
13
+ "* `targets` - A variable length list of tags. Also translates spaces and backslashed parens if you copy a prompt in as a single string, but each tag still has to be comma separated. It'll throw out anything that doesn't parse as a known tag.\n",
14
+ "* `exclude` - Specific to `related_tags`. Given a list of tag names, excludes posts that contain these tags.\n",
15
+ "* `minus` - The `big_list_suggestions` version. Subtracts the correlations of these tags after processing the positive ones.\n",
16
+ "* `category` - One of the `CAT_*` constants, to filter it down to only specific types of tags, for example `CAT_ARTIST` to get a list of artists correlated with a tag.\n",
17
+ "* `samples` - When querying big tags, limit the number of posts. The default is 100,000, which is high enough to have very little randomness in the results and low enough to be relatively fast.\n",
18
+ "* `min_posts` - Don't show tags with fewer than this many total posts. Default is 20.\n",
19
+ "* `min_overlap` - Don't show tags with fewer than this many posts overlapping the targets. Specific to `related_tags`. Default is 5.\n",
20
+ "* `top` - Show this many tags with the highest correlation from the result set. Default 30.\n",
21
+ "* `bottom` - Just for fun, show the this many tags with the lowest correlation. Default 0.\n",
22
+ "* `exclude_implied` - Don't show tags that are directly implied by tags you already have (according to e621). Default True because it produces boring obvious answers, turn it off if you need to know extra tags to reinforce something."
23
+ ]
24
+ },
25
+ {
26
+ "cell_type": "code",
27
+ "execution_count": null,
28
+ "metadata": {},
29
+ "outputs": [],
30
+ "source": [
31
+ "import pandas\n",
32
+ "import numpy\n",
33
+ "import pandas.io.formats.style\n",
34
+ "import random\n",
35
+ "import functools\n",
36
+ "from typing import Literal\n",
37
+ "\n",
38
+ "SOURCE: Literal[\"danbooru\", \"e621\"] = \"e621\"\n",
39
+ "DATA_FOLDER = \"H:/Data/TagSuggest/e621_dataframes\""
40
+ ]
41
+ },
42
+ {
43
+ "cell_type": "markdown",
44
+ "metadata": {},
45
+ "source": [
46
+ "Yes, I've done this for some scraped Danbooru data too. The results aren't half as interesting."
47
+ ]
48
+ },
49
+ {
50
+ "cell_type": "code",
51
+ "execution_count": null,
52
+ "metadata": {},
53
+ "outputs": [],
54
+ "source": [
55
+ "if SOURCE == \"e621\":\n",
56
+ " CAT_GENERAL = 0\n",
57
+ " CAT_ARTIST = 1\n",
58
+ " CAT_UNUSED = 2\n",
59
+ " CAT_COPYRIGHT = 3\n",
60
+ " CAT_CHARACTER = 4\n",
61
+ " CAT_SPECIES = 5\n",
62
+ " CAT_INVALID = 6\n",
63
+ " CAT_META = 7\n",
64
+ " CAT_LORE = 8\n",
65
+ "\n",
66
+ " CATEGORY_COLORS = {\n",
67
+ " CAT_GENERAL: \"#b4c7d9\",\n",
68
+ " CAT_ARTIST: \"#f2ac08\",\n",
69
+ " CAT_UNUSED: \"#ff3d3d\",\n",
70
+ " CAT_COPYRIGHT: \"#d0d\",\n",
71
+ " CAT_CHARACTER: \"#0a0\",\n",
72
+ " CAT_SPECIES: \"#ed5d1f\",\n",
73
+ " CAT_INVALID: \"#ff3d3d\",\n",
74
+ " CAT_META: \"#fff\",\n",
75
+ " CAT_LORE: \"#282\"\n",
76
+ " }\n",
77
+ "elif SOURCE == \"danbooru\":\n",
78
+ " CAT_GENERAL = 0\n",
79
+ " CAT_ARTIST = 1\n",
80
+ " CAT_UNUSED = 2\n",
81
+ " CAT_COPYRIGHT = 3\n",
82
+ " CAT_CHARACTER = 4\n",
83
+ " CAT_META = 5\n",
84
+ "\n",
85
+ " CATEGORY_COLORS = {\n",
86
+ " CAT_GENERAL: \"#b4c7d9\",\n",
87
+ " CAT_ARTIST: \"#f2ac08\",\n",
88
+ " CAT_UNUSED: \"#ff3d3d\",\n",
89
+ " CAT_COPYRIGHT: \"#d0d\",\n",
90
+ " CAT_CHARACTER: \"#0a0\",\n",
91
+ " CAT_META: \"#fff\",\n",
92
+ " }"
93
+ ]
94
+ },
95
+ {
96
+ "cell_type": "markdown",
97
+ "metadata": {},
98
+ "source": [
99
+ "Load everything and arrange the tags to be queried by name or id."
100
+ ]
101
+ },
102
+ {
103
+ "cell_type": "code",
104
+ "execution_count": null,
105
+ "metadata": {},
106
+ "outputs": [],
107
+ "source": [
108
+ "tags = pandas.read_feather(f\"{DATA_FOLDER}/tags.feather\")\n",
109
+ "posts_by_tag = pandas.read_feather(f\"{DATA_FOLDER}/posts_by_tag.feather\").set_index(\"tag_id\")\n",
110
+ "tags_by_post = pandas.read_feather(f\"{DATA_FOLDER}/tags_by_post.feather\").set_index(\"post_id\")\n",
111
+ "implications = pandas.read_feather(f\"{DATA_FOLDER}/implications.feather\")\n",
112
+ "tags_by_name = tags.copy(deep=True)\n",
113
+ "tags_by_name.set_index(\"name\", inplace=True)\n",
114
+ "tags.set_index(\"tag_id\", inplace=True)"
115
+ ]
116
+ },
117
+ {
118
+ "cell_type": "markdown",
119
+ "metadata": {},
120
+ "source": [
121
+ "Now define the functions themselves. I should document these later, but I want to get this out first."
122
+ ]
123
+ },
124
+ {
125
+ "cell_type": "code",
126
+ "execution_count": null,
127
+ "metadata": {},
128
+ "outputs": [],
129
+ "source": [
130
+ "@functools.cache\n",
131
+ "def get_related_tags(targets: tuple[str, ...], exclude: tuple[str, ...] = (), samples: int = 100_000) -> pandas.DataFrame:\n",
132
+ " these_tags = tags_by_name.loc[list(targets)]\n",
133
+ " posts_with_these_tags = posts_by_tag.loc[these_tags[\"tag_id\"]].applymap(set).groupby(lambda x: True).agg(lambda x: set.intersection(*x))[\"post_id\"][True]\n",
134
+ " if (len(exclude) > 0):\n",
135
+ " excluded_tags = tags_by_name.loc[list(exclude)]\n",
136
+ " posts_with_excluded_tags = posts_by_tag.loc[excluded_tags[\"tag_id\"]].applymap(set).groupby(lambda x: True).agg(lambda x: set.union(*x))[\"post_id\"][True]\n",
137
+ " posts_with_these_tags = posts_with_these_tags - posts_with_excluded_tags\n",
138
+ " total_post_count_together = len(posts_with_these_tags)\n",
139
+ " sample_posts = random.sample(list(posts_with_these_tags), samples) if total_post_count_together > samples else list(posts_with_these_tags)\n",
140
+ " post_count_together = len(sample_posts)\n",
141
+ " sample_ratio = post_count_together / total_post_count_together\n",
142
+ " tags_in_these_posts = tags_by_post.loc[sample_posts]\n",
143
+ " counts_in_these_posts = tags_in_these_posts[\"tag_id\"].explode().value_counts().rename(\"overlap\")\n",
144
+ " summaries = pandas.DataFrame(counts_in_these_posts).join(tags[tags[\"post_count\"]>0], how=\"right\").fillna(0)\n",
145
+ " summaries[\"overlap\"] = numpy.minimum(summaries[\"overlap\"] / sample_ratio, summaries[\"post_count\"])\n",
146
+ " summaries = summaries[[\"category\", \"name\", \"overlap\", \"post_count\"]]\n",
147
+ " # Old \"interestingness\" value, didn't give as good results as an actual statistical technique, go figure. Code kept for curiosity's sake.\n",
148
+ " #summaries[\"interestingness\"] = summaries[\"overlap\"].pow(2) / (total_post_count_together * summaries[\"post_count\"])\n",
149
+ " # Phi coefficient stuff.\n",
150
+ " n = float(len(tags_by_post))\n",
151
+ " n11 = summaries[\"overlap\"]\n",
152
+ " n1x = float(total_post_count_together)\n",
153
+ " nx1 = summaries[\"post_count\"].astype(\"float64\")\n",
154
+ " summaries[\"correlation\"] = (n * n11 - n1x * nx1) / numpy.sqrt(n1x * nx1 * (n - n1x) * (n - nx1))\n",
155
+ " return summaries\n",
156
+ "\n",
157
+ "def format_tags(styler: pandas.io.formats.style.Styler):\n",
158
+ " styler.apply(lambda row: numpy.where(row.index == \"name\", \"color:\"+CATEGORY_COLORS[row[\"category\"]], \"\"), axis=1)\n",
159
+ " styler.hide(level=0)\n",
160
+ " styler.hide(\"category\",axis=1)\n",
161
+ " if 'overlap' in styler.data:\n",
162
+ " styler.format(\"{:.0f}\".format, subset=[\"overlap\"])\n",
163
+ " if 'correlation' in styler.data:\n",
164
+ " styler.format(\"{:.2f}\".format, subset=[\"correlation\"])\n",
165
+ " styler.background_gradient(vmin=-1.0, vmax=1.0, cmap=\"RdYlGn\", subset=[\"correlation\"])\n",
166
+ " if 'score' in styler.data:\n",
167
+ " styler.format(\"{:.2f}\".format, subset=[\"score\"])\n",
168
+ " styler.background_gradient(vmin=-1.0, vmax=1.0, cmap=\"RdYlGn\", subset=[\"score\"])\n",
169
+ " return styler\n",
170
+ "\n",
171
+ "def related_tags(*targets: str, exclude: tuple[str, ...] = (), category: int = None, samples: int = 100_000, min_overlap: int = 5, min_posts: int = 20, top: int = 30, bottom: int = 0) -> pandas.DataFrame:\n",
172
+ " result = get_related_tags(targets, exclude=exclude, samples=samples)\n",
173
+ " if category != None:\n",
174
+ " result = result[result[\"category\"] == category]\n",
175
+ " result = result[~result[\"name\"].isin(targets)]\n",
176
+ " result = result[result[\"overlap\"] >= min_overlap]\n",
177
+ " result = result[result[\"post_count\"] >= min_posts]\n",
178
+ " top_part = result.sort_values(\"correlation\", ascending=False)[:top]\n",
179
+ " bottom_part = result.sort_values(\"correlation\", ascending=True)[:bottom].sort_values(\"correlation\", ascending=False)\n",
180
+ " return pandas.concat([top_part, bottom_part]).style.pipe(format_tags)\n",
181
+ "\n",
182
+ "def implications_for(*subjects: str, seen: set[str] = None):\n",
183
+ " if seen is None:\n",
184
+ " seen = set()\n",
185
+ " for subject in subjects:\n",
186
+ " found = tags.loc[list(implications[implications[\"antecedent_id\"] == tags_by_name.loc[subject, \"tag_id\"]].loc[:,\"consequent_id\"]), \"name\"].values\n",
187
+ " for f in found:\n",
188
+ " if f in seen:\n",
189
+ " pass\n",
190
+ " else:\n",
191
+ " yield f\n",
192
+ " seen.add(f)\n",
193
+ " yield from implications_for(f, seen=seen)\n",
194
+ "\n",
195
+ "# Simplified version of related_tags that sorts by overlap, especially for reinforcing a prompt with redundant tags.\n",
196
+ "def tags_for(*targets: str, exclude: tuple[str, ...] = (), category: int = None, samples: int = 100_000, min_overlap: int = 0, min_posts: int = 0, top: int = 30, bottom: int = 0) -> pandas.DataFrame:\n",
197
+ " result = get_related_tags(targets, exclude=exclude, samples=samples)\n",
198
+ " if category != None:\n",
199
+ " result = result[result[\"category\"] == category]\n",
200
+ " result = result[~result[\"name\"].isin(targets)]\n",
201
+ " result = result[result[\"overlap\"] >= min_overlap]\n",
202
+ " result = result[result[\"post_count\"] >= min_posts]\n",
203
+ " top_part = result.sort_values(\"overlap\", ascending=False)[:top]\n",
204
+ " bottom_part = result.sort_values(\"overlap\", ascending=True)[:bottom].sort_values(\"overlap\", ascending=False)\n",
205
+ " return pandas.concat([top_part, bottom_part]).style.pipe(format_tags)\n",
206
+ "\n",
207
+ "def parse_tags(*parts: str):\n",
208
+ " for part in parts:\n",
209
+ " for potential_tag in part.split(\",\"):\n",
210
+ " potential_tag = potential_tag.strip().replace(\" \", \"_\").replace(\"\\\\(\", \"(\").replace(\"\\\\)\", \")\")\n",
211
+ " if potential_tag == \"\":\n",
212
+ " pass\n",
213
+ " elif potential_tag in tags_by_name.index:\n",
214
+ " yield potential_tag\n",
215
+ " else:\n",
216
+ " print(\"Couldn't find tag '{potential_tag}', skipping it.\")\n",
217
+ "\n",
218
+ "def add_suggestions(suggestions: pandas.DataFrame, new_tags: str | list[str], multiplier: int, samples : int, min_posts: int):\n",
219
+ " if isinstance(new_tags, str):\n",
220
+ " new_tags = [new_tags]\n",
221
+ " for new_tag in new_tags:\n",
222
+ " related = get_related_tags((new_tag,), samples=samples)\n",
223
+ " related = related[related[\"post_count\"] >= min_posts]\n",
224
+ " if suggestions is None:\n",
225
+ " suggestions = related.rename(columns={\"correlation\": \"score\"})\n",
226
+ " else:\n",
227
+ " suggestions = suggestions.join(related, rsuffix=\"r\")\n",
228
+ " # This is a totally made up way to combine correlations. It keeps them from going outside the +/- 1 range, which is nice. It also makes older\n",
229
+ " # tags less important every time newer ones are added. That could be considered a feature or not.\n",
230
+ " suggestions[\"score\"] = numpy.real(numpy.power((numpy.sqrt(suggestions[\"score\"] + 0j) + numpy.sqrt(multiplier * suggestions[\"correlation\"] + 0j)) / 2, 2))\n",
231
+ " return suggestions[[\"category\", \"name\", \"post_count\", \"score\"]]\n",
232
+ "\n",
233
+ "def big_list_suggestions(*targets: str, minus: list[str] = [], category: int = None, samples: int = 100_000, min_posts: int = 20, top: int = 30, bottom: int = 0, exclude_implied: bool = True):\n",
234
+ " suggestions = None\n",
235
+ " parsed_targets = list(parse_tags(*targets))\n",
236
+ " for target in parsed_targets:\n",
237
+ " suggestions = add_suggestions(suggestions, target, 1, samples, min_posts)\n",
238
+ " parsed_minus = list(parse_tags(*minus))\n",
239
+ " for target in parsed_minus:\n",
240
+ " suggestions = add_suggestions(suggestions, target, -1, samples, min_posts)\n",
241
+ " if category != None:\n",
242
+ " suggestions = suggestions[suggestions[\"category\"] == category]\n",
243
+ " suggestions = suggestions[~suggestions[\"name\"].isin(parsed_targets)]\n",
244
+ " if exclude_implied:\n",
245
+ " exclude = list(implications_for(*parsed_targets)) + list(implications_for(*parsed_minus))\n",
246
+ " suggestions = suggestions[~suggestions[\"name\"].isin(exclude)]\n",
247
+ " suggestions = suggestions[suggestions[\"post_count\"] >= min_posts]\n",
248
+ " top_part = suggestions.sort_values(\"score\", ascending=False)[:top]\n",
249
+ " bottom_part = suggestions.sort_values(\"score\", ascending=True)[:bottom].sort_values(\"score\", ascending=False)\n",
250
+ " return pandas.concat([top_part, bottom_part]).style.pipe(format_tags)"
251
+ ]
252
+ },
253
+ {
254
+ "cell_type": "markdown",
255
+ "metadata": {},
256
+ "source": [
257
+ "And now a bonus. This class can build an entire prompt from a starting tag or tags. Previous tags are used to pick new tags based on their correlations. The output is formatted to be pasted directly into the \"Prompts from file or textbox\" script in automatic1111's webui. Some examples are with the others below. Methods:\n",
258
+ "* `focus` - Push future choices toward things associated with the given tag. Use this to give the builder hints about things you want to see.\n",
259
+ "* `include` - Like `focus`, but also adds the tag to the positive prompt.\n",
260
+ "* `avoid` - Push future choices _away_ from things associated with the given tag. Use this to help the builder avoid making unwanted stuff by defining what's unwanted.\n",
261
+ "* `exclulde` - Like `avoid`, but also adds the tag to the negative prompt.\n",
262
+ "* `pick` - Grab a few tags from the top X of the given category, based on associations with tags already in the prompt, and add them to the list. The top lists are recalculated with each tag selected.\n",
263
+ "* `foreach_pick` - Grab a few tags and branch off a new prompt for each one.\n",
264
+ "* `pick_fast` - Instead of picking one tag at a time and recalculating the top lists each time, pick X tags from the current list. This is especially fast if it's the last step before building, because suggestions won't have to be recalculated for all those new tags.\n",
265
+ "* `branch` - Create X different prompts at this point without picking any new tags.\n",
266
+ "\n",
267
+ "TODO\n",
268
+ "* Defer everything until build is called, so a progress bar can be calculated for the entire process.\n",
269
+ "* Try weighing options by correlation instead of blindly picking from the top X. It worked great for the species randomizer."
270
+ ]
271
+ },
272
+ {
273
+ "cell_type": "code",
274
+ "execution_count": null,
275
+ "metadata": {},
276
+ "outputs": [],
277
+ "source": [
278
+ "from typing import Callable\n",
279
+ "\n",
280
+ "\n",
281
+ "def pick_tags(suggestions: pandas.DataFrame, category: int, count: int, from_top: int, excluding: list[str], weighted: bool = True):\n",
282
+ " options = suggestions[(True if category is None else suggestions[\"category\"] == category) & (suggestions[\"score\"] > 0) & ~suggestions[\"name\"].isin(excluding)].sort_values(\"score\", ascending=False)[:from_top]\n",
283
+ " if weighted:\n",
284
+ " values = list(options[\"name\"].values)\n",
285
+ " weights = list(options[\"score\"].values)\n",
286
+ " choices = []\n",
287
+ " for _ in range(count):\n",
288
+ " choice = random.choices(population=values, weights=weights, k=1)[0]\n",
289
+ " weights.pop(values.index(choice))\n",
290
+ " values.remove(choice)\n",
291
+ " choices.append(choice)\n",
292
+ " return choices\n",
293
+ " else:\n",
294
+ " return random.sample(list(options[\"name\"].values), count)\n",
295
+ "\n",
296
+ "def tag_to_prompt(tag: str) -> str:\n",
297
+ " if (tags_by_name.loc[tag][\"category\"] == CAT_ARTIST):\n",
298
+ " tag = \"by \" + tag\n",
299
+ " return tag.replace(\"_\", \" \").replace(\"(\" , \"\\\\(\").replace(\")\" , \"\\\\)\")\n",
300
+ "\n",
301
+ "# A lambda in a for loop doesn't capture variables the way I want it to, so this is a method now\n",
302
+ "def add_suggestions_later(suggestions: pandas.DataFrame, new_tags: str | list[str], multiplier: int, samples: int, min_posts: int):\n",
303
+ " return lambda: add_suggestions(suggestions, new_tags, multiplier, samples, min_posts)\n",
304
+ "\n",
305
+ "\n",
306
+ "Prompt = tuple[list[str], list[str], Callable[[], pandas.DataFrame]]\n",
307
+ "\n",
308
+ "class PromptBuilder:\n",
309
+ " prompts: list[Prompt]\n",
310
+ " samples: int\n",
311
+ " min_posts: int\n",
312
+ " skip_list: list[str]\n",
313
+ "\n",
314
+ " def __init__(self, prompts = [([],[],lambda: None)], skip=[], samples = 100_000, min_posts = 20):\n",
315
+ " self.prompts = prompts\n",
316
+ " self.samples = samples\n",
317
+ " self.min_posts = min_posts\n",
318
+ " self.skip_list = skip\n",
319
+ "\n",
320
+ " def include(self, tag: str):\n",
321
+ " return PromptBuilder(prompts=[\n",
322
+ " (tag_list + [tag], negative_list, add_suggestions_later(suggestions(), tag, 1, self.samples, self.min_posts))\n",
323
+ " for (tag_list, negative_list, suggestions) in self.prompts\n",
324
+ " ], samples=self.samples, min_posts=self.min_posts, skip=self.skip_list)\n",
325
+ "\n",
326
+ " def focus(self, tag: str):\n",
327
+ " return PromptBuilder(prompts=[\n",
328
+ " (tag_list, negative_list, add_suggestions_later(suggestions(), tag, 1, self.samples, self.min_posts))\n",
329
+ " for (tag_list, negative_list, suggestions) in self.prompts\n",
330
+ " ], samples=self.samples, min_posts=self.min_posts, skip=self.skip_list)\n",
331
+ "\n",
332
+ " def exclude(self, tag: str):\n",
333
+ " return PromptBuilder(prompts=[\n",
334
+ " (tag_list, negative_list + [tag], add_suggestions_later(suggestions(), tag, -1, self.samples, self.min_posts))\n",
335
+ " for (tag_list, negative_list, suggestions) in self.prompts\n",
336
+ " ], samples=self.samples, min_posts=self.min_posts, skip=self.skip_list)\n",
337
+ "\n",
338
+ " def avoid(self, tag: str):\n",
339
+ " return PromptBuilder(prompts=[\n",
340
+ " (tag_list, negative_list, add_suggestions_later(suggestions(), tag, -1, self.samples, self.min_posts))\n",
341
+ " for (tag_list, negative_list, suggestions) in self.prompts\n",
342
+ " ], samples=self.samples, min_posts=self.min_posts, skip=self.skip_list)\n",
343
+ "\n",
344
+ " def pick(self, category: int, count: int, from_top: int):\n",
345
+ " new_prompts = self.prompts\n",
346
+ " for _ in range(count):\n",
347
+ " new_prompts = [\n",
348
+ " (tag_list + [tag], negative_list, add_suggestions_later(s, tag, 1, self.samples, self.min_posts))\n",
349
+ " for (tag_list, negative_list, suggestions) in new_prompts\n",
350
+ " for s in (suggestions(),)\n",
351
+ " for tag in pick_tags(s, category, 1, from_top, tag_list + negative_list + self.skip_list)\n",
352
+ " ]\n",
353
+ " return PromptBuilder(new_prompts, samples=self.samples, min_posts=self.min_posts, skip=self.skip_list)\n",
354
+ "\n",
355
+ " def foreach_pick(self, category: int, count: int, from_top: int):\n",
356
+ " return PromptBuilder(prompts=[\n",
357
+ " (tag_list + [tag], negative_list, add_suggestions_later(s, tag, 1, self.samples, self.min_posts))\n",
358
+ " for (tag_list, negative_list, suggestions) in self.prompts\n",
359
+ " for s in (suggestions(),)\n",
360
+ " for tag in pick_tags(s, category, count, from_top, tag_list + negative_list + self.skip_list)\n",
361
+ " ], samples=self.samples, min_posts=self.min_posts, skip=self.skip_list)\n",
362
+ " \n",
363
+ " def pick_fast(self, category: int, count: int, from_top: int):\n",
364
+ " prompts = []\n",
365
+ " for (tag_list, negative_list, suggestions) in self.prompts:\n",
366
+ " s = suggestions()\n",
367
+ " new_tags = pick_tags(s, category, count, from_top, tag_list + negative_list + self.skip_list)\n",
368
+ " prompts.append((tag_list + new_tags, negative_list, add_suggestions_later(s, new_tags, 1, self.samples, self.min_posts)))\n",
369
+ " return PromptBuilder(prompts=prompts, samples=self.samples, min_posts=self.min_posts, skip=self.skip_list)\n",
370
+ "\n",
371
+ " def branch(self, count: int):\n",
372
+ " return PromptBuilder(prompts=[prompt for prompt in self.prompts for _ in range(count)], samples=self.samples, min_posts=self.min_posts, skip=self.skip_list)\n",
373
+ "\n",
374
+ " def build(self):\n",
375
+ " for (tag_list, negative_list, _) in self.prompts:\n",
376
+ " positive_prompt = \", \".join([ tag_to_prompt(tag) for tag in tag_list])\n",
377
+ " negative_prompt = \", \".join([ tag_to_prompt(tag) for tag in negative_list])\n",
378
+ " if negative_prompt:\n",
379
+ " yield \"--prompt \\\"{positive_prompt}\\\" --negative_prompt \\\"{negative_prompt}\\\"\"\n",
380
+ " else:\n",
381
+ " yield positive_prompt\n",
382
+ "\n",
383
+ " def print(self):\n",
384
+ " for prompt in self.build():\n",
385
+ " print(prompt)"
386
+ ]
387
+ },
388
+ {
389
+ "cell_type": "code",
390
+ "execution_count": null,
391
+ "metadata": {},
392
+ "outputs": [],
393
+ "source": []
394
+ },
395
+ {
396
+ "cell_type": "markdown",
397
+ "metadata": {},
398
+ "source": [
399
+ "And there you go. Have fun!"
400
+ ]
401
+ },
402
+ {
403
+ "cell_type": "markdown",
404
+ "metadata": {},
405
+ "source": [
406
+ "# Examples\n",
407
+ "\n",
408
+ "Get general tags related to vikings:\n",
409
+ "\n",
410
+ "```related_tags(\"viking\", category=CAT_GENERAL, min_posts=20)```\n",
411
+ "\n",
412
+ "Find artists who draw cheetahs:\n",
413
+ "\n",
414
+ "```related_tags(\"cheetah\", category=CAT_ARTIST, min_posts=20)```\n",
415
+ "\n",
416
+ "Arm an avali:\n",
417
+ "\n",
418
+ "```big_list_suggestions(\"avali, holding weapon, science fiction\", category=CAT_GENERAL)```\n",
419
+ "\n",
420
+ "# Prompt Builder Examples\n",
421
+ "\n",
422
+ "Use with caution, it has all of e621 to pick from...\n",
423
+ "\n",
424
+ "Start with an artist, pick four of the artist's top species, and build a quick prompt for each of them.\n",
425
+ "\n",
426
+ "```PromptBuilder().include(\"red-izak\").foreach_pick(CAT_SPECIES, 4, 20).pick_fast(CAT_GENERAL, 10, 20).print()```\n",
427
+ "\n",
428
+ "Same thing as above, but this time build the prompt much more slowly by reconsidering the list after every tag chosen. Should create more sensible prompts. Should.\n",
429
+ "\n",
430
+ "```PromptBuilder().include(\"red-izak\").foreach_pick(CAT_SPECIES, 4, 10).pick(CAT_GENERAL, 10, 20).print()```\n",
431
+ "\n",
432
+ "Start with an overall scene idea, grab four artists that might do it especially well, add two characters, and augment with a few more tags.\n",
433
+ "\n",
434
+ "```PromptBuilder().include(\"beach\").include(\"volleyball\").avoid(\"sex\").foreach_pick(CAT_ARTIST, 4, 10).pick_fast(CAT_SPECIES, 2, 10).pick_fast(CAT_GENERAL, 10, 40).print()```\n",
435
+ "\n",
436
+ "Make a Halloween prompt! The skips are too-generic tags that tend to make the prompt wander away from the Halloween theme.\n",
437
+ "\n",
438
+ "```PromptBuilder(skip=[\"holidays\", \"costume\", \"food\"]).include(\"halloween\").foreach_pick(CAT_ARTIST, 4, 100).pick(None, 10, 40).print()```"
439
+ ]
440
+ }
441
+ ],
442
+ "metadata": {
443
+ "kernelspec": {
444
+ "display_name": ".venv",
445
+ "language": "python",
446
+ "name": "python3"
447
+ },
448
+ "language_info": {
449
+ "codemirror_mode": {
450
+ "name": "ipython",
451
+ "version": 3
452
+ },
453
+ "file_extension": ".py",
454
+ "mimetype": "text/x-python",
455
+ "name": "python",
456
+ "nbconvert_exporter": "python",
457
+ "pygments_lexer": "ipython3",
458
+ "version": "3.10.11"
459
+ },
460
+ "orig_nbformat": 4
461
+ },
462
+ "nbformat": 4,
463
+ "nbformat_minor": 2
464
+ }
implications.feather ADDED
Binary file (277 kB). View file
 
posts_by_tag.feather ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:86bae28a897c20647688703601520ac1182a6605d9e4f0f0c47c59331c22c379
3
+ size 720730674
tags.feather ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:4541d14a57d3ead9634f79cdb544a8eb3d4aae43ebfdaad84c5457a68380caf2
3
+ size 25311978
tags_by_post.feather ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:7ed5a997d7a3c6c8eaebcf99d65c77358dde7759b0c58dad1d6faeafba7f4265
3
+ size 495786722