feat: derivadas de acesso com tamanhos pela NARA e CONARQ (medio 1.200, grande 2.000)

- 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
This commit is contained in:
2026-10-01 09:48:55 +02:00
parent ff7dc3cf3f
commit fd37348811
7 changed files with 195 additions and 10 deletions
+16 -1
View File
@@ -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
+1 -1
View File
@@ -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)
+32 -4
View File
@@ -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
+133
View File
@@ -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/<handle>.webp 1.200 px no lado maior NARA 2004 "Access – Recommended"
derivados/grande/<handle>.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())
+6
View File
@@ -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); '
+4 -2
View File
@@ -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)
+3 -2
View File
@@ -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();