Spaces:
Running
Running
rht
#2
by RedHotTensors - opened
- README.md +0 -12
- app.py +261 -422
- maintenance/compact_logs.py → compact_logs.py +43 -40
- explorer.py +29 -86
- maintenance/refresh_cdn_availability.py +0 -159
- stats_from_logs.py +38 -45
- storage.py +59 -104
README.md
CHANGED
|
@@ -34,15 +34,3 @@ Each vote row stores:
|
|
| 34 |
- `dataset`
|
| 35 |
- `group`
|
| 36 |
- `session_id`
|
| 37 |
-
- `pair_reason` (which sampler branch served the pair)
|
| 38 |
-
- `rater_name` (optional self-reported handle, empty when anonymous)
|
| 39 |
-
|
| 40 |
-
## Maintenance
|
| 41 |
-
|
| 42 |
-
Scripts under `maintenance/` import from the repo root, so run them as modules from there:
|
| 43 |
-
|
| 44 |
-
```
|
| 45 |
-
python -m maintenance.compact_logs
|
| 46 |
-
```
|
| 47 |
-
|
| 48 |
-
Compaction rewrites every shard through `VOTE_COLUMNS`, so columns added to the schema are backfilled as null on older rows.
|
|
|
|
| 34 |
- `dataset`
|
| 35 |
- `group`
|
| 36 |
- `session_id`
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app.py
CHANGED
|
@@ -5,36 +5,22 @@ import time
|
|
| 5 |
import uuid
|
| 6 |
import os
|
| 7 |
import html
|
| 8 |
-
import sys
|
| 9 |
-
|
| 10 |
-
from typing import Callable
|
| 11 |
|
| 12 |
import pandas as pd
|
| 13 |
from huggingface_hub import hf_hub_download
|
| 14 |
-
from huggingface_hub.utils import EntryNotFoundError
|
| 15 |
|
| 16 |
from storage import VoteStorage
|
| 17 |
-
from
|
| 18 |
-
|
| 19 |
-
from stats_from_logs import load_vote_logs, stats_by_md5
|
| 20 |
-
from explorer import ALLOWED_CLASSIFIER_FILTERS, DISAGREEMENT_SORT_MODE, SORT_MODES, add_results_tab, build_results_data, load_more_results, on_gallery_select
|
| 21 |
|
| 22 |
DEBUG_MODE = os.getenv("DEBUG", "0").lower() in ("1", "true", "yes", "on")
|
| 23 |
RATINGS_APP_TOKEN = os.getenv("RATINGS_APP_TOKEN")
|
| 24 |
SUBMIT_KEY = os.getenv("RATINGS_SUBMIT_KEY")
|
| 25 |
assert SUBMIT_KEY, "Missing required env var: RATINGS_SUBMIT_KEY"
|
| 26 |
POOL_REPO_ID = "taigasan/e6-visual-ratings"
|
| 27 |
-
RATER_NAME_MAX_LEN = 64
|
| 28 |
VOTE_STORAGE = VoteStorage(mode="void" if DEBUG_MODE else "hf", token=RATINGS_APP_TOKEN)
|
| 29 |
STATS_RELOAD_S = 30 * 60
|
| 30 |
|
| 31 |
-
def _drop_cdn_unavailable(df: pd.DataFrame, label: str) -> pd.DataFrame:
|
| 32 |
-
keep = df["cdn_available"].astype("boolean").fillna(True).astype(bool)
|
| 33 |
-
if dropped := int((~keep).sum()):
|
| 34 |
-
print(f"{label}: dropping {dropped} items unavailable on the e621 CDN")
|
| 35 |
-
|
| 36 |
-
return df[keep].reset_index(drop=True)
|
| 37 |
-
|
| 38 |
# -- Pool dataset -----------------------------------------------------------
|
| 39 |
_pool_path = hf_hub_download(
|
| 40 |
repo_id=POOL_REPO_ID,
|
|
@@ -42,221 +28,111 @@ _pool_path = hf_hub_download(
|
|
| 42 |
repo_type="dataset",
|
| 43 |
token=RATINGS_APP_TOKEN
|
| 44 |
)
|
| 45 |
-
_pool_df =
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
WINS_LOC = _pool_df.columns.get_loc("wins")
|
| 49 |
-
LOSSES_LOC = _pool_df.columns.get_loc("losses")
|
| 50 |
-
TIES_LOC = _pool_df.columns.get_loc("ties")
|
| 51 |
-
VOTES_LOC = _pool_df.columns.get_loc("votes")
|
| 52 |
-
WINRATE_LOC = _pool_df.columns.get_loc("winrate")
|
| 53 |
-
|
| 54 |
-
_md5_to_idx = { md5: idx for idx, md5 in enumerate(_pool_df["md5"]) }
|
| 55 |
-
|
| 56 |
-
_pool_lock = threading.Lock()
|
| 57 |
-
|
| 58 |
_stats_last_loaded_at = 0.0
|
|
|
|
| 59 |
_explorer_df = pd.DataFrame(columns=["group", "id", "md5", "rating", "sample_url", "image_url", "classifier", "classifier_score", "percentile"])
|
| 60 |
|
| 61 |
-
_disputed_queues: dict[str, list[tuple[int, int, float]]] = {}
|
| 62 |
-
|
| 63 |
-
def _ordered_pair(pair: tuple[str, str]) -> tuple[str, str]:
|
| 64 |
-
if pair[0] <= pair[1]:
|
| 65 |
-
return pair
|
| 66 |
-
|
| 67 |
-
return (pair[1], pair[0])
|
| 68 |
-
|
| 69 |
-
def _load_stats() -> None:
|
| 70 |
-
global _disputed_queues
|
| 71 |
-
|
| 72 |
-
VOTE_STORAGE.sync()
|
| 73 |
-
load_vote_logs(repo_id=POOL_REPO_ID, token=RATINGS_APP_TOKEN)
|
| 74 |
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
pair_votes = Counter(
|
| 89 |
-
_ordered_pair(pair)
|
| 90 |
-
for pair in zip(votes_df["md5a"], votes_df["md5b"])
|
| 91 |
)
|
| 92 |
-
|
| 93 |
-
if n_missing:
|
| 94 |
-
print(f"{n_missing} md5s have stats but are not in the pool!", file=sys.stderr)
|
| 95 |
-
|
| 96 |
-
try:
|
| 97 |
-
disputed_df = pd.read_parquet(hf_hub_download(
|
| 98 |
repo_id=POOL_REPO_ID,
|
| 99 |
-
filename="
|
| 100 |
repo_type="dataset",
|
| 101 |
token=RATINGS_APP_TOKEN,
|
| 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 |
-
pd.read_parquet(
|
| 142 |
-
test_set_path,
|
| 143 |
-
columns=["group", "id", "md5", "rating", "sample_url", "image_url", "cdn_available"],
|
| 144 |
-
),
|
| 145 |
-
"test set",
|
| 146 |
-
).drop(columns="cdn_available")
|
| 147 |
-
|
| 148 |
-
classifier_scores_df = pd.read_parquet(classifier_scores_path)
|
| 149 |
-
assert {"classifier", "md5", "classifier_score", "percentile"}.issubset(classifier_scores_df.columns), "classifier_scores.parquet missing expected columns"
|
| 150 |
-
classifier_scores_df = classifier_scores_df[["classifier", "md5", "classifier_score", "percentile"]]
|
| 151 |
-
classifier_scores_df["classifier"] = classifier_scores_df["classifier"].astype(str)
|
| 152 |
-
classifier_scores_df["md5"] = classifier_scores_df["md5"].astype(str)
|
| 153 |
-
test_df["md5"] = test_df["md5"].astype(str)
|
| 154 |
-
|
| 155 |
-
global _explorer_df
|
| 156 |
-
_explorer_df = test_df.merge(classifier_scores_df, on="md5", how="left", validate="one_to_many")
|
| 157 |
-
|
| 158 |
-
def _stats_reloader() -> None:
|
| 159 |
-
while True:
|
| 160 |
-
time.sleep(STATS_RELOAD_S)
|
| 161 |
-
_load_stats()
|
| 162 |
-
|
| 163 |
-
_load_stats()
|
| 164 |
-
threading.Thread(target=_stats_reloader, daemon=True).start()
|
| 165 |
-
|
| 166 |
-
def _pick_from(df: pd.DataFrame, *, weights: pd.Series | None = None) -> tuple[pd.Series, pd.Series, int] | None:
|
| 167 |
-
if len(df) < 2:
|
| 168 |
-
return None
|
| 169 |
-
|
| 170 |
-
sample = df.sample(2, weights=weights, replace=False)
|
| 171 |
-
return sample.iloc[0], sample.iloc[1], len(df)
|
| 172 |
-
|
| 173 |
-
def _pick_similar(
|
| 174 |
-
df: pd.DataFrame,
|
| 175 |
-
distance: Callable[[pd.DataFrame, pd.Series], pd.Series],
|
| 176 |
-
*,
|
| 177 |
-
weights: Callable[[pd.DataFrame], pd.Series] | None = None,
|
| 178 |
-
other_df: pd.DataFrame | None = None,
|
| 179 |
-
) -> tuple[pd.Series, pd.Series, int] | None:
|
| 180 |
-
if len(df) < 2:
|
| 181 |
-
return None
|
| 182 |
-
|
| 183 |
-
if other_df is None:
|
| 184 |
-
other_df = df
|
| 185 |
-
elif len(other_df) < 2:
|
| 186 |
-
return None
|
| 187 |
-
|
| 188 |
-
weight_vals: pd.Series | None = None
|
| 189 |
-
if weights is not None:
|
| 190 |
-
weight_vals = weights(df)
|
| 191 |
-
|
| 192 |
-
first = df.sample(weights=weight_vals).iloc[0]
|
| 193 |
-
weight_vals = 1.0 / (1.0 + distance(other_df, first))
|
| 194 |
-
|
| 195 |
-
while True:
|
| 196 |
-
other = other_df.sample(weights=weight_vals).iloc[0]
|
| 197 |
-
if other["md5"] != first["md5"]:
|
| 198 |
-
return first, other, len(df)
|
| 199 |
-
|
| 200 |
-
def _pool_fetch_pair(group: str) -> tuple[pd.Series, pd.Series, int, str]:
|
| 201 |
-
queue = _disputed_queues.get(group)
|
| 202 |
-
if queue and random.random() < 0.33:
|
| 203 |
-
try:
|
| 204 |
-
idx_a, idx_b, _ = queue.pop()
|
| 205 |
-
except IndexError:
|
| 206 |
-
pass
|
| 207 |
else:
|
| 208 |
-
|
|
|
|
| 209 |
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
|
|
|
| 213 |
|
| 214 |
-
# Pair first-time winners.
|
| 215 |
-
picked = _pick_from(voted[(votes == 1) & (voted["wins"] == 1)])
|
| 216 |
-
if picked is not None:
|
| 217 |
-
return *picked, "new-winners"
|
| 218 |
-
|
| 219 |
-
# Pair first-time losers.
|
| 220 |
-
picked = _pick_from(voted[(votes == 1) & (voted["losses"] == 1)])
|
| 221 |
-
if picked is not None:
|
| 222 |
-
return *picked, "new-losers"
|
| 223 |
-
|
| 224 |
-
def record_distance(df: pd.DataFrame, pivot: pd.Series) -> pd.Series:
|
| 225 |
-
return (
|
| 226 |
-
(df["wins"] - pivot["wins"])**2 +
|
| 227 |
-
(df["losses"] - pivot["losses"])**2
|
| 228 |
-
)**0.75 # L2 is a bit too loose
|
| 229 |
-
|
| 230 |
-
# Link cliques to main network and break ties.
|
| 231 |
-
nonties = votes - voted["ties"]
|
| 232 |
-
picked = _pick_similar(
|
| 233 |
-
voted[(nonties == 0) | (votes == 2)],
|
| 234 |
-
record_distance,
|
| 235 |
-
other_df=voted[nonties > 3],
|
| 236 |
-
)
|
| 237 |
-
if picked is not None:
|
| 238 |
-
return *picked, "sparse"
|
| 239 |
-
|
| 240 |
-
# Introduce new images.
|
| 241 |
-
if len(voted) < 8 or random.random() < 0.2:
|
| 242 |
-
unvoted = gdf[gdf["votes"] == 0]
|
| 243 |
-
match len(unvoted):
|
| 244 |
-
case 0:
|
| 245 |
-
pass
|
| 246 |
-
case 1:
|
| 247 |
-
return unvoted.iloc[0], voted.iloc[0], 1, "new"
|
| 248 |
-
case _:
|
| 249 |
-
picked = _pick_from(unvoted)
|
| 250 |
-
assert picked is not None
|
| 251 |
-
return *picked, "new"
|
| 252 |
-
|
| 253 |
-
# Vote-weighted random sampling between similar winrates, slighlty biased against picking losers.
|
| 254 |
-
picked = _pick_similar(
|
| 255 |
-
voted, record_distance,
|
| 256 |
-
weights=lambda df: 1.0 / (df["votes"]**1.25 + 0.1 * df["losses"]),
|
| 257 |
-
)
|
| 258 |
-
assert picked is not None
|
| 259 |
-
return *picked, "fair-probe"
|
| 260 |
|
| 261 |
def _row_image_url(row) -> str:
|
| 262 |
sample_url = row.get("sample_url")
|
|
@@ -272,56 +148,89 @@ DATASETS: dict[str, dict] = {
|
|
| 272 |
"fetch_pair": _pool_fetch_pair,
|
| 273 |
"get_id": lambda row: row["md5"],
|
| 274 |
"get_image": _row_image_url,
|
| 275 |
-
"groups": sorted(_pool_df["group"].unique()),
|
| 276 |
},
|
| 277 |
}
|
| 278 |
DEFAULT_DATASET = list(DATASETS.keys())[0]
|
| 279 |
|
| 280 |
-
def
|
| 281 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 282 |
|
| 283 |
def _render_current(state: dict, submit_status: str = "") -> tuple:
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 290 |
|
| 291 |
-
can_go_back = bool(state.get("pending", ()))
|
| 292 |
-
pair_details = f"/ {state['group']} / {state.get('pair_reason', 'unknown')} ({state.get('n_candidates', '?')})"
|
| 293 |
|
| 294 |
-
return img_a_html, img_b_html, gr.Button(interactive=can_go_back), html.escape(pair_details), _submit_status(submit_status), state
|
| 295 |
|
| 296 |
def _normalize_rating_pref(pref: str | None) -> str:
|
| 297 |
return pref if pref in ("safe", "all") else "safe"
|
| 298 |
|
| 299 |
-
|
| 300 |
-
|
|
|
|
| 301 |
submit_key = _normalize_submit_key(submit_key)
|
| 302 |
-
|
| 303 |
-
return rating_pref, submit_key, rater_name, image_height, image_height, groups, *new_round(DEFAULT_DATASET, groups, state)
|
| 304 |
|
| 305 |
-
def _on_groups_change(groups: list[str], state: dict):
|
| 306 |
-
return *new_round(DEFAULT_DATASET, groups, state), groups
|
| 307 |
|
| 308 |
-
def
|
| 309 |
-
|
|
|
|
| 310 |
|
| 311 |
-
def _normalize_submit_key(submit_key: str | None) -> str:
|
| 312 |
-
return (submit_key or "").strip()
|
| 313 |
|
| 314 |
-
def
|
| 315 |
-
return
|
| 316 |
|
| 317 |
-
def _submit_status(message: str = "") -> gr.HTML:
|
| 318 |
-
# "hidden" takes up no space but keeps the block mounted, so the first message
|
| 319 |
-
# to arrive renders immediately instead of on the next update.
|
| 320 |
-
return gr.HTML(value=message, visible=True if message else "hidden")
|
| 321 |
|
| 322 |
def _filtered_explorer_df(rating_pref: str) -> pd.DataFrame:
|
| 323 |
return _filtered_explorer_df_by_classifier(rating_pref, ALLOWED_CLASSIFIER_FILTERS[0])
|
| 324 |
|
|
|
|
| 325 |
def _filtered_explorer_df_by_classifier(rating_pref: str, classifier_name: str) -> pd.DataFrame:
|
| 326 |
if rating_pref == "all":
|
| 327 |
rating_filtered = _explorer_df
|
|
@@ -331,10 +240,12 @@ def _filtered_explorer_df_by_classifier(rating_pref: str, classifier_name: str)
|
|
| 331 |
assert classifier_name in ALLOWED_CLASSIFIER_FILTERS, f"Unsupported classifier filter: {classifier_name}"
|
| 332 |
return rating_filtered[rating_filtered["classifier"] == classifier_name]
|
| 333 |
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
|
|
|
|
|
|
| 338 |
filtered_explorer_df = _filtered_explorer_df_by_classifier(rating_pref, classifier_name)
|
| 339 |
summary, score_distribution_plot, distribution_data, gallery_items, page_meta, next_offset, btn_update = build_results_data(
|
| 340 |
filtered_explorer_df,
|
|
@@ -345,10 +256,12 @@ def _load_results(rating_pref: str, sort_mode: str, classifier_filter: str):
|
|
| 345 |
)
|
| 346 |
return summary, score_distribution_plot, distribution_data, gallery_items, btn_update, "Click an image to reveal its ID and link.", page_meta, next_offset
|
| 347 |
|
|
|
|
| 348 |
def _normalize_sort_mode(sort_mode: str | None) -> str:
|
| 349 |
-
if sort_mode in
|
| 350 |
return sort_mode
|
| 351 |
-
return
|
|
|
|
| 352 |
|
| 353 |
def _normalize_classifier_filter(classifier_name: str | None) -> str:
|
| 354 |
if classifier_name in ALLOWED_CLASSIFIER_FILTERS:
|
|
@@ -357,36 +270,33 @@ def _normalize_classifier_filter(classifier_name: str | None) -> str:
|
|
| 357 |
|
| 358 |
# -- Gradio callbacks -------------------------------------------------------
|
| 359 |
|
| 360 |
-
def new_round(dataset_name: str,
|
| 361 |
-
if not groups:
|
| 362 |
-
return "", "", gr.skip(), "", _submit_status("Please select at least one group."), state
|
| 363 |
-
|
| 364 |
cfg = DATASETS[dataset_name]
|
|
|
|
|
|
|
| 365 |
group = random.choice(groups)
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
|
|
|
| 371 |
state.setdefault("session_id", uuid.uuid4().hex)
|
| 372 |
-
state["n_candidates"] = n_candidates
|
| 373 |
key_a = cfg["get_id"](row_a)
|
| 374 |
key_b = cfg["get_id"](row_b)
|
| 375 |
id_a = int(row_a["id"])
|
| 376 |
id_b = int(row_b["id"])
|
| 377 |
-
state.update(dataset=dataset_name, key_a=key_a, key_b=key_b, id_a=id_a, id_b=id_b, group=group, pair_reason=pair_reason)
|
| 378 |
url_a = cfg["get_image"](row_a)
|
| 379 |
url_b = cfg["get_image"](row_b)
|
| 380 |
state["url_a"] = url_a
|
| 381 |
state["url_b"] = url_b
|
| 382 |
return _render_current(state)
|
| 383 |
|
| 384 |
-
def _queue_decision(winner: str | None, state: dict
|
| 385 |
assert state.get("session_id"), "Missing session_id: refusing to record vote"
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
pending.append({
|
| 389 |
-
"rater_name": rater_name,
|
| 390 |
"winner": winner,
|
| 391 |
"key_a": state["key_a"],
|
| 392 |
"key_b": state["key_b"],
|
|
@@ -395,92 +305,53 @@ def _queue_decision(winner: str | None, state: dict, rater_name: str):
|
|
| 395 |
"url_a": state["url_a"],
|
| 396 |
"url_b": state["url_b"],
|
| 397 |
"dataset": state["dataset"],
|
|
|
|
| 398 |
"group": state["group"],
|
| 399 |
"pair_reason": state.get("pair_reason", ""),
|
| 400 |
"session_id": state["session_id"],
|
| 401 |
-
}
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
|
| 411 |
-
|
| 412 |
-
|
| 413 |
-
submit_key = _normalize_submit_key(submit_key)
|
| 414 |
-
if submit_key != SUBMIT_KEY:
|
| 415 |
-
headline = "That submit key isn't valid." if submit_key else "A submit key is required to record votes."
|
| 416 |
-
return _render_current(state, (
|
| 417 |
-
f"<strong>{headline}</strong> Nothing you vote on is being saved. "
|
| 418 |
-
"Enter a working key under <em>Settings → Submit Key</em> — ask for one on "
|
| 419 |
-
"<a href='https://discord.com/channels/1019133813105905664/1531769333196259499/1531769333196259499' target='_blank' rel='noopener noreferrer'>Discord</a>."
|
| 420 |
-
))
|
| 421 |
-
|
| 422 |
-
if not groups:
|
| 423 |
-
return "", "", gr.skip(), "", _submit_status("Please select at least one group."), state
|
| 424 |
-
|
| 425 |
-
_queue_decision(winner, state, _normalize_rater_name(rater_name))
|
| 426 |
-
|
| 427 |
-
a_idx = _md5_to_idx[state["key_a"]]
|
| 428 |
-
b_idx = _md5_to_idx[state["key_b"]]
|
| 429 |
-
with _pool_lock:
|
| 430 |
-
match winner:
|
| 431 |
-
case "A":
|
| 432 |
-
_add_vote(a_idx, WINS_LOC)
|
| 433 |
-
_add_vote(b_idx, LOSSES_LOC)
|
| 434 |
-
case "B":
|
| 435 |
-
_add_vote(a_idx, LOSSES_LOC)
|
| 436 |
-
_add_vote(b_idx, WINS_LOC)
|
| 437 |
-
case None:
|
| 438 |
-
_add_vote(a_idx, TIES_LOC)
|
| 439 |
-
_add_vote(b_idx, TIES_LOC)
|
| 440 |
-
case _:
|
| 441 |
-
raise AssertionError
|
| 442 |
-
|
| 443 |
-
return new_round(state["dataset"], groups, state)
|
| 444 |
|
| 445 |
def go_back(state: dict) -> tuple:
|
| 446 |
pending = state.setdefault("pending", [])
|
| 447 |
-
|
| 448 |
-
|
| 449 |
-
|
| 450 |
-
|
| 451 |
-
|
| 452 |
-
|
| 453 |
-
|
| 454 |
-
|
| 455 |
-
|
| 456 |
-
|
| 457 |
-
|
| 458 |
-
|
| 459 |
-
|
| 460 |
-
|
| 461 |
-
|
| 462 |
-
|
| 463 |
-
|
| 464 |
-
|
| 465 |
-
|
| 466 |
-
|
| 467 |
-
|
| 468 |
-
|
| 469 |
-
case "B":
|
| 470 |
-
_add_vote(a_idx, LOSSES_LOC, -1)
|
| 471 |
-
_add_vote(b_idx, WINS_LOC, -1)
|
| 472 |
-
case None:
|
| 473 |
-
_add_vote(a_idx, TIES_LOC, -1)
|
| 474 |
-
_add_vote(b_idx, TIES_LOC, -1)
|
| 475 |
-
case _:
|
| 476 |
-
raise AssertionError
|
| 477 |
-
|
| 478 |
return _render_current(state)
|
| 479 |
|
| 480 |
# -- UI ---------------------------------------------------------------------
|
| 481 |
|
| 482 |
with gr.Blocks(
|
| 483 |
-
title="
|
| 484 |
head="""
|
| 485 |
<script>
|
| 486 |
const VOTE_COOLDOWN_MS = 1500;
|
|
@@ -563,15 +434,9 @@ with gr.Blocks(
|
|
| 563 |
} else if ((e.key === 'z' || e.key === 'Z') && (e.ctrlKey || e.metaKey) && ratingTabActive) {
|
| 564 |
e.preventDefault();
|
| 565 |
backButton?.click();
|
| 566 |
-
} else if (e.key === 'ArrowDown') {
|
| 567 |
-
|
| 568 |
-
|
| 569 |
-
backButton?.click();
|
| 570 |
-
}
|
| 571 |
-
if (resultsTabActive) {
|
| 572 |
-
e.preventDefault();
|
| 573 |
-
resultsLoadMoreButton?.click();
|
| 574 |
-
}
|
| 575 |
}
|
| 576 |
});
|
| 577 |
document.addEventListener('click', function (e) {
|
|
@@ -631,6 +496,7 @@ with gr.Blocks(
|
|
| 631 |
}
|
| 632 |
.rating-image-frame {
|
| 633 |
width: 100%;
|
|
|
|
| 634 |
border: 1px solid #e6e6e6;
|
| 635 |
border-radius: 8px;
|
| 636 |
background: #333;
|
|
@@ -663,18 +529,21 @@ with gr.Blocks(
|
|
| 663 |
.hidden-action-btn {
|
| 664 |
display: none !important;
|
| 665 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 666 |
.submit-status-msg {
|
| 667 |
-
|
| 668 |
-
|
| 669 |
-
|
| 670 |
border-radius: 8px;
|
| 671 |
-
|
| 672 |
-
font-size: 0.95rem;
|
| 673 |
-
line-height: 1.45;
|
| 674 |
-
}
|
| 675 |
-
.submit-status-msg a {
|
| 676 |
-
text-decoration: underline;
|
| 677 |
-
font-weight: 600;
|
| 678 |
}
|
| 679 |
#results-gallery {
|
| 680 |
--explorer-thumb-ratio: 1 / 1;
|
|
@@ -689,77 +558,54 @@ with gr.Blocks(
|
|
| 689 |
object-fit: contain !important;
|
| 690 |
background: #1f2937;
|
| 691 |
}
|
| 692 |
-
a {
|
| 693 |
-
padding: 0 !important;
|
| 694 |
-
}
|
| 695 |
""",
|
| 696 |
-
fill_width=True,
|
| 697 |
) as demo:
|
| 698 |
state = gr.State({})
|
| 699 |
rating_pref_store = gr.BrowserState(default_value="safe", storage_key="rating_pref")
|
| 700 |
submit_key_store = gr.BrowserState(default_value="", storage_key="submit_key")
|
| 701 |
-
|
| 702 |
-
results_sort_store = gr.BrowserState(default_value=DISAGREEMENT_SORT_MODE, storage_key="results_sort_mode")
|
| 703 |
results_classifier_store = gr.BrowserState(default_value=ALLOWED_CLASSIFIER_FILTERS[0], storage_key="results_classifier")
|
| 704 |
-
image_height_store = gr.BrowserState(default_value=768, storage_key="image_height")
|
| 705 |
-
groups_store = gr.BrowserState(default_value=[
|
| 706 |
-
group
|
| 707 |
-
for group in DATASETS[DEFAULT_DATASET]["groups"]
|
| 708 |
-
if group.endswith("_safe")
|
| 709 |
-
], storage_key="groups")
|
| 710 |
|
| 711 |
with gr.Tabs():
|
| 712 |
with gr.Tab("Image Quality Rater"):
|
| 713 |
gr.Markdown("Rate relative image quality. Choose the image with better quality, or select same quality if they are comparable. Both images are drawn from the same group to avoid cross-group bias.")
|
| 714 |
-
submit_status = gr.HTML(html_template="<div class='submit-status-msg'>${value}</div>", elem_id="submit-status", visible="hidden")
|
| 715 |
|
| 716 |
with gr.Row():
|
| 717 |
img_a = gr.HTML(elem_id="img-a")
|
| 718 |
img_b = gr.HTML(elem_id="img-b")
|
| 719 |
|
| 720 |
-
with gr.Row(
|
| 721 |
-
btn_a = gr.Button("
|
| 722 |
-
|
| 723 |
-
|
| 724 |
-
btn_skip = gr.Button("⬆️ Same Quality", elem_id="btn-skip")
|
| 725 |
-
btn_back_action = gr.Button("⬇️ Undo", elem_id="btn-back-action")
|
| 726 |
-
|
| 727 |
-
btn_b = gr.Button("➡️ Prefer B", variant="primary", elem_id="btn-vote-b")
|
| 728 |
|
| 729 |
with gr.Accordion("Settings", open=False):
|
| 730 |
-
|
| 731 |
-
|
| 732 |
-
|
| 733 |
-
|
| 734 |
-
|
| 735 |
-
|
| 736 |
-
image_height_slider = gr.Slider(
|
| 737 |
-
minimum=512, maximum=2048, step=16, precision=0,
|
| 738 |
-
label="Image Size",
|
| 739 |
)
|
| 740 |
submit_key_tb = gr.Textbox(
|
| 741 |
value="",
|
| 742 |
type="password",
|
| 743 |
-
label="Submit
|
| 744 |
elem_id="submit-key",
|
| 745 |
)
|
| 746 |
-
rater_name_tb = gr.Textbox(
|
| 747 |
-
value="",
|
| 748 |
-
label="Rater Name",
|
| 749 |
-
elem_id="rater-name",
|
| 750 |
-
max_length=RATER_NAME_MAX_LEN,
|
| 751 |
-
info=(
|
| 752 |
-
"Entirely optional. Leave this blank to rate anonymously"
|
| 753 |
-
),
|
| 754 |
-
)
|
| 755 |
|
| 756 |
-
|
| 757 |
-
gr.
|
| 758 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 759 |
|
| 760 |
(
|
| 761 |
results_summary_md,
|
| 762 |
-
results_rating_dd,
|
| 763 |
results_sort_dd,
|
| 764 |
results_classifier_dd,
|
| 765 |
results_score_distribution_plot,
|
|
@@ -771,7 +617,7 @@ with gr.Blocks(
|
|
| 771 |
results_page_offset_state,
|
| 772 |
) = add_results_tab(_pool_df)
|
| 773 |
|
| 774 |
-
outputs = [img_a, img_b,
|
| 775 |
results_outputs = [
|
| 776 |
results_summary_md,
|
| 777 |
results_score_distribution_plot,
|
|
@@ -783,29 +629,22 @@ with gr.Blocks(
|
|
| 783 |
results_page_offset_state,
|
| 784 |
]
|
| 785 |
|
| 786 |
-
|
| 787 |
-
|
| 788 |
-
|
| 789 |
-
btn_skip.click(fn=lambda s, g, k, n: vote(None, s, g, k, n), inputs=vote_inputs, outputs=outputs, queue=False, show_progress="hidden")
|
| 790 |
btn_back_action.click(fn=go_back, inputs=[state], outputs=outputs, queue=False, show_progress="hidden")
|
| 791 |
-
|
| 792 |
-
submit_key_tb.
|
| 793 |
-
|
| 794 |
-
|
| 795 |
-
image_height_slider.change(fn=_on_image_height_change, inputs=[image_height_slider], outputs=[image_height_store, image_height], queue=False, show_progress="hidden")
|
| 796 |
-
|
| 797 |
-
results_rating_dd.change(fn=_normalize_rating_pref, inputs=[results_rating_dd], outputs=[rating_pref_store], queue=False, show_progress="hidden")
|
| 798 |
-
results_rating_dd.change(fn=_load_results, inputs=[results_rating_dd, results_sort_store, results_classifier_store], outputs=results_outputs, queue=False, show_progress="hidden")
|
| 799 |
results_sort_dd.change(fn=_normalize_sort_mode, inputs=[results_sort_dd], outputs=[results_sort_store], queue=False, show_progress="hidden")
|
| 800 |
results_sort_dd.change(fn=_load_results, inputs=[rating_pref_store, results_sort_dd, results_classifier_store], outputs=results_outputs, queue=False, show_progress="hidden")
|
| 801 |
results_classifier_dd.change(fn=_normalize_classifier_filter, inputs=[results_classifier_dd], outputs=[results_classifier_store], queue=False, show_progress="hidden")
|
| 802 |
results_classifier_dd.change(fn=_load_results, inputs=[rating_pref_store, results_sort_store, results_classifier_dd], outputs=results_outputs, queue=False, show_progress="hidden")
|
| 803 |
-
|
| 804 |
-
demo.load(fn=_initial_load, inputs=[state, rating_pref_store, submit_key_store, rater_name_store, image_height_store, groups_store], outputs=[results_rating_dd, submit_key_tb, rater_name_tb, image_height_slider, image_height, groups_select, *outputs], queue=False, show_progress="hidden")
|
| 805 |
demo.load(fn=_load_results, inputs=[rating_pref_store, results_sort_store, results_classifier_store], outputs=results_outputs, queue=False, show_progress="hidden")
|
| 806 |
demo.load(fn=_normalize_sort_mode, inputs=[results_sort_store], outputs=[results_sort_dd], queue=False, show_progress="hidden")
|
| 807 |
demo.load(fn=_normalize_classifier_filter, inputs=[results_classifier_store], outputs=[results_classifier_dd], queue=False, show_progress="hidden")
|
| 808 |
-
|
| 809 |
results_load_more_btn.click(
|
| 810 |
fn=lambda r, s, c, o: load_more_results(_filtered_explorer_df_by_classifier(_normalize_rating_pref(r), _normalize_classifier_filter(c)), _explorer_df, s, o),
|
| 811 |
inputs=[rating_pref_store, results_sort_store, results_classifier_store, results_page_offset_state],
|
|
|
|
| 5 |
import uuid
|
| 6 |
import os
|
| 7 |
import html
|
|
|
|
|
|
|
|
|
|
| 8 |
|
| 9 |
import pandas as pd
|
| 10 |
from huggingface_hub import hf_hub_download
|
|
|
|
| 11 |
|
| 12 |
from storage import VoteStorage
|
| 13 |
+
from stats_from_logs import load_stats_by_md5
|
| 14 |
+
from explorer import ALLOWED_CLASSIFIER_FILTERS, add_results_tab, build_results_data, load_more_results, on_gallery_select
|
|
|
|
|
|
|
| 15 |
|
| 16 |
DEBUG_MODE = os.getenv("DEBUG", "0").lower() in ("1", "true", "yes", "on")
|
| 17 |
RATINGS_APP_TOKEN = os.getenv("RATINGS_APP_TOKEN")
|
| 18 |
SUBMIT_KEY = os.getenv("RATINGS_SUBMIT_KEY")
|
| 19 |
assert SUBMIT_KEY, "Missing required env var: RATINGS_SUBMIT_KEY"
|
| 20 |
POOL_REPO_ID = "taigasan/e6-visual-ratings"
|
|
|
|
| 21 |
VOTE_STORAGE = VoteStorage(mode="void" if DEBUG_MODE else "hf", token=RATINGS_APP_TOKEN)
|
| 22 |
STATS_RELOAD_S = 30 * 60
|
| 23 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
# -- Pool dataset -----------------------------------------------------------
|
| 25 |
_pool_path = hf_hub_download(
|
| 26 |
repo_id=POOL_REPO_ID,
|
|
|
|
| 28 |
repo_type="dataset",
|
| 29 |
token=RATINGS_APP_TOKEN
|
| 30 |
)
|
| 31 |
+
_pool_df = pd.read_parquet(_pool_path)
|
| 32 |
+
_pool_group_dfs = {g: gdf for g, gdf in _pool_df.groupby("group")}
|
| 33 |
+
_stats_lock = threading.Lock()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
_stats_last_loaded_at = 0.0
|
| 35 |
+
_stats_by_key: dict[str, tuple[int, int]] = {}
|
| 36 |
_explorer_df = pd.DataFrame(columns=["group", "id", "md5", "rating", "sample_url", "image_url", "classifier", "classifier_score", "percentile"])
|
| 37 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
|
| 39 |
+
def _reload_stats_if_due(force: bool = False):
|
| 40 |
+
global _stats_last_loaded_at, _stats_by_key, _explorer_df
|
| 41 |
+
now = time.time()
|
| 42 |
+
if not force and (now - _stats_last_loaded_at) < STATS_RELOAD_S:
|
| 43 |
+
return
|
| 44 |
+
with _stats_lock:
|
| 45 |
+
now = time.time()
|
| 46 |
+
if not force and (now - _stats_last_loaded_at) < STATS_RELOAD_S:
|
| 47 |
+
return
|
| 48 |
+
_stats_by_key = load_stats_by_md5(
|
| 49 |
+
repo_id=POOL_REPO_ID,
|
| 50 |
+
token=RATINGS_APP_TOKEN,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
)
|
| 52 |
+
classifier_scores_path = hf_hub_download(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
repo_id=POOL_REPO_ID,
|
| 54 |
+
filename="classifier_scores.parquet",
|
| 55 |
repo_type="dataset",
|
| 56 |
token=RATINGS_APP_TOKEN,
|
| 57 |
+
)
|
| 58 |
+
validation_set_path = hf_hub_download(
|
| 59 |
+
repo_id=POOL_REPO_ID,
|
| 60 |
+
filename="validation_set.parquet",
|
| 61 |
+
repo_type="dataset",
|
| 62 |
+
token=RATINGS_APP_TOKEN,
|
| 63 |
+
)
|
| 64 |
+
validation_df = pd.read_parquet(
|
| 65 |
+
validation_set_path,
|
| 66 |
+
columns=["group", "id", "md5", "rating", "sample_url", "image_url"],
|
| 67 |
+
)
|
| 68 |
+
classifier_scores_df = pd.read_parquet(classifier_scores_path)
|
| 69 |
+
assert {"classifier", "md5", "classifier_score", "percentile"}.issubset(classifier_scores_df.columns), "classifier_scores.parquet missing expected columns"
|
| 70 |
+
classifier_scores_df = classifier_scores_df[["classifier", "md5", "classifier_score", "percentile"]]
|
| 71 |
+
classifier_scores_df["classifier"] = classifier_scores_df["classifier"].astype(str)
|
| 72 |
+
classifier_scores_df["md5"] = classifier_scores_df["md5"].astype(str)
|
| 73 |
+
validation_df["md5"] = validation_df["md5"].astype(str)
|
| 74 |
+
_explorer_df = validation_df.merge(classifier_scores_df, on="md5", how="left", validate="one_to_many")
|
| 75 |
+
_stats_last_loaded_at = now
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
_reload_stats_if_due(force=True)
|
| 79 |
+
|
| 80 |
+
def _pool_fetch_pair(group_name: str) -> tuple:
|
| 81 |
+
gdf = _pool_group_dfs[group_name]
|
| 82 |
+
assert len(gdf) >= 2, f"Not enough rows for group: {group_name}"
|
| 83 |
+
md5_keys = gdf["md5"].astype(str)
|
| 84 |
+
wins = md5_keys.map(lambda k: _stats_by_key.get(k, (0, 0))[0])
|
| 85 |
+
losses = md5_keys.map(lambda k: _stats_by_key.get(k, (0, 0))[1])
|
| 86 |
+
|
| 87 |
+
def _pick_from_mask(mask: pd.Series):
|
| 88 |
+
candidate_df = gdf[mask]
|
| 89 |
+
if len(candidate_df) < 2:
|
| 90 |
+
return None
|
| 91 |
+
sample = candidate_df.sample(2, replace=False)
|
| 92 |
+
return sample.iloc[0], sample.iloc[1]
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
# 1) Repeat the lowest-margin edge participating in a cycle. (To prevent deadlock, stop if all margins are 4+.)
|
| 96 |
+
# a) If deadlocked on a cycle with 4+ images and no inner cycles, sample a random missing edge inside the cycle.
|
| 97 |
+
# 2) Pair images that both have wins only . (One of them will lose/tie. Stop when there is only one left.)
|
| 98 |
+
# 3) Pair images that both have losses only. (One of them will win/tie. Stop when there is only one left.)
|
| 99 |
+
# 4) Pair images with only 2 edges.
|
| 100 |
+
# 5) X% chance, re-sample an existing edge, inversely proportional to existing number of samples.
|
| 101 |
+
# 6) Y% chance, sample a random missing edge between images already sampled.
|
| 102 |
+
# 7) Pair an unsampled image with a random sampled image.
|
| 103 |
+
|
| 104 |
+
# 2) Pair images that currently have wins-only records.
|
| 105 |
+
picked = _pick_from_mask((wins > 0) & (losses == 0))
|
| 106 |
+
if picked is not None:
|
| 107 |
+
return picked[0], picked[1], "wins-only"
|
| 108 |
|
| 109 |
+
# 3) Pair images that currently have losses-only records.
|
| 110 |
+
picked = _pick_from_mask((wins == 0) & (losses > 0))
|
| 111 |
+
if picked is not None:
|
| 112 |
+
return picked[0], picked[1], "losses-only"
|
| 113 |
|
| 114 |
+
# 4) Pair images that currently have exactly 2 total edges.
|
| 115 |
+
vote_totals = wins + losses
|
| 116 |
+
picked = _pick_from_mask(vote_totals == 2)
|
| 117 |
+
if picked is not None:
|
| 118 |
+
return picked[0], picked[1], "total_votes=2"
|
| 119 |
+
|
| 120 |
+
# 7) Prefer pairing an unsampled image with a random previously sampled image.
|
| 121 |
+
unsampled_mask = vote_totals == 0
|
| 122 |
+
if unsampled_mask.any():
|
| 123 |
+
unsampled_row = gdf[unsampled_mask].sample(1).iloc[0]
|
| 124 |
+
sampled_df = gdf[~unsampled_mask]
|
| 125 |
+
if len(sampled_df) >= 1:
|
| 126 |
+
sampled_row = sampled_df.sample(1).iloc[0]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 127 |
else:
|
| 128 |
+
sampled_row = gdf.drop(index=unsampled_row.name).sample(1).iloc[0]
|
| 129 |
+
return unsampled_row, sampled_row, "unsampled+sampled"
|
| 130 |
|
| 131 |
+
# 8) Safety fall back to low-vote weighted sampling.
|
| 132 |
+
sample_weights = 1.0 / (vote_totals + 1.0)
|
| 133 |
+
sample = gdf.sample(2, weights=sample_weights, replace=False)
|
| 134 |
+
return sample.iloc[0], sample.iloc[1], "low-vote"
|
| 135 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 136 |
|
| 137 |
def _row_image_url(row) -> str:
|
| 138 |
sample_url = row.get("sample_url")
|
|
|
|
| 148 |
"fetch_pair": _pool_fetch_pair,
|
| 149 |
"get_id": lambda row: row["md5"],
|
| 150 |
"get_image": _row_image_url,
|
| 151 |
+
"groups": {g: g for g in sorted(_pool_df["group"].unique())},
|
| 152 |
},
|
| 153 |
}
|
| 154 |
DEFAULT_DATASET = list(DATASETS.keys())[0]
|
| 155 |
|
| 156 |
+
def _select_groups(cfg: dict, rating_pref: str) -> list[str]:
|
| 157 |
+
groups = list(cfg["groups"].keys())
|
| 158 |
+
if rating_pref == "all":
|
| 159 |
+
return groups
|
| 160 |
+
return [g for g in groups if g.endswith(f"_{rating_pref}")]
|
| 161 |
+
|
| 162 |
+
def _commit_oldest_pending(state: dict):
|
| 163 |
+
pending = state.setdefault("pending", [])
|
| 164 |
+
if len(pending) <= 1:
|
| 165 |
+
return
|
| 166 |
+
oldest = pending.pop(0)
|
| 167 |
+
if oldest.get("winner") in ("A", "B"):
|
| 168 |
+
_apply_local_stats_update(oldest["winner"], oldest["key_a"], oldest["key_b"])
|
| 169 |
+
threading.Thread(target=VOTE_STORAGE.append_vote_row, args=(oldest.copy(), oldest.get("winner")), daemon=True).start()
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
def _apply_local_stats_update(winner: str, key_a: str, key_b: str):
|
| 173 |
+
assert winner in ("A", "B")
|
| 174 |
+
with _stats_lock:
|
| 175 |
+
wins_a, losses_a = _stats_by_key.get(str(key_a), (0, 0))
|
| 176 |
+
wins_b, losses_b = _stats_by_key.get(str(key_b), (0, 0))
|
| 177 |
+
if winner == "A":
|
| 178 |
+
_stats_by_key[str(key_a)] = (wins_a + 1, losses_a)
|
| 179 |
+
_stats_by_key[str(key_b)] = (wins_b, losses_b + 1)
|
| 180 |
+
else:
|
| 181 |
+
_stats_by_key[str(key_a)] = (wins_a, losses_a + 1)
|
| 182 |
+
_stats_by_key[str(key_b)] = (wins_b + 1, losses_b)
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
def _format_rating_post_row(post_id: int, wins: int, losses: int, label: str | None = None) -> str:
|
| 186 |
+
total_votes = wins + losses
|
| 187 |
+
url = f"https://e621.net/posts/{post_id}"
|
| 188 |
+
row = f"{url} | Times rated: {total_votes}"
|
| 189 |
+
return f"{label}: {row}" if label else row
|
| 190 |
|
| 191 |
def _render_current(state: dict, submit_status: str = "") -> tuple:
|
| 192 |
+
_reload_stats_if_due()
|
| 193 |
+
wins_a, losses_a = _stats_by_key.get(str(state["key_a"]), (0, 0))
|
| 194 |
+
wins_b, losses_b = _stats_by_key.get(str(state["key_b"]), (0, 0))
|
| 195 |
+
title_a = "Image A"
|
| 196 |
+
title_b = "Image B"
|
| 197 |
+
img_a_html = f"<div class=\"rating-card\"><div class=\"rating-card-title\"><strong>{html.escape(title_a)}</strong></div><div class=\"rating-image-frame\"><img src=\"{html.escape(state['url_a'])}\" class=\"rating-image\" loading=\"eager\" referrerpolicy=\"no-referrer\"></div></div>"
|
| 198 |
+
img_b_html = f"<div class=\"rating-card\"><div class=\"rating-card-title\"><strong>{html.escape(title_b)}</strong></div><div class=\"rating-image-frame\"><img src=\"{html.escape(state['url_b'])}\" class=\"rating-image\" loading=\"eager\" referrerpolicy=\"no-referrer\"></div></div>"
|
| 199 |
+
link_a = _format_rating_post_row(state["id_a"], wins_a, losses_a, label="Image A")
|
| 200 |
+
link_b = _format_rating_post_row(state["id_b"], wins_b, losses_b, label="Image B")
|
| 201 |
+
can_go_back = bool(state.get("can_go_back"))
|
| 202 |
+
back_md = "[Undo Rating (Ctrl+z)](#back)" if can_go_back else "<span class='subtle-back-link-disabled'>Undo Rating (Ctrl+z)</span>"
|
| 203 |
+
group_md = f"<span class='subtle-note'>Group: {state['group']}</span>"
|
| 204 |
+
pair_reason = state.get("pair_reason", "")
|
| 205 |
+
pair_reason_md = f"<span class='subtle-note'>Pair: {html.escape(pair_reason)}</span>" if pair_reason else ""
|
| 206 |
+
status_md = f"<span class='submit-status-msg'>{html.escape(submit_status)}</span>" if submit_status else ""
|
| 207 |
+
return img_a_html, img_b_html, link_a, link_b, back_md, group_md, pair_reason_md, status_md, state
|
| 208 |
|
|
|
|
|
|
|
| 209 |
|
|
|
|
| 210 |
|
| 211 |
def _normalize_rating_pref(pref: str | None) -> str:
|
| 212 |
return pref if pref in ("safe", "all") else "safe"
|
| 213 |
|
| 214 |
+
|
| 215 |
+
def _initial_load(state: dict, pref: str | None, submit_key: str | None):
|
| 216 |
+
rating_pref = _normalize_rating_pref(pref)
|
| 217 |
submit_key = _normalize_submit_key(submit_key)
|
| 218 |
+
return rating_pref, submit_key, *new_round(DEFAULT_DATASET, rating_pref, state)
|
|
|
|
| 219 |
|
|
|
|
|
|
|
| 220 |
|
| 221 |
+
def _on_rating_change(rating_pref: str, state: dict):
|
| 222 |
+
rating_pref = _normalize_rating_pref(rating_pref)
|
| 223 |
+
return *new_round(DEFAULT_DATASET, rating_pref, state), rating_pref
|
| 224 |
|
|
|
|
|
|
|
| 225 |
|
| 226 |
+
def _normalize_submit_key(submit_key: str | None) -> str:
|
| 227 |
+
return submit_key or ""
|
| 228 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 229 |
|
| 230 |
def _filtered_explorer_df(rating_pref: str) -> pd.DataFrame:
|
| 231 |
return _filtered_explorer_df_by_classifier(rating_pref, ALLOWED_CLASSIFIER_FILTERS[0])
|
| 232 |
|
| 233 |
+
|
| 234 |
def _filtered_explorer_df_by_classifier(rating_pref: str, classifier_name: str) -> pd.DataFrame:
|
| 235 |
if rating_pref == "all":
|
| 236 |
rating_filtered = _explorer_df
|
|
|
|
| 240 |
assert classifier_name in ALLOWED_CLASSIFIER_FILTERS, f"Unsupported classifier filter: {classifier_name}"
|
| 241 |
return rating_filtered[rating_filtered["classifier"] == classifier_name]
|
| 242 |
|
| 243 |
+
|
| 244 |
+
def _load_results(rating_pref_value: str, sort_mode_value: str, classifier_filter_value: str):
|
| 245 |
+
rating_pref = _normalize_rating_pref(rating_pref_value)
|
| 246 |
+
sort_mode = _normalize_sort_mode(sort_mode_value)
|
| 247 |
+
classifier_name = _normalize_classifier_filter(classifier_filter_value)
|
| 248 |
+
_reload_stats_if_due()
|
| 249 |
filtered_explorer_df = _filtered_explorer_df_by_classifier(rating_pref, classifier_name)
|
| 250 |
summary, score_distribution_plot, distribution_data, gallery_items, page_meta, next_offset, btn_update = build_results_data(
|
| 251 |
filtered_explorer_df,
|
|
|
|
| 256 |
)
|
| 257 |
return summary, score_distribution_plot, distribution_data, gallery_items, btn_update, "Click an image to reveal its ID and link.", page_meta, next_offset
|
| 258 |
|
| 259 |
+
|
| 260 |
def _normalize_sort_mode(sort_mode: str | None) -> str:
|
| 261 |
+
if sort_mode in ("Default", "Rating: Low to High", "Rating: High to Low"):
|
| 262 |
return sort_mode
|
| 263 |
+
return "Default"
|
| 264 |
+
|
| 265 |
|
| 266 |
def _normalize_classifier_filter(classifier_name: str | None) -> str:
|
| 267 |
if classifier_name in ALLOWED_CLASSIFIER_FILTERS:
|
|
|
|
| 270 |
|
| 271 |
# -- Gradio callbacks -------------------------------------------------------
|
| 272 |
|
| 273 |
+
def new_round(dataset_name: str, rating_pref: str, state: dict) -> tuple:
|
|
|
|
|
|
|
|
|
|
| 274 |
cfg = DATASETS[dataset_name]
|
| 275 |
+
groups = _select_groups(cfg, rating_pref)
|
| 276 |
+
assert groups, f"No groups for rating preference: {rating_pref}"
|
| 277 |
group = random.choice(groups)
|
| 278 |
+
pair_data = cfg["fetch_pair"](cfg["groups"][group])
|
| 279 |
+
if len(pair_data) == 3:
|
| 280 |
+
row_a, row_b, pair_reason = pair_data
|
| 281 |
+
else:
|
| 282 |
+
row_a, row_b = pair_data
|
| 283 |
+
pair_reason = ""
|
| 284 |
state.setdefault("session_id", uuid.uuid4().hex)
|
|
|
|
| 285 |
key_a = cfg["get_id"](row_a)
|
| 286 |
key_b = cfg["get_id"](row_b)
|
| 287 |
id_a = int(row_a["id"])
|
| 288 |
id_b = int(row_b["id"])
|
| 289 |
+
state.update(dataset=dataset_name, rating_pref=rating_pref, key_a=key_a, key_b=key_b, id_a=id_a, id_b=id_b, group=group, pair_reason=pair_reason)
|
| 290 |
url_a = cfg["get_image"](row_a)
|
| 291 |
url_b = cfg["get_image"](row_b)
|
| 292 |
state["url_a"] = url_a
|
| 293 |
state["url_b"] = url_b
|
| 294 |
return _render_current(state)
|
| 295 |
|
| 296 |
+
def _queue_decision(winner: str | None, state: dict):
|
| 297 |
assert state.get("session_id"), "Missing session_id: refusing to record vote"
|
| 298 |
+
state.setdefault("pending", [])
|
| 299 |
+
decision = {
|
|
|
|
|
|
|
| 300 |
"winner": winner,
|
| 301 |
"key_a": state["key_a"],
|
| 302 |
"key_b": state["key_b"],
|
|
|
|
| 305 |
"url_a": state["url_a"],
|
| 306 |
"url_b": state["url_b"],
|
| 307 |
"dataset": state["dataset"],
|
| 308 |
+
"rating_pref": state["rating_pref"],
|
| 309 |
"group": state["group"],
|
| 310 |
"pair_reason": state.get("pair_reason", ""),
|
| 311 |
"session_id": state["session_id"],
|
| 312 |
+
}
|
| 313 |
+
state["pending"].append(decision)
|
| 314 |
+
state["last_decision"] = decision
|
| 315 |
+
state["can_go_back"] = True
|
| 316 |
+
_commit_oldest_pending(state)
|
| 317 |
+
|
| 318 |
+
def vote(winner: str | None, state: dict, submit_key: str | None) -> tuple:
|
| 319 |
+
assert winner in ("A", "B", None)
|
| 320 |
+
if _normalize_submit_key(submit_key) != SUBMIT_KEY:
|
| 321 |
+
return _render_current(state, "Wrong submission key.")
|
| 322 |
+
_queue_decision(winner, state)
|
| 323 |
+
return new_round(state["dataset"], state["rating_pref"], state)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 324 |
|
| 325 |
def go_back(state: dict) -> tuple:
|
| 326 |
pending = state.setdefault("pending", [])
|
| 327 |
+
if not state.get("can_go_back"):
|
| 328 |
+
return _render_current(state)
|
| 329 |
+
last = state.get("last_decision")
|
| 330 |
+
if not last:
|
| 331 |
+
state["can_go_back"] = False
|
| 332 |
+
return _render_current(state)
|
| 333 |
+
if pending and pending[-1] == last:
|
| 334 |
+
pending.pop()
|
| 335 |
+
state["can_go_back"] = False
|
| 336 |
+
state["last_decision"] = None
|
| 337 |
+
state.update(
|
| 338 |
+
dataset=last["dataset"],
|
| 339 |
+
rating_pref=last["rating_pref"],
|
| 340 |
+
key_a=last["key_a"],
|
| 341 |
+
key_b=last["key_b"],
|
| 342 |
+
id_a=last["id_a"],
|
| 343 |
+
id_b=last["id_b"],
|
| 344 |
+
url_a=last["url_a"],
|
| 345 |
+
url_b=last["url_b"],
|
| 346 |
+
group=last["group"],
|
| 347 |
+
pair_reason=last.get("pair_reason", ""),
|
| 348 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 349 |
return _render_current(state)
|
| 350 |
|
| 351 |
# -- UI ---------------------------------------------------------------------
|
| 352 |
|
| 353 |
with gr.Blocks(
|
| 354 |
+
title="Image Rater",
|
| 355 |
head="""
|
| 356 |
<script>
|
| 357 |
const VOTE_COOLDOWN_MS = 1500;
|
|
|
|
| 434 |
} else if ((e.key === 'z' || e.key === 'Z') && (e.ctrlKey || e.metaKey) && ratingTabActive) {
|
| 435 |
e.preventDefault();
|
| 436 |
backButton?.click();
|
| 437 |
+
} else if (e.key === 'ArrowDown' && resultsTabActive) {
|
| 438 |
+
e.preventDefault();
|
| 439 |
+
resultsLoadMoreButton?.click();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 440 |
}
|
| 441 |
});
|
| 442 |
document.addEventListener('click', function (e) {
|
|
|
|
| 496 |
}
|
| 497 |
.rating-image-frame {
|
| 498 |
width: 100%;
|
| 499 |
+
height: 512px;
|
| 500 |
border: 1px solid #e6e6e6;
|
| 501 |
border-radius: 8px;
|
| 502 |
background: #333;
|
|
|
|
| 529 |
.hidden-action-btn {
|
| 530 |
display: none !important;
|
| 531 |
}
|
| 532 |
+
#submit-status {
|
| 533 |
+
position: fixed;
|
| 534 |
+
left: 50%;
|
| 535 |
+
bottom: 20px;
|
| 536 |
+
transform: translateX(-50%);
|
| 537 |
+
z-index: 9998;
|
| 538 |
+
pointer-events: none;
|
| 539 |
+
min-height: 1.2em;
|
| 540 |
+
}
|
| 541 |
.submit-status-msg {
|
| 542 |
+
background: rgba(20, 20, 20, 0.92);
|
| 543 |
+
color: #fff;
|
| 544 |
+
padding: 8px 12px;
|
| 545 |
border-radius: 8px;
|
| 546 |
+
font-size: 0.92rem;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 547 |
}
|
| 548 |
#results-gallery {
|
| 549 |
--explorer-thumb-ratio: 1 / 1;
|
|
|
|
| 558 |
object-fit: contain !important;
|
| 559 |
background: #1f2937;
|
| 560 |
}
|
|
|
|
|
|
|
|
|
|
| 561 |
""",
|
|
|
|
| 562 |
) as demo:
|
| 563 |
state = gr.State({})
|
| 564 |
rating_pref_store = gr.BrowserState(default_value="safe", storage_key="rating_pref")
|
| 565 |
submit_key_store = gr.BrowserState(default_value="", storage_key="submit_key")
|
| 566 |
+
results_sort_store = gr.BrowserState(default_value="Default", storage_key="results_sort_mode")
|
|
|
|
| 567 |
results_classifier_store = gr.BrowserState(default_value=ALLOWED_CLASSIFIER_FILTERS[0], storage_key="results_classifier")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 568 |
|
| 569 |
with gr.Tabs():
|
| 570 |
with gr.Tab("Image Quality Rater"):
|
| 571 |
gr.Markdown("Rate relative image quality. Choose the image with better quality, or select same quality if they are comparable. Both images are drawn from the same group to avoid cross-group bias.")
|
|
|
|
| 572 |
|
| 573 |
with gr.Row():
|
| 574 |
img_a = gr.HTML(elem_id="img-a")
|
| 575 |
img_b = gr.HTML(elem_id="img-b")
|
| 576 |
|
| 577 |
+
with gr.Row():
|
| 578 |
+
btn_a = gr.Button("👍 Prefer A", variant="primary", elem_id="btn-vote-a")
|
| 579 |
+
btn_skip = gr.Button("Same quality", elem_id="btn-skip")
|
| 580 |
+
btn_b = gr.Button("👍 Prefer B", variant="primary", elem_id="btn-vote-b")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 581 |
|
| 582 |
with gr.Accordion("Settings", open=False):
|
| 583 |
+
gr.Markdown("<span style='color:#888;font-size:0.9em;'>Advanced options</span>")
|
| 584 |
+
rating_dd = gr.Dropdown(
|
| 585 |
+
choices=["safe", "all"],
|
| 586 |
+
value="safe",
|
| 587 |
+
label="Rating",
|
| 588 |
+
elem_id="rating-pref",
|
|
|
|
|
|
|
|
|
|
| 589 |
)
|
| 590 |
submit_key_tb = gr.Textbox(
|
| 591 |
value="",
|
| 592 |
type="password",
|
| 593 |
+
label="Submit key",
|
| 594 |
elem_id="submit-key",
|
| 595 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 596 |
|
| 597 |
+
link_a = gr.Markdown(label="Image A link")
|
| 598 |
+
link_b = gr.Markdown(label="Image B link")
|
| 599 |
+
back_link = gr.Markdown(elem_classes=["subtle-back-link-wrap"])
|
| 600 |
+
btn_back_action = gr.Button("Undo Rating (Ctrl+z)", elem_id="btn-back-action", elem_classes=["hidden-action-btn"])
|
| 601 |
+
details_md = gr.Markdown()
|
| 602 |
+
pair_reason_md = gr.Markdown()
|
| 603 |
+
submit_status_md = gr.Markdown(elem_id="submit-status")
|
| 604 |
+
gr.Markdown("<span class='subtle-note'>Dataset: <a href='https://huggingface.co/datasets/taigasan/e6-visual-ratings' target='_blank' rel='noopener noreferrer'>taigasan/e6-visual-ratings</a></span>")
|
| 605 |
+
gr.Markdown("<span class='subtle-note'>Keyboard Shortcuts: ⬅️ vote A, ⬆️ same quality, ➡️ vote B, Ctrl+z undo rating</span>")
|
| 606 |
|
| 607 |
(
|
| 608 |
results_summary_md,
|
|
|
|
| 609 |
results_sort_dd,
|
| 610 |
results_classifier_dd,
|
| 611 |
results_score_distribution_plot,
|
|
|
|
| 617 |
results_page_offset_state,
|
| 618 |
) = add_results_tab(_pool_df)
|
| 619 |
|
| 620 |
+
outputs = [img_a, img_b, link_a, link_b, back_link, details_md, pair_reason_md, submit_status_md, state]
|
| 621 |
results_outputs = [
|
| 622 |
results_summary_md,
|
| 623 |
results_score_distribution_plot,
|
|
|
|
| 629 |
results_page_offset_state,
|
| 630 |
]
|
| 631 |
|
| 632 |
+
btn_a.click(fn=lambda s, k: vote("A", s, k), inputs=[state, submit_key_store], outputs=outputs, queue=False, show_progress="hidden")
|
| 633 |
+
btn_b.click(fn=lambda s, k: vote("B", s, k), inputs=[state, submit_key_store], outputs=outputs, queue=False, show_progress="hidden")
|
| 634 |
+
btn_skip.click(fn=lambda s, k: vote(None, s, k), inputs=[state, submit_key_store], outputs=outputs, queue=False, show_progress="hidden")
|
|
|
|
| 635 |
btn_back_action.click(fn=go_back, inputs=[state], outputs=outputs, queue=False, show_progress="hidden")
|
| 636 |
+
rating_dd.change(fn=_on_rating_change, inputs=[rating_dd, state], outputs=[*outputs, rating_pref_store], queue=False, show_progress="hidden")
|
| 637 |
+
submit_key_tb.input(fn=_normalize_submit_key, inputs=[submit_key_tb], outputs=[submit_key_store], queue=False, show_progress="hidden")
|
| 638 |
+
submit_key_tb.change(fn=_normalize_submit_key, inputs=[submit_key_tb], outputs=[submit_key_store], queue=False, show_progress="hidden")
|
| 639 |
+
rating_dd.change(fn=_load_results, inputs=[rating_dd, results_sort_store, results_classifier_store], outputs=results_outputs, queue=False, show_progress="hidden")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 640 |
results_sort_dd.change(fn=_normalize_sort_mode, inputs=[results_sort_dd], outputs=[results_sort_store], queue=False, show_progress="hidden")
|
| 641 |
results_sort_dd.change(fn=_load_results, inputs=[rating_pref_store, results_sort_dd, results_classifier_store], outputs=results_outputs, queue=False, show_progress="hidden")
|
| 642 |
results_classifier_dd.change(fn=_normalize_classifier_filter, inputs=[results_classifier_dd], outputs=[results_classifier_store], queue=False, show_progress="hidden")
|
| 643 |
results_classifier_dd.change(fn=_load_results, inputs=[rating_pref_store, results_sort_store, results_classifier_dd], outputs=results_outputs, queue=False, show_progress="hidden")
|
| 644 |
+
demo.load(fn=_initial_load, inputs=[state, rating_pref_store, submit_key_store], outputs=[rating_dd, submit_key_tb, *outputs], queue=False, show_progress="hidden")
|
|
|
|
| 645 |
demo.load(fn=_load_results, inputs=[rating_pref_store, results_sort_store, results_classifier_store], outputs=results_outputs, queue=False, show_progress="hidden")
|
| 646 |
demo.load(fn=_normalize_sort_mode, inputs=[results_sort_store], outputs=[results_sort_dd], queue=False, show_progress="hidden")
|
| 647 |
demo.load(fn=_normalize_classifier_filter, inputs=[results_classifier_store], outputs=[results_classifier_dd], queue=False, show_progress="hidden")
|
|
|
|
| 648 |
results_load_more_btn.click(
|
| 649 |
fn=lambda r, s, c, o: load_more_results(_filtered_explorer_df_by_classifier(_normalize_rating_pref(r), _normalize_classifier_filter(c)), _explorer_df, s, o),
|
| 650 |
inputs=[rating_pref_store, results_sort_store, results_classifier_store, results_page_offset_state],
|
maintenance/compact_logs.py → compact_logs.py
RENAMED
|
@@ -1,20 +1,21 @@
|
|
| 1 |
-
|
| 2 |
from __future__ import annotations
|
| 3 |
|
| 4 |
import os
|
| 5 |
import time
|
| 6 |
import uuid
|
|
|
|
|
|
|
| 7 |
|
| 8 |
import pandas as pd
|
| 9 |
from huggingface_hub import CommitOperationAdd, CommitOperationDelete, HfApi, hf_hub_download
|
| 10 |
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
VOTES_REPO_ID = "RedRocket/e6-visual-ratings"
|
| 14 |
VOTES_REPO_TYPE = "dataset"
|
| 15 |
VOTES_LOG_SUBDIR = "ratings_log"
|
| 16 |
RATINGS_APP_TOKEN_ENV = "RATINGS_APP_TOKEN"
|
| 17 |
|
|
|
|
| 18 |
def _list_vote_shards(api: HfApi) -> list[str]:
|
| 19 |
files = api.list_repo_files(repo_id=VOTES_REPO_ID, repo_type=VOTES_REPO_TYPE)
|
| 20 |
shard_prefix = f"{VOTES_LOG_SUBDIR}/votes_"
|
|
@@ -25,19 +26,21 @@ def _list_vote_shards(api: HfApi) -> list[str]:
|
|
| 25 |
and f.endswith(".parquet")
|
| 26 |
)
|
| 27 |
|
|
|
|
| 28 |
def _new_compacted_shard_path() -> str:
|
| 29 |
ts = int(time.time())
|
| 30 |
return f"{VOTES_LOG_SUBDIR}/votes_{ts}_{uuid.uuid4().hex}.parquet"
|
| 31 |
|
| 32 |
-
|
|
|
|
| 33 |
token = os.getenv(RATINGS_APP_TOKEN_ENV)
|
| 34 |
api = HfApi(token=token)
|
| 35 |
|
| 36 |
shards = _list_vote_shards(api)
|
| 37 |
-
if
|
| 38 |
-
|
| 39 |
|
| 40 |
-
frames
|
| 41 |
for shard in shards:
|
| 42 |
shard_local = hf_hub_download(
|
| 43 |
repo_id=VOTES_REPO_ID,
|
|
@@ -45,47 +48,47 @@ def compact_votes() -> tuple[int, int, str] | None:
|
|
| 45 |
repo_type=VOTES_REPO_TYPE,
|
| 46 |
token=token,
|
| 47 |
)
|
| 48 |
-
|
|
|
|
| 49 |
|
|
|
|
| 50 |
combined = pd.concat(frames, ignore_index=True, sort=False)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
operations=[
|
| 70 |
-
CommitOperationAdd(path_or_fileobj=compacted_data, path_in_repo=compacted_path),
|
| 71 |
-
*(CommitOperationDelete(path_in_repo=shard) for shard in shards),
|
| 72 |
-
],
|
| 73 |
-
)
|
| 74 |
|
| 75 |
-
return len(shards),
|
| 76 |
|
| 77 |
-
def _main() -> None:
|
| 78 |
-
result = compact_votes()
|
| 79 |
-
if result is None:
|
| 80 |
-
print(f"Nothing to compact.")
|
| 81 |
-
return
|
| 82 |
|
| 83 |
-
|
|
|
|
| 84 |
print(
|
| 85 |
-
f"Compacted {shard_count}
|
| 86 |
f"{VOTES_REPO_ID}/{compacted_path} "
|
| 87 |
f"with {row_count} rows."
|
| 88 |
)
|
| 89 |
|
|
|
|
| 90 |
if __name__ == "__main__":
|
| 91 |
-
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
from __future__ import annotations
|
| 3 |
|
| 4 |
import os
|
| 5 |
import time
|
| 6 |
import uuid
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
from tempfile import NamedTemporaryFile
|
| 9 |
|
| 10 |
import pandas as pd
|
| 11 |
from huggingface_hub import CommitOperationAdd, CommitOperationDelete, HfApi, hf_hub_download
|
| 12 |
|
| 13 |
+
VOTES_REPO_ID = "taigasan/e6-visual-ratings"
|
|
|
|
|
|
|
| 14 |
VOTES_REPO_TYPE = "dataset"
|
| 15 |
VOTES_LOG_SUBDIR = "ratings_log"
|
| 16 |
RATINGS_APP_TOKEN_ENV = "RATINGS_APP_TOKEN"
|
| 17 |
|
| 18 |
+
|
| 19 |
def _list_vote_shards(api: HfApi) -> list[str]:
|
| 20 |
files = api.list_repo_files(repo_id=VOTES_REPO_ID, repo_type=VOTES_REPO_TYPE)
|
| 21 |
shard_prefix = f"{VOTES_LOG_SUBDIR}/votes_"
|
|
|
|
| 26 |
and f.endswith(".parquet")
|
| 27 |
)
|
| 28 |
|
| 29 |
+
|
| 30 |
def _new_compacted_shard_path() -> str:
|
| 31 |
ts = int(time.time())
|
| 32 |
return f"{VOTES_LOG_SUBDIR}/votes_{ts}_{uuid.uuid4().hex}.parquet"
|
| 33 |
|
| 34 |
+
|
| 35 |
+
def compact_votes() -> tuple[int, int, str]:
|
| 36 |
token = os.getenv(RATINGS_APP_TOKEN_ENV)
|
| 37 |
api = HfApi(token=token)
|
| 38 |
|
| 39 |
shards = _list_vote_shards(api)
|
| 40 |
+
if not shards:
|
| 41 |
+
raise FileNotFoundError(f"No vote shards found in {VOTES_REPO_ID}/{VOTES_LOG_SUBDIR}")
|
| 42 |
|
| 43 |
+
frames = []
|
| 44 |
for shard in shards:
|
| 45 |
shard_local = hf_hub_download(
|
| 46 |
repo_id=VOTES_REPO_ID,
|
|
|
|
| 48 |
repo_type=VOTES_REPO_TYPE,
|
| 49 |
token=token,
|
| 50 |
)
|
| 51 |
+
frame = pd.read_parquet(shard_local)
|
| 52 |
+
frames.append(frame)
|
| 53 |
|
| 54 |
+
input_row_count = sum(len(frame) for frame in frames)
|
| 55 |
combined = pd.concat(frames, ignore_index=True, sort=False)
|
| 56 |
+
output_row_count = int(len(combined))
|
| 57 |
+
if output_row_count != input_row_count:
|
| 58 |
+
raise RuntimeError(
|
| 59 |
+
f"Refusing to commit: row mismatch during compaction "
|
| 60 |
+
f"({input_row_count} -> {output_row_count})."
|
| 61 |
+
)
|
| 62 |
|
| 63 |
+
with NamedTemporaryFile(suffix=".parquet", delete=False) as tmp:
|
| 64 |
+
tmp_path = Path(tmp.name)
|
| 65 |
+
try:
|
| 66 |
+
combined.to_parquet(tmp_path, index=False)
|
| 67 |
+
compacted_path = _new_compacted_shard_path()
|
| 68 |
+
operations = [
|
| 69 |
+
CommitOperationAdd(path_or_fileobj=str(tmp_path), path_in_repo=compacted_path),
|
| 70 |
+
*[CommitOperationDelete(path_in_repo=shard) for shard in shards],
|
| 71 |
+
]
|
| 72 |
+
api.create_commit(
|
| 73 |
+
repo_id=VOTES_REPO_ID,
|
| 74 |
+
repo_type=VOTES_REPO_TYPE,
|
| 75 |
+
commit_message=f"compact {len(shards)} vote shard(s)",
|
| 76 |
+
operations=operations,
|
| 77 |
+
)
|
| 78 |
+
finally:
|
| 79 |
+
tmp_path.unlink(missing_ok=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
|
| 81 |
+
return len(shards), output_row_count, compacted_path
|
| 82 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
|
| 84 |
+
def main() -> None:
|
| 85 |
+
shard_count, row_count, compacted_path = compact_votes()
|
| 86 |
print(
|
| 87 |
+
f"Compacted {shard_count} shard(s) into "
|
| 88 |
f"{VOTES_REPO_ID}/{compacted_path} "
|
| 89 |
f"with {row_count} rows."
|
| 90 |
)
|
| 91 |
|
| 92 |
+
|
| 93 |
if __name__ == "__main__":
|
| 94 |
+
main()
|
explorer.py
CHANGED
|
@@ -3,13 +3,11 @@ import matplotlib.pyplot as plt
|
|
| 3 |
import numpy as np
|
| 4 |
import pandas as pd
|
| 5 |
import html
|
| 6 |
-
from matplotlib.colors import LinearSegmentedColormap
|
| 7 |
from matplotlib.figure import Figure
|
| 8 |
|
| 9 |
PAGE_SIZE = 30
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
ALLOWED_CLASSIFIER_FILTERS = ("Siglip + Hydra head", "TaggerExperiment", "PairsExperiment backbone + Hydra head")
|
| 13 |
|
| 14 |
|
| 15 |
def _row_image_url(row) -> str | None:
|
|
@@ -34,14 +32,10 @@ def _gallery_items(meta: list[dict]) -> list[tuple[str, str]]:
|
|
| 34 |
|
| 35 |
def _distribution_data(
|
| 36 |
explorer_df: pd.DataFrame,
|
| 37 |
-
classifier_name: str | None = None,
|
| 38 |
) -> dict[str, list[float] | int]:
|
| 39 |
-
scores_df = explorer_df
|
| 40 |
-
if classifier_name is not None:
|
| 41 |
-
scores_df = scores_df[scores_df["classifier"].astype(str) == classifier_name]
|
| 42 |
valid_scores = [
|
| 43 |
float(score)
|
| 44 |
-
for score in
|
| 45 |
if pd.notna(score)
|
| 46 |
]
|
| 47 |
if not valid_scores:
|
|
@@ -54,65 +48,30 @@ def _distribution_data(
|
|
| 54 |
}
|
| 55 |
|
| 56 |
|
| 57 |
-
PLOT_BG = "#0b0e14"
|
| 58 |
-
PLOT_TEXT = "#e6edf7"
|
| 59 |
-
PLOT_MUTED = "#8b98ad"
|
| 60 |
-
PLOT_GRID = "#1c2230"
|
| 61 |
-
PLOT_ACCENT = "#fbbf24"
|
| 62 |
-
_BAR_CMAP = LinearSegmentedColormap.from_list("explorer_bars", ["#22d3ee", "#4f8cf7", "#8b5cf6"])
|
| 63 |
-
|
| 64 |
-
|
| 65 |
def _classifier_score_distribution_plot(
|
| 66 |
distribution_data: dict[str, list[float] | int],
|
| 67 |
selected_score: float | None = None,
|
| 68 |
) -> Figure:
|
| 69 |
-
fig, ax = plt.subplots(figsize=(6
|
| 70 |
-
fig.patch.set_facecolor(
|
| 71 |
-
ax.set_facecolor(
|
| 72 |
bin_edges = np.asarray(distribution_data.get("bin_edges", []), dtype=float)
|
| 73 |
counts = np.asarray(distribution_data.get("counts", []), dtype=float)
|
| 74 |
if counts.size > 0 and bin_edges.size == counts.size + 1:
|
| 75 |
widths = np.diff(bin_edges)
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
colors = _BAR_CMAP((centers - centers[0]) / span)
|
| 79 |
-
ax.bar(bin_edges[:-1], counts, width=widths * 0.92, align="edge", color=colors, linewidth=0.0, zorder=3)
|
| 80 |
-
ax.fill_between(centers, counts, step="mid", color="#4f8cf7", alpha=0.10, linewidth=0.0, zorder=2)
|
| 81 |
-
ax.set_ylabel("Count", color=PLOT_MUTED, fontsize=8.5, labelpad=6)
|
| 82 |
-
ax.set_ylim(0, float(counts.max()) * 1.18)
|
| 83 |
if selected_score is not None:
|
| 84 |
-
|
| 85 |
-
for width, alpha in ((7.0, 0.07), (4.0, 0.12), (1.6, 0.95)):
|
| 86 |
-
ax.axvline(score, color=PLOT_ACCENT, linewidth=width, alpha=alpha, zorder=4)
|
| 87 |
-
at_right_edge = score > (bin_edges[0] + bin_edges[-1]) / 2.0
|
| 88 |
-
ax.annotate(
|
| 89 |
-
f"{score:.4f}",
|
| 90 |
-
xy=(score, ax.get_ylim()[1]),
|
| 91 |
-
xytext=(-6 if at_right_edge else 6, -4),
|
| 92 |
-
textcoords="offset points",
|
| 93 |
-
ha="right" if at_right_edge else "left",
|
| 94 |
-
va="top",
|
| 95 |
-
fontsize=8.0,
|
| 96 |
-
color=PLOT_ACCENT,
|
| 97 |
-
zorder=5,
|
| 98 |
-
bbox=dict(boxstyle="round,pad=0.28", facecolor=PLOT_BG, edgecolor=PLOT_ACCENT, alpha=0.85, linewidth=0.6),
|
| 99 |
-
)
|
| 100 |
else:
|
| 101 |
-
ax.text(0.5, 0.5, "No classifier scores available.", ha="center", va="center", transform=ax.transAxes, color=
|
| 102 |
ax.set_yticks([])
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
ax.
|
| 109 |
-
ax.tick_params(colors=PLOT_MUTED, labelsize=8, length=0, pad=4)
|
| 110 |
-
for name, spine in ax.spines.items():
|
| 111 |
-
spine.set_visible(name == "bottom")
|
| 112 |
-
spine.set_color(PLOT_GRID)
|
| 113 |
-
spine.set_linewidth(0.8)
|
| 114 |
-
ax.grid(axis="y", color=PLOT_GRID, linewidth=0.7, alpha=0.9, zorder=0)
|
| 115 |
-
ax.set_axisbelow(True)
|
| 116 |
fig.tight_layout()
|
| 117 |
return fig
|
| 118 |
|
|
@@ -123,20 +82,6 @@ def _build_page_meta(
|
|
| 123 |
sort_mode: str,
|
| 124 |
offset: int,
|
| 125 |
) -> tuple[list[dict[str, str | int]], int, bool, int]:
|
| 126 |
-
all_scores = all_explorer_df[["md5", "classifier", "classifier_score", "percentile"]].copy()
|
| 127 |
-
all_scores = all_scores[
|
| 128 |
-
all_scores["classifier"].notna()
|
| 129 |
-
& all_scores["classifier_score"].notna()
|
| 130 |
-
& all_scores["percentile"].notna()
|
| 131 |
-
]
|
| 132 |
-
all_scores["md5"] = all_scores["md5"].astype(str)
|
| 133 |
-
all_scores["classifier"] = all_scores["classifier"].astype(str)
|
| 134 |
-
all_scores = all_scores[all_scores["classifier"].isin(ALLOWED_CLASSIFIER_FILTERS)]
|
| 135 |
-
all_scores = all_scores.drop_duplicates(subset=["md5", "classifier"], keep="last")
|
| 136 |
-
classifier_order = {name: i for i, name in enumerate(ALLOWED_CLASSIFIER_FILTERS)}
|
| 137 |
-
all_scores["classifier_order"] = all_scores["classifier"].map(classifier_order)
|
| 138 |
-
all_scores = all_scores.sort_values(["md5", "classifier_order"], kind="mergesort")
|
| 139 |
-
|
| 140 |
filtered = explorer_df[["id", "md5", "sample_url", "image_url", "classifier_score", "percentile"]].copy()
|
| 141 |
has_sample = filtered["sample_url"].notna() & (filtered["sample_url"] != "")
|
| 142 |
has_image = filtered["image_url"].notna() & (filtered["image_url"] != "")
|
|
@@ -146,12 +91,17 @@ def _build_page_meta(
|
|
| 146 |
filtered = filtered.sort_values("classifier_score", ascending=True, kind="mergesort")
|
| 147 |
elif sort_mode == "Rating: High to Low":
|
| 148 |
filtered = filtered.sort_values("classifier_score", ascending=False, kind="mergesort")
|
| 149 |
-
else:
|
| 150 |
-
disagreement_by_md5 = all_scores.groupby("md5")["percentile"].agg(lambda s: s.max() - s.min())
|
| 151 |
-
filtered = filtered.assign(_disagreement=filtered["md5"].astype(str).map(disagreement_by_md5).fillna(0.0))
|
| 152 |
-
filtered = filtered.sort_values("_disagreement", ascending=False, kind="mergesort")
|
| 153 |
-
filtered = filtered.drop(columns="_disagreement")
|
| 154 |
page_df = filtered.iloc[offset:offset + PAGE_SIZE]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 155 |
all_scores_by_md5 = {
|
| 156 |
str(md5): [
|
| 157 |
{
|
|
@@ -187,8 +137,8 @@ def build_results_data(
|
|
| 187 |
classifier_name: str,
|
| 188 |
) -> tuple[str, Figure, dict[str, list[float] | int], list[tuple[str, str]], list[dict[str, str | int]], int, dict]:
|
| 189 |
page_meta, next_offset, has_more, total = _build_page_meta(explorer_df, all_explorer_df, sort_mode, offset=0)
|
| 190 |
-
summary = f"Showing {total} images from
|
| 191 |
-
distribution_data = _distribution_data(
|
| 192 |
score_distribution_plot = _classifier_score_distribution_plot(distribution_data)
|
| 193 |
return summary, score_distribution_plot, distribution_data, _gallery_items(page_meta), page_meta, next_offset, gr.update(visible=has_more)
|
| 194 |
|
|
@@ -258,16 +208,10 @@ def add_results_tab(pool_df: pd.DataFrame):
|
|
| 258 |
results_load_more_btn = gr.Button("Load more (ArrowDown)", elem_id="btn-results-load-more")
|
| 259 |
selected_image_md = gr.Markdown("Click an image to reveal its ID and link.")
|
| 260 |
results_score_distribution_plot = gr.Plot(label="Classifier score distribution")
|
| 261 |
-
results_rating_dd = gr.Dropdown(
|
| 262 |
-
choices=["safe", "all"],
|
| 263 |
-
value="safe",
|
| 264 |
-
label="Rating",
|
| 265 |
-
)
|
| 266 |
results_sort_dd = gr.Dropdown(
|
| 267 |
choices=SORT_MODES,
|
| 268 |
-
value=
|
| 269 |
label="Sort",
|
| 270 |
-
info="Disagreement surfaces images the classifiers rate most differently — useful for spotting which classifier is off.",
|
| 271 |
elem_id="results-sort-mode",
|
| 272 |
)
|
| 273 |
results_classifier_dd = gr.Dropdown(
|
|
@@ -281,7 +225,6 @@ def add_results_tab(pool_df: pd.DataFrame):
|
|
| 281 |
results_page_offset_state = gr.State(0)
|
| 282 |
return (
|
| 283 |
results_summary_md,
|
| 284 |
-
results_rating_dd,
|
| 285 |
results_sort_dd,
|
| 286 |
results_classifier_dd,
|
| 287 |
results_score_distribution_plot,
|
|
|
|
| 3 |
import numpy as np
|
| 4 |
import pandas as pd
|
| 5 |
import html
|
|
|
|
| 6 |
from matplotlib.figure import Figure
|
| 7 |
|
| 8 |
PAGE_SIZE = 30
|
| 9 |
+
SORT_MODES = ["Default", "Rating: Low to High", "Rating: High to Low"]
|
| 10 |
+
ALLOWED_CLASSIFIER_FILTERS = ("SiglipFinetune", "ScoringHead-v5", "TaggerExperiment")
|
|
|
|
| 11 |
|
| 12 |
|
| 13 |
def _row_image_url(row) -> str | None:
|
|
|
|
| 32 |
|
| 33 |
def _distribution_data(
|
| 34 |
explorer_df: pd.DataFrame,
|
|
|
|
| 35 |
) -> dict[str, list[float] | int]:
|
|
|
|
|
|
|
|
|
|
| 36 |
valid_scores = [
|
| 37 |
float(score)
|
| 38 |
+
for score in explorer_df["classifier_score"].tolist()
|
| 39 |
if pd.notna(score)
|
| 40 |
]
|
| 41 |
if not valid_scores:
|
|
|
|
| 48 |
}
|
| 49 |
|
| 50 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
def _classifier_score_distribution_plot(
|
| 52 |
distribution_data: dict[str, list[float] | int],
|
| 53 |
selected_score: float | None = None,
|
| 54 |
) -> Figure:
|
| 55 |
+
fig, ax = plt.subplots(figsize=(6, 2.2))
|
| 56 |
+
fig.patch.set_facecolor("#0f1117")
|
| 57 |
+
ax.set_facecolor("#151922")
|
| 58 |
bin_edges = np.asarray(distribution_data.get("bin_edges", []), dtype=float)
|
| 59 |
counts = np.asarray(distribution_data.get("counts", []), dtype=float)
|
| 60 |
if counts.size > 0 and bin_edges.size == counts.size + 1:
|
| 61 |
widths = np.diff(bin_edges)
|
| 62 |
+
ax.bar(bin_edges[:-1], counts, width=widths, align="edge", color="#3b82f6", alpha=0.9, edgecolor="#93c5fd", linewidth=0.35)
|
| 63 |
+
ax.set_ylabel("Count", color="#e5e7eb")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
if selected_score is not None:
|
| 65 |
+
ax.axvline(float(selected_score), color="#f97316", linewidth=2.0)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
else:
|
| 67 |
+
ax.text(0.5, 0.5, "No classifier scores available.", ha="center", va="center", transform=ax.transAxes, color="#e5e7eb")
|
| 68 |
ax.set_yticks([])
|
| 69 |
+
ax.set_title("Classifier Score Distribution", color="#f3f4f6")
|
| 70 |
+
ax.set_xlabel("Classifier score", color="#e5e7eb")
|
| 71 |
+
ax.tick_params(colors="#d1d5db")
|
| 72 |
+
for spine in ax.spines.values():
|
| 73 |
+
spine.set_color("#4b5563")
|
| 74 |
+
ax.grid(axis="y", color="#374151", alpha=0.4)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
fig.tight_layout()
|
| 76 |
return fig
|
| 77 |
|
|
|
|
| 82 |
sort_mode: str,
|
| 83 |
offset: int,
|
| 84 |
) -> tuple[list[dict[str, str | int]], int, bool, int]:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
filtered = explorer_df[["id", "md5", "sample_url", "image_url", "classifier_score", "percentile"]].copy()
|
| 86 |
has_sample = filtered["sample_url"].notna() & (filtered["sample_url"] != "")
|
| 87 |
has_image = filtered["image_url"].notna() & (filtered["image_url"] != "")
|
|
|
|
| 91 |
filtered = filtered.sort_values("classifier_score", ascending=True, kind="mergesort")
|
| 92 |
elif sort_mode == "Rating: High to Low":
|
| 93 |
filtered = filtered.sort_values("classifier_score", ascending=False, kind="mergesort")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
page_df = filtered.iloc[offset:offset + PAGE_SIZE]
|
| 95 |
+
all_scores = all_explorer_df[["md5", "classifier", "classifier_score", "percentile"]].copy()
|
| 96 |
+
all_scores = all_scores[
|
| 97 |
+
all_scores["classifier"].notna()
|
| 98 |
+
& all_scores["classifier_score"].notna()
|
| 99 |
+
& all_scores["percentile"].notna()
|
| 100 |
+
]
|
| 101 |
+
all_scores["md5"] = all_scores["md5"].astype(str)
|
| 102 |
+
all_scores["classifier"] = all_scores["classifier"].astype(str)
|
| 103 |
+
all_scores = all_scores.drop_duplicates(subset=["md5", "classifier"], keep="last")
|
| 104 |
+
all_scores = all_scores.sort_values(["md5", "classifier"], kind="mergesort")
|
| 105 |
all_scores_by_md5 = {
|
| 106 |
str(md5): [
|
| 107 |
{
|
|
|
|
| 137 |
classifier_name: str,
|
| 138 |
) -> tuple[str, Figure, dict[str, list[float] | int], list[tuple[str, str]], list[dict[str, str | int]], int, dict]:
|
| 139 |
page_meta, next_offset, has_more, total = _build_page_meta(explorer_df, all_explorer_df, sort_mode, offset=0)
|
| 140 |
+
summary = f"Showing {total} images from validation_set.parquet joined with classifier_scores.parquet (rating: {rating_pref}, classifier: {classifier_name}, sort: {sort_mode})."
|
| 141 |
+
distribution_data = _distribution_data(explorer_df)
|
| 142 |
score_distribution_plot = _classifier_score_distribution_plot(distribution_data)
|
| 143 |
return summary, score_distribution_plot, distribution_data, _gallery_items(page_meta), page_meta, next_offset, gr.update(visible=has_more)
|
| 144 |
|
|
|
|
| 208 |
results_load_more_btn = gr.Button("Load more (ArrowDown)", elem_id="btn-results-load-more")
|
| 209 |
selected_image_md = gr.Markdown("Click an image to reveal its ID and link.")
|
| 210 |
results_score_distribution_plot = gr.Plot(label="Classifier score distribution")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 211 |
results_sort_dd = gr.Dropdown(
|
| 212 |
choices=SORT_MODES,
|
| 213 |
+
value="Default",
|
| 214 |
label="Sort",
|
|
|
|
| 215 |
elem_id="results-sort-mode",
|
| 216 |
)
|
| 217 |
results_classifier_dd = gr.Dropdown(
|
|
|
|
| 225 |
results_page_offset_state = gr.State(0)
|
| 226 |
return (
|
| 227 |
results_summary_md,
|
|
|
|
| 228 |
results_sort_dd,
|
| 229 |
results_classifier_dd,
|
| 230 |
results_score_distribution_plot,
|
maintenance/refresh_cdn_availability.py
DELETED
|
@@ -1,159 +0,0 @@
|
|
| 1 |
-
#!/usr/bin/env python3
|
| 2 |
-
from __future__ import annotations
|
| 3 |
-
|
| 4 |
-
import csv
|
| 5 |
-
import gzip
|
| 6 |
-
import io
|
| 7 |
-
import os
|
| 8 |
-
import sys
|
| 9 |
-
from collections.abc import Iterator
|
| 10 |
-
|
| 11 |
-
import httpx
|
| 12 |
-
import pandas as pd
|
| 13 |
-
from huggingface_hub import CommitOperationAdd, HfApi, hf_hub_download
|
| 14 |
-
from tqdm import tqdm
|
| 15 |
-
|
| 16 |
-
POOL_REPO_ID = "RedRocket/e6-visual-ratings"
|
| 17 |
-
POOL_REPO_TYPE = "dataset"
|
| 18 |
-
POOL_FILES = ("pool.parquet", "test_set.parquet")
|
| 19 |
-
RATINGS_APP_TOKEN_ENV = "RATINGS_APP_TOKEN"
|
| 20 |
-
|
| 21 |
-
# Nullable boolean: True = the file is still served, False = it is gone.
|
| 22 |
-
CDN_AVAILABLE_COLUMN = "cdn_available"
|
| 23 |
-
|
| 24 |
-
# https://e621.net/help/api asks that bulk lookups go through the daily export
|
| 25 |
-
# rather than the site ("If you are looking for bulk data, or are expecting to
|
| 26 |
-
# do tens of thousands of lookups, please use the daily data exports"), so this
|
| 27 |
-
# whole refresh is one request. Sampled posts confirm the export is enough on
|
| 28 |
-
# its own: every is_deleted post 404s on the CDN, as does every md5 the export
|
| 29 |
-
# does not list at all (its post was replaced, so nothing serves that file).
|
| 30 |
-
POSTS_EXPORT_URL = "https://static1.e621.net/data/db_export/posts.csv.gz"
|
| 31 |
-
USER_AGENT = f"e6-visual-ratings-maintenance/1.0 (https://huggingface.co/datasets/{POOL_REPO_ID})"
|
| 32 |
-
REQUEST_TIMEOUT_S = 60.0
|
| 33 |
-
|
| 34 |
-
class _ChunkReader(io.RawIOBase):
|
| 35 |
-
"""File-like view over a chunk iterator, so gzip can stream the download."""
|
| 36 |
-
|
| 37 |
-
def __init__(self, chunks: Iterator[bytes], progress: tqdm) -> None:
|
| 38 |
-
self._chunks = chunks
|
| 39 |
-
self._progress = progress
|
| 40 |
-
self._buffer = b""
|
| 41 |
-
|
| 42 |
-
def readable(self) -> bool:
|
| 43 |
-
return True
|
| 44 |
-
|
| 45 |
-
def readinto(self, view) -> int:
|
| 46 |
-
while not self._buffer:
|
| 47 |
-
chunk = next(self._chunks, b"")
|
| 48 |
-
if not chunk:
|
| 49 |
-
return 0
|
| 50 |
-
|
| 51 |
-
self._buffer = chunk
|
| 52 |
-
self._progress.update(len(chunk))
|
| 53 |
-
|
| 54 |
-
taken = min(len(view), len(self._buffer))
|
| 55 |
-
view[:taken] = self._buffer[:taken]
|
| 56 |
-
self._buffer = self._buffer[taken:]
|
| 57 |
-
return taken
|
| 58 |
-
|
| 59 |
-
def _exported_availability(wanted: set[str]) -> dict[str, bool]:
|
| 60 |
-
"""Maps each wanted md5 the daily export knows about to `not is_deleted`."""
|
| 61 |
-
# Post descriptions blow past the default csv field limit.
|
| 62 |
-
csv.field_size_limit(sys.maxsize)
|
| 63 |
-
|
| 64 |
-
availability: dict[str, bool] = {}
|
| 65 |
-
with httpx.stream(
|
| 66 |
-
"GET",
|
| 67 |
-
POSTS_EXPORT_URL,
|
| 68 |
-
headers={"User-Agent": USER_AGENT},
|
| 69 |
-
timeout=REQUEST_TIMEOUT_S,
|
| 70 |
-
follow_redirects=True,
|
| 71 |
-
) as response:
|
| 72 |
-
response.raise_for_status()
|
| 73 |
-
total = int(response.headers.get("content-length", 0)) or None
|
| 74 |
-
|
| 75 |
-
with tqdm(total=total, unit="B", unit_scale=True, desc="posts.csv.gz") as progress:
|
| 76 |
-
chunks = io.BufferedReader(_ChunkReader(response.iter_bytes(), progress))
|
| 77 |
-
rows = csv.reader(io.TextIOWrapper(gzip.GzipFile(fileobj=chunks), encoding="utf-8", newline=""))
|
| 78 |
-
|
| 79 |
-
header = next(rows)
|
| 80 |
-
md5_at = header.index("md5")
|
| 81 |
-
is_deleted_at = header.index("is_deleted")
|
| 82 |
-
|
| 83 |
-
for row in rows:
|
| 84 |
-
md5 = row[md5_at]
|
| 85 |
-
if md5 in wanted:
|
| 86 |
-
availability[md5] = row[is_deleted_at] == "f"
|
| 87 |
-
|
| 88 |
-
return availability
|
| 89 |
-
|
| 90 |
-
def _labels(availability: pd.Series) -> pd.Series:
|
| 91 |
-
return availability.map({True: "available", False: "missing"}).fillna("unknown")
|
| 92 |
-
|
| 93 |
-
def refresh_cdn_availability() -> None:
|
| 94 |
-
token = os.getenv(RATINGS_APP_TOKEN_ENV)
|
| 95 |
-
api = HfApi(token=token)
|
| 96 |
-
|
| 97 |
-
frames: dict[str, pd.DataFrame] = {}
|
| 98 |
-
for filename in POOL_FILES:
|
| 99 |
-
local_path = hf_hub_download(
|
| 100 |
-
repo_id=POOL_REPO_ID,
|
| 101 |
-
filename=filename,
|
| 102 |
-
repo_type=POOL_REPO_TYPE,
|
| 103 |
-
token=token,
|
| 104 |
-
)
|
| 105 |
-
frames[filename] = pd.read_parquet(local_path)
|
| 106 |
-
|
| 107 |
-
md5s = {md5 for df in frames.values() for md5 in df["md5"]}
|
| 108 |
-
print(f"looking up {len(md5s)} unique md5s from {', '.join(frames)}")
|
| 109 |
-
|
| 110 |
-
exported = _exported_availability(md5s)
|
| 111 |
-
# An md5 the export does not list belongs to a post that was replaced, and
|
| 112 |
-
# its file is gone from the CDN just like a deleted one's.
|
| 113 |
-
availability = {md5: exported.get(md5, False) for md5 in md5s}
|
| 114 |
-
available = sum(availability.values())
|
| 115 |
-
print(f"export: {available} available, {len(md5s) - available} missing")
|
| 116 |
-
|
| 117 |
-
operations: list[CommitOperationAdd] = []
|
| 118 |
-
for filename, df in frames.items():
|
| 119 |
-
refreshed = pd.Series([availability.get(md5) for md5 in df["md5"]], index=df.index, dtype="boolean")
|
| 120 |
-
counts = _labels(refreshed).value_counts()
|
| 121 |
-
|
| 122 |
-
if CDN_AVAILABLE_COLUMN in df.columns:
|
| 123 |
-
previous = _labels(df[CDN_AVAILABLE_COLUMN].astype("boolean"))
|
| 124 |
-
changed = int((previous != _labels(refreshed)).sum())
|
| 125 |
-
else:
|
| 126 |
-
changed = len(df)
|
| 127 |
-
|
| 128 |
-
print(
|
| 129 |
-
f"{filename}: {len(df)} rows, "
|
| 130 |
-
f"{counts.get('available', 0)} available, "
|
| 131 |
-
f"{counts.get('missing', 0)} missing, "
|
| 132 |
-
f"{counts.get('unknown', 0)} unknown "
|
| 133 |
-
f"({changed} changed)"
|
| 134 |
-
)
|
| 135 |
-
|
| 136 |
-
if not changed:
|
| 137 |
-
continue
|
| 138 |
-
|
| 139 |
-
df[CDN_AVAILABLE_COLUMN] = refreshed
|
| 140 |
-
operations.append(CommitOperationAdd(
|
| 141 |
-
path_or_fileobj=df.to_parquet(index=False),
|
| 142 |
-
path_in_repo=filename,
|
| 143 |
-
))
|
| 144 |
-
|
| 145 |
-
if not operations:
|
| 146 |
-
print("Nothing changed.")
|
| 147 |
-
return
|
| 148 |
-
|
| 149 |
-
updated = [operation.path_in_repo for operation in operations]
|
| 150 |
-
api.create_commit(
|
| 151 |
-
repo_id=POOL_REPO_ID,
|
| 152 |
-
repo_type=POOL_REPO_TYPE,
|
| 153 |
-
commit_message=f"refresh {CDN_AVAILABLE_COLUMN} in {', '.join(updated)}",
|
| 154 |
-
operations=operations,
|
| 155 |
-
)
|
| 156 |
-
print(f"Updated {', '.join(updated)} in {POOL_REPO_ID}.")
|
| 157 |
-
|
| 158 |
-
if __name__ == "__main__":
|
| 159 |
-
refresh_cdn_availability()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
stats_from_logs.py
CHANGED
|
@@ -1,24 +1,11 @@
|
|
| 1 |
-
from collections import
|
| 2 |
-
from dataclasses import dataclass
|
| 3 |
|
| 4 |
import pandas as pd
|
| 5 |
from huggingface_hub import HfApi, hf_hub_download
|
| 6 |
|
| 7 |
-
VOTES_LOG_SUBDIR = "ratings_log"
|
| 8 |
-
|
| 9 |
-
@dataclass(slots=True)
|
| 10 |
-
class Stats:
|
| 11 |
-
wins: int = 0
|
| 12 |
-
losses: int = 0
|
| 13 |
-
ties: int = 0
|
| 14 |
|
| 15 |
-
|
| 16 |
-
def votes(self) -> int:
|
| 17 |
-
return self.wins + self.losses + self.ties
|
| 18 |
|
| 19 |
-
@property
|
| 20 |
-
def winrate(self) -> float:
|
| 21 |
-
return (self.wins + self.ties * 0.5) / max(self.votes, 1)
|
| 22 |
|
| 23 |
def _list_remote_log_files(repo_id: str, token: str | None) -> list[str]:
|
| 24 |
api = HfApi(token=token)
|
|
@@ -29,36 +16,42 @@ def _list_remote_log_files(repo_id: str, token: str | None) -> list[str]:
|
|
| 29 |
if f.startswith(f"{VOTES_LOG_SUBDIR}/") and f.endswith(".parquet")
|
| 30 |
)
|
| 31 |
|
| 32 |
-
def load_vote_logs(*, repo_id: str, token: str | None) -> pd.DataFrame:
|
| 33 |
-
"""All logged votes as one DataFrame with columns md5a, md5b, winner_md5."""
|
| 34 |
-
frames = [
|
| 35 |
-
pd.read_parquet(
|
| 36 |
-
hf_hub_download(
|
| 37 |
-
repo_id=repo_id,
|
| 38 |
-
filename=path_in_repo,
|
| 39 |
-
repo_type="dataset",
|
| 40 |
-
token=token,
|
| 41 |
-
),
|
| 42 |
-
columns=["md5a", "md5b", "winner_md5"],
|
| 43 |
-
)
|
| 44 |
-
for path_in_repo in _list_remote_log_files(repo_id, token)
|
| 45 |
-
]
|
| 46 |
-
if not frames:
|
| 47 |
-
return pd.DataFrame(columns=["md5a", "md5b", "winner_md5"])
|
| 48 |
-
return pd.concat(frames, ignore_index=True)
|
| 49 |
|
| 50 |
-
def
|
| 51 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
|
| 64 |
-
|
|
|
|
|
|
| 1 |
+
from collections import Counter
|
|
|
|
| 2 |
|
| 3 |
import pandas as pd
|
| 4 |
from huggingface_hub import HfApi, hf_hub_download
|
| 5 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
|
| 7 |
+
VOTES_LOG_SUBDIR = "ratings_log"
|
|
|
|
|
|
|
| 8 |
|
|
|
|
|
|
|
|
|
|
| 9 |
|
| 10 |
def _list_remote_log_files(repo_id: str, token: str | None) -> list[str]:
|
| 11 |
api = HfApi(token=token)
|
|
|
|
| 16 |
if f.startswith(f"{VOTES_LOG_SUBDIR}/") and f.endswith(".parquet")
|
| 17 |
)
|
| 18 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
|
| 20 |
+
def _accumulate_stats_from_df(df: pd.DataFrame, wins_counter: Counter, losses_counter: Counter):
|
| 21 |
+
if df.empty:
|
| 22 |
+
return
|
| 23 |
+
valid = df[df["winner_md5"].notna()].copy()
|
| 24 |
+
if valid.empty:
|
| 25 |
+
return
|
| 26 |
+
valid["md5a"] = valid["md5a"].astype(str)
|
| 27 |
+
valid["md5b"] = valid["md5b"].astype(str)
|
| 28 |
+
valid["winner_md5"] = valid["winner_md5"].astype(str)
|
| 29 |
+
|
| 30 |
+
a_won_mask = valid["winner_md5"] == valid["md5a"]
|
| 31 |
+
b_won_mask = valid["winner_md5"] == valid["md5b"]
|
| 32 |
|
| 33 |
+
winner_keys = pd.concat([valid.loc[a_won_mask, "md5a"], valid.loc[b_won_mask, "md5b"]], ignore_index=True)
|
| 34 |
+
loser_keys = pd.concat([valid.loc[a_won_mask, "md5b"], valid.loc[b_won_mask, "md5a"]], ignore_index=True)
|
| 35 |
+
|
| 36 |
+
for key, count in winner_keys.value_counts().items():
|
| 37 |
+
wins_counter[str(key)] += int(count)
|
| 38 |
+
for key, count in loser_keys.value_counts().items():
|
| 39 |
+
losses_counter[str(key)] += int(count)
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def load_stats_by_md5(*, repo_id: str, token: str | None) -> dict[str, tuple[int, int]]:
|
| 43 |
+
wins_counter: Counter[str] = Counter()
|
| 44 |
+
losses_counter: Counter[str] = Counter()
|
| 45 |
+
|
| 46 |
+
for path_in_repo in _list_remote_log_files(repo_id, token):
|
| 47 |
+
local_path = hf_hub_download(
|
| 48 |
+
repo_id=repo_id,
|
| 49 |
+
filename=path_in_repo,
|
| 50 |
+
repo_type="dataset",
|
| 51 |
+
token=token,
|
| 52 |
+
)
|
| 53 |
+
df = pd.read_parquet(local_path, columns=["md5a", "md5b", "winner_md5"])
|
| 54 |
+
_accumulate_stats_from_df(df, wins_counter, losses_counter)
|
| 55 |
|
| 56 |
+
all_keys = set(wins_counter) | set(losses_counter)
|
| 57 |
+
return {k: (int(wins_counter.get(k, 0)), int(losses_counter.get(k, 0))) for k in all_keys}
|
storage.py
CHANGED
|
@@ -23,126 +23,86 @@ VOTE_COLUMNS = [
|
|
| 23 |
"dataset",
|
| 24 |
"group",
|
| 25 |
"session_id",
|
| 26 |
-
"pair_reason", # sampler channel; lets dataset builds treat model-selected pairs specially
|
| 27 |
-
"rater_name", # optional self-reported handle; empty when the rater stays anonymous
|
| 28 |
]
|
| 29 |
|
| 30 |
|
| 31 |
class VoteStorage:
|
| 32 |
-
def __init__(self, mode: str, token: str | None = None)
|
| 33 |
assert mode in ("hf", "void"), f"Unsupported storage mode: {mode}"
|
| 34 |
self.mode = mode
|
|
|
|
| 35 |
is_debug_mode = self.mode == "void"
|
| 36 |
-
|
| 37 |
self._flush_every = 3 if is_debug_mode else 50
|
| 38 |
self._flush_interval_sec = 15.0 if is_debug_mode else 300.0
|
| 39 |
-
self.
|
| 40 |
-
|
| 41 |
-
self._flush_condition = threading.Condition(threading.Lock())
|
| 42 |
-
self._sync_event = threading.Event()
|
| 43 |
-
self._sync_lock = threading.Lock()
|
| 44 |
self._votes_buffer: list[dict] = []
|
| 45 |
-
self.
|
| 46 |
-
|
| 47 |
self._flush_thread = threading.Thread(target=self._flush_loop, daemon=True)
|
| 48 |
self._flush_thread.start()
|
| 49 |
-
|
| 50 |
atexit.register(self.close)
|
| 51 |
|
| 52 |
-
def
|
| 53 |
-
|
| 54 |
-
if self.mode == "void":
|
| 55 |
-
return
|
| 56 |
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
for col in VOTE_COLUMNS:
|
| 60 |
-
if col not in df.columns:
|
| 61 |
-
df[col] = None
|
| 62 |
-
|
| 63 |
-
df = df[VOTE_COLUMNS]
|
| 64 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
ts = int(time.time())
|
| 66 |
shard = f"votes_{ts}_{uuid.uuid4().hex}.parquet"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
repo_id=VOTES_REPO_ID,
|
| 72 |
-
repo_type=VOTES_REPO_TYPE,
|
| 73 |
-
commit_message=f"upload {len(df)} vote rows",
|
| 74 |
-
)
|
| 75 |
-
|
| 76 |
-
def _flush_loop(self) -> None:
|
| 77 |
-
while True:
|
| 78 |
-
with self._flush_condition:
|
| 79 |
-
while True:
|
| 80 |
-
# Forced sync.
|
| 81 |
-
if not self._sync_event.is_set():
|
| 82 |
-
if self._votes_buffer:
|
| 83 |
-
break
|
| 84 |
-
|
| 85 |
-
self._sync_event.set()
|
| 86 |
-
|
| 87 |
-
# Shutdown wanted.
|
| 88 |
-
if self._shutdown:
|
| 89 |
-
if self._votes_buffer:
|
| 90 |
-
break
|
| 91 |
-
|
| 92 |
-
return
|
| 93 |
-
|
| 94 |
-
# Have enough votes to flush now.
|
| 95 |
-
if len(self._votes_buffer) >= self._flush_every:
|
| 96 |
-
break
|
| 97 |
-
|
| 98 |
-
# Wait for a notify to flush early or shutdown.
|
| 99 |
-
if not self._flush_condition.wait(self._flush_interval_sec):
|
| 100 |
-
# Interval elapsed. Flush if there is at least one vote.
|
| 101 |
-
if self._votes_buffer:
|
| 102 |
-
break
|
| 103 |
-
|
| 104 |
-
# Atomically take the batch of votes.
|
| 105 |
-
batch = self._votes_buffer
|
| 106 |
-
self._votes_buffer = []
|
| 107 |
-
|
| 108 |
-
self._upload_votes_batch(batch)
|
| 109 |
-
|
| 110 |
-
def sync(self) -> None:
|
| 111 |
-
with self._sync_lock:
|
| 112 |
-
with self._flush_condition:
|
| 113 |
-
is_shutdown = self._shutdown
|
| 114 |
-
if not is_shutdown:
|
| 115 |
-
self._sync_event.clear()
|
| 116 |
-
self._flush_condition.notify()
|
| 117 |
-
|
| 118 |
-
if not is_shutdown:
|
| 119 |
-
self._sync_event.wait()
|
| 120 |
-
|
| 121 |
-
if is_shutdown:
|
| 122 |
-
self._flush_thread.join()
|
| 123 |
-
|
| 124 |
-
def close(self) -> None:
|
| 125 |
-
with self._flush_condition:
|
| 126 |
-
self._shutdown = True
|
| 127 |
-
self._flush_condition.notify()
|
| 128 |
|
| 129 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 130 |
|
| 131 |
-
def
|
| 132 |
id_a = int(state["id_a"])
|
| 133 |
id_b = int(state["id_b"])
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
case "B":
|
| 140 |
-
winner_md5 = state["key_b"]
|
| 141 |
-
case None:
|
| 142 |
-
winner_md5 = None
|
| 143 |
-
case _:
|
| 144 |
-
raise AssertionError
|
| 145 |
-
|
| 146 |
vote_row = {
|
| 147 |
"vote_id": uuid.uuid4().hex,
|
| 148 |
"timestamp": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
|
@@ -154,12 +114,7 @@ class VoteStorage:
|
|
| 154 |
"dataset": state["dataset"],
|
| 155 |
"group": state["group"],
|
| 156 |
"session_id": state["session_id"],
|
| 157 |
-
"pair_reason": state.get("pair_reason", ""),
|
| 158 |
-
"rater_name": state.get("rater_name", ""),
|
| 159 |
}
|
| 160 |
-
|
| 161 |
-
with self._flush_condition:
|
| 162 |
self._votes_buffer.append(vote_row)
|
| 163 |
-
|
| 164 |
-
if len(self._votes_buffer) == self._flush_every:
|
| 165 |
-
self._flush_condition.notify()
|
|
|
|
| 23 |
"dataset",
|
| 24 |
"group",
|
| 25 |
"session_id",
|
|
|
|
|
|
|
| 26 |
]
|
| 27 |
|
| 28 |
|
| 29 |
class VoteStorage:
|
| 30 |
+
def __init__(self, mode: str, token: str | None = None):
|
| 31 |
assert mode in ("hf", "void"), f"Unsupported storage mode: {mode}"
|
| 32 |
self.mode = mode
|
| 33 |
+
self._token = token
|
| 34 |
is_debug_mode = self.mode == "void"
|
|
|
|
| 35 |
self._flush_every = 3 if is_debug_mode else 50
|
| 36 |
self._flush_interval_sec = 15.0 if is_debug_mode else 300.0
|
| 37 |
+
self._votes_lock = threading.Lock()
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
self._votes_buffer: list[dict] = []
|
| 39 |
+
self._stop_event = threading.Event()
|
|
|
|
| 40 |
self._flush_thread = threading.Thread(target=self._flush_loop, daemon=True)
|
| 41 |
self._flush_thread.start()
|
|
|
|
| 42 |
atexit.register(self.close)
|
| 43 |
|
| 44 |
+
def _hf_token(self) -> str | None:
|
| 45 |
+
return self._token
|
|
|
|
|
|
|
| 46 |
|
| 47 |
+
def _empty_votes_df(self) -> pd.DataFrame:
|
| 48 |
+
return pd.DataFrame(columns=VOTE_COLUMNS)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
|
| 50 |
+
def _upload_votes_batch(self, df: pd.DataFrame, commit_message: str):
|
| 51 |
+
assert set(VOTE_COLUMNS).issubset(df.columns), "Missing vote columns in upload batch"
|
| 52 |
+
if self.mode == "void":
|
| 53 |
+
_ = commit_message
|
| 54 |
+
return
|
| 55 |
ts = int(time.time())
|
| 56 |
shard = f"votes_{ts}_{uuid.uuid4().hex}.parquet"
|
| 57 |
+
api = HfApi(token=self._hf_token())
|
| 58 |
+
with NamedTemporaryFile(suffix=".parquet", delete=False) as tmp:
|
| 59 |
+
tmp_path = tmp.name
|
| 60 |
+
try:
|
| 61 |
+
df[VOTE_COLUMNS].to_parquet(tmp_path, index=False)
|
| 62 |
+
api.upload_file(
|
| 63 |
+
path_or_fileobj=tmp_path,
|
| 64 |
+
path_in_repo=f"{VOTES_LOG_SUBDIR}/{shard}",
|
| 65 |
+
repo_id=VOTES_REPO_ID,
|
| 66 |
+
repo_type=VOTES_REPO_TYPE,
|
| 67 |
+
commit_message=commit_message,
|
| 68 |
+
)
|
| 69 |
+
finally:
|
| 70 |
+
if os.path.exists(tmp_path):
|
| 71 |
+
os.remove(tmp_path)
|
| 72 |
+
|
| 73 |
+
def _flush_votes(self, force: bool = False):
|
| 74 |
+
with self._votes_lock:
|
| 75 |
+
if not self._votes_buffer:
|
| 76 |
+
return
|
| 77 |
+
if not force and len(self._votes_buffer) < self._flush_every:
|
| 78 |
+
return
|
| 79 |
+
batch = list(self._votes_buffer)
|
| 80 |
+
self._votes_buffer.clear()
|
| 81 |
+
incoming = pd.DataFrame(batch)
|
| 82 |
+
for col in VOTE_COLUMNS:
|
| 83 |
+
if col not in incoming.columns:
|
| 84 |
+
incoming[col] = None
|
| 85 |
+
self._upload_votes_batch(incoming[VOTE_COLUMNS], commit_message=f"append {len(batch)} vote rows")
|
| 86 |
|
| 87 |
+
def _flush_loop(self):
|
| 88 |
+
while not self._stop_event.wait(self._flush_interval_sec):
|
| 89 |
+
self._flush_votes(force=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
|
| 91 |
+
def close(self):
|
| 92 |
+
if self._stop_event.is_set():
|
| 93 |
+
return
|
| 94 |
+
self._stop_event.set()
|
| 95 |
+
self._flush_thread.join(timeout=1.0)
|
| 96 |
+
self._flush_votes(force=True)
|
| 97 |
|
| 98 |
+
def append_vote_row(self, state: dict, winner: str | None):
|
| 99 |
id_a = int(state["id_a"])
|
| 100 |
id_b = int(state["id_b"])
|
| 101 |
+
winner_md5 = None
|
| 102 |
+
if winner == "A":
|
| 103 |
+
winner_md5 = state["key_a"]
|
| 104 |
+
elif winner == "B":
|
| 105 |
+
winner_md5 = state["key_b"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
vote_row = {
|
| 107 |
"vote_id": uuid.uuid4().hex,
|
| 108 |
"timestamp": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
|
|
|
| 114 |
"dataset": state["dataset"],
|
| 115 |
"group": state["group"],
|
| 116 |
"session_id": state["session_id"],
|
|
|
|
|
|
|
| 117 |
}
|
| 118 |
+
with self._votes_lock:
|
|
|
|
| 119 |
self._votes_buffer.append(vote_row)
|
| 120 |
+
self._flush_votes()
|
|
|
|
|
|