chore: audit RF4 media quality
This commit is contained in:
@@ -48,9 +48,11 @@ RF4DB/RF4-STAT/RF4MAP/RF4 Posts сначала принимаются в изо
|
||||
|
||||
`python -m rf4_research.media_cli --coverage` сравнивает manifest с датированным `data/media/catalog-baseline.json`: отдельно считает уникальные нормализованные подписи и кандидатов без подписи, поэтому альтернативные URL не завышают покрытие. На 14.09.2026 manifest содержит 704 записи: 456 approved, 227 duplicate, 10 queued и 11 invalid. Опубликованы 243 уникально подписанные рыбы из 252, все 149 найденных изображений снастей/приманок и 64 справочных материала; 10 оставшихся RF4DB URL будут загружены после cooldown. Карты 19 водоёмов ещё предстоит проиндексировать, а общий target снастей остаётся `null` до проверяемого полного счётчика.
|
||||
|
||||
`python -m rf4_research.media_cli --quality-report` выполняет offline-проверку разрешения опубликованных рыб. Текущий отчёт выявляет 227 PNG RF4MAP размером 48×48, которые увеличиваются в карточках до 180 px, и 16 WebP 1024×1024; для всех низких версий уже известны альтернативные RF4DB URL. План загрузки, сравнения, безопасного переключения и генерации производных размеров ведётся в B20–B25 ROADMAP.
|
||||
|
||||
`python -m rf4_research.media_cli --queue-plan` без сетевых запросов объединяет manifest с общим cooldown-state: показывает queued-состав каждого домена, оставшееся время и наиболее полезный следующий asset с приоритетом водоёмов и рыб. Разрешённое media-окно загружается командой `--download-batch --batch-limit 40`: до 40 assets на домен под одной резервацией, затем 30 минут до нового batch. Блокировка/rate limit/сетевая ошибка останавливает домен сразу, три последовательных невалидных ответа — досрочно. Все файлы остаются в карантине до ручного review. Точный поимённый список отсутствующих сущностей появится только после получения канонического перечня; разница между двумя несогласованными каталогами не выдаётся за доказанный gap.
|
||||
|
||||
Актуальный внешний ориентир — 19 водоёмов и 252 вида рыб; локальная альфа пока содержит 2+2 сущности. Media-manifest включает 704 записи из RF4MAP, RF4DB и официального руководства: 24 одобрены, 417 сохранены для review, 227 являются альтернативными дубликатами, 25 новых рыб ожидают загрузки и 11 URL невалидны. В Git находятся 441 оригинал; подтверждённых entity-карт водоёмов пока нет. Полное число «снастей» не заявляется: приманки — лишь одна часть каталога наряду с удилищами, катушками, лесками, крючками и оснастками.
|
||||
Актуальный внешний ориентир — 19 водоёмов и 252 вида рыб; локальная альфа пока содержит 2+2 справочные сущности. Media-manifest включает 704 записи из RF4MAP, RF4DB и официального руководства: 456 опубликованы, 227 являются альтернативными дубликатами, 10 ожидают загрузки и 11 URL невалидны. Все 456 оригиналов находятся в Git; подтверждённых entity-карт водоёмов пока нет. Полное число «снастей» не заявляется: приманки — лишь одна часть каталога наряду с удилищами, катушками, лесками, крючками и оснастками.
|
||||
|
||||
Исследовательские RF4-ассеты в `data/media/files/` версионируются обычным Git вместе с `data/media/manifest.json`, чтобы клон репозитория был самодостаточным и не зависел от локального кэша. Это не относится к пользовательским скриншотам: они по-прежнему хранятся в MinIO/S3 и не попадают в Git.
|
||||
|
||||
|
||||
@@ -39,6 +39,12 @@
|
||||
- [ ] **B17 · Полный каталог рыб RF4DB — финальная загрузка.** Разрешённый RF4DB дал все 252 подписанных кандидата; 227 совпадений с RF4MAP сохранены как provenance-only `duplicate`, 15 уникальных файлов скачаны, а 10 URL (9 уникальных названий) остаются в очереди после корректно остановленного TLS-сбоя. Все поддомены RF4DB объединены одним 30-минутным cooldown. Завершить следующим разрешённым batch-окном и отдельно утвердить новые файлы.
|
||||
- [ ] **B18 · Каталог водоёмов RF4DB.** После cooldown проиндексировать 19 русских карточек водоёмов, проверить полноразмерные карты и поставить только уникальные изображения в media queue.
|
||||
- [ ] **B19 · Каталог снастей RF4DB.** После водоёмов проиндексировать категории gear, построить crosswalk по типу, бренду, семейству и названию; не считать каталог полным до получения проверяемого общего счётчика.
|
||||
- [x] **B20 · Аудит качества рыбных изображений.** Offline `media_cli --quality-report` проверяет фактические dimensions опубликованных файлов, отдельно считает неизбежный upscale в карточках и известные альтернативы. На 14.09: 227 из 243 опубликованных рыб имеют только 48×48 PNG RF4MAP и растягиваются до 180 px; 16 имеют 1024×1024 WebP. Для всех 227 низких файлов уже известны альтернативные URL RF4DB. Совпадение сущности больше нельзя считать достаточным основанием пропустить потенциально более качественный файл.
|
||||
- [ ] **B21 · Очередь quality-upgrade.** Не менять текущие approved-файлы до готовности замены. Перевести 227 RF4DB `duplicate` в отдельное состояние `upgrade_queued`, сохранив `duplicate_of`, и загружать партиями до 40 в разрешённые 30-минутные окна. Ошибка останавливает домен по существующим правилам; публикация старой версии при этом не прерывается.
|
||||
- [ ] **B22 · Сравнение вариантов разных источников.** После загрузки для каждой рыбы сравнить реальные dimensions, размер файла, MIME, прозрачность, aspect ratio и визуальное соответствие подписи. Минимальный технический порог для основной карточки — 256 px по меньшей стороне, предпочтительный — 512 px; маленький файл сохранять только как fallback. Источник RF4DB/RF4MAP/официальный RF4 не получает автоматического приоритета: выбирается лучший прошедший проверку файл.
|
||||
- [ ] **B23 · Безопасное продвижение и provenance.** Добавить связь `supersedes`/`replaced_by`, отдельное решение review и атомарное переключение публичного `entity_key`; сохранить обе исходные ссылки и возможность отката. На сайте показывать источник именно выбранного изображения, а в раскрываемом provenance — все проверенные варианты. Не удалять старый Git-файл в том же коммите, где включается новый.
|
||||
- [ ] **B24 · Производные размеры.** После выбора оригиналов генерировать детерминированные WebP/AVIF thumbnails для каталога и отдельный крупный вариант для detail, фиксировать хэши производных в manifest и отдавать `srcset`. Это уберёт загрузку 1024×1024 на каждой маленькой карточке и исключит browser-upscale 48×48.
|
||||
- [ ] **B25 · Визуальная приёмка media.** Собирать контактный лист «старое / кандидат / выбранное» с названием и источником; вручную проверить минимум все замены и репрезентативные desktop/mobile страницы в light/dark. Gate: нет битых файлов, искажённых пропорций, ложных соответствий, обрезанного объекта и изображений ниже 256 px без явной пометки «низкое разрешение».
|
||||
|
||||
### Тёмная тема
|
||||
|
||||
|
||||
@@ -251,6 +251,45 @@ def media_coverage(root: Path) -> dict:
|
||||
return {"baseline_date": baseline["verified_at"], "entities": result}
|
||||
|
||||
|
||||
def media_quality_report(root: Path, *, minimum_dimension: int = 256, display_dimension: int = 180) -> dict:
|
||||
"""Report published raster quality and known higher-quality source alternatives offline."""
|
||||
manifest = json.loads((root / "manifest.json").read_text(encoding="utf-8"))
|
||||
assets = manifest.get("assets", [])
|
||||
approved = [item for item in assets if item.get("status") == "approved" and item.get("entity_type") == "fish"]
|
||||
low = [item for item in approved if min(int(item.get("width") or 0), int(item.get("height") or 0)) < minimum_dimension]
|
||||
upsampled = [item for item in approved if min(int(item.get("width") or 0), int(item.get("height") or 0)) < display_dimension]
|
||||
low_urls = {item.get("asset_url") for item in low}
|
||||
alternatives = [
|
||||
item for item in assets
|
||||
if item.get("entity_type") == "fish"
|
||||
and item.get("status") in {"duplicate", "queued"}
|
||||
and (item.get("duplicate_of") in low_urls or item.get("status") == "queued")
|
||||
]
|
||||
|
||||
by_source: dict[str, dict[str, int]] = {}
|
||||
for item in approved:
|
||||
host = urlsplit(str(item.get("asset_url") or "")).hostname or "unknown"
|
||||
summary = by_source.setdefault(host, {"published": 0, "below_minimum": 0, "upsampled_in_cards": 0})
|
||||
summary["published"] += 1
|
||||
summary["below_minimum"] += item in low
|
||||
summary["upsampled_in_cards"] += item in upsampled
|
||||
|
||||
return {
|
||||
"entity_type": "fish",
|
||||
"minimum_dimension": minimum_dimension,
|
||||
"display_dimension": display_dimension,
|
||||
"published": len(approved),
|
||||
"below_minimum": len(low),
|
||||
"upsampled_in_cards": len(upsampled),
|
||||
"known_alternative_urls": len(alternatives),
|
||||
"by_source": by_source,
|
||||
"examples": [
|
||||
{"label": item.get("label"), "width": item.get("width"), "height": item.get("height"), "asset_url": item.get("asset_url")}
|
||||
for item in sorted(low, key=lambda row: str(row.get("label") or ""))[:20]
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def inspect_image(body: bytes) -> tuple[int, int, str]:
|
||||
try:
|
||||
with Image.open(io.BytesIO(body)) as image:
|
||||
|
||||
@@ -8,7 +8,7 @@ import urllib.error
|
||||
import urllib.request
|
||||
|
||||
from .community_cli import MIN_FETCH_INTERVAL_SECONDS, USER_AGENT, _StrictRedirectHandler, _read_state, _validate_url_before_io, check_and_reserve, fetch_html, fetch_site_key
|
||||
from .media_assets import approve_stored_assets, audit_media_catalog, extract_media_candidates, media_coverage, merge_manifest, reconcile_queued_duplicates, reclassify_manifest, review_asset, store_asset
|
||||
from .media_assets import approve_stored_assets, audit_media_catalog, extract_media_candidates, media_coverage, media_quality_report, merge_manifest, reconcile_queued_duplicates, reclassify_manifest, review_asset, store_asset
|
||||
|
||||
|
||||
DEFAULT_ROOT = Path("data/media")
|
||||
@@ -190,6 +190,7 @@ def main(argv: list[str] | None = None) -> int:
|
||||
parser.add_argument("--note")
|
||||
parser.add_argument("--audit", action="store_true", help="Verify manifest metadata, hashes and local files without network access")
|
||||
parser.add_argument("--coverage", action="store_true", help="Compare candidates and approvals with the catalog baseline")
|
||||
parser.add_argument("--quality-report", action="store_true", help="Report low-resolution published fish and known alternatives without network access")
|
||||
parser.add_argument("--queue-plan", action="store_true", help="Show the next useful queued asset per domain without network access")
|
||||
parser.add_argument("--root", type=Path, default=DEFAULT_ROOT)
|
||||
parser.add_argument("--state-file", type=Path, default=Path(".cache/community-fetch-state.json"))
|
||||
@@ -202,6 +203,9 @@ def main(argv: list[str] | None = None) -> int:
|
||||
if args.coverage:
|
||||
print(json.dumps(media_coverage(args.root), ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
if args.quality_report:
|
||||
print(json.dumps(media_quality_report(args.root), ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
if args.queue_plan:
|
||||
print(json.dumps(media_queue_plan(args.root, args.state_file), ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
|
||||
@@ -6,7 +6,7 @@ import io
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
from rf4_research.media_assets import approve_stored_assets, audit_media_catalog, extract_media_candidates, inspect_image, media_coverage, merge_manifest, reconcile_queued_duplicates, review_asset, store_asset
|
||||
from rf4_research.media_assets import approve_stored_assets, audit_media_catalog, extract_media_candidates, inspect_image, media_coverage, media_quality_report, merge_manifest, reconcile_queued_duplicates, review_asset, store_asset
|
||||
|
||||
|
||||
def test_extracts_and_classifies_unique_https_media() -> None:
|
||||
@@ -179,3 +179,17 @@ def test_bulk_approval_only_publishes_stored_assets(tmp_path: Path) -> None:
|
||||
assert assets[0]["entity_key"] == "fish:pike"
|
||||
assert assets[0]["review_note"] == "owner approved"
|
||||
assert assets[1]["status"] == "queued"
|
||||
|
||||
|
||||
def test_quality_report_finds_low_resolution_asset_and_known_alternative(tmp_path: Path) -> None:
|
||||
(tmp_path / "manifest.json").write_text(json.dumps({"assets": [
|
||||
{"entity_type": "fish", "label": "Щука", "status": "approved", "asset_url": "https://small.example/pike.png", "width": 48, "height": 48},
|
||||
{"entity_type": "fish", "label": "Щука", "status": "duplicate", "asset_url": "https://large.example/pike.webp", "duplicate_of": "https://small.example/pike.png"},
|
||||
{"entity_type": "fish", "label": "Окунь", "status": "approved", "asset_url": "https://large.example/perch.webp", "width": 1024, "height": 1024},
|
||||
]}), encoding="utf-8")
|
||||
|
||||
report = media_quality_report(tmp_path)
|
||||
|
||||
assert (report["published"], report["below_minimum"], report["upsampled_in_cards"]) == (2, 1, 1)
|
||||
assert report["known_alternative_urls"] == 1
|
||||
assert report["by_source"]["small.example"]["below_minimum"] == 1
|
||||
|
||||
Reference in New Issue
Block a user