From fd37348811db8eae20131f4d84ffcd8adbb147e6 Mon Sep 17 00:00:00 2001 From: rafapolo Date: Thu, 1 Oct 2026 09:48:55 +0200 Subject: [PATCH] feat: derivadas de acesso com tamanhos pela NARA e CONARQ (medio 1.200, grande 2.000) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - pipeline/deriva-acesso.py gera medio/ e grande/ da matriz no finland, em WebP sRGB, sem ampliar, com retomada - MANIFEST e CONARQ.md documentam matriz, derivada de navegação e de acesso (Res. CONARQ 31/2010) e os tamanhos da NARA 2004 - INDEX.md revisto; teste-upload.txt e falhas-500 antigos saem do bucket --- CONARQ.md | 17 ++++- INDEX.md | 2 +- MANIFEST.md | 36 ++++++++-- pipeline/deriva-acesso.py | 133 +++++++++++++++++++++++++++++++++++++ pipeline/nobrade/config.py | 6 ++ pipeline/nobrade/mural.py | 6 +- web/wall.html | 5 +- 7 files changed, 195 insertions(+), 10 deletions(-) create mode 100644 pipeline/deriva-acesso.py diff --git a/CONARQ.md b/CONARQ.md index df42eb3..c0bd53a 100644 --- a/CONARQ.md +++ b/CONARQ.md @@ -254,7 +254,22 @@ Versionados no git: textos, revisão, autoridades e pendências. `unidades.jsonl - **Valores corrompidos na origem** (fragmentos de XML ou hash no lugar do texto): desconsiderados e anotados em 6.2. -### 5.6 Pendências fora do código +### 5.6 Matriz e derivadas (Resolução CONARQ 31/2010) + +A NOBRADE não trata de imagem; quem trata é a Resolução 31/2010 +(*Recomendações para digitalização de documentos arquivísticos +permanentes*). Ela separa **matriz digital** (alta resolução, TIFF ≥ 300 dpi +em escala 1:1, acesso restrito) de **derivadas**: de acesso (JPEG/PNG, +formato aberto, "em variados tamanhos de acordo com a banda") e de +navegação (thumbnail). Não fixa pixels para as derivadas. + +Aqui a matriz é a imagem de preservação de cada instituição (`preservacao/`). +Os tamanhos das derivadas seguem a NARA 2004, citada pela própria +recomendação: `thumb256/` 256 px (navegação), `medio/` 1.200 px (acesso +recomendado) e `grande/` 2.000 px (acesso ampliado), em WebP sRGB, sem +ampliar. Tabela completa em `MANIFEST.md` → "Tamanhos das derivadas". + +### 5.7 Pendências fora do código - [ ] Pedir ao CONARQ o código da entidade custodiadora (proc. 1.1.1). O rascunho para o formulário CODEARQ está em diff --git a/INDEX.md b/INDEX.md index 0c52e7d..aab64d0 100644 --- a/INDEX.md +++ b/INDEX.md @@ -1,6 +1,6 @@ # INDEX — Brasiliana Fotográfica → S3 -> Índice do que foi coletado. Ver `MANIFEST.md` pra estrutura/checksums e `tasks/PLANO.md` pro estado ao vivo dos downloads (as imagens continuam sendo baixadas depois deste índice ser gerado — os metadados abaixo já são definitivos). +> Índice do que foi coletado (coleta concluída em 2026-09-13; índice revisto em 2026-10-01). Ver `MANIFEST.md` para a estrutura do bucket, checksums, derivadas e a descrição NOBRADE em `descricao/`. ## Acervo DSpace (brasilianafotografica.bn.gov.br/brasiliana) diff --git a/MANIFEST.md b/MANIFEST.md index a42b9df..93abb14 100644 --- a/MANIFEST.md +++ b/MANIFEST.md @@ -78,8 +78,11 @@ s3://brasiliana/ {handle-com-underscore}.webp miniatura DERIVADA por nós (não é material de origem — ver abaixo) derivados/medio/ - {handle-com-underscore}.webp imagem média (~1000 px) DERIVADA, usada - no cartão do mural + {handle-com-underscore}.webp derivada de acesso, 1.200 px, DERIVADA, + aberta no visor do mural + derivados/grande/ + {handle-com-underscore}.webp derivada de acesso ampliada, 2.000 px, + carregada no zoom do visor ``` ### Sobre `derivados/` @@ -97,8 +100,33 @@ imagem de preservação quando ela existe e, nos 69 itens sem ela, do então o conjunto é uniforme. Cobre **13.059 dos 13.060 handles do catálogo (99,99%)**. O único ausente -é `7523`, cujos bitstreams a origem serve com HTTP 500. `derivados/medio/` -tem a mesma cobertura (13.059). +é `7523`, cujos bitstreams a origem serve com HTTP 500. `derivados/medio/` e +`derivados/grande/` têm a mesma cobertura (13.059). + +#### Tamanhos das derivadas (definidos em 2026-10-01) + +Nos termos do CONARQ (Resolução 31/2010, *Recomendações para digitalização +de documentos arquivísticos permanentes*), a imagem de preservação faz o +papel de **matriz digital**, e o que está em `derivados/` são **derivadas**: +a de navegação (thumbnail) e as de acesso, que o CONARQ pede em formato +aberto e "em variados tamanhos de acordo com a banda" — sem fixar pixels. +Os pixels seguem a NARA (*Technical Guidelines for Digitizing Archival +Materials for Electronic Access*, 2004, tabela de fotografias), que é a +referência citada pela própria recomendação do CONARQ: + +| Pasta | Lado maior | CONARQ | NARA 2004 (fotografias) | Uso no mural | +|---|--:|---|---|---| +| `thumb256/` | 256 px | derivada de navegação | thumbnail (≤ 200 px) | fonte do atlas 3D (blocos de 128 px) | +| `medio/` | 1.200 px | derivada de acesso | *Access – Recommended* (800–1.200 px) | imagem aberta no visor | +| `grande/` | 2.000 px | derivada de acesso | *Larger Alternative* (1.200–2.000 px) | carregada só no zoom | + +Regras comuns: WebP (formato aberto; o CONARQ recomenda JPEG/PNG mas admite +outros quando o acesso exige), espaço de cor **sRGB** (pedido pela NARA para +acesso), metadados embutidos removidos, e **nunca ampliar** — a matriz menor +que o alvo fica no próprio tamanho. Também chega ao nível 3–4 do Europeana +Publishing Framework (≥ 0,95 megapixel). Gerado por +`pipeline/deriva-acesso.py` a partir da matriz (ou, nos 68 itens sem ela, da +imagem servida pelo DSpace). Contagem de itens: o `catalogo.jsonl` final tem **13.060 handles distintos**, todos itens reais. O falso-positivo `20.500.12156.1/1` (página raiz da diff --git a/pipeline/deriva-acesso.py b/pipeline/deriva-acesso.py new file mode 100644 index 0000000..1398c4c --- /dev/null +++ b/pipeline/deriva-acesso.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python3 +"""Derivadas de acesso (CONARQ, Res. 31/2010) a partir da matriz de preservação. + +Gera, para cada item, duas cópias WebP em sRGB, sem metadados embutidos e sem +nunca ampliar (o que for menor que o alvo fica no tamanho original): + + derivados/medio/.webp 1.200 px no lado maior NARA 2004 "Access – Recommended" + derivados/grande/.webp 2.000 px no lado maior NARA 2004 "Larger Alternative" + +A miniatura (derivados/thumb256/, 256 px) já existe e não muda. Ver a tabela +de tamanhos em MANIFEST.md e CONARQ.md. + +Roda no finland (tráfego intra-Hetzner, o bucket é hel1): + scp pipeline/deriva-acesso.py derivar.jsonl finland:/root/brasiliana/ + ssh finland 'cd /root/brasiliana && set -a && . /root/.s3env && set +a && \ + nohup python3 deriva-acesso.py derivar.jsonl > deriva-acesso.log 2>&1 &' + +`derivar.jsonl`: uma linha {handle, fonte} por item, fonte = chave no bucket da +imagem de preservação (ou, sem ela, da imagem servida pelo DSpace). O progresso +vai para `deriva-acesso.resultados.jsonl`; rodar de novo retoma de onde parou. +Opções: --seco (gera em /tmp e não sobe), --limite N, --threads N. +""" + +import argparse +import hashlib +import json +import os +import shutil +import subprocess +import sys +import tempfile +import threading +import time +from concurrent.futures import ThreadPoolExecutor, as_completed + +import boto3 + +BUCKET = os.environ.get('ARCHIVE_BUCKET', 'brasiliana') +ENDPOINT = os.environ.get('S3_ENDPOINT', 'https://hel1.your-objectstorage.com') +ALVOS = [ # (pasta, lado maior em px, qualidade WebP) + ('medio', 1200, 80), + ('grande', 2000, 82), +] +CACHE = 'public, max-age=604800' + +trava = threading.Lock() + + +def processa(s3, item, seco, saida_seca): + h = item['handle'] + pasta = h.replace('/', '_') + tmp = tempfile.mkdtemp(prefix='deriva-') + try: + ext = os.path.splitext(item['fonte'])[1] or '.img' + src = os.path.join(tmp, 'src' + ext) + s3.download_file(BUCKET, item['fonte'], src) + feitos = [] + for nome, lado, q in ALVOS: + out = os.path.join(tmp, f'{nome}.webp') + # "WxH>" = só reduz; sRGB na saída (NARA pede sRGB nas derivadas de acesso) + r = subprocess.run(['vipsthumbnail', src, '--size', f'{lado}x{lado}>', + '--export-profile', 'srgb', '-o', f'{out}[Q={q},strip]'], + capture_output=True, text=True, timeout=600) + if r.returncode != 0 or not os.path.exists(out): + # perfil de cor ausente ou ilegível: tenta sem conversão de perfil + r = subprocess.run(['vipsthumbnail', src, '--size', f'{lado}x{lado}>', + '-o', f'{out}[Q={q},strip]'], capture_output=True, text=True, timeout=600) + if r.returncode != 0: + raise RuntimeError(f'vipsthumbnail {nome}: {r.stderr.strip()[:300]}') + dims = subprocess.run(['vipsheader', '-f', 'width', out], capture_output=True, text=True).stdout.strip(), \ + subprocess.run(['vipsheader', '-f', 'height', out], capture_output=True, text=True).stdout.strip() + dado = open(out, 'rb').read() + chave = f'derivados/{nome}/{pasta}.webp' + if seco: + shutil.copy(out, os.path.join(saida_seca, f'{pasta}.{nome}.webp')) + else: + s3.put_object(Bucket=BUCKET, Key=chave, Body=dado, ACL='public-read', + ContentType='image/webp', CacheControl=CACHE) + feitos.append({'tipo': f'derivado-{nome}', 'chave': chave, 'bytes': len(dado), + 'sha256': hashlib.sha256(dado).hexdigest(), 'w': int(dims[0]), 'h': int(dims[1])}) + return {'handle': h, 'fonte': item['fonte'], 'ok': True, 'arquivos': feitos, + 'quando': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())} + except Exception as e: # noqa: BLE001 — um item ruim não para a fila + return {'handle': h, 'fonte': item['fonte'], 'ok': False, 'erro': f'{type(e).__name__}: {e}'[:400]} + finally: + shutil.rmtree(tmp, ignore_errors=True) + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument('lista') + ap.add_argument('--seco', action='store_true') + ap.add_argument('--limite', type=int, default=0) + ap.add_argument('--threads', type=int, default=4) + ap.add_argument('--resultados', default='deriva-acesso.resultados.jsonl') + a = ap.parse_args() + + itens = [json.loads(l) for l in open(a.lista, encoding='utf-8') if l.strip()] + feitos = set() + if os.path.exists(a.resultados) and not a.seco: + for l in open(a.resultados, encoding='utf-8'): + r = json.loads(l) + if r.get('ok'): + feitos.add(r['handle']) + fila = [i for i in itens if i['handle'] not in feitos] + if a.limite: + fila = fila[:a.limite] + saida_seca = tempfile.mkdtemp(prefix='deriva-seco-') if a.seco else None + print(f'{len(itens)} itens, {len(feitos)} já feitos, {len(fila)} na fila' + + (f' (seco → {saida_seca})' if a.seco else ''), flush=True) + + s3 = boto3.client('s3', endpoint_url=ENDPOINT) + t0, n, erros = time.time(), 0, 0 + with ThreadPoolExecutor(a.threads) as ex, open(a.resultados if not a.seco else os.devnull, 'a', encoding='utf-8') as res: + for fut in as_completed([ex.submit(processa, s3, i, a.seco, saida_seca) for i in fila]): + r = fut.result() + n += 1 + erros += not r['ok'] + with trava: + res.write(json.dumps(r, ensure_ascii=False) + '\n') + res.flush() + if not r['ok']: + print(f"ERRO {r['handle']}: {r['erro']}", flush=True) + if n % 100 == 0 or n == len(fila): + dt = time.time() - t0 + print(f'{n}/{len(fila)} · {erros} erros · {n / dt * 60:.0f}/min · ' + f'faltam ~{(len(fila) - n) / max(n / dt, 1e-9) / 60:.0f} min', flush=True) + print('FIM', flush=True) + return 1 if erros else 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/pipeline/nobrade/config.py b/pipeline/nobrade/config.py index 3221737..872a3b3 100644 --- a/pipeline/nobrade/config.py +++ b/pipeline/nobrade/config.py @@ -24,6 +24,12 @@ ENTIDADE_SITE = 'https://xn--2dk.xyz/brasiliana/' COLECAO = 'BF' COLECAO_TITULO = 'Coleção Brasiliana Fotográfica (cópia digital)' RESPONSAVEL = 'rafapolo' + +# Derivadas de acesso no bucket (CONARQ Res. 31/2010; tamanhos pela NARA 2004): +# thumb256/ 256 px (navegação) · medio/ 1.200 px (acesso) · grande/ 2.000 px +# (acesso ampliado). GRANDE liga a troca para grande/ no zoom do visor — +# só True depois que pipeline/deriva-acesso.py cobriu o acervo inteiro. +GRANDE = False BUCKET = 's3://brasiliana' REGRAS = ('NOBRADE: Norma brasileira de descrição arquivística (CONARQ, 2006); ' diff --git a/pipeline/nobrade/mural.py b/pipeline/nobrade/mural.py index bd642ec..24ff269 100644 --- a/pipeline/nobrade/mural.py +++ b/pipeline/nobrade/mural.py @@ -11,12 +11,13 @@ Campos novos em meta (curtos: o mapa inteiro desce para o navegador): ai índice em `autoridades` (2.1, forma autorizada ISAAR(CPF)) dq 1 quando a data foi atribuída pelo arquivista (proc. 1.3.3.2) No topo do mapa: `series`, `autoridades`, `instituicoes` (community → nome -autorizado) e `colecao` (código e título do nível 1). +autorizado), `colecao` (código e título do nível 1) e `grande` (há derivada +de 2.000 px para o zoom). """ import json -from .config import COLECAO_TITULO, INSTITUICOES, RAIZ, SAIDA, codigo +from .config import COLECAO_TITULO, GRANDE, INSTITUICOES, RAIZ, SAIDA, codigo MAPA = RAIZ / 'web' / 'atlas' / 'atlas-map.json' @@ -48,6 +49,7 @@ def main(): d['autoridades'] = formas d['instituicoes'] = {k: v[1] for k, v in INSTITUICOES.items()} d['colecao'] = {'codigo': codigo('BF'), 'titulo': COLECAO_TITULO} + d['grande'] = GRANDE # o visor só pede derivados/grande/ quando isto é verdade feitos = 0 for h, e in d['map'].items(): u = dossies.get(h) diff --git a/web/wall.html b/web/wall.html index 4a44ff8..4c92379 100644 --- a/web/wall.html +++ b/web/wall.html @@ -1541,7 +1541,8 @@ function citation(data, meta, inst, aut) { // anchored under the cursor / between the fingers, and panning is clamped so // the image never leaves the stage. A sharper copy (derivados/grande) is // swapped in once the zoom passes what the medium copy can show, keeping the -// on-screen size exactly where it was. +// on-screen size exactly where it was. Sizes: medio 1.200 px, grande 2.000 px +// (CONARQ derivadas de acesso; NARA 2004 "Access – Recommended" / "Larger Alternative"). const wrapEl = document.getElementById('card-wrap'); const stage = document.getElementById('stage'); const stImg = document.getElementById('card-img'); @@ -1604,7 +1605,7 @@ function stageLoad(data) { Z.handle = h; Z.touched = false; Z.grande = false; Z.nw = 0; stImg.classList.remove('ready'); stImg.alt = (data.meta || {}).title || ''; - // the atlas tile first (instant, blurry), then the medium copy (~1000 px) + // the atlas tile first (instant, blurry), then the medium copy (1.200 px, NARA "access") const entry = atlasEntries[h], page = atlasSources[entry?.atlas || 0]; let got = false; const big = new Image();